ui-design-workbench-cli 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. android_resource_resolver.py +370 -0
  2. android_xml_support.py +230 -0
  3. apple_resource_resolver.py +141 -0
  4. coverage_report.py +134 -0
  5. fidelity_adapter_api.py +89 -0
  6. fidelity_adapters.py +508 -0
  7. fidelity_core.py +241 -0
  8. fidelity_platform_adapters.py +902 -0
  9. ir_contracts.py +76 -0
  10. native_render_android.py +160 -0
  11. native_render_apple.py +165 -0
  12. native_render_contracts.py +148 -0
  13. native_render_registry.py +86 -0
  14. quality_common.py +217 -0
  15. render_preview.py +1797 -0
  16. scan_ui.py +1485 -0
  17. scoped_context.py +458 -0
  18. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/SKILL.md +52 -0
  19. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/adapter-api.md +20 -0
  20. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/agent-integrations.md +39 -0
  21. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/cache-protocol.md +61 -0
  22. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/design-modes.md +109 -0
  23. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/fidelity.md +53 -0
  24. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/ir-schema.md +412 -0
  25. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/native-rendering.md +40 -0
  26. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/platform-profiles.json +84 -0
  27. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/platform-standards.md +131 -0
  28. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/platforms.md +40 -0
  29. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/quality-automation.md +77 -0
  30. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/review-workflow.md +135 -0
  31. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/ui-guidance-mode.md +22 -0
  32. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/ui-policy-schema.md +83 -0
  33. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/ui-reviewer.md +175 -0
  34. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/validation.md +34 -0
  35. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/workbench-ui.md +108 -0
  36. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/schemas/native-render-state.schema.json +71 -0
  37. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/schemas/ui-agent-job.schema.json +36 -0
  38. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/schemas/ui-graph.schema.json +46 -0
  39. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/schemas/ui-ir.patch.schema.json +36 -0
  40. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/schemas/ui-ir.schema.json +296 -0
  41. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/schemas/uidw-config.schema.json +68 -0
  42. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/android_resource_resolver.py +370 -0
  43. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/android_xml_support.py +230 -0
  44. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/apple_resource_resolver.py +141 -0
  45. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/coverage_report.py +134 -0
  46. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/fidelity_adapter_api.py +89 -0
  47. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/fidelity_adapters.py +508 -0
  48. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/fidelity_core.py +241 -0
  49. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/fidelity_platform_adapters.py +902 -0
  50. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/generate_interaction_matrix.py +166 -0
  51. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/ir_contracts.py +76 -0
  52. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/merge_review_state.py +267 -0
  53. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/native_render_android.py +160 -0
  54. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/native_render_apple.py +165 -0
  55. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/native_render_contracts.py +148 -0
  56. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/native_render_registry.py +86 -0
  57. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/quality_common.py +217 -0
  58. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/render_preview.py +1797 -0
  59. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/scan_ui.py +1485 -0
  60. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/scoped_context.py +458 -0
  61. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/smoke_preview.js +978 -0
  62. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/uidw.py +2821 -0
  63. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/uidw_mcp.py +282 -0
  64. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/validate_platform_profiles.py +93 -0
  65. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/visual_regression.py +141 -0
  66. ui_design_workbench_cli-0.5.0.dist-info/METADATA +133 -0
  67. ui_design_workbench_cli-0.5.0.dist-info/RECORD +73 -0
  68. ui_design_workbench_cli-0.5.0.dist-info/WHEEL +5 -0
  69. ui_design_workbench_cli-0.5.0.dist-info/entry_points.txt +3 -0
  70. ui_design_workbench_cli-0.5.0.dist-info/top_level.txt +20 -0
  71. uidw.py +2821 -0
  72. uidw_mcp.py +282 -0
  73. validate_platform_profiles.py +93 -0
@@ -0,0 +1,282 @@
1
+ #!/usr/bin/env python3
2
+ """Optional local stdio MCP facade over the deterministic UIDW CLI core."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import uidw
12
+ from native_render_registry import native_render_status
13
+ from scoped_context import apply_patch_file, build_scoped_context
14
+
15
+
16
+ SERVER_INSTRUCTIONS = (
17
+ "Start with ui_project, then call ui_scope for one screen or a small finding set. "
18
+ "If ui_project returns setupRequired, ask the user only for low, medium, or high, then call "
19
+ "ui_configure with that choice before continuing. "
20
+ "Do not request or embed the complete UI IR when bounded context is sufficient. "
21
+ "Use ui_prepare_job for review, proposal, or implementation handoffs. Review and proposal work "
22
+ "must preserve immutable baseline UI and return sparse ui-ir.patch.json operations; application "
23
+ "source changes require an explicit implementation request. Use ui_build_preview for projection "
24
+ "checks only; do not infer UI/UX findings unless the user explicitly requests a review. "
25
+ "For Android or Apple visual accuracy, call ui_native_status and never describe source projection as a native render. "
26
+ "Native builds, simulators, and captures are explicit-only operations."
27
+ )
28
+
29
+
30
+ def _root(value: str | None, default: Path) -> Path:
31
+ path = Path(value).expanduser().resolve() if value else default.resolve()
32
+ if not path.is_dir():
33
+ raise ValueError(f"Repository directory does not exist: {path}")
34
+ return path
35
+
36
+
37
+ def _ir(root: Path, value: str | None) -> tuple[Path, dict[str, Any]]:
38
+ return uidw.load_ir_argument(uidw.state_paths(root), Path(value).expanduser() if value else None)
39
+
40
+
41
+ def project_summary(repo: str | None, default_repo: Path) -> dict[str, Any]:
42
+ root = _root(repo, default_repo)
43
+ initialization, paths, config = uidw.ensure_initialized(root)
44
+ ir = uidw.load_project_ir(paths) or {}
45
+ configuration = uidw.configuration_context(config)
46
+ setup_required = bool(configuration.get("setupRequired"))
47
+ return {
48
+ "status": initialization.get("status"),
49
+ "cache": initialization.get("initialization", {}).get("status"),
50
+ "repoRoot": str(root),
51
+ "uiIrFile": str(paths["ir"]),
52
+ "contextFile": str(paths["context"]),
53
+ "screens": [{"id": item.get("id"), "name": item.get("name"), "platform": item.get("platform")} for item in ir.get("screens", [])],
54
+ "findingCount": len(ir.get("review", {}).get("audit", {}).get("findings", [])),
55
+ "setupRequired": setup_required,
56
+ "detailLevel": configuration.get("detailLevel"),
57
+ "detailChoices": ["low", "medium", "high"] if setup_required else [],
58
+ "questionsForUser": configuration.get("questionsForUser", []),
59
+ "uiModeEnabled": bool(config.get(uidw.UI_MODE_KEY, {}).get("enabled")),
60
+ "next": (
61
+ "Ask the user to choose low, medium, or high, then call ui_configure."
62
+ if setup_required
63
+ else "Use ui_scope for one screen or a small finding set; do not read the full IR."
64
+ ),
65
+ }
66
+
67
+
68
+ def scoped_payload(
69
+ repo: str | None,
70
+ default_repo: Path,
71
+ ui_ir_file: str | None,
72
+ screen_ids: list[str],
73
+ finding_ids: list[str],
74
+ max_tokens: int,
75
+ ) -> dict[str, Any]:
76
+ root = _root(repo, default_repo)
77
+ ir_path, ir = _ir(root, ui_ir_file)
78
+ return build_scoped_context(
79
+ ir,
80
+ screen_ids=screen_ids,
81
+ finding_ids=finding_ids,
82
+ token_budget=max_tokens,
83
+ ui_ir_file=str(ir_path),
84
+ )
85
+
86
+
87
+ def compact_workbench_result(result: dict[str, Any]) -> dict[str, Any]:
88
+ return {
89
+ key: result.get(key)
90
+ for key in ("version", "status", "previewFile", "url", "workflow", "initialization")
91
+ if result.get(key) is not None
92
+ }
93
+
94
+
95
+ def configure_detail(repo: str | None, default_repo: Path, detail: str) -> dict[str, Any]:
96
+ root = _root(repo, default_repo)
97
+ normalized = detail.strip().lower()
98
+ if normalized not in {"low", "medium", "high"}:
99
+ return {
100
+ "status": "blocked",
101
+ "error": "detail must be low, medium, or high",
102
+ "detailChoices": ["low", "medium", "high"],
103
+ }
104
+ result = uidw.configure_project(root, "set", "detail", normalized)
105
+ configuration = result.get("configuration", {})
106
+ return {
107
+ "status": result.get("status"),
108
+ "repoRoot": result.get("repoRoot"),
109
+ "setupRequired": bool(configuration.get("setupRequired")),
110
+ "detailLevel": configuration.get("detailLevel"),
111
+ "mockData": configuration.get(uidw.MOCK_DATA_KEY),
112
+ "contextFile": result.get("contextFile"),
113
+ "next": "Use ui_scope, then ui_build_preview when the requested screens are ready.",
114
+ }
115
+
116
+
117
+ def build_preview(
118
+ repo: str | None,
119
+ default_repo: Path,
120
+ ui_ir_file: str | None,
121
+ output_dir: str | None,
122
+ level: str,
123
+ ) -> dict[str, Any]:
124
+ root = _root(repo, default_repo)
125
+ paths = uidw.state_paths(root)
126
+ configuration = uidw.configuration_context(uidw.load_config(paths["config"]))
127
+ if configuration.get("setupRequired"):
128
+ return {
129
+ "status": "needs-setup",
130
+ "setupRequired": True,
131
+ "detailChoices": ["low", "medium", "high"],
132
+ "questionsForUser": configuration.get("questionsForUser", []),
133
+ "next": "Ask the user to choose low, medium, or high, then call ui_configure.",
134
+ }
135
+
136
+ ir_path, _ = _ir(root, ui_ir_file)
137
+ destination = Path(output_dir).expanduser().resolve() if output_dir else ir_path.parent / "workbench"
138
+ cached_ir = paths["ir"].resolve()
139
+ allow_draft = ir_path.resolve() == cached_ir
140
+ try:
141
+ result = uidw.build_workbench(
142
+ root,
143
+ ir_path,
144
+ destination,
145
+ level,
146
+ allow_draft,
147
+ "generic",
148
+ False,
149
+ None,
150
+ None,
151
+ None,
152
+ None,
153
+ None,
154
+ )
155
+ except (RuntimeError, ValueError, OSError) as exc:
156
+ return {
157
+ "status": "blocked",
158
+ "error": str(exc),
159
+ "uiIrFile": str(ir_path),
160
+ "next": "Resolve the reported IR or fidelity problem, then call ui_build_preview again.",
161
+ }
162
+ return compact_workbench_result(result)
163
+
164
+
165
+ def create_server(default_repo: Path, name: str = "UI Design Workbench") -> Any:
166
+ try:
167
+ from mcp.server import MCPServer
168
+ except ImportError as exc:
169
+ raise RuntimeError(
170
+ "MCP support is optional. Install it with `pip install \"ui-design-workbench-cli[mcp]\"` "
171
+ "or `pip install \"mcp>=2,<3\"`. The regular uidw CLI does not require it."
172
+ ) from exc
173
+
174
+ server = MCPServer(
175
+ name,
176
+ version=uidw.CLI_VERSION,
177
+ instructions=SERVER_INSTRUCTIONS,
178
+ )
179
+
180
+ @server.tool()
181
+ def ui_project(repo: str = "") -> dict[str, Any]:
182
+ """Get a compact cached UI catalog and the next bounded action."""
183
+ return project_summary(repo or None, default_repo)
184
+
185
+ @server.tool()
186
+ def ui_configure(detail: str, repo: str = "") -> dict[str, Any]:
187
+ """Set the required low, medium, or high preview detail after the user chooses it."""
188
+ return configure_detail(repo or None, default_repo, detail)
189
+
190
+ @server.tool()
191
+ def ui_scope(
192
+ screen_ids: list[str],
193
+ finding_ids: list[str],
194
+ repo: str = "",
195
+ ui_ir_file: str = "",
196
+ max_tokens: int = 4000,
197
+ ) -> dict[str, Any]:
198
+ """Read complete UI evidence only for named screens/findings."""
199
+ return scoped_payload(repo or None, default_repo, ui_ir_file or None, screen_ids, finding_ids, max_tokens)
200
+
201
+ @server.tool()
202
+ def ui_prepare_job(
203
+ kind: str,
204
+ finding_ids: list[str],
205
+ repo: str = "",
206
+ ui_ir_file: str = "",
207
+ output_file: str = "",
208
+ scope: str = "current",
209
+ direct: bool = False,
210
+ ) -> dict[str, Any]:
211
+ """Prepare a portable bounded job plus context and ui-ir.patch.json."""
212
+ root = _root(repo or None, default_repo)
213
+ ir_path, ir = _ir(root, ui_ir_file or None)
214
+ if kind not in {"expert", "proposal", "implementation"}:
215
+ raise ValueError("kind must be expert, proposal, or implementation")
216
+ output = Path(output_file).expanduser().resolve() if output_file else ir_path.parent / f"ui-agent-job-{kind}.json"
217
+ return uidw.prepare_agent_job(root, ir_path, ir, kind, output, identifiers=finding_ids, scope=scope, direct=direct)
218
+
219
+ @server.tool()
220
+ def ui_apply_patch(
221
+ patch_file: str,
222
+ repo: str = "",
223
+ ui_ir_file: str = "",
224
+ output_file: str = "",
225
+ ) -> dict[str, Any]:
226
+ """Validate and apply sparse review/proposal operations without touching baseline UI."""
227
+ root = _root(repo or None, default_repo)
228
+ ir_path, _ = _ir(root, ui_ir_file or None)
229
+ output = Path(output_file).expanduser().resolve() if output_file else ir_path.with_name("ui-ir.patched.json")
230
+ return apply_patch_file(ir_path, Path(patch_file).expanduser(), output)
231
+
232
+ @server.tool()
233
+ def ui_build_preview(
234
+ repo: str = "",
235
+ ui_ir_file: str = "",
236
+ output_dir: str = "",
237
+ level: str = "quick",
238
+ ) -> dict[str, Any]:
239
+ """Render and projection-check the HTML workbench; never auto-review UX."""
240
+ return build_preview(repo or None, default_repo, ui_ir_file or None, output_dir or None, level)
241
+
242
+ @server.tool()
243
+ def ui_native_status(repo: str = "", platform: str = "all") -> dict[str, Any]:
244
+ """Discover native Android/Apple render providers without executing builds or simulators."""
245
+ if platform not in {"all", "android", "apple"}:
246
+ raise ValueError("platform must be all, android, or apple")
247
+ root = _root(repo or None, default_repo)
248
+ return native_render_status(root, uidw.state_paths(root)["native"], platform)
249
+
250
+ @server.tool()
251
+ def ui_fidelity(identifier: str, repo: str = "", ui_ir_file: str = "") -> dict[str, Any]:
252
+ """Explain one node/evidence id without returning the complete IR."""
253
+ root = _root(repo or None, default_repo)
254
+ _, ir = _ir(root, ui_ir_file or None)
255
+ return uidw.fidelity_command(ir, "explain", identifier, None, "json")
256
+
257
+ return server
258
+
259
+
260
+ def run_server(default_repo: Path, name: str = "UI Design Workbench") -> None:
261
+ create_server(default_repo, name).run()
262
+
263
+
264
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
265
+ parser = argparse.ArgumentParser(description="Local stdio MCP server for UI Design Workbench")
266
+ parser.add_argument("--repo", type=Path, default=Path.cwd())
267
+ parser.add_argument("--name", default="UI Design Workbench")
268
+ return parser.parse_args(argv)
269
+
270
+
271
+ def main() -> int:
272
+ args = parse_args()
273
+ try:
274
+ run_server(args.repo.resolve(), args.name)
275
+ except (RuntimeError, ValueError, OSError) as exc:
276
+ print(f"uidw mcp: {exc}", file=sys.stderr)
277
+ return 3
278
+ return 0
279
+
280
+
281
+ if __name__ == "__main__":
282
+ raise SystemExit(main())
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env python3
2
+ """Validate platform-profile selection and node standard references in UI IR."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from quality_common import framework_adapters, node_screen_map, platform_family, profile_catalog, read_json, target_families, write_json
12
+
13
+
14
+ def validate_profiles(ir: dict[str, Any]) -> dict[str, Any]:
15
+ catalog = profile_catalog()
16
+ profiles = catalog.get("profiles", {})
17
+ adapters = catalog.get("frameworkAdapters", {})
18
+ families = target_families(ir)
19
+ selected = ir.get("design", {}).get("standardProfiles", {})
20
+ issues: list[dict[str, Any]] = []
21
+ resolved: dict[str, Any] = {}
22
+
23
+ if not families:
24
+ supported = ", ".join(sorted({str(item.get("family")) for item in profiles.values() if isinstance(item, dict) and item.get("family")}))
25
+ issues.append({"code": "target-platform-missing", "severity": "error", "message": f"design.targetPlatforms must resolve to a supported family: {supported}."})
26
+
27
+ for adapter_id in sorted(framework_adapters(ir)):
28
+ adapter = adapters[adapter_id]
29
+ if adapter.get("requiresExplicitTarget") and not ir.get("design", {}).get("targetPlatforms"):
30
+ issues.append({"code": "framework-target-ambiguous", "severity": "error", "adapter": adapter_id, "message": f"{adapter_id} requires explicit design.targetPlatforms."})
31
+
32
+ for family in sorted(families):
33
+ selection = selected.get(family)
34
+ if not isinstance(selection, dict):
35
+ issues.append({"code": "profile-missing", "severity": "error", "family": family, "message": f"Missing design.standardProfiles.{family}."})
36
+ continue
37
+ profile_id = selection.get("id")
38
+ if profile_id == "project":
39
+ if not selection.get("reason"):
40
+ issues.append({"code": "project-profile-unreasoned", "severity": "error", "family": family, "message": "A project profile needs a reason and evidence."})
41
+ else:
42
+ resolved[family] = {**selection, "family": family, "standardRefPrefixes": ["project."]}
43
+ continue
44
+ profile = profiles.get(profile_id)
45
+ if not profile or profile.get("family") != family:
46
+ issues.append({"code": "profile-invalid", "severity": "error", "family": family, "profile": profile_id, "message": f"Profile {profile_id!r} does not belong to {family}."})
47
+ continue
48
+ resolved[family] = {**profile, "id": profile_id, "source": selection.get("source")}
49
+
50
+ decisions = {item.get("id") for item in ir.get("design", {}).get("decisions", []) if isinstance(item, dict)}
51
+ screen_families = {str(screen.get("id")): platform_family(screen.get("platform")) for screen in ir.get("screens", [])}
52
+ node_screens = node_screen_map(ir)
53
+ for node_id, node in ir.get("nodes", {}).items():
54
+ if node.get("type") == "spacer" or node.get("decisionId") in decisions:
55
+ continue
56
+ shared = str(node.get("standardRef", ""))
57
+ per_platform = node.get("standardRefs", {}) if isinstance(node.get("standardRefs"), dict) else {}
58
+ node_families = {screen_families.get(screen_id) for screen_id in node_screens.get(str(node_id), set())}
59
+ node_families.discard(None)
60
+ node_families = node_families or families
61
+ for family in sorted(node_families):
62
+ profile = resolved.get(family)
63
+ if not profile:
64
+ continue
65
+ ref = str(per_platform.get(family, shared))
66
+ prefixes = tuple(profile.get("standardRefPrefixes", []))
67
+ if not ref.startswith(prefixes):
68
+ issues.append({"code": "standard-ref-mismatch", "severity": "error", "family": family, "node": node_id, "ref": ref or None, "allowedPrefixes": list(prefixes), "message": f"Node {node_id} lacks a {family} standard reference or decision."})
69
+
70
+ return {
71
+ "version": 1,
72
+ "status": "blocked" if any(item["severity"] == "error" for item in issues) else "pass",
73
+ "targetFamilies": sorted(families),
74
+ "frameworkAdapters": sorted(framework_adapters(ir)),
75
+ "resolvedProfiles": resolved,
76
+ "issues": issues,
77
+ }
78
+
79
+
80
+ def main() -> int:
81
+ parser = argparse.ArgumentParser(description=__doc__)
82
+ parser.add_argument("ir", help="Path to ui-ir.json")
83
+ parser.add_argument("--output", required=True, help="Path for platform-profile-report.json")
84
+ parser.add_argument("--strict", action="store_true", help="Exit non-zero when the report is blocked")
85
+ args = parser.parse_args()
86
+ report = validate_profiles(read_json(args.ir))
87
+ write_json(args.output, report)
88
+ print(Path(args.output).resolve())
89
+ return 2 if args.strict and report["status"] != "pass" else 0
90
+
91
+
92
+ if __name__ == "__main__":
93
+ sys.exit(main())
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env python3
2
+ """Compare preview screenshots and optional geometry snapshots with explicit tolerances."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import math
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ try:
13
+ from PIL import Image
14
+ except ImportError: # pragma: no cover - depends on the selected workspace runtime
15
+ Image = None
16
+
17
+ from quality_common import read_json, write_json
18
+
19
+
20
+ def compare_images(baseline_path: str, candidate_path: str, channel_tolerance: int, diff_path: str | None) -> dict[str, Any]:
21
+ baseline = Image.open(baseline_path).convert("RGBA")
22
+ candidate = Image.open(candidate_path).convert("RGBA")
23
+ if baseline.size != candidate.size:
24
+ return {"status": "fail", "baselineSize": list(baseline.size), "candidateSize": list(candidate.size), "reason": "image-size-mismatch"}
25
+
26
+ total = baseline.width * baseline.height
27
+ changed = 0
28
+ absolute_sum = 0
29
+ maximum = 0
30
+ bounds = [baseline.width, baseline.height, -1, -1]
31
+ overlay = Image.new("RGBA", baseline.size, (0, 0, 0, 0)) if diff_path else None
32
+ overlay_pixels = overlay.load() if overlay else None
33
+
34
+ baseline_data = baseline.get_flattened_data() if hasattr(baseline, "get_flattened_data") else baseline.getdata()
35
+ candidate_data = candidate.get_flattened_data() if hasattr(candidate, "get_flattened_data") else candidate.getdata()
36
+ for index, (before, after) in enumerate(zip(baseline_data, candidate_data)):
37
+ differences = tuple(abs(int(before[channel]) - int(after[channel])) for channel in range(4))
38
+ pixel_max = max(differences)
39
+ absolute_sum += sum(differences)
40
+ maximum = max(maximum, pixel_max)
41
+ if pixel_max <= channel_tolerance:
42
+ continue
43
+ changed += 1
44
+ x = index % baseline.width
45
+ y = index // baseline.width
46
+ bounds[0] = min(bounds[0], x)
47
+ bounds[1] = min(bounds[1], y)
48
+ bounds[2] = max(bounds[2], x)
49
+ bounds[3] = max(bounds[3], y)
50
+ if overlay_pixels is not None:
51
+ overlay_pixels[x, y] = (239, 68, 68, 220)
52
+
53
+ if overlay and diff_path:
54
+ target = Path(diff_path)
55
+ target.parent.mkdir(parents=True, exist_ok=True)
56
+ highlighted = candidate.copy()
57
+ highlighted.alpha_composite(overlay)
58
+ highlighted.save(target)
59
+
60
+ return {
61
+ "status": "pass",
62
+ "baselineSize": list(baseline.size),
63
+ "candidateSize": list(candidate.size),
64
+ "channelTolerance": channel_tolerance,
65
+ "changedPixels": changed,
66
+ "changedRatio": round(changed / total, 8) if total else 0,
67
+ "meanAbsoluteError": round(absolute_sum / (total * 4), 5) if total else 0,
68
+ "maximumChannelError": maximum,
69
+ "changedBounds": None if changed == 0 else bounds,
70
+ }
71
+
72
+
73
+ def compare_geometry(baseline_path: str, candidate_path: str, tolerance: float) -> dict[str, Any]:
74
+ baseline = read_json(baseline_path)
75
+ candidate = read_json(candidate_path)
76
+ before = baseline.get("elements", {})
77
+ after = candidate.get("elements", {})
78
+ missing = sorted(set(before) - set(after))
79
+ unexpected = sorted(set(after) - set(before))
80
+ changes: list[dict[str, Any]] = []
81
+ for key in sorted(set(before) & set(after)):
82
+ first, second = before[key], after[key]
83
+ if first is None or second is None:
84
+ if first != second:
85
+ changes.append({"element": key, "field": "presence", "before": first, "after": second})
86
+ continue
87
+ for field in ("x", "y", "width", "height"):
88
+ a, b = first.get(field), second.get(field)
89
+ if isinstance(a, (int, float)) and isinstance(b, (int, float)) and math.fabs(a - b) > tolerance:
90
+ changes.append({"element": key, "field": field, "before": a, "after": b, "delta": round(b - a, 3)})
91
+ for field in ("visible", "overflowX", "overflowY"):
92
+ if first.get(field) != second.get(field):
93
+ changes.append({"element": key, "field": field, "before": first.get(field), "after": second.get(field)})
94
+ overlaps = candidate.get("overlaps", [])
95
+ return {
96
+ "status": "fail" if missing or unexpected or changes or overlaps else "pass",
97
+ "toleranceCssPx": tolerance,
98
+ "missingElements": missing,
99
+ "unexpectedElements": unexpected,
100
+ "changes": changes,
101
+ "candidateOverlaps": overlaps,
102
+ }
103
+
104
+
105
+ def main() -> int:
106
+ parser = argparse.ArgumentParser(description=__doc__)
107
+ parser.add_argument("--baseline", required=True, help="Approved PNG screenshot")
108
+ parser.add_argument("--candidate", required=True, help="Candidate PNG screenshot")
109
+ parser.add_argument("--output", required=True, help="Path for visual-regression.json")
110
+ parser.add_argument("--diff-image", help="Optional highlighted PNG output")
111
+ parser.add_argument("--baseline-geometry", help="Approved geometry JSON from smoke_preview.js")
112
+ parser.add_argument("--candidate-geometry", help="Candidate geometry JSON from smoke_preview.js")
113
+ parser.add_argument("--channel-tolerance", type=int, default=12)
114
+ parser.add_argument("--max-changed-ratio", type=float, default=.005)
115
+ parser.add_argument("--max-mean-error", type=float, default=1.0)
116
+ parser.add_argument("--geometry-tolerance", type=float, default=1.0)
117
+ parser.add_argument("--strict", action="store_true", help="Exit non-zero when thresholds fail")
118
+ args = parser.parse_args()
119
+ if Image is None:
120
+ parser.error("Pillow is required for pixel comparison; use a workspace Python runtime that provides PIL.")
121
+ if bool(args.baseline_geometry) != bool(args.candidate_geometry):
122
+ parser.error("Provide both --baseline-geometry and --candidate-geometry")
123
+
124
+ image_report = compare_images(args.baseline, args.candidate, max(0, args.channel_tolerance), args.diff_image)
125
+ image_pass = image_report.get("status") == "pass" and image_report.get("changedRatio", 1) <= args.max_changed_ratio and image_report.get("meanAbsoluteError", float("inf")) <= args.max_mean_error
126
+ geometry_report = compare_geometry(args.baseline_geometry, args.candidate_geometry, max(0, args.geometry_tolerance)) if args.baseline_geometry else None
127
+ geometry_pass = geometry_report is None or geometry_report.get("status") == "pass"
128
+ report = {
129
+ "version": 1,
130
+ "status": "pass" if image_pass and geometry_pass else "fail",
131
+ "thresholds": {"maxChangedRatio": args.max_changed_ratio, "maxMeanAbsoluteError": args.max_mean_error, "geometryToleranceCssPx": args.geometry_tolerance},
132
+ "image": image_report,
133
+ "geometry": geometry_report,
134
+ }
135
+ write_json(args.output, report)
136
+ print(f"Visual regression: {report['status']} | changed={image_report.get('changedRatio', 1):.4%}")
137
+ return 2 if args.strict and report["status"] != "pass" else 0
138
+
139
+
140
+ if __name__ == "__main__":
141
+ sys.exit(main())
@@ -0,0 +1,133 @@
1
+ Metadata-Version: 2.4
2
+ Name: ui-design-workbench-cli
3
+ Version: 0.5.0
4
+ Summary: Repository UI indexing, graph, preview, and deterministic review CLI
5
+ Author: Elgreed
6
+ Project-URL: Homepage, https://github.com/Elgreed/ui-design-workbench
7
+ Project-URL: Repository, https://github.com/Elgreed/ui-design-workbench
8
+ Project-URL: Changelog, https://github.com/Elgreed/ui-design-workbench/blob/main/CHANGELOG.md
9
+ Keywords: agent-skills,ui,ux,design-review,code-analysis
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Software Development :: Quality Assurance
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ Provides-Extra: mcp
18
+ Requires-Dist: mcp<3,>=2; extra == "mcp"
19
+
20
+ # UI Design Workbench
21
+
22
+ [Русская версия](README.ru.md) · [Changelog](CHANGELOG.md) · [Release guide](RELEASING.md)
23
+
24
+ Turn repository UI source into an offline interactive HTML workbench—without building or running the application.
25
+
26
+ ```text
27
+ UI source → cached UI map → strict UI IR → interactive HTML
28
+ ```
29
+
30
+ Use it to understand unfamiliar UI, inspect screens and navigation, verify a reconstruction, or run an explicit UI/UX review.
31
+
32
+ ## Install
33
+
34
+ Requires Python 3.10+ and [`pipx`](https://pipx.pypa.io/).
35
+
36
+ ```sh
37
+ pipx install ui-design-workbench-cli
38
+ uidw install-skill codex
39
+ ```
40
+
41
+ `install-skill` also supports `claude`, `cursor`, `gemini`, `copilot`, `opencode`, `agents`, and `all`.
42
+
43
+ Optional local MCP support:
44
+
45
+ ```sh
46
+ pipx inject ui-design-workbench-cli "mcp>=2,<3"
47
+ ```
48
+
49
+ Verify the installation:
50
+
51
+ ```sh
52
+ uidw --version
53
+ uidw doctor
54
+ ```
55
+
56
+ ## Quick start
57
+
58
+ Choose preview detail once:
59
+
60
+ ```sh
61
+ uidw --repo <repo> config setup
62
+ ```
63
+
64
+ Build and open the source-derived workbench:
65
+
66
+ ```sh
67
+ uidw --repo <repo> workbench --output-dir <artifacts> --level full --open
68
+ ```
69
+
70
+ Run a UI/UX audit only when product critique is wanted:
71
+
72
+ ```sh
73
+ uidw --repo <repo> review --output-dir <review-dir> --level full
74
+ ```
75
+
76
+ `workbench` and `check` validate the projection. Only `review` creates UI/UX findings.
77
+
78
+ ## What it provides
79
+
80
+ - Screen, route, component, token, theme, and state inventory.
81
+ - Standalone HTML with source links and reconstructed navigation.
82
+ - Incremental scans that reprocess only changed UI files.
83
+ - Property-level evidence and explicit unsupported gaps instead of guessed UI.
84
+ - Sparse review patches and a separate, authorized source-apply step.
85
+ - Structural Android and Apple resource projection, plus discovery of available native capture paths.
86
+
87
+ Supported source families include Web, React, Vue, Svelte, Jetpack Compose, Android Views XML, SwiftUI, Storyboard/XIB, WinUI/WPF, and Flutter.
88
+
89
+ ## Main commands
90
+
91
+ | Command | Purpose |
92
+ | --- | --- |
93
+ | `uidw doctor` | Check installation and optional dependencies |
94
+ | `uidw --repo <repo> context --json` | Read compact cached project context |
95
+ | `uidw --repo <repo> scope ...` | Prepare bounded context for a screen or finding |
96
+ | `uidw --repo <repo> patch ...` | Validate or apply sparse review-artifact changes |
97
+ | `uidw --repo <repo> workbench ...` | Build and validate the HTML projection |
98
+ | `uidw --repo <repo> native status` | Discover native Android/Apple render providers without running them |
99
+ | `uidw --repo <repo> check ...` | Repeat projection checks without a UI/UX audit |
100
+ | `uidw --repo <repo> review ...` | Start an explicit UI/UX review |
101
+ | `uidw --repo <repo> fidelity ...` | Inspect source evidence and adapter limits |
102
+ | `uidw --repo <repo> mcp` | Start the optional local stdio MCP server |
103
+
104
+ Run `uidw help overview`, `uidw help advanced`, or `uidw <command> --help` for details.
105
+
106
+ ## Accuracy and safety
107
+
108
+ - HTML is a static source projection, not proof of runtime or pixel parity.
109
+ - Android and Apple translation remains structural until a source-matched native capture exists.
110
+ - Unsupported bindings, custom drawing, runtime data, and platform behavior remain explicit gaps.
111
+ - Preview and review keep application source read-only; applying a proposal is a separate step.
112
+ - Derived cache and review state live outside the target repository by default.
113
+
114
+ ## Upgrade
115
+
116
+ ```sh
117
+ pipx upgrade ui-design-workbench-cli
118
+ uidw install-skill codex
119
+ ```
120
+
121
+ A Windows `.exe` is not published yet. PyPI + `pipx` is the primary cross-platform installation path.
122
+
123
+ ## Documentation
124
+
125
+ - [Changelog](CHANGELOG.md)
126
+ - [Release and distribution guide](RELEASING.md)
127
+ - [Agent integrations](references/agent-integrations.md)
128
+ - [Fidelity contract](references/fidelity.md)
129
+ - [Native rendering](references/native-rendering.md)
130
+ - [IR schema](references/ir-schema.md)
131
+ - [Review workflow](references/review-workflow.md)
132
+
133
+ Current CLI version: `0.5.0`.