lumo-mobile 0.0.1__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.
- lumo_mobile-0.0.1/PKG-INFO +58 -0
- lumo_mobile-0.0.1/README.md +28 -0
- lumo_mobile-0.0.1/lumo/__init__.py +3 -0
- lumo_mobile-0.0.1/lumo/mcp/__init__.py +10 -0
- lumo_mobile-0.0.1/lumo/mcp/server.py +249 -0
- lumo_mobile-0.0.1/lumo/parity/__init__.py +31 -0
- lumo_mobile-0.0.1/lumo/parity/cli.py +123 -0
- lumo_mobile-0.0.1/lumo/parity/core.py +354 -0
- lumo_mobile-0.0.1/lumo/theory/__init__.py +42 -0
- lumo_mobile-0.0.1/lumo/theory/cli.py +121 -0
- lumo_mobile-0.0.1/lumo/theory/core.py +420 -0
- lumo_mobile-0.0.1/lumo/wcag/__init__.py +26 -0
- lumo_mobile-0.0.1/lumo/wcag/cli.py +88 -0
- lumo_mobile-0.0.1/lumo/wcag/core.py +277 -0
- lumo_mobile-0.0.1/lumo_mobile.egg-info/PKG-INFO +58 -0
- lumo_mobile-0.0.1/lumo_mobile.egg-info/SOURCES.txt +24 -0
- lumo_mobile-0.0.1/lumo_mobile.egg-info/dependency_links.txt +1 -0
- lumo_mobile-0.0.1/lumo_mobile.egg-info/entry_points.txt +5 -0
- lumo_mobile-0.0.1/lumo_mobile.egg-info/requires.txt +7 -0
- lumo_mobile-0.0.1/lumo_mobile.egg-info/top_level.txt +1 -0
- lumo_mobile-0.0.1/pyproject.toml +70 -0
- lumo_mobile-0.0.1/setup.cfg +4 -0
- lumo_mobile-0.0.1/tests/test_mcp.py +163 -0
- lumo_mobile-0.0.1/tests/test_parity.py +203 -0
- lumo_mobile-0.0.1/tests/test_theory.py +238 -0
- lumo_mobile-0.0.1/tests/test_wcag.py +189 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lumo-mobile
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Deterministic mobile UI/UX checks for Compose / SwiftUI / XML / UIKit — WCAG validator with OKLCH auto-correct, cross-platform parity diff, cognitive-science (Fitts / Hick / Gestalt) layout checks. Ships with an MCP server.
|
|
5
|
+
Author: Viktor Savchik
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/OneXeor/lumo
|
|
8
|
+
Project-URL: Repository, https://github.com/OneXeor/lumo
|
|
9
|
+
Project-URL: Issues, https://github.com/OneXeor/lumo/issues
|
|
10
|
+
Keywords: mobile,design,ui,ux,wcag,accessibility,jetpack-compose,swiftui,uikit,android,ios,fitts,gestalt,design-system,cross-platform,mcp
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Classifier: Topic :: Software Development :: User Interfaces
|
|
21
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
Requires-Dist: numpy>=1.26
|
|
25
|
+
Requires-Dist: mcp>=1.27
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
28
|
+
Requires-Dist: ruff>=0.4; extra == "dev"
|
|
29
|
+
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
30
|
+
|
|
31
|
+
# lumo-mobile
|
|
32
|
+
|
|
33
|
+
Deterministic mobile UI/UX checks invoked by [Lumo](https://github.com/OneXeor/lumo) — a
|
|
34
|
+
Claude Code skill / MCP server / CLI toolkit for designing polished mobile
|
|
35
|
+
apps on Jetpack Compose, Android XML, SwiftUI, and UIKit.
|
|
36
|
+
|
|
37
|
+
Install:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pipx install lumo-mobile
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Three CLIs (plus one MCP server) ship:
|
|
44
|
+
|
|
45
|
+
| Command | What it does |
|
|
46
|
+
|---|---|
|
|
47
|
+
| `lumo-wcag check --fg <hex> --bg <hex>` | WCAG AA / AAA contrast verdict using the W3C luminance formula. |
|
|
48
|
+
| `lumo-wcag fix --fg <hex> --bg <hex>` | OKLCH auto-correct that preserves hue and chroma while pushing the contrast above the threshold. |
|
|
49
|
+
| `lumo-theory check --layout <path>` | Cognitive-science layout checks: Fitts (undersized targets, relative difficulty for primaries), Hick overload, Gestalt proximity, one-handed reachability. |
|
|
50
|
+
| `lumo-parity diff --android <path> --ios <path> [--config <path>]` | Cross-platform diff between Android (dp) and iOS (pt) layouts, with optional design-system token validation. |
|
|
51
|
+
| `lumo-mcp` | Model Context Protocol server (stdio) exposing all of the above to Claude Code, Cursor, Continue, Aider, Goose, Zed, Codex. |
|
|
52
|
+
|
|
53
|
+
See the [main repo](https://github.com/OneXeor/lumo) for the full SKILL.md,
|
|
54
|
+
examples, and rationale.
|
|
55
|
+
|
|
56
|
+
## License
|
|
57
|
+
|
|
58
|
+
MIT
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# lumo-mobile
|
|
2
|
+
|
|
3
|
+
Deterministic mobile UI/UX checks invoked by [Lumo](https://github.com/OneXeor/lumo) — a
|
|
4
|
+
Claude Code skill / MCP server / CLI toolkit for designing polished mobile
|
|
5
|
+
apps on Jetpack Compose, Android XML, SwiftUI, and UIKit.
|
|
6
|
+
|
|
7
|
+
Install:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pipx install lumo-mobile
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Three CLIs (plus one MCP server) ship:
|
|
14
|
+
|
|
15
|
+
| Command | What it does |
|
|
16
|
+
|---|---|
|
|
17
|
+
| `lumo-wcag check --fg <hex> --bg <hex>` | WCAG AA / AAA contrast verdict using the W3C luminance formula. |
|
|
18
|
+
| `lumo-wcag fix --fg <hex> --bg <hex>` | OKLCH auto-correct that preserves hue and chroma while pushing the contrast above the threshold. |
|
|
19
|
+
| `lumo-theory check --layout <path>` | Cognitive-science layout checks: Fitts (undersized targets, relative difficulty for primaries), Hick overload, Gestalt proximity, one-handed reachability. |
|
|
20
|
+
| `lumo-parity diff --android <path> --ios <path> [--config <path>]` | Cross-platform diff between Android (dp) and iOS (pt) layouts, with optional design-system token validation. |
|
|
21
|
+
| `lumo-mcp` | Model Context Protocol server (stdio) exposing all of the above to Claude Code, Cursor, Continue, Aider, Goose, Zed, Codex. |
|
|
22
|
+
|
|
23
|
+
See the [main repo](https://github.com/OneXeor/lumo) for the full SKILL.md,
|
|
24
|
+
examples, and rationale.
|
|
25
|
+
|
|
26
|
+
## License
|
|
27
|
+
|
|
28
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""MCP server exposing Lumo tools to any MCP-compatible client.
|
|
2
|
+
|
|
3
|
+
Public API:
|
|
4
|
+
server — the FastMCP instance (importable for tests and custom mounts)
|
|
5
|
+
main() — stdio entrypoint, registered as `lumo-mcp` console script
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from lumo.mcp.server import main, server
|
|
9
|
+
|
|
10
|
+
__all__ = ["main", "server"]
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Lumo MCP server.
|
|
2
|
+
|
|
3
|
+
Exposes the three Lumo tools (WCAG, theory, parity) over the Model Context
|
|
4
|
+
Protocol so any MCP-compatible client (Claude Code, Cursor, Continue,
|
|
5
|
+
Aider, Goose, Zed, etc.) can call them with structured arguments.
|
|
6
|
+
|
|
7
|
+
This is a thin wrapper over the existing Python API — the heavy lifting
|
|
8
|
+
stays in lumo.wcag, lumo.theory, lumo.parity. Adding MCP did not change a
|
|
9
|
+
single line of those modules.
|
|
10
|
+
|
|
11
|
+
Transport: stdio (the MCP standard for local servers).
|
|
12
|
+
|
|
13
|
+
Run directly:
|
|
14
|
+
lumo-mcp
|
|
15
|
+
|
|
16
|
+
Or for development:
|
|
17
|
+
python -m lumo.mcp.server
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from dataclasses import asdict
|
|
23
|
+
from typing import Any, Literal
|
|
24
|
+
|
|
25
|
+
from mcp.server.fastmcp import FastMCP
|
|
26
|
+
|
|
27
|
+
from lumo.parity.core import DesignSystemConfig, diff
|
|
28
|
+
from lumo.theory.core import Element, Layout, Screen, check_layout
|
|
29
|
+
from lumo.wcag.core import auto_correct, check_pair
|
|
30
|
+
|
|
31
|
+
server = FastMCP("lumo")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ============================================================================
|
|
35
|
+
# Tool 1 — WCAG check
|
|
36
|
+
# ============================================================================
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@server.tool()
|
|
40
|
+
def lumo_wcag_check(
|
|
41
|
+
fg: str,
|
|
42
|
+
bg: str,
|
|
43
|
+
level: Literal["AA", "AAA"] = "AA",
|
|
44
|
+
size: Literal["normal", "large"] = "normal",
|
|
45
|
+
) -> dict[str, Any]:
|
|
46
|
+
"""Check whether a foreground/background color pair meets WCAG contrast.
|
|
47
|
+
|
|
48
|
+
Uses the W3C relative luminance formula. Returns the exact contrast ratio,
|
|
49
|
+
the required threshold for the given level + text size, and a pass / fail
|
|
50
|
+
verdict. This is deterministic math, not an LLM guess.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
fg: Foreground hex (#RGB, #RRGGBB, #RGBA, or #RRGGBBAA — alpha ignored)
|
|
54
|
+
bg: Background hex (same formats)
|
|
55
|
+
level: "AA" (4.5:1 normal / 3:1 large) or "AAA" (7:1 / 4.5:1)
|
|
56
|
+
size: "normal" or "large" (large = ≥18pt or ≥14pt bold)
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Dict with fg, bg, ratio, level, size, required, passes.
|
|
60
|
+
"""
|
|
61
|
+
result = check_pair(fg, bg, level, size)
|
|
62
|
+
return asdict(result)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ============================================================================
|
|
66
|
+
# Tool 2 — WCAG auto-correct
|
|
67
|
+
# ============================================================================
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@server.tool()
|
|
71
|
+
def lumo_wcag_fix(
|
|
72
|
+
fg: str,
|
|
73
|
+
bg: str,
|
|
74
|
+
level: Literal["AA", "AAA"] = "AA",
|
|
75
|
+
size: Literal["normal", "large"] = "normal",
|
|
76
|
+
max_iterations: int = 60,
|
|
77
|
+
) -> dict[str, Any]:
|
|
78
|
+
"""Auto-correct a failing foreground color to meet WCAG, preserving hue and chroma.
|
|
79
|
+
|
|
80
|
+
Adjusts the foreground's L-channel in OKLCH (perceptually uniform color
|
|
81
|
+
space) until the pair passes. Brand identity stays intact because chroma
|
|
82
|
+
and hue are held fixed. Returns the corrected hex plus iteration count
|
|
83
|
+
and direction (darken_fg / lighten_fg / unchanged).
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
fg: Foreground hex to correct
|
|
87
|
+
bg: Background hex (untouched)
|
|
88
|
+
level: "AA" or "AAA"
|
|
89
|
+
size: "normal" or "large"
|
|
90
|
+
max_iterations: safety bound on the iterative search
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
Dict with original CheckResult, corrected_fg, corrected_bg, corrected
|
|
94
|
+
CheckResult, iterations, and strategy.
|
|
95
|
+
"""
|
|
96
|
+
result = auto_correct(fg, bg, level, size, max_iterations)
|
|
97
|
+
return {
|
|
98
|
+
"original": asdict(result.original),
|
|
99
|
+
"corrected_fg": result.corrected_fg,
|
|
100
|
+
"corrected_bg": result.corrected_bg,
|
|
101
|
+
"corrected": asdict(result.corrected),
|
|
102
|
+
"iterations": result.iterations,
|
|
103
|
+
"strategy": result.strategy,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ============================================================================
|
|
108
|
+
# Tool 3 — theory_check
|
|
109
|
+
# ============================================================================
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@server.tool()
|
|
113
|
+
def lumo_theory_check(layout: dict[str, Any]) -> dict[str, Any]:
|
|
114
|
+
"""Run cognitive-science layout checks (Fitts, Hick, Gestalt, reach).
|
|
115
|
+
|
|
116
|
+
Accepts a layout JSON (same schema as the lumo-theory CLI). Returns
|
|
117
|
+
findings with severity, recommendation, and the metric that produced
|
|
118
|
+
them. Each finding inherits a confidence label from the layout's
|
|
119
|
+
`source` field — `measured` / `code-estimated` / `description-estimated`
|
|
120
|
+
— so the consumer can weigh trust honestly.
|
|
121
|
+
|
|
122
|
+
Tool does NOT produce absolute Fitts MT or Hick RT in ms. Those depend
|
|
123
|
+
on device-specific constants with ±40% variance; we return relative
|
|
124
|
+
ratios and discrete flags instead.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
layout: Layout JSON with keys `screen` ({width, height, unit}),
|
|
128
|
+
`source` (one of measured | code-estimated |
|
|
129
|
+
description-estimated), and `elements` (list of element
|
|
130
|
+
dicts with id, role, x, y, w, h, optional group + weight).
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Dict with `source`, `counts_by_severity`, and `findings` list.
|
|
134
|
+
"""
|
|
135
|
+
screen = Screen(
|
|
136
|
+
width=float(layout["screen"]["width"]),
|
|
137
|
+
height=float(layout["screen"]["height"]),
|
|
138
|
+
unit=layout["screen"].get("unit", "dp"),
|
|
139
|
+
)
|
|
140
|
+
elements = tuple(
|
|
141
|
+
Element(
|
|
142
|
+
id=str(e["id"]),
|
|
143
|
+
role=e["role"],
|
|
144
|
+
x=float(e["x"]),
|
|
145
|
+
y=float(e["y"]),
|
|
146
|
+
w=float(e["w"]),
|
|
147
|
+
h=float(e["h"]),
|
|
148
|
+
group=e.get("group"),
|
|
149
|
+
weight=e.get("weight", "equal"),
|
|
150
|
+
)
|
|
151
|
+
for e in layout.get("elements", [])
|
|
152
|
+
)
|
|
153
|
+
parsed = Layout(screen=screen, elements=elements, source=layout.get("source", "description-estimated"))
|
|
154
|
+
report = check_layout(parsed)
|
|
155
|
+
return {
|
|
156
|
+
"source": report.source,
|
|
157
|
+
"counts_by_severity": report.counts_by_severity,
|
|
158
|
+
"findings": [asdict(f) for f in report.findings],
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ============================================================================
|
|
163
|
+
# Tool 4 — platform_parity
|
|
164
|
+
# ============================================================================
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@server.tool()
|
|
168
|
+
def lumo_parity_diff(
|
|
169
|
+
android: dict[str, Any],
|
|
170
|
+
ios: dict[str, Any],
|
|
171
|
+
config: dict[str, Any] | None = None,
|
|
172
|
+
) -> dict[str, Any]:
|
|
173
|
+
"""Diff an Android (dp) and iOS (pt) layout, optionally against a design system.
|
|
174
|
+
|
|
175
|
+
Compares the two layouts for component presence and sizing mismatches.
|
|
176
|
+
Whitelists known platform-specific defaults (Material 48dp vs Apple HIG
|
|
177
|
+
44pt touch target; Material bottom nav 80dp vs iOS Tab Bar 49pt) and
|
|
178
|
+
reports them as `info`, not as mismatches.
|
|
179
|
+
|
|
180
|
+
Reminder: dp and pt are both density-independent and equal in physical
|
|
181
|
+
size on screen. 16dp matches 16pt. The classic "iOS uses 3× because
|
|
182
|
+
Retina" misconception (writing 48pt on iOS for 16dp Android) is exactly
|
|
183
|
+
the bug this tool catches.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
android: Android layout JSON (same schema as lumo_theory_check)
|
|
187
|
+
ios: iOS layout JSON
|
|
188
|
+
config: Optional design-system config with `spacing`, `sizing`,
|
|
189
|
+
`colors` token maps
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
Dict with `confidence`, `android_source`, `ios_source`,
|
|
193
|
+
`counts_by_severity`, and `findings` list.
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
def _to_layout(data: dict[str, Any]) -> Layout:
|
|
197
|
+
screen = Screen(
|
|
198
|
+
width=float(data["screen"]["width"]),
|
|
199
|
+
height=float(data["screen"]["height"]),
|
|
200
|
+
unit=data["screen"].get("unit", "dp"),
|
|
201
|
+
)
|
|
202
|
+
elements = tuple(
|
|
203
|
+
Element(
|
|
204
|
+
id=str(e["id"]),
|
|
205
|
+
role=e["role"],
|
|
206
|
+
x=float(e["x"]),
|
|
207
|
+
y=float(e["y"]),
|
|
208
|
+
w=float(e["w"]),
|
|
209
|
+
h=float(e["h"]),
|
|
210
|
+
group=e.get("group"),
|
|
211
|
+
weight=e.get("weight", "equal"),
|
|
212
|
+
)
|
|
213
|
+
for e in data.get("elements", [])
|
|
214
|
+
)
|
|
215
|
+
return Layout(screen=screen, elements=elements, source=data.get("source", "description-estimated"))
|
|
216
|
+
|
|
217
|
+
android_layout = _to_layout(android)
|
|
218
|
+
ios_layout = _to_layout(ios)
|
|
219
|
+
|
|
220
|
+
ds_config = None
|
|
221
|
+
if config is not None:
|
|
222
|
+
ds_config = DesignSystemConfig(
|
|
223
|
+
spacing=config.get("spacing", {}),
|
|
224
|
+
sizing=config.get("sizing", {}),
|
|
225
|
+
colors=config.get("colors", {}),
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
report = diff(android_layout, ios_layout, ds_config)
|
|
229
|
+
return {
|
|
230
|
+
"confidence": report.confidence,
|
|
231
|
+
"android_source": report.android_source,
|
|
232
|
+
"ios_source": report.ios_source,
|
|
233
|
+
"counts_by_severity": report.counts_by_severity,
|
|
234
|
+
"findings": [asdict(f) for f in report.findings],
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# ============================================================================
|
|
239
|
+
# Entrypoint
|
|
240
|
+
# ============================================================================
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def main() -> None:
|
|
244
|
+
"""Run the Lumo MCP server over stdio (the MCP standard for local tools)."""
|
|
245
|
+
server.run(transport="stdio")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
if __name__ == "__main__":
|
|
249
|
+
main()
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Cross-platform parity diff for Android (Compose / XML) vs iOS (SwiftUI / UIKit).
|
|
2
|
+
|
|
3
|
+
Public API:
|
|
4
|
+
diff(android, ios, config=None) -> ParityReport
|
|
5
|
+
|
|
6
|
+
The diff compares two layout JSONs (same schema as theory_check) and, when
|
|
7
|
+
provided, validates both against a shared design-system config.
|
|
8
|
+
|
|
9
|
+
Honest design notes:
|
|
10
|
+
- dp and pt are both density-independent. A 16dp / 16pt comparison is
|
|
11
|
+
legitimate (NOT 16dp / 48pt — that's a known junior misconception).
|
|
12
|
+
- Some numeric discrepancies are *expected* per platform standards
|
|
13
|
+
(44pt touch target vs 48dp, 49pt Tab Bar vs 80dp bottom nav,
|
|
14
|
+
17pt iOS body text vs 16sp Material). These live in the whitelist
|
|
15
|
+
and are reported as `info`, not `mismatch`.
|
|
16
|
+
- Confidence propagates from the lower of the two input sources.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from lumo.parity.core import (
|
|
20
|
+
DesignSystemConfig,
|
|
21
|
+
ParityFinding,
|
|
22
|
+
ParityReport,
|
|
23
|
+
diff,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"DesignSystemConfig",
|
|
28
|
+
"ParityFinding",
|
|
29
|
+
"ParityReport",
|
|
30
|
+
"diff",
|
|
31
|
+
]
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""CLI for platform_parity.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
lumo-parity diff --android <path|-> --ios <path|-> [--config <path>] [--json]
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from dataclasses import asdict
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from lumo.parity.core import DesignSystemConfig, diff
|
|
17
|
+
from lumo.theory.core import Element, Layout, Screen
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _load_layout(source: str) -> Layout:
|
|
21
|
+
raw = sys.stdin.read() if source == "-" else Path(source).read_text(encoding="utf-8")
|
|
22
|
+
data: dict[str, Any] = json.loads(raw)
|
|
23
|
+
screen = Screen(
|
|
24
|
+
width=float(data["screen"]["width"]),
|
|
25
|
+
height=float(data["screen"]["height"]),
|
|
26
|
+
unit=data["screen"].get("unit", "dp"),
|
|
27
|
+
)
|
|
28
|
+
elements = tuple(
|
|
29
|
+
Element(
|
|
30
|
+
id=str(e["id"]),
|
|
31
|
+
role=e["role"],
|
|
32
|
+
x=float(e["x"]),
|
|
33
|
+
y=float(e["y"]),
|
|
34
|
+
w=float(e["w"]),
|
|
35
|
+
h=float(e["h"]),
|
|
36
|
+
group=e.get("group"),
|
|
37
|
+
weight=e.get("weight", "equal"),
|
|
38
|
+
)
|
|
39
|
+
for e in data.get("elements", [])
|
|
40
|
+
)
|
|
41
|
+
return Layout(screen=screen, elements=elements, source=data.get("source", "description-estimated"))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _load_config(path: str) -> DesignSystemConfig:
|
|
45
|
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
46
|
+
return DesignSystemConfig(
|
|
47
|
+
spacing=data.get("spacing", {}),
|
|
48
|
+
sizing=data.get("sizing", {}),
|
|
49
|
+
colors=data.get("colors", {}),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _print_human(report: Any) -> None:
|
|
54
|
+
if not report.findings:
|
|
55
|
+
print("OK Android and iOS layouts are in parity.")
|
|
56
|
+
print(f" confidence: {report.confidence}")
|
|
57
|
+
print(f" android: {report.android_source}, ios: {report.ios_source}")
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
counts = report.counts_by_severity
|
|
61
|
+
severity_summary = ", ".join(
|
|
62
|
+
f"{n} {sev}" for sev, n in sorted(counts.items(), key=lambda x: x[0])
|
|
63
|
+
)
|
|
64
|
+
print(f"FOUND {len(report.findings)} parity findings ({severity_summary})")
|
|
65
|
+
print(f" confidence: {report.confidence}")
|
|
66
|
+
print(f" android: {report.android_source}, ios: {report.ios_source}\n")
|
|
67
|
+
|
|
68
|
+
for i, f in enumerate(report.findings, 1):
|
|
69
|
+
print(f" {i}. [{f.severity.upper():8}] {f.check}")
|
|
70
|
+
if f.element_id:
|
|
71
|
+
print(f" element: {f.element_id}")
|
|
72
|
+
if f.android_value is not None or f.ios_value is not None:
|
|
73
|
+
print(f" android: {f.android_value} ios: {f.ios_value}")
|
|
74
|
+
print(f" {f.message}")
|
|
75
|
+
print(f" → {f.recommendation}")
|
|
76
|
+
print()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _print_json(report: Any) -> None:
|
|
80
|
+
payload = {
|
|
81
|
+
"confidence": report.confidence,
|
|
82
|
+
"android_source": report.android_source,
|
|
83
|
+
"ios_source": report.ios_source,
|
|
84
|
+
"counts_by_severity": report.counts_by_severity,
|
|
85
|
+
"findings": [asdict(f) for f in report.findings],
|
|
86
|
+
}
|
|
87
|
+
print(json.dumps(payload, indent=2, default=str))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def main(argv: list[str] | None = None) -> int:
|
|
91
|
+
parser = argparse.ArgumentParser(
|
|
92
|
+
prog="lumo-parity",
|
|
93
|
+
description="Cross-platform parity diff: Android (dp) vs iOS (pt).",
|
|
94
|
+
)
|
|
95
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
96
|
+
|
|
97
|
+
d = sub.add_parser("diff", help="Diff two layouts and (optionally) validate against a design system.")
|
|
98
|
+
d.add_argument("--android", required=True, help="Path to Android layout JSON, or '-' for stdin.")
|
|
99
|
+
d.add_argument("--ios", required=True, help="Path to iOS layout JSON, or '-' for stdin.")
|
|
100
|
+
d.add_argument("--config", default=None, help="Optional lumo.config.json with design tokens.")
|
|
101
|
+
d.add_argument("--json", action="store_true", help="Emit JSON.")
|
|
102
|
+
|
|
103
|
+
args = parser.parse_args(argv)
|
|
104
|
+
|
|
105
|
+
if args.cmd == "diff":
|
|
106
|
+
if args.android == "-" and args.ios == "-":
|
|
107
|
+
print("error: cannot read both layouts from stdin", file=sys.stderr)
|
|
108
|
+
return 2
|
|
109
|
+
android = _load_layout(args.android)
|
|
110
|
+
ios = _load_layout(args.ios)
|
|
111
|
+
config = _load_config(args.config) if args.config else None
|
|
112
|
+
report = diff(android, ios, config)
|
|
113
|
+
if args.json:
|
|
114
|
+
_print_json(report)
|
|
115
|
+
else:
|
|
116
|
+
_print_human(report)
|
|
117
|
+
return 0 if not report.findings else 1
|
|
118
|
+
|
|
119
|
+
return 2
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
if __name__ == "__main__":
|
|
123
|
+
sys.exit(main())
|