altitude-design 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (295) hide show
  1. package/README.md +42 -7
  2. package/dist/adapters/codemirror.cjs +88 -0
  3. package/dist/adapters/codemirror.cjs.map +7 -0
  4. package/dist/adapters/codemirror.js +67 -0
  5. package/dist/adapters/codemirror.js.map +7 -0
  6. package/dist/adapters/react-flow.css +102 -0
  7. package/dist/adapters/tiptap.css +71 -0
  8. package/dist/altitude.css +3121 -244
  9. package/dist/altitude.iife.js +33 -5
  10. package/dist/altitude.no-fonts.css +3121 -244
  11. package/dist/chart-themes.cjs +195 -0
  12. package/dist/chart-themes.cjs.map +7 -0
  13. package/dist/chart-themes.js +165 -0
  14. package/dist/chart-themes.js.map +7 -0
  15. package/dist/cli/altitude.mjs +134 -0
  16. package/dist/guidance/README.md +32 -0
  17. package/dist/guidance/agent-setup.md +22 -0
  18. package/dist/guidance/app-skeleton.md +17 -0
  19. package/dist/guidance/evidence.json +95 -0
  20. package/dist/guidance/index.json +493 -0
  21. package/dist/guidance/llms.txt +20 -0
  22. package/dist/guidance/manifest.json +92 -0
  23. package/dist/guidance/pattern.schema.json +181 -0
  24. package/dist/guidance/patterns/agent-action.json +77 -0
  25. package/dist/guidance/patterns/agent-action.md +39 -0
  26. package/dist/guidance/patterns/assistant-conversation.json +77 -0
  27. package/dist/guidance/patterns/assistant-conversation.md +50 -0
  28. package/dist/guidance/patterns/async-collection.json +71 -0
  29. package/dist/guidance/patterns/async-collection.md +29 -0
  30. package/dist/guidance/patterns/audit-log.json +75 -0
  31. package/dist/guidance/patterns/audit-log.md +27 -0
  32. package/dist/guidance/patterns/auth.json +86 -0
  33. package/dist/guidance/patterns/auth.md +29 -0
  34. package/dist/guidance/patterns/billing.json +80 -0
  35. package/dist/guidance/patterns/billing.md +29 -0
  36. package/dist/guidance/patterns/consequential-action.json +76 -0
  37. package/dist/guidance/patterns/consequential-action.md +27 -0
  38. package/dist/guidance/patterns/developer-settings.json +79 -0
  39. package/dist/guidance/patterns/developer-settings.md +29 -0
  40. package/dist/guidance/patterns/edit-record.json +74 -0
  41. package/dist/guidance/patterns/edit-record.md +51 -0
  42. package/dist/guidance/patterns/import.json +77 -0
  43. package/dist/guidance/patterns/import.md +31 -0
  44. package/dist/guidance/patterns/members.json +77 -0
  45. package/dist/guidance/patterns/members.md +27 -0
  46. package/dist/guidance/patterns/onboarding.json +78 -0
  47. package/dist/guidance/patterns/onboarding.md +31 -0
  48. package/dist/guidance/patterns/review-submit.json +72 -0
  49. package/dist/guidance/patterns/review-submit.md +27 -0
  50. package/dist/guidance/patterns/screen-layout.json +79 -0
  51. package/dist/guidance/patterns/screen-layout.md +40 -0
  52. package/dist/guidance/patterns/settings.json +78 -0
  53. package/dist/guidance/patterns/settings.md +32 -0
  54. package/dist/guidance/patterns/sync.json +77 -0
  55. package/dist/guidance/patterns/sync.md +31 -0
  56. package/dist/guidance/principles.md +23 -0
  57. package/dist/guidance/references/build/checks/agent-action-pattern.mjs +57 -0
  58. package/dist/guidance/references/build/checks/agent-setup.mjs +78 -0
  59. package/dist/guidance/references/build/checks/assistant-conversation-pattern.mjs +49 -0
  60. package/dist/guidance/references/build/checks/audit-log-pattern.mjs +63 -0
  61. package/dist/guidance/references/build/checks/auth-pattern.mjs +67 -0
  62. package/dist/guidance/references/build/checks/billing-pattern.mjs +63 -0
  63. package/dist/guidance/references/build/checks/developer-settings-pattern.mjs +76 -0
  64. package/dist/guidance/references/build/checks/guidance.mjs +71 -0
  65. package/dist/guidance/references/build/checks/import-pattern.mjs +69 -0
  66. package/dist/guidance/references/build/checks/members-pattern.mjs +71 -0
  67. package/dist/guidance/references/build/checks/onboarding-pattern.mjs +54 -0
  68. package/dist/guidance/references/build/checks/pattern-types.mjs +13 -0
  69. package/dist/guidance/references/build/checks/pattern-workflows.mjs +226 -0
  70. package/dist/guidance/references/build/checks/primary-scope.mjs +49 -0
  71. package/dist/guidance/references/build/checks/render-tells.mjs +72 -0
  72. package/dist/guidance/references/build/checks/screen-layout-pattern.mjs +111 -0
  73. package/dist/guidance/references/build/checks/semantics.mjs +72 -0
  74. package/dist/guidance/references/build/checks/settings-pattern.mjs +73 -0
  75. package/dist/guidance/references/build/checks/sync-pattern.mjs +62 -0
  76. package/dist/guidance/references/build/checks/tells-lint.mjs +164 -0
  77. package/dist/guidance/references/build/fixtures/pattern-workflows.tsx +83 -0
  78. package/dist/guidance/references/build/fixtures/patterns/agent-action.jsx +60 -0
  79. package/dist/guidance/references/build/fixtures/patterns/app-skeleton.jsx +94 -0
  80. package/dist/guidance/references/build/fixtures/patterns/assistant-conversation.jsx +79 -0
  81. package/dist/guidance/references/build/fixtures/patterns/audit-log.jsx +81 -0
  82. package/dist/guidance/references/build/fixtures/patterns/auth.jsx +150 -0
  83. package/dist/guidance/references/build/fixtures/patterns/billing.jsx +96 -0
  84. package/dist/guidance/references/build/fixtures/patterns/developer-settings.jsx +95 -0
  85. package/dist/guidance/references/build/fixtures/patterns/import.jsx +102 -0
  86. package/dist/guidance/references/build/fixtures/patterns/members.jsx +106 -0
  87. package/dist/guidance/references/build/fixtures/patterns/onboarding.jsx +70 -0
  88. package/dist/guidance/references/build/fixtures/patterns/screen-layout-decision.jsx +74 -0
  89. package/dist/guidance/references/build/fixtures/patterns/screen-layout-editor.jsx +56 -0
  90. package/dist/guidance/references/build/fixtures/patterns/screen-layout-report.jsx +42 -0
  91. package/dist/guidance/references/build/fixtures/patterns/settings.jsx +73 -0
  92. package/dist/guidance/references/build/fixtures/patterns/sync.jsx +78 -0
  93. package/dist/guidance/references/project/components/src/pattern-async-collection.tsx +57 -0
  94. package/dist/guidance/references/project/components/src/pattern-consequential-action.tsx +77 -0
  95. package/dist/guidance/references/project/components/src/pattern-edit-record.tsx +127 -0
  96. package/dist/guidance/references/project/components/src/pattern-review-submit.tsx +47 -0
  97. package/dist/guidance/references/project/components/src/settings.tsx +79 -0
  98. package/dist/guidance/references/scripts/build-framework-fixture.mjs +20 -0
  99. package/dist/guidance/rules.json +653 -0
  100. package/dist/guidance/visual/Elevation.md +194 -0
  101. package/dist/guidance/visual/Fundamentals.md +83 -0
  102. package/dist/guidance/visual/Research.md +1620 -0
  103. package/dist/index.cjs +18795 -1925
  104. package/dist/index.cjs.map +4 -4
  105. package/dist/index.js +18795 -1925
  106. package/dist/index.js.map +4 -4
  107. package/dist/tokens.css +84 -84
  108. package/dist/tokens.json +301 -128
  109. package/dist/types/adapter-codemirror.d.ts +13 -0
  110. package/dist/types/agent-fleet.d.ts +166 -0
  111. package/dist/types/ai-label.d.ts +30 -0
  112. package/dist/types/app-shell.d.ts +83 -0
  113. package/dist/types/area-chart.d.ts +80 -0
  114. package/dist/types/artifact.d.ts +65 -0
  115. package/dist/types/bar-chart.d.ts +83 -0
  116. package/dist/types/bar-list.d.ts +35 -0
  117. package/dist/types/breadcrumb.d.ts +62 -0
  118. package/dist/types/chart-brush.d.ts +35 -0
  119. package/dist/types/chart-themes.d.ts +229 -0
  120. package/dist/types/chart.d.ts +570 -0
  121. package/dist/types/checklist.d.ts +71 -0
  122. package/dist/types/code-block.d.ts +32 -0
  123. package/dist/types/collections.d.ts +111 -4
  124. package/dist/types/color-picker.d.ts +38 -0
  125. package/dist/types/command.d.ts +6 -3
  126. package/dist/types/comment-thread.d.ts +48 -0
  127. package/dist/types/confirmation.d.ts +60 -0
  128. package/dist/types/context-menu.d.ts +17 -0
  129. package/dist/types/controls.d.ts +100 -17
  130. package/dist/types/conversation.d.ts +130 -0
  131. package/dist/types/copy-button.d.ts +19 -0
  132. package/dist/types/core.d.ts +139 -10
  133. package/dist/types/data-grid.d.ts +76 -0
  134. package/dist/types/date-picker.d.ts +96 -0
  135. package/dist/types/diff-view.d.ts +54 -0
  136. package/dist/types/dot-plot.d.ts +54 -0
  137. package/dist/types/edit-region.d.ts +121 -0
  138. package/dist/types/feature-gate.d.ts +22 -0
  139. package/dist/types/filter-bar.d.ts +65 -0
  140. package/dist/types/formatted-number.d.ts +38 -0
  141. package/dist/types/heatmap.d.ts +64 -0
  142. package/dist/types/in-place.d.ts +24 -0
  143. package/dist/types/index.d.ts +158 -15
  144. package/dist/types/inline-citation.d.ts +35 -0
  145. package/dist/types/inline-edit.d.ts +71 -0
  146. package/dist/types/input-otp.d.ts +37 -0
  147. package/dist/types/item-group.d.ts +104 -0
  148. package/dist/types/line-chart.d.ts +116 -0
  149. package/dist/types/link.d.ts +11 -0
  150. package/dist/types/markdown.d.ts +88 -0
  151. package/dist/types/meter.d.ts +43 -0
  152. package/dist/types/modal-state.d.ts +62 -0
  153. package/dist/types/nav.d.ts +8 -1
  154. package/dist/types/notifications.d.ts +42 -0
  155. package/dist/types/number-input.d.ts +30 -0
  156. package/dist/types/overlays.d.ts +51 -17
  157. package/dist/types/page-header.d.ts +27 -0
  158. package/dist/types/pagination.d.ts +29 -0
  159. package/dist/types/palette.d.ts +64 -0
  160. package/dist/types/password-input.d.ts +22 -0
  161. package/dist/types/pattern-assistant-conversation.d.ts +55 -0
  162. package/dist/types/pattern-async-collection.d.ts +34 -0
  163. package/dist/types/pattern-consequential-action.d.ts +53 -0
  164. package/dist/types/pattern-edit-record.d.ts +72 -0
  165. package/dist/types/pattern-review-submit.d.ts +43 -0
  166. package/dist/types/permission-matrix.d.ts +34 -0
  167. package/dist/types/plan-picker.d.ts +40 -0
  168. package/dist/types/prompt-input.d.ts +106 -0
  169. package/dist/types/property-list.d.ts +67 -0
  170. package/dist/types/question.d.ts +64 -0
  171. package/dist/types/relative-time.d.ts +21 -0
  172. package/dist/types/removal-receipt.d.ts +1180 -0
  173. package/dist/types/resizable.d.ts +48 -0
  174. package/dist/types/roving.d.ts +44 -0
  175. package/dist/types/save-bar.d.ts +38 -0
  176. package/dist/types/saved-views.d.ts +72 -0
  177. package/dist/types/scatter-chart.d.ts +60 -0
  178. package/dist/types/schedule.d.ts +80 -0
  179. package/dist/types/secret-field.d.ts +33 -0
  180. package/dist/types/select.d.ts +44 -2
  181. package/dist/types/separator.d.ts +11 -0
  182. package/dist/types/settings.d.ts +39 -0
  183. package/dist/types/sheet.d.ts +1 -1
  184. package/dist/types/sparkline.d.ts +46 -0
  185. package/dist/types/split-button.d.ts +21 -0
  186. package/dist/types/stat.d.ts +54 -0
  187. package/dist/types/stepper.d.ts +6 -3
  188. package/dist/types/suggestions.d.ts +29 -0
  189. package/dist/types/suspense.d.ts +12 -1
  190. package/dist/types/sync-indicator.d.ts +24 -0
  191. package/dist/types/tag-input.d.ts +65 -0
  192. package/dist/types/terminal.d.ts +33 -0
  193. package/dist/types/thread-list.d.ts +58 -0
  194. package/dist/types/time-input.d.ts +33 -0
  195. package/dist/types/timeline.d.ts +38 -0
  196. package/dist/types/toggle.d.ts +46 -0
  197. package/dist/types/toolbar.d.ts +34 -0
  198. package/dist/types/tour.d.ts +23 -0
  199. package/dist/types/trace.d.ts +65 -0
  200. package/dist/types/tree.d.ts +71 -0
  201. package/dist/types-cjs/adapter-codemirror.d.cts +13 -0
  202. package/dist/types-cjs/agent-fleet.d.cts +166 -0
  203. package/dist/types-cjs/ai-label.d.cts +30 -0
  204. package/dist/types-cjs/app-shell.d.cts +83 -0
  205. package/dist/types-cjs/area-chart.d.cts +80 -0
  206. package/dist/types-cjs/artifact.d.cts +65 -0
  207. package/dist/types-cjs/bar-chart.d.cts +83 -0
  208. package/dist/types-cjs/bar-list.d.cts +35 -0
  209. package/dist/types-cjs/breadcrumb.d.cts +62 -0
  210. package/dist/types-cjs/chart-brush.d.cts +35 -0
  211. package/dist/types-cjs/chart-themes.d.cts +229 -0
  212. package/dist/types-cjs/chart.d.cts +570 -0
  213. package/dist/types-cjs/checklist.d.cts +71 -0
  214. package/dist/types-cjs/code-block.d.cts +32 -0
  215. package/dist/types-cjs/collections.d.cts +246 -0
  216. package/dist/types-cjs/color-picker.d.cts +38 -0
  217. package/dist/types-cjs/command.d.cts +66 -0
  218. package/dist/types-cjs/comment-thread.d.cts +48 -0
  219. package/dist/types-cjs/confirmation.d.cts +60 -0
  220. package/dist/types-cjs/context-menu.d.cts +17 -0
  221. package/dist/types-cjs/controls.d.cts +360 -0
  222. package/dist/types-cjs/conversation.d.cts +130 -0
  223. package/dist/types-cjs/copy-button.d.cts +19 -0
  224. package/dist/types-cjs/core.d.cts +256 -0
  225. package/dist/types-cjs/data-grid.d.cts +76 -0
  226. package/dist/types-cjs/date-picker.d.cts +96 -0
  227. package/dist/types-cjs/diff-view.d.cts +54 -0
  228. package/dist/types-cjs/dot-plot.d.cts +54 -0
  229. package/dist/types-cjs/edit-region.d.cts +121 -0
  230. package/dist/types-cjs/empty.d.cts +24 -0
  231. package/dist/types-cjs/feature-gate.d.cts +22 -0
  232. package/dist/types-cjs/filter-bar.d.cts +65 -0
  233. package/dist/types-cjs/formatted-number.d.cts +38 -0
  234. package/dist/types-cjs/heatmap.d.cts +64 -0
  235. package/dist/types-cjs/in-place.d.cts +24 -0
  236. package/dist/types-cjs/index.d.cts +154 -0
  237. package/dist/types-cjs/inline-citation.d.cts +35 -0
  238. package/dist/types-cjs/inline-edit.d.cts +71 -0
  239. package/dist/types-cjs/input-otp.d.cts +37 -0
  240. package/dist/types-cjs/item-group.d.cts +104 -0
  241. package/dist/types-cjs/line-chart.d.cts +116 -0
  242. package/dist/types-cjs/link.d.cts +11 -0
  243. package/dist/types-cjs/markdown.d.cts +88 -0
  244. package/dist/types-cjs/meter.d.cts +43 -0
  245. package/dist/types-cjs/modal-state.d.cts +62 -0
  246. package/dist/types-cjs/nav.d.cts +25 -0
  247. package/dist/types-cjs/notifications.d.cts +42 -0
  248. package/dist/types-cjs/number-input.d.cts +30 -0
  249. package/dist/types-cjs/overlays.d.cts +170 -0
  250. package/dist/types-cjs/page-header.d.cts +27 -0
  251. package/dist/types-cjs/pagination.d.cts +29 -0
  252. package/dist/types-cjs/palette.d.cts +64 -0
  253. package/dist/types-cjs/password-input.d.cts +22 -0
  254. package/dist/types-cjs/pattern-assistant-conversation.d.cts +55 -0
  255. package/dist/types-cjs/pattern-async-collection.d.cts +34 -0
  256. package/dist/types-cjs/pattern-consequential-action.d.cts +53 -0
  257. package/dist/types-cjs/pattern-edit-record.d.cts +72 -0
  258. package/dist/types-cjs/pattern-review-submit.d.cts +43 -0
  259. package/dist/types-cjs/permission-matrix.d.cts +34 -0
  260. package/dist/types-cjs/pile.d.cts +27 -0
  261. package/dist/types-cjs/plan-picker.d.cts +40 -0
  262. package/dist/types-cjs/prompt-input.d.cts +106 -0
  263. package/dist/types-cjs/property-list.d.cts +67 -0
  264. package/dist/types-cjs/question.d.cts +64 -0
  265. package/dist/types-cjs/relative-time.d.cts +21 -0
  266. package/dist/types-cjs/removal-receipt.d.cts +1180 -0
  267. package/dist/types-cjs/resizable.d.cts +48 -0
  268. package/dist/types-cjs/roving.d.cts +44 -0
  269. package/dist/types-cjs/save-bar.d.cts +38 -0
  270. package/dist/types-cjs/saved-views.d.cts +72 -0
  271. package/dist/types-cjs/scatter-chart.d.cts +60 -0
  272. package/dist/types-cjs/schedule.d.cts +80 -0
  273. package/dist/types-cjs/secret-field.d.cts +33 -0
  274. package/dist/types-cjs/select.d.cts +109 -0
  275. package/dist/types-cjs/separator.d.cts +11 -0
  276. package/dist/types-cjs/settings.d.cts +39 -0
  277. package/dist/types-cjs/sheet.d.cts +27 -0
  278. package/dist/types-cjs/sparkline.d.cts +46 -0
  279. package/dist/types-cjs/split-button.d.cts +21 -0
  280. package/dist/types-cjs/stat.d.cts +54 -0
  281. package/dist/types-cjs/stepper.d.cts +26 -0
  282. package/dist/types-cjs/suggestions.d.cts +29 -0
  283. package/dist/types-cjs/suspense.d.cts +85 -0
  284. package/dist/types-cjs/sync-indicator.d.cts +24 -0
  285. package/dist/types-cjs/tag-input.d.cts +65 -0
  286. package/dist/types-cjs/terminal.d.cts +33 -0
  287. package/dist/types-cjs/thread-list.d.cts +58 -0
  288. package/dist/types-cjs/time-input.d.cts +33 -0
  289. package/dist/types-cjs/timeline.d.cts +38 -0
  290. package/dist/types-cjs/toggle.d.cts +46 -0
  291. package/dist/types-cjs/toolbar.d.cts +34 -0
  292. package/dist/types-cjs/tour.d.cts +23 -0
  293. package/dist/types-cjs/trace.d.cts +65 -0
  294. package/dist/types-cjs/tree.d.cts +71 -0
  295. package/package.json +89 -9
@@ -0,0 +1,226 @@
1
+ // Controlled service tests: mutations update service data only when explicitly accepted.
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, writeFile, rm, readFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join, resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { build } from 'esbuild';
8
+ import { chromium } from 'playwright-core';
9
+ const root = fileURLToPath(new URL('../../', import.meta.url));
10
+ const temp = await mkdtemp(join(tmpdir(), 'altitude-patterns-'));
11
+ const aliases = process.env.ALT_PACKAGE_ROOT ? { react: resolve(process.env.ALT_PACKAGE_ROOT, '../react'), 'react-dom': resolve(process.env.ALT_PACKAGE_ROOT, '../react-dom') } : {};
12
+ const bundle = await build({ alias: aliases, stdin: { contents: `import * as React from 'react'; import {createRoot} from 'react-dom/client'; import * as Altitude from '${process.env.ALT_PACKAGE_ROOT ? resolve(process.env.ALT_PACKAGE_ROOT, 'dist/index.js') : process.env.ALT_PATTERN_ENTRY || './project/components/src/index.tsx'}'; window.React=React; window.createRoot=createRoot; window.Altitude=Altitude;`, resolveDir: root, loader: 'tsx' }, bundle: true, write: false, format: 'iife', define: { 'process.env.NODE_ENV': '"development"' } });
13
+ await writeFile(join(temp, 'bundle.js'), bundle.outputFiles[0].contents);
14
+ await writeFile(join(temp, 'index.html'), '<!doctype html><html lang="en"><title>Pattern contracts</title><body><div id="root"></div><script src="bundle.js"></script></body></html>');
15
+ const browser = await chromium.launch(process.env.CHROME ? { executablePath: process.env.CHROME } : {});
16
+ let checks = 0;
17
+ const rules = JSON.parse(await readFile(resolve(root, 'project/framework/rules.json'), 'utf8')).rules;
18
+ const ruleAliases = { 'FORM-PRESERVE-INPUT':'ALT-EDIT-001', 'FORM-STALE-RESPONSE':'ALT-EDIT-001', 'FORM-VALIDATION-TIMING':'ALT-EDIT-003',
19
+ 'FORM-UNSAVED':'ALT-EDIT-003', 'FORM-ERROR-ASSOCIATION':'ALT-EDIT-002', 'ACTION-TRUE-OUTCOME':'ALT-EDIT-001', 'ACTION-DUPLICATE':'ALT-ACTION-001',
20
+ 'COLLECTION-STATES':'ALT-COLLECTION-001', 'COLLECTION-SELECTION':'ALT-COLLECTION-001', 'COLLECTION-RECOVERY':'ALT-COLLECTION-001', 'COLLECTION-IDENTITY':'ALT-COLLECTION-001',
21
+ 'ACTION-CONFIRMATION':'ALT-ACTION-001', 'ACTION-FOCUS':'ALT-ACTION-001', 'ACTION-RECOVERY':'ALT-ACTION-001', 'ACTION-UNDO':'ALT-ACTION-001', 'ACTION-TARGET':'ALT-ACTION-001',
22
+ 'REVIEW-RETAIN':'ALT-REVIEW-001', 'REVIEW-RECOVERY':'ALT-REVIEW-001', 'REVIEW-CONFIRMATION':'ALT-REVIEW-001', 'REVIEW-PROCESS':'ALT-REVIEW-001',
23
+ 'CONVO-DUPLICATE':'ALT-CONVO-001', 'CONVO-STALE':'ALT-CONVO-001', 'CONVO-KEEP':'ALT-CONVO-001', 'CONVO-COMPLETE':'ALT-CONVO-001', 'CONVO-ERRORS':'ALT-CONVO-001', 'CONVO-RELOAD':'ALT-CONVO-001' };
24
+ const check = (condition, message) => {
25
+ const ruleId = ruleAliases[message.split(':')[0]] || 'ALT-STATUS-001';
26
+ if (!condition) console.error(JSON.stringify({ ruleId, classification:'deterministic', scope:'controlled fixture contract; not a general usability verdict',
27
+ observed:'Contract assertion returned false', expected:message, repair:rules.find(rule=>rule.id===ruleId)?.repair }));
28
+ assert.ok(condition, `${ruleId}: ${message}`); checks++;
29
+ };
30
+ const page = await browser.newPage();
31
+ const errors = []; page.on('pageerror', error => errors.push(error.message));
32
+ await page.goto(`file://${join(temp, 'index.html')}`);
33
+ const mountEdit = async (validationMode = 'submit') => {
34
+ await page.evaluate(mode => {
35
+ window.fixtureRoot?.unmount(); const R = React, h = R.createElement, A = Altitude;
36
+ const api = { writes: [], data: { name: 'Original' }, leaves: [] }; window.harness = api;
37
+ function Fixture() {
38
+ const form = A.useEditRecord({ initialValue: { name: 'Original' }, validationMode: mode,
39
+ validate: value => value.name.trim() ? {} : { name: 'Enter a name.' },
40
+ save: draft => new Promise(resolve => { api.writes.push({ draft, resolve: result => { if (result.ok) api.data = result.value; resolve(result); } }); }),
41
+ }); api.form = form;
42
+ return h('form', { noValidate: true, onSubmit: e => { e.preventDefault(); void form.submit(); } },
43
+ h(A.ErrorSummary, { errors: Object.entries(form.errors).map(([key,message]) => ({ id: key, label: 'Name', message })), focusKey: form.submitCount }),
44
+ h(A.Field, { label: 'Name', error: form.errors.name }, h(A.Input, { id: 'name', value: form.draft.name, onChange: e => form.setField('name', e.target.value), onBlur: () => form.validateField('name') })),
45
+ h('button', { type: 'submit' }, 'Save changes'), h(A.WorkflowStatus, null, form.status), h('span', { id: 'phase' }, form.phase));
46
+ }
47
+ window.fixtureRoot = createRoot(document.getElementById('root')); fixtureRoot.render(h(R.StrictMode, null, h(A.AltitudeProvider, null, h(Fixture))));
48
+ }, validationMode);
49
+ await page.getByRole('textbox', { name: 'Name' }).waitFor();
50
+ };
51
+ const resolveWrite = (result, index = 0) => page.evaluate(({result,index}) => harness.writes[index].resolve(result), {result,index});
52
+ const phase = value => page.waitForFunction(value => document.getElementById('phase').textContent === value, value);
53
+ try {
54
+ check(await page.evaluate(() => typeof Altitude.useEditRecord === 'function'), 'FORM-PRESERVE-INPUT: edit-record hook is exported');
55
+ await mountEdit(); const input = page.getByRole('textbox', { name: 'Name' });
56
+ await input.fill(''); await input.blur(); check(await page.getByRole('alert').count() === 0, 'FORM-VALIDATION-TIMING: submit policy does not validate untouched blur');
57
+ await page.getByRole('button', { name: 'Save changes' }).click(); await phase('invalid');
58
+ check(await page.evaluate(() => harness.writes.length) === 0, 'FORM-ERROR-ASSOCIATION: invalid values never persist');
59
+ check(await input.getAttribute('aria-invalid') === 'true', 'FORM-ERROR-ASSOCIATION: same error drives field');
60
+ await page.getByRole('link', { name: 'Name: Enter a name.' }).click(); check(await input.evaluate(el => el === document.activeElement), 'FORM-ERROR-ASSOCIATION: summary link focuses field');
61
+ await input.fill('Retained'); check(await page.getByRole('link', { name: /Enter a name/ }).count() === 0, 'FORM-ERROR-ASSOCIATION: correction updates one error source');
62
+ await page.evaluate(() => { harness.form.submit(); harness.form.submit(); }); await phase('submitting');
63
+ check(await page.evaluate(() => harness.writes.length) === 1, 'ACTION-DUPLICATE: same-tick submissions dispatch once');
64
+ check(await page.evaluate(() => harness.data.name) === 'Original', 'ACTION-TRUE-OUTCOME: pending has not changed actual service data');
65
+ check(await page.getByRole('status').textContent() === 'Saving changes', 'ACTION-TRUE-OUTCOME: pending never claims success');
66
+ check(await page.evaluate(() => harness.form.requestLeave('navigation', () => {throw Error('must not prompt during pending');}))===false,'FORM-UNSAVED: pending navigation blocked without claiming cancellation');
67
+ await resolveWrite({ ok: false, error: 'Connection unavailable. Try again.' }); await phase('failed');
68
+ check(await input.inputValue() === 'Retained', 'FORM-PRESERVE-INPUT: failed write retains draft');
69
+ await page.evaluate(() => { void harness.form.submit(); }); await resolveWrite({ ok: true, value: { name: 'Retained' } }, 1); await phase('saved');
70
+ check(await page.evaluate(() => harness.data.name === 'Retained' && !harness.form.dirty), 'ACTION-TRUE-OUTCOME: retry changes data then clears dirty state');
71
+ await input.fill('First'); await page.evaluate(() => { void harness.form.submit(); }); await input.fill('Newer');
72
+ await resolveWrite({ ok: true, value: { name: 'First' } }, 2); await phase('editing');
73
+ check(await page.evaluate(() => harness.form.draft.name === 'Newer' && harness.form.dirty && harness.data.name === 'First'), 'FORM-STALE-RESPONSE: accepted older save cannot overwrite newer edits');
74
+ check(!(await page.getByRole('status').textContent()).includes('Changes saved'), 'FORM-STALE-RESPONSE: current draft not falsely marked saved');
75
+ check(await page.evaluate(() => harness.form.requestLeave('back', () => false)) === false, 'FORM-UNSAVED: retain blocks Back');
76
+ check(await page.evaluate(() => harness.form.requestLeave('navigation', () => true)) === true, 'FORM-UNSAVED: explicit discard permits navigation');
77
+ check(await page.evaluate(() => harness.form.draft.name) === 'First', 'FORM-UNSAVED: discard restores accepted baseline');
78
+ check(await page.evaluate(() => harness.form.requestLeave('cancel', () => { throw Error('must not ask clean form'); })) === true, 'FORM-UNSAVED: clean leave requires no discard decision');
79
+ await mountEdit('blur'); await page.getByRole('textbox', { name: 'Name' }).fill(''); await page.getByRole('textbox', { name: 'Name' }).blur(); await phase('invalid');
80
+ check(await page.getByRole('alert').count() === 1, 'FORM-VALIDATION-TIMING: blur reports a single inline alert');
81
+ await mountEdit(); await page.getByRole('textbox', {name:'Name'}).fill('Server rejects this');
82
+ await page.getByRole('button',{name:'Save changes'}).click(); await phase('submitting');
83
+ await resolveWrite({ok:false,error:'Check the name.',errors:{name:'This name is taken.'}}); await phase('failed');
84
+ check(await page.getByRole('region',{name:'Check these fields'}).evaluate(el=>el===document.activeElement),'FORM-ERROR-ASSOCIATION: late server errors focus the summary once');
85
+ await page.getByRole('textbox',{name:'Name'}).fill('Correction');
86
+ check(await page.getByRole('textbox',{name:'Name'}).evaluate(el=>el===document.activeElement),'FORM-ERROR-ASSOCIATION: correcting errors never steals focus back');
87
+ // Collection: out-of-order responses cannot replace a newer query; selection is explicit.
88
+ check(await page.evaluate(() => typeof Altitude.useAsyncCollection === 'function'), 'COLLECTION-STATES: collection hook is exported');
89
+ await page.evaluate(() => {
90
+ fixtureRoot.unmount(); const h = React.createElement; const api = { reads: [] }; window.harness = api;
91
+ function Fixture() { const collection = Altitude.useAsyncCollection({ initialQuery: '', isFiltered: query => query !== '', getKey: item => item.id, selection: 'preserve',
92
+ load: (query, context) => new Promise((resolve,reject) => api.reads.push({query,context,resolve,reject})) }); api.collection = collection;
93
+ return h('div', null, h('span', {id:'phase'}, collection.phase), h(Altitude.WorkflowStatus, null, collection.status), collection.items.map(item => h('p',{key:item.id},item.name)));
94
+ } fixtureRoot = createRoot(document.getElementById('root')); fixtureRoot.render(h(Fixture));
95
+ }); await phase('loading');
96
+ await page.evaluate(() => harness.reads[0].resolve([{id:'a',name:'Alpha'},{id:'b',name:'Beta'}])); await phase('populated');
97
+ await page.evaluate(() => { harness.collection.toggleSelection('a'); harness.collection.setQuery('missing'); }); await phase('refreshing');
98
+ await page.evaluate(() => harness.reads[1].resolve([])); await phase('no-results');
99
+ check(await page.evaluate(() => harness.collection.selectedKeys.has('a')), 'COLLECTION-SELECTION: preserve policy explicitly retains hidden stable key');
100
+ await page.evaluate(() => harness.collection.reset()); await phase('refreshing');
101
+ await page.evaluate(() => harness.reads[2].reject(Error('private server failure'))); await phase('failed');
102
+ check(!(await page.getByRole('status').textContent()).includes('private'), 'COLLECTION-RECOVERY: safe failure feedback');
103
+ await page.evaluate(() => harness.collection.refresh()); await page.evaluate(() => harness.reads[3].resolve([])); await phase('empty');
104
+ await page.evaluate(() => harness.collection.setQuery('older')); await page.evaluate(() => harness.collection.setQuery('newer'));
105
+ await page.evaluate(() => harness.reads[5].resolve([{id:'n',name:'Newest'}])); await phase('populated');
106
+ await page.evaluate(() => harness.reads[4].resolve([{id:'o',name:'Outdated'}]));
107
+ check(await page.evaluate(() => harness.collection.items[0].id) === 'n', 'COLLECTION-IDENTITY: stale query cannot replace current results');
108
+ await page.evaluate(() => harness.collection.refresh()); await page.evaluate(() => harness.reads[6].resolve([{id:'n',name:'One'},{id:'n',name:'Two'}])); await phase('failed');
109
+ check(await page.evaluate(() => harness.collection.items[0].name) === 'Newest', 'COLLECTION-IDENTITY: duplicate keys reject result and retain last good data');
110
+ await page.evaluate(() => {
111
+ fixtureRoot.unmount();const h=React.createElement;const api={};window.harness=api;
112
+ function Fixture(){const collection=Altitude.useAsyncCollection({initialQuery:'',isFiltered:q=>!!q,selection:'clear-on-query',getKey:item=>item.id,load:()=>[{id:'a'}]});api.collection=collection;return h('span',{id:'phase'},collection.phase);}
113
+ fixtureRoot=createRoot(document.getElementById('root'));fixtureRoot.render(h(React.StrictMode,null,h(Fixture)));
114
+ });await phase('populated');await page.evaluate(()=>{harness.collection.toggleSelection('a');harness.collection.setQuery('other');});await phase('populated');
115
+ check(await page.evaluate(()=>harness.collection.selectedKeys.size)===0,'COLLECTION-SELECTION: clear-on-query policy drops selection explicitly');
116
+ // Consequential action: confirmation does not mutate; Undo has a real inverse operation.
117
+ check(await page.evaluate(() => typeof Altitude.useConsequentialAction === 'function'), 'ACTION-TRUE-OUTCOME: consequential hook is exported');
118
+ await page.evaluate(() => {
119
+ fixtureRoot.unmount(); const h = React.createElement; const api = { data:['a'], writes:[], undos:[] }; window.harness=api;
120
+ function Fixture() { const action = Altitude.useConsequentialAction({ target:'Alpha', consequence:'Move Alpha to trash.', requiresConfirmation:true,
121
+ execute: () => new Promise(resolve => api.writes.push(result => {if(result.ok) api.data=[]; resolve(result);})),
122
+ undo: result => new Promise(resolve => api.undos.push(outcome => {if(outcome.ok) api.data=[result]; resolve(outcome);})),
123
+ focusFallback: () => document.getElementById('fallback') }); api.action=action;
124
+ return h('div',null,h('button',{id:'trigger',onClick:action.request},'Delete Alpha'),h('button',{id:'fallback'},'Next record'),h('span',{id:'phase'},action.phase),h(Altitude.WorkflowStatus,null,action.status));
125
+ } fixtureRoot=createRoot(document.getElementById('root')); fixtureRoot.render(h(Fixture));
126
+ }); await phase('idle');
127
+ await page.getByRole('button',{name:'Delete Alpha'}).click(); await phase('confirming');
128
+ check(await page.evaluate(() => harness.writes.length === 0 && harness.data.length === 1), 'ACTION-CONFIRMATION: intent decision does not execute');
129
+ await page.evaluate(() => harness.action.cancel()); await phase('idle');
130
+ check(await page.locator('#trigger').evaluate(el=>el===document.activeElement),'ACTION-FOCUS: cancel restores trigger');
131
+ await page.evaluate(() => { harness.action.request(); harness.action.confirm(); harness.action.confirm(); }); await phase('pending');
132
+ check(await page.evaluate(() => harness.writes.length) === 1,'ACTION-DUPLICATE: repeated confirmation dispatches once');
133
+ await page.evaluate(() => harness.writes[0]({ok:false,error:'Could not delete. Try again.'})); await phase('failed');
134
+ check(await page.evaluate(() => harness.data.length) === 1,'ACTION-RECOVERY: failed action leaves service data intact');
135
+ await page.evaluate(() => { void harness.action.retry(); }); await page.evaluate(() => harness.writes[1]({ok:true,value:'a'})); await phase('succeeded');
136
+ check(await page.evaluate(() => harness.data.length === 0 && harness.action.canUndo),'ACTION-TRUE-OUTCOME: succeeded after data mutation with real undo');
137
+ await page.evaluate(() => { void harness.action.undo(); }); await phase('undoing');
138
+ await page.evaluate(() => harness.undos[0]({ok:false,error:'Could not restore. Try again.'})); await phase('failed');
139
+ await page.evaluate(() => { void harness.action.retry(); }); await page.evaluate(() => harness.undos[1]({ok:true})); await phase('undone');
140
+ check(await page.evaluate(() => harness.data[0]) === 'a','ACTION-UNDO: retry performs real restoration');
141
+ await page.evaluate(() => {
142
+ fixtureRoot.unmount(); const h=React.createElement; const api={writes:[],data:['first','second']};window.harness=api;
143
+ function Fixture(){const [target,setTarget]=React.useState('first');api.setTarget=setTarget;api.observedTarget=target;
144
+ const action=Altitude.useConsequentialAction({target,consequence:`Delete ${target}.`,requiresConfirmation:true,
145
+ execute:()=>new Promise(resolve=>api.writes.push(()=>{api.data=api.data.filter(id=>id!==target);resolve({ok:true,value:target});})),focusFallback:()=>document.getElementById('fallback')});api.action=action;
146
+ return h('div',null,action.phase!=='succeeded'&&h('button',{id:'trigger',onClick:action.request},'Delete record'),h('h2',{id:'fallback',tabIndex:-1},'Remaining records'),h('span',{id:'phase'},action.phase));
147
+ }fixtureRoot=createRoot(document.getElementById('root'));fixtureRoot.render(h(Fixture));
148
+ });await phase('idle');await page.getByRole('button',{name:'Delete record'}).click();await page.evaluate(()=>harness.setTarget('second'));await page.waitForFunction(()=>harness.observedTarget==='second');
149
+ check(await page.evaluate(()=>harness.action.target)==='first','ACTION-TARGET: confirmation retains the named target when parent options change');
150
+ await page.evaluate(()=>harness.action.confirm());await page.evaluate(()=>harness.writes[0]());await phase('succeeded');
151
+ check(await page.evaluate(()=>harness.data.join(','))==='second','ACTION-TARGET: confirmed mutation applies only to the captured target');
152
+ check(await page.locator('#fallback').evaluate(el=>el===document.activeElement),'ACTION-FOCUS: removed trigger restores logical fallback after DOM commit');
153
+ // Review: correcting preserves answers; only a real accepted receipt completes the process.
154
+ check(await page.evaluate(() => typeof Altitude.useReviewSubmit === 'function'), 'REVIEW-RETAIN: review hook is exported');
155
+ await page.evaluate(() => {
156
+ fixtureRoot.unmount(); const h=React.createElement; const api={writes:[],data:[]}; window.harness=api;
157
+ function Fixture(){const review=Altitude.useReviewSubmit({initialValue:{name:'Original',note:'Keep this answer'},validate:value=>value.name?{}:{name:'Enter a name.'},
158
+ submit:draft=>new Promise(resolve=>api.writes.push(result=>{if(result.ok)api.data.push(draft);resolve(result);}))});api.review=review;
159
+ return h('div',null,h('span',{id:'phase'},review.phase),h(Altitude.WorkflowStatus,null,review.status),review.receipt&&h('p',null,review.receipt.reference));
160
+ } fixtureRoot=createRoot(document.getElementById('root'));fixtureRoot.render(h(Fixture));
161
+ }); await phase('editing');
162
+ await page.evaluate(() => harness.review.review()); await phase('review'); await page.evaluate(() => harness.review.correct()); await phase('editing');
163
+ await page.evaluate(() => {harness.review.setField('name','Corrected'); harness.review.review();}); await phase('review');
164
+ check(await page.evaluate(() => harness.review.draft.note) === 'Keep this answer','REVIEW-RETAIN: correcting preserves other answers');
165
+ await page.evaluate(() => {harness.review.submit();harness.review.submit();}); await phase('submitting');
166
+ check(await page.evaluate(() => harness.writes.length===1 && harness.review.receipt===undefined),'ACTION-TRUE-OUTCOME: submission locks and no premature receipt');
167
+ await page.evaluate(() => harness.writes[0]({ok:false,error:'Connection interrupted. Try again.'})); await phase('review');
168
+ check(await page.evaluate(() => harness.review.draft.name) === 'Corrected','REVIEW-RECOVERY: failed/interrupted submit preserves corrected data');
169
+ await page.evaluate(() => {void harness.review.submit();}); await page.evaluate(() => harness.writes[1]({ok:true,receipt:{reference:'Accepted #42'}})); await phase('complete');
170
+ check(await page.evaluate(() => harness.data.length===1 && harness.data[0].name==='Corrected' && harness.review.receipt.reference==='Accepted #42'),'REVIEW-CONFIRMATION: persistent receipt corresponds to actual accepted data');
171
+ await page.evaluate(() => {harness.review.correct();harness.review.setField('name','Wrong');harness.review.submit();}); await phase('complete');
172
+ check(await page.evaluate(() => harness.writes.length)===2,'REVIEW-CONFIRMATION: completed transaction cannot accidentally resubmit');
173
+ await page.evaluate(() => {
174
+ fixtureRoot.unmount(); const h=React.createElement;const api={writes:0};window.harness=api;
175
+ function Fixture(){const review=Altitude.useReviewSubmit({initialValue:{name:'Ready'},validate:()=>({}),submit:()=>{api.writes++;return {ok:true};}});api.review=review;return h('span',{id:'phase'},review.phase);}
176
+ fixtureRoot=createRoot(document.getElementById('root'));fixtureRoot.render(h(Fixture));
177
+ });await phase('editing');await page.evaluate(()=>{void harness.review.submit();});
178
+ check(await page.evaluate(()=>harness.writes)===0,'REVIEW-PROCESS: cannot submit before review');
179
+ await page.evaluate(()=>{harness.review.review();void harness.review.submit();});await page.waitForFunction(()=>harness.writes===1);await phase('review');
180
+ check(await page.evaluate(()=>harness.review.receipt===undefined && !!harness.review.error),'REVIEW-CONFIRMATION: malformed JavaScript success without receipt is recoverable failure');
181
+ // Assistant conversation (#285): a turn that can't be sent twice, drops events from a superseded stream, keeps the partial
182
+ // answer on Stop, completes only when the stream does, says errors in words, and shows an unfinished answer after a
183
+ // reload as interrupted.
184
+ check(await page.evaluate(() => typeof Altitude.useAssistantTurn === 'function'), 'CONVO-DUPLICATE: the turn hook is exported');
185
+ const mountTurn = (restored) => page.evaluate((restored) => {
186
+ fixtureRoot.unmount(); const h = React.createElement; const api = { streams: [] }; window.harness = api;
187
+ const stream = (input, signal) => {
188
+ const q = []; let wake = null, done = false, err = null;
189
+ const s = { input, signal, push: (t) => { q.push(t); wake?.(); }, end: () => { done = true; wake?.(); }, fail: (e) => { err = e; wake?.(); } };
190
+ api.streams.push(s);
191
+ return (async function* () { for (;;) { if (q.length) { yield q.shift(); continue; } if (err) throw err; if (done) return; await new Promise((r) => { wake = r; }); wake = null; } })();
192
+ };
193
+ function Fixture() { const turn = Altitude.useAssistantTurn({ stream, restored: restored ?? undefined, locale: 'en-US' }); api.turn = turn; return h('span', { id: 'phase' }, turn.state); }
194
+ fixtureRoot = createRoot(document.getElementById('root')); fixtureRoot.render(h(Fixture));
195
+ }, restored ?? null);
196
+ await mountTurn(); await phase('ready');
197
+ await page.evaluate(() => { harness.turn.submit('What changed?'); harness.turn.submit('What changed?'); }); await phase('submitted');
198
+ check(await page.evaluate(() => harness.streams.length) === 1, 'CONVO-DUPLICATE: a double submit sends once');
199
+ await page.evaluate(() => harness.streams[0].push('The billing ')); await phase('streaming');
200
+ check(await page.evaluate(() => harness.turn.promptStatus === 'streaming' && harness.turn.messageStatus === 'streaming'), 'CONVO-COMPLETE: streaming maps to the composer and the message');
201
+ await page.evaluate(() => harness.turn.stop()); await phase('stopped');
202
+ await page.evaluate(() => harness.streams[0].push('migration')); await page.waitForTimeout(50);
203
+ check(await page.evaluate(() => harness.turn.text) === 'The billing ', 'CONVO-STALE: Stop keeps the partial answer and drops what arrives after');
204
+ await page.evaluate(() => harness.turn.retry()); await phase('submitted');
205
+ await page.evaluate(() => { harness.streams[0].push(' late'); harness.streams[1].push('Two risks.'); }); await phase('streaming');
206
+ check(await page.evaluate(() => harness.turn.text) === 'Two risks.' && await page.evaluate(() => harness.streams[1].input) === 'What changed?', 'CONVO-STALE: retry sends the kept input and ignores the superseded stream');
207
+ check(await page.evaluate(() => harness.turn.state) === 'streaming', 'CONVO-COMPLETE: text alone never completes an answer');
208
+ await page.evaluate(() => harness.streams[1].end()); await phase('complete');
209
+ check(await page.evaluate(() => harness.turn.messageStatus) === 'complete', 'CONVO-COMPLETE: the answer settles when the stream says so');
210
+ await page.evaluate(() => harness.turn.submit('And now?')); await phase('submitted');
211
+ await page.evaluate(() => harness.streams[2].fail(new Altitude.AssistantError('rate-limited', undefined, new Date(2026, 9, 30, 15, 40).getTime()))); await phase('failed');
212
+ const failed = await page.evaluate(() => ({ text: harness.turn.errorText, input: harness.turn.input, prompt: harness.turn.promptStatus }));
213
+ check(failed.text === "You've reached this hour's limit. Try again at 3:40 PM." && failed.input === 'And now?' && failed.prompt === 'error', `CONVO-ERRORS: a rate limit says when, and the message is kept (${JSON.stringify(failed)})`);
214
+ check(await page.evaluate(() => [Altitude.assistantErrorText({ kind: 'context-full' }), Altitude.assistantErrorText({ kind: 'offline' })].every((t) => t && !/something went wrong/i.test(t))), 'CONVO-ERRORS: each kind has its own words');
215
+ await page.evaluate(() => { harness.turn.retry(); }); await phase('submitted');
216
+ await page.evaluate(() => harness.streams[3].push('Hi')); await phase('streaming');
217
+ await page.evaluate(() => harness.turn.awaitInput(true)); await phase('awaiting-input');
218
+ await page.evaluate(() => harness.turn.submit('Interrupting')); await page.waitForTimeout(30);
219
+ check(await page.evaluate(() => harness.streams.length) === 4, 'CONVO-DUPLICATE: nothing is sent over a turn waiting for the person');
220
+ await mountTurn({ input: 'What changed?', text: 'The billing', complete: false }); await phase('interrupted');
221
+ check(await page.evaluate(() => harness.turn.text === 'The billing' && harness.turn.messageStatus === 'error'), 'CONVO-RELOAD: an unfinished answer after a reload is interrupted, never complete');
222
+ await mountTurn({ input: 'What changed?', text: 'Done.', complete: true }); await phase('complete');
223
+ check(await page.evaluate(() => harness.turn.messageStatus) === 'complete', 'CONVO-RELOAD: a completed answer is restored as complete');
224
+ check(errors.length === 0, `no browser errors: ${errors.join('; ')}`);
225
+ console.log(`Workflow patterns: ${checks} assertions passed; Chromium ${browser.version()}.`);
226
+ } finally { await browser.close(); await rm(temp, { recursive:true, force:true }); }
@@ -0,0 +1,49 @@
1
+ // One primary per view (law 1, ALT-VISUAL-002): with devChecks, two visible primary Buttons in one page warn once,
2
+ // naming them and law 1; one primary on the page plus one in a Dialog doesn't (a Dialog is its own view); PrimaryScope
3
+ // separates independent parts; a hidden primary doesn't count; without devChecks in this build nothing is checked.
4
+ // Renders its own trees into a preview page (test/p-Button-daylight.html); exits non-zero on a failure.
5
+ import { chromium } from 'playwright-core';
6
+ const browser = await chromium.launch(process.env.CHROME ? { executablePath: process.env.CHROME } : {});
7
+ const T = new URL('../test/', import.meta.url).pathname;
8
+ let pass = 0, fail = 0;
9
+ const expect = (c, m) => { if (c) pass++; else { fail++; console.log('FAIL', m); } };
10
+ const page = await browser.newPage({ viewport: { width: 960, height: 600 } });
11
+ const warnings = [];
12
+ page.on('console', (m) => { if (m.type() === 'warning') warnings.push(m.text()); });
13
+ await page.goto(`file://${T}p-Button-daylight.html`);
14
+ await page.waitForTimeout(300);
15
+
16
+ // Renders a tree in a fresh root and returns the warnings it caused.
17
+ const run = async (tree) => {
18
+ warnings.length = 0;
19
+ await page.evaluate((tree) => new Promise((done) => {
20
+ const A = window.Altitude, h = React.createElement;
21
+ const P = (label, props = {}) => h(A.Button, { variant: 'primary', key: label, ...props }, label);
22
+ const S = (label) => h(A.Button, { variant: 'secondary', key: label }, label);
23
+ const trees = {
24
+ two: () => h(A.AltitudeProvider, { devChecks: true }, P('Save'), P('Publish')),
25
+ one: () => h(A.AltitudeProvider, { devChecks: true }, P('Save'), S('Cancel')),
26
+ dialog: () => h(A.AltitudeProvider, { devChecks: true }, P('Invite members'), h(A.Dialog, { open: true, title: 'Invite', footer: P('Send invites') })),
27
+ scoped: () => h(A.AltitudeProvider, { devChecks: true }, h(A.PrimaryScope, null, P('Upgrade')), h(A.PrimaryScope, null, P('Connect'))),
28
+ hidden: () => h(A.AltitudeProvider, { devChecks: true }, P('Save'), h('div', { hidden: true }, P('Publish'))),
29
+ off: () => h(A.AltitudeProvider, null, P('Save'), P('Publish')),
30
+ };
31
+ const d = document.createElement('div'); document.body.append(d);
32
+ const root = ReactDOM.createRoot(d);
33
+ root.render(trees[tree]());
34
+ setTimeout(() => { root.unmount(); d.remove(); done(); }, 400);
35
+ }), tree);
36
+ return warnings.filter((w) => w.includes('Law 1'));
37
+ };
38
+
39
+ const two = await run('two');
40
+ expect(two.length === 1 && two[0].includes('"Save"') && two[0].includes('"Publish"'), `two visible primaries warn once, naming them (${two.join(' | ')})`);
41
+ expect((await run('one')).length === 0, 'one primary and a secondary: no warning');
42
+ expect((await run('dialog')).length === 0, 'a Dialog is its own view');
43
+ expect((await run('scoped')).length === 0, 'PrimaryScope separates independent parts');
44
+ expect((await run('hidden')).length === 0, 'a hidden primary doesn\'t count');
45
+ expect((await run('off')).length === 0, 'without devChecks (no NODE_ENV in this build) nothing is checked');
46
+
47
+ console.log(`Primary scope: ${pass} passed, ${fail} failed`);
48
+ if (fail) process.exitCode = 1;
49
+ await browser.close();
@@ -0,0 +1,72 @@
1
+ // The tells of generated interfaces that only show once a page is rendered (Distinctiveness, #321). Warnings, never
2
+ // failures: a row of genuinely equal items is legitimate, as Charts says of dashboard cards, so a person decides.
3
+ // LA4 three or more sibling surfaces of the same size, laid out together, none of them leading: no primary action and
4
+ // no larger title in one of them, and no larger title just before the set (a hero number above a row of secondary
5
+ // ones leads it). A set of choices (radios, options, tabs) is exempt: options should weigh the same, and the
6
+ // current or recommended one is said in words;
7
+ // LA6 a view of eight or more different sections stacked with one gap, so nothing groups.
8
+ // It opens every preview (test/p-<Name>-daylight.html, after a render or PAGES_ONLY=1) at 960 CSS px. An element inside
9
+ // [data-tells-ignore="<id> <reason>"] is skipped for that id. TY5 (a font that never loads) is in tells-lint.mjs: the
10
+ // preview pages leave the font import out on purpose, so it can't be judged here.
11
+ // node checks/render-tells.mjs every preview
12
+ // node checks/render-tells.mjs Card,Stat just these
13
+ import { chromium } from 'playwright-core';
14
+ import { existsSync, readdirSync } from 'fs';
15
+ const T = new URL('../test/', import.meta.url).pathname;
16
+ const root = new URL('../..', import.meta.url).pathname;
17
+ const names = process.argv[2] ? process.argv[2].split(',') : readdirSync(root + 'project/components').filter((d) => existsSync(`${root}project/components/${d}/preview.html`)).sort();
18
+ const browser = await chromium.launch(process.env.CHROME ? { executablePath: process.env.CHROME } : {});
19
+
20
+ const measure = () => {
21
+ const out = [];
22
+ const ignored = (e, id) => { const s = e.closest('[data-tells-ignore]'); return !!s && new RegExp(`\\b${id}\\s+[A-Za-z]`).test(s.dataset.tellsIgnore); };
23
+ const visible = (e) => { const r = e.getBoundingClientRect(), cs = getComputedStyle(e); return r.width > 0 && r.height > 0 && cs.visibility !== 'hidden' && cs.display !== 'none' && +cs.opacity !== 0; };
24
+ const name = (e) => `${e.tagName.toLowerCase()}${typeof e.className === 'string' && e.className.trim() ? '.' + e.className.trim().split(/\s+/).slice(0, 2).join('.') : ''}`;
25
+ const transparent = (c) => c === 'rgba(0, 0, 0, 0)' || c === 'transparent';
26
+ const surface = (e) => { const cs = getComputedStyle(e); return cs.boxShadow !== 'none' || (!transparent(cs.backgroundColor) && cs.backgroundColor !== getComputedStyle(e.parentElement).backgroundColor && parseFloat(cs.borderTopLeftRadius) > 0); };
27
+ const near = (a, b, t) => Math.abs(a - b) <= t * Math.max(a, b);
28
+ const big = (e) => [e, ...e.querySelectorAll('*')].some((x) => parseFloat(getComputedStyle(x).fontSize) >= 28 && x.textContent.trim());
29
+ const leads = (e) => !!e.querySelector('.alt-btn[data-variant="primary"]') || big(e);
30
+ // A larger title or number among the set's container's earlier siblings, or its parent's, leads the set.
31
+ const ledFromBefore = (p) => [p, p.parentElement].filter(Boolean).some((n) => { for (let s = n.previousElementSibling; s; s = s.previousElementSibling) if (visible(s) && big(s)) return true; return false; });
32
+ const choice = (e) => e.matches('[role="radio"], [role="option"], [role="tab"], [role="checkbox"]') || !!e.closest('[role="radiogroup"], [role="listbox"], [role="tablist"]');
33
+ for (const p of document.body.querySelectorAll('*')) {
34
+ const kids = [...p.children].filter(visible);
35
+ if (kids.length < 3) continue;
36
+ // LA4: a set of same-sized cards, in a row or a grid, none leading.
37
+ const cards = kids.filter((k) => { const r = k.getBoundingClientRect(); return r.width >= 120 && r.height >= 64 && surface(k); });
38
+ if (cards.length >= 3 && cards.length >= kids.length * 0.75 && !ignored(p, 'LA4')) {
39
+ const rs = cards.map((c) => c.getBoundingClientRect());
40
+ const w = rs.map((r) => r.width).sort((a, b) => a - b)[rs.length >> 1], h = rs.map((r) => r.height).sort((a, b) => a - b)[rs.length >> 1];
41
+ const same = rs.every((r) => near(r.width, w, 0.05) && near(r.height, h, 0.05));
42
+ const together = rs.some((r, i) => rs.some((q, j) => i !== j && Math.abs(r.top - q.top) < 2));
43
+ const leading = cards.filter(leads).length;
44
+ if (same && together && (leading === 0 || leading === cards.length) && !cards.every(choice) && !ledFromBefore(p))
45
+ out.push(['LA4', `${cards.length} equal surfaces (${Math.round(w)}×${Math.round(h)}) in ${name(p)}, none leading`]);
46
+ }
47
+ // LA6: different sections, stacked, all one gap apart.
48
+ if (kids.length >= 8 && !ignored(p, 'LA6')) {
49
+ const rs = kids.map((k) => k.getBoundingClientRect());
50
+ const stacked = rs.every((r, i) => i === 0 || r.top >= rs[i - 1].bottom - 1);
51
+ const kinds = new Set(kids.map((k) => k.tagName + '.' + (typeof k.className === 'string' ? k.className.split(/\s+/)[0] : ''))).size;
52
+ const gaps = new Set(rs.slice(1).map((r, i) => Math.round(r.top - rs[i].bottom)));
53
+ if (stacked && kinds >= 3 && gaps.size === 1 && [...gaps][0] > 0) out.push(['LA6', `${kids.length} sections in ${name(p)}, all ${[...gaps][0]}px apart`]);
54
+ }
55
+ }
56
+ return out;
57
+ };
58
+
59
+ const findings = [];
60
+ for (const n of names) {
61
+ const file = `${T}p-${n}-daylight.html`;
62
+ if (!existsSync(file)) { console.error(`render-tells: no ${file}; render first (node shoot.mjs "" daylight 960, or PAGES_ONLY=1)`); process.exit(1); }
63
+ const page = await browser.newPage({ viewport: { width: 960, height: 900 } });
64
+ await page.goto('file://' + file);
65
+ await page.waitForTimeout(600);
66
+ for (const [id, text] of await page.evaluate(measure)) findings.push({ id, name: n, text });
67
+ await page.close();
68
+ }
69
+ await browser.close();
70
+
71
+ for (const f of findings) console.log(process.env.GITHUB_ACTIONS ? `::warning title=Tell ${f.id} in ${f.name}::${f.text}` : `${f.id} ${f.name}: ${f.text}`);
72
+ console.log(`render-tells: ${names.length} previews, ${findings.length} warning(s)${findings.length ? '. Each is a judgement: give one thing the lead, group with spacing, or mark it data-tells-ignore="<id> <reason>" (project/Distinctiveness.md).' : ''}`);
@@ -0,0 +1,111 @@
1
+ // Screen layout pattern: each frame's stated lead (the design record in its fixture's first lines) is the first thing
2
+ // after the page header and, where the record says so, the largest region; there's at most one primary action and no
3
+ // navigation sidebar. The editor saves as a status and publishes with its outcome taking focus; the decision puts its
4
+ // consequence and both choices before the evidence, asks for a decline's reason in place, returns focus to Decline on
5
+ // Escape, and replaces the choice with the outcome; the report's takeaway heads the chart that leads, before any other
6
+ // heading after the title. Unled equal sets and flat stacks are render-tells.mjs's (it opens every preview). Run after a
7
+ // render of every preview (it opens test/p-ScreenLayout*-daylight.html); exits non-zero on a failure.
8
+ import { chromium } from 'playwright-core';
9
+ const browser = await chromium.launch(process.env.CHROME ? { executablePath: process.env.CHROME } : {});
10
+ const T = new URL('../test/', import.meta.url).pathname;
11
+ let pass = 0, fail = 0;
12
+ const expect = (c, m) => { if (c) pass++; else { fail++; console.log('FAIL', m); } };
13
+ const open = async (name) => {
14
+ const page = await browser.newPage({ viewport: { width: 960, height: 1000 } });
15
+ await page.goto(`file://${T}p-${name}-daylight.html`);
16
+ await page.waitForTimeout(500);
17
+ return page;
18
+ };
19
+ // The frame: main's regions in order, with their areas; the primary buttons; any navigation besides the breadcrumb.
20
+ const frame = (page) => page.evaluate(() => {
21
+ const main = document.querySelector('main'), kids = [...main.children].filter((c) => getComputedStyle(c).display !== 'none');
22
+ return {
23
+ regions: kids.map((c) => ({ cls: typeof c.className === 'string' ? c.className : '', tag: c.tagName, area: c.getBoundingClientRect().width * c.getBoundingClientRect().height })),
24
+ primaries: [...document.querySelectorAll('button[data-variant="primary"]')].filter((b) => b.getClientRects().length).length,
25
+ navs: [...document.querySelectorAll('nav, [role="navigation"]')].filter((n) => !n.closest('.alt-page-head')).length,
26
+ };
27
+ });
28
+ const leads = (f, cls, largest) => {
29
+ const i = f.regions.findIndex((r) => r.cls.split(' ').includes(cls));
30
+ expect(f.regions[0]?.cls.includes('alt-page-head') && i === 1, `${cls} is the first thing after the page header (${f.regions.map((r) => r.cls || r.tag).join(', ')})`);
31
+ if (largest) expect(i >= 0 && f.regions.every((r, j) => j === i || r.area < f.regions[i].area), `${cls} is the largest region`);
32
+ expect(f.primaries <= 1 && f.navs === 0, `one primary action at most and no navigation sidebar (${f.primaries} primary, ${f.navs} nav)`);
33
+ };
34
+ const focused = (page) => page.evaluate(() => { const a = document.activeElement; return { id: a?.id || '', text: a?.textContent?.trim() || '', role: a?.getAttribute('role') || '', tag: a?.tagName || '' }; });
35
+
36
+ { // Focused editor.
37
+ const page = await open('ScreenLayoutEditor');
38
+ leads(await frame(page), 'le-body', true);
39
+ const area = (sel) => page.evaluate((s) => { const r = document.querySelector(s).getBoundingClientRect(); return r.width * r.height; }, sel);
40
+ expect(await area('.le-draft') > 2 * await area('.le-side'), 'the draft fills the column; its details sit beside it, narrower');
41
+ expect(await page.locator('.le-draft').evaluate((d, s) => d.compareDocumentPosition(document.querySelector(s)) & Node.DOCUMENT_POSITION_FOLLOWING, '.le-side') > 0, 'the draft comes before its details in reading order');
42
+ await page.getByLabel('Note').fill('A first line.');
43
+ expect(/Saving/.test(await page.locator('.alt-sync').textContent()), 'typing saves as a status, not a second button');
44
+ await page.waitForTimeout(1000);
45
+ expect(/Saved/.test(await page.locator('.alt-sync').textContent()), 'and settles to Saved');
46
+ await page.locator('#le-title').fill('');
47
+ await page.getByRole('button', { name: 'Publish' }).click();
48
+ await page.waitForTimeout(200);
49
+ expect((await focused(page)).id === 'le-title' && /Give the note a title/.test(await page.locator('.le-draft').textContent()), 'publishing without a title says so and focuses the title');
50
+ await page.locator('#le-title').fill('Saved views');
51
+ await page.getByRole('button', { name: 'Publish' }).click();
52
+ await page.waitForTimeout(1000);
53
+ const f = await focused(page);
54
+ expect(f.role === 'status' && /^Published/.test(f.text), `the outcome takes focus where the task ends (${f.role} “${f.text.slice(0, 30)}”)`);
55
+ expect(/View the changelog/.test(f.text) && (await frame(page)).primaries === 0, 'it points to what comes next, and Publish is gone');
56
+ await page.close();
57
+ }
58
+
59
+ { // Decision.
60
+ const page = await open('ScreenLayoutDecision');
61
+ leads(await frame(page), 'ld-choice', false);
62
+ const order = await page.evaluate(() => {
63
+ const choice = document.querySelector('.ld-choice'), evidence = document.querySelector('.ld-evidence');
64
+ return { consequence: !!choice.querySelector('.ld-consequence'), buttons: [...choice.querySelectorAll('button')].map((b) => b.textContent.trim()),
65
+ above: choice.getBoundingClientRect().bottom <= evidence.getBoundingClientRect().top, title: document.querySelector('.alt-page-head h1, .alt-page-head h2')?.textContent };
66
+ });
67
+ expect(/\?$/.test(order.title || ''), `the question is the title (${order.title})`);
68
+ expect(order.consequence && order.buttons.join() === 'Approve refund,Decline' && order.above, `the consequence and both choices come before the evidence (${order.buttons.join()})`);
69
+ await page.getByRole('button', { name: 'Decline' }).click();
70
+ await page.waitForTimeout(200);
71
+ expect((await focused(page)).id === 'ld-reason', 'Decline asks for its reason in place, focused');
72
+ await page.keyboard.press('Escape');
73
+ await page.waitForTimeout(200);
74
+ expect((await focused(page)).text === 'Decline', 'Escape cancels and returns focus to Decline');
75
+ await page.keyboard.press('Enter');
76
+ await page.waitForTimeout(200);
77
+ await page.getByRole('button', { name: 'Send decline' }).click();
78
+ await page.waitForTimeout(200);
79
+ expect((await focused(page)).id === 'ld-reason' && /Say why/.test(await page.locator('.ld-choice').textContent()), 'a decline needs a reason, and says so');
80
+ await page.locator('#ld-reason').fill('The duplicate invoice was already credited on 3 October.');
81
+ await page.getByRole('button', { name: 'Send decline' }).click();
82
+ await page.waitForTimeout(1000);
83
+ let f = await focused(page);
84
+ expect(f.role === 'status' && /^Refund declined/.test(f.text) && /Next request/.test(f.text), `the outcome replaces the choice, takes focus and points to the next request (“${f.text.slice(0, 30)}”)`);
85
+ await page.reload(); await page.waitForTimeout(500);
86
+ await page.getByRole('button', { name: 'Approve refund' }).click();
87
+ await page.waitForTimeout(1000);
88
+ f = await focused(page);
89
+ expect(f.role === 'status' && /^Refund approved/.test(f.text) && (await frame(page)).primaries === 0, 'approving reports the outcome in its place');
90
+ await page.close();
91
+ }
92
+
93
+ { // Report.
94
+ const page = await open('ScreenLayoutReport');
95
+ const f = await frame(page);
96
+ leads(f, 'lr-lead', true);
97
+ const r = await page.evaluate(() => {
98
+ // The takeaway is set as the figure's heading (ChartContainer's .alt-chart-takeaway): it must come before, and read
99
+ // at least as large as, the first heading after the title.
100
+ const take = document.querySelector('.lr-lead .alt-chart-takeaway'), h = [...document.querySelectorAll('main h2, main h3')].find((e) => !e.closest('.alt-page-head') && !e.closest('.lr-lead'));
101
+ const chart = document.querySelector('.lr-lead').getBoundingClientRect(), stats = document.querySelector('.alt-stat-group, [aria-label="October in numbers"]').getBoundingClientRect();
102
+ return { first: take?.textContent || '', before: !!take && !!h && !!(take.compareDocumentPosition(h) & Node.DOCUMENT_POSITION_FOLLOWING), larger: !!take && !!h && parseFloat(getComputedStyle(take).fontSize) >= parseFloat(getComputedStyle(h).fontSize), hero: document.querySelectorAll('.alt-stat[data-size="hero"]').length, ratio: (chart.width * chart.height) / (stats.width * stats.height), statsBelow: stats.top >= chart.bottom };
103
+ });
104
+ expect(r.before && r.larger && /^Signups doubled/.test(r.first), `the takeaway heads the chart that leads, before and above every heading after the title (“${r.first.slice(0, 30)}”)`);
105
+ expect(r.hero === 0 && r.statsBelow && r.ratio > 2, `the secondary numbers follow the chart, smaller and grouped (area ×${r.ratio.toFixed(1)})`);
106
+ await page.close();
107
+ }
108
+
109
+ console.log(`Screen layout pattern: ${pass} passed, ${fail} failed`);
110
+ if (fail) process.exitCode = 1;
111
+ await browser.close();
@@ -0,0 +1,72 @@
1
+ import { tokensCss } from '../../scripts/tokens-css.mjs';
2
+ import { chromium } from 'playwright-core';
3
+ import { build } from 'esbuild';
4
+ import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import { resolve, dirname } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ const here = dirname(fileURLToPath(import.meta.url));
9
+ const repo = resolve(here, '../..');
10
+ const scratch = await mkdtemp(resolve(tmpdir(), 'altitude-semantics-'));
11
+ const reactRoot = process.env.ALT_REACT19 ? resolve(repo, 'node_modules') : resolve(repo, 'build/node_modules');
12
+ await build({entryPoints: [resolve(here, 'fixtures/semantics.tsx')], bundle: true, outfile: resolve(scratch, 'fixture.js'), alias: {react: resolve(reactRoot, 'react'), 'react-dom': resolve(reactRoot, 'react-dom')}, define: {'process.env.NODE_ENV': '"development"'}});
13
+ const css = await readFile(resolve(repo, 'project/components/bundle.css'), 'utf8');
14
+ await writeFile(resolve(scratch, 'index.html'), `<style>${tokensCss(resolve(repo, 'project/tokens.json'))}${css}</style><div id="root"></div><script src="fixture.js"></script>`);
15
+ const browser = await chromium.launch(process.env.CHROME ? { executablePath: process.env.CHROME } : {});
16
+ let pass = 0, fail = 0;
17
+ const expect = (condition, label) => { if (condition) { pass++; console.log('PASS', label); } else { fail++; console.error('FAIL', label); } };
18
+ const page = await browser.newPage();
19
+ page.on('pageerror', e => { fail++; console.error('FAIL page error', e.message); });
20
+ await page.goto('file://' + resolve(scratch, 'index.html'));
21
+ await page.waitForSelector('#surface');
22
+ const count = async () => Number(await page.locator('#count').textContent());
23
+ const activate = async (selector, key) => { await page.locator(selector).focus(); await page.keyboard.press(key); };
24
+ await activate('#surface', 'Enter'); expect(await count() === 1, 'AUDIT Surface Enter activates');
25
+ await page.locator('#surface').focus(); await page.keyboard.down('Space'); expect(await count() === 1, 'Surface Space waits for release'); await page.keyboard.up('Space'); expect(await count() === 2, 'AUDIT Surface Space activates');
26
+ expect(await page.locator('#surface').getAttribute('role') === 'button', 'Surface announces button semantics');
27
+ let before = await count();
28
+ await activate('#link', 'Enter'); expect(await count() === before + 1 && await page.locator('#link').getAttribute('role') === null, 'Native link keeps link semantics and single activation');
29
+ before = await count();
30
+ await activate('#native', 'Enter'); await activate('#native', 'Space'); expect(await count() === before + 2, 'Native Surface activates exactly once per key');
31
+ before = await count(); await activate('#nested button', 'Space');
32
+ expect(await count() === before && await page.locator('#nested-count').textContent() === '1', 'Nested control does not activate containing Surface');
33
+ before = await count(); await activate('#cancelled', 'Enter'); expect(await count() === before, 'Consumer preventDefault cancels synthetic activation');
34
+ await activate('#disabled', 'Enter'); await page.locator('#disabled').click({force: true}); expect(await count() === before, 'Disabled Surface does not activate');
35
+ await page.locator('#disabled-link').click({force:true}); await activate('#disabled-link','Enter'); expect(!page.url().includes('#disabled-navigation') && await page.locator('#disabled-link').getAttribute('tabindex') === '-1', 'Disabled native link without callback cannot navigate or remain in tab order');
36
+ expect(await page.locator('#disabled-link').getAttribute('href') === null && await page.locator('#disabled-link').getAttribute('role') === 'link', 'Disabled link has no context-menu/middle-click destination but retains its semantic role');
37
+ const description = () => page.getByLabel('Title', {exact: true}).evaluate(el => ({ids: el.getAttribute('aria-describedby').split(/\s+/), required: el.required, texts: el.getAttribute('aria-describedby').split(/\s+/).map(id => document.getElementById(id)?.textContent)}));
38
+ let desc = await description();
39
+ expect(desc.ids.length === 3 && desc.texts.every(Boolean) && desc.texts.includes('External description') && desc.texts.includes('Helpful hint'), 'AUDIT Field composes existing hint and error IDs without dangling references');
40
+ expect(new Set(desc.ids).size === desc.ids.length && desc.required, 'Field deduplicates IDs and preserves child required');
41
+ await page.locator('#toggle-error').click(); desc = await description(); expect(desc.ids.length === 2 && desc.texts.every(Boolean), 'Field removes stale error reference after recovery');
42
+ expect(!await page.locator('#form').evaluate(el => el.checkValidity()), 'AUDIT Empty required Select fails native form validation');
43
+ await page.locator('#submit').click(); expect(await page.locator('#choice').evaluate(el => document.activeElement === el), 'Invalid submission focuses visible Select trigger');
44
+ expect(await page.locator('#choice').getAttribute('aria-invalid') === 'true' && await page.locator('#choice').evaluate(el => el.getAttribute('aria-describedby')?.split(/\s+/).some(id => document.getElementById(id)?.textContent.includes('Choose a record.'))), 'Required failure announces a linked actionable error on the visible control');
45
+ const choose = async (id, text) => { await page.locator(id).click(); await page.getByRole('option', {name: text, exact: true}).click(); };
46
+ await choose('#choice', 'Alpha'); expect(await page.locator('#form').evaluate(el => el.checkValidity()), 'Selected required Select satisfies native validation');
47
+ expect(await page.locator('#choice').getAttribute('aria-invalid') === null && await page.getByText('Choose a record.',{exact:true}).count() === 0, 'Choosing a valid value clears the native required error and association');
48
+ expect(await page.locator('#form').evaluate(el => new FormData(el).get('choice')) === 'a', 'Select contributes selected value to FormData');
49
+ await choose('#default-choice', 'Beta'); await choose('#controlled-choice', 'Beta');
50
+ await page.locator('#reset').click(); await page.waitForTimeout(30);
51
+ expect(await page.locator('#choice').textContent() === 'Choose…' && await page.locator('#default-choice').textContent() === 'Alpha', 'Reset restores uncontrolled defaults and visible labels');
52
+ expect(await page.locator('#choice').getAttribute('aria-invalid') === null && await page.getByText('Choose a record.',{exact:true}).count() === 0, 'Reset clears attempted-validation feedback even for an empty default');
53
+ await page.locator('#form').evaluate(el=>el.checkValidity()); await page.locator('#reset').click(); await page.waitForTimeout(30); expect(await page.locator('#choice').getAttribute('aria-invalid') === null, 'Reset after a failed validation clears the required error');
54
+ expect(await page.locator('#controlled-choice').textContent() === 'Beta' && await page.locator('#form').evaluate(el => new FormData(el).get('controlled')) === 'b', 'Controlled Select remains synchronized after reset');
55
+ await choose('#choice', 'Alpha');
56
+ await page.locator('#form').evaluate(el => el.addEventListener('reset', event => event.preventDefault(), {once: true}));
57
+ await page.locator('#reset').click(); await page.waitForTimeout(30);
58
+ expect(await page.locator('#choice').textContent() === 'Alpha', 'Cancelled reset retains Select value');
59
+ await page.locator('#reset-controlled').click(); await page.locator('#reset').click(); await page.waitForTimeout(30);
60
+ expect(await page.locator('#controlled-choice').textContent() === 'Alpha' && await page.locator('#form').evaluate(el => new FormData(el).get('controlled')) === 'a', 'Application reset handler can update controlled value and form data together');
61
+ expect(!await page.locator('#unnamed-form').evaluate(el => el.checkValidity()), 'Required unnamed Select with explicit empty option validates');
62
+ await page.locator('#disable-choice').click(); expect(await page.locator('#form').evaluate(el => el.checkValidity() && !new FormData(el).has('choice')), 'Disabled required Select excluded from validation and submission');
63
+ await page.locator('#open-dialog').click(); await page.waitForTimeout(40);
64
+ expect(await page.locator('.alt-dialog').evaluate(el => document.activeElement === el), 'Sparse Dialog begins on panel');
65
+ await page.keyboard.press('Shift+Tab'); expect(await page.locator('.alt-dialog-close').evaluate(el => document.activeElement === el), 'AUDIT Sparse Dialog Shift+Tab wraps to close');
66
+ expect(await page.locator('.alt-stage').evaluate(el => el.inert), 'Dialog isolates background');
67
+ await page.keyboard.press('Escape'); await page.waitForTimeout(400);
68
+ expect(await page.locator('#open-dialog').evaluate(el => document.activeElement === el) && !await page.locator('.alt-stage').evaluate(el => el.inert), 'Dialog restores invoker and releases background');
69
+ await page.locator('#open-empty').click(); await page.waitForTimeout(40); await page.keyboard.press('Tab'); await page.keyboard.press('Shift+Tab');
70
+ expect(await page.locator('.alt-dialog').evaluate(el => document.activeElement === el), 'No-control Dialog keeps both Tab directions on panel');
71
+ await browser.close(); await rm(scratch, {recursive: true, force: true});
72
+ console.log(`${pass} passed, ${fail} failed`); if (fail) process.exitCode = 1;