@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.
- package/LICENSE +201 -0
- package/NOTICE +54 -0
- package/README.md +360 -2
- package/catalog/a11y.json +668 -0
- package/catalog/basic-map.json +104 -0
- package/catalog/code.json +58 -0
- package/catalog/core.catalog.json +4752 -0
- package/catalog/core.schema.json +22667 -0
- package/catalog/host.json +64 -0
- package/catalog/layout.json +20 -0
- package/catalog/limits.json +9 -0
- package/catalog/locale.json +48 -0
- package/catalog/macros.json +3417 -0
- package/catalog/recipes.json +61 -0
- package/catalog/strings.json +72 -0
- package/catalog/style.json +128 -0
- package/catalog/theme.schema.json +42543 -0
- package/catalog/tokens.json +47 -0
- package/conformance/README.md +217 -0
- package/conformance/baseline.ts +101 -0
- package/conformance/browser-page.ts +146 -0
- package/conformance/browser-suites.ts +683 -0
- package/conformance/compare.ts +201 -0
- package/conformance/dom.ts +96 -0
- package/conformance/dump.ts +163 -0
- package/conformance/fonts.json +154 -0
- package/conformance/manifest.json +224 -0
- package/conformance/parity-compare.ts +191 -0
- package/conformance/parity-report.schema.json +73 -0
- package/conformance/parity.ts +412 -0
- package/conformance/pixel.ts +177 -0
- package/conformance/primitives-page.ts +49 -0
- package/conformance/report.schema.json +66 -0
- package/conformance/run.ts +118 -0
- package/dist/chunks/index-98npjvsd.js +3940 -0
- package/dist/chunks/index-98npjvsd.js.map +27 -0
- package/dist/chunks/index-gwkt14w3.js +7314 -0
- package/dist/chunks/index-gwkt14w3.js.map +10 -0
- package/dist/chunks/index-njt7yzzm.js +134 -0
- package/dist/chunks/index-njt7yzzm.js.map +9 -0
- package/dist/chunks/index-we7va6v6.js +7984 -0
- package/dist/chunks/index-we7va6v6.js.map +15 -0
- package/dist/chunks/index-xrm6f7wc.js +2563 -0
- package/dist/chunks/index-xrm6f7wc.js.map +10 -0
- package/dist/conformance.js +182749 -0
- package/dist/conformance.js.map +10 -0
- package/dist/entries/prompt.js +251 -0
- package/dist/entries/prompt.js.map +11 -0
- package/dist/entries/schema.js +1725 -0
- package/dist/entries/schema.js.map +11 -0
- package/dist/host/index.js +122 -0
- package/dist/host/index.js.map +9 -0
- package/dist/index.js +3213 -0
- package/dist/index.js.map +27 -0
- package/dist/types/src/a11y.d.ts +21 -0
- package/dist/types/src/animation.d.ts +76 -0
- package/dist/types/src/basic-map.d.ts +38 -0
- package/dist/types/src/builder.d.ts +50 -0
- package/dist/types/src/catalog.d.ts +54 -0
- package/dist/types/src/catalog.described.d.ts +33 -0
- package/dist/types/src/catalog.generated.d.ts +829 -0
- package/dist/types/src/chart.d.ts +46 -0
- package/dist/types/src/code.d.ts +44 -0
- package/dist/types/src/color.d.ts +20 -0
- package/dist/types/src/conformance.d.ts +39 -0
- package/dist/types/src/connector/app.d.ts +14 -0
- package/dist/types/src/connector/connector.d.ts +77 -0
- package/dist/types/src/connector/index.d.ts +4 -0
- package/dist/types/src/css.d.ts +29 -0
- package/dist/types/src/define.d.ts +66 -0
- package/dist/types/src/direction.d.ts +16 -0
- package/dist/types/src/dynamic.d.ts +159 -0
- package/dist/types/src/entries/prompt.d.ts +11 -0
- package/dist/types/src/entries/schema.d.ts +8 -0
- package/dist/types/src/expr.d.ts +71 -0
- package/dist/types/src/extension.d.ts +4 -0
- package/dist/types/src/format.d.ts +112 -0
- package/dist/types/src/geometry.d.ts +40 -0
- package/dist/types/src/host/contract.d.ts +188 -0
- package/dist/types/src/host/decoders.d.ts +61 -0
- package/dist/types/src/host/index.d.ts +11 -0
- package/dist/types/src/host/package.d.ts +58 -0
- package/dist/types/src/host/policy.d.ts +105 -0
- package/dist/types/src/host/router.d.ts +34 -0
- package/dist/types/src/host/runtime.d.ts +205 -0
- package/dist/types/src/host/sources.d.ts +15 -0
- package/dist/types/src/host/transports.d.ts +109 -0
- package/dist/types/src/index.d.ts +63 -0
- package/dist/types/src/layout.d.ts +20 -0
- package/dist/types/src/limits.d.ts +21 -0
- package/dist/types/src/list.d.ts +133 -0
- package/dist/types/src/locale.d.ts +22 -0
- package/dist/types/src/macros.d.ts +29 -0
- package/dist/types/src/overlay.d.ts +27 -0
- package/dist/types/src/prompt.d.ts +44 -0
- package/dist/types/src/recipes.d.ts +17 -0
- package/dist/types/src/reducer.d.ts +41 -0
- package/dist/types/src/resizable.d.ts +38 -0
- package/dist/types/src/schema.d.ts +7 -0
- package/dist/types/src/strings.d.ts +13 -0
- package/dist/types/src/style.d.ts +201 -0
- package/dist/types/src/test-preload.d.ts +1 -0
- package/dist/types/src/text-shimmer.d.ts +24 -0
- package/dist/types/src/theme-types.d.ts +162 -0
- package/dist/types/src/theme.d.ts +73 -0
- package/dist/types/src/themes.d.ts +22 -0
- package/dist/types/src/tree-guides.d.ts +17 -0
- package/dist/types/src/types.d.ts +314 -0
- package/dist/types/src/validate.d.ts +31 -0
- package/dist/types/src/web/base-css.d.ts +1 -0
- package/dist/types/src/web/box-css.d.ts +55 -0
- package/dist/types/src/web/coerce.d.ts +32 -0
- package/dist/types/src/web/data.d.ts +105 -0
- package/dist/types/src/web/dates.d.ts +12 -0
- package/dist/types/src/web/form.d.ts +44 -0
- package/dist/types/src/web/host-types.d.ts +119 -0
- package/dist/types/src/web/index.d.ts +28 -0
- package/dist/types/src/web/inputs.d.ts +55 -0
- package/dist/types/src/web/list.d.ts +13 -0
- package/dist/types/src/web/listbox.d.ts +14 -0
- package/dist/types/src/web/markdown.d.ts +96 -0
- package/dist/types/src/web/media.d.ts +62 -0
- package/dist/types/src/web/parts.d.ts +4 -0
- package/dist/types/src/web/primitives/alert-dialog.d.ts +10 -0
- package/dist/types/src/web/primitives/alert.d.ts +9 -0
- package/dist/types/src/web/primitives/avatar-color.d.ts +2 -0
- package/dist/types/src/web/primitives/avatar.d.ts +13 -0
- package/dist/types/src/web/primitives/badge.d.ts +10 -0
- package/dist/types/src/web/primitives/breadcrumb.d.ts +10 -0
- package/dist/types/src/web/primitives/button.d.ts +6 -0
- package/dist/types/src/web/primitives/calendar.d.ts +43 -0
- package/dist/types/src/web/primitives/card.d.ts +9 -0
- package/dist/types/src/web/primitives/checkbox.d.ts +5 -0
- package/dist/types/src/web/primitives/choice-tiles.d.ts +32 -0
- package/dist/types/src/web/primitives/cn.d.ts +3 -0
- package/dist/types/src/web/primitives/code-block.d.ts +24 -0
- package/dist/types/src/web/primitives/command.d.ts +134 -0
- package/dist/types/src/web/primitives/context-ring.d.ts +24 -0
- package/dist/types/src/web/primitives/dialog-arms.d.ts +6 -0
- package/dist/types/src/web/primitives/dialog.d.ts +24 -0
- package/dist/types/src/web/primitives/disclosure-header.d.ts +5 -0
- package/dist/types/src/web/primitives/dock.d.ts +42 -0
- package/dist/types/src/web/primitives/dropdown-menu.d.ts +14 -0
- package/dist/types/src/web/primitives/empty-state.d.ts +15 -0
- package/dist/types/src/web/primitives/glass-card.d.ts +4 -0
- package/dist/types/src/web/primitives/hover-card.d.ts +7 -0
- package/dist/types/src/web/primitives/icon-disc.d.ts +12 -0
- package/dist/types/src/web/primitives/index.d.ts +48 -0
- package/dist/types/src/web/primitives/input.d.ts +5 -0
- package/dist/types/src/web/primitives/kbd.d.ts +4 -0
- package/dist/types/src/web/primitives/label.d.ts +3 -0
- package/dist/types/src/web/primitives/menu-surface.d.ts +17 -0
- package/dist/types/src/web/primitives/meter.d.ts +21 -0
- package/dist/types/src/web/primitives/popover.d.ts +3 -0
- package/dist/types/src/web/primitives/progress-ring.d.ts +5 -0
- package/dist/types/src/web/primitives/progress.d.ts +8 -0
- package/dist/types/src/web/primitives/scroll-area.d.ts +49 -0
- package/dist/types/src/web/primitives/segmented-control.d.ts +5 -0
- package/dist/types/src/web/primitives/select.d.ts +47 -0
- package/dist/types/src/web/primitives/separator.d.ts +3 -0
- package/dist/types/src/web/primitives/sheet-chrome.d.ts +4 -0
- package/dist/types/src/web/primitives/sheet.d.ts +20 -0
- package/dist/types/src/web/primitives/skeleton.d.ts +3 -0
- package/dist/types/src/web/primitives/spinner.d.ts +3 -0
- package/dist/types/src/web/primitives/switch.d.ts +4 -0
- package/dist/types/src/web/primitives/table.d.ts +11 -0
- package/dist/types/src/web/primitives/tabs.d.ts +13 -0
- package/dist/types/src/web/primitives/textarea.d.ts +3 -0
- package/dist/types/src/web/primitives/toast.d.ts +163 -0
- package/dist/types/src/web/primitives/tooltip.d.ts +3 -0
- package/dist/types/src/web/primitives/truncate.d.ts +7 -0
- package/dist/types/src/web/primitives/typeahead-portal.d.ts +12 -0
- package/dist/types/src/web/share.d.ts +6 -0
- package/dist/types/src/web/table.d.ts +20 -0
- package/dist/types/src/web/theme-css.d.ts +69 -0
- package/dist/types/src/web/urls.d.ts +21 -0
- package/dist/web/index.js +2303 -0
- package/dist/web/index.js.map +26 -0
- package/dist/web/primitives/index.js +1450 -0
- package/dist/web/primitives/index.js.map +57 -0
- package/docs/components.generated.json +6238 -0
- package/docs/round-1-contract.md +679 -0
- package/docs/round-2-contract.md +476 -0
- package/docs/round-3-contract.md +174 -0
- package/docs/themes.generated.json +3503 -0
- package/fixtures/animations.json +2890 -0
- package/fixtures/bench-list.json +58 -0
- package/fixtures/bind-time.json +30664 -0
- package/fixtures/catalog-basic-map.json +1692 -0
- package/fixtures/catalog-components.json +9323 -0
- package/fixtures/catalog-extension.json +620 -0
- package/fixtures/catalog-macros.json +36370 -0
- package/fixtures/code-tokens.json +1993 -0
- package/fixtures/conformance-baseline.json +55544 -0
- package/fixtures/conformance-cases.json +49 -0
- package/fixtures/conformance-known.json +2123 -0
- package/fixtures/control-geometry.json +4200 -0
- package/fixtures/dashboard.data.json +74 -0
- package/fixtures/dashboard.json +752 -0
- package/fixtures/demo-surface.json +47 -0
- package/fixtures/format.json +870 -0
- package/fixtures/host-policy.json +1067 -0
- package/fixtures/host-router.json +1152 -0
- package/fixtures/host-transport.json +590 -0
- package/fixtures/interactions-round1.json +773 -0
- package/fixtures/kitchen-sink.expanded.json +10844 -0
- package/fixtures/kitchen-sink.json +3049 -0
- package/fixtures/layout-geometry-round1.json +7278 -0
- package/fixtures/layout-geometry.json +2072 -0
- package/fixtures/overlay-geometry.json +272 -0
- package/fixtures/primitive-specimens.json +58 -0
- package/fixtures/prompt-budget.json +33 -0
- package/fixtures/resizable.json +714 -0
- package/fixtures/round4-contract.json +2985 -0
- package/fixtures/specimens.json +9832 -0
- package/fixtures/style-conditions.json +336 -0
- package/fixtures/template-items.json +2215 -0
- package/fixtures/text-direction.json +309 -0
- package/fixtures/text-shimmer.json +96 -0
- package/fixtures/theme-extends.json +573 -0
- package/fixtures/theme-invalid.json +1296 -0
- package/fixtures/theme-recipes.json +58600 -0
- package/fixtures/theme-resolved.json +23949 -0
- package/fixtures/toast-stack.json +215 -0
- package/fixtures/tree-guides.json +224 -0
- package/fixtures/virtual-list.json +733 -0
- package/generated/ExponentialUICatalog.generated.kt +67 -0
- package/generated/ExponentialUICatalog.generated.swift +67 -0
- package/generated/ExponentialUIThemes.generated.kt +23 -0
- package/generated/ExponentialUIThemes.generated.swift +23 -0
- package/generated/catalog.descriptions.generated.rs +6 -0
- package/generated/catalog.generated.rs +80 -0
- package/generated/catalog.themes.generated.rs +34 -0
- package/generated/core.descriptions.json +2505 -0
- package/generated/core.runtime.json +3609 -0
- package/package.json +87 -4
- package/themes/exponential.recipes.json +49 -0
- package/themes/exponential.theme.json +755 -0
- package/themes/neutral.theme.json +5676 -0
- package/themes/orange.theme.json +43 -0
- package/themes/playful.theme.json +219 -0
- package/vendor/a2ui/LICENSE.txt +203 -0
- package/vendor/a2ui/README.md +27 -0
- package/vendor/a2ui/v0_9/basic_catalog.json +1387 -0
- package/vendor/a2ui/v0_9/basic_catalog_rules.txt +5 -0
- package/vendor/a2ui/v0_9/client_capabilities.json +97 -0
- package/vendor/a2ui/v0_9/client_to_server.json +104 -0
- package/vendor/a2ui/v0_9/common_types.json +315 -0
- package/vendor/a2ui/v0_9/server_to_client.json +132 -0
- package/vendor/json-render/LICENSE.txt +201 -0
- 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
|
+
}
|