@exponential-at/ui 0.0.0-stage → 0.1.0

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