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
fidelity_adapters.py ADDED
@@ -0,0 +1,508 @@
1
+ #!/usr/bin/env python3
2
+ """Provider-neutral source adapter API with built-in Web and Compose adapters."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import html.parser
7
+ import re
8
+ from typing import Any
9
+
10
+ from android_resource_resolver import AndroidResourceCatalog, material_icon_asset
11
+ from fidelity_adapter_api import AdapterResult, SourceContext, UiSourceAdapter, adapter_capabilities, register_adapter, registered_adapters, translate_sources
12
+ from fidelity_core import property_evidence, stable_id
13
+
14
+
15
+ def _slug(value: str, fallback: str = "node") -> str:
16
+ separated = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "-", value)
17
+ return re.sub(r"[^a-z0-9]+", "-", separated.lower()).strip("-") or fallback
18
+
19
+
20
+ def _css_value(value: str, group: str, tokens: dict[str, Any]) -> Any:
21
+ match = re.fullmatch(r"var\(\s*--([\w-]+)\s*(?:,[^)]+)?\)", value.strip())
22
+ if not match:
23
+ return value.strip()
24
+ name = _slug(match.group(1)).replace("-", "_")
25
+ if name in tokens.get(group, {}):
26
+ return f"${group}.{name}"
27
+ for candidate_group, entries in tokens.items():
28
+ if name in entries:
29
+ return f"${candidate_group}.{name}"
30
+ return value.strip()
31
+
32
+
33
+ CSS_PROPERTY_MAP = {
34
+ "display": ("layout", "display"), "flex-direction": ("layout", "direction"), "gap": ("layout", "gap"),
35
+ "width": ("layout", "width"), "height": ("layout", "height"), "min-width": ("layout", "minWidth"),
36
+ "min-height": ("layout", "minHeight"), "max-width": ("layout", "maxWidth"), "padding": ("layout", "padding"),
37
+ "margin": ("layout", "margin"), "align-items": ("layout", "align"), "justify-content": ("layout", "justify"),
38
+ "background": ("style", "background"), "background-color": ("style", "backgroundColor"), "color": ("style", "color"),
39
+ "border": ("style", "border"), "border-color": ("style", "borderColor"), "border-width": ("style", "borderWidth"),
40
+ "border-radius": ("style", "radius"), "box-shadow": ("style", "boxShadow"), "font-family": ("style", "fontFamily"),
41
+ "font-size": ("style", "fontSize"), "font-weight": ("style", "fontWeight"), "line-height": ("style", "lineHeight"),
42
+ "text-align": ("style", "textAlign"), "opacity": ("style", "opacity"),
43
+ }
44
+
45
+
46
+ def _token_group(name: str, value: str) -> str:
47
+ lower = name.lower()
48
+ if value.strip().startswith(("#", "rgb", "hsl")) or any(part in lower for part in ("color", "bg", "surface", "text", "accent")):
49
+ return "colors"
50
+ if "radius" in lower:
51
+ return "radii"
52
+ if any(part in lower for part in ("font", "line-height", "type")):
53
+ return "typography"
54
+ return "spacing"
55
+
56
+
57
+ class _HtmlTreeParser(html.parser.HTMLParser):
58
+ def __init__(self, source: str):
59
+ super().__init__(convert_charrefs=True)
60
+ self.source = source
61
+ self.counter = 0
62
+ self.roots: list[dict[str, Any]] = []
63
+ self.stack: list[dict[str, Any]] = []
64
+
65
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
66
+ self.counter += 1
67
+ node = {"tag": tag.lower(), "attrs": dict(attrs), "children": [], "text": "", "line": self.getpos()[0], "index": self.counter}
68
+ (self.stack[-1]["children"] if self.stack else self.roots).append(node)
69
+ if tag.lower() not in {"img", "input", "br", "hr", "meta", "link"}:
70
+ self.stack.append(node)
71
+
72
+ def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
73
+ self.handle_starttag(tag, attrs)
74
+ if self.stack and self.stack[-1]["tag"] == tag.lower():
75
+ self.stack.pop()
76
+
77
+ def handle_endtag(self, tag: str) -> None:
78
+ for index in range(len(self.stack) - 1, -1, -1):
79
+ if self.stack[index]["tag"] == tag.lower():
80
+ del self.stack[index:]
81
+ break
82
+
83
+ def handle_data(self, data: str) -> None:
84
+ if self.stack and data.strip():
85
+ self.stack[-1]["text"] += (" " if self.stack[-1]["text"] else "") + " ".join(data.split())
86
+
87
+
88
+ class WebAdapter:
89
+ id = "web"
90
+ platforms = ("web",)
91
+ extensions = (".html", ".htm", ".css")
92
+ maturity = "golden"
93
+ structural_tier = "translated"
94
+ visual_tier = "projection"
95
+ native_evidence_required = False
96
+ limitations = ("simple CSS selectors only", "runtime-generated DOM is unsupported")
97
+
98
+ def supports(self, context: SourceContext) -> bool:
99
+ return context.path.suffix.lower() in {".html", ".htm"}
100
+
101
+ @staticmethod
102
+ def _css(context: SourceContext) -> tuple[dict[str, dict[str, tuple[str, int, str]]], dict[str, Any], list[dict[str, Any]]]:
103
+ rules: dict[str, dict[str, tuple[str, int, str]]] = {}
104
+ tokens: dict[str, Any] = {"colors": {}, "spacing": {}, "radii": {}, "typography": {}}
105
+ unsupported: list[dict[str, Any]] = []
106
+ texts: list[tuple[str, str]] = [(context.source, context.text)]
107
+ for css_path in sorted(context.path.parent.glob("*.css")):
108
+ try:
109
+ texts.append((css_path.relative_to(context.root).as_posix(), css_path.read_text(encoding="utf-8", errors="ignore")))
110
+ except OSError:
111
+ continue
112
+ for source, text in texts:
113
+ for token in re.finditer(r"--([\w-]+)\s*:\s*([^;{}]+);", text):
114
+ name, value = token.group(1), token.group(2).strip()
115
+ group = _token_group(name, value)
116
+ tokens[group][_slug(name).replace("-", "_")] = {"value": value, "source": {"file": source, "line": text.count("\n", 0, token.start()) + 1}, "adapter": "web"}
117
+ for block in re.finditer(r"([^{}]+)\{([^{}]*)\}", text, re.S):
118
+ selectors, body = block.group(1).strip(), block.group(2)
119
+ line = text.count("\n", 0, block.start()) + 1
120
+ for selector in (part.strip() for part in selectors.split(",")):
121
+ if not re.fullmatch(r"(?:[a-zA-Z][\w-]*|\.[\w-]+|#[\w-]+|:root)", selector):
122
+ unsupported.append({"adapter": "web", "file": source, "line": line, "expression": selector, "reason": "unsupported-css-selector"})
123
+ continue
124
+ bucket = rules.setdefault(selector, {})
125
+ for declaration in re.finditer(r"([\w-]+)\s*:\s*([^;]+)", body):
126
+ prop, value = declaration.group(1).lower(), declaration.group(2).strip()
127
+ if prop.startswith("--"):
128
+ continue
129
+ if prop not in CSS_PROPERTY_MAP:
130
+ unsupported.append({"adapter": "web", "file": source, "line": line, "expression": f"{prop}: {value}", "reason": "unsupported-css-property"})
131
+ continue
132
+ bucket[prop] = (value, line, source)
133
+ return rules, tokens, unsupported
134
+
135
+ def translate(self, context: SourceContext) -> AdapterResult:
136
+ result = AdapterResult(adapter=self.id)
137
+ rules, result.tokens, result.unsupported = self._css(context)
138
+ parser = _HtmlTreeParser(context.source)
139
+ parser.feed(context.text)
140
+ body = next((item for item in parser.roots if item["tag"] == "html"), None)
141
+ roots = body["children"] if body else parser.roots
142
+ body_node = next((item for item in roots if item["tag"] == "body"), None)
143
+ roots = body_node["children"] if body_node else roots
144
+ screen_id = _slug(context.path.stem, "web-screen")
145
+ root_id = f"{screen_id}-root"
146
+ used: set[str] = set()
147
+
148
+ def convert(item: dict[str, Any]) -> str | None:
149
+ tag = item["tag"]
150
+ if tag in {"script", "style", "head", "title", "meta", "link"}:
151
+ return None
152
+ attrs = item["attrs"]
153
+ base = _slug(attrs.get("id") or f"{tag}-{item['index']}")
154
+ node_id = f"{screen_id}-{base}"
155
+ while node_id in used:
156
+ node_id += "-2"
157
+ used.add(node_id)
158
+ node_type = "container"
159
+ if tag in {"ul", "ol"}: node_type = "list"
160
+ elif tag == "li": node_type = "card"
161
+ elif tag in {"h1", "h2", "h3", "h4", "h5", "h6", "p", "span", "label", "small", "strong"}: node_type = "text"
162
+ elif tag in {"button", "a"}: node_type = "button"
163
+ elif tag in {"input", "textarea", "select"}: node_type = "input"
164
+ elif tag == "img": node_type = "image"
165
+ elif tag == "hr": node_type = "divider"
166
+ node: dict[str, Any] = {"type": node_type, "layout": {}, "style": {}, "children": [], "source": {"file": context.source, "line": item["line"]}, "confidence": "high", "standardRef": f"html.{tag}", "provenance": {}}
167
+ if item["text"]:
168
+ node["text"] = item["text"]
169
+ node["provenance"]["text"] = property_evidence(context.source, item["line"], item["text"], self.id, "exact")
170
+ if node_type == "image" and attrs.get("src"):
171
+ node["asset"] = attrs["src"]
172
+ node["provenance"]["asset"] = property_evidence(context.source, item["line"], attrs["src"], self.id, "exact")
173
+ if tag == "a" and attrs.get("href"):
174
+ result.unsupported.append({"adapter": self.id, "file": context.source, "line": item["line"], "expression": attrs["href"], "reason": "route-resolution-required"})
175
+ selectors = [tag]
176
+ if attrs.get("id"): selectors.append(f"#{attrs['id']}")
177
+ selectors.extend(f".{name}" for name in str(attrs.get("class") or "").split())
178
+ declarations: dict[str, tuple[str, int, str]] = {}
179
+ for selector in selectors:
180
+ declarations.update(rules.get(selector, {}))
181
+ inline = attrs.get("style") or ""
182
+ for declaration in re.finditer(r"([\w-]+)\s*:\s*([^;]+)", inline):
183
+ declarations[declaration.group(1).lower()] = (declaration.group(2).strip(), item["line"], context.source)
184
+ for prop, (value, line, source) in declarations.items():
185
+ if prop not in CSS_PROPERTY_MAP:
186
+ continue
187
+ group, key = CSS_PROPERTY_MAP[prop]
188
+ if prop == "flex-direction": value = "column" if value == "column" else "row"
189
+ node[group][key] = _css_value(value, "colors" if group == "style" else "spacing", result.tokens)
190
+ node["provenance"][f"{group}.{key}"] = property_evidence(source, line, f"{prop}: {value}", self.id, "exact")
191
+ child_ids = [child_id for child in item["children"] if (child_id := convert(child))]
192
+ node["children"] = child_ids
193
+ result.nodes[node_id] = node
194
+ return node_id
195
+
196
+ child_ids = [node_id for item in roots if (node_id := convert(item))]
197
+ result.nodes[root_id] = {
198
+ "type": "container", "layout": {"direction": "column", "width": "fill", "height": "fill"}, "style": {}, "children": child_ids,
199
+ "source": {"file": context.source, "line": 1}, "confidence": "high", "standardRef": "html.document",
200
+ "provenance": {
201
+ "layout.direction": property_evidence(context.source, 1, "document flow", self.id, "high"),
202
+ "layout.width": property_evidence(context.source, 1, "viewport width", self.id, "high"),
203
+ "layout.height": property_evidence(context.source, 1, "viewport height", self.id, "high"),
204
+ },
205
+ }
206
+ result.screens.append({"id": screen_id, "name": context.path.stem, "root": root_id, "route": "", "platform": "web", "source": {"file": context.source, "line": 1, "symbol": context.path.stem}, "confidence": "high"})
207
+ return result
208
+
209
+
210
+ COMPOSE_TYPES = {"Column": "container", "Row": "container", "Box": "container", "LazyColumn": "list", "LazyRow": "list", "Scaffold": "container", "Card": "card", "Surface": "card", "Text": "text", "Button": "button", "TextField": "input", "OutlinedTextField": "input", "Image": "image", "Icon": "icon", "Spacer": "spacer"}
211
+
212
+
213
+ def _balanced_close(text: str, start: int, opening: str, closing: str) -> int | None:
214
+ depth, quote, escaped = 0, None, False
215
+ for index in range(start, len(text)):
216
+ char = text[index]
217
+ if quote:
218
+ if escaped:
219
+ escaped = False
220
+ elif char == "\\":
221
+ escaped = True
222
+ elif char == quote:
223
+ quote = None
224
+ continue
225
+ if char in {'"', "'"}:
226
+ quote = char
227
+ elif char == opening:
228
+ depth += 1
229
+ elif char == closing:
230
+ depth -= 1
231
+ if depth == 0:
232
+ return index
233
+ return None
234
+
235
+
236
+ def _compose_calls(body: str) -> list[dict[str, Any]]:
237
+ calls: list[dict[str, Any]] = []
238
+ pattern = re.compile(r"\b(" + "|".join(COMPOSE_TYPES) + r")\s*\(")
239
+ for match in pattern.finditer(body):
240
+ open_paren = body.find("(", match.start())
241
+ close_paren = _balanced_close(body, open_paren, "(", ")")
242
+ if close_paren is None:
243
+ continue
244
+ cursor = close_paren + 1
245
+ while cursor < len(body) and body[cursor].isspace():
246
+ cursor += 1
247
+ block_end = _balanced_close(body, cursor, "{", "}") if cursor < len(body) and body[cursor] == "{" else None
248
+ calls.append({"widget": match.group(1), "start": match.start(), "args": body[open_paren + 1:close_paren], "blockStart": cursor if block_end is not None else None, "blockEnd": block_end})
249
+ return calls
250
+
251
+
252
+ def _compose_modifier_calls(value: str) -> list[tuple[str, str]]:
253
+ calls: list[tuple[str, str]] = []
254
+ for match in re.finditer(r"\.(\w+)\s*\(", value):
255
+ open_paren = value.find("(", match.start())
256
+ close_paren = _balanced_close(value, open_paren, "(", ")")
257
+ if close_paren is not None:
258
+ calls.append((match.group(1), value[open_paren + 1:close_paren]))
259
+ return calls
260
+
261
+
262
+ def _compose_length(value: str, tokens: dict[str, Any], group: str = "spacing") -> Any:
263
+ clean = value.strip()
264
+ dimension = re.fullmatch(r"(-?\d+(?:\.\d+)?)\.(?:dp|sp)", clean)
265
+ if dimension:
266
+ return f"{dimension.group(1)}px"
267
+ number = re.fullmatch(r"-?\d+(?:\.\d+)?", clean)
268
+ if number:
269
+ return float(clean) if "." in clean else int(clean)
270
+ token_name = _slug(clean).replace("-", "_")
271
+ if token_name in tokens.get(group, {}):
272
+ return f"${group}.{token_name}"
273
+ for candidate, entries in tokens.items():
274
+ if token_name in entries:
275
+ return f"${candidate}.{token_name}"
276
+ return clean
277
+
278
+
279
+ def _compose_color(value: str) -> str | None:
280
+ clean = value.strip()
281
+ argb = re.fullmatch(r"Color\(0x([0-9a-fA-F]{8})\)", clean)
282
+ if argb:
283
+ alpha, red, green, blue = (argb.group(1)[index:index + 2] for index in (0, 2, 4, 6))
284
+ return f"#{red}{green}{blue}" if alpha.lower() == "ff" else f"#{red}{green}{blue}{alpha}"
285
+ rgb = re.fullmatch(r"Color\(0x([0-9a-fA-F]{6})\)", clean)
286
+ if rgb:
287
+ return f"#{rgb.group(1)}"
288
+ named = {"Black": "#000000", "White": "#ffffff", "Red": "#f44336", "Green": "#4caf50", "Blue": "#2196f3", "Transparent": "transparent"}
289
+ match = re.fullmatch(r"Color\.(\w+)", clean)
290
+ return named.get(match.group(1)) if match else None
291
+
292
+
293
+ def _compose_named_value(args: str, name: str) -> str | None:
294
+ match = re.search(rf"\b{re.escape(name)}\s*=\s*([^,\n]+)", args)
295
+ return match.group(1).strip() if match else None
296
+
297
+
298
+ def _set_compose(node: dict[str, Any], path: str, value: Any, context: SourceContext, line: int, expression: str, adapter: str, confidence: str = "exact") -> None:
299
+ group, key = path.split(".", 1)
300
+ node.setdefault(group, {})[key] = value
301
+ node.setdefault("provenance", {})[path] = property_evidence(context.source, line, expression, adapter, confidence)
302
+
303
+
304
+ class ComposeAdapter:
305
+ id = "compose"
306
+ platforms = ("android",)
307
+ extensions = (".kt", ".kts")
308
+ maturity = "structural"
309
+ structural_tier = "translated"
310
+ visual_tier = "none"
311
+ native_evidence_required = True
312
+ native_providers = ("android-compose-screenshot", "paparazzi", "roborazzi", "android-emulator")
313
+ resource_resolution = ("R.string", "R.drawable", "R.mipmap", "common Material Icons")
314
+ layout_features = ("size/fill/weight/aspectRatio", "directional padding", "Row/Column arrangement", "Box alignment", "basic typography")
315
+ limitations = ("supported Compose primitives and modifiers only", "custom composables remain explicit unsupported entries")
316
+
317
+ def __init__(self) -> None:
318
+ self._catalogs: dict[str, AndroidResourceCatalog] = {}
319
+
320
+ def prepare(self, contexts: list[SourceContext]) -> None:
321
+ roots = {str(context.root.resolve()): context.root for context in contexts if self.supports(context)}
322
+ self._catalogs = {key: AndroidResourceCatalog.discover(root) for key, root in roots.items()}
323
+
324
+ def _catalog(self, context: SourceContext) -> AndroidResourceCatalog:
325
+ key = str(context.root.resolve())
326
+ if key not in self._catalogs:
327
+ self._catalogs[key] = AndroidResourceCatalog.discover(context.root)
328
+ return self._catalogs[key]
329
+
330
+ def supports(self, context: SourceContext) -> bool:
331
+ return context.path.suffix.lower() in {".kt", ".kts"} and ("@Composable" in context.text or "androidx.compose" in context.text)
332
+
333
+ def translate(self, context: SourceContext) -> AdapterResult:
334
+ result = AdapterResult(adapter=self.id)
335
+ catalog = self._catalog(context)
336
+ platform = "android"
337
+ for match in re.finditer(r"(?:val|const\s+val)\s+(\w+)\s*=\s*(Color\([^\n]+?\)|[^\s]+\.(?:dp|sp))", context.text):
338
+ name, value = match.group(1), match.group(2).strip()
339
+ group = "colors" if value.startswith("Color") else "typography" if value.endswith(".sp") else "spacing"
340
+ normalized = re.sub(r"(\d+(?:\.\d+)?)\.(?:dp|sp)", r"\1px", value)
341
+ result.tokens[group][_slug(name).replace("-", "_")] = {"value": normalized, "source": {"file": context.source, "line": context.text.count("\n", 0, match.start()) + 1}, "adapter": self.id}
342
+ functions = list(re.finditer(r"@Composable[\s\S]*?fun\s+([A-Z]\w*)\s*\([^)]*\)\s*\{", context.text))
343
+ for function_index, function in enumerate(functions):
344
+ name = function.group(1)
345
+ start = function.end()
346
+ depth, end = 1, start
347
+ while end < len(context.text) and depth:
348
+ depth += (context.text[end] == "{") - (context.text[end] == "}")
349
+ end += 1
350
+ body = context.text[start:max(start, end - 1)]
351
+ screen_id = _slug(name)
352
+ root_id = f"{screen_id}-root"
353
+ result.nodes[root_id] = {"type": "container", "layout": {"direction": "column", "width": "fill", "height": "fill"}, "style": {}, "children": [], "source": {"file": context.source, "line": context.text.count("\n", 0, function.start()) + 1, "symbol": name}, "confidence": "high", "standardRef": f"project.{platform}.compose.screen", "provenance": {
354
+ "layout.direction": property_evidence(context.source, context.text.count("\n", 0, function.start()) + 1, f"@Composable fun {name}", self.id, "high"),
355
+ "layout.width": property_evidence(context.source, context.text.count("\n", 0, function.start()) + 1, f"@Composable fun {name}", self.id, "high"),
356
+ "layout.height": property_evidence(context.source, context.text.count("\n", 0, function.start()) + 1, f"@Composable fun {name}", self.id, "high"),
357
+ }}
358
+ calls = _compose_calls(body)
359
+ call_nodes: list[tuple[dict[str, Any], str]] = []
360
+ for count, call in enumerate(calls, start=1):
361
+ widget, args = call["widget"], call["args"]
362
+ node_id = f"{screen_id}-{_slug(widget)}-{count}"
363
+ line = context.text.count("\n", 0, start + call["start"]) + 1
364
+ native_prefix = "material3"
365
+ standard = f"{native_prefix}.{widget.lower()}" if widget in {"Scaffold", "Card", "Surface", "Text", "Button", "TextField", "OutlinedTextField", "Icon"} else f"project.{platform}.compose.{widget.lower()}"
366
+ node: dict[str, Any] = {"type": COMPOSE_TYPES[widget], "component": widget, "layout": {}, "style": {}, "children": [], "source": {"file": context.source, "line": line, "symbol": name}, "confidence": "high", "standardRef": standard, "provenance": {"component": property_evidence(context.source, line, widget, self.id, "exact")}}
367
+ if standard.startswith("material3."):
368
+ node["inheritsAppearance"] = True
369
+ literal = re.search(r"(?:text\s*=\s*)?\"([^\"]*)\"", args)
370
+ if literal and node["type"] in {"text", "button", "input"}:
371
+ node["text"] = literal.group(1)
372
+ node["provenance"]["text"] = property_evidence(context.source, line, literal.group(0), self.id, "exact")
373
+ string_resource = re.search(r"stringResource\s*\(\s*R\.string\.(\w+)\s*\)", args)
374
+ if string_resource and node["type"] in {"text", "button", "input"}:
375
+ resolved = catalog.resolve(f"@string/{string_resource.group(1)}", "string")
376
+ if resolved.resolved:
377
+ node["text"] = resolved.value
378
+ node["provenance"]["text"] = property_evidence(resolved.source, resolved.line, string_resource.group(0), self.id, resolved.confidence)
379
+ else:
380
+ result.unsupported.append({"adapter": self.id, "file": context.source, "line": line, "expression": string_resource.group(0), "reason": "unresolved-android-string"})
381
+ if widget in {"Column", "LazyColumn"}:
382
+ node["layout"]["direction"] = "column"
383
+ node["provenance"]["layout.direction"] = property_evidence(context.source, line, widget, self.id, "exact")
384
+ if widget in {"Row", "LazyRow"}:
385
+ node["layout"]["direction"] = "row"
386
+ node["provenance"]["layout.direction"] = property_evidence(context.source, line, widget, self.id, "exact")
387
+ if widget == "Box":
388
+ node["layout"]["direction"] = "overlay"
389
+ node["provenance"]["layout.direction"] = property_evidence(context.source, line, widget, self.id, "exact")
390
+ alignments = {
391
+ "Alignment.Start": "start", "Alignment.CenterHorizontally": "center", "Alignment.End": "end",
392
+ "Alignment.Top": "start", "Alignment.CenterVertically": "center", "Alignment.Bottom": "end",
393
+ }
394
+ arrangements = {
395
+ "Arrangement.Start": "start", "Arrangement.Top": "start", "Arrangement.End": "end", "Arrangement.Bottom": "end",
396
+ "Arrangement.Center": "center", "Arrangement.SpaceBetween": "between", "Arrangement.SpaceAround": "around", "Arrangement.SpaceEvenly": "evenly",
397
+ }
398
+ if widget in {"Column", "LazyColumn"}:
399
+ alignment = _compose_named_value(args, "horizontalAlignment")
400
+ arrangement = _compose_named_value(args, "verticalArrangement")
401
+ if alignment in alignments: _set_compose(node, "layout.align", alignments[alignment], context, line, f"horizontalAlignment = {alignment}", self.id)
402
+ if arrangement in arrangements: _set_compose(node, "layout.justify", arrangements[arrangement], context, line, f"verticalArrangement = {arrangement}", self.id)
403
+ if widget in {"Row", "LazyRow"}:
404
+ alignment = _compose_named_value(args, "verticalAlignment")
405
+ arrangement = _compose_named_value(args, "horizontalArrangement")
406
+ if alignment in alignments: _set_compose(node, "layout.align", alignments[alignment], context, line, f"verticalAlignment = {alignment}", self.id)
407
+ if arrangement in arrangements: _set_compose(node, "layout.justify", arrangements[arrangement], context, line, f"horizontalArrangement = {arrangement}", self.id)
408
+ if widget == "Box":
409
+ content_alignment = _compose_named_value(args, "contentAlignment")
410
+ if content_alignment and "Center" in content_alignment:
411
+ _set_compose(node, "layout.align", "center", context, line, f"contentAlignment = {content_alignment}", self.id)
412
+ _set_compose(node, "layout.justify", "center", context, line, f"contentAlignment = {content_alignment}", self.id)
413
+ for method, value in _compose_modifier_calls(args):
414
+ expression = f".{method}({value})"
415
+ if method in {"size", "width", "height", "requiredSize", "requiredWidth", "requiredHeight"}:
416
+ normalized = _compose_length(value.split(",", 1)[0], result.tokens)
417
+ keys = ("width", "height") if method in {"size", "requiredSize"} else ("width",) if "Width" in method or method == "width" else ("height",)
418
+ for key in keys: _set_compose(node, f"layout.{key}", normalized, context, line, expression, self.id)
419
+ elif method == "padding":
420
+ named = {name: _compose_named_value(value, name) for name in ("horizontal", "vertical", "start", "end", "top", "bottom")}
421
+ if any(named.values()):
422
+ key_map = {"horizontal": "paddingHorizontal", "vertical": "paddingVertical", "start": "paddingLeft", "end": "paddingRight", "top": "paddingTop", "bottom": "paddingBottom"}
423
+ for side_name, raw in named.items():
424
+ if raw: _set_compose(node, f"layout.{key_map[side_name]}", _compose_length(raw, result.tokens), context, line, expression, self.id)
425
+ else:
426
+ _set_compose(node, "layout.padding", _compose_length(value.split(",", 1)[0], result.tokens), context, line, expression, self.id)
427
+ elif method in {"fillMaxWidth", "fillMaxHeight", "fillMaxSize"}:
428
+ keys = ("width", "height") if method == "fillMaxSize" else ("width",) if method == "fillMaxWidth" else ("height",)
429
+ for key in keys: _set_compose(node, f"layout.{key}", "fill", context, line, expression, self.id)
430
+ elif method == "weight":
431
+ _set_compose(node, "layout.grow", _compose_length(value.split(",", 1)[0], result.tokens), context, line, expression, self.id)
432
+ elif method == "aspectRatio":
433
+ _set_compose(node, "layout.aspectRatio", _compose_length(value.split(",", 1)[0], result.tokens), context, line, expression, self.id)
434
+ elif method in {"verticalScroll", "horizontalScroll"}:
435
+ _set_compose(node, "layout.overflowY" if method == "verticalScroll" else "layout.overflowX", "auto", context, line, expression, self.id)
436
+ elif method == "align":
437
+ target = next((mapped for source, mapped in alignments.items() if source in value), None)
438
+ if target: _set_compose(node, "layout.alignSelf", target, context, line, expression, self.id)
439
+ elif method == "background":
440
+ color = _compose_color(value.split(",", 1)[0])
441
+ if color: _set_compose(node, "style.backgroundColor", color, context, line, expression, self.id)
442
+ elif method == "clip":
443
+ radius = re.search(r"RoundedCornerShape\s*\(\s*([^)]+)\)", value)
444
+ if radius: _set_compose(node, "style.radius", _compose_length(radius.group(1), result.tokens), context, line, expression, self.id)
445
+ font_size = _compose_named_value(args, "fontSize")
446
+ if font_size: _set_compose(node, "style.fontSize", _compose_length(font_size, result.tokens, "typography"), context, line, f"fontSize = {font_size}", self.id)
447
+ line_height = _compose_named_value(args, "lineHeight")
448
+ if line_height: _set_compose(node, "style.lineHeight", _compose_length(line_height, result.tokens, "typography"), context, line, f"lineHeight = {line_height}", self.id)
449
+ letter_spacing = _compose_named_value(args, "letterSpacing")
450
+ if letter_spacing: _set_compose(node, "style.letterSpacing", _compose_length(letter_spacing, result.tokens, "typography"), context, line, f"letterSpacing = {letter_spacing}", self.id)
451
+ font_weight = _compose_named_value(args, "fontWeight")
452
+ weight_map = {"FontWeight.Normal": 400, "FontWeight.Medium": 500, "FontWeight.SemiBold": 600, "FontWeight.Bold": 700, "FontWeight.ExtraBold": 800}
453
+ if font_weight in weight_map: _set_compose(node, "style.fontWeight", weight_map[font_weight], context, line, f"fontWeight = {font_weight}", self.id)
454
+ color_value = _compose_named_value(args, "color")
455
+ color = _compose_color(color_value) if color_value else None
456
+ if color: _set_compose(node, "style.color", color, context, line, f"color = {color_value}", self.id)
457
+ text_align = _compose_named_value(args, "textAlign")
458
+ if text_align:
459
+ _set_compose(node, "style.textAlign", text_align.rsplit(".", 1)[-1].lower(), context, line, f"textAlign = {text_align}", self.id)
460
+ if widget == "Icon":
461
+ icon = re.search(r"Icons\.(?:Default|Filled|Outlined|Rounded|Sharp|TwoTone)\.(\w+)", args)
462
+ if icon:
463
+ node["iconName"] = icon.group(1)
464
+ asset = material_icon_asset(icon.group(1))
465
+ if asset:
466
+ node["asset"] = asset
467
+ node["provenance"]["asset"] = property_evidence(context.source, line, icon.group(0), self.id, "high")
468
+ else:
469
+ result.unsupported.append({"adapter": self.id, "file": context.source, "line": line, "expression": icon.group(0), "reason": "unsupported-material-icon"})
470
+ painter = re.search(r"painterResource\s*\(\s*(?:id\s*=\s*)?R\.(drawable|mipmap)\.(\w+)\s*\)", args)
471
+ if painter:
472
+ drawable = catalog.drawable(f"@{painter.group(1)}/{painter.group(2)}")
473
+ if drawable.asset:
474
+ node["asset"] = drawable.asset
475
+ node["provenance"]["asset"] = property_evidence(drawable.source, drawable.line, painter.group(0), self.id, drawable.confidence)
476
+ else:
477
+ result.unsupported.append({"adapter": self.id, "file": context.source, "line": line, "expression": painter.group(0), "reason": "unsupported-android-drawable"})
478
+ description = _compose_named_value(args, "contentDescription")
479
+ if description and description != "null":
480
+ node["alt"] = description.strip('"')
481
+ node["semantics"] = {"label": node["alt"], "role": "image" if node["type"] in {"image", "icon"} else "button"}
482
+ result.nodes[node_id] = node
483
+ call_nodes.append((call, node_id))
484
+ for call, node_id in call_nodes:
485
+ parents = [
486
+ (candidate, candidate_id) for candidate, candidate_id in call_nodes
487
+ if candidate["blockStart"] is not None and candidate["blockEnd"] is not None
488
+ and candidate["blockStart"] < call["start"] < candidate["blockEnd"]
489
+ ]
490
+ parent_id = max(parents, key=lambda item: item[0]["blockStart"])[1] if parents else root_id
491
+ result.nodes[parent_id]["children"].append(node_id)
492
+ if result.nodes[parent_id].get("layout", {}).get("direction") == "overlay":
493
+ result.nodes[node_id].setdefault("layout", {})["gridArea"] = "1 / 1"
494
+ supported_names = set(COMPOSE_TYPES) | {"Modifier", "Color", "RoundedCornerShape"}
495
+ for unknown in re.finditer(r"\b([A-Z]\w*)\s*\(", body):
496
+ if unknown.group(1) in supported_names:
497
+ continue
498
+ result.unsupported.append({"adapter": self.id, "file": context.source, "line": context.text.count("\n", 0, start + unknown.start()) + 1, "expression": unknown.group(1), "reason": "unsupported-compose-call"})
499
+ result.screens.append({"id": screen_id, "name": name, "root": root_id, "route": "", "platform": platform, "source": {"file": context.source, "line": context.text.count("\n", 0, function.start()) + 1, "symbol": name}, "confidence": "high"})
500
+ return result
501
+
502
+
503
+ register_adapter(WebAdapter())
504
+ register_adapter(ComposeAdapter())
505
+ from fidelity_platform_adapters import builtin_platform_adapters
506
+
507
+ for _adapter in builtin_platform_adapters(WebAdapter()):
508
+ register_adapter(_adapter)