@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,412 @@
1
+ // FEED-128: the deterministic WEB PARITY check. Two web renderers that claim
2
+ // the same DOM + stylesheet contract (`@exponential-at/ui-react` is the
3
+ // REFERENCE; `@exponential-at/ui-solid` the first candidate) are loaded side
4
+ // by side in ONE headless Chromium — same viewport, device scale 1, the
5
+ // conformance font set, reduced motion, animations and transitions off — and
6
+ // every case is compared four ways, all EXACT:
7
+ // dom the `.xui-surface` subtrees serialised (tag, attributes by name,
8
+ // text; adjacent text nodes MERGED, as the jsdom helper does: the
9
+ // HTML parser of Playwright's pinned Chromium splits an innerHTML
10
+ // text past 65 536 chars, React's `<style>`, where `textContent`
11
+ // keeps one node), generated ids normalised (parity-compare.ts);
12
+ // style every `[data-xui-id]` element's (and every overlay part's) full
13
+ // computed style;
14
+ // (FEED-131: a PRIMITIVE case's page wears `data-xui-parity-root` on
15
+ // <body>: the dom is the whole body incl. its attributes, and EVERY
16
+ // element under it is compared, keyed by its path `body/div[0]/button[1]`);
17
+ // frame the same elements' getBoundingClientRect, 0 px tolerance;
18
+ // pixels full-page screenshots through pixelmatch (threshold 0, AA
19
+ // excluded): 0 differing pixels.
20
+ // Not a manifest suite (the natives' reports keep the manifest's list): a
21
+ // web-only gate with its own report (parity-report.schema.json), checked by
22
+ // `bun run --filter @exponential-at/ui parity:check <report.json>`.
23
+
24
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"
25
+ import { join } from "node:path"
26
+ import pixelmatch from "pixelmatch"
27
+ import sharp from "sharp"
28
+ import type { Browser, BrowserContext, Page } from "playwright"
29
+ import { allCases, caseInput, fontFaceCss, fontFiles, MANIFEST_PATH, type ConformanceManifest, type FontManifest } from "./dump"
30
+ import { launchChromium, pageUrl, type Query, type RunningHarness, type WebHarness } from "./browser-page"
31
+ import { caseSlug, firstDomDiff, firstFrameDiff, firstStyleDiff, normalizeIds, PARITY_FORMAT, type DomNode, type ParityCaseResult, type ParityReport, type StyleTable } from "./parity-compare"
32
+
33
+ export * from "./parity-compare"
34
+
35
+ const repoRoot = join(import.meta.dirname, `..`, `..`, `..`)
36
+ const fixturesDir = join(import.meta.dirname, `..`, `fixtures`)
37
+
38
+ /** The forced states every control specimen is re-dumped in (`data-xs` on
39
+ * every `[data-xui-part]`; the recipe sheet honours `[data-xs~=state]`). */
40
+ export const FORCED_STATES = [`hover`, `focus`, `pressed`, `checked`, `open`, `disabled`, `invalid`] as const
41
+
42
+ /** The specimens whose component is a CONTROL (control-geometry.json's
43
+ * interactive set): they get the forced-state cases. */
44
+ export const CONTROL_COMPONENTS = [`Button`, `Toggle`, `Segmented`, `Input`, `Textarea`, `Select`, `DatePicker`, `Checkbox`, `Radio`, `Switch`, `Slider`, `Tabs`, `NumberField`, `ChipInput`, `TimePicker`, `DateRangePicker`]
45
+
46
+ export interface ParityCase {
47
+ /** `matrix:<conformance key>`, `specimen:<id>`, `state:<id>:<state>`,
48
+ * `overlay:<n>`, `primitive:<id>:<theme>-<mode>[:<interact>]`. */
49
+ id: string
50
+ group: `matrix` | `specimen` | `state` | `overlay` | `primitive`
51
+ query: Query
52
+ viewport: { width: number; height: number }
53
+ /** A forced state written onto every part before the dump. */
54
+ state?: string
55
+ /** An overlay case: wait for `window.__xuiOverlay()` to place it. */
56
+ overlay?: boolean
57
+ /** A primitive specimen that opens an overlay on mount: wait for
58
+ * `window.__xuiOverlay?.()` (when the page defines it), then two frames. */
59
+ open?: boolean
60
+ /** Hover / focus the FIRST `[data-xui-target]` inside the specimen
61
+ * before the dump (primitive cases). */
62
+ interact?: `hover` | `focus`
63
+ }
64
+
65
+ /** One `primitive-specimens.json` row (FEED-131). */
66
+ export interface PrimitiveSpecimen {
67
+ /** `<primitive>/<variant>`. */
68
+ id: string
69
+ /** The harness module owning it (`harness/primitives/<primitive>.tsx`). */
70
+ primitive: string
71
+ viewport: { width: number; height: number }
72
+ open: boolean
73
+ interact: `hover` | `focus` | null
74
+ }
75
+
76
+ /** The theme × mode pairs every primitive specimen runs in. */
77
+ export const PRIMITIVE_THEMES: readonly { theme: string; mode: `light` | `dark` }[] = [
78
+ { theme: `exponential`, mode: `light` },
79
+ { theme: `orange`, mode: `dark` },
80
+ ]
81
+
82
+ /** The primitive cases alone (group `primitive`): every specimen × PRIMITIVE_THEMES,
83
+ * plus its `interact` variant when the row names one. */
84
+ export function primitiveCases(specimens: readonly PrimitiveSpecimen[] = fixture<{ specimens: PrimitiveSpecimen[] }>(`primitive-specimens.json`).specimens): ParityCase[] {
85
+ const out: ParityCase[] = []
86
+ for (const s of specimens)
87
+ for (const { theme, mode } of PRIMITIVE_THEMES) {
88
+ const base: ParityCase = { id: `primitive:${s.id}:${theme}-${mode}`, group: `primitive`, query: { view: `primitives`, id: s.id, theme, mode, width: s.viewport.width }, viewport: s.viewport }
89
+ if (s.open) base.open = true
90
+ out.push(base)
91
+ if (s.interact) out.push({ ...base, id: `${base.id}:${s.interact}`, interact: s.interact })
92
+ }
93
+ return out
94
+ }
95
+
96
+
97
+
98
+ export interface ParityOptions {
99
+ reference: WebHarness
100
+ candidate: WebHarness
101
+ /** Report names (default `reference` / `candidate`). */
102
+ names?: { reference?: string; candidate?: string }
103
+ /** Default: `parityCases()`. */
104
+ cases?: ParityCase[]
105
+ /** Extra Chromium flags (always PARITY_CHROMIUM_ARGS) and an
106
+ * executable (default `EXPONENTIAL_UI_CHROME`, else Playwright's). */
107
+ chrome?: { args?: string[]; executablePath?: string }
108
+ /** Where differing screenshots land (default <repo>/.conformance/parity). */
109
+ outDir?: string
110
+ /** Cases compared at once (default 4). */
111
+ concurrency?: number
112
+ log?: (line: string) => void
113
+ }
114
+
115
+ /** Chromium for exact pixels: unhinted fractional glyph advances, and
116
+ * DETERMINISTIC raster (software, whole-tile, sRGB, grayscale AA): without
117
+ * `--disable-partial-raster` + `--disable-gpu` a composited layer's
118
+ * anti-aliased rounded corners (a carousel scroller, an open drawer) come
119
+ * out ±1 level between two loads of the SAME page now and then. */
120
+ export const PARITY_CHROMIUM_ARGS = [`--font-render-hinting=none`, `--disable-gpu`, `--disable-partial-raster`, `--force-color-profile=srgb`, `--disable-lcd-text`]
121
+
122
+ const fixture = <T>(name: string) => JSON.parse(readFileSync(join(fixturesDir, name), `utf8`)) as T
123
+ const repoJson = async (path: string) => JSON.parse(readFileSync(join(repoRoot, path), `utf8`)) as unknown
124
+
125
+ /** Every parity case: the conformance matrix (fixtures × themes × modes ×
126
+ * widths × directions, each through the harness's `?view=tree` with the
127
+ * case's tree + data exactly as the conformance page builds them), every
128
+ * specimen at `specimenWidth`, the control specimens in each forced state,
129
+ * every overlay-geometry case with its overlay open, and the primitive
130
+ * specimens (`primitiveCases`). */
131
+ export async function parityCases(opts: { specimenWidth?: number; states?: readonly string[]; controls?: readonly string[] } = {}): Promise<ParityCase[]> {
132
+ const out: ParityCase[] = []
133
+ const manifest = (await repoJson(MANIFEST_PATH)) as ConformanceManifest
134
+ const inputs = new Map<string, { tree: string; data: string }>()
135
+ for (const c of allCases(manifest)) {
136
+ // The tree + data depend on the fixture and the direction only.
137
+ const k = `${c.fixture}/${c.direction}`
138
+ if (!inputs.has(k)) {
139
+ const { tree, data } = await caseInput(manifest, c, repoJson)
140
+ inputs.set(k, { tree: JSON.stringify(tree), data: JSON.stringify(data) })
141
+ }
142
+ const input = inputs.get(k)!
143
+ out.push({ id: `matrix:${c.key}`, group: `matrix`, query: { view: `tree`, tree: input.tree, data: input.data, theme: c.theme, mode: c.mode, width: c.width, dir: c.direction, locale: manifest.locale }, viewport: { width: c.width, height: 900 } })
144
+ }
145
+ const width = opts.specimenWidth ?? 900
146
+ const specimens = fixture<{ specimens: { id: string; component?: string }[] }>(`specimens.json`).specimens
147
+ const query = (id: string): Query => ({ view: `specimen`, id, theme: `exponential`, mode: `light`, width })
148
+ for (const s of specimens) out.push({ id: `specimen:${s.id}`, group: `specimen`, query: query(s.id), viewport: { width, height: 900 } })
149
+ const controls = new Set(opts.controls ?? CONTROL_COMPONENTS)
150
+ for (const s of specimens.filter((s) => s.component && controls.has(s.component)))
151
+ for (const state of opts.states ?? FORCED_STATES) out.push({ id: `state:${s.id}:${state}`, group: `state`, query: query(s.id), viewport: { width, height: 900 }, state })
152
+ const overlays = fixture<{ cases: { name: string; viewport: { width: number; height: number } }[] }>(`overlay-geometry.json`).cases
153
+ for (const [i, c] of overlays.entries()) out.push({ id: `overlay:${i}`, group: `overlay`, query: { view: `overlay`, case: i }, viewport: c.viewport, overlay: true })
154
+ out.push(...primitiveCases())
155
+ return out
156
+ }
157
+
158
+ /** The in-page probe (a string, so no transpiler helper leaks into it):
159
+ * `window.__xuiParity` = { dom, table, values, force }. */
160
+ const PROBE = `(() => {
161
+ const rooted = () => !!document.body && document.body.hasAttribute('data-xui-parity-root');
162
+ const skipped = (el) => el.tagName === 'SCRIPT' || (el.tagName === 'STYLE' && el.hasAttribute('data-xui-parity'));
163
+ const surfaces = () => rooted() ? [document.body] : Array.from(document.querySelectorAll('.xui-surface')).filter((s) => !s.parentElement || !s.parentElement.closest('.xui-surface'));
164
+ const ser = (n) => {
165
+ if (n.nodeType === 3) return n.data.length ? { x: n.data } : null;
166
+ if (n.nodeType !== 1) return null;
167
+ if (rooted() && skipped(n)) return null;
168
+ const a = Array.from(n.attributes).map((x) => [x.name, x.value]).sort((p, q) => (p[0] < q[0] ? -1 : p[0] > q[0] ? 1 : 0));
169
+ const c = [];
170
+ for (const k of Array.from(n.childNodes)) {
171
+ const s = ser(k);
172
+ if (!s) continue;
173
+ const last = c[c.length - 1];
174
+ if (s.x !== undefined && last && last.x !== undefined) last.x += s.x;
175
+ else c.push(s);
176
+ }
177
+ return { t: n.tagName.toLowerCase(), a, c };
178
+ };
179
+ const compared = () => {
180
+ const out = [];
181
+ if (rooted()) {
182
+ const walk = (el, key) => {
183
+ out.push([key, el]);
184
+ let i = 0;
185
+ for (const k of Array.from(el.children)) if (!skipped(k)) walk(k, key + '/' + k.tagName.toLowerCase() + '[' + i++ + ']');
186
+ };
187
+ walk(document.body, 'body');
188
+ return out;
189
+ }
190
+ for (const s of surfaces()) for (const el of [s, ...Array.from(s.querySelectorAll('*'))]) {
191
+ const id = el.getAttribute('data-xui-id');
192
+ if (id !== null) { out.push([id, el]); continue; }
193
+ const part = el.getAttribute('data-xui-part');
194
+ if (part !== null && el.closest('[data-xui-layer]')) {
195
+ const owner = el.parentElement && el.parentElement.closest('[data-xui-id]');
196
+ out.push([(owner ? owner.getAttribute('data-xui-id') : '') + '/' + part, el]);
197
+ }
198
+ }
199
+ return out;
200
+ };
201
+ // Sorted on a primitive page: its :root carries custom properties, which
202
+ // Chromium enumerates in a per-document order (the hash is order-bound).
203
+ const properties = () => { const p = Array.from(getComputedStyle(document.documentElement)); return rooted() ? p.sort() : p; };
204
+ const hash = (str) => {
205
+ let h1 = 0xdeadbeef, h2 = 0x41c6ce57;
206
+ for (let i = 0; i < str.length; i++) { const ch = str.charCodeAt(i); h1 = Math.imul(h1 ^ ch, 2654435761); h2 = Math.imul(h2 ^ ch, 1597334677); }
207
+ h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
208
+ h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
209
+ return (h2 >>> 0).toString(16) + (h1 >>> 0).toString(16);
210
+ };
211
+ const valuesOf = (el, props) => { const cs = getComputedStyle(el); return props.map((p) => cs.getPropertyValue(p)); };
212
+ window.__xuiParity = {
213
+ dom: () => surfaces().map(ser),
214
+ table: () => {
215
+ const props = properties();
216
+ return { properties: ['(hash)'], count: props.length, elements: compared().map(([key, el]) => {
217
+ const r = el.getBoundingClientRect();
218
+ return { key, values: [hash(valuesOf(el, props).join('\\u0001'))], frame: { x: r.x, y: r.y, w: r.width, h: r.height } };
219
+ }) };
220
+ },
221
+ values: (i) => { const props = properties(); const e = compared()[i]; return { properties: props, elements: e ? [{ key: e[0], values: valuesOf(e[1], props), frame: { x: 0, y: 0, w: 0, h: 0 } }] : [] }; },
222
+ force: (state) => {
223
+ for (const s of surfaces()) for (const el of Array.from(s.querySelectorAll('[data-xui-part]'))) {
224
+ const prev = el.getAttribute('data-xs');
225
+ el.setAttribute('data-xs', prev ? prev + ' ' + state : state);
226
+ }
227
+ },
228
+ };
229
+ })()`
230
+
231
+ /** Motion off, applied identically to both pages AFTER the surface's own
232
+ * sheets (and `!important`, so no rule of theirs wins). */
233
+ const STILL_CSS = `*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}`
234
+
235
+ const frames = (page: Page, n = 2) => page.evaluate(`new Promise((r) => { const step = (k) => (k <= 0 ? r(null) : requestAnimationFrame(() => step(k - 1))); step(${n}) })`)
236
+
237
+ interface Side {
238
+ page: Page
239
+ context: BrowserContext
240
+ errors: string[]
241
+ }
242
+
243
+ async function openSide(browser: Browser, baseUrl: string, c: ParityCase, fontCss: string, fontAllow: Set<string>): Promise<Side> {
244
+ const context = await browser.newContext({ viewport: c.viewport, deviceScaleFactor: 1, locale: `en-US`, reducedMotion: `reduce`, timezoneId: `UTC` })
245
+ // Offline: the harness answers, the font set is served off disk, the
246
+ // fixtures' example.com media fail the same way on both sides.
247
+ await context.route(`**/*`, async (route) => {
248
+ const url = new URL(route.request().url())
249
+ if (url.pathname.startsWith(`/__xui-parity/fonts/`)) {
250
+ const rel = decodeURIComponent(url.pathname.slice(`/__xui-parity/fonts/`.length))
251
+ if (!fontAllow.has(rel)) return route.abort()
252
+ return route.fulfill({ status: 200, contentType: `font/ttf`, body: readFileSync(join(repoRoot, rel)) })
253
+ }
254
+ if (!route.request().url().startsWith(baseUrl)) return route.abort()
255
+ // The page request goes out WITHOUT its query (a tree in the URL is far
256
+ // longer than a server's request line): the harness serves one page for
257
+ // every query and the page still reads its own `location.search`.
258
+ if (route.request().isNavigationRequest() && url.search) return route.continue({ url: `${baseUrl}${url.pathname}` })
259
+ return route.continue()
260
+ })
261
+ // The font set, before the bundle runs (documentElement may not exist yet).
262
+ await context.addInitScript({
263
+ content: `(() => { const add = () => { if (!document.documentElement) return false; const s = document.createElement('style'); s.setAttribute('data-xui-parity', 'fonts'); s.textContent = ${JSON.stringify(fontCss)}; document.documentElement.appendChild(s); return true; }; if (!add()) { const mo = new MutationObserver(() => { if (add()) mo.disconnect(); }); mo.observe(document, { childList: true }); } })()`,
264
+ })
265
+ const page = await context.newPage()
266
+ const errors: string[] = []
267
+ page.on(`pageerror`, (e) => errors.push(String(e)))
268
+ page.on(`console`, (m) => {
269
+ if (m.type() === `error` && !m.text().startsWith(`Failed to load resource`)) errors.push(m.text())
270
+ })
271
+ await page.goto(pageUrl(baseUrl, c.query))
272
+ const primitive = c.group === `primitive`
273
+ await page.waitForSelector(primitive ? `body[data-xui-parity-root] [data-xui-specimen]` : `.xui-surface [data-xui-id]`, { state: `attached`, timeout: 30_000 })
274
+ await page.evaluate(`(() => { const s = document.createElement('style'); s.setAttribute('data-xui-parity', 'still'); s.textContent = ${JSON.stringify(STILL_CSS)}; document.body.appendChild(s); })()`)
275
+ await page.evaluate(`Promise.all(Array.from(document.fonts).map((f) => f.load().catch(() => undefined))).then(() => document.fonts.ready)`)
276
+ if (c.overlay) await page.waitForFunction(`typeof window.__xuiOverlay === 'function' && window.__xuiOverlay() !== null`, undefined, { timeout: 30_000 })
277
+ if (c.open) await page.waitForFunction(`typeof window.__xuiOverlay !== 'function' || window.__xuiOverlay() !== null`, undefined, { timeout: 30_000 })
278
+ await frames(page, 2)
279
+ await page.evaluate(PROBE)
280
+ if (c.state) {
281
+ await page.evaluate(`window.__xuiParity.force(${JSON.stringify(c.state)})`)
282
+ await frames(page, 2)
283
+ }
284
+ if (c.interact) {
285
+ const target = page.locator(`[data-xui-specimen] [data-xui-target]`).first()
286
+ await (c.interact === `hover` ? target.hover({ force: true }) : target.focus())
287
+ await frames(page, 2)
288
+ }
289
+ return { page, context, errors }
290
+ }
291
+
292
+ async function raster(png: Buffer): Promise<{ width: number; height: number; data: Uint8Array }> {
293
+ const { data, info } = await sharp(png).ensureAlpha().raw().toBuffer({ resolveWithObject: true })
294
+ return { width: info.width, height: info.height, data: new Uint8Array(data.buffer, data.byteOffset, data.length) }
295
+ }
296
+
297
+ async function compareCase(browser: Browser, ref: RunningHarness, cand: RunningHarness, c: ParityCase, fontCss: string, fontAllow: Set<string>, outDir: string): Promise<ParityCaseResult> {
298
+ const started = Date.now()
299
+ const result: ParityCaseResult = { case: c.id, ok: false, elapsedMs: 0 }
300
+ const sides: Side[] = []
301
+ try {
302
+ const [a, b] = await Promise.all([openSide(browser, ref.baseUrl, c, fontCss, fontAllow), openSide(browser, cand.baseUrl, c, fontCss, fontAllow)])
303
+ sides.push(a, b)
304
+ const errs = [...a.errors.map((e) => `reference: ${e}`), ...b.errors.map((e) => `candidate: ${e}`)]
305
+ if (errs.length) throw new Error(`page errors:\n${errs.join(`\n`)}`)
306
+ const [domA, domB] = (await Promise.all([a.page.evaluate(`window.__xuiParity.dom()`), b.page.evaluate(`window.__xuiParity.dom()`)])) as [DomNode[], DomNode[]]
307
+ result.dom = firstDomDiff(normalizeIds(domA), normalizeIds(domB))
308
+ const [tA, tB] = (await Promise.all([a.page.evaluate(`window.__xuiParity.table()`), b.page.evaluate(`window.__xuiParity.table()`)])) as [StyleTable, StyleTable]
309
+ const hashDiff = firstStyleDiff(tA, tB)
310
+ if (hashDiff && hashDiff.property === `(hash)`) {
311
+ // The first element whose computed style differs: its full values.
312
+ const i = tA.elements.findIndex((e, k) => e.values[0] !== tB.elements[k]?.values[0])
313
+ const [vA, vB] = (await Promise.all([a.page.evaluate(`window.__xuiParity.values(${i})`), b.page.evaluate(`window.__xuiParity.values(${i})`)])) as [StyleTable, StyleTable]
314
+ result.style = firstStyleDiff(vA, vB) ?? hashDiff
315
+ } else if (hashDiff) result.style = hashDiff
316
+ result.frame = firstFrameDiff(tA, tB)
317
+ // Native <video>/<audio> boxes are MASKED (same colour on both sides):
318
+ // the browser's own media controls run a loading spinner in the UA
319
+ // shadow root no author CSS stops; their DOM, style and frames are still
320
+ // compared above.
321
+ const shot = (p: Page) => p.screenshot({ fullPage: true, animations: `disabled`, caret: `hide`, mask: [p.locator(`.xui-surface video, .xui-surface audio, [data-xui-specimen] video, [data-xui-specimen] audio`)], maskColor: `#ff00ff` })
322
+ const [pA, pB] = await Promise.all([shot(a.page), shot(b.page)])
323
+ const [rA, rB] = await Promise.all([raster(pA), raster(pB)])
324
+ const size = `${rA.width}x${rA.height}`
325
+ let differing: number
326
+ let diff: Uint8Array | undefined
327
+ if (rA.width !== rB.width || rA.height !== rB.height) differing = Math.max(rA.width * rA.height, rB.width * rB.height)
328
+ else if (Buffer.from(rA.data).equals(Buffer.from(rB.data))) differing = 0
329
+ else {
330
+ diff = new Uint8Array(rA.data.length)
331
+ differing = pixelmatch(rA.data, rB.data, diff, rA.width, rA.height, { threshold: 0, includeAA: false })
332
+ }
333
+ result.pixels = { differing, size: rA.width === rB.width && rA.height === rB.height ? size : `${size} vs ${rB.width}x${rB.height}` }
334
+ if (differing > 0) {
335
+ mkdirSync(outDir, { recursive: true })
336
+ const base = join(outDir, caseSlug(c.id))
337
+ writeFileSync(`${base}.ref.png`, pA)
338
+ writeFileSync(`${base}.cand.png`, pB)
339
+ result.pixels.referencePng = `${base}.ref.png`
340
+ result.pixels.candidatePng = `${base}.cand.png`
341
+ if (diff) {
342
+ await sharp(Buffer.from(diff), { raw: { width: rA.width, height: rA.height, channels: 4 } }).png().toFile(`${base}.diff.png`)
343
+ result.pixels.diffPng = `${base}.diff.png`
344
+ }
345
+ }
346
+ result.ok = !result.dom && !result.style && !result.frame && differing === 0
347
+ } catch (e) {
348
+ result.error = e instanceof Error ? e.message : String(e)
349
+ } finally {
350
+ await Promise.all(sides.map((s) => s.context.close().catch(() => undefined)))
351
+ result.elapsedMs = Date.now() - started
352
+ }
353
+ for (const k of [`dom`, `style`, `frame`] as const) if (result[k] === undefined) delete result[k]
354
+ return result
355
+ }
356
+
357
+ export async function runParity(options: ParityOptions): Promise<ParityReport> {
358
+ const started = Date.now()
359
+ const cases = options.cases ?? (await parityCases())
360
+ const manifest = (await repoJson(MANIFEST_PATH)) as ConformanceManifest
361
+ const fonts = (await repoJson(manifest.fonts)) as FontManifest
362
+ const fontAllow = new Set(fontFiles(fonts))
363
+ const fontCss = fontFaceCss(fonts, (file) => `/__xui-parity/fonts/${file}`)
364
+ const outDir = options.outDir ?? join(repoRoot, `.conformance`, `parity`)
365
+ const [ref, cand] = await Promise.all([options.reference.start(), options.candidate.start()])
366
+ let browser: Browser | undefined
367
+ let chromium = `?`
368
+ const results: ParityCaseResult[] = new Array(cases.length)
369
+ try {
370
+ const args = [...PARITY_CHROMIUM_ARGS, ...(options.chrome?.args ?? [])]
371
+ browser = await launchChromium(args, options.chrome?.executablePath)
372
+ chromium = browser.version()
373
+ const b = browser
374
+ let next = 0
375
+ let done = 0
376
+ const worker = async () => {
377
+ for (let i = next++; i < cases.length; i = next++) {
378
+ results[i] = await compareCase(b, ref, cand, cases[i]!, fontCss, fontAllow, outDir)
379
+ // A harness that went away mid-case (respawned by bunHarness): once more.
380
+ if (results[i]!.error?.includes(`ERR_CONNECTION_REFUSED`)) {
381
+ await new Promise((r) => setTimeout(r, 3000))
382
+ results[i] = await compareCase(b, ref, cand, cases[i]!, fontCss, fontAllow, outDir)
383
+ }
384
+ done += 1
385
+ const r = results[i]!
386
+ if (!r.ok || done % 25 === 0) options.log?.(`${done}/${cases.length} ${r.ok ? `ok ` : `FAIL`} ${r.case} (${r.elapsedMs} ms)${r.ok ? `` : ` ${JSON.stringify({ error: r.error, dom: r.dom, style: r.style, frame: r.frame, pixels: r.pixels }).slice(0, 600)}`}`)
387
+ }
388
+ }
389
+ await Promise.all(Array.from({ length: Math.max(1, options.concurrency ?? 4) }, worker))
390
+ } finally {
391
+ await browser?.close()
392
+ await Promise.all([ref.stop(), cand.stop()])
393
+ }
394
+ const byGroup: Record<string, { cases: number; passed: number }> = {}
395
+ for (const [i, r] of results.entries()) {
396
+ const g = (byGroup[cases[i]!.group] ??= { cases: 0, passed: 0 })
397
+ g.cases += 1
398
+ if (r.ok) g.passed += 1
399
+ }
400
+ const passed = results.filter((r) => r.ok).length
401
+ return {
402
+ format: PARITY_FORMAT,
403
+ reference: options.names?.reference ?? `reference`,
404
+ candidate: options.names?.candidate ?? `candidate`,
405
+ chromium: `Chromium ${chromium} headless, ${PARITY_CHROMIUM_ARGS.join(` `)}, deviceScaleFactor 1, reduced motion`,
406
+ startedAt: new Date(started).toISOString(),
407
+ elapsedMs: Date.now() - started,
408
+ summary: { cases: results.length, passed, failed: results.length - passed, byGroup },
409
+ cases: results,
410
+ }
411
+ }
412
+
@@ -0,0 +1,177 @@
1
+ // The PERCEPTUAL step of the conformance harness (reported, never gating):
2
+ // per case, the web surface as Chromium paints it (the conformance page,
3
+ // Playwright element screenshot, deviceScaleFactor 1) next to the gpui
4
+ // painter in a REAL window under Xvfb (`conformance_dump --window`, wgpu on
5
+ // the machine's Vulkan — lavapipe works — and a FONTCONFIG_FILE that lists
6
+ // only the conformance fonts), compared over their common top-left area:
7
+ // the share of pixels pixelmatch calls different (threshold 0.1) and a mean
8
+ // SSIM over 8×8 luminance blocks. Files land in `<out>/pixels/`; nothing is
9
+ // written under shots/ (the committed store shots stay with
10
+ // packages/exponential-ui-react/scripts/shots.ts).
11
+
12
+ import { spawnSync } from "node:child_process"
13
+ import { existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync, rmSync } from "node:fs"
14
+ import { basename, join } from "node:path"
15
+ import pixelmatch from "pixelmatch"
16
+ import sharp from "sharp"
17
+ import { fontFiles, MANIFEST_PATH, type ConformanceManifest, type Dump, type FontManifest } from "./dump"
18
+
19
+ export interface PixelRow {
20
+ key: string
21
+ diffRatio?: number
22
+ ssim?: number
23
+ size?: string
24
+ files?: string
25
+ note?: string
26
+ }
27
+
28
+ export interface Raster {
29
+ width: number
30
+ height: number
31
+ data: Uint8Array
32
+ }
33
+
34
+ async function load(path: string): Promise<Raster> {
35
+ const { data, info } = await sharp(path).ensureAlpha().raw().toBuffer({ resolveWithObject: true })
36
+ return { width: info.width, height: info.height, data: new Uint8Array(data.buffer, data.byteOffset, data.length) }
37
+ }
38
+
39
+ /** The top-left `w × h` of a raster. */
40
+ export function crop(r: Raster, w: number, h: number): Raster {
41
+ const out = new Uint8Array(w * h * 4)
42
+ for (let y = 0; y < h; y++) out.set(r.data.subarray(y * r.width * 4, y * r.width * 4 + w * 4), y * w * 4)
43
+ return { width: w, height: h, data: out }
44
+ }
45
+
46
+ const luma = (d: Uint8Array, i: number) => 0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2]
47
+
48
+ /** Mean SSIM over non-overlapping 8×8 luminance blocks (same size inputs). */
49
+ export function ssim(a: Raster, b: Raster, block = 8): number {
50
+ const c1 = (0.01 * 255) ** 2
51
+ const c2 = (0.03 * 255) ** 2
52
+ let sum = 0
53
+ let n = 0
54
+ for (let by = 0; by + block <= a.height; by += block)
55
+ for (let bx = 0; bx + block <= a.width; bx += block) {
56
+ let ma = 0
57
+ let mb = 0
58
+ for (let y = by; y < by + block; y++)
59
+ for (let x = bx; x < bx + block; x++) {
60
+ const i = (y * a.width + x) * 4
61
+ ma += luma(a.data, i)
62
+ mb += luma(b.data, i)
63
+ }
64
+ const k = block * block
65
+ ma /= k
66
+ mb /= k
67
+ let va = 0
68
+ let vb = 0
69
+ let cov = 0
70
+ for (let y = by; y < by + block; y++)
71
+ for (let x = bx; x < bx + block; x++) {
72
+ const i = (y * a.width + x) * 4
73
+ const da = luma(a.data, i) - ma
74
+ const db = luma(b.data, i) - mb
75
+ va += da * da
76
+ vb += db * db
77
+ cov += da * db
78
+ }
79
+ va /= k - 1
80
+ vb /= k - 1
81
+ cov /= k - 1
82
+ sum += ((2 * ma * mb + c1) * (2 * cov + c2)) / ((ma * ma + mb * mb + c1) * (va + vb + c2))
83
+ n++
84
+ }
85
+ return n ? sum / n : 1
86
+ }
87
+
88
+ /** Compare two PNGs over their common area; writes the pixelmatch diff. */
89
+ export async function comparePngs(webPng: string, desktopPng: string, diffPng: string): Promise<{ diffRatio: number; ssim: number; size: string }> {
90
+ const a = await load(webPng)
91
+ const b = await load(desktopPng)
92
+ const w = Math.min(a.width, b.width)
93
+ const h = Math.min(a.height, b.height)
94
+ const ca = crop(a, w, h)
95
+ const cb = crop(b, w, h)
96
+ const diff = new Uint8Array(w * h * 4)
97
+ const changed = pixelmatch(ca.data, cb.data, diff, w, h, { threshold: 0.1 })
98
+ await sharp(Buffer.from(diff), { raw: { width: w, height: h, channels: 4 } })
99
+ .png()
100
+ .toFile(diffPng)
101
+ return { diffRatio: changed / (w * h), ssim: ssim(ca, cb), size: `web ${a.width}×${a.height} · gpui ${b.width}×${b.height} · compared ${w}×${h}` }
102
+ }
103
+
104
+ const has = (cmd: string) => spawnSync(`sh`, [`-c`, `command -v ${cmd}`]).status === 0
105
+
106
+ /** A fontconfig root holding ONLY the conformance font files (+ the last resort). */
107
+ function fontconfigDir(repoRoot: string, dir: string): string {
108
+ const manifest = JSON.parse(readFileSync(join(repoRoot, MANIFEST_PATH), `utf8`)) as ConformanceManifest
109
+ const fonts = JSON.parse(readFileSync(join(repoRoot, manifest.fonts), `utf8`)) as FontManifest
110
+ rmSync(dir, { recursive: true, force: true })
111
+ mkdirSync(join(dir, `fonts`), { recursive: true })
112
+ const files = fontFiles(fonts).map((f) => join(repoRoot, f))
113
+ for (const family of fonts.lastResort?.families ?? []) {
114
+ const r = spawnSync(`fc-match`, [`-f`, `%{family}\n%{file}`, family], { encoding: `utf8` })
115
+ const [found, file] = (r.stdout ?? ``).split(`\n`)
116
+ if (file && found?.split(`,`).some((f) => f.trim() === family)) {
117
+ files.push(file.trim())
118
+ break
119
+ }
120
+ }
121
+ for (const f of files) symlinkSync(f, join(dir, `fonts`, basename(f)))
122
+ const conf = join(dir, `fonts.conf`)
123
+ writeFileSync(conf, `<?xml version="1.0"?>\n<!DOCTYPE fontconfig SYSTEM "fonts.dtd">\n<fontconfig><dir>${join(dir, `fonts`)}</dir><cachedir>${join(dir, `cache`)}</cachedir></fontconfig>\n`)
124
+ return conf
125
+ }
126
+
127
+ export async function pixelStep(repoRoot: string, outDir: string, keys: string[]): Promise<PixelRow[]> {
128
+ const dir = join(outDir, `pixels`)
129
+ mkdirSync(dir, { recursive: true })
130
+ if (!keys.length) return []
131
+ // Web: the conformance page, one element screenshot per case.
132
+ const web = spawnSync(`bun`, [`scripts/conformance-web.ts`, `--keys`, keys.join(`,`), `--shots`, dir, `--out`, join(dir, `web-shots.json`)], { cwd: join(repoRoot, `packages/exponential-ui-react`), stdio: [`ignore`, `inherit`, `inherit`] })
133
+ const desktopDump = join(outDir, `desktop.json`)
134
+ const heights = existsSync(desktopDump) ? (JSON.parse(readFileSync(desktopDump, `utf8`)) as Dump).cases : {}
135
+ const missing = [`xvfb-run`, `xdotool`, `import`].filter((c) => !has(c))
136
+ const canDesktop = (process.platform === `linux` || process.platform === `freebsd`) && missing.length === 0
137
+ let built = false
138
+ if (canDesktop) built = spawnSync(`cargo`, [`build`, `-q`, `-p`, `exponential-ui-gpui`, `--example`, `conformance_dump`], { cwd: join(repoRoot, `apps/desktop`), stdio: [`ignore`, `inherit`, `inherit`] }).status === 0
139
+ const conf = canDesktop ? fontconfigDir(repoRoot, join(dir, `fontconfig`)) : ``
140
+ const rows: PixelRow[] = []
141
+ for (const key of keys) {
142
+ const slug = key.replaceAll(`/`, `_`)
143
+ const webPng = join(dir, `${slug}.web.png`)
144
+ if (web.status !== 0 || !existsSync(webPng)) {
145
+ rows.push({ key, note: `web screenshot failed` })
146
+ continue
147
+ }
148
+ if (!canDesktop) {
149
+ rows.push({ key, note: `desktop capture unavailable here (${missing.length ? `missing ${missing.join(`, `)}` : process.platform}); web shot ${webPng}` })
150
+ continue
151
+ }
152
+ if (!built) {
153
+ rows.push({ key, note: `the conformance_dump example did not build` })
154
+ continue
155
+ }
156
+ const width = Number(key.split(`/`)[3])
157
+ const height = Math.min(8000, Math.ceil(heights[key]?.height ?? 2000))
158
+ const deskPng = join(dir, `${slug}.desktop.png`)
159
+ rmSync(deskPng, { force: true })
160
+ // The window presents its first frame on input: move the pointer once.
161
+ const script = [
162
+ `cargo run -q -p exponential-ui-gpui --example conformance_dump -- --window '${key}' --height ${height} & P=$!`,
163
+ `for i in $(seq 1 240); do W=$(xdotool search --name '^xui-conformance$' 2>/dev/null | head -1); [ -n "$W" ] && break; sleep 0.5; done`,
164
+ `sleep 3; xdotool mousemove ${width + 5} 5; sleep 1; xdotool mousemove ${width + 6} 6; sleep 2`,
165
+ `[ -n "$W" ] && import -window "$W" '${deskPng}'`,
166
+ `kill $P`,
167
+ ].join(`\n`)
168
+ const r = spawnSync(`xvfb-run`, [`-a`, `-s`, `-screen 0 ${width + 16}x${height + 16}x24 +extension RENDER +extension Composite`, `sh`, `-c`, script], { cwd: join(repoRoot, `apps/desktop`), env: { ...process.env, FONTCONFIG_FILE: conf }, encoding: `utf8`, timeout: 300_000 })
169
+ if (!existsSync(deskPng)) {
170
+ rows.push({ key, note: `desktop capture failed (xvfb-run exit ${r.status}): ${(r.stderr ?? ``).split(`\n`).filter((l) => l && !l.includes(`libEGL`)).slice(-3).join(` | `)}` })
171
+ continue
172
+ }
173
+ const m = await comparePngs(webPng, deskPng, join(dir, `${slug}.diff.png`))
174
+ rows.push({ key, ...m, files: `${slug}.{web,desktop,diff}.png` })
175
+ }
176
+ return rows
177
+ }
@@ -0,0 +1,49 @@
1
+ // FEED-131: the framework-neutral half of both harnesses' `?view=primitives`
2
+ // page (packages/exponential-ui-{react,solid}/harness/primitives/mount.tsx):
3
+ // ?view=primitives&id=<specimen>&theme=<id>&mode=light|dark&width=<px>
4
+ // The <head> gets the unscoped shadcn variable block (`globalsCss`, `:root`
5
+ // + `.dark`), the package's OWN compiled stylesheet (served by its
6
+ // serve.ts at PRIMITIVES_SHEET_URL) and the page rules; `<html>` wears
7
+ // `class="dark"` in dark mode; `<body>` wears `data-xui-parity-root` (the
8
+ // web-parity probe then compares the WHOLE body) + `data-xui-mode`. The
9
+ // caller renders `<div data-xui-specimen="<id>" style="padding:16px">` into
10
+ // the returned container. Body inline style stays EMPTY (Radix writes it).
11
+
12
+ import { builtinTheme } from "../src/themes"
13
+ import { globalsCss } from "../src/web/theme-css"
14
+
15
+ /** Where each harness's serve.ts answers with its package's dist stylesheet. */
16
+ export const PRIMITIVES_SHEET_URL = `/dist/primitives.css`
17
+
18
+ /** The page's own rules (no preflight in the sheet): body chrome only. */
19
+ export const PRIMITIVES_PAGE_CSS = `body{margin:0;background:var(--background);color:var(--foreground);font-family:var(--font-sans)}`
20
+
21
+ export interface PrimitivesPage {
22
+ id: string
23
+ mode: `light` | `dark`
24
+ container: HTMLElement
25
+ }
26
+
27
+ const style = (name: string, css: string) => {
28
+ const s = document.createElement(`style`)
29
+ s.setAttribute(`data-xui-primitives`, name)
30
+ s.textContent = css
31
+ document.head.appendChild(s)
32
+ }
33
+
34
+ /** Head styles + root attributes, then the stylesheet fetched and applied:
35
+ * resolves once the page is ready to render the specimen. */
36
+ export async function preparePrimitivesPage(params = new URLSearchParams(location.search)): Promise<PrimitivesPage> {
37
+ const id = params.get(`id`) ?? ``
38
+ const mode = params.get(`mode`) === `dark` ? `dark` : `light`
39
+ const theme = builtinTheme(params.get(`theme`) ?? `exponential`)
40
+ if (mode === `dark`) document.documentElement.classList.add(`dark`)
41
+ document.body.setAttribute(`data-xui-parity-root`, ``)
42
+ document.body.setAttribute(`data-xui-mode`, mode)
43
+ style(`globals`, globalsCss(theme))
44
+ const r = await fetch(PRIMITIVES_SHEET_URL)
45
+ if (!r.ok) throw new Error(`primitives: ${PRIMITIVES_SHEET_URL} answered ${r.status}`)
46
+ style(`sheet`, await r.text())
47
+ style(`page`, PRIMITIVES_PAGE_CSS)
48
+ return { id, mode, container: document.getElementById(`app`)! }
49
+ }