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,141 @@
1
+ #!/usr/bin/env python3
2
+ """Read-only Apple asset, color, localization, and symbol resolution."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import base64
7
+ import json
8
+ import os
9
+ import re
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+
15
+ IGNORED_DIRS = {".git", ".build", ".swiftpm", ".ui-design-workbench", "build", "DerivedData", "Pods", "vendor"}
16
+ FILE_LIMIT = 30000
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class AppleResource:
21
+ value: str
22
+ source: str
23
+ confidence: str = "high"
24
+
25
+
26
+ def _component(value: Any) -> int:
27
+ raw = str(value or "0").strip()
28
+ try:
29
+ number = int(raw, 16) / 255 if raw.lower().startswith("0x") else float(raw)
30
+ except ValueError:
31
+ number = 0
32
+ return max(0, min(255, round(number * 255)))
33
+
34
+
35
+ def _color_value(payload: dict[str, Any]) -> str | None:
36
+ components = payload.get("components", {}) if isinstance(payload, dict) else {}
37
+ if not isinstance(components, dict):
38
+ return None
39
+ red, green, blue = (_component(components.get(key)) for key in ("red", "green", "blue"))
40
+ alpha = _component(components.get("alpha", 1))
41
+ return f"#{red:02x}{green:02x}{blue:02x}" if alpha == 255 else f"#{red:02x}{green:02x}{blue:02x}{alpha:02x}"
42
+
43
+
44
+ class AppleResourceCatalog:
45
+ def __init__(self, root: Path):
46
+ self.root = root.resolve()
47
+ self.images: dict[str, AppleResource] = {}
48
+ self.colors: dict[str, AppleResource] = {}
49
+ self.strings: dict[str, AppleResource] = {}
50
+ self.diagnostics: list[dict[str, Any]] = []
51
+
52
+ @classmethod
53
+ def discover(cls, root: Path) -> "AppleResourceCatalog":
54
+ catalog = cls(root)
55
+ seen = 0
56
+ for directory, names, files in os.walk(catalog.root):
57
+ names[:] = [name for name in names if name not in IGNORED_DIRS and not name.startswith(".")]
58
+ folder = Path(directory)
59
+ for filename in files:
60
+ seen += 1
61
+ if seen > FILE_LIMIT:
62
+ catalog.diagnostics.append({"reason": "apple-resource-file-limit", "limit": FILE_LIMIT})
63
+ return catalog
64
+ path = folder / filename
65
+ if filename == "Contents.json" and folder.suffix.lower() in {".imageset", ".colorset"}:
66
+ catalog._read_asset(path)
67
+ elif path.suffix.lower() == ".strings" and folder.name.lower().endswith(".lproj"):
68
+ catalog._read_strings(path)
69
+ return catalog
70
+
71
+ def _relative(self, path: Path) -> str:
72
+ try:
73
+ return path.resolve().relative_to(self.root).as_posix()
74
+ except ValueError:
75
+ return path.name
76
+
77
+ def _read_asset(self, path: Path) -> None:
78
+ try:
79
+ payload = json.loads(path.read_text(encoding="utf-8"))
80
+ except (OSError, json.JSONDecodeError):
81
+ self.diagnostics.append({"reason": "invalid-apple-asset-catalog", "file": self._relative(path)})
82
+ return
83
+ name = path.parent.stem
84
+ if path.parent.suffix.lower() == ".imageset":
85
+ candidates = [item for item in payload.get("images", []) if isinstance(item, dict) and item.get("filename")]
86
+ candidates.sort(key=lambda item: ({"1x": 0, "2x": 1, "3x": 2}.get(str(item.get("scale")), -1), item.get("idiom") == "universal"), reverse=True)
87
+ if candidates:
88
+ image = path.parent / str(candidates[0]["filename"])
89
+ if image.is_file():
90
+ self.images[name] = AppleResource(self._relative(image), self._relative(path))
91
+ else:
92
+ candidates = [item for item in payload.get("colors", []) if isinstance(item, dict) and isinstance(item.get("color"), dict)]
93
+ universal = next((item for item in candidates if item.get("idiom") == "universal"), candidates[0] if candidates else None)
94
+ value = _color_value(universal["color"]) if universal else None
95
+ if value:
96
+ self.colors[name] = AppleResource(value, self._relative(path))
97
+
98
+ def _read_strings(self, path: Path) -> None:
99
+ try:
100
+ text = path.read_text(encoding="utf-8", errors="replace")
101
+ except OSError:
102
+ return
103
+ source = self._relative(path)
104
+ for match in re.finditer(r'"((?:\\.|[^"\\])*)"\s*=\s*"((?:\\.|[^"\\])*)"\s*;', text):
105
+ try:
106
+ key = json.loads(f'"{match.group(1)}"')
107
+ value = json.loads(f'"{match.group(2)}"')
108
+ except json.JSONDecodeError:
109
+ continue
110
+ self.strings.setdefault(key, AppleResource(value, source))
111
+
112
+ def image(self, name: str) -> AppleResource | None:
113
+ return self.images.get(str(name or "").strip())
114
+
115
+ def color(self, name: str) -> AppleResource | None:
116
+ return self.colors.get(str(name or "").strip())
117
+
118
+ def localized(self, key: str) -> AppleResource | None:
119
+ return self.strings.get(str(key or "").strip())
120
+
121
+
122
+ SF_SYMBOL_PATHS = {
123
+ "plus": "M12 5v14M5 12h14",
124
+ "xmark": "M6 6l12 12M18 6L6 18",
125
+ "checkmark": "M5 12l4 4L19 6",
126
+ "magnifyingglass": "M10.5 4a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13M15.5 15.5L21 21",
127
+ "house": "M3 11.5L12 4l9 7.5V21h-6v-6H9v6H3z",
128
+ "gearshape": "M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M19 5l-2 2M7 17l-2 2",
129
+ "trash": "M5 7h14M9 7V4h6v3M7 7l1 14h8l1-14M10 10v7M14 10v7",
130
+ "pencil": "M4 20l4-1 11-11-3-3L5 16zM14 7l3 3",
131
+ }
132
+
133
+
134
+ def sf_symbol_asset(name: str) -> str | None:
135
+ key = str(name or "").split(".", 1)[0].lower()
136
+ path = SF_SYMBOL_PATHS.get(key)
137
+ if not path:
138
+ return None
139
+ fill = ' fill="currentColor"' if key == "house" else ' fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"'
140
+ svg = f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="{path}"{fill}/></svg>'
141
+ return "data:image/svg+xml;base64," + base64.b64encode(svg.encode("utf-8")).decode("ascii")
coverage_report.py ADDED
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env python3
2
+ """Build a deterministic UI translation coverage report from ui-ir.json."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import sys
8
+ from collections import Counter
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from quality_common import hierarchy_issues, navigation_graph_issues, read_json, walk_tree_screen_ids, write_json
13
+ from render_preview import fidelity_audit
14
+ from validate_platform_profiles import validate_profiles
15
+
16
+
17
+ def gate(gate_id: str, label: str, passed: bool, actual: Any, expected: Any, details: Any = None) -> dict[str, Any]:
18
+ value = {"id": gate_id, "label": label, "status": "pass" if passed else "fail", "actual": actual, "expected": expected}
19
+ if details:
20
+ value["details"] = details
21
+ return value
22
+
23
+
24
+ def build_report(ir: dict[str, Any], purpose: str = "projection") -> dict[str, Any]:
25
+ if purpose not in {"projection", "review"}:
26
+ raise ValueError(f"Unknown coverage purpose: {purpose}")
27
+ audit = fidelity_audit(ir)
28
+ profiles = validate_profiles(ir)
29
+ screen_ids = [str(item.get("id")) for item in ir.get("screens", []) if item.get("id")]
30
+ tree_ids = walk_tree_screen_ids(ir.get("screenTree", []))
31
+ tree_counts = Counter(tree_ids)
32
+ missing_tree = sorted(set(screen_ids) - set(tree_ids))
33
+ duplicate_tree = sorted(item for item, count in tree_counts.items() if count > 1)
34
+ unknown_tree = sorted(set(tree_ids) - set(screen_ids))
35
+ hierarchy = hierarchy_issues(ir)
36
+ graph_issues = navigation_graph_issues(ir)
37
+ mode = ir.get("design", {}).get("mode", "reconstruct")
38
+ needs_design_evidence = purpose == "review" and mode in {"generate", "redesign"}
39
+ needs_source = mode == "reconstruct"
40
+ visual_verification = ir.get("visualVerification", {}) if isinstance(ir.get("visualVerification"), dict) else {}
41
+ visual_status = str(visual_verification.get("status") or "unverified")
42
+
43
+ gates = [
44
+ gate("fidelity", "Renderer fidelity audit", audit.get("status") == "reviewable", audit.get("status"), "reviewable", audit.get("reasons")),
45
+ gate("screen-tree", "Every screen appears once in screenTree", not missing_tree and not duplicate_tree and not unknown_tree, len(tree_ids), len(screen_ids), {"missing": missing_tree, "duplicates": duplicate_tree, "unknown": unknown_tree}),
46
+ gate("screen-hierarchy", "screenTree preserves source navigation hierarchy", not hierarchy, len(hierarchy), 0, hierarchy),
47
+ gate("navigation-graph", "Source navigation graph covers nested logical views", not graph_issues, len(graph_issues), 0, graph_issues),
48
+ gate("screens", "Translated screen coverage", audit.get("screenCoverage", 0) == 1, audit.get("screenCoverage", 0), 1, audit.get("missingScreens")),
49
+ gate("screen-accountability", "Discovered screens translated or explicitly excluded", audit.get("screenAccountabilityCoverage", 0) == 1, audit.get("screenAccountabilityCoverage", 0), 1, audit.get("missingScreens")),
50
+ gate("routes", "Discovered route coverage", audit.get("routeCoverage", 0) == 1, audit.get("routeCoverage", 0), 1, audit.get("missingRoutes")),
51
+ gate("navigation", "Discovered navigation coverage", audit.get("navigationCoverage", 0) == 1, audit.get("navigationCoverage", 0), 1, audit.get("missingNavigationTargets")),
52
+ gate("components", "Mapped component coverage", audit.get("componentCoverage", 0) == 1, audit.get("componentCoverage", 0), 1),
53
+ gate("appearance", "Explicit appearance coverage", audit.get("appearanceCoverage", 0) >= .9, audit.get("appearanceCoverage", 0), .9),
54
+ gate("profiles", "Platform profiles", profiles.get("status") == "pass", profiles.get("status"), "pass", profiles.get("issues")),
55
+ ]
56
+ if needs_source:
57
+ gates.append(gate("source", "Source mapping coverage", audit.get("sourceCoverage", 0) >= .8, audit.get("sourceCoverage", 0), .8))
58
+ if needs_design_evidence:
59
+ gates.extend([
60
+ gate("evidence", "Design evidence coverage", audit.get("evidenceCoverage", 0) >= .9, audit.get("evidenceCoverage", 0), .9),
61
+ gate("semantics", "Interactive semantics coverage", audit.get("semanticCoverage", 0) == 1, audit.get("semanticCoverage", 0), 1),
62
+ gate("standards", "Platform-standard coverage", audit.get("standardCoverage", 0) == 1, audit.get("standardCoverage", 0), 1),
63
+ gate("targets", "Interactive target coverage", audit.get("targetCoverage", 0) == 1, audit.get("targetCoverage", 0), 1, audit.get("targetFailures")),
64
+ gate("contrast", "Resolvable contrast coverage", audit.get("contrastCoverage", 0) == 1 and not audit.get("contrastFailures"), audit.get("contrastCoverage", 0), 1, {"failures": audit.get("contrastFailures"), "unresolved": audit.get("contrastUnresolved")}),
65
+ gate("states", "Declared scenario coverage", audit.get("stateCoverage", 0) == 1, audit.get("stateCoverage", 0), 1, audit.get("missingStates")),
66
+ ])
67
+
68
+ failed = [item for item in gates if item["status"] == "fail"]
69
+ return {
70
+ "version": 1,
71
+ "status": "blocked" if failed else "pass",
72
+ "project": ir.get("project", {}).get("name", "Project"),
73
+ "designMode": mode,
74
+ "purpose": purpose,
75
+ "verification": {
76
+ "sourceProjection": "pass" if audit.get("status") == "reviewable" else "blocked",
77
+ "visualParity": visual_status,
78
+ "reason": visual_verification.get("reason") or (None if visual_status != "unverified" else "No Layoutlib, emulator screenshot, or golden-image comparison was supplied."),
79
+ },
80
+ "summary": {"passed": len(gates) - len(failed), "failed": len(failed), "total": len(gates)},
81
+ "inventory": {
82
+ "screens": len(screen_ids),
83
+ "treeEntries": len(tree_ids),
84
+ "nodes": len(ir.get("nodes", {})),
85
+ "components": len(ir.get("componentCatalog", {}).get("components", [])),
86
+ "actions": audit.get("interactionActions", 0),
87
+ "navigationActions": audit.get("navigationActions", 0),
88
+ },
89
+ "coverage": {key: value for key, value in audit.items() if key.endswith("Coverage")},
90
+ "gates": gates,
91
+ "platformProfiles": profiles,
92
+ "fidelityAudit": audit,
93
+ }
94
+
95
+
96
+ def markdown(report: dict[str, Any]) -> str:
97
+ lines = [
98
+ f"# UI coverage · {report['project']}",
99
+ "",
100
+ f"Status: **{report['status']}** · passed {report['summary']['passed']} / {report['summary']['total']}",
101
+ "",
102
+ "| Gate | Status | Actual | Expected |",
103
+ "| --- | --- | ---: | ---: |",
104
+ ]
105
+ for item in report["gates"]:
106
+ lines.append(f"| {item['label']} | {item['status']} | {item['actual']} | {item['expected']} |")
107
+ failed = [item for item in report["gates"] if item["status"] == "fail"]
108
+ if failed:
109
+ lines.extend(["", "## Blocking details", ""])
110
+ for item in failed:
111
+ lines.append(f"- **{item['label']}**: {item.get('details') or 'threshold not met'}")
112
+ return "\n".join(lines) + "\n"
113
+
114
+
115
+ def main() -> int:
116
+ parser = argparse.ArgumentParser(description=__doc__)
117
+ parser.add_argument("ir", help="Path to ui-ir.json")
118
+ parser.add_argument("--output", required=True, help="Path for ui-coverage.json")
119
+ parser.add_argument("--markdown", help="Optional Markdown summary path")
120
+ parser.add_argument("--mode", choices=("projection", "review"), default="projection", help="Projection checks transfer only; review also evaluates design-quality coverage")
121
+ parser.add_argument("--strict", action="store_true", help="Exit non-zero when a gate fails")
122
+ args = parser.parse_args()
123
+ report = build_report(read_json(args.ir), args.mode)
124
+ write_json(args.output, report)
125
+ if args.markdown:
126
+ target = Path(args.markdown)
127
+ target.parent.mkdir(parents=True, exist_ok=True)
128
+ target.write_text(markdown(report), encoding="utf-8")
129
+ print(f"Coverage: {report['status']} | {report['summary']['passed']}/{report['summary']['total']} gates")
130
+ return 2 if args.strict and report["status"] != "pass" else 0
131
+
132
+
133
+ if __name__ == "__main__":
134
+ sys.exit(main())
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env python3
2
+ """Stable provider-neutral API for deterministic source-to-IR adapters."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any, Protocol
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class SourceContext:
13
+ root: Path
14
+ path: Path
15
+ source: str
16
+ text: str
17
+ platforms: tuple[str, ...]
18
+ role: str
19
+
20
+
21
+ @dataclass
22
+ class AdapterResult:
23
+ adapter: str
24
+ screens: list[dict[str, Any]] = field(default_factory=list)
25
+ nodes: dict[str, dict[str, Any]] = field(default_factory=dict)
26
+ tokens: dict[str, dict[str, Any]] = field(default_factory=lambda: {"colors": {}, "spacing": {}, "radii": {}, "typography": {}})
27
+ themes: list[dict[str, Any]] = field(default_factory=list)
28
+ components: list[dict[str, Any]] = field(default_factory=list)
29
+ unsupported: list[dict[str, Any]] = field(default_factory=list)
30
+
31
+
32
+ class UiSourceAdapter(Protocol):
33
+ id: str
34
+
35
+ def supports(self, context: SourceContext) -> bool: ...
36
+ def translate(self, context: SourceContext) -> AdapterResult: ...
37
+
38
+
39
+ _ADAPTERS: list[UiSourceAdapter] = []
40
+
41
+
42
+ def register_adapter(adapter: UiSourceAdapter) -> None:
43
+ if any(current.id == adapter.id for current in _ADAPTERS):
44
+ raise ValueError(f"Adapter already registered: {adapter.id}")
45
+ _ADAPTERS.append(adapter)
46
+
47
+
48
+ def registered_adapters() -> tuple[UiSourceAdapter, ...]:
49
+ return tuple(_ADAPTERS)
50
+
51
+
52
+ def adapter_capabilities() -> list[dict[str, Any]]:
53
+ return [
54
+ {
55
+ "id": adapter.id,
56
+ "platforms": list(getattr(adapter, "platforms", ())),
57
+ "extensions": list(getattr(adapter, "extensions", ())),
58
+ "maturity": str(getattr(adapter, "maturity", "conservative")),
59
+ "structuralTier": str(getattr(adapter, "structural_tier", "translated")),
60
+ "visualTier": str(getattr(adapter, "visual_tier", "none")),
61
+ "nativeEvidenceRequired": bool(getattr(adapter, "native_evidence_required", False)),
62
+ "nativeProviders": list(getattr(adapter, "native_providers", ())),
63
+ "resourceResolution": list(getattr(adapter, "resource_resolution", ())),
64
+ "layoutFeatures": list(getattr(adapter, "layout_features", ())),
65
+ "limitations": list(getattr(adapter, "limitations", ())),
66
+ }
67
+ for adapter in _ADAPTERS
68
+ ]
69
+
70
+
71
+ def translate_sources(contexts: list[SourceContext]) -> AdapterResult:
72
+ combined = AdapterResult(adapter="registry")
73
+ for adapter in _ADAPTERS:
74
+ prepare = getattr(adapter, "prepare", None)
75
+ if callable(prepare):
76
+ prepare(contexts)
77
+ for context in contexts:
78
+ adapter = next((candidate for candidate in _ADAPTERS if candidate.supports(context)), None)
79
+ if adapter is None:
80
+ continue
81
+ result = adapter.translate(context)
82
+ combined.screens.extend(result.screens)
83
+ combined.nodes.update(result.nodes)
84
+ for group, values in result.tokens.items():
85
+ combined.tokens.setdefault(group, {}).update(values if isinstance(values, dict) else {})
86
+ combined.themes.extend(result.themes)
87
+ combined.components.extend(result.components)
88
+ combined.unsupported.extend(result.unsupported)
89
+ return combined