ui-design-workbench-cli 0.6.7__tar.gz → 0.6.9__tar.gz

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 (67) hide show
  1. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/PKG-INFO +2 -2
  2. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/README.md +1 -1
  3. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/pyproject.toml +9 -1
  4. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/references/fidelity.md +12 -0
  5. ui_design_workbench_cli-0.6.9/scripts/compose_instances.py +165 -0
  6. ui_design_workbench_cli-0.6.9/scripts/compose_resources.py +242 -0
  7. ui_design_workbench_cli-0.6.9/scripts/compose_syntax.py +36 -0
  8. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/fidelity_adapter_api.py +11 -1
  9. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/fidelity_adapters.py +225 -45
  10. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/fidelity_platform_adapters.py +323 -59
  11. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/layout_model.py +11 -2
  12. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/platform_component_catalog.py +23 -0
  13. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/render_preview.py +7 -5
  14. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/scan_ui.py +1 -0
  15. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/smoke_preview.js +38 -12
  16. ui_design_workbench_cli-0.6.9/scripts/source_syntax.py +118 -0
  17. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/ui_design_workbench_cli.egg-info/PKG-INFO +2 -2
  18. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/ui_design_workbench_cli.egg-info/SOURCES.txt +4 -0
  19. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/ui_design_workbench_cli.egg-info/top_level.txt +4 -0
  20. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/uidw.py +6 -2
  21. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/SKILL.md +0 -0
  22. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/references/adapter-api.md +0 -0
  23. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/references/agent-integrations.md +0 -0
  24. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/references/cache-protocol.md +0 -0
  25. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/references/component-catalog.json +0 -0
  26. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/references/component-catalog.md +0 -0
  27. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/references/component-inventory.json +0 -0
  28. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/references/design-modes.md +0 -0
  29. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/references/ir-schema.md +0 -0
  30. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/references/native-rendering.md +0 -0
  31. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/references/platform-profiles.json +0 -0
  32. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/references/platform-standards.md +0 -0
  33. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/references/platforms.md +0 -0
  34. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/references/quality-automation.md +0 -0
  35. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/references/review-workflow.md +0 -0
  36. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/references/ui-guidance-mode.md +0 -0
  37. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/references/ui-policy-schema.md +0 -0
  38. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/references/ui-reviewer.md +0 -0
  39. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/references/validation.md +0 -0
  40. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/references/workbench-ui.md +0 -0
  41. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/schemas/native-render-state.schema.json +0 -0
  42. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/schemas/ui-agent-job.schema.json +0 -0
  43. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/schemas/ui-graph.schema.json +0 -0
  44. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/schemas/ui-ir.patch.schema.json +0 -0
  45. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/schemas/ui-ir.schema.json +0 -0
  46. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/schemas/uidw-config.schema.json +0 -0
  47. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/android_resource_resolver.py +0 -0
  48. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/android_xml_support.py +0 -0
  49. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/apple_resource_resolver.py +0 -0
  50. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/coverage_report.py +0 -0
  51. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/fidelity_core.py +0 -0
  52. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/generate_interaction_matrix.py +0 -0
  53. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/ir_contracts.py +0 -0
  54. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/merge_review_state.py +0 -0
  55. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/native_render_android.py +0 -0
  56. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/native_render_apple.py +0 -0
  57. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/native_render_contracts.py +0 -0
  58. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/native_render_registry.py +0 -0
  59. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/quality_common.py +0 -0
  60. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/scoped_context.py +0 -0
  61. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/ui_design_workbench_cli.egg-info/dependency_links.txt +0 -0
  62. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/ui_design_workbench_cli.egg-info/entry_points.txt +0 -0
  63. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/ui_design_workbench_cli.egg-info/requires.txt +0 -0
  64. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/uidw_mcp.py +0 -0
  65. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/validate_platform_profiles.py +0 -0
  66. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/scripts/visual_regression.py +0 -0
  67. {ui_design_workbench_cli-0.6.7 → ui_design_workbench_cli-0.6.9}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ui-design-workbench-cli
3
- Version: 0.6.7
3
+ Version: 0.6.9
4
4
  Summary: Repository UI indexing, graph, preview, and deterministic review CLI
5
5
  Author: Elgreed
6
6
  Project-URL: Homepage, https://github.com/Elgreed/ui-design-workbench
@@ -131,4 +131,4 @@ A Windows `.exe` is not published yet. PyPI + `pipx` is the primary cross-platfo
131
131
  - [IR schema](references/ir-schema.md)
132
132
  - [Review workflow](references/review-workflow.md)
133
133
 
134
- Current CLI version: `0.6.7`.
134
+ Current CLI version: `0.6.9`.
@@ -112,4 +112,4 @@ A Windows `.exe` is not published yet. PyPI + `pipx` is the primary cross-platfo
112
112
  - [IR schema](references/ir-schema.md)
113
113
  - [Review workflow](references/review-workflow.md)
114
114
 
115
- Current CLI version: `0.6.7`.
115
+ Current CLI version: `0.6.9`.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ui-design-workbench-cli"
7
- version = "0.6.7"
7
+ version = "0.6.9"
8
8
  description = "Repository UI indexing, graph, preview, and deterministic review CLI"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -43,6 +43,10 @@ py-modules = [
43
43
  "fidelity_core",
44
44
  "fidelity_adapter_api",
45
45
  "fidelity_adapters",
46
+ "source_syntax",
47
+ "compose_syntax",
48
+ "compose_resources",
49
+ "compose_instances",
46
50
  "fidelity_platform_adapters",
47
51
  "android_resource_resolver",
48
52
  "apple_resource_resolver",
@@ -68,6 +72,10 @@ py-modules = [
68
72
  "scripts/coverage_report.py",
69
73
  "scripts/fidelity_adapter_api.py",
70
74
  "scripts/fidelity_adapters.py",
75
+ "scripts/source_syntax.py",
76
+ "scripts/compose_syntax.py",
77
+ "scripts/compose_resources.py",
78
+ "scripts/compose_instances.py",
71
79
  "scripts/fidelity_core.py",
72
80
  "scripts/fidelity_platform_adapters.py",
73
81
  "scripts/generate_interaction_matrix.py",
@@ -10,6 +10,18 @@ Use `uidw fidelity capabilities` for the installed support matrix, `uidw fidelit
10
10
 
11
11
  The HTML projection resolves recognized controls through the official-doc-first [platform component catalog](component-catalog.md). Catalog geometry is a render-only fallback: explicit source layout, theme, token, and style evidence always wins, and the source IR is not mutated.
12
12
 
13
+ Compose translation follows unique local composable declarations, literal parameter defaults and constructor preview fixtures. Simple imported enum `entries.forEach` loops and literal resource getters can supply repeated content. Expansion is limited to six nested components and 2,000 calls per translated source; ambiguity, recursion and exhausted budgets remain explicit gaps. Runtime Kotlin, arbitrary collection transformations, slot forwarding, custom drawing and arbitrary theme providers are not evaluated.
14
+
15
+ Literal project theme values and local font assets are resolved from source. The box model marks automatic text measurement as partial and lets the browser wrap and measure the loaded font, rather than treating average character widths as solved geometry. Browser line boxes and shadows can still differ from native rendering. A partial context does not establish native visual parity.
16
+
17
+ For a user-requested native comparison, use the same authored preview state, locale, font scale and logical viewport. Capture metadata can provide `viewport.safeAreaInsets` in logical pixels for Compose system-bar padding. `smoke_preview.js --capture-device --capture-screen ID --capture-theme light --screenshot screen.png --geometry-output geometry.json` captures the device content after fonts load, excluding workbench chrome. Keep the native screenshot, device density, source revision and comparison results with the diagnostic artifact; system-bar icons themselves are outside the source projection.
18
+
19
+ Flutter and SwiftUI use the same balanced source delimiter and literal masking utilities as Compose. Visual parameters are scoped to the current call, excluding child widgets and event handlers. SwiftUI expands unique local View declarations with literal member arguments and represents order-dependent frame/padding/background operations as separate source-backed nodes. Flutter resolves literal constructor defaults, distinguishes TextStyle height factors from widget heights, and propagates SizedBox tight constraints to its child. Expansion is capped at six component levels and 2,000 widget calls. Arbitrary control flow, unavailable SF Symbols and recursive expansion remain gaps.
20
+
21
+ XAML translates four-value Thickness in left/top/right/bottom order, alpha-first colors, explicit grid tracks/cell spans and literal states. WPF Border and TextBlock defaults are distinct from generic card recipes. Resource bindings, control templates, nonliteral grid tracks and more complex platform layout behavior still require separate handling. Web handles the supported simple selectors in CSS cascade order, including importance and linked stylesheet order; unsupported at-rules are reported rather than applied unconditionally.
22
+
23
+ The source fixtures in `fixtures/golden/platform-fidelity` exercise these shared failure patterns. A Flutter engine golden, a WPF RenderTargetBitmap and an original Web page can provide independent runtime evidence for their respective fixture. WPF evidence does not verify WinUI, and Flutter test-engine evidence does not verify every device configuration. SwiftUI iOS/macOS fixture assertions are source-only unless matching Apple runtime captures are supplied. Successful source tests or a small native comparison must never promote unrelated screens or platforms to verified visual parity.
24
+
13
25
  Use this contract before creating reviewable IR. The goal is not a visually pleasant approximation; it is a traceable projection of the authored UI.
14
26
 
15
27
  ## Evidence bundle per screen
@@ -0,0 +1,165 @@
1
+ """Static argument substitution for bounded local Compose component instances."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import os
7
+
8
+ from compose_syntax import arguments, closing, mask_literals, named_argument
9
+
10
+
11
+ def substitute(expression: str, bindings: dict[str, str]) -> str:
12
+ masked = mask_literals(expression)
13
+ edits = []
14
+ for match in re.finditer(r"\b[A-Za-z_]\w*\b", masked):
15
+ if match.start() > 0 and masked[match.start() - 1] == ".":
16
+ continue
17
+ # Named argument keys and lambda parameter declarations are not values.
18
+ tail = masked[match.end():]
19
+ if re.match(r"\s*=(?!=)", tail) or re.match(r"\s*->", tail):
20
+ continue
21
+ value = bindings.get(match.group())
22
+ if value is not None and value != match.group():
23
+ edits.append((match.start(), match.end(), value))
24
+ for start, end, value in reversed(edits):
25
+ expression = expression[:start] + value + expression[end:]
26
+ return expression
27
+
28
+
29
+ def static_value(expression: str, bindings: dict[str, str]) -> str:
30
+ expression = substitute(expression, bindings).strip()
31
+ # Read authored constructor fixtures such as ScreenState(selected = Choice.ONE).selected.
32
+ for match in reversed(list(re.finditer(r"\b[A-Z]\w*\s*\(", mask_literals(expression)))):
33
+ start = expression.find("(", match.start())
34
+ end = closing(expression, start)
35
+ member = re.match(r"\.(\w+)", expression[end + 1:]) if end is not None else None
36
+ if member:
37
+ value = named_argument(expression[start + 1:end], member.group(1))
38
+ if value is not None:
39
+ expression = expression[:match.start()] + value + expression[end + 1 + member.end():]
40
+ equality = re.fullmatch(r"([A-Z]\w*\.\w+|true|false|null)\s*==\s*([A-Z]\w*\.\w+|true|false|null)", expression)
41
+ if equality:
42
+ return "true" if equality.group(1) == equality.group(2) else "false"
43
+ condition = re.match(r"if\s*\(\s*([^()]+)\s*\)\s*", expression)
44
+ if condition:
45
+ test = condition.group(1).strip()
46
+ equality = re.fullmatch(r"([A-Z]\w*\.\w+|true|false)\s*==\s*([A-Z]\w*\.\w+|true|false)", test)
47
+ selected = test == "true" if test in {"true", "false"} else equality.group(1) == equality.group(2) if equality else None
48
+ if selected is not None:
49
+ branch_start = condition.end()
50
+ if expression[branch_start:branch_start + 1] == "{":
51
+ branch_end = closing(expression, branch_start)
52
+ if branch_end is not None:
53
+ alternative = re.match(r"\s*else\s*", expression[branch_end + 1:])
54
+ if alternative:
55
+ second = expression[branch_end + 1 + alternative.end():].strip()
56
+ return (expression[branch_start + 1:branch_end] if selected else second.removeprefix("{").removesuffix("}")).strip()
57
+ inline = re.fullmatch(r"([^\n]+?)\s+else\s+([^\n]+)", expression[branch_start:])
58
+ if inline:
59
+ return inline.group(1 if selected else 2).strip()
60
+ choice = re.match(r"when\s*\(\s*([A-Z]\w*\.\w+)\s*\)\s*\{", expression)
61
+ if choice:
62
+ branches = list(re.finditer(r"(?:^|\n)\s*([A-Z]\w*\.\w+|else)\s*->\s*", expression))
63
+ for index, branch in enumerate(branches):
64
+ if branch.group(1) in {choice.group(1), "else"}:
65
+ end = branches[index + 1].start() if index + 1 < len(branches) else expression.rfind("}")
66
+ return expression[branch.end():end].strip()
67
+ return expression
68
+
69
+
70
+ def bind_parameters(parameters: str, args: str) -> dict[str, str]:
71
+ result = {}
72
+ names = []
73
+ for parameter in arguments(parameters):
74
+ match = re.match(r"(\w+)\s*:", parameter.strip())
75
+ if not match:
76
+ continue
77
+ name = match.group(1)
78
+ names.append(name)
79
+ default = re.search(r"(?<![=!])=(?!=)", parameter)
80
+ if default:
81
+ result[name] = parameter[default.end():].strip()
82
+ position = 0
83
+ for argument in arguments(args):
84
+ named = re.match(r"(\w+)\s*=\s*([\s\S]+)", argument)
85
+ if named:
86
+ if named.group(1) in names:
87
+ result[named.group(1)] = named.group(2)
88
+ elif position < len(names):
89
+ result[names[position]] = argument
90
+ position += 1
91
+ return result
92
+
93
+
94
+ class StaticImports:
95
+ def __init__(self):
96
+ self.cache = {}
97
+ self.paths = {}
98
+
99
+ def source_paths(self, root):
100
+ if root not in self.paths:
101
+ paths = []
102
+ for directory, subdirs, files in os.walk(root):
103
+ subdirs[:] = [name for name in subdirs if name not in {
104
+ "build", "node_modules", ".git", ".gradle", ".idea", ".codegraph", ".ui-design-workbench"}]
105
+ for name in files:
106
+ if name.endswith(".kt"):
107
+ path = root / os.path.relpath(os.path.join(directory, name), root)
108
+ if "/src/" in path.as_posix():
109
+ paths.append(path)
110
+ self.paths[root] = paths
111
+ return self.paths[root]
112
+
113
+ def sources(self, name, context):
114
+ key = (str(context.root), context.source, name)
115
+ if key not in self.cache:
116
+ imports = re.findall(rf"^import\s+([\w.]+\.{re.escape(name)})\s*$", context.text, re.M)
117
+ paths = []
118
+ for imported in imports:
119
+ package = imported.rsplit(".", 1)[0].replace(".", "/")
120
+ paths.extend(path for path in self.source_paths(context.root)
121
+ if any(path.parent.as_posix().endswith(f"/{language}/{package}") for language in ("java", "kotlin")))
122
+ self.cache[key] = [(path, path.read_text(encoding="utf-8")) for path in sorted(set(paths)) if path.is_file()]
123
+ return self.cache[key]
124
+
125
+ def enum_entries(self, name, context):
126
+ matches = []
127
+ for path, text in self.sources(name, context):
128
+ match = re.search(rf"\benum\s+class\s+{re.escape(name)}\s*\{{([^{{}};]*)}}", mask_literals(text))
129
+ if match:
130
+ entries = [item.strip() for item in match.group(1).split(",") if item.strip()]
131
+ if all(re.fullmatch(r"[A-Z]\w*", item) for item in entries) and len(entries) <= 32:
132
+ matches.append([f"{name}.{item}" for item in entries])
133
+ return matches[0] if len(matches) == 1 else []
134
+
135
+ def resolve_properties(self, expression, context):
136
+ for match in reversed(list(re.finditer(r"\b([A-Z]\w*\.\w+)\.(\w+)\b", expression))):
137
+ values = []
138
+ for path, text in self.sources(match.group(2), context):
139
+ owner = match.group(1).split(".")[0]
140
+ declaration = re.search(rf"\bval\s+{owner}\.{match.group(2)}\s*:\s*\w+\s+get\(\)\s*=\s*when\s*\(this\)\s*\{{", text)
141
+ if declaration:
142
+ end = closing(text, text.find("{", declaration.start()))
143
+ body = text[declaration.end():end] if end is not None else ""
144
+ branch = re.search(rf"\b{re.escape(match.group(1))}\s*->\s*(R\.string\.\w+)\b", body)
145
+ if branch:
146
+ values.append(branch.group(1))
147
+ if len(values) == 1:
148
+ expression = expression[:match.start()] + values[0] + expression[match.end():]
149
+ return expression
150
+
151
+ def expand_calls(self, calls, body, context):
152
+ result = calls
153
+ for loop in re.finditer(r"\b(\w+)\.entries\.forEach\s*\{\s*(\w+)\s*->", mask_literals(body)):
154
+ end = closing(body, body.find("{", loop.start()))
155
+ entries = self.enum_entries(loop.group(1), context)
156
+ if end is None or not entries:
157
+ continue
158
+ enclosed = [call for call in result if loop.end() <= call["start"] < end]
159
+ if enclosed:
160
+ position = result.index(enclosed[0])
161
+ expanded = [{**call, "loopBindings": {**call.get("loopBindings", {}), loop.group(2): entry}, "loopInstance": entry}
162
+ for entry in entries for call in enclosed]
163
+ result = [call for call in result if call not in enclosed]
164
+ result[position:position] = expanded
165
+ return result
@@ -0,0 +1,242 @@
1
+ """Resolve literal Compose theme values and local font assets without executing Kotlin."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections import defaultdict
7
+
8
+ from compose_syntax import arguments, closing, mask_literals, named_argument
9
+ from fidelity_core import property_evidence
10
+
11
+
12
+ WEIGHTS = {"Thin": 100, "ExtraLight": 200, "Light": 300, "Normal": 400, "Medium": 500,
13
+ "SemiBold": 600, "Bold": 700, "ExtraBold": 800, "Black": 900}
14
+
15
+
16
+ def expression_at(text: str, start: int) -> str:
17
+ masked = mask_literals(text)
18
+ depth = 0
19
+ for index in range(start, len(text)):
20
+ char = masked[index]
21
+ if char in "({[":
22
+ depth += 1
23
+ elif char in ")}]":
24
+ if depth == 0:
25
+ return text[start:index].strip()
26
+ depth -= 1
27
+ elif char in "\n;," and depth == 0:
28
+ return text[start:index].strip()
29
+ return text[start:].strip()
30
+
31
+
32
+ class ComposeResources:
33
+ def __init__(self, contexts):
34
+ self.definitions = defaultdict(list)
35
+ self.aliases = {}
36
+ self.fonts = []
37
+ self.bindings = defaultdict(set)
38
+ self._resolved = {}
39
+ self.contexts = [context for context in contexts if context.path.suffix == ".kt" and
40
+ (context.role == "theme" or "/theme/" in context.source)]
41
+ for context in self.contexts:
42
+ text = context.text
43
+ for match in re.finditer(r"\bval\s+(\w+)\s*(?::\s*[\w<>?.]+)?\s*=\s*", mask_literals(text)):
44
+ self.definitions[match.group(1)].append((expression_at(text, match.end()), context, match.start()))
45
+ for function in re.finditer(r"\bfun\s+(\w+)\s*\(\s*\)\s*:\s*Brush\s*\{", mask_literals(text)):
46
+ end = closing(text, text.find("{", function.start()))
47
+ body = text[function.end():end] if end is not None else ""
48
+ gradients = list(re.finditer(r"Brush\.verticalGradient\s*\(", mask_literals(body)))
49
+ if len(gradients) == 1:
50
+ start = gradients[0].start()
51
+ gradient_end = closing(body, body.find("(", start))
52
+ if gradient_end is not None:
53
+ from compose_instances import substitute
54
+ bindings = {match.group(1): expression_at(body, match.end()) for match in
55
+ re.finditer(r"\bval\s+(\w+)\s*=\s*", mask_literals(body[:start]))}
56
+ gradient = substitute(body[start:gradient_end + 1], bindings)
57
+ self.definitions[function.group(1) + "()"].append((gradient, context, function.start()))
58
+ for match in re.finditer(r"\bval\s+Typography\.(\w+)\s*:\s*TextStyle\s+get\(\)\s*=\s*(\w+)", text):
59
+ self.aliases[f"MaterialTheme.typography.{match.group(1)}"] = match.group(2)
60
+ for match in re.finditer(r"\bobject\s+(\w+)\s*\{", mask_literals(text)):
61
+ end = closing(text, text.find("{", match.start()))
62
+ body = text[match.end():end] if end is not None else ""
63
+ for prop in re.finditer(r"\bval\s+(\w+)\s*:\s*\w+[\s\S]*?get\(\)\s*=\s*(\w+)\.current", body):
64
+ self.aliases[f"{match.group(1)}.{prop.group(1)}"] = prop.group(2)
65
+ for match in re.finditer(r"\bMaterialTheme\s*\(", mask_literals(text)):
66
+ start = text.find("(", match.start())
67
+ end = closing(text, start)
68
+ if end is None:
69
+ continue
70
+ for name in ("typography", "colorScheme", "shapes"):
71
+ value = named_argument(text[start + 1:end], name)
72
+ if value:
73
+ self.bindings[name].add(value)
74
+ for name, values in self.bindings.items():
75
+ if len(values) == 1:
76
+ self.aliases[f"MaterialTheme.{name}"] = next(iter(values))
77
+ for name, definitions in self.definitions.items():
78
+ if len(definitions) != 1 or not definitions[0][0].startswith("FontFamily("):
79
+ continue
80
+ expression, context, offset = definitions[0]
81
+ for match in re.finditer(r"\bFont\s*\(", expression):
82
+ start = expression.find("(", match.start())
83
+ end = closing(expression, start)
84
+ if end is None:
85
+ continue
86
+ args = expression[start + 1:end]
87
+ resource = re.search(r"R\.font\.(\w+)", args)
88
+ if resource is None:
89
+ continue
90
+ assets = [path for path in context.root.glob(f"**/src/main/res/font/{resource.group(1)}.*")
91
+ if path.suffix.lower() in {".ttf", ".otf", ".woff", ".woff2"}]
92
+ if len(assets) != 1:
93
+ continue
94
+ weight = (named_argument(args, "weight") or "FontWeight.Normal").split(".")[-1]
95
+ self.fonts.append({"family": name, "asset": assets[0].relative_to(context.root).as_posix(),
96
+ "weight": WEIGHTS.get(weight, 400), "style": "normal"})
97
+
98
+ def resolve(self, expression, dark=False, seen=None):
99
+ if not isinstance(expression, str) or seen is not None:
100
+ return self._resolve(expression, dark, seen)
101
+ key = (expression.strip(), dark)
102
+ if key not in self._resolved:
103
+ self._resolved[key] = self._resolve(expression, dark, set())
104
+ return self._resolved[key]
105
+
106
+ def _resolve(self, expression, dark=False, seen=None):
107
+ if not isinstance(expression, str):
108
+ return expression
109
+ expression = expression.strip()
110
+ from compose_instances import static_value
111
+ selected = static_value(expression, {})
112
+ if selected != expression:
113
+ return self.resolve(selected, dark, seen)
114
+ seen = set(seen or ())
115
+ if expression in seen or len(seen) > 24:
116
+ return None
117
+ seen.add(expression)
118
+ dimension = re.fullmatch(r"(-?\d+(?:\.\d+)?)\.(?:dp|sp)", expression)
119
+ if dimension:
120
+ return f"{dimension.group(1)}px"
121
+ if expression.startswith("FontWeight."):
122
+ return WEIGHTS.get(expression.split(".")[-1])
123
+ color = re.fullmatch(r"Color\(0x([\da-fA-F]{8})\)", expression)
124
+ if color:
125
+ value = color.group(1).lower()
126
+ return "#" + value[2:] + (value[:2] if value[:2] != "ff" else "")
127
+ if expression in {"Color.White", "Color.Black", "Color.Transparent"}:
128
+ return {"Color.White": "#ffffff", "Color.Black": "#000000", "Color.Transparent": "transparent"}[expression]
129
+ conditional = re.fullmatch(r"if\s*\(\s*darkTheme\s*\)\s*(\w+)\s+else\s+(\w+)", expression)
130
+ if conditional:
131
+ return self.resolve(conditional.group(1 if dark else 2), dark, seen)
132
+ for alias in sorted(self.aliases, key=len, reverse=True):
133
+ if expression == alias or expression.startswith(alias + "."):
134
+ value = self.resolve(self.aliases[alias], dark, seen)
135
+ for part in expression[len(alias):].strip(".").split(".") if expression != alias else []:
136
+ value = value.get(part) if isinstance(value, dict) else None
137
+ return value
138
+ if expression in self.definitions:
139
+ definitions = self.definitions[expression]
140
+ if len(definitions) != 1:
141
+ return None
142
+ value = definitions[0][0]
143
+ if value.startswith("FontFamily("):
144
+ return expression if any(font["family"] == expression for font in self.fonts) else None
145
+ local = re.fullmatch(r"staticCompositionLocalOf\s*\{\s*(\w+)\s*}", value, re.S)
146
+ return self.resolve(local.group(1) if local else value, dark, seen)
147
+ if re.fullmatch(r"\w+(?:\.\w+)+", expression):
148
+ parts = expression.split(".")
149
+ value = self.resolve(parts[0], dark, seen)
150
+ for part in parts[1:]:
151
+ value = value.get(part) if isinstance(value, dict) else None
152
+ return value
153
+ call = re.match(r"\w+\s*\(", expression)
154
+ if expression.startswith("RoundedCornerShape("):
155
+ values = arguments(expression[len("RoundedCornerShape("):-1])
156
+ if len(values) == 1 and "=" not in values[0]:
157
+ return self.resolve(values[0], dark, seen)
158
+ if expression.startswith("Brush.verticalGradient("):
159
+ args = expression[len("Brush.verticalGradient("):-1]
160
+ colors = named_argument(args, "colors")
161
+ if colors and colors.startswith("listOf("):
162
+ values = [self.resolve(value, dark, seen) for value in arguments(colors[7:-1])]
163
+ if len(values) >= 2 and all(isinstance(value, str) and value.startswith("#") for value in values):
164
+ return "linear-gradient(to bottom, " + ", ".join(values) + ")"
165
+ if call and closing(expression, expression.find("(")) == len(expression) - 1:
166
+ values = {}
167
+ for argument in arguments(expression[expression.find("(") + 1:-1]):
168
+ field = re.match(r"(\w+)\s*=\s*([\s\S]+)", argument)
169
+ if field:
170
+ value = self.resolve(field.group(2), dark, seen)
171
+ if value is not None:
172
+ values[field.group(1)] = value
173
+ return values or None
174
+ return None
175
+
176
+ def resolve_local(self, expression, context, dark=False):
177
+ value = self.resolve(expression, dark)
178
+ if value is not None or not re.fullmatch(r"\w+", expression.strip()):
179
+ return value
180
+ definitions = list(re.finditer(rf"\bval\s+{re.escape(expression.strip())}\s*=\s*", mask_literals(context.text)))
181
+ if len(definitions) == 1:
182
+ return self.resolve(expression_at(context.text, definitions[0].end()), dark)
183
+ return None
184
+
185
+ def apply(self, result, context):
186
+ from fidelity_adapters import _compose_named_value
187
+ for node in result.nodes.values():
188
+ for group in ("layout", "style"):
189
+ for key, expression in list(node.get(group, {}).items()):
190
+ resolved = self.resolve(expression)
191
+ if resolved is not None and not isinstance(resolved, dict):
192
+ node[group][key] = resolved
193
+ # Node call arguments are kept local to this translation pass.
194
+ args = node.pop("_composeArguments", "")
195
+ style_expression = _compose_named_value(args, "style")
196
+ typography = self.resolve(style_expression)
197
+ if isinstance(typography, dict):
198
+ for key in ("fontSize", "fontFamily", "fontWeight", "lineHeight", "letterSpacing"):
199
+ if key in typography and key not in node["style"]:
200
+ node["style"][key] = typography[key]
201
+ node["provenance"][f"style.{key}"] = property_evidence(context.source, node["source"]["line"], style_expression, "compose", "high")
202
+ color_expression = _compose_named_value(args, "color")
203
+ color = self.resolve(color_expression)
204
+ if isinstance(color, str) and color.startswith("#"):
205
+ key = "backgroundColor" if node.get("component") == "Surface" else "color"
206
+ node["style"][key] = color
207
+ node["provenance"][f"style.{key}"] = property_evidence(context.source, node["source"]["line"], color_expression, "compose", "high")
208
+ dark_color = self.resolve(color_expression, dark=True)
209
+ if isinstance(dark_color, str) and dark_color != color:
210
+ node.setdefault("_composeDarkStyle", {})[key] = dark_color
211
+ def assign(group, key, value, expression):
212
+ if value is not None:
213
+ node[group][key] = value
214
+ node["provenance"][f"{group}.{key}"] = property_evidence(context.source, node["source"]["line"], expression, "compose", "high")
215
+ dark_value = self.resolve(expression, dark=True)
216
+ if isinstance(dark_value, dict):
217
+ dark_value = dark_value.get({"borderColor": "color", "borderWidth": "width"}.get(key, key))
218
+ if group == "style" and isinstance(dark_value, (str, int, float)) and dark_value != value:
219
+ node.setdefault("_composeDarkStyle", {})[key] = dark_value
220
+ shape = _compose_named_value(args, "shape")
221
+ radius = self.resolve(shape)
222
+ if isinstance(radius, str):
223
+ assign("style", "radius", radius, shape)
224
+ border_expression = _compose_named_value(args, "border")
225
+ border = self.resolve(border_expression)
226
+ if isinstance(border, dict):
227
+ assign("style", "borderWidth", border.get("width"), border_expression)
228
+ assign("style", "borderColor", border.get("color"), border_expression)
229
+ assign("style", "borderStyle", "solid", border_expression)
230
+ padding_expression = _compose_named_value(args, "contentPadding")
231
+ padding = self.resolve(padding_expression)
232
+ if isinstance(padding, dict):
233
+ for key in ("horizontal", "vertical", "top", "bottom", "start", "end"):
234
+ target = {"start": "Left", "end": "Right"}.get(key, key.title())
235
+ assign("layout", "padding" + target, padding.get(key), padding_expression)
236
+ if node.get("component") == "Button":
237
+ for key, role in (("backgroundColor", "primary"), ("color", "onPrimary")):
238
+ if key not in node["style"]:
239
+ assign("style", key, self.resolve("MaterialTheme.colorScheme." + role), "MaterialTheme.colorScheme." + role)
240
+ if node.get("component") == "RadioButton":
241
+ role = "primary" if node.get("checked") else "onSurfaceVariant"
242
+ assign("style", "color", self.resolve("MaterialTheme.colorScheme." + role), "MaterialTheme.colorScheme." + role)
@@ -0,0 +1,36 @@
1
+ """Bounded Kotlin syntax helpers; offsets always refer to the original source."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Iterator
7
+
8
+
9
+ from source_syntax import arguments, closing, mask_literals, named_argument
10
+
11
+
12
+ def composable_functions(text: str) -> Iterator[dict]:
13
+ masked = mask_literals(text)
14
+ # A declaration may have visibility modifiers, annotations and function-typed parameters.
15
+ pattern = re.compile(r"@Composable\b(?:(?!\bfun\b)[\s\S])*?\bfun\s+([A-Z]\w*)\s*\(")
16
+ cursor = 0
17
+ while match := pattern.search(masked, cursor):
18
+ open_paren = masked.find("(", match.start(1))
19
+ end_args = closing(text, open_paren)
20
+ if end_args is None:
21
+ break
22
+ body_start = end_args + 1
23
+ while body_start < len(text) and masked[body_start].isspace():
24
+ body_start += 1
25
+ if masked[body_start:body_start + 1] == ":":
26
+ return_type = re.match(r":\s*Unit\s*", masked[body_start:])
27
+ body_start += len(return_type.group()) if return_type else 0
28
+ if masked[body_start:body_start + 1] != "{":
29
+ cursor = end_args + 1
30
+ continue
31
+ body_end = closing(text, body_start)
32
+ if body_end is None:
33
+ break
34
+ yield {"name": match.group(1), "start": match.start(), "bodyStart": body_start + 1,
35
+ "bodyEnd": body_end, "parameters": text[open_paren + 1:end_args]}
36
+ cursor = body_end + 1
@@ -27,6 +27,7 @@ class AdapterResult:
27
27
  themes: list[dict[str, Any]] = field(default_factory=list)
28
28
  components: list[dict[str, Any]] = field(default_factory=list)
29
29
  unsupported: list[dict[str, Any]] = field(default_factory=list)
30
+ fonts: list[dict[str, Any]] = field(default_factory=list)
30
31
 
31
32
 
32
33
  class UiSourceAdapter(Protocol):
@@ -83,7 +84,16 @@ def translate_sources(contexts: list[SourceContext]) -> AdapterResult:
83
84
  combined.nodes.update(result.nodes)
84
85
  for group, values in result.tokens.items():
85
86
  combined.tokens.setdefault(group, {}).update(values if isinstance(values, dict) else {})
86
- combined.themes.extend(result.themes)
87
+ for theme in result.themes:
88
+ existing = next((item for item in combined.themes if item.get("id") == theme.get("id")), None)
89
+ if existing is None:
90
+ combined.themes.append(theme)
91
+ else:
92
+ for field in ("nodeOverrides", "tokenOverrides"):
93
+ existing.setdefault(field, {}).update(theme.get(field, {}))
87
94
  combined.components.extend(result.components)
88
95
  combined.unsupported.extend(result.unsupported)
96
+ for font in result.fonts:
97
+ if font not in combined.fonts:
98
+ combined.fonts.append(font)
89
99
  return combined