@surlymochan/evalid 0.0.0-stage → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +74 -0
- package/LICENSE +21 -0
- package/README.md +315 -2
- package/RELEASE_NOTES.md +102 -0
- package/atoms/registry.json +758 -0
- package/bin/xflow.js +7208 -0
- package/docs/adoption/README.md +128 -0
- package/docs/adoption/openflow-release-hardening.md +61 -0
- package/docs/benchmark-openflow-vs-gstack-superpowers-openspec-2026-05-25.md +81 -0
- package/docs/compatibility.md +52 -0
- package/docs/competitive-benchmark.md +108 -0
- package/docs/corps-operator-guide.md +75 -0
- package/docs/demo-proof.md +126 -0
- package/docs/examples-gallery.md +115 -0
- package/docs/fixtures/tracker-item.json +7 -0
- package/docs/goal-vs-codex.md +129 -0
- package/docs/gstack-comparison.md +88 -0
- package/docs/install-upgrade.md +133 -0
- package/docs/integrations.md +49 -0
- package/docs/launch-demo.md +123 -0
- package/docs/launch-dossier.md +180 -0
- package/docs/methodology.md +149 -0
- package/docs/npm-publish-handoff.md +83 -0
- package/docs/openspec-migration.md +106 -0
- package/docs/public-benchmark.md +68 -0
- package/docs/public-release.md +145 -0
- package/docs/quality-assessment.md +139 -0
- package/docs/quickstart.md +156 -0
- package/docs/reviewer-guide.md +43 -0
- package/docs/spec-kit-benchmark.md +73 -0
- package/docs/super-assistant-comparison.md +106 -0
- package/docs/superpowers-comparison.md +82 -0
- package/docs/team-adoption.md +123 -0
- package/docs/tooling-matrix.md +91 -0
- package/docs/walkthrough.md +76 -0
- package/docs/workflow-manual.md +1125 -0
- package/package.json +83 -4
- package/prompts/pencil-draft.md +46 -0
- package/prompts/pencil-refine.md +46 -0
- package/prompts/phase-artifact.md +24 -0
- package/prompts/phase-execution.md +32 -0
- package/schemas/competitor-reconstruction-contract.schema.json +91 -0
- package/schemas/llm-design-review-aggregate.schema.json +83 -0
- package/schemas/visual-benchmark.schema.json +978 -0
- package/schemas/workflow.schema.json +100 -0
- package/scripts/run-tests.mjs +23 -0
- package/scripts/takein_digest.py +139 -0
- package/specs/README.md +118 -0
- package/specs/architecture.md +36 -0
- package/specs/product.md +33 -0
- package/specs/workflow.md +349 -0
- package/src/agent_team/atoms/codex-app-bridge.js +8 -0
- package/src/agent_team/atoms/design/competitor-reconstruction-review.js +44 -0
- package/src/agent_team/atoms/design/contract-freeze.js +8 -0
- package/src/agent_team/atoms/design/generation-contract.js +442 -0
- package/src/agent_team/atoms/design/reconstruction-pack.js +246 -0
- package/src/agent_team/atoms/design/reference-surface-lock.js +49 -0
- package/src/agent_team/atoms/patch-challenge.js +19 -0
- package/src/agent_team/atoms/pencil/accept.js +9 -0
- package/src/agent_team/atoms/pencil/draft.js +365 -0
- package/src/agent_team/atoms/pencil/refine.js +229 -0
- package/src/agent_team/atoms/phase-run-dispatch.js +8 -0
- package/src/agent_team/atoms/team-run.js +380 -0
- package/src/agent_team/atoms/visual/aesthetic-review.js +231 -0
- package/src/agent_team/atoms/visual/benchmark-repair-plan.js +291 -0
- package/src/agent_team/atoms/visual/benchmark.js +1652 -0
- package/src/agent_team/atoms/visual/design-selection.js +23 -0
- package/src/agent_team/atoms/visual/direction-synthesis.js +124 -0
- package/src/agent_team/atoms/visual/dom-rect-export.js +129 -0
- package/src/agent_team/atoms/visual/image-diff.js +186 -0
- package/src/agent_team/atoms/visual/layout-competition.js +8 -0
- package/src/agent_team/atoms/visual/page-evidence-capture.js +806 -0
- package/src/agent_team/atoms/visual/playwright-runtime.js +8 -0
- package/src/agent_team/atoms/visual/review-aggregate.js +87 -0
- package/src/agent_team/atoms/visual/review.js +77 -0
- package/src/agent_team/atoms/visual/visual-report.js +197 -0
- package/src/agent_team/atoms/visual/visual-token-export.js +417 -0
- package/src/agent_team/common.js +311 -0
- package/src/core/atoms/agent-invoke.js +684 -0
- package/src/core/atoms/event/ingest.js +30 -0
- package/src/core/atoms/experience/digest.js +55 -0
- package/src/core/atoms/gate/artifact-verify.js +83 -0
- package/src/core/atoms/gate/competitor-contract-validate.js +119 -0
- package/src/core/atoms/gate/evidence-collect.js +64 -0
- package/src/core/atoms/gate/payload-from-phase-run.js +78 -0
- package/src/core/atoms/gate/visual-benchmark-validate.js +183 -0
- package/src/core/atoms/gate/workbench.js +79 -0
- package/src/core/atoms/intake/classify.js +15 -0
- package/src/core/atoms/intake/design-skill-select.js +16 -0
- package/src/core/atoms/intake/preview-or-refine.js +19 -0
- package/src/core/atoms/intake/recommend.js +20 -0
- package/src/core/atoms/intake/research-same-category.js +19 -0
- package/src/core/atoms/intervention/create.js +30 -0
- package/src/core/atoms/memory/manage.js +27 -0
- package/src/core/atoms/memory/mount.js +13 -0
- package/src/core/atoms/mission/create.js +19 -0
- package/src/core/atoms/mission/list.js +14 -0
- package/src/core/atoms/mission/show.js +26 -0
- package/src/core/atoms/notify/checkpoint.js +46 -0
- package/src/core/atoms/phase-run/complete.js +42 -0
- package/src/core/atoms/phase-run/fail.js +40 -0
- package/src/core/atoms/phase-run/persist.js +39 -0
- package/src/core/atoms/phase-run/start.js +30 -0
- package/src/core/atoms/plugin/run.js +76 -0
- package/src/core/atoms/policy/gate.js +90 -0
- package/src/core/atoms/policy/resolve.js +11 -0
- package/src/core/atoms/storage/sync.js +57 -0
- package/src/core/atoms/workflow/advance.js +25 -0
- package/src/core/atoms/workflow/guard.js +127 -0
- package/src/core/atoms/workflow/state-load.js +37 -0
- package/src/core/bindings.js +37 -0
- package/src/core/change-artifacts.js +134 -0
- package/src/core/competitive-score.js +265 -0
- package/src/core/concurrency-policy.js +35 -0
- package/src/core/execution-log.js +108 -0
- package/src/core/intake-recommendation.js +134 -0
- package/src/core/memory-state.js +62 -0
- package/src/core/mission-state.js +780 -0
- package/src/core/pending-gates.js +66 -0
- package/src/core/phase-cost.js +116 -0
- package/src/core/phase-run-read.js +27 -0
- package/src/core/policy-overlay.js +180 -0
- package/src/core/state-store.js +201 -0
- package/src/core/workflow-executor.js +998 -0
- package/src/core/workflow-integrity.js +111 -0
- package/src/core/workflow-loader.js +163 -0
- package/src/core/workflow-rerun.js +84 -0
- package/src/core/workflow-state.js +119 -0
- package/src/server.js +1307 -0
- package/workflows/corps.yaml +479 -0
- package/workflows/yolo.yaml +233 -0
- package/xflow/README.md +76 -0
- package/xflow/aha/SKILL.md +82 -0
- package/xflow/aha/scripts/append_aha.py +50 -0
- package/xflow/archive/scripts/archive_change.py +338 -0
- package/xflow/archive/templates/merge-design.md +54 -0
- package/xflow/archive/templates/merge-workflow.md +26 -0
- package/xflow/atoms/__init__.py +6 -0
- package/xflow/atoms/a1_issue_create_or_patch.py +70 -0
- package/xflow/atoms/a2_branch_create_and_link.py +42 -0
- package/xflow/atoms/a3_repo_doctor.py +11 -0
- package/xflow/atoms/a4_project_set_status.py +11 -0
- package/xflow/atoms/a5_archive_commit_push_close.py +11 -0
- package/xflow/atoms/a6_pr_create.py +72 -0
- package/xflow/atoms/b1_change_scaffold.py +11 -0
- package/xflow/atoms/b2_status_read.py +16 -0
- package/xflow/atoms/b2b_status_write.py +20 -0
- package/xflow/atoms/b3_status_transition.py +27 -0
- package/xflow/atoms/b4_handoff_scaffold_or_refresh.py +11 -0
- package/xflow/atoms/b5_aha_append.py +11 -0
- package/xflow/atoms/b6_takein_digest.py +11 -0
- package/xflow/atoms/b6b_lookback_digest.py +11 -0
- package/xflow/atoms/b7_change_mission_bind.py +43 -0
- package/xflow/atoms/c1_mem.py +54 -0
- package/xflow/atoms/e6_gate_local_precheck.py +23 -0
- package/xflow/atoms/h1_design_lite_gate.py +74 -0
- package/xflow/atoms/i6a_tdd_run.py +104 -0
- package/xflow/atoms/i6b_tdd_proof_validate.py +165 -0
- package/xflow/atoms/i6c_tdd_quality_review.py +357 -0
- package/xflow/atoms/j1_tests_run.py +129 -0
- package/xflow/atoms/j4a_spec_consistency_structural.py +105 -0
- package/xflow/atoms/j4b_spec_delta_review.py +316 -0
- package/xflow/atoms/j4c_openspec_migration_map.py +121 -0
- package/xflow/atoms/j4d_spec_clarify_gate.py +373 -0
- package/xflow/atoms/j6_cr_review.py +443 -0
- package/xflow/atoms/k1_artifacts_complete_check.py +39 -0
- package/xflow/atoms/k2_merge_snippets_apply.py +11 -0
- package/xflow/atoms/k3_handoff_refresh.py +14 -0
- package/xflow/atoms/k4_mem_lesson_persist.py +65 -0
- package/xflow/atoms/k5_archive_publish.py +21 -0
- package/xflow/atoms/k6_aha_merge.py +42 -0
- package/xflow/atoms/tdd_lib.py +747 -0
- package/xflow/corps/SKILL.md +109 -0
- package/xflow/exec/scripts/set_issue_in_progress.py +159 -0
- package/xflow/goal/SKILL.md +150 -0
- package/xflow/handoff/SKILL.md +95 -0
- package/xflow/handoff/scripts/scaffold_handoff.py +304 -0
- package/xflow/lookback/SKILL.md +66 -0
- package/xflow/lookback/scripts/lookback_digest.py +46 -0
- package/xflow/openissue/scripts/open_issue_flow.py +438 -0
- package/xflow/plan/SKILL.md +38 -0
- package/xflow/ralph/SKILL.md +123 -0
- package/xflow/scripts/check_installed_xflow_skill_sync.sh +44 -0
- package/xflow/scripts/common.py +480 -0
- package/xflow/scripts/doctor.py +278 -0
- package/xflow/scripts/sync_installed_xflow_skill.sh +134 -0
- package/xflow/takein/SKILL.md +81 -0
- package/xflow/takein/scripts/takein_digest.py +88 -0
- package/xflow/yolo/SKILL.md +66 -0
- package/xflow/yolo/scripts/scaffold_specs_change.py +673 -0
- package/xflow/yolo/scripts/yolo_gate.py +312 -0
- package/xflow/yolo/templates/DESIGN.md +179 -0
|
@@ -0,0 +1,1652 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, relative, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { readJsonFile, resolveChangePath, writeJsonFile } from '../../../core/change-artifacts.js';
|
|
5
|
+
import { computeImageDiffMetrics } from './image-diff.js';
|
|
6
|
+
import { exportDomRectsFromSnapshot } from './dom-rect-export.js';
|
|
7
|
+
import { capturePageEvidence } from './page-evidence-capture.js';
|
|
8
|
+
import { renderVisualBenchmarkReport } from './visual-report.js';
|
|
9
|
+
import { exportVisualTokensFromSnapshot } from './visual-token-export.js';
|
|
10
|
+
|
|
11
|
+
const DEFAULT_THRESHOLDS = {
|
|
12
|
+
structural_similarity_min: 0.9,
|
|
13
|
+
layout_shift_score_max: 0.08,
|
|
14
|
+
pixel_diff_ratio_max: 0.12,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function resolveEvidencePath(projectRoot, filePath) {
|
|
18
|
+
if (!filePath) return null;
|
|
19
|
+
return isAbsolute(filePath) ? filePath : resolve(projectRoot, filePath);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseReferenceScenarios(input) {
|
|
23
|
+
if (Array.isArray(input.reference_scenarios)) return input.reference_scenarios;
|
|
24
|
+
const raw = input.reference_scenarios_json;
|
|
25
|
+
if (Array.isArray(raw)) return raw;
|
|
26
|
+
if (typeof raw === 'string' && raw.trim()) {
|
|
27
|
+
try {
|
|
28
|
+
const parsed = JSON.parse(raw);
|
|
29
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
30
|
+
} catch {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parseObjectLike(value) {
|
|
38
|
+
if (!value) return null;
|
|
39
|
+
if (typeof value === 'object' && !Array.isArray(value)) return value;
|
|
40
|
+
if (typeof value === 'string' && value.trim()) {
|
|
41
|
+
try {
|
|
42
|
+
const parsed = JSON.parse(value);
|
|
43
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function parseArrayLike(value) {
|
|
52
|
+
if (Array.isArray(value)) return value;
|
|
53
|
+
if (typeof value === 'string' && value.trim()) {
|
|
54
|
+
try {
|
|
55
|
+
const parsed = JSON.parse(value);
|
|
56
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
57
|
+
} catch {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseViewportVariants(input = {}) {
|
|
65
|
+
const raw = input.viewport_variants_json ?? input.viewport_variants;
|
|
66
|
+
return parseArrayLike(raw)
|
|
67
|
+
.filter((entry) => entry && typeof entry === 'object')
|
|
68
|
+
.map((entry, index) => ({
|
|
69
|
+
id: typeof entry.id === 'string' && entry.id.trim() ? entry.id.trim() : `viewport-${index + 1}`,
|
|
70
|
+
viewport: parseObjectLike(entry.viewport) || (entry.viewport && typeof entry.viewport === 'object' ? entry.viewport : null),
|
|
71
|
+
platform_profile: typeof entry.platform_profile === 'string' ? entry.platform_profile : null,
|
|
72
|
+
reference_image: typeof entry.reference_image === 'string' ? entry.reference_image : null,
|
|
73
|
+
capture_states: parseArrayLike(entry.capture_states_json).length > 0 ? parseArrayLike(entry.capture_states_json) : (Array.isArray(entry.capture_states) ? entry.capture_states : []),
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function buildImplicitReferenceScenarios(input = {}) {
|
|
78
|
+
if (!input.capture_url || !input.reference_image) return [];
|
|
79
|
+
const viewportVariants = parseViewportVariants(input);
|
|
80
|
+
if (viewportVariants.length > 0) {
|
|
81
|
+
return viewportVariants.map((variant, index) => ({
|
|
82
|
+
id: variant.id || `captured-viewport-${index + 1}`,
|
|
83
|
+
viewport: variant.viewport || parseObjectLike(input.viewport) || {
|
|
84
|
+
width: Number.isFinite(Number(input.width)) ? Number(input.width) : 1440,
|
|
85
|
+
height: Number.isFinite(Number(input.height)) ? Number(input.height) : 900,
|
|
86
|
+
},
|
|
87
|
+
capture_url: input.capture_url,
|
|
88
|
+
platform_profile: variant.platform_profile || input.platform_profile || null,
|
|
89
|
+
reference_image: variant.reference_image || input.reference_image,
|
|
90
|
+
layout_contract: parseObjectLike(input.layout_contract) || parseObjectLike(input.layout_contract_json) || null,
|
|
91
|
+
visual_token_contract: parseObjectLike(input.visual_token_contract) || parseObjectLike(input.visual_token_contract_json) || null,
|
|
92
|
+
structure_checks: Array.isArray(input.structure_checks) ? input.structure_checks : [],
|
|
93
|
+
capture_states: variant.capture_states.length > 0
|
|
94
|
+
? variant.capture_states
|
|
95
|
+
: (parseArrayLike(input.capture_states_json).length > 0 ? parseArrayLike(input.capture_states_json) : (Array.isArray(input.capture_states) ? input.capture_states : [])),
|
|
96
|
+
auto_discover_states: input.auto_discover_states === true,
|
|
97
|
+
state_limit: Number.isFinite(Number(input.state_limit)) ? Number(input.state_limit) : null,
|
|
98
|
+
panel_attr: input.panel_attr || 'data-panel',
|
|
99
|
+
panel_selector: input.panel_selector || null,
|
|
100
|
+
wait_ms: Number.isFinite(Number(input.wait_ms)) ? Number(input.wait_ms) : 250,
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
103
|
+
return [
|
|
104
|
+
{
|
|
105
|
+
id: input.capture_id || 'captured-main',
|
|
106
|
+
viewport: parseObjectLike(input.viewport) || {
|
|
107
|
+
width: Number.isFinite(Number(input.width)) ? Number(input.width) : 1440,
|
|
108
|
+
height: Number.isFinite(Number(input.height)) ? Number(input.height) : 900,
|
|
109
|
+
},
|
|
110
|
+
capture_url: input.capture_url,
|
|
111
|
+
platform_profile: input.platform_profile || null,
|
|
112
|
+
reference_image: input.reference_image,
|
|
113
|
+
layout_contract: parseObjectLike(input.layout_contract) || parseObjectLike(input.layout_contract_json) || null,
|
|
114
|
+
visual_token_contract: parseObjectLike(input.visual_token_contract) || parseObjectLike(input.visual_token_contract_json) || null,
|
|
115
|
+
structure_checks: Array.isArray(input.structure_checks) ? input.structure_checks : [],
|
|
116
|
+
capture_states: parseArrayLike(input.capture_states_json).length > 0 ? parseArrayLike(input.capture_states_json) : (Array.isArray(input.capture_states) ? input.capture_states : []),
|
|
117
|
+
auto_discover_states: input.auto_discover_states === true,
|
|
118
|
+
state_limit: Number.isFinite(Number(input.state_limit)) ? Number(input.state_limit) : null,
|
|
119
|
+
panel_attr: input.panel_attr || 'data-panel',
|
|
120
|
+
panel_selector: input.panel_selector || null,
|
|
121
|
+
wait_ms: Number.isFinite(Number(input.wait_ms)) ? Number(input.wait_ms) : 250,
|
|
122
|
+
},
|
|
123
|
+
];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function determineBenchmarkInputMode(input = {}, explicitReferences = [], implicitReferences = []) {
|
|
127
|
+
if (explicitReferences.length > 0) return 'reference_scenarios';
|
|
128
|
+
if (implicitReferences.length > 0) return 'capture_url';
|
|
129
|
+
return 'missing';
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function loadGenerationDefaults(projectRoot, changeId) {
|
|
133
|
+
if (!changeId) return {};
|
|
134
|
+
const generationArtifact = readJsonFile(resolveChangePath(projectRoot, changeId, 'generation_contract.json'), null);
|
|
135
|
+
const reconstructionArtifact = readJsonFile(resolveChangePath(projectRoot, changeId, 'reconstruction_pack.json'), null);
|
|
136
|
+
const generationContract = generationArtifact?.generation_contract || null;
|
|
137
|
+
const reconstructionPack = reconstructionArtifact?.reconstruction_pack || null;
|
|
138
|
+
if (!generationContract && !reconstructionPack) return {};
|
|
139
|
+
|
|
140
|
+
const requiredRegions = Array.isArray(generationContract?.layout_constraints?.required_regions)
|
|
141
|
+
? generationContract.layout_constraints.required_regions
|
|
142
|
+
: Array.isArray(reconstructionPack?.layout_map?.regions)
|
|
143
|
+
? reconstructionPack.layout_map.regions.map((region) => region?.id).filter(Boolean)
|
|
144
|
+
: [];
|
|
145
|
+
const anchorRules = Array.isArray(reconstructionPack?.layout_map?.anchors)
|
|
146
|
+
? reconstructionPack.layout_map.anchors
|
|
147
|
+
.filter((anchor) => anchor?.id && anchor?.relative_to)
|
|
148
|
+
.map((anchor) => ({
|
|
149
|
+
id: anchor.id,
|
|
150
|
+
from: anchor.id.replace(/_anchor$/, ''),
|
|
151
|
+
to: anchor.relative_to,
|
|
152
|
+
relation: 'right_of',
|
|
153
|
+
}))
|
|
154
|
+
: [];
|
|
155
|
+
|
|
156
|
+
const layoutContract = requiredRegions.length > 0
|
|
157
|
+
? {
|
|
158
|
+
expected_columns: requiredRegions.length,
|
|
159
|
+
required_panels: requiredRegions,
|
|
160
|
+
anchor_relations: anchorRules,
|
|
161
|
+
min_main_width: 480,
|
|
162
|
+
}
|
|
163
|
+
: null;
|
|
164
|
+
|
|
165
|
+
const visualTokenContract = generationContract?.visual_constraints?.token_contract || null;
|
|
166
|
+
return {
|
|
167
|
+
layout_contract: layoutContract,
|
|
168
|
+
visual_token_contract: visualTokenContract,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function applyGenerationDefaults(scenario = {}, defaults = {}) {
|
|
173
|
+
const hasLayoutEvidence = Boolean(
|
|
174
|
+
scenario.capture_url
|
|
175
|
+
|| scenario.observed_layout
|
|
176
|
+
|| scenario.layout_observations,
|
|
177
|
+
);
|
|
178
|
+
const hasTokenEvidence = Boolean(
|
|
179
|
+
scenario.capture_url
|
|
180
|
+
|| scenario.observed_visual_tokens,
|
|
181
|
+
);
|
|
182
|
+
return {
|
|
183
|
+
...scenario,
|
|
184
|
+
layout_contract: scenario.layout_contract || (hasLayoutEvidence ? defaults.layout_contract : null) || null,
|
|
185
|
+
visual_token_contract: scenario.visual_token_contract || (hasTokenEvidence ? defaults.visual_token_contract : null) || null,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function normalizeStructureChecks(checks) {
|
|
190
|
+
if (!Array.isArray(checks)) return [];
|
|
191
|
+
return checks.map((check, index) => {
|
|
192
|
+
const status = check?.status || (check?.passed === true ? 'pass' : 'needs_follow_up');
|
|
193
|
+
return {
|
|
194
|
+
id: check?.id || `structure-check-${index + 1}`,
|
|
195
|
+
status,
|
|
196
|
+
detail: check?.detail || null,
|
|
197
|
+
};
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function makeStructureCheck(id, passed, detail) {
|
|
202
|
+
return {
|
|
203
|
+
id,
|
|
204
|
+
status: passed ? 'pass' : 'needs_follow_up',
|
|
205
|
+
detail,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function normalizeStringList(values) {
|
|
210
|
+
if (!Array.isArray(values)) return [];
|
|
211
|
+
return values
|
|
212
|
+
.filter((value) => typeof value === 'string' && value.trim())
|
|
213
|
+
.map((value) => value.trim().toLowerCase());
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function normalizeNumberList(values) {
|
|
217
|
+
if (!Array.isArray(values)) return [];
|
|
218
|
+
return values
|
|
219
|
+
.map((value) => Number(value))
|
|
220
|
+
.filter((value) => Number.isFinite(value));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function numericSpread(values) {
|
|
224
|
+
const normalized = normalizeNumberList(values);
|
|
225
|
+
if (normalized.length === 0) return null;
|
|
226
|
+
return Math.max(...normalized) - Math.min(...normalized);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function normalizeColorValue(value) {
|
|
230
|
+
if (typeof value !== 'string') return null;
|
|
231
|
+
const trimmed = value.trim().toLowerCase();
|
|
232
|
+
if (!trimmed) return null;
|
|
233
|
+
if (/^#[0-9a-f]{3}$/.test(trimmed)) {
|
|
234
|
+
return `#${trimmed[1]}${trimmed[1]}${trimmed[2]}${trimmed[2]}${trimmed[3]}${trimmed[3]}`;
|
|
235
|
+
}
|
|
236
|
+
if (/^#[0-9a-f]{6}$/.test(trimmed)) return trimmed;
|
|
237
|
+
const rgbMatch = trimmed.match(/^rgba?\((\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
|
238
|
+
if (rgbMatch) {
|
|
239
|
+
const [, r, g, b] = rgbMatch;
|
|
240
|
+
const channels = [r, g, b].map((channel) => {
|
|
241
|
+
const valueNumber = Math.max(0, Math.min(255, Number(channel)));
|
|
242
|
+
return valueNumber.toString(16).padStart(2, '0');
|
|
243
|
+
});
|
|
244
|
+
return `#${channels.join('')}`;
|
|
245
|
+
}
|
|
246
|
+
return trimmed;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function normalizeColorMap(value) {
|
|
250
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
|
251
|
+
return Object.fromEntries(
|
|
252
|
+
Object.entries(value)
|
|
253
|
+
.map(([key, color]) => [key, normalizeColorValue(color)])
|
|
254
|
+
.filter(([, color]) => Boolean(color)),
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function makeTokenCheck(id, passed, detail) {
|
|
259
|
+
return {
|
|
260
|
+
id,
|
|
261
|
+
status: passed ? 'pass' : 'needs_follow_up',
|
|
262
|
+
detail,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function normalizeViewportMetrics(value) {
|
|
267
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
|
268
|
+
const safeAreaTop = Number(value.safe_area_top);
|
|
269
|
+
const safeAreaBottom = Number(value.safe_area_bottom);
|
|
270
|
+
const keyboardInsetBottom = Number(value.keyboard_inset_bottom);
|
|
271
|
+
return {
|
|
272
|
+
safe_area_top: Number.isFinite(safeAreaTop) ? safeAreaTop : null,
|
|
273
|
+
safe_area_bottom: Number.isFinite(safeAreaBottom) ? safeAreaBottom : null,
|
|
274
|
+
keyboard_inset_bottom: Number.isFinite(keyboardInsetBottom) ? keyboardInsetBottom : null,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function makeStateContractCheck(id, passed, detail) {
|
|
279
|
+
return {
|
|
280
|
+
id,
|
|
281
|
+
status: passed ? 'pass' : 'needs_follow_up',
|
|
282
|
+
detail,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function makeMatrixCheck(id, passed, detail) {
|
|
287
|
+
return {
|
|
288
|
+
id,
|
|
289
|
+
status: passed ? 'pass' : 'needs_follow_up',
|
|
290
|
+
detail,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function evaluateVisualTokenContract(contract = {}, observed = {}) {
|
|
295
|
+
if (!contract || typeof contract !== 'object') return [];
|
|
296
|
+
const checks = [];
|
|
297
|
+
|
|
298
|
+
const requiredFonts = normalizeStringList(contract.font_families_required);
|
|
299
|
+
const observedFonts = normalizeStringList(observed.font_families);
|
|
300
|
+
if (requiredFonts.length > 0) {
|
|
301
|
+
const missing = requiredFonts.filter((font) => !observedFonts.includes(font));
|
|
302
|
+
checks.push(makeTokenCheck(
|
|
303
|
+
'token-font-families',
|
|
304
|
+
missing.length === 0,
|
|
305
|
+
missing.length === 0
|
|
306
|
+
? `required fonts present: ${requiredFonts.join(', ')}`
|
|
307
|
+
: `missing fonts: ${missing.join(', ')}; observed ${observedFonts.join(', ') || 'none'}`,
|
|
308
|
+
));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const requiredWeights = normalizeNumberList(contract.font_weights_required);
|
|
312
|
+
const observedWeights = normalizeNumberList(observed.font_weights);
|
|
313
|
+
if (requiredWeights.length > 0) {
|
|
314
|
+
const missing = requiredWeights.filter((weight) => !observedWeights.includes(weight));
|
|
315
|
+
checks.push(makeTokenCheck(
|
|
316
|
+
'token-font-weights',
|
|
317
|
+
missing.length === 0,
|
|
318
|
+
missing.length === 0
|
|
319
|
+
? `required weights present: ${requiredWeights.join(', ')}`
|
|
320
|
+
: `missing weights: ${missing.join(', ')}; observed ${observedWeights.join(', ') || 'none'}`,
|
|
321
|
+
));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const textSizes = normalizeNumberList(observed.text_sizes);
|
|
325
|
+
if (contract.text_size_range && typeof contract.text_size_range === 'object') {
|
|
326
|
+
const min = Number(contract.text_size_range.min);
|
|
327
|
+
const max = Number(contract.text_size_range.max);
|
|
328
|
+
const observedMin = textSizes.length > 0 ? Math.min(...textSizes) : null;
|
|
329
|
+
const observedMax = textSizes.length > 0 ? Math.max(...textSizes) : null;
|
|
330
|
+
const passed = observedMin !== null
|
|
331
|
+
&& observedMax !== null
|
|
332
|
+
&& (!Number.isFinite(min) || observedMin >= min)
|
|
333
|
+
&& (!Number.isFinite(max) || observedMax <= max);
|
|
334
|
+
checks.push(makeTokenCheck(
|
|
335
|
+
'token-text-size-range',
|
|
336
|
+
passed,
|
|
337
|
+
`expected min/max within ${Number.isFinite(min) ? min : '-inf'}..${Number.isFinite(max) ? max : '+inf'}, observed ${observedMin ?? 'missing'}..${observedMax ?? 'missing'}`,
|
|
338
|
+
));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const lineHeights = normalizeNumberList(observed.line_heights);
|
|
342
|
+
if (contract.line_height_range && typeof contract.line_height_range === 'object') {
|
|
343
|
+
const min = Number(contract.line_height_range.min);
|
|
344
|
+
const max = Number(contract.line_height_range.max);
|
|
345
|
+
const observedMin = lineHeights.length > 0 ? Math.min(...lineHeights) : null;
|
|
346
|
+
const observedMax = lineHeights.length > 0 ? Math.max(...lineHeights) : null;
|
|
347
|
+
const passed = observedMin !== null
|
|
348
|
+
&& observedMax !== null
|
|
349
|
+
&& (!Number.isFinite(min) || observedMin >= min)
|
|
350
|
+
&& (!Number.isFinite(max) || observedMax <= max);
|
|
351
|
+
checks.push(makeTokenCheck(
|
|
352
|
+
'token-line-height-range',
|
|
353
|
+
passed,
|
|
354
|
+
`expected min/max within ${Number.isFinite(min) ? min : '-inf'}..${Number.isFinite(max) ? max : '+inf'}, observed ${observedMin ?? 'missing'}..${observedMax ?? 'missing'}`,
|
|
355
|
+
));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const letterSpacings = normalizeNumberList(observed.letter_spacings);
|
|
359
|
+
if (contract.letter_spacing_range && typeof contract.letter_spacing_range === 'object') {
|
|
360
|
+
const min = Number(contract.letter_spacing_range.min);
|
|
361
|
+
const max = Number(contract.letter_spacing_range.max);
|
|
362
|
+
const observedMin = letterSpacings.length > 0 ? Math.min(...letterSpacings) : null;
|
|
363
|
+
const observedMax = letterSpacings.length > 0 ? Math.max(...letterSpacings) : null;
|
|
364
|
+
const passed = observedMin !== null
|
|
365
|
+
&& observedMax !== null
|
|
366
|
+
&& (!Number.isFinite(min) || observedMin >= min)
|
|
367
|
+
&& (!Number.isFinite(max) || observedMax <= max);
|
|
368
|
+
checks.push(makeTokenCheck(
|
|
369
|
+
'token-letter-spacing-range',
|
|
370
|
+
passed,
|
|
371
|
+
`expected min/max within ${Number.isFinite(min) ? min : '-inf'}..${Number.isFinite(max) ? max : '+inf'}, observed ${observedMin ?? 'missing'}..${observedMax ?? 'missing'}`,
|
|
372
|
+
));
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const radiusValues = normalizeNumberList(observed.radius_values);
|
|
376
|
+
if (contract.radius_range && typeof contract.radius_range === 'object') {
|
|
377
|
+
const min = Number(contract.radius_range.min);
|
|
378
|
+
const max = Number(contract.radius_range.max);
|
|
379
|
+
const observedMin = radiusValues.length > 0 ? Math.min(...radiusValues) : null;
|
|
380
|
+
const observedMax = radiusValues.length > 0 ? Math.max(...radiusValues) : null;
|
|
381
|
+
const passed = observedMin !== null
|
|
382
|
+
&& observedMax !== null
|
|
383
|
+
&& (!Number.isFinite(min) || observedMin >= min)
|
|
384
|
+
&& (!Number.isFinite(max) || observedMax <= max);
|
|
385
|
+
checks.push(makeTokenCheck(
|
|
386
|
+
'token-radius-range',
|
|
387
|
+
passed,
|
|
388
|
+
`expected min/max within ${Number.isFinite(min) ? min : '-inf'}..${Number.isFinite(max) ? max : '+inf'}, observed ${observedMin ?? 'missing'}..${observedMax ?? 'missing'}`,
|
|
389
|
+
));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const borderWidths = normalizeNumberList(observed.border_widths);
|
|
393
|
+
if (contract.border_width_range && typeof contract.border_width_range === 'object') {
|
|
394
|
+
const min = Number(contract.border_width_range.min);
|
|
395
|
+
const max = Number(contract.border_width_range.max);
|
|
396
|
+
const observedMin = borderWidths.length > 0 ? Math.min(...borderWidths) : null;
|
|
397
|
+
const observedMax = borderWidths.length > 0 ? Math.max(...borderWidths) : null;
|
|
398
|
+
const passed = observedMin !== null
|
|
399
|
+
&& observedMax !== null
|
|
400
|
+
&& (!Number.isFinite(min) || observedMin >= min)
|
|
401
|
+
&& (!Number.isFinite(max) || observedMax <= max);
|
|
402
|
+
checks.push(makeTokenCheck(
|
|
403
|
+
'token-border-width-range',
|
|
404
|
+
passed,
|
|
405
|
+
`expected min/max within ${Number.isFinite(min) ? min : '-inf'}..${Number.isFinite(max) ? max : '+inf'}, observed ${observedMin ?? 'missing'}..${observedMax ?? 'missing'}`,
|
|
406
|
+
));
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const requiredBorderStyles = normalizeStringList(contract.border_styles_required);
|
|
410
|
+
const observedBorderStyles = normalizeStringList(observed.border_styles);
|
|
411
|
+
if (requiredBorderStyles.length > 0) {
|
|
412
|
+
const missing = requiredBorderStyles.filter((style) => !observedBorderStyles.includes(style));
|
|
413
|
+
checks.push(makeTokenCheck(
|
|
414
|
+
'token-border-styles',
|
|
415
|
+
missing.length === 0,
|
|
416
|
+
missing.length === 0
|
|
417
|
+
? `required border styles present: ${requiredBorderStyles.join(', ')}`
|
|
418
|
+
: `missing border styles: ${missing.join(', ')}; observed ${observedBorderStyles.join(', ') || 'none'}`,
|
|
419
|
+
));
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const requiredColors = normalizeColorMap(contract.color_roles);
|
|
423
|
+
const observedColors = normalizeColorMap(observed.color_roles);
|
|
424
|
+
if (Object.keys(requiredColors).length > 0) {
|
|
425
|
+
const mismatches = Object.entries(requiredColors)
|
|
426
|
+
.filter(([role, color]) => observedColors[role] !== color)
|
|
427
|
+
.map(([role, color]) => `${role}: expected ${color}, observed ${observedColors[role] || 'missing'}`);
|
|
428
|
+
checks.push(makeTokenCheck(
|
|
429
|
+
'token-color-roles',
|
|
430
|
+
mismatches.length === 0,
|
|
431
|
+
mismatches.length === 0
|
|
432
|
+
? `required color roles matched: ${Object.keys(requiredColors).join(', ')}`
|
|
433
|
+
: mismatches.join('; '),
|
|
434
|
+
));
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const spacingValues = normalizeNumberList(observed.spacing_values);
|
|
438
|
+
if (contract.spacing_scale && typeof contract.spacing_scale === 'object') {
|
|
439
|
+
const base = Number(contract.spacing_scale.base);
|
|
440
|
+
const allowedMultipliers = normalizeNumberList(contract.spacing_scale.allowed_multipliers);
|
|
441
|
+
const tolerance = Number.isFinite(Number(contract.spacing_scale.tolerance))
|
|
442
|
+
? Number(contract.spacing_scale.tolerance)
|
|
443
|
+
: 0.5;
|
|
444
|
+
const invalidValues = !Number.isFinite(base) || base <= 0
|
|
445
|
+
? spacingValues
|
|
446
|
+
: spacingValues.filter((value) => {
|
|
447
|
+
if (value === 0) return false;
|
|
448
|
+
if (allowedMultipliers.length === 0) return false;
|
|
449
|
+
return !allowedMultipliers.some((multiplier) => Math.abs(value - (base * multiplier)) <= tolerance);
|
|
450
|
+
});
|
|
451
|
+
checks.push(makeTokenCheck(
|
|
452
|
+
'token-spacing-scale',
|
|
453
|
+
Number.isFinite(base) && base > 0 && invalidValues.length === 0,
|
|
454
|
+
!Number.isFinite(base) || base <= 0
|
|
455
|
+
? 'spacing base missing or invalid'
|
|
456
|
+
: invalidValues.length === 0
|
|
457
|
+
? `spacing values fit base ${base} with multipliers ${allowedMultipliers.join(', ')}`
|
|
458
|
+
: `invalid spacing values: ${invalidValues.join(', ')}`,
|
|
459
|
+
));
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const requiredShadowSignatures = normalizeStringList(contract.shadow_signatures_required);
|
|
463
|
+
const observedShadowSignatures = normalizeStringList(observed.shadow_signatures);
|
|
464
|
+
if (requiredShadowSignatures.length > 0) {
|
|
465
|
+
const missing = requiredShadowSignatures.filter((signature) => !observedShadowSignatures.includes(signature));
|
|
466
|
+
checks.push(makeTokenCheck(
|
|
467
|
+
'token-shadow-signatures',
|
|
468
|
+
missing.length === 0,
|
|
469
|
+
missing.length === 0
|
|
470
|
+
? `required shadow signatures present: ${requiredShadowSignatures.join(', ')}`
|
|
471
|
+
: `missing shadow signatures: ${missing.join(', ')}; observed ${observedShadowSignatures.join(', ') || 'none'}`,
|
|
472
|
+
));
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const iconDensityValues = normalizeNumberList(observed.icon_density_values);
|
|
476
|
+
if (contract.icon_density_range && typeof contract.icon_density_range === 'object') {
|
|
477
|
+
const min = Number(contract.icon_density_range.min);
|
|
478
|
+
const max = Number(contract.icon_density_range.max);
|
|
479
|
+
const observedMin = iconDensityValues.length > 0 ? Math.min(...iconDensityValues) : null;
|
|
480
|
+
const observedMax = iconDensityValues.length > 0 ? Math.max(...iconDensityValues) : null;
|
|
481
|
+
const passed = observedMin !== null
|
|
482
|
+
&& observedMax !== null
|
|
483
|
+
&& (!Number.isFinite(min) || observedMin >= min)
|
|
484
|
+
&& (!Number.isFinite(max) || observedMax <= max);
|
|
485
|
+
checks.push(makeTokenCheck(
|
|
486
|
+
'token-icon-density-range',
|
|
487
|
+
passed,
|
|
488
|
+
`expected min/max within ${Number.isFinite(min) ? min : '-inf'}..${Number.isFinite(max) ? max : '+inf'}, observed ${observedMin ?? 'missing'}..${observedMax ?? 'missing'}`,
|
|
489
|
+
));
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const iconSizeValues = normalizeNumberList(observed.icon_size_values);
|
|
493
|
+
if (contract.icon_size_range && typeof contract.icon_size_range === 'object') {
|
|
494
|
+
const min = Number(contract.icon_size_range.min);
|
|
495
|
+
const max = Number(contract.icon_size_range.max);
|
|
496
|
+
const observedMin = iconSizeValues.length > 0 ? Math.min(...iconSizeValues) : null;
|
|
497
|
+
const observedMax = iconSizeValues.length > 0 ? Math.max(...iconSizeValues) : null;
|
|
498
|
+
const passed = observedMin !== null
|
|
499
|
+
&& observedMax !== null
|
|
500
|
+
&& (!Number.isFinite(min) || observedMin >= min)
|
|
501
|
+
&& (!Number.isFinite(max) || observedMax <= max);
|
|
502
|
+
checks.push(makeTokenCheck(
|
|
503
|
+
'token-icon-size-range',
|
|
504
|
+
passed,
|
|
505
|
+
`expected min/max within ${Number.isFinite(min) ? min : '-inf'}..${Number.isFinite(max) ? max : '+inf'}, observed ${observedMin ?? 'missing'}..${observedMax ?? 'missing'}`,
|
|
506
|
+
));
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const touchTargetSizes = normalizeNumberList(observed.touch_target_sizes);
|
|
510
|
+
if (Number.isFinite(Number(contract.min_touch_target_size))) {
|
|
511
|
+
const minTouchTarget = Number(contract.min_touch_target_size);
|
|
512
|
+
const observedMin = touchTargetSizes.length > 0 ? Math.min(...touchTargetSizes) : null;
|
|
513
|
+
checks.push(makeTokenCheck(
|
|
514
|
+
'token-min-touch-target-size',
|
|
515
|
+
observedMin !== null && observedMin >= minTouchTarget,
|
|
516
|
+
`expected min touch target >= ${minTouchTarget}, observed ${observedMin ?? 'missing'}`,
|
|
517
|
+
));
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (contract.bottom_sheet_handle_required === true) {
|
|
521
|
+
checks.push(makeTokenCheck(
|
|
522
|
+
'token-bottom-sheet-handle',
|
|
523
|
+
observed.bottom_sheet_handle_present === true,
|
|
524
|
+
`expected bottom sheet handle present, observed ${observed.bottom_sheet_handle_present === true ? 'present' : 'missing'}`,
|
|
525
|
+
));
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (contract.tabbar_active_state_required === true) {
|
|
529
|
+
checks.push(makeTokenCheck(
|
|
530
|
+
'token-tabbar-active-state',
|
|
531
|
+
observed.tabbar_active_state_present === true,
|
|
532
|
+
`expected active tab state present, observed ${observed.tabbar_active_state_present === true ? 'present' : 'missing'}`,
|
|
533
|
+
));
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (contract.floating_primary_action_required === true) {
|
|
537
|
+
checks.push(makeTokenCheck(
|
|
538
|
+
'token-floating-primary-action',
|
|
539
|
+
observed.floating_primary_action_present === true,
|
|
540
|
+
`expected floating primary action present, observed ${observed.floating_primary_action_present === true ? 'present' : 'missing'}`,
|
|
541
|
+
));
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (contract.segmented_control_active_state_required === true) {
|
|
545
|
+
checks.push(makeTokenCheck(
|
|
546
|
+
'token-segmented-control-active-state',
|
|
547
|
+
observed.segmented_control_active_state_present === true,
|
|
548
|
+
`expected segmented control active state present, observed ${observed.segmented_control_active_state_present === true ? 'present' : 'missing'}`,
|
|
549
|
+
));
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const requiredSemantics = normalizeStringList(contract.required_component_semantics);
|
|
553
|
+
const observedSemantics = normalizeStringList(observed.component_semantics);
|
|
554
|
+
if (requiredSemantics.length > 0) {
|
|
555
|
+
const missing = requiredSemantics.filter((semantic) => !observedSemantics.includes(semantic));
|
|
556
|
+
checks.push(makeTokenCheck(
|
|
557
|
+
'token-component-semantics',
|
|
558
|
+
missing.length === 0,
|
|
559
|
+
missing.length === 0
|
|
560
|
+
? `required component semantics present: ${requiredSemantics.join(', ')}`
|
|
561
|
+
: `missing component semantics: ${missing.join(', ')}; observed ${observedSemantics.join(', ') || 'none'}`,
|
|
562
|
+
));
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const requiredPlatformPhysicsProfiles = normalizeStringList(contract.required_platform_physics_profiles);
|
|
566
|
+
const observedPlatformPhysicsProfiles = normalizeStringList(observed.platform_physics_profiles);
|
|
567
|
+
if (requiredPlatformPhysicsProfiles.length > 0) {
|
|
568
|
+
const missing = requiredPlatformPhysicsProfiles.filter((profile) => !observedPlatformPhysicsProfiles.includes(profile));
|
|
569
|
+
checks.push(makeTokenCheck(
|
|
570
|
+
'token-platform-physics-profiles',
|
|
571
|
+
missing.length === 0,
|
|
572
|
+
missing.length === 0
|
|
573
|
+
? `required platform physics profiles present: ${requiredPlatformPhysicsProfiles.join(', ')}`
|
|
574
|
+
: `missing platform physics profiles: ${missing.join(', ')}; observed ${observedPlatformPhysicsProfiles.join(', ') || 'none'}`,
|
|
575
|
+
));
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const listRowHeights = normalizeNumberList(observed.list_row_heights);
|
|
579
|
+
if (contract.list_row_height_range && typeof contract.list_row_height_range === 'object') {
|
|
580
|
+
const min = Number(contract.list_row_height_range.min);
|
|
581
|
+
const max = Number(contract.list_row_height_range.max);
|
|
582
|
+
const observedMin = listRowHeights.length > 0 ? Math.min(...listRowHeights) : null;
|
|
583
|
+
const observedMax = listRowHeights.length > 0 ? Math.max(...listRowHeights) : null;
|
|
584
|
+
const passed = observedMin !== null
|
|
585
|
+
&& observedMax !== null
|
|
586
|
+
&& (!Number.isFinite(min) || observedMin >= min)
|
|
587
|
+
&& (!Number.isFinite(max) || observedMax <= max);
|
|
588
|
+
checks.push(makeTokenCheck(
|
|
589
|
+
'token-list-row-height-range',
|
|
590
|
+
passed,
|
|
591
|
+
`expected min/max within ${Number.isFinite(min) ? min : '-inf'}..${Number.isFinite(max) ? max : '+inf'}, observed ${observedMin ?? 'missing'}..${observedMax ?? 'missing'}`,
|
|
592
|
+
));
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const toolbarControlDensityValues = normalizeNumberList(observed.toolbar_control_density_values);
|
|
596
|
+
if (contract.toolbar_control_density_range && typeof contract.toolbar_control_density_range === 'object') {
|
|
597
|
+
const min = Number(contract.toolbar_control_density_range.min);
|
|
598
|
+
const max = Number(contract.toolbar_control_density_range.max);
|
|
599
|
+
const observedMin = toolbarControlDensityValues.length > 0 ? Math.min(...toolbarControlDensityValues) : null;
|
|
600
|
+
const observedMax = toolbarControlDensityValues.length > 0 ? Math.max(...toolbarControlDensityValues) : null;
|
|
601
|
+
const passed = observedMin !== null
|
|
602
|
+
&& observedMax !== null
|
|
603
|
+
&& (!Number.isFinite(min) || observedMin >= min)
|
|
604
|
+
&& (!Number.isFinite(max) || observedMax <= max);
|
|
605
|
+
checks.push(makeTokenCheck(
|
|
606
|
+
'token-toolbar-control-density-range',
|
|
607
|
+
passed,
|
|
608
|
+
`expected min/max within ${Number.isFinite(min) ? min : '-inf'}..${Number.isFinite(max) ? max : '+inf'}, observed ${observedMin ?? 'missing'}..${observedMax ?? 'missing'}`,
|
|
609
|
+
));
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const detailBlockSpacingValues = normalizeNumberList(observed.detail_block_spacing_values);
|
|
613
|
+
if (contract.detail_block_spacing_scale && typeof contract.detail_block_spacing_scale === 'object') {
|
|
614
|
+
const base = Number(contract.detail_block_spacing_scale.base);
|
|
615
|
+
const allowedMultipliers = normalizeNumberList(contract.detail_block_spacing_scale.allowed_multipliers);
|
|
616
|
+
const tolerance = Number.isFinite(Number(contract.detail_block_spacing_scale.tolerance))
|
|
617
|
+
? Number(contract.detail_block_spacing_scale.tolerance)
|
|
618
|
+
: 0.5;
|
|
619
|
+
const invalidValues = !Number.isFinite(base) || base <= 0
|
|
620
|
+
? detailBlockSpacingValues
|
|
621
|
+
: detailBlockSpacingValues.filter((value) => {
|
|
622
|
+
if (allowedMultipliers.length === 0) return false;
|
|
623
|
+
return !allowedMultipliers.some((multiplier) => Math.abs(value - (base * multiplier)) <= tolerance);
|
|
624
|
+
});
|
|
625
|
+
checks.push(makeTokenCheck(
|
|
626
|
+
'token-detail-block-spacing-scale',
|
|
627
|
+
Number.isFinite(base) && base > 0 && invalidValues.length === 0 && detailBlockSpacingValues.length > 0,
|
|
628
|
+
!Number.isFinite(base) || base <= 0
|
|
629
|
+
? 'detail block spacing base missing or invalid'
|
|
630
|
+
: invalidValues.length === 0
|
|
631
|
+
? `detail block spacing fits base ${base} with multipliers ${allowedMultipliers.join(', ')}`
|
|
632
|
+
: `invalid detail block spacing values: ${invalidValues.join(', ')}`,
|
|
633
|
+
));
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const familyConsistencyContract = contract.component_family_consistency;
|
|
637
|
+
const observedFamilies = observed.component_family_consistency && typeof observed.component_family_consistency === 'object'
|
|
638
|
+
? observed.component_family_consistency
|
|
639
|
+
: {};
|
|
640
|
+
if (familyConsistencyContract && typeof familyConsistencyContract === 'object') {
|
|
641
|
+
for (const [familyName, familyRules] of Object.entries(familyConsistencyContract)) {
|
|
642
|
+
if (!familyRules || typeof familyRules !== 'object') continue;
|
|
643
|
+
const observedFamily = observedFamilies[familyName] && typeof observedFamilies[familyName] === 'object'
|
|
644
|
+
? observedFamilies[familyName]
|
|
645
|
+
: {};
|
|
646
|
+
const dimensions = [
|
|
647
|
+
['max_text_size_delta', 'text_sizes', 'text-size'],
|
|
648
|
+
['max_line_height_delta', 'line_heights', 'line-height'],
|
|
649
|
+
['max_radius_delta', 'radius_values', 'radius'],
|
|
650
|
+
['max_border_width_delta', 'border_widths', 'border-width'],
|
|
651
|
+
];
|
|
652
|
+
for (const [ruleField, observedField, label] of dimensions) {
|
|
653
|
+
if (!Number.isFinite(Number(familyRules[ruleField]))) continue;
|
|
654
|
+
const maxDelta = Number(familyRules[ruleField]);
|
|
655
|
+
const spread = numericSpread(observedFamily[observedField]);
|
|
656
|
+
checks.push(makeTokenCheck(
|
|
657
|
+
`token-family-${familyName}-${label}-consistency`,
|
|
658
|
+
spread !== null && spread <= maxDelta,
|
|
659
|
+
spread === null
|
|
660
|
+
? `${familyName} missing ${observedField}`
|
|
661
|
+
: `${familyName} ${label} spread ${spread} (max ${maxDelta})`,
|
|
662
|
+
));
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const requiredBorderWeightTiers = normalizeStringList(contract.border_weight_tiers_required);
|
|
668
|
+
const observedBorderWeightTiers = normalizeStringList(observed.border_weight_tiers);
|
|
669
|
+
if (requiredBorderWeightTiers.length > 0) {
|
|
670
|
+
const missing = requiredBorderWeightTiers.filter((tier) => !observedBorderWeightTiers.includes(tier));
|
|
671
|
+
checks.push(makeTokenCheck(
|
|
672
|
+
'token-border-weight-tiers',
|
|
673
|
+
missing.length === 0,
|
|
674
|
+
missing.length === 0
|
|
675
|
+
? `required border weight tiers present: ${requiredBorderWeightTiers.join(', ')}`
|
|
676
|
+
: `missing border weight tiers: ${missing.join(', ')}; observed ${observedBorderWeightTiers.join(', ') || 'none'}`,
|
|
677
|
+
));
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const requiredShadowStrengthTiers = normalizeStringList(contract.shadow_strength_tiers_required);
|
|
681
|
+
const observedShadowStrengthTiers = normalizeStringList(observed.shadow_strength_tiers);
|
|
682
|
+
if (requiredShadowStrengthTiers.length > 0) {
|
|
683
|
+
const missing = requiredShadowStrengthTiers.filter((tier) => !observedShadowStrengthTiers.includes(tier));
|
|
684
|
+
checks.push(makeTokenCheck(
|
|
685
|
+
'token-shadow-strength-tiers',
|
|
686
|
+
missing.length === 0,
|
|
687
|
+
missing.length === 0
|
|
688
|
+
? `required shadow strength tiers present: ${requiredShadowStrengthTiers.join(', ')}`
|
|
689
|
+
: `missing shadow strength tiers: ${missing.join(', ')}; observed ${observedShadowStrengthTiers.join(', ') || 'none'}`,
|
|
690
|
+
));
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
return checks;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function normalizeObservedLayout(observedLayout, layoutObservations) {
|
|
697
|
+
if (observedLayout && typeof observedLayout === 'object') {
|
|
698
|
+
return observedLayout;
|
|
699
|
+
}
|
|
700
|
+
const panels = normalizeObservedPanels(layoutObservations);
|
|
701
|
+
if (panels.length === 0) return null;
|
|
702
|
+
const columnTracks = new Map();
|
|
703
|
+
for (const panel of panels) {
|
|
704
|
+
if (!panel?.id || !Number.isFinite(panel.width) || !Number.isFinite(panel.x)) continue;
|
|
705
|
+
const trackKey = `${Math.round(panel.x)}:${Math.round(panel.width)}`;
|
|
706
|
+
const current = columnTracks.get(trackKey);
|
|
707
|
+
if (!current || panel.height > current.height || (panel.height === current.height && panel.y > current.y)) {
|
|
708
|
+
columnTracks.set(trackKey, panel);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
const columns = Array.from(columnTracks.values())
|
|
712
|
+
.sort((left, right) => {
|
|
713
|
+
const leftX = Number.isFinite(left?.x) ? left.x : Number.POSITIVE_INFINITY;
|
|
714
|
+
const rightX = Number.isFinite(right?.x) ? right.x : Number.POSITIVE_INFINITY;
|
|
715
|
+
return leftX - rightX;
|
|
716
|
+
})
|
|
717
|
+
.map((panel) => ({
|
|
718
|
+
id: panel.id,
|
|
719
|
+
width: panel.width,
|
|
720
|
+
}));
|
|
721
|
+
return columns.length > 0 ? { columns } : null;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function normalizeObservedPanels(layoutObservations) {
|
|
725
|
+
const panels = Array.isArray(layoutObservations?.panels) ? layoutObservations.panels : [];
|
|
726
|
+
const regions = Array.isArray(layoutObservations?.regions) ? layoutObservations.regions : [];
|
|
727
|
+
const domRects = Array.isArray(layoutObservations?.dom_rects) ? layoutObservations.dom_rects : [];
|
|
728
|
+
const normalizedPanels = panels
|
|
729
|
+
.filter((panel) =>
|
|
730
|
+
panel
|
|
731
|
+
&& panel.id
|
|
732
|
+
&& Number.isFinite(panel.width)
|
|
733
|
+
&& Number.isFinite(panel.height)
|
|
734
|
+
&& Number.isFinite(panel.x))
|
|
735
|
+
.map((panel) => ({
|
|
736
|
+
id: panel.id,
|
|
737
|
+
x: panel.x,
|
|
738
|
+
y: Number.isFinite(panel.y) ? panel.y : 0,
|
|
739
|
+
width: panel.width,
|
|
740
|
+
height: panel.height,
|
|
741
|
+
right: panel.x + panel.width,
|
|
742
|
+
bottom: (Number.isFinite(panel.y) ? panel.y : 0) + panel.height,
|
|
743
|
+
}));
|
|
744
|
+
const normalizedRegions = regions
|
|
745
|
+
.filter((region) =>
|
|
746
|
+
region
|
|
747
|
+
&& region.id
|
|
748
|
+
&& Number.isFinite(region.left)
|
|
749
|
+
&& Number.isFinite(region.top)
|
|
750
|
+
&& Number.isFinite(region.right)
|
|
751
|
+
&& Number.isFinite(region.bottom)
|
|
752
|
+
&& region.right > region.left
|
|
753
|
+
&& region.bottom > region.top)
|
|
754
|
+
.map((region) => ({
|
|
755
|
+
id: region.id,
|
|
756
|
+
x: region.left,
|
|
757
|
+
y: region.top,
|
|
758
|
+
width: region.right - region.left,
|
|
759
|
+
height: region.bottom - region.top,
|
|
760
|
+
right: region.right,
|
|
761
|
+
bottom: region.bottom,
|
|
762
|
+
}));
|
|
763
|
+
const normalizedDomRects = domRects
|
|
764
|
+
.filter((entry) =>
|
|
765
|
+
entry
|
|
766
|
+
&& (entry.panel_id || entry.id)
|
|
767
|
+
&& Number.isFinite(entry.left)
|
|
768
|
+
&& Number.isFinite(entry.top)
|
|
769
|
+
&& Number.isFinite(entry.width)
|
|
770
|
+
&& Number.isFinite(entry.height)
|
|
771
|
+
&& entry.width > 0
|
|
772
|
+
&& entry.height > 0)
|
|
773
|
+
.map((entry) => ({
|
|
774
|
+
id: entry.panel_id || entry.id,
|
|
775
|
+
x: entry.left,
|
|
776
|
+
y: entry.top,
|
|
777
|
+
width: entry.width,
|
|
778
|
+
height: entry.height,
|
|
779
|
+
right: entry.left + entry.width,
|
|
780
|
+
bottom: entry.top + entry.height,
|
|
781
|
+
}));
|
|
782
|
+
if (normalizedPanels.length > 0) return normalizedPanels;
|
|
783
|
+
if (normalizedRegions.length > 0) return normalizedRegions;
|
|
784
|
+
return normalizedDomRects;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
const WORKSPACE_PATTERN_EXPANSIONS = {
|
|
788
|
+
header_with_split_workspace: [
|
|
789
|
+
{ id: 'pattern-header-above-main', from: 'header', to: 'main', relation: 'above', gap_max: 120 },
|
|
790
|
+
{ id: 'pattern-header-above-sidebar', from: 'header', to: 'sidebar', relation: 'above', gap_max: 24 },
|
|
791
|
+
{ id: 'pattern-header-aligned-left-sidebar', from: 'header', to: 'sidebar', relation: 'aligned_left', tolerance: 24 },
|
|
792
|
+
],
|
|
793
|
+
sidebar_docked_left: [
|
|
794
|
+
{ id: 'pattern-sidebar-left-of-main', from: 'sidebar', to: 'main', relation: 'left_of', gap_max: 0 },
|
|
795
|
+
{ id: 'pattern-sidebar-aligned-top-main', from: 'sidebar', to: 'main', relation: 'aligned_top', tolerance: 0 },
|
|
796
|
+
],
|
|
797
|
+
filters_above_list: [
|
|
798
|
+
{ id: 'pattern-filters-above-list', from: 'filters', to: 'list', relation: 'above', gap_max: 24 },
|
|
799
|
+
{ id: 'pattern-filters-aligned-left-list', from: 'filters', to: 'list', relation: 'aligned_left', tolerance: 24 },
|
|
800
|
+
],
|
|
801
|
+
list_detail_master_detail: [
|
|
802
|
+
{ id: 'pattern-list-left-of-detail', from: 'list', to: 'detail', relation: 'left_of', gap_max: 0 },
|
|
803
|
+
{ id: 'pattern-list-aligned-top-detail', from: 'list', to: 'detail', relation: 'aligned_top', tolerance: 24 },
|
|
804
|
+
],
|
|
805
|
+
detail_docked_right: [
|
|
806
|
+
{ id: 'pattern-detail-right-of-main', from: 'detail', to: 'main', relation: 'right_of', gap_max: 0 },
|
|
807
|
+
{ id: 'pattern-detail-aligned-top-main', from: 'detail', to: 'main', relation: 'aligned_top', tolerance: 0 },
|
|
808
|
+
],
|
|
809
|
+
toolbar_over_workspace: [
|
|
810
|
+
{ id: 'pattern-toolbar-above-main', from: 'toolbar', to: 'main', relation: 'above', gap_max: 24 },
|
|
811
|
+
{ id: 'pattern-toolbar-aligned-left-main', from: 'toolbar', to: 'main', relation: 'aligned_left', tolerance: 24 },
|
|
812
|
+
],
|
|
813
|
+
compact_top_nav: [
|
|
814
|
+
{ id: 'pattern-compact-top-nav-above-main', from: 'header', to: 'main', relation: 'above', gap_max: 16 },
|
|
815
|
+
{ id: 'pattern-compact-top-nav-aligned-left-main', from: 'header', to: 'main', relation: 'aligned_left', tolerance: 16 },
|
|
816
|
+
],
|
|
817
|
+
bottom_tabbar_docked: [
|
|
818
|
+
{ id: 'pattern-bottom-tabbar-below-main', from: 'bottom_bar', to: 'main', relation: 'below', gap_max: 0 },
|
|
819
|
+
{ id: 'pattern-bottom-tabbar-aligned-left-main', from: 'bottom_bar', to: 'main', relation: 'aligned_left', tolerance: 16 },
|
|
820
|
+
],
|
|
821
|
+
bottom_sheet_over_content: [
|
|
822
|
+
{ id: 'pattern-bottom-sheet-below-main-top', from: 'sheet', to: 'header', relation: 'below', gap_min: 0 },
|
|
823
|
+
{ id: 'pattern-bottom-sheet-aligned-left-main', from: 'sheet', to: 'main', relation: 'aligned_left', tolerance: 16 },
|
|
824
|
+
],
|
|
825
|
+
};
|
|
826
|
+
|
|
827
|
+
function expandAnchorRelations(layoutContract = {}) {
|
|
828
|
+
if (!layoutContract || typeof layoutContract !== 'object') return [];
|
|
829
|
+
const explicitRelations = Array.isArray(layoutContract.anchor_relations) ? layoutContract.anchor_relations : [];
|
|
830
|
+
const workspacePatterns = Array.isArray(layoutContract.workspace_patterns) ? layoutContract.workspace_patterns : [];
|
|
831
|
+
const expandedPatternRelations = workspacePatterns.flatMap((pattern) => WORKSPACE_PATTERN_EXPANSIONS[pattern] || []);
|
|
832
|
+
const byId = new Map();
|
|
833
|
+
for (const relation of [...expandedPatternRelations, ...explicitRelations]) {
|
|
834
|
+
const relationId = relation?.id || `${relation?.from || 'unknown'}-${relation?.relation || 'relation'}-${relation?.to || 'unknown'}`;
|
|
835
|
+
byId.set(relationId, { ...relation, id: relationId });
|
|
836
|
+
}
|
|
837
|
+
return Array.from(byId.values());
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function evaluateLayoutContract(layoutContract = {}, observedLayout = {}, observedPanels = [], viewport = null, viewportMetrics = {}) {
|
|
841
|
+
if (!layoutContract || typeof layoutContract !== 'object') return [];
|
|
842
|
+
const normalizedObservedLayout = observedLayout && typeof observedLayout === 'object' ? observedLayout : {};
|
|
843
|
+
const columns = Array.isArray(normalizedObservedLayout.columns) ? normalizedObservedLayout.columns : [];
|
|
844
|
+
const checks = [];
|
|
845
|
+
|
|
846
|
+
if (Number.isFinite(layoutContract.expected_columns)) {
|
|
847
|
+
checks.push(makeStructureCheck(
|
|
848
|
+
'layout-column-count',
|
|
849
|
+
columns.length === layoutContract.expected_columns,
|
|
850
|
+
`expected ${layoutContract.expected_columns}, observed ${columns.length}`,
|
|
851
|
+
));
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
if (Array.isArray(layoutContract.required_panels) && layoutContract.required_panels.length > 0) {
|
|
855
|
+
const observedIds = columns.map((column) => column?.id).filter(Boolean);
|
|
856
|
+
const filteredObservedIds = observedIds.filter((id) => layoutContract.required_panels.includes(id));
|
|
857
|
+
checks.push(makeStructureCheck(
|
|
858
|
+
'layout-panel-order',
|
|
859
|
+
JSON.stringify(filteredObservedIds) === JSON.stringify(layoutContract.required_panels),
|
|
860
|
+
`expected ${layoutContract.required_panels.join(' > ')}, observed ${filteredObservedIds.join(' > ') || 'none'}`,
|
|
861
|
+
));
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const widthRequirements = [
|
|
865
|
+
['min_sidebar_width', 'sidebar'],
|
|
866
|
+
['min_main_width', layoutContract.main_panel_id || 'main'],
|
|
867
|
+
['min_detail_width', 'detail'],
|
|
868
|
+
];
|
|
869
|
+
for (const [field, panelId] of widthRequirements) {
|
|
870
|
+
if (!Number.isFinite(layoutContract[field])) continue;
|
|
871
|
+
const fallbackPanelId = field === 'min_main_width'
|
|
872
|
+
? (Array.isArray(layoutContract.required_panels) ? layoutContract.required_panels[0] : null)
|
|
873
|
+
: null;
|
|
874
|
+
const column = columns.find((entry) => entry?.id === panelId)
|
|
875
|
+
|| columns.find((entry) => fallbackPanelId && entry?.id === fallbackPanelId)
|
|
876
|
+
|| (field === 'min_main_width' ? columns[0] : null);
|
|
877
|
+
const observedWidth = Number.isFinite(column?.width) ? column.width : null;
|
|
878
|
+
checks.push(makeStructureCheck(
|
|
879
|
+
field === 'min_main_width' ? 'layout-main-width' : `layout-${panelId}-width`,
|
|
880
|
+
observedWidth !== null && observedWidth >= layoutContract[field],
|
|
881
|
+
`expected >= ${layoutContract[field]}, observed ${observedWidth ?? 'missing'}${column?.id ? ` via ${column.id}` : ''}`,
|
|
882
|
+
));
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
const topSafeAreaPanels = Array.isArray(layoutContract.safe_area_top_panels) ? layoutContract.safe_area_top_panels : [];
|
|
886
|
+
const bottomSafeAreaPanels = Array.isArray(layoutContract.safe_area_bottom_panels) ? layoutContract.safe_area_bottom_panels : [];
|
|
887
|
+
const keyboardAwarePanels = Array.isArray(layoutContract.keyboard_aware_panels) ? layoutContract.keyboard_aware_panels : [];
|
|
888
|
+
const thumbReachPanels = Array.isArray(layoutContract.thumb_reach_primary_action_panels)
|
|
889
|
+
? layoutContract.thumb_reach_primary_action_panels
|
|
890
|
+
: [];
|
|
891
|
+
const viewportHeight = Number(viewport?.height);
|
|
892
|
+
const safeAreaTolerance = Number.isFinite(Number(layoutContract.safe_area_tolerance))
|
|
893
|
+
? Number(layoutContract.safe_area_tolerance)
|
|
894
|
+
: 0;
|
|
895
|
+
const keyboardClearanceMin = Number.isFinite(Number(layoutContract.keyboard_clearance_min))
|
|
896
|
+
? Number(layoutContract.keyboard_clearance_min)
|
|
897
|
+
: 0;
|
|
898
|
+
const thumbReachZone = layoutContract.thumb_reach_zone && typeof layoutContract.thumb_reach_zone === 'object'
|
|
899
|
+
? layoutContract.thumb_reach_zone
|
|
900
|
+
: {};
|
|
901
|
+
const thumbReachMinRatio = Number.isFinite(Number(thumbReachZone.min_y_ratio)) ? Number(thumbReachZone.min_y_ratio) : 0.55;
|
|
902
|
+
const thumbReachMaxRatio = Number.isFinite(Number(thumbReachZone.max_y_ratio)) ? Number(thumbReachZone.max_y_ratio) : 0.95;
|
|
903
|
+
const metrics = normalizeViewportMetrics(viewportMetrics);
|
|
904
|
+
|
|
905
|
+
for (const panelId of topSafeAreaPanels) {
|
|
906
|
+
const panel = observedPanels.find((entry) => entry?.id === panelId);
|
|
907
|
+
const safeTop = metrics.safe_area_top;
|
|
908
|
+
checks.push(makeStructureCheck(
|
|
909
|
+
`layout-safe-area-top-${panelId}`,
|
|
910
|
+
Boolean(panel) && Number.isFinite(safeTop) && panel.y >= (safeTop - safeAreaTolerance),
|
|
911
|
+
!panel
|
|
912
|
+
? `missing observed panel ${panelId}`
|
|
913
|
+
: !Number.isFinite(safeTop)
|
|
914
|
+
? 'safe area top metric missing'
|
|
915
|
+
: `expected ${panelId} y >= ${safeTop - safeAreaTolerance}, observed ${panel.y}`,
|
|
916
|
+
));
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
for (const panelId of bottomSafeAreaPanels) {
|
|
920
|
+
const panel = observedPanels.find((entry) => entry?.id === panelId);
|
|
921
|
+
const safeBottom = metrics.safe_area_bottom;
|
|
922
|
+
const bottomGap = panel && Number.isFinite(viewportHeight) ? viewportHeight - panel.bottom : null;
|
|
923
|
+
checks.push(makeStructureCheck(
|
|
924
|
+
`layout-safe-area-bottom-${panelId}`,
|
|
925
|
+
Boolean(panel) && Number.isFinite(safeBottom) && Number.isFinite(bottomGap) && bottomGap >= (safeBottom - safeAreaTolerance),
|
|
926
|
+
!panel
|
|
927
|
+
? `missing observed panel ${panelId}`
|
|
928
|
+
: !Number.isFinite(safeBottom) || !Number.isFinite(bottomGap)
|
|
929
|
+
? 'safe area bottom metric missing'
|
|
930
|
+
: `expected ${panelId} bottom gap >= ${safeBottom - safeAreaTolerance}, observed ${bottomGap}`,
|
|
931
|
+
));
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
for (const panelId of keyboardAwarePanels) {
|
|
935
|
+
const panel = observedPanels.find((entry) => entry?.id === panelId);
|
|
936
|
+
const keyboardInset = metrics.keyboard_inset_bottom;
|
|
937
|
+
const visibleBottom = Number.isFinite(viewportHeight) && Number.isFinite(keyboardInset)
|
|
938
|
+
? viewportHeight - keyboardInset
|
|
939
|
+
: null;
|
|
940
|
+
const clearance = panel && Number.isFinite(visibleBottom) ? visibleBottom - panel.bottom : null;
|
|
941
|
+
checks.push(makeStructureCheck(
|
|
942
|
+
`layout-keyboard-clearance-${panelId}`,
|
|
943
|
+
Boolean(panel) && Number.isFinite(clearance) && clearance >= keyboardClearanceMin,
|
|
944
|
+
!panel
|
|
945
|
+
? `missing observed panel ${panelId}`
|
|
946
|
+
: !Number.isFinite(clearance)
|
|
947
|
+
? 'keyboard inset metric missing'
|
|
948
|
+
: `expected ${panelId} clearance above keyboard >= ${keyboardClearanceMin}, observed ${clearance}`,
|
|
949
|
+
));
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
for (const panelId of thumbReachPanels) {
|
|
953
|
+
const panel = observedPanels.find((entry) => entry?.id === panelId);
|
|
954
|
+
const centerRatio = panel && Number.isFinite(viewportHeight) && viewportHeight > 0
|
|
955
|
+
? ((panel.y + panel.bottom) / 2) / viewportHeight
|
|
956
|
+
: null;
|
|
957
|
+
checks.push(makeStructureCheck(
|
|
958
|
+
`layout-thumb-reach-${panelId}`,
|
|
959
|
+
Boolean(panel) && Number.isFinite(centerRatio) && centerRatio >= thumbReachMinRatio && centerRatio <= thumbReachMaxRatio,
|
|
960
|
+
!panel
|
|
961
|
+
? `missing observed panel ${panelId}`
|
|
962
|
+
: !Number.isFinite(centerRatio)
|
|
963
|
+
? 'viewport height missing'
|
|
964
|
+
: `expected ${panelId} center ratio within ${thumbReachMinRatio}..${thumbReachMaxRatio}, observed ${Number(centerRatio.toFixed(3))}`,
|
|
965
|
+
));
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
return checks;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
function evaluateAlignmentGrid(layoutContract = {}, observedPanels = []) {
|
|
972
|
+
if (!layoutContract || typeof layoutContract !== 'object') return [];
|
|
973
|
+
const step = Number(layoutContract.alignment_grid_step);
|
|
974
|
+
if (!Number.isFinite(step) || step <= 0) return [];
|
|
975
|
+
const tolerance = Number.isFinite(Number(layoutContract.alignment_grid_tolerance))
|
|
976
|
+
? Number(layoutContract.alignment_grid_tolerance)
|
|
977
|
+
: 1;
|
|
978
|
+
const allowedPanels = Array.isArray(layoutContract.alignment_grid_panels) && layoutContract.alignment_grid_panels.length > 0
|
|
979
|
+
? new Set(layoutContract.alignment_grid_panels)
|
|
980
|
+
: null;
|
|
981
|
+
|
|
982
|
+
return observedPanels
|
|
983
|
+
.filter((panel) => !allowedPanels || allowedPanels.has(panel.id))
|
|
984
|
+
.map((panel) => {
|
|
985
|
+
const xDelta = Math.min(panel.x % step, step - (panel.x % step || 0));
|
|
986
|
+
const yDelta = Math.min(panel.y % step, step - (panel.y % step || 0));
|
|
987
|
+
const passed = xDelta <= tolerance && yDelta <= tolerance;
|
|
988
|
+
return makeStructureCheck(
|
|
989
|
+
`layout-grid-alignment-${panel.id}`,
|
|
990
|
+
passed,
|
|
991
|
+
`expected ${panel.id} on ${step}px grid (+/- ${tolerance}), observed xΔ=${Number(xDelta.toFixed(2))}, yΔ=${Number(yDelta.toFixed(2))}`,
|
|
992
|
+
);
|
|
993
|
+
});
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
function evaluateAnchorRelations(layoutContract = {}, observedPanels = []) {
|
|
997
|
+
if (!layoutContract || typeof layoutContract !== 'object') return [];
|
|
998
|
+
const relations = expandAnchorRelations(layoutContract);
|
|
999
|
+
if (relations.length === 0) return [];
|
|
1000
|
+
|
|
1001
|
+
return relations.map((relation, index) => {
|
|
1002
|
+
const from = observedPanels.find((panel) => panel.id === relation?.from);
|
|
1003
|
+
const to = observedPanels.find((panel) => panel.id === relation?.to);
|
|
1004
|
+
const relationId = relation?.id || `anchor-relation-${index + 1}`;
|
|
1005
|
+
if (!from || !to) {
|
|
1006
|
+
return makeStructureCheck(
|
|
1007
|
+
relationId,
|
|
1008
|
+
false,
|
|
1009
|
+
`missing observed panels for ${relation?.from || 'unknown'} -> ${relation?.to || 'unknown'}`,
|
|
1010
|
+
);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
let passed = false;
|
|
1014
|
+
let detail = null;
|
|
1015
|
+
switch (relation?.relation) {
|
|
1016
|
+
case 'left_of': {
|
|
1017
|
+
const gap = to.x - from.right;
|
|
1018
|
+
passed = from.right <= to.x;
|
|
1019
|
+
if (passed && Number.isFinite(relation.gap_min)) passed = gap >= relation.gap_min;
|
|
1020
|
+
if (passed && Number.isFinite(relation.gap_max)) passed = gap <= relation.gap_max;
|
|
1021
|
+
detail = `expected ${from.id} left_of ${to.id}, observed gap ${gap}`;
|
|
1022
|
+
break;
|
|
1023
|
+
}
|
|
1024
|
+
case 'right_of': {
|
|
1025
|
+
const gap = from.x - to.right;
|
|
1026
|
+
passed = from.x >= to.right;
|
|
1027
|
+
if (passed && Number.isFinite(relation.gap_min)) passed = gap >= relation.gap_min;
|
|
1028
|
+
if (passed && Number.isFinite(relation.gap_max)) passed = gap <= relation.gap_max;
|
|
1029
|
+
detail = `expected ${from.id} right_of ${to.id}, observed gap ${gap}`;
|
|
1030
|
+
break;
|
|
1031
|
+
}
|
|
1032
|
+
case 'above': {
|
|
1033
|
+
const gap = to.y - from.bottom;
|
|
1034
|
+
passed = from.bottom <= to.y;
|
|
1035
|
+
if (passed && Number.isFinite(relation.gap_min)) passed = gap >= relation.gap_min;
|
|
1036
|
+
if (passed && Number.isFinite(relation.gap_max)) passed = gap <= relation.gap_max;
|
|
1037
|
+
detail = `expected ${from.id} above ${to.id}, observed gap ${gap}`;
|
|
1038
|
+
break;
|
|
1039
|
+
}
|
|
1040
|
+
case 'below': {
|
|
1041
|
+
const gap = from.y - to.bottom;
|
|
1042
|
+
passed = from.y >= to.bottom;
|
|
1043
|
+
if (passed && Number.isFinite(relation.gap_min)) passed = gap >= relation.gap_min;
|
|
1044
|
+
if (passed && Number.isFinite(relation.gap_max)) passed = gap <= relation.gap_max;
|
|
1045
|
+
detail = `expected ${from.id} below ${to.id}, observed gap ${gap}`;
|
|
1046
|
+
break;
|
|
1047
|
+
}
|
|
1048
|
+
case 'aligned_top': {
|
|
1049
|
+
const delta = Math.abs(from.y - to.y);
|
|
1050
|
+
passed = Number.isFinite(relation.tolerance) ? delta <= relation.tolerance : delta === 0;
|
|
1051
|
+
detail = `expected ${from.id} aligned_top ${to.id}, observed delta ${delta}`;
|
|
1052
|
+
break;
|
|
1053
|
+
}
|
|
1054
|
+
case 'aligned_left': {
|
|
1055
|
+
const delta = Math.abs(from.x - to.x);
|
|
1056
|
+
passed = Number.isFinite(relation.tolerance) ? delta <= relation.tolerance : delta === 0;
|
|
1057
|
+
detail = `expected ${from.id} aligned_left ${to.id}, observed delta ${delta}`;
|
|
1058
|
+
break;
|
|
1059
|
+
}
|
|
1060
|
+
default: {
|
|
1061
|
+
passed = false;
|
|
1062
|
+
detail = `unsupported relation ${relation?.relation || 'unknown'}`;
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
return makeStructureCheck(relationId, passed, detail);
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
function evaluateDiffMetrics(metrics) {
|
|
1071
|
+
if (!metrics || typeof metrics !== 'object') {
|
|
1072
|
+
return {
|
|
1073
|
+
status: 'needs_follow_up',
|
|
1074
|
+
issues: ['diff_metrics_missing'],
|
|
1075
|
+
values: null,
|
|
1076
|
+
thresholds: { ...DEFAULT_THRESHOLDS },
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
const thresholds = {
|
|
1081
|
+
structural_similarity_min: metrics.thresholds?.structural_similarity_min ?? DEFAULT_THRESHOLDS.structural_similarity_min,
|
|
1082
|
+
layout_shift_score_max: metrics.thresholds?.layout_shift_score_max ?? DEFAULT_THRESHOLDS.layout_shift_score_max,
|
|
1083
|
+
pixel_diff_ratio_max: metrics.thresholds?.pixel_diff_ratio_max ?? DEFAULT_THRESHOLDS.pixel_diff_ratio_max,
|
|
1084
|
+
};
|
|
1085
|
+
|
|
1086
|
+
const values = {
|
|
1087
|
+
structural_similarity: Number.isFinite(metrics.structural_similarity) ? metrics.structural_similarity : null,
|
|
1088
|
+
layout_shift_score: Number.isFinite(metrics.layout_shift_score) ? metrics.layout_shift_score : null,
|
|
1089
|
+
pixel_diff_ratio: Number.isFinite(metrics.pixel_diff_ratio) ? metrics.pixel_diff_ratio : null,
|
|
1090
|
+
};
|
|
1091
|
+
|
|
1092
|
+
const issues = [];
|
|
1093
|
+
const observed = Object.values(values).some((value) => value !== null);
|
|
1094
|
+
if (!observed) issues.push('diff_metrics_unusable');
|
|
1095
|
+
if (values.structural_similarity !== null && values.structural_similarity < thresholds.structural_similarity_min) {
|
|
1096
|
+
issues.push('structural_similarity_below_threshold');
|
|
1097
|
+
}
|
|
1098
|
+
if (values.layout_shift_score !== null && values.layout_shift_score > thresholds.layout_shift_score_max) {
|
|
1099
|
+
issues.push('layout_shift_score_above_threshold');
|
|
1100
|
+
}
|
|
1101
|
+
if (values.pixel_diff_ratio !== null && values.pixel_diff_ratio > thresholds.pixel_diff_ratio_max) {
|
|
1102
|
+
issues.push('pixel_diff_ratio_above_threshold');
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
return {
|
|
1106
|
+
status: issues.length === 0 ? 'pass' : 'needs_follow_up',
|
|
1107
|
+
issues,
|
|
1108
|
+
values,
|
|
1109
|
+
thresholds,
|
|
1110
|
+
hotspots: Array.isArray(metrics.hotspots) ? metrics.hotspots : [],
|
|
1111
|
+
heatmap_file: typeof metrics.heatmap_file === 'string' ? metrics.heatmap_file : null,
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
async function resolveScenarioDiffMetrics(projectRoot, scenario = {}, options = {}) {
|
|
1116
|
+
if (scenario.diff_metrics && typeof scenario.diff_metrics === 'object') {
|
|
1117
|
+
return scenario.diff_metrics;
|
|
1118
|
+
}
|
|
1119
|
+
const referencePath = resolveEvidencePath(projectRoot, scenario.reference_image);
|
|
1120
|
+
const screenshotPath = resolveEvidencePath(projectRoot, scenario.screenshot_image);
|
|
1121
|
+
if (!referencePath || !screenshotPath) return null;
|
|
1122
|
+
if (!existsSync(referencePath) || !existsSync(screenshotPath)) return null;
|
|
1123
|
+
const computed = await computeImageDiffMetrics({
|
|
1124
|
+
referencePath,
|
|
1125
|
+
candidatePath: screenshotPath,
|
|
1126
|
+
pixelThreshold: scenario.diff_pixel_threshold,
|
|
1127
|
+
blockRows: scenario.diff_block_rows,
|
|
1128
|
+
blockCols: scenario.diff_block_cols,
|
|
1129
|
+
heatmapOutputPath: options.heatmapOutputPath || null,
|
|
1130
|
+
});
|
|
1131
|
+
return {
|
|
1132
|
+
structural_similarity: computed.structural_similarity,
|
|
1133
|
+
layout_shift_score: computed.layout_shift_score,
|
|
1134
|
+
pixel_diff_ratio: computed.pixel_diff_ratio,
|
|
1135
|
+
thresholds: scenario.diff_thresholds && typeof scenario.diff_thresholds === 'object' ? scenario.diff_thresholds : undefined,
|
|
1136
|
+
hotspots: computed.hotspots,
|
|
1137
|
+
heatmap_file: computed.heatmap_file,
|
|
1138
|
+
};
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
async function evaluateStateTransitions(projectRoot, scenario = {}) {
|
|
1142
|
+
const baseScreenshotPath = resolveEvidencePath(projectRoot, scenario.screenshot_image);
|
|
1143
|
+
if (!baseScreenshotPath || !existsSync(baseScreenshotPath)) return [];
|
|
1144
|
+
const stateEvidence = Array.isArray(scenario.state_evidence) ? scenario.state_evidence : [];
|
|
1145
|
+
const checks = [];
|
|
1146
|
+
|
|
1147
|
+
for (const state of stateEvidence) {
|
|
1148
|
+
if (state?.expect_visual_change === false) continue;
|
|
1149
|
+
const stateScreenshotPath = resolveEvidencePath(projectRoot, state.screenshot_image);
|
|
1150
|
+
if (!stateScreenshotPath || !existsSync(stateScreenshotPath)) {
|
|
1151
|
+
checks.push(makeStructureCheck(`state-transition-${state?.id || 'unknown'}`, false, 'state screenshot missing'));
|
|
1152
|
+
continue;
|
|
1153
|
+
}
|
|
1154
|
+
const referenceStatePath = state.reference_image ? resolveEvidencePath(projectRoot, state.reference_image) : null;
|
|
1155
|
+
const comparisonPath = referenceStatePath || baseScreenshotPath;
|
|
1156
|
+
if (!comparisonPath || !existsSync(comparisonPath)) {
|
|
1157
|
+
checks.push(makeStructureCheck(`state-transition-${state?.id || 'unknown'}`, false, 'state reference image missing'));
|
|
1158
|
+
continue;
|
|
1159
|
+
}
|
|
1160
|
+
const diff = await computeImageDiffMetrics({
|
|
1161
|
+
referencePath: comparisonPath,
|
|
1162
|
+
candidatePath: stateScreenshotPath,
|
|
1163
|
+
});
|
|
1164
|
+
const minPixelDiffRatio = Number.isFinite(Number(state.min_pixel_diff_ratio)) ? Number(state.min_pixel_diff_ratio) : 0.002;
|
|
1165
|
+
const passed = referenceStatePath
|
|
1166
|
+
? diff.pixel_diff_ratio <= Math.max(minPixelDiffRatio, 0.12)
|
|
1167
|
+
: (diff.pixel_diff_ratio >= minPixelDiffRatio || diff.layout_shift_score > 0);
|
|
1168
|
+
checks.push({
|
|
1169
|
+
id: `state-transition-${state.id}`,
|
|
1170
|
+
status: passed ? 'pass' : 'needs_follow_up',
|
|
1171
|
+
detail: referenceStatePath
|
|
1172
|
+
? `expected ${state.id} to align with reference state, observed pixel_diff_ratio=${diff.pixel_diff_ratio.toFixed(4)}, layout_shift_score=${diff.layout_shift_score.toFixed(4)}`
|
|
1173
|
+
: `expected visual change from base for ${state.id}, observed pixel_diff_ratio=${diff.pixel_diff_ratio.toFixed(4)}, layout_shift_score=${diff.layout_shift_score.toFixed(4)}`,
|
|
1174
|
+
diff_metrics: {
|
|
1175
|
+
pixel_diff_ratio: diff.pixel_diff_ratio,
|
|
1176
|
+
layout_shift_score: diff.layout_shift_score,
|
|
1177
|
+
structural_similarity: diff.structural_similarity,
|
|
1178
|
+
},
|
|
1179
|
+
compared_to: referenceStatePath ? 'reference_state' : 'base_state',
|
|
1180
|
+
});
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
return checks;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
async function evaluateMotionTransitions(projectRoot, scenario = {}) {
|
|
1187
|
+
const baseScreenshotPath = resolveEvidencePath(projectRoot, scenario.screenshot_image);
|
|
1188
|
+
if (!baseScreenshotPath || !existsSync(baseScreenshotPath)) return [];
|
|
1189
|
+
const stateEvidence = Array.isArray(scenario.state_evidence) ? scenario.state_evidence : [];
|
|
1190
|
+
const checks = [];
|
|
1191
|
+
|
|
1192
|
+
for (const state of stateEvidence) {
|
|
1193
|
+
const frameImages = Array.isArray(state?.frame_screenshot_images) ? state.frame_screenshot_images : [];
|
|
1194
|
+
if (frameImages.length === 0 && state?.expect_motion !== true) continue;
|
|
1195
|
+
const comparisonPath = state.reference_image ? resolveEvidencePath(projectRoot, state.reference_image) : baseScreenshotPath;
|
|
1196
|
+
if (!comparisonPath || !existsSync(comparisonPath)) {
|
|
1197
|
+
checks.push(makeStateContractCheck(`motion-transition-${state?.id || 'unknown'}`, false, 'motion reference image missing'));
|
|
1198
|
+
continue;
|
|
1199
|
+
}
|
|
1200
|
+
const diffs = [];
|
|
1201
|
+
for (const frameImage of frameImages) {
|
|
1202
|
+
const framePath = resolveEvidencePath(projectRoot, frameImage);
|
|
1203
|
+
if (!framePath || !existsSync(framePath)) continue;
|
|
1204
|
+
const diff = await computeImageDiffMetrics({
|
|
1205
|
+
referencePath: comparisonPath,
|
|
1206
|
+
candidatePath: framePath,
|
|
1207
|
+
});
|
|
1208
|
+
diffs.push(diff);
|
|
1209
|
+
}
|
|
1210
|
+
const changedFrames = diffs.filter((diff) => diff.pixel_diff_ratio >= 0.002 || diff.layout_shift_score > 0).length;
|
|
1211
|
+
const minChangedFrames = Number.isFinite(Number(state?.min_motion_changed_frames))
|
|
1212
|
+
? Number(state.min_motion_changed_frames)
|
|
1213
|
+
: (frameImages.length > 0 ? 1 : 0);
|
|
1214
|
+
const passed = frameImages.length > 0 && changedFrames >= minChangedFrames;
|
|
1215
|
+
checks.push({
|
|
1216
|
+
id: `motion-transition-${state.id}`,
|
|
1217
|
+
status: passed ? 'pass' : 'needs_follow_up',
|
|
1218
|
+
detail: `expected changed frames >= ${minChangedFrames}, observed ${changedFrames}/${frameImages.length}`,
|
|
1219
|
+
motion_profile: {
|
|
1220
|
+
sampled_frames: frameImages.length,
|
|
1221
|
+
changed_frames: changedFrames,
|
|
1222
|
+
peak_pixel_diff_ratio: diffs.length > 0 ? Math.max(...diffs.map((diff) => diff.pixel_diff_ratio)) : null,
|
|
1223
|
+
peak_layout_shift_score: diffs.length > 0 ? Math.max(...diffs.map((diff) => diff.layout_shift_score)) : null,
|
|
1224
|
+
},
|
|
1225
|
+
});
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
return checks;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
function evaluateStateContract(scenario = {}) {
|
|
1232
|
+
const contract = scenario?.state_contract;
|
|
1233
|
+
if (!contract || typeof contract !== 'object') return [];
|
|
1234
|
+
const stateEvidence = Array.isArray(scenario.state_evidence) ? scenario.state_evidence : [];
|
|
1235
|
+
const checks = [];
|
|
1236
|
+
const requiredWorkbenchStates = normalizeStringList(contract.required_workbench_states);
|
|
1237
|
+
const observedWorkbenchStates = normalizeStringList(stateEvidence.map((state) => state?.workbench_state_type));
|
|
1238
|
+
if (requiredWorkbenchStates.length > 0) {
|
|
1239
|
+
const missing = requiredWorkbenchStates.filter((stateType) => !observedWorkbenchStates.includes(stateType));
|
|
1240
|
+
checks.push(makeStateContractCheck(
|
|
1241
|
+
'state-contract-required-workbench-states',
|
|
1242
|
+
missing.length === 0,
|
|
1243
|
+
missing.length === 0
|
|
1244
|
+
? `required workbench states present: ${requiredWorkbenchStates.join(', ')}`
|
|
1245
|
+
: `missing workbench states: ${missing.join(', ')}; observed ${observedWorkbenchStates.join(', ') || 'none'}`,
|
|
1246
|
+
));
|
|
1247
|
+
}
|
|
1248
|
+
return checks;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
function evaluateStateFamilyConsistency(scenario = {}, stateTransitionChecks = []) {
|
|
1252
|
+
const contract = scenario?.state_family_contract;
|
|
1253
|
+
if (!contract || typeof contract !== 'object') return [];
|
|
1254
|
+
const stateEvidence = Array.isArray(scenario.state_evidence) ? scenario.state_evidence : [];
|
|
1255
|
+
const checkByStateId = new Map(
|
|
1256
|
+
stateTransitionChecks.map((check) => [String(check?.id || '').replace(/^state-transition-/, ''), check]),
|
|
1257
|
+
);
|
|
1258
|
+
const checks = [];
|
|
1259
|
+
|
|
1260
|
+
for (const [familyName, familyRules] of Object.entries(contract)) {
|
|
1261
|
+
if (!familyRules || typeof familyRules !== 'object') continue;
|
|
1262
|
+
const familyStates = stateEvidence.filter((state) => state?.component_family === familyName);
|
|
1263
|
+
const observedVariants = normalizeStringList(familyStates.map((state) => state?.state_variant));
|
|
1264
|
+
const requiredVariants = normalizeStringList(familyRules.required_variants);
|
|
1265
|
+
if (requiredVariants.length > 0) {
|
|
1266
|
+
const missingVariants = requiredVariants.filter((variant) => !observedVariants.includes(variant));
|
|
1267
|
+
checks.push(makeStateContractCheck(
|
|
1268
|
+
`state-family-${familyName}-required-variants`,
|
|
1269
|
+
missingVariants.length === 0,
|
|
1270
|
+
missingVariants.length === 0
|
|
1271
|
+
? `required variants present: ${requiredVariants.join(', ')}`
|
|
1272
|
+
: `missing variants: ${missingVariants.join(', ')}; observed ${observedVariants.join(', ') || 'none'}`,
|
|
1273
|
+
));
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
const diffs = familyStates
|
|
1277
|
+
.map((state) => checkByStateId.get(state.id)?.diff_metrics)
|
|
1278
|
+
.filter((metrics) => metrics && typeof metrics === 'object');
|
|
1279
|
+
const pixelDiffValues = normalizeNumberList(diffs.map((metrics) => metrics.pixel_diff_ratio));
|
|
1280
|
+
const layoutShiftValues = normalizeNumberList(diffs.map((metrics) => metrics.layout_shift_score));
|
|
1281
|
+
const stateTokens = familyStates
|
|
1282
|
+
.map((state) => state?.state_visual_tokens)
|
|
1283
|
+
.filter((tokens) => tokens && typeof tokens === 'object');
|
|
1284
|
+
const distinctSurfaceColors = new Set(
|
|
1285
|
+
stateTokens
|
|
1286
|
+
.map((tokens) => normalizeColorValue(tokens?.color_roles?.surface))
|
|
1287
|
+
.filter(Boolean),
|
|
1288
|
+
);
|
|
1289
|
+
const distinctShadowStrengthTiers = new Set(
|
|
1290
|
+
stateTokens
|
|
1291
|
+
.flatMap((tokens) => normalizeStringList(tokens?.shadow_strength_tiers)),
|
|
1292
|
+
);
|
|
1293
|
+
const distinctTimingFunctions = new Set(
|
|
1294
|
+
stateTokens
|
|
1295
|
+
.flatMap((tokens) => normalizeStringList(tokens?.transition_timing_functions)),
|
|
1296
|
+
);
|
|
1297
|
+
const radiusValues = normalizeNumberList(
|
|
1298
|
+
stateTokens.flatMap((tokens) => Array.isArray(tokens?.radius_values) ? tokens.radius_values : []),
|
|
1299
|
+
);
|
|
1300
|
+
const borderWidthValues = normalizeNumberList(
|
|
1301
|
+
stateTokens.flatMap((tokens) => Array.isArray(tokens?.border_widths) ? tokens.border_widths : []),
|
|
1302
|
+
);
|
|
1303
|
+
const transitionDurationValues = normalizeNumberList(
|
|
1304
|
+
stateTokens.flatMap((tokens) => Array.isArray(tokens?.transition_durations) ? tokens.transition_durations : []),
|
|
1305
|
+
);
|
|
1306
|
+
const animationDurationValues = normalizeNumberList(
|
|
1307
|
+
stateTokens.flatMap((tokens) => Array.isArray(tokens?.animation_durations) ? tokens.animation_durations : []),
|
|
1308
|
+
);
|
|
1309
|
+
|
|
1310
|
+
if (Number.isFinite(Number(familyRules.max_pixel_diff_spread))) {
|
|
1311
|
+
const spread = numericSpread(pixelDiffValues);
|
|
1312
|
+
const maxSpread = Number(familyRules.max_pixel_diff_spread);
|
|
1313
|
+
checks.push(makeStateContractCheck(
|
|
1314
|
+
`state-family-${familyName}-pixel-diff-spread`,
|
|
1315
|
+
spread !== null && spread <= maxSpread,
|
|
1316
|
+
spread === null ? `${familyName} missing pixel diff evidence` : `${familyName} pixel diff spread ${spread} (max ${maxSpread})`,
|
|
1317
|
+
));
|
|
1318
|
+
}
|
|
1319
|
+
if (Number.isFinite(Number(familyRules.max_layout_shift_spread))) {
|
|
1320
|
+
const spread = numericSpread(layoutShiftValues);
|
|
1321
|
+
const maxSpread = Number(familyRules.max_layout_shift_spread);
|
|
1322
|
+
checks.push(makeStateContractCheck(
|
|
1323
|
+
`state-family-${familyName}-layout-shift-spread`,
|
|
1324
|
+
spread !== null && spread <= maxSpread,
|
|
1325
|
+
spread === null ? `${familyName} missing layout shift evidence` : `${familyName} layout shift spread ${spread} (max ${maxSpread})`,
|
|
1326
|
+
));
|
|
1327
|
+
}
|
|
1328
|
+
if (Number.isFinite(Number(familyRules.min_distinct_surface_colors))) {
|
|
1329
|
+
const minDistinct = Number(familyRules.min_distinct_surface_colors);
|
|
1330
|
+
checks.push(makeStateContractCheck(
|
|
1331
|
+
`state-family-${familyName}-surface-colors`,
|
|
1332
|
+
distinctSurfaceColors.size >= minDistinct,
|
|
1333
|
+
`${familyName} distinct surface colors ${distinctSurfaceColors.size} (min ${minDistinct})`,
|
|
1334
|
+
));
|
|
1335
|
+
}
|
|
1336
|
+
if (Number.isFinite(Number(familyRules.min_distinct_shadow_strength_tiers))) {
|
|
1337
|
+
const minDistinct = Number(familyRules.min_distinct_shadow_strength_tiers);
|
|
1338
|
+
checks.push(makeStateContractCheck(
|
|
1339
|
+
`state-family-${familyName}-shadow-strength-tiers`,
|
|
1340
|
+
distinctShadowStrengthTiers.size >= minDistinct,
|
|
1341
|
+
`${familyName} distinct shadow strength tiers ${distinctShadowStrengthTiers.size} (min ${minDistinct})`,
|
|
1342
|
+
));
|
|
1343
|
+
}
|
|
1344
|
+
if (Array.isArray(familyRules.required_timing_functions) && familyRules.required_timing_functions.length > 0) {
|
|
1345
|
+
const requiredTimingFunctions = normalizeStringList(familyRules.required_timing_functions);
|
|
1346
|
+
const missingTimingFunctions = requiredTimingFunctions.filter((fn) => !distinctTimingFunctions.has(fn));
|
|
1347
|
+
checks.push(makeStateContractCheck(
|
|
1348
|
+
`state-family-${familyName}-timing-functions`,
|
|
1349
|
+
missingTimingFunctions.length === 0,
|
|
1350
|
+
missingTimingFunctions.length === 0
|
|
1351
|
+
? `required timing functions present: ${requiredTimingFunctions.join(', ')}`
|
|
1352
|
+
: `missing timing functions: ${missingTimingFunctions.join(', ')}; observed ${Array.from(distinctTimingFunctions).join(', ') || 'none'}`,
|
|
1353
|
+
));
|
|
1354
|
+
}
|
|
1355
|
+
if (Number.isFinite(Number(familyRules.max_radius_spread))) {
|
|
1356
|
+
const spread = numericSpread(radiusValues);
|
|
1357
|
+
const maxSpread = Number(familyRules.max_radius_spread);
|
|
1358
|
+
checks.push(makeStateContractCheck(
|
|
1359
|
+
`state-family-${familyName}-radius-spread`,
|
|
1360
|
+
spread !== null && spread <= maxSpread,
|
|
1361
|
+
spread === null ? `${familyName} missing radius token evidence` : `${familyName} radius spread ${spread} (max ${maxSpread})`,
|
|
1362
|
+
));
|
|
1363
|
+
}
|
|
1364
|
+
if (Number.isFinite(Number(familyRules.max_border_width_spread))) {
|
|
1365
|
+
const spread = numericSpread(borderWidthValues);
|
|
1366
|
+
const maxSpread = Number(familyRules.max_border_width_spread);
|
|
1367
|
+
checks.push(makeStateContractCheck(
|
|
1368
|
+
`state-family-${familyName}-border-width-spread`,
|
|
1369
|
+
spread !== null && spread <= maxSpread,
|
|
1370
|
+
spread === null ? `${familyName} missing border width token evidence` : `${familyName} border width spread ${spread} (max ${maxSpread})`,
|
|
1371
|
+
));
|
|
1372
|
+
}
|
|
1373
|
+
if (Number.isFinite(Number(familyRules.max_transition_duration_spread))) {
|
|
1374
|
+
const spread = numericSpread(transitionDurationValues);
|
|
1375
|
+
const maxSpread = Number(familyRules.max_transition_duration_spread);
|
|
1376
|
+
checks.push(makeStateContractCheck(
|
|
1377
|
+
`state-family-${familyName}-transition-duration-spread`,
|
|
1378
|
+
spread !== null && spread <= maxSpread,
|
|
1379
|
+
spread === null ? `${familyName} missing transition duration evidence` : `${familyName} transition duration spread ${spread}ms (max ${maxSpread}ms)`,
|
|
1380
|
+
));
|
|
1381
|
+
}
|
|
1382
|
+
if (Number.isFinite(Number(familyRules.max_animation_duration_spread))) {
|
|
1383
|
+
const spread = numericSpread(animationDurationValues);
|
|
1384
|
+
const maxSpread = Number(familyRules.max_animation_duration_spread);
|
|
1385
|
+
checks.push(makeStateContractCheck(
|
|
1386
|
+
`state-family-${familyName}-animation-duration-spread`,
|
|
1387
|
+
spread !== null && spread <= maxSpread,
|
|
1388
|
+
spread === null ? `${familyName} missing animation duration evidence` : `${familyName} animation duration spread ${spread}ms (max ${maxSpread}ms)`,
|
|
1389
|
+
));
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
return checks;
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
function evaluateBenchmarkMatrix(contract = {}, scenarios = []) {
|
|
1397
|
+
if (!contract || typeof contract !== 'object') return [];
|
|
1398
|
+
const checks = [];
|
|
1399
|
+
const scenarioIds = normalizeStringList(scenarios.map((scenario) => scenario?.id));
|
|
1400
|
+
const requiredScenarioIds = normalizeStringList(contract.required_scenario_ids);
|
|
1401
|
+
const requiredPlatformProfiles = normalizeStringList(contract.required_platform_profiles);
|
|
1402
|
+
if (requiredScenarioIds.length > 0) {
|
|
1403
|
+
const missing = requiredScenarioIds.filter((id) => !scenarioIds.includes(id));
|
|
1404
|
+
checks.push(makeMatrixCheck(
|
|
1405
|
+
'matrix-required-scenarios',
|
|
1406
|
+
missing.length === 0,
|
|
1407
|
+
missing.length === 0
|
|
1408
|
+
? `required scenarios present: ${requiredScenarioIds.join(', ')}`
|
|
1409
|
+
: `missing scenarios: ${missing.join(', ')}; observed ${scenarioIds.join(', ') || 'none'}`,
|
|
1410
|
+
));
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
if (requiredPlatformProfiles.length > 0) {
|
|
1414
|
+
const observedProfiles = normalizeStringList(scenarios.map((scenario) => scenario?.platform_profile));
|
|
1415
|
+
const missing = requiredPlatformProfiles.filter((profile) => !observedProfiles.includes(profile));
|
|
1416
|
+
checks.push(makeMatrixCheck(
|
|
1417
|
+
'matrix-required-platform-profiles',
|
|
1418
|
+
missing.length === 0,
|
|
1419
|
+
missing.length === 0
|
|
1420
|
+
? `required platform profiles present: ${requiredPlatformProfiles.join(', ')}`
|
|
1421
|
+
: `missing platform profiles: ${missing.join(', ')}; observed ${observedProfiles.join(', ') || 'none'}`,
|
|
1422
|
+
));
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
const requiredStateVariants = normalizeStringList(contract.required_state_variants);
|
|
1426
|
+
if (requiredStateVariants.length > 0) {
|
|
1427
|
+
const observedVariants = normalizeStringList(
|
|
1428
|
+
scenarios.flatMap((scenario) => Array.isArray(scenario?.state_evidence) ? scenario.state_evidence.map((state) => state?.state_variant) : []),
|
|
1429
|
+
);
|
|
1430
|
+
const missing = requiredStateVariants.filter((variant) => !observedVariants.includes(variant));
|
|
1431
|
+
checks.push(makeMatrixCheck(
|
|
1432
|
+
'matrix-required-state-variants',
|
|
1433
|
+
missing.length === 0,
|
|
1434
|
+
missing.length === 0
|
|
1435
|
+
? `required state variants present: ${requiredStateVariants.join(', ')}`
|
|
1436
|
+
: `missing state variants: ${missing.join(', ')}; observed ${observedVariants.join(', ') || 'none'}`,
|
|
1437
|
+
));
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
if (Number.isFinite(Number(contract.max_average_pixel_diff_ratio))) {
|
|
1441
|
+
const values = scenarios
|
|
1442
|
+
.map((scenario) => Number(scenario?.diff_metrics?.values?.pixel_diff_ratio))
|
|
1443
|
+
.filter((value) => Number.isFinite(value));
|
|
1444
|
+
const average = values.length > 0 ? values.reduce((sum, value) => sum + value, 0) / values.length : null;
|
|
1445
|
+
const maxAverage = Number(contract.max_average_pixel_diff_ratio);
|
|
1446
|
+
checks.push(makeMatrixCheck(
|
|
1447
|
+
'matrix-average-pixel-diff-ratio',
|
|
1448
|
+
average !== null && average <= maxAverage,
|
|
1449
|
+
average === null ? 'missing scenario diff metrics' : `average pixel diff ratio ${average} (max ${maxAverage})`,
|
|
1450
|
+
));
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
return checks;
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
async function materializeScenarioCapture(projectRoot, changeId, scenario = {}, index) {
|
|
1457
|
+
if (!scenario.capture_url) return scenario;
|
|
1458
|
+
const scenarioId = scenario.id || `scenario-${index + 1}`;
|
|
1459
|
+
const screenshotPath = scenario.screenshot_image
|
|
1460
|
+
? resolveEvidencePath(projectRoot, scenario.screenshot_image)
|
|
1461
|
+
: resolve(projectRoot, 'output', 'playwright', `${changeId}-${scenarioId}.png`);
|
|
1462
|
+
const snapshotPath = resolve(projectRoot, '.as-xflow', `${changeId}-${scenarioId}-snapshot.json`);
|
|
1463
|
+
const capture = await capturePageEvidence({
|
|
1464
|
+
url: scenario.capture_url,
|
|
1465
|
+
snapshotPath,
|
|
1466
|
+
screenshotPath,
|
|
1467
|
+
width: scenario.viewport?.width,
|
|
1468
|
+
height: scenario.viewport?.height,
|
|
1469
|
+
waitMs: scenario.wait_ms,
|
|
1470
|
+
platformProfile: scenario.platform_profile || null,
|
|
1471
|
+
panelAttr: scenario.panel_attr || 'data-panel',
|
|
1472
|
+
panelSelector: scenario.panel_selector || null,
|
|
1473
|
+
captureStates: Array.isArray(scenario.capture_states) ? scenario.capture_states : [],
|
|
1474
|
+
autoDiscoverStates: scenario.auto_discover_states === true,
|
|
1475
|
+
stateLimit: Number.isFinite(Number(scenario.state_limit)) ? Number(scenario.state_limit) : 4,
|
|
1476
|
+
});
|
|
1477
|
+
const domRects = exportDomRectsFromSnapshot(capture.snapshot, { panelAttr: scenario.panel_attr || 'data-panel' });
|
|
1478
|
+
const visualTokens = exportVisualTokensFromSnapshot(capture.snapshot);
|
|
1479
|
+
return {
|
|
1480
|
+
...scenario,
|
|
1481
|
+
capture_url: scenario.capture_url,
|
|
1482
|
+
screenshot_image: relative(projectRoot, capture.screenshot_file),
|
|
1483
|
+
layout_observations: {
|
|
1484
|
+
...(scenario.layout_observations && typeof scenario.layout_observations === 'object' ? scenario.layout_observations : {}),
|
|
1485
|
+
dom_rects: domRects.dom_rects,
|
|
1486
|
+
},
|
|
1487
|
+
observed_visual_tokens: scenario.observed_visual_tokens || visualTokens.observed_visual_tokens,
|
|
1488
|
+
viewport_metrics: scenario.viewport_metrics || capture.snapshot?.viewport_metrics || null,
|
|
1489
|
+
platform_profile: scenario.platform_profile || capture.snapshot?.platform_profile || null,
|
|
1490
|
+
state_evidence: Array.isArray(capture.states)
|
|
1491
|
+
? capture.states.map((state) => ({
|
|
1492
|
+
id: state.id,
|
|
1493
|
+
label: state.label || null,
|
|
1494
|
+
screenshot_image: relative(projectRoot, state.screenshot_file),
|
|
1495
|
+
nodes_captured: state.nodes_captured,
|
|
1496
|
+
expect_visual_change: state.expect_visual_change !== false,
|
|
1497
|
+
min_pixel_diff_ratio: Number.isFinite(Number(state.min_pixel_diff_ratio)) ? Number(state.min_pixel_diff_ratio) : null,
|
|
1498
|
+
reference_image: state.reference_image || null,
|
|
1499
|
+
priority_score: Number.isFinite(Number(state.priority_score)) ? Number(state.priority_score) : null,
|
|
1500
|
+
priority_reason: state.priority_reason || null,
|
|
1501
|
+
source: state.source || null,
|
|
1502
|
+
workbench_state_type: state.workbench_state_type || null,
|
|
1503
|
+
state_tags: Array.isArray(state.state_tags) ? state.state_tags : [],
|
|
1504
|
+
component_family: state.component_family || null,
|
|
1505
|
+
state_variant: state.state_variant || null,
|
|
1506
|
+
state_visual_tokens: state.nodes ? exportVisualTokensFromSnapshot({ nodes: state.nodes }).observed_visual_tokens : null,
|
|
1507
|
+
expect_motion: state.expect_motion === true,
|
|
1508
|
+
min_motion_changed_frames: Number.isFinite(Number(state.min_motion_changed_frames)) ? Number(state.min_motion_changed_frames) : null,
|
|
1509
|
+
frame_screenshot_images: Array.isArray(state.frame_screenshot_files) ? state.frame_screenshot_files.map((filePath) => relative(projectRoot, filePath)) : [],
|
|
1510
|
+
}))
|
|
1511
|
+
: [],
|
|
1512
|
+
};
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
async function normalizeScenario(projectRoot, scenario = {}, index) {
|
|
1516
|
+
const changeId = scenario.change_id_hint || 'unknown-change';
|
|
1517
|
+
const materializedScenario = await materializeScenarioCapture(projectRoot, changeId, scenario, index);
|
|
1518
|
+
scenario = materializedScenario;
|
|
1519
|
+
const referencePath = resolveEvidencePath(projectRoot, scenario.reference_image);
|
|
1520
|
+
const screenshotPath = resolveEvidencePath(projectRoot, scenario.screenshot_image);
|
|
1521
|
+
const observedLayout = normalizeObservedLayout(scenario.observed_layout, scenario.layout_observations);
|
|
1522
|
+
const observedPanels = normalizeObservedPanels(scenario.layout_observations);
|
|
1523
|
+
const viewportMetrics = normalizeViewportMetrics(scenario.viewport_metrics || scenario.observed_visual_tokens || {});
|
|
1524
|
+
const layoutDerivedChecks = evaluateLayoutContract(scenario.layout_contract, observedLayout, observedPanels, scenario.viewport, viewportMetrics);
|
|
1525
|
+
const anchorDerivedChecks = evaluateAnchorRelations(scenario.layout_contract, observedPanels);
|
|
1526
|
+
const alignmentGridChecks = evaluateAlignmentGrid(scenario.layout_contract, observedPanels);
|
|
1527
|
+
const structureChecks = [
|
|
1528
|
+
...normalizeStructureChecks(scenario.structure_checks),
|
|
1529
|
+
...layoutDerivedChecks,
|
|
1530
|
+
...anchorDerivedChecks,
|
|
1531
|
+
...alignmentGridChecks,
|
|
1532
|
+
];
|
|
1533
|
+
const scenarioHeatmapPath = scenario.screenshot_image
|
|
1534
|
+
? resolve(projectRoot, 'output', 'playwright', `${changeId}-${scenario.id || `scenario-${index + 1}`}-heatmap.png`)
|
|
1535
|
+
: null;
|
|
1536
|
+
const diffMetrics = evaluateDiffMetrics(await resolveScenarioDiffMetrics(projectRoot, scenario, {
|
|
1537
|
+
heatmapOutputPath: scenarioHeatmapPath,
|
|
1538
|
+
}));
|
|
1539
|
+
const tokenChecks = evaluateVisualTokenContract(scenario.visual_token_contract, scenario.observed_visual_tokens);
|
|
1540
|
+
const stateTransitionChecks = await evaluateStateTransitions(projectRoot, scenario);
|
|
1541
|
+
const motionTransitionChecks = await evaluateMotionTransitions(projectRoot, scenario);
|
|
1542
|
+
const stateContractChecks = evaluateStateContract(scenario);
|
|
1543
|
+
const stateFamilyChecks = evaluateStateFamilyConsistency(scenario, stateTransitionChecks);
|
|
1544
|
+
const blockers = [];
|
|
1545
|
+
|
|
1546
|
+
const evidence = {
|
|
1547
|
+
reference_image_exists: Boolean(referencePath && existsSync(referencePath)),
|
|
1548
|
+
screenshot_image_exists: Boolean(screenshotPath && existsSync(screenshotPath)),
|
|
1549
|
+
};
|
|
1550
|
+
if (!evidence.reference_image_exists) blockers.push('reference_image_missing');
|
|
1551
|
+
if (!evidence.screenshot_image_exists) blockers.push('screenshot_image_missing');
|
|
1552
|
+
if (diffMetrics.status !== 'pass') blockers.push(...diffMetrics.issues);
|
|
1553
|
+
if (structureChecks.some((check) => check.status !== 'pass')) blockers.push('structure_checks_unresolved');
|
|
1554
|
+
if (tokenChecks.some((check) => check.status !== 'pass')) blockers.push('token_checks_unresolved');
|
|
1555
|
+
if (stateTransitionChecks.some((check) => check.status !== 'pass')) blockers.push('state_transition_checks_unresolved');
|
|
1556
|
+
if (motionTransitionChecks.some((check) => check.status !== 'pass')) blockers.push('motion_transition_checks_unresolved');
|
|
1557
|
+
if (stateContractChecks.some((check) => check.status !== 'pass')) blockers.push('state_contract_unresolved');
|
|
1558
|
+
if (stateFamilyChecks.some((check) => check.status !== 'pass')) blockers.push('state_family_checks_unresolved');
|
|
1559
|
+
|
|
1560
|
+
const explicitStatus = scenario.status || null;
|
|
1561
|
+
const computedStatus = blockers.length === 0 ? 'pass' : 'needs_follow_up';
|
|
1562
|
+
|
|
1563
|
+
return {
|
|
1564
|
+
id: scenario.id || `scenario-${index + 1}`,
|
|
1565
|
+
viewport: scenario.viewport || null,
|
|
1566
|
+
platform_profile: scenario.platform_profile || null,
|
|
1567
|
+
reference_image: scenario.reference_image || null,
|
|
1568
|
+
screenshot_image: scenario.screenshot_image || null,
|
|
1569
|
+
diff_metrics: diffMetrics,
|
|
1570
|
+
structure_checks: structureChecks,
|
|
1571
|
+
token_checks: tokenChecks,
|
|
1572
|
+
state_transition_checks: stateTransitionChecks,
|
|
1573
|
+
motion_transition_checks: motionTransitionChecks,
|
|
1574
|
+
state_contract_checks: stateContractChecks,
|
|
1575
|
+
state_family_checks: stateFamilyChecks,
|
|
1576
|
+
layout_contract: scenario.layout_contract || null,
|
|
1577
|
+
state_contract: scenario.state_contract || null,
|
|
1578
|
+
state_family_contract: scenario.state_family_contract || null,
|
|
1579
|
+
visual_token_contract: scenario.visual_token_contract || null,
|
|
1580
|
+
observed_layout: observedLayout,
|
|
1581
|
+
layout_observations: scenario.layout_observations || null,
|
|
1582
|
+
observed_visual_tokens: scenario.observed_visual_tokens || null,
|
|
1583
|
+
viewport_metrics: Object.keys(viewportMetrics).length > 0 ? viewportMetrics : null,
|
|
1584
|
+
state_evidence: Array.isArray(scenario.state_evidence) ? scenario.state_evidence : [],
|
|
1585
|
+
screenshot_evidence_mode: scenario.capture_url ? 'captured_page' : 'reference_backed',
|
|
1586
|
+
evidence,
|
|
1587
|
+
status: explicitStatus === 'pass' && computedStatus !== 'pass' ? 'needs_follow_up' : (explicitStatus || computedStatus),
|
|
1588
|
+
blockers,
|
|
1589
|
+
};
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
export async function visualBenchmark(input = {}, context = {}) {
|
|
1593
|
+
const projectRoot = context.projectRoot || process.cwd();
|
|
1594
|
+
const changeId = context.changeId || input.change_id || null;
|
|
1595
|
+
if (!changeId) throw new Error('change_id is required for H6.visual.benchmark');
|
|
1596
|
+
const outputPath = resolveChangePath(projectRoot, changeId, 'visual_benchmark.json');
|
|
1597
|
+
const references = parseReferenceScenarios(input);
|
|
1598
|
+
const implicitReferences = buildImplicitReferenceScenarios(input);
|
|
1599
|
+
const generationDefaults = loadGenerationDefaults(projectRoot, changeId);
|
|
1600
|
+
const effectiveReferences = (references.length > 0 ? references : implicitReferences)
|
|
1601
|
+
.map((scenario) => applyGenerationDefaults(scenario, generationDefaults));
|
|
1602
|
+
const inputMode = determineBenchmarkInputMode(input, references, implicitReferences);
|
|
1603
|
+
const competitorProduct = input.competitor_product || null;
|
|
1604
|
+
const requiredModules = Array.isArray(input.required_modules) ? input.required_modules : [];
|
|
1605
|
+
const benchmarkMatrixContract = parseObjectLike(input.benchmark_matrix_contract) || parseObjectLike(input.benchmark_matrix_contract_json) || null;
|
|
1606
|
+
const scenarios = effectiveReferences.length > 0
|
|
1607
|
+
? await Promise.all(effectiveReferences.map((scenario, index) => normalizeScenario(projectRoot, { ...scenario, change_id_hint: changeId }, index)))
|
|
1608
|
+
: [
|
|
1609
|
+
{
|
|
1610
|
+
id: 'desktop-reference-missing',
|
|
1611
|
+
screenshot_evidence_mode: 'metadata_only',
|
|
1612
|
+
status: 'needs_follow_up',
|
|
1613
|
+
blocker: 'reference_screenshot_required',
|
|
1614
|
+
},
|
|
1615
|
+
];
|
|
1616
|
+
const matrixChecks = evaluateBenchmarkMatrix(benchmarkMatrixContract, scenarios);
|
|
1617
|
+
const payload = {
|
|
1618
|
+
version: 1,
|
|
1619
|
+
benchmark_mode: effectiveReferences.length > 0 ? 'reference_backed' : 'placeholder',
|
|
1620
|
+
benchmark_input_mode: inputMode,
|
|
1621
|
+
competitor_product: competitorProduct,
|
|
1622
|
+
required_modules: requiredModules,
|
|
1623
|
+
benchmark_matrix_contract: benchmarkMatrixContract,
|
|
1624
|
+
matrix_checks: matrixChecks,
|
|
1625
|
+
scenarios,
|
|
1626
|
+
report_file: null,
|
|
1627
|
+
};
|
|
1628
|
+
if (matrixChecks.some((check) => check.status !== 'pass')) {
|
|
1629
|
+
payload.matrix_status = 'needs_follow_up';
|
|
1630
|
+
} else {
|
|
1631
|
+
payload.matrix_status = 'pass';
|
|
1632
|
+
}
|
|
1633
|
+
if (effectiveReferences.length > 0) {
|
|
1634
|
+
const reportPath = resolveChangePath(projectRoot, changeId, 'visual_benchmark_report.html');
|
|
1635
|
+
renderVisualBenchmarkReport({
|
|
1636
|
+
projectRoot,
|
|
1637
|
+
outputPath: reportPath,
|
|
1638
|
+
artifact: payload,
|
|
1639
|
+
});
|
|
1640
|
+
payload.report_file = reportPath;
|
|
1641
|
+
}
|
|
1642
|
+
writeJsonFile(outputPath, payload);
|
|
1643
|
+
const scenariosPass = scenarios.every((scenario) => scenario.status === 'pass');
|
|
1644
|
+
const matrixPass = payload.matrix_status !== 'needs_follow_up';
|
|
1645
|
+
return {
|
|
1646
|
+
ok: true,
|
|
1647
|
+
status: effectiveReferences.length > 0 && scenariosPass && matrixPass ? 'pass' : 'needs_follow_up',
|
|
1648
|
+
output_file: outputPath,
|
|
1649
|
+
report_file: payload.report_file,
|
|
1650
|
+
payload,
|
|
1651
|
+
};
|
|
1652
|
+
}
|