@exponential_app/eui 0.0.0-stage → 0.2.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 (274) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +54 -0
  3. package/README.md +377 -2
  4. package/catalog/a11y.json +705 -0
  5. package/catalog/basic-map.json +104 -0
  6. package/catalog/code.json +58 -0
  7. package/catalog/core.catalog.json +5190 -0
  8. package/catalog/core.schema.json +24542 -0
  9. package/catalog/host.json +95 -0
  10. package/catalog/layout.json +20 -0
  11. package/catalog/limits.json +9 -0
  12. package/catalog/locale.json +48 -0
  13. package/catalog/macros.json +4152 -0
  14. package/catalog/recipes.json +64 -0
  15. package/catalog/strings.json +84 -0
  16. package/catalog/style.json +180 -0
  17. package/catalog/theme.schema.json +47754 -0
  18. package/catalog/tokens.json +47 -0
  19. package/conformance/README.md +218 -0
  20. package/conformance/baseline.ts +101 -0
  21. package/conformance/browser-page.ts +146 -0
  22. package/conformance/browser-suites.ts +699 -0
  23. package/conformance/compare.ts +201 -0
  24. package/conformance/dom.ts +97 -0
  25. package/conformance/dump.ts +163 -0
  26. package/conformance/fonts.json +157 -0
  27. package/conformance/manifest.json +233 -0
  28. package/conformance/parity-compare.ts +191 -0
  29. package/conformance/parity-report.schema.json +73 -0
  30. package/conformance/parity.ts +412 -0
  31. package/conformance/pixel.ts +177 -0
  32. package/conformance/primitives-page.ts +49 -0
  33. package/conformance/report.schema.json +66 -0
  34. package/conformance/run.ts +118 -0
  35. package/conformance/voice-wave-child.ts +102 -0
  36. package/dist/chunks/index-5fmaa0xq.js +8384 -0
  37. package/dist/chunks/index-5fmaa0xq.js.map +10 -0
  38. package/dist/chunks/index-62yzr94z.js +2784 -0
  39. package/dist/chunks/index-62yzr94z.js.map +10 -0
  40. package/dist/chunks/index-bg2jsgjq.js +8541 -0
  41. package/dist/chunks/index-bg2jsgjq.js.map +15 -0
  42. package/dist/chunks/index-fy7y2nz8.js +186 -0
  43. package/dist/chunks/index-fy7y2nz8.js.map +9 -0
  44. package/dist/chunks/index-gtk2gwsb.js +4555 -0
  45. package/dist/chunks/index-gtk2gwsb.js.map +31 -0
  46. package/dist/chunks/index-t6qz1770.js +95 -0
  47. package/dist/chunks/index-t6qz1770.js.map +11 -0
  48. package/dist/conformance.js +230462 -0
  49. package/dist/conformance.js.map +10 -0
  50. package/dist/entries/prompt.js +251 -0
  51. package/dist/entries/prompt.js.map +11 -0
  52. package/dist/entries/schema.js +1725 -0
  53. package/dist/entries/schema.js.map +11 -0
  54. package/dist/host/index.js +194 -0
  55. package/dist/host/index.js.map +9 -0
  56. package/dist/index.js +3851 -0
  57. package/dist/index.js.map +30 -0
  58. package/dist/types/src/a11y.d.ts +21 -0
  59. package/dist/types/src/animation.d.ts +76 -0
  60. package/dist/types/src/basic-map.d.ts +38 -0
  61. package/dist/types/src/builder.d.ts +50 -0
  62. package/dist/types/src/catalog.d.ts +54 -0
  63. package/dist/types/src/catalog.described.d.ts +33 -0
  64. package/dist/types/src/catalog.generated.d.ts +904 -0
  65. package/dist/types/src/chart.d.ts +46 -0
  66. package/dist/types/src/code.d.ts +44 -0
  67. package/dist/types/src/color.d.ts +20 -0
  68. package/dist/types/src/conformance.d.ts +39 -0
  69. package/dist/types/src/connector/app.d.ts +14 -0
  70. package/dist/types/src/connector/connector.d.ts +77 -0
  71. package/dist/types/src/connector/index.d.ts +4 -0
  72. package/dist/types/src/css.d.ts +27 -0
  73. package/dist/types/src/define.d.ts +66 -0
  74. package/dist/types/src/direction.d.ts +16 -0
  75. package/dist/types/src/dynamic.d.ts +161 -0
  76. package/dist/types/src/entries/prompt.d.ts +11 -0
  77. package/dist/types/src/entries/schema.d.ts +8 -0
  78. package/dist/types/src/expr.d.ts +71 -0
  79. package/dist/types/src/extension.d.ts +4 -0
  80. package/dist/types/src/format.d.ts +112 -0
  81. package/dist/types/src/geometry.d.ts +40 -0
  82. package/dist/types/src/host/contract.d.ts +225 -0
  83. package/dist/types/src/host/decoders.d.ts +61 -0
  84. package/dist/types/src/host/index.d.ts +15 -0
  85. package/dist/types/src/host/package.d.ts +102 -0
  86. package/dist/types/src/host/policy.d.ts +107 -0
  87. package/dist/types/src/host/record-store.d.ts +103 -0
  88. package/dist/types/src/host/records.d.ts +45 -0
  89. package/dist/types/src/host/router.d.ts +38 -0
  90. package/dist/types/src/host/runtime.d.ts +213 -0
  91. package/dist/types/src/host/signature.d.ts +52 -0
  92. package/dist/types/src/host/sources.d.ts +22 -0
  93. package/dist/types/src/host/tools.d.ts +42 -0
  94. package/dist/types/src/host/transports.d.ts +109 -0
  95. package/dist/types/src/index.d.ts +70 -0
  96. package/dist/types/src/layout.d.ts +20 -0
  97. package/dist/types/src/limits.d.ts +21 -0
  98. package/dist/types/src/list.d.ts +133 -0
  99. package/dist/types/src/locale.d.ts +22 -0
  100. package/dist/types/src/macros.d.ts +29 -0
  101. package/dist/types/src/overlay.d.ts +27 -0
  102. package/dist/types/src/prompt.d.ts +44 -0
  103. package/dist/types/src/qr-code.d.ts +30 -0
  104. package/dist/types/src/recipes.d.ts +17 -0
  105. package/dist/types/src/reducer.d.ts +41 -0
  106. package/dist/types/src/resizable.d.ts +38 -0
  107. package/dist/types/src/schema.d.ts +7 -0
  108. package/dist/types/src/strings.d.ts +13 -0
  109. package/dist/types/src/style.d.ts +201 -0
  110. package/dist/types/src/test-preload.d.ts +1 -0
  111. package/dist/types/src/text-clusters.d.ts +11 -0
  112. package/dist/types/src/text-shimmer.d.ts +24 -0
  113. package/dist/types/src/theme-types.d.ts +162 -0
  114. package/dist/types/src/theme.d.ts +75 -0
  115. package/dist/types/src/themes.d.ts +22 -0
  116. package/dist/types/src/tree-guides.d.ts +17 -0
  117. package/dist/types/src/types.d.ts +320 -0
  118. package/dist/types/src/validate.d.ts +31 -0
  119. package/dist/types/src/voice-wave.d.ts +181 -0
  120. package/dist/types/src/web/base-css.d.ts +1 -0
  121. package/dist/types/src/web/box-css.d.ts +63 -0
  122. package/dist/types/src/web/coerce.d.ts +32 -0
  123. package/dist/types/src/web/custom-icons.generated.d.ts +4 -0
  124. package/dist/types/src/web/data.d.ts +107 -0
  125. package/dist/types/src/web/dates.d.ts +12 -0
  126. package/dist/types/src/web/form.d.ts +44 -0
  127. package/dist/types/src/web/host-types.d.ts +126 -0
  128. package/dist/types/src/web/index.d.ts +35 -0
  129. package/dist/types/src/web/inputs.d.ts +55 -0
  130. package/dist/types/src/web/list.d.ts +16 -0
  131. package/dist/types/src/web/listbox.d.ts +14 -0
  132. package/dist/types/src/web/markdown.d.ts +96 -0
  133. package/dist/types/src/web/media.d.ts +63 -0
  134. package/dist/types/src/web/parts.d.ts +4 -0
  135. package/dist/types/src/web/primitives/alert-dialog.d.ts +10 -0
  136. package/dist/types/src/web/primitives/alert.d.ts +9 -0
  137. package/dist/types/src/web/primitives/avatar-color.d.ts +2 -0
  138. package/dist/types/src/web/primitives/avatar.d.ts +13 -0
  139. package/dist/types/src/web/primitives/badge.d.ts +10 -0
  140. package/dist/types/src/web/primitives/breadcrumb.d.ts +10 -0
  141. package/dist/types/src/web/primitives/button.d.ts +6 -0
  142. package/dist/types/src/web/primitives/calendar.d.ts +43 -0
  143. package/dist/types/src/web/primitives/card.d.ts +9 -0
  144. package/dist/types/src/web/primitives/checkbox.d.ts +5 -0
  145. package/dist/types/src/web/primitives/choice-tiles.d.ts +32 -0
  146. package/dist/types/src/web/primitives/cn.d.ts +3 -0
  147. package/dist/types/src/web/primitives/code-block.d.ts +24 -0
  148. package/dist/types/src/web/primitives/command.d.ts +134 -0
  149. package/dist/types/src/web/primitives/context-ring.d.ts +24 -0
  150. package/dist/types/src/web/primitives/dialog-arms.d.ts +6 -0
  151. package/dist/types/src/web/primitives/dialog.d.ts +24 -0
  152. package/dist/types/src/web/primitives/disclosure-header.d.ts +5 -0
  153. package/dist/types/src/web/primitives/dock.d.ts +42 -0
  154. package/dist/types/src/web/primitives/dropdown-menu.d.ts +14 -0
  155. package/dist/types/src/web/primitives/empty-state.d.ts +15 -0
  156. package/dist/types/src/web/primitives/glass-card.d.ts +4 -0
  157. package/dist/types/src/web/primitives/hover-card.d.ts +7 -0
  158. package/dist/types/src/web/primitives/icon-disc.d.ts +12 -0
  159. package/dist/types/src/web/primitives/index.d.ts +50 -0
  160. package/dist/types/src/web/primitives/input.d.ts +5 -0
  161. package/dist/types/src/web/primitives/kbd.d.ts +4 -0
  162. package/dist/types/src/web/primitives/label.d.ts +3 -0
  163. package/dist/types/src/web/primitives/measure.d.ts +16 -0
  164. package/dist/types/src/web/primitives/menu-surface.d.ts +17 -0
  165. package/dist/types/src/web/primitives/meter.d.ts +21 -0
  166. package/dist/types/src/web/primitives/popover.d.ts +3 -0
  167. package/dist/types/src/web/primitives/progress-ring.d.ts +5 -0
  168. package/dist/types/src/web/primitives/progress.d.ts +8 -0
  169. package/dist/types/src/web/primitives/scroll-area.d.ts +49 -0
  170. package/dist/types/src/web/primitives/segmented-control.d.ts +5 -0
  171. package/dist/types/src/web/primitives/select.d.ts +47 -0
  172. package/dist/types/src/web/primitives/separator.d.ts +3 -0
  173. package/dist/types/src/web/primitives/sheet-chrome.d.ts +4 -0
  174. package/dist/types/src/web/primitives/sheet.d.ts +20 -0
  175. package/dist/types/src/web/primitives/skeleton.d.ts +3 -0
  176. package/dist/types/src/web/primitives/spinner.d.ts +3 -0
  177. package/dist/types/src/web/primitives/switch.d.ts +4 -0
  178. package/dist/types/src/web/primitives/table.d.ts +11 -0
  179. package/dist/types/src/web/primitives/tabs.d.ts +13 -0
  180. package/dist/types/src/web/primitives/textarea.d.ts +3 -0
  181. package/dist/types/src/web/primitives/toast.d.ts +163 -0
  182. package/dist/types/src/web/primitives/tooltip.d.ts +3 -0
  183. package/dist/types/src/web/primitives/truncate.d.ts +7 -0
  184. package/dist/types/src/web/primitives/typeahead-portal.d.ts +12 -0
  185. package/dist/types/src/web/primitives/voice-wave.d.ts +50 -0
  186. package/dist/types/src/web/round5.d.ts +107 -0
  187. package/dist/types/src/web/share.d.ts +6 -0
  188. package/dist/types/src/web/table.d.ts +20 -0
  189. package/dist/types/src/web/theme-css.d.ts +69 -0
  190. package/dist/types/src/web/urls.d.ts +21 -0
  191. package/dist/web/index.js +2565 -0
  192. package/dist/web/index.js.map +28 -0
  193. package/dist/web/primitives/index.js +1476 -0
  194. package/dist/web/primitives/index.js.map +57 -0
  195. package/docs/components.generated.json +6783 -0
  196. package/docs/package-format.md +182 -0
  197. package/docs/round-1-contract.md +679 -0
  198. package/docs/round-2-contract.md +476 -0
  199. package/docs/round-3-contract.md +174 -0
  200. package/docs/round-5-contract.md +268 -0
  201. package/docs/themes.generated.json +3647 -0
  202. package/fixtures/animations.json +2890 -0
  203. package/fixtures/bench-list.json +58 -0
  204. package/fixtures/bind-time.json +54734 -0
  205. package/fixtures/catalog-basic-map.json +1692 -0
  206. package/fixtures/catalog-components.json +10293 -0
  207. package/fixtures/catalog-extension.json +620 -0
  208. package/fixtures/catalog-macros.json +51805 -0
  209. package/fixtures/code-tokens.json +1993 -0
  210. package/fixtures/conformance-baseline.json +59016 -0
  211. package/fixtures/conformance-cases.json +49 -0
  212. package/fixtures/conformance-known.json +2111 -0
  213. package/fixtures/control-geometry.json +5129 -0
  214. package/fixtures/dashboard.data.json +74 -0
  215. package/fixtures/dashboard.json +781 -0
  216. package/fixtures/demo-surface.json +47 -0
  217. package/fixtures/format.json +870 -0
  218. package/fixtures/host-package.json +1262 -0
  219. package/fixtures/host-policy.json +1209 -0
  220. package/fixtures/host-router.json +2034 -0
  221. package/fixtures/host-transport.json +590 -0
  222. package/fixtures/interactions-round1.json +1058 -0
  223. package/fixtures/kitchen-sink.expanded.json +11972 -0
  224. package/fixtures/kitchen-sink.json +3140 -0
  225. package/fixtures/layout-geometry-round1.json +7278 -0
  226. package/fixtures/layout-geometry.json +2072 -0
  227. package/fixtures/overlay-geometry.json +272 -0
  228. package/fixtures/pointer.json +89 -0
  229. package/fixtures/primitive-specimens.json +59 -0
  230. package/fixtures/prompt-budget.json +33 -0
  231. package/fixtures/qr-code.json +480 -0
  232. package/fixtures/resizable.json +714 -0
  233. package/fixtures/round4-contract.json +2985 -0
  234. package/fixtures/specimens.json +11168 -0
  235. package/fixtures/style-conditions.json +336 -0
  236. package/fixtures/template-items.json +2215 -0
  237. package/fixtures/text-clusters.json +65 -0
  238. package/fixtures/text-direction.json +309 -0
  239. package/fixtures/text-shimmer.json +96 -0
  240. package/fixtures/theme-extends.json +573 -0
  241. package/fixtures/theme-invalid.json +1300 -0
  242. package/fixtures/theme-recipes.json +61762 -0
  243. package/fixtures/theme-resolved.json +25969 -0
  244. package/fixtures/toast-stack.json +215 -0
  245. package/fixtures/tree-guides.json +224 -0
  246. package/fixtures/virtual-list.json +733 -0
  247. package/fixtures/voice-wave.json +19236 -0
  248. package/generated/ExponentialUICatalog.generated.kt +67 -0
  249. package/generated/ExponentialUICatalog.generated.swift +67 -0
  250. package/generated/ExponentialUIThemes.generated.kt +23 -0
  251. package/generated/ExponentialUIThemes.generated.swift +23 -0
  252. package/generated/catalog.descriptions.generated.rs +6 -0
  253. package/generated/catalog.generated.rs +80 -0
  254. package/generated/catalog.themes.generated.rs +34 -0
  255. package/generated/core.descriptions.json +2726 -0
  256. package/generated/core.runtime.json +3944 -0
  257. package/package.json +87 -4
  258. package/themes/exponential.recipes.json +49 -0
  259. package/themes/exponential.theme.json +768 -0
  260. package/themes/neutral.theme.json +6178 -0
  261. package/themes/orange.theme.json +43 -0
  262. package/themes/playful.theme.json +219 -0
  263. package/vendor/a2ui/LICENSE.txt +203 -0
  264. package/vendor/a2ui/README.md +27 -0
  265. package/vendor/a2ui/v0_9/basic_catalog.json +1387 -0
  266. package/vendor/a2ui/v0_9/basic_catalog_rules.txt +5 -0
  267. package/vendor/a2ui/v0_9/client_capabilities.json +97 -0
  268. package/vendor/a2ui/v0_9/client_to_server.json +104 -0
  269. package/vendor/a2ui/v0_9/common_types.json +315 -0
  270. package/vendor/a2ui/v0_9/server_to_client.json +132 -0
  271. package/vendor/json-render/LICENSE.txt +201 -0
  272. package/vendor/json-render/README.md +13 -0
  273. package/vendor/nayuki-qr/LICENSE.txt +20 -0
  274. package/vendor/nayuki-qr/README.md +10 -0
@@ -0,0 +1,699 @@
1
+ // FEED-128 (from VAPP-91's React runner): the SHARED browser conformance
2
+ // runner of every WEB renderer. Every suite of `manifest.json`: the pure
3
+ // ones through the TS reference (the core + the shared web layer
4
+ // `@exponential_app/eui/web`) plus the renderer's own logic (`options.logic`),
5
+ // the painted ones in headless Chromium through the renderer's HARNESS
6
+ // (browser-page.ts says the page protocol). A plain async function (the core
7
+ // carries no vitest): it returns the report and the blocks that threw; the
8
+ // renderer's thin test writes the report and asserts `checkReport`.
9
+
10
+ import { readFileSync } from "node:fs"
11
+ import { join } from "node:path"
12
+ import type { Browser } from "playwright"
13
+ import {
14
+ A2UI_BASIC_CATALOG_ID,
15
+ BUILTIN_THEMES,
16
+ BUILTIN_THEME_IDS,
17
+ CORE_CATALOG_ID,
18
+ HostRouter,
19
+ JsonlDecoder,
20
+ MODES,
21
+ SseDecoder,
22
+ UNKNOWN_COMPONENT,
23
+ builtinTheme,
24
+ checkGeometry,
25
+ clientCapabilities,
26
+ combineDecisions,
27
+ decideFunction,
28
+ decideUrl,
29
+ defineExtension,
30
+ loadTheme,
31
+ THEME_SCHEMA_ID,
32
+ mcpActionCall,
33
+ mediaRequest,
34
+ messagesFromMcpResult,
35
+ parseSource,
36
+ placeOverlay,
37
+ preorder,
38
+ reduceNested,
39
+ reduceSurface,
40
+ resolveRecipe,
41
+ supportedCatalogIds,
42
+ tryLoadTheme,
43
+ validatePackage,
44
+ validateRecord,
45
+ sourceWithParams,
46
+ DEFAULT_STRINGS,
47
+ animationFrame,
48
+ displayString,
49
+ intlFormatter,
50
+ scrollOffsetForIndex,
51
+ tableRowKeys,
52
+ tokenizeCode,
53
+ writePointer,
54
+ keyframesCss,
55
+ resolveStyleValues,
56
+ styleToCss,
57
+ } from "../src/index"
58
+ import type { ChildTemplate, Decoded, ExtensionDef, FlatComponent, ModeName, NestedNode, UiNode, AppPackage } from "../src/index"
59
+ import { conformanceManifest } from "../src/conformance"
60
+ import type { ConformanceReport } from "../src/conformance"
61
+ import { BASE_CSS, CLIENT_FUNCTIONS, LITERAL_ROWS_ROOT, absolutePath, bindTree, getPointer, isBinding, memoFormatter, resolveNodeProps, resolveValue, setPointer, type DataModel, type ResolveContext } from "../src/web/index"
62
+ import { launchChromium, openPage, pageUrl, type Query, type WebHarness } from "./browser-page"
63
+
64
+ /** The renderer-side functions the pure suites run (everything else is the
65
+ * core or the shared web layer). `Ctx` = the renderer's surface context;
66
+ * the runner hands it a STUB carrying only what these functions read:
67
+ * `templateItems` gets `{ data, templateNode }`, `runNodeAction` gets
68
+ * `{ surfaceId, data, functions, openUrl, locale, strings, formatter,
69
+ * setData(pointer, value), host: { onAction, onFunctionCall } }`. */
70
+ export interface BrowserSuiteLogic<Ctx = never> {
71
+ /** A templated node's items in `scope` (`null` = no template / no list). */
72
+ templateItems(ctx: Ctx, node: UiNode, scope: string): { items: { key: string; path: string; index: number }[] } | null | undefined
73
+ /** A template item's instance suffix (`${instance}.<segment of key>`). */
74
+ itemInstance(instance: string, key: string): string
75
+ /** Runs the node's `event` action (`set` writes through `ctx.setData`, any
76
+ * other function reaches `ctx.host.onFunctionCall` / `onAction`). */
77
+ runNodeAction(ctx: Ctx, node: UiNode, domId: string, scope: string, event: string): unknown
78
+ }
79
+
80
+ export interface BrowserSuitesOptions<Ctx = never> {
81
+ /** The report's identity: package name, `web`, package version. */
82
+ renderer: { name: string; platform: string; version: string }
83
+ /** Serves the renderer's harness bundle on a loopback port. */
84
+ harness: WebHarness
85
+ logic: BrowserSuiteLogic<Ctx>
86
+ /** Extra Chromium flags (default none). */
87
+ chromiumArgs?: string[]
88
+ /** Progress lines (default silent). */
89
+ log?: (line: string) => void
90
+ }
91
+
92
+ export interface BrowserSuitesResult {
93
+ report: ConformanceReport
94
+ /** `<block>: <error>` per block that threw (its cases so far stay recorded). */
95
+ failures: string[]
96
+ }
97
+
98
+ /** The harness probes the painted suites read (typed here, not as a global
99
+ * `Window` augmentation: each renderer's harness declares its own). */
100
+ interface XuiProbes {
101
+ __xuiFrames?: (rootId?: string) => { id: string; x: number; y: number; w: number; h: number }[]
102
+ __xuiOverlay?: () => { x: number; y: number; w: number; h: number; side: string } | null
103
+ __xuiControls?: () => Record<string, Record<string, number> | null>
104
+ __xuiHandle?: () => { scrollToIndex(id: string, index: number, align: `start` | `center` | `end` | `nearest`): void } | null
105
+ __xuiMount?: (tree: NestedNode, options?: Record<string, unknown>) => Promise<void>
106
+ __xuiVList?: (c: never) => Promise<{ scroll(at: number): Promise<unknown>; scrollToIndex(index: number, align: `start` | `center` | `end` | `nearest`): Promise<number> }>
107
+ }
108
+
109
+ const fixture = (name: string) => JSON.parse(readFileSync(join(import.meta.dirname, `..`, `fixtures`, name), `utf8`))
110
+ const canon = (v: unknown) => JSON.stringify(v)
111
+
112
+ function walk(node: UiNode, visit: (n: UiNode) => void): void {
113
+ visit(node)
114
+ for (const slot of Object.values(node.slots ?? {})) walk(slot, visit)
115
+ node.children.forEach((c) => walk(c, visit))
116
+ }
117
+
118
+ export async function runBrowserSuites<Ctx = never>(options: BrowserSuitesOptions<Ctx>): Promise<BrowserSuitesResult> {
119
+ const { logic } = options
120
+ type Suite = { cases: number; passed: number; failed: string[] }
121
+ const suites: Record<string, Suite> = {}
122
+ const failures: string[] = []
123
+ function record(suite: string, name: string, ok: boolean | string | (() => boolean | string), detail = ``) {
124
+ const s = (suites[suite] ??= { cases: 0, passed: 0, failed: [] })
125
+ s.cases += 1
126
+ let verdict: boolean | string
127
+ try {
128
+ verdict = typeof ok === `function` ? ok() : ok
129
+ } catch (e) {
130
+ verdict = e instanceof Error ? e.message : String(e)
131
+ }
132
+ if (verdict === true) s.passed += 1
133
+ else s.failed.push(`${name}${typeof verdict === `string` ? `: ${verdict}` : detail ? `: ${detail}` : ``}`)
134
+ }
135
+ const block = async (name: string, fn: () => unknown) => {
136
+ const started = Date.now()
137
+ try {
138
+ await fn()
139
+ } catch (e) {
140
+ failures.push(`${name}: ${e instanceof Error ? (e.stack ?? e.message) : String(e)}`)
141
+ }
142
+ options.log?.(`${name}: ${((Date.now() - started) / 1000).toFixed(1)} s`)
143
+ }
144
+
145
+ const served = await options.harness.start()
146
+ let browser: Browser | undefined
147
+ try {
148
+ browser = await launchChromium(options.chromiumArgs ?? [])
149
+ const b = browser
150
+ const url = (query: Query) => pageUrl(served.baseUrl, query)
151
+ const open = (query: Query, viewport?: { width: number; height: number }) => openPage(b, served.baseUrl, query, viewport)
152
+ await runAll(b, url, open)
153
+ } finally {
154
+ await browser?.close()
155
+ await served.stop()
156
+ }
157
+ const report: ConformanceReport = { renderer: options.renderer.name, platform: options.renderer.platform, version: options.renderer.version, conformanceVersion: conformanceManifest().version, suites }
158
+ return { report, failures }
159
+
160
+ async function runAll(browser: Browser, url: (q: Query) => string, open: (q: Query, viewport?: { width: number; height: number }) => ReturnType<typeof openPage>) {
161
+ await block(`catalog: every case reduces cleanly and paints without Unknown`, async () => {
162
+ const cases = fixture(`catalog-components.json`).cases as { name: string; node: NestedNode }[]
163
+ const page = await open({ view: `catalog`, theme: `exponential`, mode: `light` }, { width: 1400, height: 900 })
164
+ // Every case is painted once its last surface (a toast in its layer) is.
165
+ await page.waitForFunction(() => document.querySelector(`[data-xui-surface="Toast-example"] [data-xui-id]`) !== null, undefined, { timeout: 60_000 })
166
+ const painted = (await page.evaluate(() => Array.from(document.querySelectorAll(`.xui-surface`)).map((s) => ({ id: s.getAttribute(`data-xui-surface`), nodes: s.querySelectorAll(`[data-xui-id]`).length, unknown: s.querySelectorAll(`.xui-Unknown-root, [data-xui-component="Unknown"]`).length })))) as { id: string; nodes: number; unknown: number }[]
167
+ await page.context().close()
168
+ const byId = new Map(painted.map((p) => [p.id, p]))
169
+ for (const c of cases)
170
+ record(`catalog`, c.name, () => {
171
+ const { issues } = reduceNested(c.node, { catalogId: CORE_CATALOG_ID })
172
+ if (issues.length) return `issues ${canon(issues)}`
173
+ const p = byId.get(c.name.replace(/[^a-z0-9]/gi, `-`))
174
+ if (!p) return `not painted`
175
+ if (p.unknown) return `Unknown painted`
176
+ // A closed overlay (round 1: `Toast/open=false`) paints nothing at all.
177
+ return p.nodes > 0 || (c.node.props as { open?: unknown } | undefined)?.open === false || `no nodes`
178
+ })
179
+ })
180
+
181
+ await block(`macros, basic-map, extension`, () => {
182
+ for (const c of fixture(`catalog-macros.json`).cases as { name: string; input: NestedNode; expected: UiNode }[])
183
+ record(`macros`, c.name, () => canon(reduceNested(c.input, { catalogId: CORE_CATALOG_ID }).root) === canon(c.expected))
184
+ for (const c of fixture(`catalog-basic-map.json`).cases as { name: string; components: FlatComponent[]; expected: unknown }[])
185
+ record(`basic-map`, c.name, () => canon(reduceSurface(c.components, { catalogId: A2UI_BASIC_CATALOG_ID })) === canon(c.expected))
186
+ const ext = fixture(`catalog-extension.json`)
187
+ const extension = defineExtension(ext.extension as ExtensionDef)
188
+ for (const c of ext.cases as { name: string; catalogId: string; components: FlatComponent[]; expected: unknown }[])
189
+ record(`extension`, c.name, () => canon(reduceSurface(c.components, { catalogId: c.catalogId, extensions: [extension] })) === canon(c.expected))
190
+ })
191
+
192
+ await block(`themes`, () => {
193
+ const resolved = fixture(`theme-resolved.json`).themes as Record<string, unknown>
194
+ for (const [id, expected] of Object.entries(resolved)) record(`theme-resolved`, id, () => canon(builtinTheme(id)) === canon(expected))
195
+ for (const c of fixture(`theme-recipes.json`).cases as { component: string; part: string; props: Record<string, unknown>; visuals: Record<string, Record<string, Record<string, unknown>>> }[])
196
+ record(`theme-recipes`, `${c.component}/${c.part} ${canon(c.props)}`, () => {
197
+ for (const [themeId, byMode] of Object.entries(c.visuals))
198
+ for (const [mode, byState] of Object.entries(byMode))
199
+ for (const [state, style] of Object.entries(byState)) {
200
+ const states = state === `default` ? [] : [state]
201
+ if (canon(resolveRecipe(builtinTheme(themeId), { component: c.component, part: c.part, props: c.props, states }, mode as ModeName)) !== canon(style)) return `${themeId}/${mode}/${state}`
202
+ }
203
+ return true
204
+ })
205
+ for (const c of fixture(`theme-extends.json`).cases as { name: string; theme: never; expected: { chain: string[]; probes: { component: string; part: string; props: Record<string, unknown>; states: string[]; mode: ModeName; style: unknown }[] } }[])
206
+ record(`theme-extends`, c.name, () => {
207
+ const theme = loadTheme(c.theme, { themes: BUILTIN_THEMES })
208
+ if (canon(theme.chain) !== canon(c.expected.chain)) return `chain`
209
+ return c.expected.probes.every((p) => canon(resolveRecipe(theme, { component: p.component, part: p.part, props: p.props, states: p.states }, p.mode)) === canon(p.style))
210
+ })
211
+ for (const c of fixture(`theme-invalid.json`).cases as { name: string; theme: unknown; issues: unknown }[])
212
+ record(`theme-invalid`, c.name, () => {
213
+ const { theme, issues } = tryLoadTheme(c.theme, { themes: BUILTIN_THEMES })
214
+ return theme === null && canon(issues) === canon(c.issues)
215
+ })
216
+ })
217
+
218
+ await block(`control-geometry: the painted controls keep the theme's box`, async () => {
219
+ const themes = fixture(`control-geometry.json`).themes as Record<string, Record<string, { part: string; cases: Record<string, { geometry: Record<string, number> }> }>>
220
+ for (const [themeId, byComponent] of Object.entries(themes)) {
221
+ const page = await open({ view: `controls`, theme: themeId }, { width: 1400, height: 900 })
222
+ const boxes = (await page.evaluate(() => (window as unknown as XuiProbes).__xuiControls!())) as Record<string, Record<string, number> | null>
223
+ await page.context().close()
224
+ for (const [component, entry] of Object.entries(byComponent))
225
+ for (const [name, c] of Object.entries(entry.cases))
226
+ record(`control-geometry`, `${themeId} ${component} ${name}`, () => {
227
+ const box = boxes[`${component} ${name}`]
228
+ if (!box) return `part ${entry.part} not painted`
229
+ const issues = checkGeometry(c.geometry, box, 0.5)
230
+ return issues.length === 0 || issues.map((i) => `${i.key} ${i.expected}≠${i.actual}`).join(`, `)
231
+ })
232
+ }
233
+ })
234
+
235
+ await block(`layout: frames within 1 px of taffy`, async () => {
236
+ const geometry = fixture(`layout-geometry.json`)
237
+ const tolerance = geometry.measure.tolerancePx as number
238
+ for (const [name, c] of Object.entries(geometry.cases as Record<string, { frames: { id: string; x: number; y: number; w: number; h: number }[] }>)) {
239
+ const page = await open({ view: `geometry`, case: name })
240
+ const frames = (await page.evaluate(() => (window as unknown as XuiProbes).__xuiFrames!())) as { id: string; x: number; y: number; w: number; h: number }[]
241
+ await page.context().close()
242
+ const byId = new Map(frames.map((f) => [f.id, f]))
243
+ record(`layout`, name, () => {
244
+ if (frames.length !== c.frames.length) return `${frames.length} frames, want ${c.frames.length}`
245
+ for (const t of c.frames) {
246
+ const b = byId.get(t.id)
247
+ if (!b) return `${t.id} missing`
248
+ for (const axis of [`x`, `y`, `w`, `h`] as const) if (Math.abs(b[axis] - t[axis]) > tolerance) return `${t.id}.${axis}`
249
+ }
250
+ return true
251
+ })
252
+ }
253
+ })
254
+
255
+ await block(`overlay: placement, side and flip`, async () => {
256
+ const overlays = fixture(`overlay-geometry.json`)
257
+ const cases = overlays.cases as { name: string; viewport: { width: number; height: number }; anchor: { x: number; y: number; width: number; height: number }; side: `top`; expected: { side: string } }[]
258
+ for (const [i, c] of cases.entries()) {
259
+ const page = await open({ view: `overlay`, case: i }, { width: c.viewport.width, height: c.viewport.height })
260
+ await page.waitForSelector(`[data-xui-overlay="Popover"]`)
261
+ await page.waitForTimeout(150)
262
+ const got = (await page.evaluate(() => (window as unknown as XuiProbes).__xuiOverlay!())) as { x: number; y: number; w: number; h: number; side: string } | null
263
+ await page.context().close()
264
+ record(`overlay`, c.name, () => {
265
+ if (!got) return `not painted`
266
+ const want = placeOverlay(c.anchor, { width: got.w, height: got.h }, c.viewport, { side: c.side })
267
+ return got.side === c.expected.side && want.side === c.expected.side && Math.abs(got.x - want.x) <= overlays.tolerancePx && Math.abs(got.y - want.y) <= overlays.tolerancePx
268
+ })
269
+ }
270
+ })
271
+
272
+ await block(`replay: the kitchen sink in every built-in theme and mode`, async () => {
273
+ const expanded = fixture(`kitchen-sink.expanded.json`)
274
+ const sink = fixture(`kitchen-sink.json`) as NestedNode
275
+ const order = preorder(expanded.root as UiNode)
276
+ for (const themeId of BUILTIN_THEME_IDS)
277
+ for (const mode of MODES) {
278
+ const page = await open({ view: `kitchen-sink`, theme: themeId, mode })
279
+ const painted = (await page.evaluate(() => Array.from(document.querySelectorAll(`[data-xui-surface="ks"] [data-xui-id]`)).map((e) => (e as HTMLElement).dataset.xuiId!))) as string[]
280
+ await page.context().close()
281
+ record(`replay`, `${themeId}/${mode}`, () => {
282
+ const reduced = reduceNested(sink, { catalogId: CORE_CATALOG_ID })
283
+ if (canon(reduced.root) !== canon(expanded.root) || canon(reduced.issues) !== canon(expanded.issues)) return `reduced tree differs`
284
+ let unknown = false
285
+ walk(reduced.root, (n) => (unknown ||= n.component === UNKNOWN_COMPONENT))
286
+ if (unknown) return `Unknown`
287
+ // DOM order = pre-order (the a11y order) for every painted node.
288
+ const idx = painted.filter((id) => order.includes(id)).map((id) => order.indexOf(id))
289
+ if (idx.length < 50) return `only ${idx.length} nodes painted`
290
+ return idx.every((v, i) => i === 0 || v > idx[i - 1]!) || `DOM order is not the pre-order`
291
+ })
292
+ }
293
+ })
294
+
295
+ await block(`host-transport, host-policy, host-router`, () => {
296
+ const feed = (dec: { push(c: string): Decoded; end(): Decoded }, chunks: string[]) => {
297
+ const out: Decoded = { messages: [], issues: [] }
298
+ for (const d of [...chunks.map((c) => dec.push(c)), dec.end()]) {
299
+ out.messages.push(...d.messages)
300
+ out.issues.push(...d.issues)
301
+ }
302
+ return out
303
+ }
304
+ const t = fixture(`host-transport.json`)
305
+ for (const c of t.jsonl) record(`host-transport`, `jsonl: ${c.name}`, () => canon(feed(new JsonlDecoder(), c.chunks)) === canon(c.expected))
306
+ for (const c of t.sse) record(`host-transport`, `sse: ${c.name}`, () => canon(feed(new SseDecoder(), c.chunks)) === canon(c.expected))
307
+ for (const c of t.mcp) record(`host-transport`, `mcp: ${c.name}`, () => canon(messagesFromMcpResult(c.result)) === canon(c.expected))
308
+ for (const c of t.mcpAction) record(`host-transport`, `mcpAction: ${c.name}`, () => canon(mcpActionCall(c.message, c.tool)) === canon(c.expected))
309
+ const p = fixture(`host-policy.json`)
310
+ for (const c of p.functions) record(`host-policy`, `function: ${c.name}`, () => decideFunction(c.policy, c.fn, c.registered) === c.expected)
311
+ for (const c of p.combine) record(`host-policy`, `combine: ${c.a}+${c.b}`, () => combineDecisions(c.a, c.b) === c.expected)
312
+ for (const c of p.urls) record(`host-policy`, `url: ${c.name}`, () => canon(decideUrl(c.policy, c.url)) === canon(c.expected))
313
+ for (const c of p.media) record(`host-policy`, `media: ${c.name}`, () => canon(mediaRequest(c.url, c.options)) === canon(c.expected))
314
+ for (const c of p.sources) record(`host-policy`, `source: ${c.uri}`, () => canon(parseSource(c.uri)) === canon(c.expected))
315
+ for (const c of p.sourceParams) record(`host-policy`, `sourceParams: ${c.expected}`, () => sourceWithParams(c.source, c.params) === c.expected)
316
+ for (const c of p.negotiation)
317
+ record(`host-policy`, `negotiation: ${c.extensionIds.length}`, () => canon(supportedCatalogIds(c.extensionIds)) === canon(c.expected.supportedCatalogIds) && canon(clientCapabilities(c.extensionIds)) === canon(c.expected.clientCapabilities))
318
+ const r = fixture(`host-router.json`)
319
+ const packages = r.packages as Record<string, AppPackage>
320
+ for (const v of r.validation) record(`host-router`, `validation: ${v.package}`, () => canon(validatePackage(packages[v.package])) === canon(v.expected))
321
+ for (const c of r.records) record(`host-router`, `records: ${c.name}`, () => canon(validateRecord(packages[c.package]!.records![c.kind]!.schema, c.data)) === canon(c.expected))
322
+ for (const flow of r.flows as { name: string; extensionIds?: string[]; packages?: string[]; installIssues?: Record<string, unknown>; steps: { message: unknown; expected: unknown }[] }[])
323
+ record(`host-router`, `flow: ${flow.name}`, () => {
324
+ const router = new HostRouter({ extensionIds: flow.extensionIds ?? [] })
325
+ for (const id of flow.packages ?? []) if (canon(router.installPackage(packages[id]!)) !== canon(flow.installIssues![id])) return `install ${id}`
326
+ for (const [i, s] of flow.steps.entries()) if (canon(router.route(s.message)) !== canon(s.expected)) return `step ${i}`
327
+ return true
328
+ })
329
+ })
330
+
331
+ // Round 2 (manifest v2) through THIS renderer: the bind pass is the
332
+ // shared web layer's (`bindTree`), the action runner the renderer's
333
+ // (`logic.runNodeAction`), format calls run the bind table on the surface
334
+ // Formatter, template instances are the renderer's `templateItems` +
335
+ // `itemInstance`; style conditions, text direction, Resizable and the
336
+ // windowed list are PAINTED in Chromium and read back. Where the renderer
337
+ // calls the core directly (tokenizeCode, the
338
+ // reducer, tableRowKeys, an arbitrary scrollTo inset, the animation
339
+ // frames) the core replay stands in, said per line.
340
+ await block(`bind, code-tokens, pointer (the renderer's bind pass and action runner)`, () => {
341
+ const strings = DEFAULT_STRINGS as Readonly<Record<string, string>>
342
+ const formatter = memoFormatter(intlFormatter(`en-US`, `UTC`))
343
+ const rctx = (data: unknown, scope = ``): ResolveContext => ({ data, scope, strings, locale: `en-US`, formatter, functions: CLIENT_FUNCTIONS })
344
+ const find = (n: UiNode, id: string): UiNode | undefined => {
345
+ let hit: UiNode | undefined
346
+ walk(n, (x) => (hit ??= x.id === id ? x : undefined))
347
+ return hit
348
+ }
349
+ /** A press through `runNodeAction` on a stub surface: `set` writes a copy
350
+ * of the data, any other function is the host's `call`. */
351
+ const press = (node: UiNode, data: unknown) => {
352
+ let out = data as DataModel
353
+ let call: { call: string; args: Record<string, unknown> } | undefined
354
+ const fn = node.on?.press as { functionCall?: { call: string } } | undefined
355
+ const name = fn?.functionCall?.call
356
+ const functions = Object.fromEntries(Object.entries(CLIENT_FUNCTIONS).map(([k, f]) => [k, (args: Record<string, unknown>, c: ResolveContext) => {
357
+ if (k === name) call = { call: k, args }
358
+ return f(args, { ...c, openUrl: () => {} })
359
+ }]))
360
+ const ctx = { surfaceId: `s`, data, functions, openUrl: () => {}, locale: `en-US`, strings, formatter, setData: (p: string, v: unknown) => (out = setPointer(out, p, v)), host: { onAction: () => undefined, onFunctionCall: (e: { name: string; args: Record<string, unknown> }) => void (call = { call: e.name, args: e.args }) } } as unknown as Ctx
361
+ logic.runNodeAction(ctx, node, node.id, ``, `press`)
362
+ return call ? { data: out, call } : { data: out }
363
+ }
364
+ type BindCase = { name: string; input?: NestedNode; expanded: UiNode; issues?: unknown[]; datasets: { data: unknown; bound: UiNode | null; presses: { id: string; outcome: unknown }[]; rowSlots?: { id: string; rows: { index: number; slots: Record<string, UiNode | null> }[] }[] }[] }
365
+ const bind = fixture(`bind-time.json`) as { cases: BindCase[]; extra: BindCase[] }
366
+ for (const c of [...bind.cases, ...bind.extra])
367
+ record(`bind`, c.name, () => {
368
+ if (c.input && c.issues) {
369
+ const r = reduceNested(c.input, { catalogId: CORE_CATALOG_ID })
370
+ if (canon(r.root) !== canon(c.expanded) || canon(r.issues) !== canon(c.issues)) return `expand`
371
+ }
372
+ for (const d of c.datasets) {
373
+ if (canon(bindTree(c.expanded, rctx(d.data))) !== canon(d.bound)) return `bound`
374
+ for (const p of d.presses) if (canon(press(find(c.expanded, p.id)!, d.data)) !== canon(p.outcome)) return `press ${p.id}`
375
+ // A slot cell = the Table's RowScope: bound rows scope the row
376
+ // pointer, literal rows mount the row at a synthetic pointer.
377
+ for (const t of d.rowSlots ?? []) {
378
+ const table = find(c.expanded, t.id)!
379
+ const rowsProp = table.props.rows
380
+ const rows = resolveNodeProps(`Table`, { rows: rowsProp }, rctx(d.data)).rows as Record<string, unknown>[]
381
+ for (const r of t.rows)
382
+ for (const [name, slot] of Object.entries(r.slots)) {
383
+ const literal = `${LITERAL_ROWS_ROOT}/${t.id}/${r.index}`
384
+ const got = isBinding(rowsProp) ? bindTree(table.slots![name], rctx(d.data, `${absolutePath(rowsProp.path)}/${r.index}`)) : bindTree(table.slots![name], rctx(setPointer(d.data as DataModel, literal, rows[r.index]), literal))
385
+ if (canon(got) !== canon(slot)) return `row ${r.index}`
386
+ }
387
+ }
388
+ }
389
+ return true
390
+ })
391
+ // The CodeBlock painter calls the core tokenizer.
392
+ for (const c of fixture(`code-tokens.json`).cases as { name: string; language: string; code: string; expected: unknown }[]) record(`code-tokens`, c.name, () => canon(tokenizeCode(c.code, c.language)) === canon(c.expected))
393
+ // EXP-1385: the renderer's data writes (the core's writePointer behind
394
+ // setPointer) and its binding reads (getPointer).
395
+ const pointers = fixture(`pointer.json`) as { writes: { name: string; data: unknown; pointer: string; value?: unknown; expected?: unknown; read?: unknown; error?: string }[]; reads: { name: string; data: unknown; pointer: string; expected?: unknown }[] }
396
+ for (const c of pointers.writes)
397
+ record(`pointer`, `write: ${c.name}`, () => {
398
+ const before = canon(c.data)
399
+ const w = writePointer(c.data, c.pointer, c.value)
400
+ if (c.error !== undefined) return w.error === c.error && canon(w.data) === before ? true : `${w.error} ${canon(w.data)}`
401
+ return w.error === undefined && canon(w.data) === canon(c.expected) && canon(getPointer(w.data, c.pointer)) === canon(c.read) ? true : `${canon(w.data)} / read ${canon(getPointer(w.data, c.pointer))}`
402
+ })
403
+ for (const c of pointers.reads) record(`pointer`, `read: ${c.name}`, () => canon(getPointer(c.data, c.pointer)) === canon(c.expected))
404
+ })
405
+
406
+ await block(`style-conditions: the compiled CSS in Chromium (hover: none = a touch context)`, async () => {
407
+ type Ctx = { width: number; height?: number; hover?: boolean; reducedMotion?: boolean; states?: string[]; breakpoints?: Record<string, number> }
408
+ const conditions = fixture(`style-conditions.json`) as { cases: { name: string; style: Record<string, unknown>; contexts: Ctx[]; expected: Record<string, unknown>[] }[] }
409
+ for (const [ci, c] of conditions.cases.entries()) {
410
+ let verdict: true | string = true
411
+ for (const [xi, ctx] of c.contexts.entries()) {
412
+ // A touch context (hasTouch) is Chromium's `(hover: none)`.
413
+ const context = await browser.newContext({ viewport: { width: Math.max(ctx.width + 40, 400), height: Math.max((ctx.height ?? 120) + 40, 400) }, reducedMotion: ctx.reducedMotion ? `reduce` : `no-preference`, hasTouch: ctx.hover === false })
414
+ const page = await context.newPage()
415
+ await page.goto(url({ view: `conditions`, case: ci, ctx: xi }))
416
+ await page.waitForSelector(`[data-xui-id="n"]`, { state: `attached` })
417
+ await page.waitForTimeout(100)
418
+ const theme = ctx.breakpoints ? loadTheme({ $schema: THEME_SCHEMA_ID, id: `bp`, name: `Breakpoints`, extends: `neutral`, tokens: { breakpoint: ctx.breakpoints } } as never, { themes: BUILTIN_THEMES }) : builtinTheme(`neutral`)
419
+ const want = styleToCss(resolveStyleValues(theme, c.expected[xi], `light`), theme.fonts)
420
+ if (ctx.reducedMotion) delete want.transition // reduced motion zeroes every duration (contract §2)
421
+ const got = await page.evaluate((css) => {
422
+ const node = document.querySelector(`[data-xui-id="n"]`) as HTMLElement
423
+ const probe = document.createElement(`div`)
424
+ probe.className = `xui-el`
425
+ node.parentElement!.appendChild(probe)
426
+ for (const [k, v] of Object.entries(css as Record<string, string>)) probe.style.setProperty(k, v)
427
+ const a = getComputedStyle(node)
428
+ const b = getComputedStyle(probe)
429
+ const bad = Object.keys(css).map((k) => (k === `transition` ? `transition-duration` : k)).filter((k) => a.getPropertyValue(k) !== b.getPropertyValue(k))
430
+ probe.remove()
431
+ return bad
432
+ }, want)
433
+ await context.close()
434
+ if (got.length) {
435
+ verdict = `context ${xi}: ${got.join(`, `)}`
436
+ break
437
+ }
438
+ }
439
+ record(`style-conditions`, c.name, verdict)
440
+ }
441
+ })
442
+
443
+ await block(`format, template-items (the renderer's bind table + template keying)`, () => {
444
+ const formatter = memoFormatter(intlFormatter(`en-US`, `UTC`))
445
+ const f = fixture(`format.json`) as { calls: { name: string; call: unknown; expected: unknown }[]; zoned: { name: string; timeZone: string; call: unknown; expected: string }[]; display: { name: string; value: unknown; expected: string }[] }
446
+ for (const c of f.calls) record(`format`, c.name, () => canon(resolveValue(c.call, { data: {}, locale: `en-US`, formatter })) === canon(c.expected))
447
+ // `zoned`: the surface Formatter in the case's IANA zone (a web renderer's is Intl,
448
+ // a platform Formatter: U+202F / U+00A0 compare as a space).
449
+ const spaces = (v: unknown) => String(v).replace(/[  ]/g, ` `)
450
+ for (const c of f.zoned) record(`format`, `zoned: ${c.name}`, () => spaces(resolveValue(c.call, { data: {}, locale: `en-US`, formatter: memoFormatter(intlFormatter(`en-US`, c.timeZone)) })) === spaces(c.expected))
451
+ for (const d of f.display) record(`format`, `display: ${d.name}`, () => displayString(d.value) === d.expected)
452
+ const items = fixture(`template-items.json`)
453
+ const keyed = (data: unknown, template: ChildTemplate, scope: string) => logic.templateItems({ data, templateNode: () => ({}) } as unknown as Ctx, { template } as unknown as UiNode, scope)?.items ?? []
454
+ for (const c of items.keys) record(`template-items`, `keys: ${c.name}`, () => canon(keyed({ items: c.items }, { component: `x`, path: `/items`, ...(c.key === undefined ? {} : { key: c.key }) } as ChildTemplate, ``).map((i) => i.key)) === canon(c.expected))
455
+ // The Table painter keys its rows with the core's `tableRowKeys`.
456
+ for (const c of items.rowKeys) record(`template-items`, `rowKeys: ${c.name}`, () => canon(tableRowKeys(c.rows, c.rowKey)) === canon(c.expected))
457
+ for (const c of items.instances as { name: string; data: unknown; template: ChildTemplate; inner?: ChildTemplate; scope: string; instance: string; expected: unknown[] }[])
458
+ record(`template-items`, `instances: ${c.name}`, () => {
459
+ const outer = keyed(c.data, c.template, c.scope).map((i) => ({ key: i.key, path: i.path, index: i.index, instance: logic.itemInstance(c.instance, i.key) }))
460
+ if (!c.inner) return canon(outer) === canon(c.expected)
461
+ const nested = outer.flatMap((o) => keyed(c.data, c.inner!, o.path).map((i) => {
462
+ const instance = logic.itemInstance(o.instance, i.key)
463
+ return { outer: o.key, key: i.key, path: i.path, index: i.index, instance, ids: { issue: `issue${instance}`, title: `issue.title${instance}` } }
464
+ }))
465
+ return canon(nested) === canon(c.expected)
466
+ })
467
+ // The renderer paints the core reducer's output.
468
+ for (const c of items.reduce as { name: string; catalogId: string; nested?: NestedNode; components?: FlatComponent[]; expected: unknown }[])
469
+ record(`template-items`, `reduce: ${c.name}`, () => canon(c.nested ? reduceNested(c.nested, { catalogId: c.catalogId }) : reduceSurface(c.components!, { catalogId: c.catalogId })) === canon(c.expected))
470
+ })
471
+
472
+ await block(`text-direction, resizable, virtual-list, animations: painted in Chromium`, async () => {
473
+ const context = await browser.newContext({ viewport: { width: 1200, height: 900 }, deviceScaleFactor: 1 })
474
+ const page = await context.newPage()
475
+ await page.goto(url({ view: `mount` }))
476
+ await page.waitForFunction(() => typeof (window as unknown as XuiProbes).__xuiMount === `function` && typeof (window as unknown as XuiProbes).__xuiVList === `function`)
477
+ const mount = (tree: unknown, options: Record<string, unknown> = {}) => page.evaluate(([t, o]) => (window as unknown as XuiProbes).__xuiMount!(t as never, o as never), [tree, options] as const)
478
+ const frames = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => requestAnimationFrame(r)))))
479
+
480
+ // text-direction: every node's computed `direction` and its computed
481
+ // `text-align` resolved to the physical side.
482
+ for (const c of fixture(`text-direction.json`).cases as { name: string; tree: NestedNode; surface: `ltr` | `rtl`; expected: Record<string, { direction: string; textAlign: string }> }[]) {
483
+ await mount(c.tree, { dir: c.surface, width: 600 })
484
+ const got = await page.evaluate((ids) => {
485
+ const out: Record<string, { direction: string; textAlign: string }> = {}
486
+ for (const id of ids) {
487
+ const el = document.querySelector<HTMLElement>(`[data-xui-id="${id}"]`)
488
+ if (!el) continue
489
+ const cs = getComputedStyle(el)
490
+ const a = cs.textAlign
491
+ const physical = a === `start` || a === `-webkit-auto` ? (cs.direction === `rtl` ? `right` : `left`) : a === `end` ? (cs.direction === `rtl` ? `left` : `right`) : a === `-webkit-left` ? `left` : a === `-webkit-right` ? `right` : a === `-webkit-center` ? `center` : a
492
+ out[id] = { direction: cs.direction, textAlign: physical }
493
+ }
494
+ return out
495
+ }, Object.keys(c.expected))
496
+ const bad = Object.keys(c.expected).filter((id) => canon(got[id]) !== canon(c.expected[id]))
497
+ record(`text-direction`, c.name, bad.length === 0, bad.map((id) => `${id} ${canon(got[id])}`).join(`; `))
498
+ }
499
+
500
+ // resizable: a Resizable painted per case; sizes read off the panels.
501
+ const near = (a: number[], b: number[], tol = 1e-6) => a.length === b.length && a.every((v, i) => Math.abs(v - b[i]!) <= tol)
502
+ const rz = fixture(`resizable.json`)
503
+ const panelsOf = (count: number) => Array.from({ length: count }, (_, i) => ({ id: `p${i}`, component: `Box` }))
504
+ const sizes = (count: number) => page.evaluate((n) => Array.from({ length: n }, (_, i) => Number(document.querySelector<HTMLElement>(`[data-xui-id="rz.panel.${i}"]`)!.dataset.size)), count)
505
+ /** A pointer drag of handle `i` by (dx, dy) px: synthetic pointer events (fractional coordinates). */
506
+ const drag = (i: number, dx: number, dy: number) =>
507
+ page.evaluate(
508
+ ([i, dx, dy]) => {
509
+ // No such handle (`bad-handle`): nothing to drag, sizes unchanged.
510
+ const el = document.querySelector<HTMLElement>(`[data-xui-id="rz.handle.${i}"]`)
511
+ if (!el) return
512
+ const r = el.getBoundingClientRect()
513
+ const x = r.left + r.width / 2
514
+ const y = r.top + r.height / 2
515
+ const fire = (type: string, mx: number, my: number) => el.dispatchEvent(new PointerEvent(type, { bubbles: true, cancelable: true, composed: true, pointerId: 7, isPrimary: true, button: 0, buttons: type === `pointerup` ? 0 : 1, clientX: x + mx, clientY: y + my }))
516
+ fire(`pointerdown`, 0, 0)
517
+ fire(`pointermove`, dx, dy)
518
+ fire(`pointerup`, dx, dy)
519
+ },
520
+ [i, dx, dy] as const
521
+ )
522
+ for (const c of rz.normalize as { name: string; sizes: unknown; count: number; panels?: unknown[]; expected: number[] }[]) {
523
+ await mount({ id: `rz`, component: `Resizable`, props: { sizes: c.sizes, ...(c.panels ? { panels: c.panels } : {}) }, style: { width: 401, height: 60 }, children: panelsOf(c.count) })
524
+ const got = await sizes(c.count)
525
+ record(`resizable`, `normalize: ${c.name}`, near(got, c.expected), canon(got))
526
+ }
527
+ for (const c of rz.resize as { name: string; sizes: number[]; handle: number; delta: number; panels?: unknown[]; expected: number[] }[]) {
528
+ // 400 px of panels: 1 % = 4 px.
529
+ const n = c.sizes.length
530
+ await mount({ id: `rz`, component: `Resizable`, props: { sizes: c.sizes, ...(c.panels ? { panels: c.panels } : {}) }, style: { width: 400 + (n - 1), height: 60 }, children: panelsOf(n) }, { hairline: 1 })
531
+ await drag(c.handle, c.delta * 4, 0)
532
+ await frames()
533
+ const got = await sizes(n)
534
+ record(`resizable`, `resize: ${c.name}`, near(got, c.expected), canon(got))
535
+ }
536
+ for (const c of rz.keys as { name: string; sizes: number[]; handle: number; key: string; orientation: `horizontal` | `vertical`; direction: `ltr` | `rtl`; panels?: unknown[]; expected: number[] }[]) {
537
+ const n = c.sizes.length
538
+ await mount({ id: `rz`, component: `Resizable`, props: { sizes: c.sizes, direction: c.orientation, ...(c.panels ? { panels: c.panels } : {}) }, style: { width: 401, height: 401 }, children: panelsOf(n) }, { dir: c.direction })
539
+ await page.focus(`[data-xui-id="rz.handle.${c.handle}"]`)
540
+ await page.keyboard.press(c.key)
541
+ await frames()
542
+ const got = await sizes(n)
543
+ record(`resizable`, `keys: ${c.name}`, near(got, c.expected), canon(got))
544
+ }
545
+ for (const c of rz.extents as { name: string; sizes: number[]; container: number; handle: number; expected: number[] }[]) {
546
+ const n = c.sizes.length
547
+ await mount({ id: `rz`, component: `Resizable`, props: { sizes: c.sizes }, style: { width: c.container, height: 60 }, children: panelsOf(n) }, { hairline: c.handle })
548
+ const got = await page.evaluate((n) => Array.from({ length: n }, (_, i) => document.querySelector(`[data-xui-id="rz.panel.${i}"]`)!.getBoundingClientRect().width), n)
549
+ // CSS lays out in 1/64 px.
550
+ record(`resizable`, `extents: ${c.name}`, near(got, c.expected, 1 / 64), canon(got))
551
+ }
552
+ for (const c of rz.drag as { name: string; px: number; container: number; panels: number; orientation: `horizontal` | `vertical`; direction: `ltr` | `rtl`; expected: number }[]) {
553
+ const even = Array.from({ length: c.panels }, () => 100 / c.panels)
554
+ const vertical = c.orientation === `vertical`
555
+ await mount({ id: `rz`, component: `Resizable`, props: { sizes: even, direction: c.orientation }, style: vertical ? { width: 200, height: c.container } : { width: c.container, height: 60 }, children: panelsOf(c.panels) }, { dir: c.direction, hairline: 1 })
556
+ const before = await sizes(c.panels)
557
+ await drag(0, vertical ? 0 : c.px, vertical ? c.px : 0)
558
+ await frames()
559
+ const got = (await sizes(c.panels))[0]! - before[0]!
560
+ record(`resizable`, `drag: ${c.name}`, Math.abs(got - c.expected) <= 1e-6, String(got))
561
+ }
562
+
563
+ // virtual-list: a bare WindowedList in a scroller of `viewport` px.
564
+ const vl = fixture(`virtual-list.json`)
565
+ const expand = (e: number[] | { count: number; extent: number }) => (Array.isArray(e) ? e : Array.from({ length: e.count }, () => e.extent))
566
+ const vlist = (c: { extents: number[]; gap: number; viewport: number; overscan?: number; axis?: string; stickyRows?: number[] }, act: string, a: unknown, b?: unknown) =>
567
+ page.evaluate(
568
+ async ([c, act, a, b]) => {
569
+ const probe = await (window as unknown as XuiProbes).__xuiVList!(c as never)
570
+ if (act === `window`) return probe.scroll(a as number)
571
+ await probe.scroll(a as number)
572
+ return probe.scrollToIndex((b as { index: number }).index, (b as { align: `start` }).align)
573
+ },
574
+ [c, act, a, b] as const
575
+ )
576
+ for (const c of vl.windows as { name: string; extents: number[] | { count: number; extent: number }; gap: number; scroll: number; viewport: number; overscan?: number; expected: Record<string, number> }[]) {
577
+ const got = (await vlist({ extents: expand(c.extents), gap: c.gap, viewport: c.viewport, overscan: c.overscan, axis: c.name.includes(`horizontal`) ? `horizontal` : `vertical` }, `window`, c.scroll)) as unknown as Record<string, number>
578
+ const keys = [`start`, `end`, `before`, `after`, `total`]
579
+ record(`virtual-list`, `window: ${c.name}`, keys.every((k) => Math.abs(got[k]! - c.expected[k]!) <= 0.5), canon(got))
580
+ }
581
+ for (const c of vl.scrollTo as { name: string; extents: number[] | { count: number; extent: number }; gap: number; index: number; viewport: number; scroll: number; align: `start`; inset?: number; expected: number }[]) {
582
+ if (c.inset) {
583
+ // An arbitrary inset is not paintable (WindowedList takes it from a
584
+ // pinned header row); the painter's jump calls this function.
585
+ record(`virtual-list`, `scrollTo: ${c.name}`, scrollOffsetForIndex(expand(c.extents), c.gap, c.index, c.viewport, c.scroll, c.align, c.inset) === c.expected)
586
+ continue
587
+ }
588
+ const got = (await vlist({ extents: expand(c.extents), gap: c.gap, viewport: c.viewport }, `scrollTo`, c.scroll, { index: c.index, align: c.align })) as number
589
+ record(`virtual-list`, `scrollTo: ${c.name}`, Math.abs(got - c.expected) <= 0.5, String(got))
590
+ }
591
+ for (const c of vl.sticky as { name: string; rowExtents: number[]; headerRows: number[]; scrolls: number[]; expected: unknown[] }[]) {
592
+ const got: unknown[] = []
593
+ for (const s of c.scrolls) got.push(((await vlist({ extents: c.rowExtents, gap: 0, viewport: 60, stickyRows: c.headerRows }, `window`, s)) as { pinned: unknown }).pinned)
594
+ record(`virtual-list`, `sticky: ${c.name}`, canon(got) === canon(c.expected), canon(got))
595
+ }
596
+ // Sections: a sectioned List painted flat, its rows read back.
597
+ for (const c of vl.sections as { name: string; items: Record<string, unknown>[]; sectionBy: string; expected: unknown }[]) {
598
+ await mount({ id: `L`, component: `List`, props: { sectionBy: c.sectionBy }, template: { component: `L-row`, path: `/items`, key: `id` }, children: [{ id: `L-row`, component: `Text`, props: { text: { path: `id` } } }] }, { data: { items: c.items } })
599
+ const got = await page.evaluate((ids) => {
600
+ const rows: ({ header: number } | { item: number })[] = []
601
+ const sections: { value: string; start: number; count: number }[] = []
602
+ for (const row of Array.from(document.querySelectorAll<HTMLElement>(`[data-xui-id="L"] > .xui-list-row`))) {
603
+ const head = row.querySelector<HTMLElement>(`[data-xui-id^="L.section."]`)
604
+ if (head) {
605
+ rows.push({ header: sections.length })
606
+ sections.push({ value: head.textContent ?? ``, start: 0, count: 0 })
607
+ continue
608
+ }
609
+ const item = ids.indexOf(row.querySelector<HTMLElement>(`[data-xui-c="Text"]`)!.textContent ?? ``)
610
+ rows.push({ item })
611
+ const s = sections[sections.length - 1]
612
+ if (s.count === 0) s.start = item
613
+ s.count += 1
614
+ }
615
+ return { sections, rows }
616
+ }, c.items.map((i) => String(i.id)))
617
+ record(`virtual-list`, `sections: ${c.name}`, canon(got) === canon(c.expected), canon(got))
618
+ }
619
+ for (const c of vl.sectionedScrollTo as { name: string; index: number; viewport: number; scroll: number; align: `start`; stickyHeaders: boolean; items: Record<string, unknown>[]; sectionBy: string; headerExtent: number; itemExtent: number; expected: number }[]) {
620
+ await mount(
621
+ { id: `L`, component: `List`, props: { sectionBy: c.sectionBy, stickyHeaders: c.stickyHeaders }, style: { height: c.viewport }, template: { component: `L-row`, path: `/items`, key: `id` }, slots: { section: { id: `L-head`, component: `Text`, props: { text: { path: `value` } }, style: { height: c.headerExtent } } }, children: [{ id: `L-row`, component: `Text`, props: { text: { path: `id` } }, style: { height: c.itemExtent } }] },
622
+ { data: { items: c.items }, width: 300 }
623
+ )
624
+ const got = await page.evaluate(
625
+ async ([scroll, index, align]) => {
626
+ const list = document.querySelector<HTMLElement>(`[data-xui-id="L"]`)!
627
+ list.scrollTop = scroll as number
628
+ await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
629
+ const probes = window as unknown as XuiProbes
630
+ probes.__xuiHandle!()!.scrollToIndex(`L`, index as number, align as `start`)
631
+ for (let i = 0; i < 8; i++) await new Promise((r) => requestAnimationFrame(r))
632
+ return list.scrollTop
633
+ },
634
+ [c.scroll, c.index, c.align] as const
635
+ )
636
+ record(`virtual-list`, `sectionedScrollTo: ${c.name}`, Math.abs(got - c.expected) <= 0.5, String(got))
637
+ }
638
+
639
+ // animations: the CSS this renderer paints per theme (duration, easing,
640
+ // iterations off the computed style); the frames are the keyframes the
641
+ // base sheet ships (each case checks them against the fixture), sampled by
642
+ // the core.
643
+ const anim = fixture(`animations.json`)
644
+ for (const [id, entries] of Object.entries(anim.themes as Record<string, Record<string, { timing: { durationMs: number; easing: number[] | string; iterations: number | string }; frames: { t: number; frame: Record<string, number | null> }[]; reduced: unknown }>>))
645
+ for (const [name, e] of Object.entries(entries)) {
646
+ await mount({ id: `a`, component: `Box`, style: { width: 40, height: 40, animation: name } }, { theme: id })
647
+ const css = await page.evaluate(() => {
648
+ const cs = getComputedStyle(document.querySelector(`[data-xui-id="a"]`)!)
649
+ return { duration: cs.animationDuration, easing: cs.animationTimingFunction, iterations: cs.animationIterationCount }
650
+ })
651
+ const theme = builtinTheme(id)
652
+ const ms = css.duration.endsWith(`ms`) ? Number.parseFloat(css.duration) : Number.parseFloat(css.duration) * 1000
653
+ const easing = Array.isArray(e.timing.easing) ? `cubic-bezier(${e.timing.easing.join(`, `)})` : e.timing.easing
654
+ const verdict = (() => {
655
+ // The keyframes + @property rules this renderer ships (base-css.ts) are the fixture's.
656
+ if (keyframesCss(name) !== (anim.css as Record<string, string>)[name] || !BASE_CSS.includes(keyframesCss(name)) || !BASE_CSS.includes(anim.properties as string)) return `css`
657
+ if (Math.abs(ms - e.timing.durationMs) > 0.5) return `duration ${css.duration}`
658
+ if (css.easing !== easing) return `easing ${css.easing}`
659
+ if (css.iterations !== String(e.timing.iterations)) return `iterations ${css.iterations}`
660
+ for (const fr of e.frames) {
661
+ const got = animationFrame(name, fr.t, theme) as unknown as Record<string, number | null>
662
+ for (const [k, v] of Object.entries(fr.frame)) if (v === null ? got[k] !== null : Math.abs((got[k] as number) - v) > 1e-3) return `${fr.t}.${k}`
663
+ }
664
+ return canon(animationFrame(name, 5, theme, { reducedMotion: true })) === canon(e.reduced) ? true : `reduced`
665
+ })()
666
+ record(`animations`, `${id}/${name}`, verdict)
667
+ }
668
+ /** The painted opacity of node `a` with its animation paused at `t` ms. */
669
+ const opacityAt = (t: number) =>
670
+ page.evaluate((t) => {
671
+ const el = document.querySelector<HTMLElement>(`[data-xui-id="a"]`)!
672
+ for (const a of el.getAnimations()) {
673
+ a.pause()
674
+ a.currentTime = t
675
+ }
676
+ return Number(getComputedStyle(el).opacity)
677
+ }, t)
678
+ // `override`: animationDuration keeps the set's factor; the frame painted.
679
+ {
680
+ const o = anim.override as { theme: string; name: string; durationToken: string; timing: { durationMs: number }; frame: { opacity: number } }
681
+ await mount({ id: `a`, component: `Box`, style: { width: 40, height: 40, animation: o.name, animationDuration: o.durationToken } }, { theme: o.theme })
682
+ const duration = await page.evaluate(() => getComputedStyle(document.querySelector(`[data-xui-id="a"]`)!).animationDuration)
683
+ const ms = duration.endsWith(`ms`) ? Number.parseFloat(duration) : Number.parseFloat(duration) * 1000
684
+ const painted = await opacityAt(o.timing.durationMs / 2)
685
+ record(`animations`, `override`, Math.abs(ms - o.timing.durationMs) <= 0.5 && Math.abs(painted - o.frame.opacity) <= 1e-3, `${duration} ${painted}`)
686
+ }
687
+ // `opacity`: the painted opacity = the node's own × the frame's.
688
+ for (const c of anim.opacity as { name: string; own: number; animation: string; theme: string; t: number; expected: number }[]) {
689
+ await mount({ id: `a`, component: `Box`, style: { width: 40, height: 40, opacity: c.own, animation: c.animation } }, { theme: c.theme })
690
+ const painted = await opacityAt(c.t)
691
+ record(`animations`, `opacity: ${c.name}`, Math.abs(painted - c.expected) <= 1e-3, String(painted))
692
+ }
693
+ await context.close()
694
+ })
695
+ }
696
+ }
697
+
698
+ void BUILTIN_THEMES
699
+ void CORE_CATALOG_ID