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,902 @@
1
+ #!/usr/bin/env python3
2
+ """Built-in platform adapters beyond static Web and Jetpack Compose."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import re
7
+ import xml.etree.ElementTree as ET
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from android_resource_resolver import AndroidResourceCatalog
12
+ from android_xml_support import android_layout_metadata
13
+ from apple_resource_resolver import AppleResourceCatalog, sf_symbol_asset
14
+ from fidelity_adapter_api import AdapterResult, SourceContext
15
+ from fidelity_core import property_evidence
16
+
17
+
18
+ def _slug(value: str, fallback: str = "node") -> str:
19
+ separated = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "-", value)
20
+ return re.sub(r"[^a-z0-9]+", "-", separated.lower()).strip("-") or fallback
21
+
22
+
23
+ def _local(value: str) -> str:
24
+ return value.rsplit("}", 1)[-1].rsplit(":", 1)[-1]
25
+
26
+
27
+ def _attrs(element: ET.Element) -> dict[str, str]:
28
+ return {_local(key): str(value) for key, value in element.attrib.items()}
29
+
30
+
31
+ def _line(text: str, expression: str, start: int = 0) -> int:
32
+ offset = text.find(expression, start)
33
+ return text.count("\n", 0, max(0, offset)) + 1 if offset >= 0 else 1
34
+
35
+
36
+ def _balanced_close(text: str, start: int, opening: str, closing: str) -> int | None:
37
+ depth, quote, escaped = 0, None, False
38
+ for index in range(start, len(text)):
39
+ char = text[index]
40
+ if quote:
41
+ if escaped:
42
+ escaped = False
43
+ elif char == "\\":
44
+ escaped = True
45
+ elif char == quote:
46
+ quote = None
47
+ continue
48
+ if char in {'"', "'"}:
49
+ quote = char
50
+ elif char == opening:
51
+ depth += 1
52
+ elif char == closing:
53
+ depth -= 1
54
+ if depth == 0:
55
+ return index
56
+ return None
57
+
58
+
59
+ def _normalize_ref(value: str, preferred_group: str = "spacing") -> Any:
60
+ value = value.strip()
61
+ if value in {"match_parent", "-1"}: return "fill"
62
+ if value in {"wrap_content", "Auto"}: return "hug"
63
+ numeric = re.fullmatch(r"-?\d+(?:\.\d+)?", value)
64
+ if numeric: return float(value) if "." in value else int(value)
65
+ dimension = re.fullmatch(r"(-?\d+(?:\.\d+)?)(?:dp|sp|pt)", value)
66
+ if dimension: return f"{dimension.group(1)}px"
67
+ match = re.fullmatch(r"@(color|dimen|string)/([\w.-]+)", value)
68
+ if match:
69
+ resource_type, resource_name = match.groups()
70
+ group = "colors" if resource_type == "color" else "strings" if resource_type == "string" else preferred_group
71
+ return f"${group}.{_slug(resource_name).replace('-', '_')}"
72
+ match = re.fullmatch(r"\{(?:StaticResource|ThemeResource)\s+([\w.-]+)\}", value)
73
+ if match:
74
+ return f"${preferred_group}.{_slug(match.group(1)).replace('-', '_')}"
75
+ return value
76
+
77
+
78
+ def _set(node: dict[str, Any], path: str, value: Any, context: SourceContext, line: int, expression: str, adapter: str, confidence: str = "exact") -> None:
79
+ group, key = path.split(".", 1)
80
+ node.setdefault(group, {})[key] = value
81
+ node.setdefault("provenance", {})[path] = property_evidence(context.source, line, expression, adapter, confidence)
82
+
83
+
84
+ def _root_node(context: SourceContext, adapter: str, standard: str, line: int = 1) -> dict[str, Any]:
85
+ return {
86
+ "type": "container", "layout": {"direction": "column", "width": "fill", "height": "fill"}, "style": {}, "children": [],
87
+ "source": {"file": context.source, "line": line}, "confidence": "high", "standardRef": standard,
88
+ "provenance": {
89
+ "layout.direction": property_evidence(context.source, line, "screen root", adapter, "high"),
90
+ "layout.width": property_evidence(context.source, line, "screen bounds", adapter, "high"),
91
+ "layout.height": property_evidence(context.source, line, "screen bounds", adapter, "high"),
92
+ },
93
+ }
94
+
95
+
96
+ class AndroidXmlAdapter:
97
+ id = "android-xml"
98
+ platforms = ("android",)
99
+ extensions = (".xml",)
100
+ maturity = "structural"
101
+ structural_tier = "translated"
102
+ visual_tier = "none"
103
+ native_evidence_required = True
104
+ native_providers = ("paparazzi", "roborazzi", "android-emulator")
105
+ resource_resolution = ("values strings/colors/dimens", "styles", "bitmap/vector/shape drawables", "static layout includes")
106
+ layout_features = ("LinearLayout gravity/weight", "Frame/Constraint overlay", "parent-edge centering", "directional padding/margins", "image scaleType")
107
+ limitations = ("custom views are not expanded", "complex ConstraintLayout equations and runtime resources remain explicit gaps")
108
+ TYPES = {
109
+ "LinearLayout": "container", "ConstraintLayout": "container", "FrameLayout": "container", "RelativeLayout": "container",
110
+ "CoordinatorLayout": "container", "AppBarLayout": "container", "Toolbar": "container", "MaterialToolbar": "container",
111
+ "ScrollView": "container", "NestedScrollView": "container", "HorizontalScrollView": "container", "ViewPager": "container",
112
+ "ViewPager2": "container", "SwipeRefreshLayout": "container", "DrawerLayout": "container", "NavigationView": "container",
113
+ "RecyclerView": "list", "ListView": "list", "GridView": "list", "TabLayout": "container",
114
+ "TextView": "text", "AppCompatTextView": "text", "MaterialTextView": "text", "Button": "button",
115
+ "AppCompatButton": "button", "MaterialButton": "button", "FloatingActionButton": "button", "ExtendedFloatingActionButton": "button",
116
+ "EditText": "input", "AppCompatEditText": "input", "TextInputEditText": "input", "TextInputLayout": "container",
117
+ "CheckBox": "button", "RadioButton": "button", "Switch": "button", "SwitchMaterial": "button", "Chip": "button",
118
+ "MaterialCheckBox": "button", "MaterialSwitch": "button", "CheckedTextView": "button", "RadioGroup": "container",
119
+ "MaterialButtonToggleGroup": "container", "GridLayout": "container", "ShimmerFrameLayout": "container", "SearchView": "input",
120
+ "ImageView": "image", "AppCompatImageView": "image", "ShapeableImageView": "image", "ImageButton": "button",
121
+ "AppCompatImageButton": "button", "CardView": "card", "MaterialCardView": "card", "View": "divider", "Space": "spacer",
122
+ "ProgressBar": "custom", "Guideline": "spacer", "Barrier": "spacer", "Group": "container",
123
+ }
124
+
125
+ def __init__(self) -> None:
126
+ self._catalogs: dict[str, AndroidResourceCatalog] = {}
127
+
128
+ def prepare(self, contexts: list[SourceContext]) -> None:
129
+ roots = {str(context.root.resolve()): context.root for context in contexts if self.supports(context)}
130
+ self._catalogs = {key: AndroidResourceCatalog.discover(root) for key, root in roots.items()}
131
+
132
+ def _catalog(self, context: SourceContext) -> AndroidResourceCatalog:
133
+ key = str(context.root.resolve())
134
+ if key not in self._catalogs:
135
+ self._catalogs[key] = AndroidResourceCatalog.discover(context.root)
136
+ return self._catalogs[key]
137
+
138
+ def supports(self, context: SourceContext) -> bool:
139
+ return context.path.suffix.lower() == ".xml" and any(value.startswith("android-") for value in context.platforms)
140
+
141
+ def translate(self, context: SourceContext) -> AdapterResult:
142
+ result = AdapterResult(adapter=self.id)
143
+ catalog = self._catalog(context)
144
+ try:
145
+ root = ET.fromstring(context.text)
146
+ except ET.ParseError as exc:
147
+ result.unsupported.append({"adapter": self.id, "file": context.source, "line": getattr(exc, "position", (1,))[0], "expression": str(exc), "reason": "invalid-xml"})
148
+ return result
149
+ normalized_source = "/" + context.source.lower().replace("\\", "/")
150
+ if "/res/color" in normalized_source:
151
+ items = [child for child in root if _local(child.tag) == "item"] if _local(root.tag) == "selector" else []
152
+ default_item = next((child for child in reversed(items) if not any(key.startswith("state_") for key in _attrs(child))), None)
153
+ selected = default_item if default_item is not None else items[-1] if items else root
154
+ value = _attrs(selected).get("color") or (selected.text or "").strip()
155
+ if value:
156
+ result.tokens["colors"][_slug(context.path.stem).replace("-", "_")] = {
157
+ "value": _normalize_ref(value, "colors"),
158
+ "source": {"file": context.source, "line": _line(context.text, value)},
159
+ "adapter": self.id,
160
+ }
161
+ if len(items) > 1:
162
+ result.unsupported.append({"adapter": self.id, "file": context.source, "line": 1, "expression": context.path.name, "reason": "stateful-color-selector-uses-default-state"})
163
+ return result
164
+ if _local(root.tag) == "resources":
165
+ for child in root:
166
+ tag, attrs, value = _local(child.tag), _attrs(child), (child.text or "").strip()
167
+ name = attrs.get("name") or attrs.get("Key")
168
+ if not name:
169
+ continue
170
+ line = _line(context.text, name)
171
+ if tag == "style":
172
+ result.unsupported.append({"adapter": self.id, "file": context.source, "line": line, "expression": name, "reason": "unresolved-android-style"})
173
+ continue
174
+ if not value:
175
+ continue
176
+ if tag in {"color", "Color", "SolidColorBrush"}:
177
+ groups = ("colors",)
178
+ elif tag == "dimen":
179
+ groups = ("spacing", "typography") if value.endswith("sp") else ("spacing",)
180
+ elif tag in {"string", "plurals", "string-array"}:
181
+ groups = ("strings",)
182
+ elif tag in {"bool", "integer", "fraction"}:
183
+ groups = ("values",)
184
+ else:
185
+ continue
186
+ for group in groups:
187
+ result.tokens.setdefault(group, {})[_slug(name).replace("-", "_")] = {
188
+ "value": _normalize_ref(value, group),
189
+ "source": {"file": context.source, "line": line},
190
+ "adapter": self.id,
191
+ }
192
+ if "/values-night" in "/" + context.source.lower().replace("\\", "/"):
193
+ result.themes.append({"id": "dark", "label": "Dark", "kind": "dark", "sourceRefs": [{"file": context.source, "line": 1, "reason": "values-night"}], "tokenOverrides": result.tokens, "nodeOverrides": {}})
194
+ result.tokens = {group: {} for group in result.tokens}
195
+ return result
196
+ if context.role == "navigation":
197
+ return result
198
+ content_root = root
199
+ if _local(root.tag) == "layout":
200
+ content_root = next((child for child in root if isinstance(child.tag, str) and _local(child.tag) != "data"), None)
201
+ if content_root is None:
202
+ result.unsupported.append({"adapter": self.id, "file": context.source, "line": 1, "expression": "<layout>", "reason": "data-binding-layout-without-view-root"})
203
+ return result
204
+ platform = "android"
205
+ screen_id = _slug(context.path.stem, "android-layout")
206
+ root_id = f"{screen_id}-root"
207
+ result.nodes[root_id] = _root_node(context, self.id, "project.android.layout")
208
+ count = 0
209
+
210
+ def convert(element: ET.Element, active_context: SourceContext, active_text: str, include_stack: tuple[str, ...] = ()) -> str:
211
+ nonlocal count
212
+ count += 1
213
+ raw_tag, explicit_attrs = _local(element.tag), _attrs(element)
214
+ if raw_tag == "include":
215
+ reference = explicit_attrs.get("layout", "")
216
+ line = _line(active_text, reference or "<include")
217
+ node_id = f"{screen_id}-include-{count}"
218
+ node = {
219
+ "type": "container", "component": "include", "layout": {"width": "fill"}, "style": {}, "children": [],
220
+ "source": {"file": active_context.source, "line": line}, "confidence": "high", "standardRef": "project.android.include",
221
+ "provenance": {
222
+ "component": property_evidence(active_context.source, line, reference or "include", self.id, "exact"),
223
+ "layout.width": property_evidence(active_context.source, line, reference or "include", self.id, "high"),
224
+ },
225
+ }
226
+ target = catalog.layout(reference)
227
+ if not target or reference in include_stack:
228
+ result.unsupported.append({"adapter": self.id, "file": active_context.source, "line": line, "expression": reference, "reason": "unresolved-or-cyclic-layout-include"})
229
+ else:
230
+ try:
231
+ target_text = target.read_text(encoding="utf-8", errors="replace")
232
+ target_root = ET.fromstring(target_text)
233
+ target_context = SourceContext(active_context.root, target, target.relative_to(active_context.root).as_posix(), target_text, active_context.platforms, "component")
234
+ candidates = list(target_root) if _local(target_root.tag) == "merge" else [target_root]
235
+ node["children"] = [convert(child, target_context, target_text, (*include_stack, reference)) for child in candidates]
236
+ except (OSError, ET.ParseError, ValueError) as exc:
237
+ result.unsupported.append({"adapter": self.id, "file": active_context.source, "line": line, "expression": reference, "reason": f"invalid-layout-include:{type(exc).__name__}"})
238
+ result.nodes[node_id] = node
239
+ return node_id
240
+ style_values, style_entry = catalog.style(explicit_attrs.get("style"))
241
+ normalized_style = {key.rsplit(":", 1)[-1]: value for key, value in style_values.items()}
242
+ attrs = {**normalized_style, **explicit_attrs}
243
+ short = raw_tag.rsplit(".", 1)[-1]
244
+ node_id = f"{screen_id}-{_slug(attrs.get('id', '').split('/')[-1] or short)}-{count}"
245
+ node_type = self.TYPES.get(short, "custom")
246
+ line = _line(active_text, f"<{raw_tag}")
247
+ standard_prefix = "material3" if short.startswith("Material") or "TextInput" in short else "project"
248
+ node: dict[str, Any] = {
249
+ "type": node_type, "component": raw_tag, "layout": {}, "style": {}, "children": [], "source": {"file": active_context.source, "line": line},
250
+ "confidence": "high" if node_type != "custom" else "unsupported", "standardRef": f"{standard_prefix}.android-xml.{short.lower()}", "provenance": {},
251
+ }
252
+ if node_type != "custom":
253
+ node["inheritsAppearance"] = True
254
+ node["provenance"]["component"] = property_evidence(active_context.source, line, raw_tag, self.id, "exact")
255
+ else:
256
+ result.unsupported.append({"adapter": self.id, "file": active_context.source, "line": line, "expression": raw_tag, "reason": "unsupported-android-view"})
257
+ if short in {"FrameLayout", "ConstraintLayout", "CoordinatorLayout"}:
258
+ _set(node, "layout.direction", "overlay", active_context, line, short, self.id)
259
+ mapping = {
260
+ "layout_width": ("layout.width", "spacing"), "layout_height": ("layout.height", "spacing"), "padding": ("layout.padding", "spacing"),
261
+ "paddingHorizontal": ("layout.paddingHorizontal", "spacing"), "paddingVertical": ("layout.paddingVertical", "spacing"),
262
+ "paddingStart": ("layout.paddingLeft", "spacing"), "paddingEnd": ("layout.paddingRight", "spacing"), "paddingTop": ("layout.paddingTop", "spacing"), "paddingBottom": ("layout.paddingBottom", "spacing"),
263
+ "layout_margin": ("layout.margin", "spacing"), "layout_marginHorizontal": ("layout.marginHorizontal", "spacing"), "layout_marginVertical": ("layout.marginVertical", "spacing"),
264
+ "layout_marginStart": ("layout.marginLeft", "spacing"), "layout_marginEnd": ("layout.marginRight", "spacing"), "layout_marginTop": ("layout.marginTop", "spacing"), "layout_marginBottom": ("layout.marginBottom", "spacing"),
265
+ "minWidth": ("layout.minWidth", "spacing"), "minHeight": ("layout.minHeight", "spacing"), "maxWidth": ("layout.maxWidth", "spacing"), "maxHeight": ("layout.maxHeight", "spacing"),
266
+ "textColor": ("style.color", "color"), "textSize": ("style.fontSize", "dimen"), "alpha": ("style.opacity", "spacing"), "fontFamily": ("style.fontFamily", "font"),
267
+ "tint": ("style.color", "color"),
268
+ }
269
+ for attr, (path, group) in mapping.items():
270
+ if attr in attrs:
271
+ resolved = catalog.resolve(attrs[attr], group, normalized_style)
272
+ value = resolved.value if resolved.resolved else _normalize_ref(attrs[attr], "colors" if group == "color" else group)
273
+ source = resolved.source or (style_entry.source if attr not in explicit_attrs and style_entry else active_context.source)
274
+ source_line = resolved.line if resolved.source else style_entry.line if attr not in explicit_attrs and style_entry else line
275
+ group_name, key = path.split(".", 1)
276
+ node.setdefault(group_name, {})[key] = value
277
+ node["provenance"][path] = property_evidence(source, source_line, f'{attr}="{attrs[attr]}"', self.id, resolved.confidence if resolved.source else "exact")
278
+ if attrs.get("orientation") in {"vertical", "horizontal"}:
279
+ _set(node, "layout.direction", "column" if attrs["orientation"] == "vertical" else "row", active_context, line, f'orientation="{attrs["orientation"]}"', self.id)
280
+ direction = node.get("layout", {}).get("direction", "column")
281
+ gravity = attrs.get("gravity", "")
282
+ if gravity:
283
+ if "center" == gravity or "center_horizontal" in gravity:
284
+ _set(node, "layout.align" if direction == "column" else "layout.justify", "center", active_context, line, f'gravity="{gravity}"', self.id)
285
+ if "center" == gravity or "center_vertical" in gravity:
286
+ _set(node, "layout.justify" if direction == "column" else "layout.align", "center", active_context, line, f'gravity="{gravity}"', self.id)
287
+ if "end" in gravity or "right" in gravity:
288
+ _set(node, "layout.align" if direction == "column" else "layout.justify", "end", active_context, line, f'gravity="{gravity}"', self.id)
289
+ layout_gravity = attrs.get("layout_gravity", "")
290
+ if "center" in layout_gravity:
291
+ _set(node, "layout.alignSelf", "center", active_context, line, f'layout_gravity="{layout_gravity}"', self.id)
292
+ _set(node, "layout.justifySelf", "center", active_context, line, f'layout_gravity="{layout_gravity}"', self.id)
293
+ if attrs.get("layout_weight"):
294
+ _set(node, "layout.grow", _normalize_ref(attrs["layout_weight"]), active_context, line, f'layout_weight="{attrs["layout_weight"]}"', self.id)
295
+ text_style = attrs.get("textStyle", "")
296
+ if "bold" in text_style:
297
+ _set(node, "style.fontWeight", 700, active_context, line, f'textStyle="{text_style}"', self.id)
298
+ if "italic" in text_style:
299
+ _set(node, "style.fontStyle", "italic", active_context, line, f'textStyle="{text_style}"', self.id)
300
+ text_alignment = {"center": "center", "viewStart": "start", "textStart": "start", "viewEnd": "end", "textEnd": "end"}.get(attrs.get("textAlignment", ""))
301
+ if text_alignment:
302
+ _set(node, "style.textAlign", text_alignment, active_context, line, f'textAlignment="{attrs["textAlignment"]}"', self.id)
303
+ constraints = {key: value for key, value in attrs.items() if key.startswith("layout_constraint")}
304
+ if constraints:
305
+ result.unsupported.append({"adapter": self.id, "file": active_context.source, "line": line, "expression": ", ".join(sorted(constraints)), "reason": "unresolved-constraint-equations"})
306
+ horizontal_center = constraints.get("layout_constraintStart_toStartOf") == "parent" and constraints.get("layout_constraintEnd_toEndOf") == "parent"
307
+ vertical_center = constraints.get("layout_constraintTop_toTopOf") == "parent" and constraints.get("layout_constraintBottom_toBottomOf") == "parent"
308
+ if horizontal_center:
309
+ _set(node, "layout.justifySelf", "stretch" if attrs.get("layout_width") == "0dp" else "center", active_context, line, "horizontal parent constraints", self.id, "high")
310
+ if attrs.get("layout_width") == "0dp": node["layout"]["width"] = "fill"
311
+ if vertical_center:
312
+ _set(node, "layout.alignSelf", "stretch" if attrs.get("layout_height") == "0dp" else "center", active_context, line, "vertical parent constraints", self.id, "high")
313
+ if attrs.get("layout_height") == "0dp": node["layout"]["height"] = "fill"
314
+ unsupported_constraints = [key for key in constraints if key not in {"layout_constraintStart_toStartOf", "layout_constraintEnd_toEndOf", "layout_constraintTop_toTopOf", "layout_constraintBottom_toBottomOf"}]
315
+ if unsupported_constraints:
316
+ result.unsupported.append({"adapter": self.id, "file": active_context.source, "line": line, "expression": ", ".join(sorted(unsupported_constraints)), "reason": "unsupported-constraint-equation"})
317
+ if attrs.get("text"):
318
+ resolved = catalog.resolve(attrs["text"], "string", normalized_style)
319
+ node["text"] = resolved.value if resolved.resolved else _normalize_ref(attrs["text"], "strings")
320
+ node["provenance"]["text"] = property_evidence(resolved.source or active_context.source, resolved.line if resolved.source else line, f'text="{attrs["text"]}"', self.id, resolved.confidence if resolved.source else "exact")
321
+ if attrs.get("hint"):
322
+ resolved = catalog.resolve(attrs["hint"], "string", normalized_style)
323
+ node["placeholder"] = resolved.value if resolved.resolved else attrs["hint"]
324
+ node["provenance"]["placeholder"] = property_evidence(resolved.source or active_context.source, resolved.line if resolved.source else line, f'hint="{attrs["hint"]}"', self.id, resolved.confidence if resolved.source else "exact")
325
+ if attrs.get("contentDescription"):
326
+ resolved = catalog.resolve(attrs["contentDescription"], "string", normalized_style)
327
+ node["alt"] = resolved.value if resolved.resolved else attrs["contentDescription"]
328
+ node["semantics"] = {"role": "button" if node_type == "button" else "image", "label": node["alt"]}
329
+ node["provenance"]["alt"] = property_evidence(resolved.source or active_context.source, resolved.line if resolved.source else line, f'contentDescription="{attrs["contentDescription"]}"', self.id, resolved.confidence if resolved.source else "exact")
330
+ if attrs.get("background", "").startswith("@drawable/"):
331
+ drawable = catalog.drawable(attrs["background"], normalized_style)
332
+ node["style"].update(drawable.style)
333
+ for key in drawable.style:
334
+ node["provenance"][f"style.{key}"] = property_evidence(drawable.source, drawable.line, attrs["background"], self.id, drawable.confidence)
335
+ if not drawable.style:
336
+ result.unsupported.append({"adapter": self.id, "file": active_context.source, "line": line, "expression": attrs["background"], "reason": "unsupported-background-drawable"})
337
+ elif attrs.get("background"):
338
+ resolved = catalog.resolve(attrs["background"], "color", normalized_style)
339
+ node["style"]["backgroundColor"] = resolved.value if resolved.resolved else attrs["background"]
340
+ node["provenance"]["style.backgroundColor"] = property_evidence(resolved.source or active_context.source, resolved.line if resolved.source else line, attrs["background"], self.id, resolved.confidence if resolved.source else "exact")
341
+ if attrs.get("src") or attrs.get("srcCompat"):
342
+ asset = attrs.get("srcCompat") or attrs.get("src")
343
+ drawable = catalog.drawable(str(asset), normalized_style)
344
+ node["asset"] = drawable.asset or asset
345
+ node["style"].update(drawable.style)
346
+ node["provenance"]["asset"] = property_evidence(drawable.source or active_context.source, drawable.line if drawable.source else line, str(asset), self.id, drawable.confidence)
347
+ if not drawable.asset:
348
+ result.unsupported.append({"adapter": self.id, "file": active_context.source, "line": line, "expression": asset, "reason": "unsupported-android-drawable"})
349
+ scale_types = {"centerCrop": "cover", "fitCenter": "contain", "centerInside": "contain", "fitXY": "fill", "center": "none"}
350
+ if attrs.get("scaleType") in scale_types:
351
+ _set(node, "style.objectFit", scale_types[attrs["scaleType"]], active_context, line, f'scaleType="{attrs["scaleType"]}"', self.id)
352
+ if attrs.get("focusable") == "true":
353
+ node["states"] = {"default": {}, "focused": {"style": {"outline": "source-focus"}}}
354
+ node["children"] = [convert(child, active_context, active_text, include_stack) for child in element if isinstance(child.tag, str)]
355
+ if short == "ImageButton" and node.get("asset"):
356
+ icon_id = f"{node_id}-icon"
357
+ result.nodes[icon_id] = {
358
+ "type": "icon", "component": "ImageButton.drawable", "asset": node["asset"], "alt": node.get("alt", ""),
359
+ "layout": {"width": "fill", "height": "fill"}, "style": {"objectFit": node.get("style", {}).get("objectFit", "contain")}, "children": [],
360
+ "source": {"file": active_context.source, "line": line}, "confidence": node.get("confidence", "high"), "standardRef": node.get("standardRef"),
361
+ "provenance": {"asset": node["provenance"].get("asset", property_evidence(active_context.source, line, str(node["asset"]), self.id, "exact"))},
362
+ }
363
+ node["children"].insert(0, icon_id)
364
+ if node.get("layout", {}).get("direction") == "overlay":
365
+ for child_id in node["children"]:
366
+ child_node = result.nodes[child_id]
367
+ child_node.setdefault("layout", {})["gridArea"] = "1 / 1"
368
+ child_source = child_node.get("source", {})
369
+ child_node.setdefault("provenance", {})["layout.gridArea"] = property_evidence(
370
+ str(child_source.get("file") or active_context.source),
371
+ int(child_source.get("line") or line),
372
+ "overlay parent",
373
+ self.id,
374
+ "high",
375
+ )
376
+ result.nodes[node_id] = node
377
+ return node_id
378
+
379
+ candidates = list(content_root) if _local(content_root.tag) == "merge" else [content_root]
380
+ result.nodes[root_id]["children"] = [convert(child, context, context.text) for child in candidates]
381
+ if context.role == "screen":
382
+ metadata = android_layout_metadata(context.path) or {}
383
+ result.screens.append({
384
+ "id": screen_id,
385
+ "name": context.path.stem,
386
+ "root": root_id,
387
+ "route": "",
388
+ "platform": platform,
389
+ "source": {"file": context.source, "line": 1, "symbol": context.path.stem},
390
+ "confidence": "high",
391
+ "androidLayout": str(metadata.get("resource") or context.path.stem),
392
+ "resourceQualifier": str(metadata.get("qualifier") or ""),
393
+ })
394
+ return result
395
+
396
+
397
+ class XamlAdapter:
398
+ id = "xaml"
399
+ platforms = ("windows",)
400
+ extensions = (".xaml",)
401
+ maturity = "structural"
402
+ structural_tier = "translated"
403
+ visual_tier = "none"
404
+ native_evidence_required = True
405
+ limitations = ("templates and bindings are not evaluated", "custom controls remain unsupported")
406
+ TYPES = {"Page": "container", "Window": "container", "UserControl": "container", "Grid": "container", "StackPanel": "container", "Canvas": "container", "Border": "card", "NavigationView": "container", "Frame": "container", "ScrollViewer": "container", "ListView": "list", "GridView": "list", "ItemsControl": "list", "TextBlock": "text", "Label": "text", "Button": "button", "HyperlinkButton": "button", "TextBox": "input", "PasswordBox": "input", "Image": "image", "SymbolIcon": "icon", "FontIcon": "icon", "Rectangle": "divider"}
407
+
408
+ def supports(self, context: SourceContext) -> bool:
409
+ return context.path.suffix.lower() == ".xaml" and any(value.startswith("windows-") for value in context.platforms)
410
+
411
+ def translate(self, context: SourceContext) -> AdapterResult:
412
+ result = AdapterResult(adapter=self.id)
413
+ try:
414
+ root = ET.fromstring(context.text)
415
+ except ET.ParseError as exc:
416
+ result.unsupported.append({"adapter": self.id, "file": context.source, "line": getattr(exc, "position", (1,))[0], "expression": str(exc), "reason": "invalid-xaml"})
417
+ return result
418
+ root_tag = _local(root.tag)
419
+ if root_tag == "ResourceDictionary":
420
+ for child in root:
421
+ attrs, value = _attrs(child), (child.text or attrs.get("Color") or "").strip()
422
+ name = attrs.get("Key")
423
+ if not name or not value:
424
+ continue
425
+ group = "colors" if _local(child.tag) in {"Color", "SolidColorBrush"} else "spacing"
426
+ result.tokens[group][_slug(name).replace("-", "_")] = {"value": _normalize_ref(value, group), "source": {"file": context.source, "line": _line(context.text, name)}, "adapter": self.id}
427
+ return result
428
+ root_attrs = _attrs(root)
429
+ name = root_attrs.get("Class", context.path.stem).split(".")[-1]
430
+ screen_id, root_id = _slug(name, "windows-view"), f"{_slug(name, 'windows-view')}-root"
431
+ result.nodes[root_id] = _root_node(context, self.id, "project.windows.xaml")
432
+ count = 0
433
+
434
+ def convert(element: ET.Element) -> str:
435
+ nonlocal count
436
+ count += 1
437
+ tag, attrs = _local(element.tag), _attrs(element)
438
+ node_id = f"{screen_id}-{_slug(attrs.get('Name') or tag)}-{count}"
439
+ node_type, line = self.TYPES.get(tag, "custom"), _line(context.text, f"<{tag}")
440
+ node: dict[str, Any] = {"type": node_type, "component": tag, "layout": {}, "style": {}, "children": [], "source": {"file": context.source, "line": line}, "confidence": "high" if node_type != "custom" else "unsupported", "standardRef": f"project.windows.{tag.lower()}", "provenance": {}}
441
+ if node_type != "custom":
442
+ node["inheritsAppearance"] = True
443
+ node["provenance"]["component"] = property_evidence(context.source, line, tag, self.id, "exact")
444
+ else:
445
+ result.unsupported.append({"adapter": self.id, "file": context.source, "line": line, "expression": tag, "reason": "unsupported-xaml-control"})
446
+ mapping = {"Width": ("layout.width", "spacing"), "Height": ("layout.height", "spacing"), "MinWidth": ("layout.minWidth", "spacing"), "MinHeight": ("layout.minHeight", "spacing"), "Margin": ("layout.margin", "spacing"), "Padding": ("layout.padding", "spacing"), "Background": ("style.background", "colors"), "Foreground": ("style.color", "colors"), "FontSize": ("style.fontSize", "typography"), "CornerRadius": ("style.radius", "radii"), "Opacity": ("style.opacity", "spacing")}
447
+ for attr, (path, group) in mapping.items():
448
+ if attr in attrs:
449
+ _set(node, path, _normalize_ref(attrs[attr], group), context, line, f'{attr}="{attrs[attr]}"', self.id)
450
+ if attrs.get("Orientation") in {"Vertical", "Horizontal"}:
451
+ _set(node, "layout.direction", "column" if attrs["Orientation"] == "Vertical" else "row", context, line, f'Orientation="{attrs["Orientation"]}"', self.id)
452
+ text = attrs.get("Text") or attrs.get("Content") or (element.text or "").strip()
453
+ if text and "{" not in text:
454
+ node["text"] = text
455
+ node["provenance"]["text"] = property_evidence(context.source, line, text, self.id, "exact")
456
+ if attrs.get("Source"):
457
+ node["asset"] = attrs["Source"]
458
+ node["provenance"]["asset"] = property_evidence(context.source, line, attrs["Source"], self.id, "exact")
459
+ node["children"] = [convert(child) for child in element if isinstance(child.tag, str) and "." not in _local(child.tag)]
460
+ result.nodes[node_id] = node
461
+ return node_id
462
+
463
+ result.nodes[root_id]["children"] = [convert(root)]
464
+ result.screens.append({"id": screen_id, "name": name, "root": root_id, "route": "", "platform": "windows", "source": {"file": context.source, "line": 1, "symbol": name}, "confidence": "high"})
465
+ return result
466
+
467
+
468
+ DECLARATIVE_TYPES = {
469
+ "VStack": "container", "HStack": "container", "ZStack": "container", "NavigationStack": "container", "ScrollView": "container", "Form": "container", "Section": "container", "List": "list", "Text": "text", "Button": "button", "TextField": "input", "SecureField": "input", "Image": "image", "AsyncImage": "image", "Label": "text", "Spacer": "spacer", "Divider": "divider", "Toggle": "button",
470
+ "Column": "container", "Row": "container", "Stack": "container", "Scaffold": "container", "Container": "container", "Padding": "container", "Center": "container", "Expanded": "container", "ListView": "list", "GridView": "list", "Card": "card", "ElevatedButton": "button", "TextButton": "button", "IconButton": "button", "TextFormField": "input", "SizedBox": "spacer", "Icon": "icon",
471
+ }
472
+
473
+
474
+ def _declarative_calls(body: str, types: set[str]) -> list[dict[str, Any]]:
475
+ calls: list[dict[str, Any]] = []
476
+ pattern = re.compile(r"\b(" + "|".join(sorted(types, key=len, reverse=True)) + r")\b")
477
+ for match in pattern.finditer(body):
478
+ cursor = match.end()
479
+ while cursor < len(body) and body[cursor].isspace(): cursor += 1
480
+ args, close = "", match.end()
481
+ if cursor < len(body) and body[cursor] == "(":
482
+ end = _balanced_close(body, cursor, "(", ")")
483
+ if end is None: continue
484
+ args, close, cursor = body[cursor + 1:end], end, end + 1
485
+ while cursor < len(body) and body[cursor].isspace(): cursor += 1
486
+ block_end = _balanced_close(body, cursor, "{", "}") if cursor < len(body) and body[cursor] == "{" else None
487
+ contain_end = block_end if block_end is not None else close
488
+ calls.append({"widget": match.group(1), "start": match.start(), "args": args, "close": close, "blockStart": cursor if block_end is not None else None, "containEnd": contain_end})
489
+ return calls
490
+
491
+
492
+ def _modifier_chain(body: str, start: int) -> str:
493
+ cursor, end = start, start
494
+ while cursor < len(body):
495
+ while cursor < len(body) and body[cursor].isspace():
496
+ cursor += 1
497
+ if cursor >= len(body) or body[cursor] != ".":
498
+ break
499
+ match = re.match(r"\.\w+", body[cursor:])
500
+ if not match:
501
+ break
502
+ cursor += match.end()
503
+ while cursor < len(body) and body[cursor].isspace():
504
+ cursor += 1
505
+ if cursor < len(body) and body[cursor] == "(":
506
+ close = _balanced_close(body, cursor, "(", ")")
507
+ if close is None:
508
+ break
509
+ cursor = close + 1
510
+ end = cursor
511
+ return body[start:end]
512
+
513
+
514
+ def _apple_color(expression: str, catalog: AppleResourceCatalog | None) -> tuple[str | None, str]:
515
+ asset = re.search(r'Color\(\s*"([^"]+)"', expression)
516
+ if asset and catalog and (resolved := catalog.color(asset.group(1))):
517
+ return resolved.value, resolved.source
518
+ named = re.search(r"(?:Color\.)?\.(black|white|red|green|blue|gray|orange|yellow|pink|purple|primary|secondary)\b", expression)
519
+ if not named:
520
+ named = re.search(r"\bColor\.(black|white|red|green|blue|gray|orange|yellow|pink|purple|primary|secondary)\b", expression)
521
+ colors = {
522
+ "black": "#000000", "white": "#ffffff", "red": "#ff3b30", "green": "#34c759", "blue": "#007aff",
523
+ "gray": "#8e8e93", "orange": "#ff9500", "yellow": "#ffcc00", "pink": "#ff2d55", "purple": "#af52de",
524
+ "primary": "#000000", "secondary": "#6d6d72",
525
+ }
526
+ return (colors.get(named.group(1)), "apple-semantic-color") if named else (None, "")
527
+
528
+
529
+ def _translate_declarative(context: SourceContext, adapter: str, screen_name: str, body: str, body_offset: int, platform: str, standard_prefix: str, tokens: dict[str, Any], apple_catalog: AppleResourceCatalog | None = None) -> AdapterResult:
530
+ result = AdapterResult(adapter=adapter, tokens=tokens)
531
+ screen_id, root_id = _slug(screen_name), f"{_slug(screen_name)}-root"
532
+ result.nodes[root_id] = _root_node(context, adapter, f"project.{standard_prefix}.screen", _line(context.text, screen_name))
533
+ calls = _declarative_calls(body, set(DECLARATIVE_TYPES))
534
+ records: list[tuple[dict[str, Any], str]] = []
535
+ for index, call in enumerate(calls, start=1):
536
+ widget, args = call["widget"], call["args"]
537
+ node_id, node_type = f"{screen_id}-{_slug(widget)}-{index}", DECLARATIVE_TYPES[widget]
538
+ line = context.text.count("\n", 0, body_offset + call["start"]) + 1
539
+ native_prefix = "apple" if platform in {"ios", "macos"} else "material3" if platform == "android" else "project"
540
+ node: dict[str, Any] = {"type": node_type, "component": widget, "layout": {}, "style": {}, "children": [], "source": {"file": context.source, "line": line, "symbol": screen_name}, "confidence": "high", "standardRef": f"{native_prefix}.{standard_prefix}.{widget.lower()}", "inheritsAppearance": True, "provenance": {"component": property_evidence(context.source, line, widget, adapter, "exact")}}
541
+ literal = re.search(r"(?:text\s*:\s*|label\s*:\s*)?[\"']([^\"']+)[\"']", args)
542
+ if literal and node_type in {"text", "button", "input"}:
543
+ node["text"] = literal.group(1)
544
+ node["provenance"]["text"] = property_evidence(context.source, line, literal.group(0), adapter, "exact")
545
+ localized = re.search(r'String\(\s*localized\s*:\s*"([^"]+)"', args)
546
+ if localized and apple_catalog and (resolved := apple_catalog.localized(localized.group(1))):
547
+ node["text"] = resolved.value
548
+ node["provenance"]["text"] = property_evidence(resolved.source, 1, localized.group(0), adapter, resolved.confidence)
549
+ if widget in {"VStack", "Column", "List", "ListView"}:
550
+ _set(node, "layout.direction", "column", context, line, widget, adapter)
551
+ if widget in {"HStack", "Row"}:
552
+ _set(node, "layout.direction", "row", context, line, widget, adapter)
553
+ if widget in {"ZStack", "Stack"}:
554
+ _set(node, "layout.direction", "overlay", context, line, widget, adapter)
555
+ alignment = re.search(r"\balignment\s*:\s*\.?(leading|trailing|center|top|bottom)", args)
556
+ if alignment and widget in {"VStack", "HStack", "ZStack"}:
557
+ mapped = {"leading": "start", "trailing": "end", "top": "start", "bottom": "end", "center": "center"}[alignment.group(1)]
558
+ _set(node, "layout.align", mapped, context, line, alignment.group(0), adapter)
559
+ spacing = re.search(r"\bspacing\s*:\s*(-?\d+(?:\.\d+)?)", args)
560
+ if spacing:
561
+ _set(node, "layout.gap", _normalize_ref(spacing.group(1)), context, line, spacing.group(0), adapter)
562
+ modifier_start = int(call.get("containEnd") or call["close"]) + 1
563
+ expression = args + _modifier_chain(body, modifier_start)
564
+ padding = re.search(r"\.padding\s*\(\s*(?!\.)([\w.]+)", expression)
565
+ flutter_padding = re.search(r"padding\s*:\s*(?:const\s+)?EdgeInsets\.all\s*\(\s*([\w.]+)", expression)
566
+ padding_value = padding.group(1) if padding else flutter_padding.group(1) if flutter_padding else None
567
+ if padding_value: _set(node, "layout.padding", _normalize_ref(padding_value), context, line, (padding or flutter_padding).group(0), adapter)
568
+ directional_padding = re.search(r"\.padding\s*\(\s*\.(horizontal|vertical|top|bottom|leading|trailing)\s*,\s*([\w.]+)", expression)
569
+ if directional_padding:
570
+ key = {"horizontal": "paddingHorizontal", "vertical": "paddingVertical", "top": "paddingTop", "bottom": "paddingBottom", "leading": "paddingLeft", "trailing": "paddingRight"}[directional_padding.group(1)]
571
+ _set(node, f"layout.{key}", _normalize_ref(directional_padding.group(2)), context, line, directional_padding.group(0), adapter)
572
+ width = re.search(r"(?:width\s*:\s*|\.frame\s*\(\s*width\s*:\s*)([\w.]+)", expression)
573
+ height = re.search(r"(?:height\s*:\s*|\.frame\s*\([^)]*height\s*:\s*)([\w.]+)", expression)
574
+ if width: _set(node, "layout.width", _normalize_ref(width.group(1)), context, line, width.group(0), adapter)
575
+ if height: _set(node, "layout.height", _normalize_ref(height.group(1)), context, line, height.group(0), adapter)
576
+ if re.search(r"\.frame\s*\([^)]*maxWidth\s*:\s*\.infinity", expression):
577
+ _set(node, "layout.width", "fill", context, line, "frame maxWidth: .infinity", adapter)
578
+ if re.search(r"\.frame\s*\([^)]*maxHeight\s*:\s*\.infinity", expression):
579
+ _set(node, "layout.height", "fill", context, line, "frame maxHeight: .infinity", adapter)
580
+ if platform in {"ios", "macos"}:
581
+ system_image = re.search(r'systemName\s*:\s*"([^"]+)"', args)
582
+ named_image = re.match(r'\s*"([^"]+)"', args)
583
+ if widget == "Image" and system_image:
584
+ node["type"] = "icon"
585
+ node["iconName"] = system_image.group(1)
586
+ if asset := sf_symbol_asset(system_image.group(1)):
587
+ node["asset"] = asset
588
+ node["provenance"]["asset"] = property_evidence("apple-sf-symbol-fallback", 1, system_image.group(0), adapter, "approximate")
589
+ elif widget in {"Image", "AsyncImage"} and named_image and apple_catalog:
590
+ resolved = apple_catalog.image(named_image.group(1))
591
+ if resolved:
592
+ node["asset"] = resolved.value
593
+ node["provenance"]["asset"] = property_evidence(resolved.source, 1, named_image.group(0), adapter, resolved.confidence)
594
+ foreground = re.search(r"\.(?:foregroundStyle|foregroundColor)\s*\(([^)]*(?:\([^)]*\)[^)]*)?)\)", expression)
595
+ if foreground:
596
+ color, source = _apple_color(foreground.group(1), apple_catalog)
597
+ if color:
598
+ node["style"]["color"] = color
599
+ node["provenance"]["style.color"] = property_evidence(source or context.source, 1 if source else line, foreground.group(0), adapter, "high")
600
+ background = re.search(r"\.background\s*\(([^)]*(?:\([^)]*\)[^)]*)?)\)", expression)
601
+ if background:
602
+ color, source = _apple_color(background.group(1), apple_catalog)
603
+ if color:
604
+ node["style"]["backgroundColor"] = color
605
+ node["provenance"]["style.backgroundColor"] = property_evidence(source or context.source, 1 if source else line, background.group(0), adapter, "high")
606
+ radius = re.search(r"\.cornerRadius\s*\(\s*([\d.]+)", expression) or re.search(r"RoundedRectangle\s*\(\s*cornerRadius\s*:\s*([\d.]+)", expression)
607
+ if radius:
608
+ _set(node, "style.radius", _normalize_ref(radius.group(1)), context, line, radius.group(0), adapter)
609
+ font_size = re.search(r"\.font\s*\(\s*\.system\s*\([^)]*size\s*:\s*([\d.]+)", expression)
610
+ if font_size:
611
+ _set(node, "style.fontSize", _normalize_ref(font_size.group(1)), context, line, font_size.group(0), adapter)
612
+ font_weight = re.search(r"(?:weight\s*:\s*|\.fontWeight\s*\()\.?(bold|semibold|medium|regular)", expression)
613
+ if font_weight:
614
+ _set(node, "style.fontWeight", {"bold": 700, "semibold": 600, "medium": 500, "regular": 400}[font_weight.group(1)], context, line, font_weight.group(0), adapter)
615
+ if ".bold()" in expression:
616
+ _set(node, "style.fontWeight", 700, context, line, ".bold()", adapter)
617
+ text_align = re.search(r"\.multilineTextAlignment\s*\(\s*\.(leading|center|trailing)", expression)
618
+ if text_align:
619
+ _set(node, "style.textAlign", {"leading": "start", "center": "center", "trailing": "end"}[text_align.group(1)], context, line, text_align.group(0), adapter)
620
+ if ".scaledToFill()" in expression:
621
+ _set(node, "style.objectFit", "cover", context, line, ".scaledToFill()", adapter)
622
+ elif ".scaledToFit()" in expression:
623
+ _set(node, "style.objectFit", "contain", context, line, ".scaledToFit()", adapter)
624
+ result.nodes[node_id] = node
625
+ records.append((call, node_id))
626
+ for call, node_id in records:
627
+ parents = [(candidate, candidate_id) for candidate, candidate_id in records if candidate["start"] < call["start"] < candidate["containEnd"]]
628
+ parent_id = max(parents, key=lambda item: item[0]["start"])[1] if parents else root_id
629
+ result.nodes[parent_id]["children"].append(node_id)
630
+ known = set(DECLARATIVE_TYPES) | {"Color", "Font", "String", "EdgeInsets", "RoundedRectangle", "MaterialApp", "ThemeData"}
631
+ for unknown in re.finditer(r"\b([A-Z]\w*)\s*(?:\(|\{)", body):
632
+ if unknown.group(1) not in known:
633
+ result.unsupported.append({"adapter": adapter, "file": context.source, "line": context.text.count("\n", 0, body_offset + unknown.start()) + 1, "expression": unknown.group(1), "reason": "unsupported-declarative-component"})
634
+ result.screens.append({"id": screen_id, "name": screen_name, "root": root_id, "route": "", "platform": platform, "source": {"file": context.source, "line": _line(context.text, screen_name), "symbol": screen_name}, "confidence": "high"})
635
+ return result
636
+
637
+
638
+ class SwiftUIAdapter:
639
+ id = "swiftui"
640
+ platforms = ("ios", "macos")
641
+ extensions = (".swift",)
642
+ maturity = "structural"
643
+ structural_tier = "translated"
644
+ visual_tier = "none"
645
+ native_evidence_required = True
646
+ native_providers = ("xcode-preview", "swift-snapshot-testing", "apple-simulator")
647
+ resource_resolution = ("Asset Catalog images/colors", "Localizable.strings", "common SF Symbols")
648
+ layout_features = ("stack direction/alignment/spacing", "frame/fill/padding", "overlay stacks", "basic color/type/image modifiers")
649
+ limitations = ("supported SwiftUI primitives and modifiers only", "conditional runtime branches are not executed", "SF Symbol SVGs are structural fallbacks")
650
+
651
+ def __init__(self) -> None:
652
+ self._catalogs: dict[str, AppleResourceCatalog] = {}
653
+
654
+ def prepare(self, contexts: list[SourceContext]) -> None:
655
+ roots = {str(context.root.resolve()): context.root for context in contexts if self.supports(context)}
656
+ self._catalogs = {key: AppleResourceCatalog.discover(root) for key, root in roots.items()}
657
+
658
+ def _catalog(self, context: SourceContext) -> AppleResourceCatalog:
659
+ key = str(context.root.resolve())
660
+ if key not in self._catalogs:
661
+ self._catalogs[key] = AppleResourceCatalog.discover(context.root)
662
+ return self._catalogs[key]
663
+
664
+ def supports(self, context: SourceContext) -> bool:
665
+ return context.path.suffix.lower() == ".swift" and any(value.startswith("swiftui") for value in context.platforms)
666
+
667
+ def translate(self, context: SourceContext) -> AdapterResult:
668
+ combined = AdapterResult(adapter=self.id)
669
+ catalog = self._catalog(context)
670
+ tokens = {"colors": {}, "spacing": {}, "radii": {}, "typography": {}}
671
+ for match in re.finditer(r"(?:static\s+)?let\s+(\w+)\s*(?::\s*\w+)?\s*=\s*(Color\([^\n]+\)|\d+(?:\.\d+)?)", context.text):
672
+ group = "colors" if match.group(2).startswith("Color") else "spacing"
673
+ tokens[group][_slug(match.group(1)).replace("-", "_")] = {"value": match.group(2), "source": {"file": context.source, "line": _line(context.text, match.group(0))}, "adapter": self.id}
674
+ platform = "macos" if any(value in {"swiftui-macos", "appkit"} for value in context.platforms) else "ios"
675
+ for struct in re.finditer(r"\bstruct\s+([A-Z]\w*)\s*:\s*View\s*\{", context.text):
676
+ end = _balanced_close(context.text, context.text.find("{", struct.start()), "{", "}")
677
+ if end is None: continue
678
+ segment = context.text[struct.end():end]
679
+ body_match = re.search(r"\bvar\s+body\s*:\s*some\s+View\s*\{", segment)
680
+ if not body_match: continue
681
+ body_start = struct.end() + body_match.end()
682
+ body_end = _balanced_close(context.text, body_start - 1, "{", "}")
683
+ if body_end is None: continue
684
+ current = _translate_declarative(context, self.id, struct.group(1), context.text[body_start:body_end], body_start, platform, "swiftui", tokens, catalog)
685
+ combined.screens.extend(current.screens); combined.nodes.update(current.nodes); combined.unsupported.extend(current.unsupported)
686
+ combined.tokens = tokens
687
+ return combined
688
+
689
+
690
+ class FlutterAdapter:
691
+ id = "flutter"
692
+ platforms = ("flutter",)
693
+ extensions = (".dart",)
694
+ maturity = "structural"
695
+ structural_tier = "translated"
696
+ visual_tier = "none"
697
+ native_evidence_required = True
698
+ native_providers = ("flutter-golden", "flutter-device")
699
+ limitations = ("custom widgets remain unsupported", "platform-adaptive runtime branches are not executed")
700
+
701
+ def supports(self, context: SourceContext) -> bool:
702
+ return context.path.suffix.lower() == ".dart" and "flutter" in context.platforms
703
+
704
+ def translate(self, context: SourceContext) -> AdapterResult:
705
+ combined = AdapterResult(adapter=self.id)
706
+ tokens = {"colors": {}, "spacing": {}, "radii": {}, "typography": {}}
707
+ for match in re.finditer(r"(?:static\s+)?const\s+(?:Color|double)\s+(\w+)\s*=\s*([^;]+);", context.text):
708
+ group = "colors" if "Color" in match.group(0).split("=")[0] else "spacing"
709
+ tokens[group][_slug(match.group(1)).replace("-", "_")] = {"value": match.group(2).strip(), "source": {"file": context.source, "line": _line(context.text, match.group(0))}, "adapter": self.id}
710
+ for klass in re.finditer(r"\bclass\s+([A-Z]\w*)\s+extends\s+(?:StatelessWidget|StatefulWidget)\s*\{", context.text):
711
+ class_end = _balanced_close(context.text, context.text.find("{", klass.start()), "{", "}")
712
+ if class_end is None: continue
713
+ segment = context.text[klass.end():class_end]
714
+ build = re.search(r"\bWidget\s+build\s*\([^)]*\)\s*\{", segment)
715
+ if not build: continue
716
+ build_start = klass.end() + build.end()
717
+ build_end = _balanced_close(context.text, build_start - 1, "{", "}")
718
+ if build_end is None: continue
719
+ current = _translate_declarative(context, self.id, klass.group(1), context.text[build_start:build_end], build_start, "flutter", "flutter", tokens)
720
+ combined.screens.extend(current.screens); combined.nodes.update(current.nodes); combined.unsupported.extend(current.unsupported)
721
+ combined.tokens = tokens
722
+ return combined
723
+
724
+
725
+ class AppleInterfaceXmlAdapter:
726
+ id = "apple-interface-xml"
727
+ platforms = ("ios", "macos")
728
+ extensions = (".storyboard", ".xib")
729
+ maturity = "structural"
730
+ structural_tier = "translated"
731
+ visual_tier = "none"
732
+ native_evidence_required = True
733
+ native_providers = ("swift-snapshot-testing", "apple-simulator")
734
+ resource_resolution = ("Asset Catalog images/colors", "authored interface strings")
735
+ layout_features = ("authored frames", "stack axis/spacing", "basic width/height/center Auto Layout constraints")
736
+ limitations = ("complex Auto Layout equations are not solved", "runtime UIKit/AppKit mutations are unsupported")
737
+ TYPES = {"view": "container", "stackView": "container", "scrollView": "container", "tableView": "list", "collectionView": "list", "label": "text", "button": "button", "textField": "input", "textView": "input", "imageView": "image", "separator": "divider"}
738
+
739
+ def __init__(self) -> None:
740
+ self._catalogs: dict[str, AppleResourceCatalog] = {}
741
+
742
+ def prepare(self, contexts: list[SourceContext]) -> None:
743
+ roots = {str(context.root.resolve()): context.root for context in contexts if self.supports(context)}
744
+ self._catalogs = {key: AppleResourceCatalog.discover(root) for key, root in roots.items()}
745
+
746
+ def _catalog(self, context: SourceContext) -> AppleResourceCatalog:
747
+ key = str(context.root.resolve())
748
+ if key not in self._catalogs:
749
+ self._catalogs[key] = AppleResourceCatalog.discover(context.root)
750
+ return self._catalogs[key]
751
+
752
+ def supports(self, context: SourceContext) -> bool:
753
+ return context.path.suffix.lower() in {".storyboard", ".xib"}
754
+
755
+ def translate(self, context: SourceContext) -> AdapterResult:
756
+ result = AdapterResult(adapter=self.id)
757
+ catalog = self._catalog(context)
758
+ try: document = ET.fromstring(context.text)
759
+ except ET.ParseError as exc:
760
+ result.unsupported.append({"adapter": self.id, "file": context.source, "line": getattr(exc, "position", (1,))[0], "expression": str(exc), "reason": "invalid-interface-xml"}); return result
761
+ controllers = [item for item in document.iter() if _local(item.tag) in {"viewController", "windowController"}]
762
+ if not controllers and context.path.suffix.lower() == ".xib": controllers = [document]
763
+ platform = "macos" if "MacOSX" in context.text or any(value == "appkit" for value in context.platforms) else "ios"
764
+ for screen_index, controller in enumerate(controllers, start=1):
765
+ attrs = _attrs(controller); name = attrs.get("customClass") or attrs.get("id") or f"{context.path.stem}-{screen_index}"
766
+ screen_id, root_id = _slug(name), f"{_slug(name)}-root"
767
+ result.nodes[root_id] = _root_node(context, self.id, f"project.{platform}.interface")
768
+ count = 0
769
+ source_ids: dict[str, str] = {}
770
+ def convert(element: ET.Element, parent_tag: str = "") -> str | None:
771
+ nonlocal count
772
+ tag = _local(element.tag)
773
+ if tag in {"connections", "constraints", "resources", "dependencies", "outlet", "segue", "constraint", "color"}: return None
774
+ count += 1; attrs = _attrs(element); node_type = self.TYPES.get(tag, "custom"); line = _line(context.text, f"<{tag}")
775
+ node_id = f"{screen_id}-{_slug(attrs.get('id') or tag)}-{count}"
776
+ if attrs.get("id"):
777
+ source_ids[attrs["id"]] = node_id
778
+ node: dict[str, Any] = {"type": node_type, "component": tag, "layout": {}, "style": {}, "children": [], "source": {"file": context.source, "line": line}, "confidence": "high" if node_type != "custom" else "unsupported", "standardRef": f"project.{platform}.{tag.lower()}", "provenance": {}}
779
+ if node_type != "custom": node["inheritsAppearance"] = True; node["provenance"]["component"] = property_evidence(context.source, line, tag, self.id, "exact")
780
+ else: result.unsupported.append({"adapter": self.id, "file": context.source, "line": line, "expression": tag, "reason": "unsupported-interface-node"})
781
+ text = attrs.get("text") or attrs.get("title") or attrs.get("placeholder")
782
+ if text: node["text"] = text; node["provenance"]["text"] = property_evidence(context.source, line, text, self.id, "exact")
783
+ if tag == "view":
784
+ _set(node, "layout.direction", "overlay", context, line, tag, self.id)
785
+ if tag == "stackView":
786
+ _set(node, "layout.direction", "column" if attrs.get("axis") == "vertical" else "row", context, line, f'axis={attrs.get("axis", "horizontal")}', self.id)
787
+ if attrs.get("spacing"):
788
+ _set(node, "layout.gap", _normalize_ref(attrs["spacing"]), context, line, f'spacing={attrs["spacing"]}', self.id)
789
+ if attrs.get("image"):
790
+ resolved = catalog.image(attrs["image"])
791
+ node["asset"] = resolved.value if resolved else attrs["image"]
792
+ node["provenance"]["asset"] = property_evidence(resolved.source if resolved else context.source, 1 if resolved else line, attrs["image"], self.id, resolved.confidence if resolved else "approximate")
793
+ if not resolved:
794
+ result.unsupported.append({"adapter": self.id, "file": context.source, "line": line, "expression": attrs["image"], "reason": "unresolved-apple-image"})
795
+ named_color = next((_attrs(child).get("name") for child in element if _local(child.tag) == "color" and _attrs(child).get("name")), None)
796
+ if named_color and (resolved_color := catalog.color(named_color)):
797
+ node["style"]["color" if node_type in {"text", "button", "input", "icon"} else "backgroundColor"] = resolved_color.value
798
+ node["provenance"]["style.color" if node_type in {"text", "button", "input", "icon"} else "style.backgroundColor"] = property_evidence(resolved_color.source, 1, named_color, self.id, resolved_color.confidence)
799
+ frame = next((child for child in element if _local(child.tag) == "rect" and _attrs(child).get("key") == "frame"), None)
800
+ if frame is not None:
801
+ values = _attrs(frame)
802
+ for attr, key in (("x", "left"), ("y", "top"), ("width", "width"), ("height", "height")):
803
+ if attr in values: _set(node, f"layout.{key}", _normalize_ref(values[attr]), context, line, f"frame.{attr}={values[attr]}", self.id)
804
+ if parent_tag and parent_tag != "stackView":
805
+ _set(node, "layout.position", "absolute", context, line, "authored frame", self.id, "high")
806
+ child_ids: list[str] = []
807
+ for child in element:
808
+ if not isinstance(child.tag, str) or _local(child.tag) == "rect": continue
809
+ candidates = list(child) if _local(child.tag) in {"subviews", "objects", "scenes"} else [child]
810
+ for candidate in candidates:
811
+ if isinstance(candidate.tag, str) and (child_id := convert(candidate, tag)): child_ids.append(child_id)
812
+ node["children"] = child_ids
813
+ result.nodes[node_id] = node; return node_id
814
+ roots = [child_id for child in controller if isinstance(child.tag, str) and (child_id := convert(child))]
815
+ result.nodes[root_id]["children"] = roots
816
+ for constraint in controller.iter():
817
+ if _local(constraint.tag) != "constraint":
818
+ continue
819
+ values = _attrs(constraint)
820
+ first_id = source_ids.get(values.get("firstItem", ""))
821
+ if not first_id:
822
+ continue
823
+ node = result.nodes[first_id]
824
+ attribute = values.get("firstAttribute", "")
825
+ second_attribute = values.get("secondAttribute", "")
826
+ second_item = values.get("secondItem")
827
+ constant = values.get("constant")
828
+ if attribute in {"width", "height"} and not second_item and constant:
829
+ _set(node, f"layout.{attribute}", _normalize_ref(constant), context, _line(context.text, values.get("id", "constraint")), f"constraint {attribute}={constant}", self.id, "high")
830
+ elif attribute == "centerX" and second_attribute == "centerX":
831
+ _set(node, "layout.justifySelf", "center", context, _line(context.text, values.get("id", "constraint")), "centerX constraint", self.id, "high")
832
+ elif attribute == "centerY" and second_attribute == "centerY":
833
+ _set(node, "layout.alignSelf", "center", context, _line(context.text, values.get("id", "constraint")), "centerY constraint", self.id, "high")
834
+ else:
835
+ result.unsupported.append({"adapter": self.id, "file": context.source, "line": _line(context.text, values.get("id", "constraint")), "expression": values.get("id", "constraint"), "reason": "unsupported-auto-layout-equation"})
836
+ result.screens.append({"id": screen_id, "name": name, "root": root_id, "route": "", "platform": platform, "source": {"file": context.source, "line": _line(context.text, name), "symbol": name}, "confidence": "high"})
837
+ return result
838
+
839
+
840
+ class ProjectedMarkupAdapter:
841
+ def __init__(self, web_adapter: Any, kind: str):
842
+ self.web_adapter, self.id = web_adapter, kind
843
+ self.platforms = ("web",)
844
+ self.extensions = (".jsx", ".tsx", ".js", ".ts") if kind.startswith("react") else (f".{kind}",)
845
+ self.maturity = "golden"
846
+ self.structural_tier = "translated"
847
+ self.visual_tier = "projection"
848
+ self.native_evidence_required = False
849
+ self.native_providers = ()
850
+ self.limitations = ("dynamic expressions are not executed", "project components remain unsupported until mapped")
851
+
852
+ def supports(self, context: SourceContext) -> bool:
853
+ suffix = context.path.suffix.lower()
854
+ if self.id == "react-jsx": return suffix in {".jsx", ".tsx", ".js", ".ts"} and "react-web" in context.platforms
855
+ if self.id == "vue": return suffix == ".vue"
856
+ return suffix == ".svelte"
857
+
858
+ def translate(self, context: SourceContext) -> AdapterResult:
859
+ text, start = context.text, 0
860
+ if self.id == "vue":
861
+ match = re.search(r"<template\b[^>]*>([\s\S]*?)</template>", text, re.I)
862
+ if not match: return AdapterResult(adapter=self.id, unsupported=[{"adapter": self.id, "file": context.source, "line": 1, "expression": "template", "reason": "missing-template"}])
863
+ markup, start = match.group(1), match.start(1)
864
+ elif self.id == "svelte":
865
+ markup = re.sub(r"<(script|style)\b[^>]*>[\s\S]*?</\1>", "", text, flags=re.I)
866
+ else:
867
+ match = re.search(r"\breturn\s*\(", text)
868
+ if not match: return AdapterResult(adapter=self.id, unsupported=[{"adapter": self.id, "file": context.source, "line": 1, "expression": "return", "reason": "missing-static-jsx-return"}])
869
+ open_index = text.find("(", match.start()); end = _balanced_close(text, open_index, "(", ")")
870
+ if end is None: return AdapterResult(adapter=self.id, unsupported=[{"adapter": self.id, "file": context.source, "line": _line(text, match.group(0)), "expression": "return", "reason": "unbalanced-jsx"}])
871
+ markup, start = text[open_index + 1:end], open_index + 1
872
+ unsupported: list[dict[str, Any]] = []
873
+ for expression in re.finditer(r"\{[^{}]*\}", markup):
874
+ unsupported.append({"adapter": self.id, "file": context.source, "line": text.count("\n", 0, start + expression.start()) + 1, "expression": expression.group(0)[:200], "reason": "dynamic-markup-expression"})
875
+ projected = re.sub(r"\{[^{}]*\}", "", markup)
876
+ projected = projected.replace("className=", "class=").replace("htmlFor=", "for=")
877
+ embedded_css = "\n".join(re.findall(r"<style\b[^>]*>([\s\S]*?)</style>", text, re.I)) if self.id in {"vue", "svelte"} else ""
878
+ padded = "\n" * text.count("\n", 0, start) + projected + ("\n" + embedded_css if embedded_css else "")
879
+ projected_context = SourceContext(context.root, context.path, context.source, padded, context.platforms, context.role)
880
+ result = self.web_adapter.translate(projected_context)
881
+ result.adapter = self.id; result.unsupported.extend(unsupported)
882
+ custom_tags = {value.lower(): value for value in re.findall(r"<([A-Z][A-Za-z0-9_.]*)\b", markup)}
883
+ for node in result.nodes.values():
884
+ for evidence in node.get("provenance", {}).values():
885
+ if isinstance(evidence, dict): evidence["adapter"] = self.id; evidence["confidence"] = "high"
886
+ node["confidence"] = "high"
887
+ ref = str(node.get("standardRef") or "")
888
+ tag = ref.removeprefix("html.")
889
+ if tag in custom_tags:
890
+ node.update({"type": "custom", "component": custom_tags[tag], "confidence": "unsupported", "standardRef": f"project.{self.id}.{tag}"})
891
+ result.unsupported.append({"adapter": self.id, "file": context.source, "line": node.get("source", {}).get("line", 1), "expression": custom_tags[tag], "reason": "untranslated-project-component"})
892
+ target_platform = "web"
893
+ for screen in result.screens: screen["platform"] = target_platform; screen["name"] = context.path.stem
894
+ return result
895
+
896
+
897
+ def builtin_platform_adapters(web_adapter: Any) -> list[Any]:
898
+ return [
899
+ AndroidXmlAdapter(), SwiftUIAdapter(), XamlAdapter(), FlutterAdapter(), AppleInterfaceXmlAdapter(),
900
+ ProjectedMarkupAdapter(web_adapter, "react-jsx"),
901
+ ProjectedMarkupAdapter(web_adapter, "vue"), ProjectedMarkupAdapter(web_adapter, "svelte"),
902
+ ]