onemore-design 1.0.0
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.
- package/LICENSE +21 -0
- package/SKILL.md +180 -0
- package/bin/onemore.js +23 -0
- package/data/audit/hig-checklist.csv +39 -0
- package/data/components/content.csv +17 -0
- package/data/components/controls.csv +21 -0
- package/data/components/feedback.csv +17 -0
- package/data/components/input.csv +11 -0
- package/data/components/navigation.csv +15 -0
- package/data/foundations/colors.csv +38 -0
- package/data/foundations/corners.csv +13 -0
- package/data/foundations/elevation.csv +17 -0
- package/data/foundations/spacing.csv +21 -0
- package/data/foundations/typography.csv +26 -0
- package/data/patterns/animation.csv +24 -0
- package/data/patterns/gestures.csv +11 -0
- package/data/patterns/interaction.csv +16 -0
- package/data/patterns/layout.csv +20 -0
- package/data/platforms/ios.csv +21 -0
- package/data/platforms/macos.csv +16 -0
- package/data/platforms/visionos.csv +11 -0
- package/data/platforms/watchos.csv +11 -0
- package/data/platforms/web-apple.csv +21 -0
- package/data/reasoning/apple-reasoning.csv +16 -0
- package/data/stacks/astro.csv +21 -0
- package/data/stacks/flutter.csv +29 -0
- package/data/stacks/html-tailwind.csv +26 -0
- package/data/stacks/nativewind.csv +26 -0
- package/data/stacks/nextjs.csv +26 -0
- package/data/stacks/nuxtjs.csv +21 -0
- package/data/stacks/react-native.csv +26 -0
- package/data/stacks/react.csv +26 -0
- package/data/stacks/shadcn.csv +25 -0
- package/data/stacks/svelte.csv +21 -0
- package/data/stacks/swiftui.csv +31 -0
- package/data/stacks/uikit.csv +21 -0
- package/data/stacks/vue.csv +21 -0
- package/package.json +51 -0
- package/scripts/__init__.py +0 -0
- package/scripts/__pycache__/__init__.cpython-314.pyc +0 -0
- package/scripts/__pycache__/core.cpython-314.pyc +0 -0
- package/scripts/__pycache__/design_system.cpython-314.pyc +0 -0
- package/scripts/__pycache__/exporter.cpython-314.pyc +0 -0
- package/scripts/__pycache__/platforms.cpython-314.pyc +0 -0
- package/scripts/__pycache__/redesign.cpython-314.pyc +0 -0
- package/scripts/core.py +242 -0
- package/scripts/design_system.py +291 -0
- package/scripts/exporter.py +717 -0
- package/scripts/platforms.py +309 -0
- package/scripts/redesign.py +758 -0
- package/scripts/search.py +426 -0
|
@@ -0,0 +1,758 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""OneMore Redesign Scanner — Apple HIG violation detection and AI-actionable reports."""
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
# ---------------------------------------------------------------------------
|
|
9
|
+
# File Detection
|
|
10
|
+
# ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
UI_FILE_EXTENSIONS = {
|
|
13
|
+
".tsx": "react",
|
|
14
|
+
".jsx": "react",
|
|
15
|
+
".vue": "vue",
|
|
16
|
+
".svelte": "svelte",
|
|
17
|
+
".swift": "swiftui",
|
|
18
|
+
".dart": "flutter",
|
|
19
|
+
".html": "html",
|
|
20
|
+
".css": "css",
|
|
21
|
+
".scss": "scss",
|
|
22
|
+
".ts": "react",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
IGNORED_DIRS = {
|
|
26
|
+
"node_modules", ".git", "__pycache__", "dist", "build",
|
|
27
|
+
".next", ".nuxt", "vendor", "Pods",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Expo / React Native Project Context Detection
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
def detect_project_context(path) -> dict:
|
|
36
|
+
"""Detect if a project is Expo, React Native, or NativeWind.
|
|
37
|
+
|
|
38
|
+
Walks up from *path* looking for app.json / app.config.js / app.config.ts
|
|
39
|
+
that reference "expo", and inspects package.json for relevant dependencies.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
{
|
|
43
|
+
"is_expo": bool,
|
|
44
|
+
"is_react_native": bool,
|
|
45
|
+
"has_nativewind": bool,
|
|
46
|
+
"framework_override": "nativewind" | "react-native" | None,
|
|
47
|
+
}
|
|
48
|
+
"""
|
|
49
|
+
path = Path(path)
|
|
50
|
+
|
|
51
|
+
is_expo = False
|
|
52
|
+
is_react_native = False
|
|
53
|
+
has_nativewind = False
|
|
54
|
+
|
|
55
|
+
# Search current dir and up to 3 parent levels for Expo config files
|
|
56
|
+
search_paths = [path] + list(path.parents)[:3]
|
|
57
|
+
|
|
58
|
+
for search_dir in search_paths:
|
|
59
|
+
for config_name in ("app.json", "app.config.js", "app.config.ts"):
|
|
60
|
+
config_file = search_dir / config_name
|
|
61
|
+
if config_file.exists():
|
|
62
|
+
try:
|
|
63
|
+
content = config_file.read_text(errors="ignore")
|
|
64
|
+
if "expo" in content.lower():
|
|
65
|
+
is_expo = True
|
|
66
|
+
except Exception:
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
pkg_file = search_dir / "package.json"
|
|
70
|
+
if pkg_file.exists():
|
|
71
|
+
try:
|
|
72
|
+
content = pkg_file.read_text(errors="ignore")
|
|
73
|
+
pkg = json.loads(content)
|
|
74
|
+
all_deps = {}
|
|
75
|
+
all_deps.update(pkg.get("dependencies", {}))
|
|
76
|
+
all_deps.update(pkg.get("devDependencies", {}))
|
|
77
|
+
all_deps.update(pkg.get("peerDependencies", {}))
|
|
78
|
+
dep_str = " ".join(all_deps.keys()).lower()
|
|
79
|
+
if "react-native" in dep_str:
|
|
80
|
+
is_react_native = True
|
|
81
|
+
if "expo" in dep_str:
|
|
82
|
+
is_expo = True
|
|
83
|
+
if "nativewind" in dep_str:
|
|
84
|
+
has_nativewind = True
|
|
85
|
+
except Exception:
|
|
86
|
+
pass
|
|
87
|
+
# Stop climbing once we find a package.json
|
|
88
|
+
if pkg_file.exists():
|
|
89
|
+
break
|
|
90
|
+
|
|
91
|
+
if has_nativewind:
|
|
92
|
+
framework_override = "nativewind"
|
|
93
|
+
elif is_expo or is_react_native:
|
|
94
|
+
framework_override = "react-native"
|
|
95
|
+
else:
|
|
96
|
+
framework_override = None
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
"is_expo": is_expo,
|
|
100
|
+
"is_react_native": is_react_native,
|
|
101
|
+
"has_nativewind": has_nativewind,
|
|
102
|
+
"framework_override": framework_override,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
# HIG Violation Rules
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
HIG_RULES = [
|
|
110
|
+
# ── Colors ──────────────────────────────────────────────────────────
|
|
111
|
+
{
|
|
112
|
+
"id": "COLOR-001",
|
|
113
|
+
"category": "colors",
|
|
114
|
+
"description": "Pure black text detected — Apple uses warm near-black, not pure black",
|
|
115
|
+
"patterns": [
|
|
116
|
+
r"""#000000""",
|
|
117
|
+
r"""#000(?![\da-fA-F])""",
|
|
118
|
+
r"""rgb\(\s*0\s*,\s*0\s*,\s*0\s*\)""",
|
|
119
|
+
r"""\bcolor:\s*black\b""",
|
|
120
|
+
],
|
|
121
|
+
"fix": "Use semantic label color: '#1d1d1f' on web, Color.label in SwiftUI, var(--apple-label) with CSS variables",
|
|
122
|
+
"severity": "critical",
|
|
123
|
+
"platforms": ["all"],
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
"id": "COLOR-002",
|
|
127
|
+
"category": "colors",
|
|
128
|
+
"description": "Pure white background — Apple uses slightly warm off-white",
|
|
129
|
+
"patterns": [
|
|
130
|
+
r"""#ffffff""",
|
|
131
|
+
r"""#fff(?![\da-fA-F])""",
|
|
132
|
+
r"""rgb\(\s*255\s*,\s*255\s*,\s*255\s*\)""",
|
|
133
|
+
],
|
|
134
|
+
"fix": "Use '#fbfbfd' on web, Color.systemBackground in SwiftUI",
|
|
135
|
+
"severity": "high",
|
|
136
|
+
"platforms": ["all"],
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
"id": "COLOR-003",
|
|
140
|
+
"category": "colors",
|
|
141
|
+
"description": "Hardcoded hex color that should be a CSS variable / semantic token",
|
|
142
|
+
"patterns": [
|
|
143
|
+
r"""(?:color|background|background-color|border-color)\s*:\s*#[0-9a-fA-F]{3,8}\b""",
|
|
144
|
+
],
|
|
145
|
+
"fix": "Use --apple-* CSS custom properties or semantic color tokens",
|
|
146
|
+
"severity": "medium",
|
|
147
|
+
"platforms": ["css", "scss", "html"],
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
"id": "COLOR-004",
|
|
151
|
+
"category": "colors",
|
|
152
|
+
"description": "Non-Apple blue detected — Apple system blue is #007AFF",
|
|
153
|
+
"patterns": [
|
|
154
|
+
r"""#0000ff""",
|
|
155
|
+
r"""\bdodgerblue\b""",
|
|
156
|
+
r"""\bcornflowerblue\b""",
|
|
157
|
+
r"""\broyalblue\b""",
|
|
158
|
+
],
|
|
159
|
+
"fix": "Use Apple systemBlue: '#007AFF'",
|
|
160
|
+
"severity": "high",
|
|
161
|
+
"platforms": ["all"],
|
|
162
|
+
},
|
|
163
|
+
# ── Typography ──────────────────────────────────────────────────────
|
|
164
|
+
{
|
|
165
|
+
"id": "TYPO-001",
|
|
166
|
+
"category": "typography",
|
|
167
|
+
"description": "Non-Apple font stack — missing system font",
|
|
168
|
+
"patterns": [
|
|
169
|
+
r"""font-family\s*:\s*(?:['"]?Arial['"]?\s*,?\s*)?(?:['"]?Helvetica['"]?\s*,?\s*)?sans-serif""",
|
|
170
|
+
r"""fontFamily\s*:\s*['"](?:Arial|Helvetica)['"]""",
|
|
171
|
+
],
|
|
172
|
+
"fix": "Use font-family: -apple-system, BlinkMacSystemFont, 'Inter', system-ui, sans-serif",
|
|
173
|
+
"severity": "high",
|
|
174
|
+
"platforms": ["all"],
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
"id": "TYPO-002",
|
|
178
|
+
"category": "typography",
|
|
179
|
+
"description": "Body text size not aligned with Apple type scale",
|
|
180
|
+
"patterns": [
|
|
181
|
+
r"""font-size\s*:\s*14px""",
|
|
182
|
+
r"""font-size\s*:\s*15px""",
|
|
183
|
+
r"""fontSize\s*:\s*14\b""",
|
|
184
|
+
r"""fontSize\s*:\s*15\b""",
|
|
185
|
+
],
|
|
186
|
+
"fix": "Apple body text is 17px (iOS) or 13px (macOS). Use 17px for mobile, 13px for desktop",
|
|
187
|
+
"severity": "medium",
|
|
188
|
+
"platforms": ["all"],
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
"id": "TYPO-003",
|
|
192
|
+
"category": "typography",
|
|
193
|
+
"description": "Missing letter-spacing on large/headline text",
|
|
194
|
+
"patterns": [
|
|
195
|
+
r"""font-size\s*:\s*(?:2[4-9]|[3-9]\d|\d{3,})px(?!.*letter-spacing)""",
|
|
196
|
+
r"""fontSize\s*:\s*(?:2[4-9]|[3-9]\d|\d{3,})\b(?!.*letterSpacing)""",
|
|
197
|
+
],
|
|
198
|
+
"fix": "Apple uses tight tracking on headlines. Add letter-spacing: -0.02em for 24px+",
|
|
199
|
+
"severity": "low",
|
|
200
|
+
"platforms": ["all"],
|
|
201
|
+
},
|
|
202
|
+
# ── Spacing ─────────────────────────────────────────────────────────
|
|
203
|
+
{
|
|
204
|
+
"id": "SPACE-001",
|
|
205
|
+
"category": "spacing",
|
|
206
|
+
"description": "Odd spacing value — not aligned to 4pt grid",
|
|
207
|
+
"patterns": [
|
|
208
|
+
r"""(?:padding|margin|gap)\s*:\s*['"]?(?:5|7|9|11|13|15|17|19)px""",
|
|
209
|
+
r"""(?:padding|margin|gap)\s*:\s*(?:5|7|9|11|13|15|17|19)\b""",
|
|
210
|
+
],
|
|
211
|
+
"fix": "Align to 4pt grid: use 4/8/12/16/20/24/32/48 values",
|
|
212
|
+
"severity": "medium",
|
|
213
|
+
"platforms": ["all"],
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
"id": "SPACE-002",
|
|
217
|
+
"category": "spacing",
|
|
218
|
+
"description": "Non-grid spacing value on padding/margin",
|
|
219
|
+
"patterns": [
|
|
220
|
+
r"""(?:padding|margin)\s*:\s*['"]?(?:3|5|6|7|9|10|11|13|14|15|17|18|19|21|22|23|25|26|27|28|29|30|31|33)px""",
|
|
221
|
+
],
|
|
222
|
+
"fix": "Use 4pt-grid values: 4/8/12/16/20/24/32/48",
|
|
223
|
+
"severity": "low",
|
|
224
|
+
"platforms": ["all"],
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
"id": "SPACE-003",
|
|
228
|
+
"category": "spacing",
|
|
229
|
+
"description": "Section padding less than 100px — Apple uses 100-120px section padding minimum",
|
|
230
|
+
"patterns": [
|
|
231
|
+
r"""padding:\s*\d{1,2}px\s+0""",
|
|
232
|
+
r"""padding-top:\s*[4-9]\dpx""",
|
|
233
|
+
r"""padding-bottom:\s*[4-9]\dpx""",
|
|
234
|
+
r"""\bpy-[0-9]\b""",
|
|
235
|
+
r"""\bpy-1[0-6]\b""",
|
|
236
|
+
],
|
|
237
|
+
"fix": "Use minimum 100px (py-24 in Tailwind) vertical section padding for Apple-quality spacing",
|
|
238
|
+
"severity": "medium",
|
|
239
|
+
"platforms": ["all"],
|
|
240
|
+
},
|
|
241
|
+
# ── Corners ─────────────────────────────────────────────────────────
|
|
242
|
+
{
|
|
243
|
+
"id": "CORNER-001",
|
|
244
|
+
"category": "corners",
|
|
245
|
+
"description": "Button border-radius too small — Apple buttons use 12px",
|
|
246
|
+
"patterns": [
|
|
247
|
+
r"""border-radius\s*:\s*[1-9]px""",
|
|
248
|
+
r"""borderRadius\s*:\s*[1-9]\b""",
|
|
249
|
+
],
|
|
250
|
+
"fix": "Use borderRadius: 12 for buttons, 10/16/24 for other elements",
|
|
251
|
+
"severity": "high",
|
|
252
|
+
"platforms": ["all"],
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
"id": "CORNER-002",
|
|
256
|
+
"category": "corners",
|
|
257
|
+
"description": "Missing borderCurve: 'continuous' — needed for Apple squircle shape",
|
|
258
|
+
"patterns": [
|
|
259
|
+
r"""borderRadius\s*:\s*\d+(?!.*borderCurve)""",
|
|
260
|
+
],
|
|
261
|
+
"fix": "Add borderCurve: 'continuous' alongside borderRadius for Apple squircle corners",
|
|
262
|
+
"severity": "medium",
|
|
263
|
+
"platforms": ["react"],
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
"id": "CORNER-003",
|
|
267
|
+
"category": "corners",
|
|
268
|
+
"description": "Generic border-radius on cards — use Apple-standard radii",
|
|
269
|
+
"patterns": [
|
|
270
|
+
r"""border-radius\s*:\s*(?:3|5|6|7|9|11|13|14|15|17|18|19)px""",
|
|
271
|
+
],
|
|
272
|
+
"fix": "Use Apple corner radii: 10px (small), 16px (medium), 24px (large)",
|
|
273
|
+
"severity": "low",
|
|
274
|
+
"platforms": ["all"],
|
|
275
|
+
},
|
|
276
|
+
# ── Animation ───────────────────────────────────────────────────────
|
|
277
|
+
{
|
|
278
|
+
"id": "ANIM-001",
|
|
279
|
+
"category": "animation",
|
|
280
|
+
"description": "Non-Apple easing curve — avoid ease-in-out and linear",
|
|
281
|
+
"patterns": [
|
|
282
|
+
r"""transition.*ease-in-out""",
|
|
283
|
+
r"""transition.*\blinear\b""",
|
|
284
|
+
r"""animation.*ease-in-out""",
|
|
285
|
+
r"""animation.*\blinear\b""",
|
|
286
|
+
],
|
|
287
|
+
"fix": "Use cubic-bezier(0.25, 0.1, 0.25, 1) or spring animations",
|
|
288
|
+
"severity": "medium",
|
|
289
|
+
"platforms": ["all"],
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
"id": "ANIM-002",
|
|
293
|
+
"category": "animation",
|
|
294
|
+
"description": "Transition duration outside Apple range (200-400ms)",
|
|
295
|
+
"patterns": [
|
|
296
|
+
r"""transition.*(?:0\.0[1-9]|0\.[0-1])s[^0-9]""",
|
|
297
|
+
r"""transition.*(?:0\.[5-9]|[1-9]\.)s[^0-9]""",
|
|
298
|
+
r"""transition.*(?:5\d\d|[6-9]\d\d|\d{4,})ms""",
|
|
299
|
+
r"""transition.*\b[1-9]\dms\b""",
|
|
300
|
+
],
|
|
301
|
+
"fix": "Apple uses 200-400ms transitions. Use 0.2s-0.4s or 200ms-400ms",
|
|
302
|
+
"severity": "low",
|
|
303
|
+
"platforms": ["all"],
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
"id": "ANIM-003",
|
|
307
|
+
"category": "animation",
|
|
308
|
+
"description": "Missing prefers-reduced-motion — must respect user accessibility settings",
|
|
309
|
+
"patterns": [
|
|
310
|
+
r"""@keyframes\b""",
|
|
311
|
+
r"""animation\s*:""",
|
|
312
|
+
r"""animation-name\s*:""",
|
|
313
|
+
],
|
|
314
|
+
"fix": "Add @media (prefers-reduced-motion: reduce) to disable or simplify animations",
|
|
315
|
+
"severity": "high",
|
|
316
|
+
"platforms": ["css", "scss", "html"],
|
|
317
|
+
},
|
|
318
|
+
# ── Touch Targets ───────────────────────────────────────────────────
|
|
319
|
+
{
|
|
320
|
+
"id": "TOUCH-001",
|
|
321
|
+
"category": "touch",
|
|
322
|
+
"description": "Touch target too small — minimum 44px per Apple HIG",
|
|
323
|
+
"patterns": [
|
|
324
|
+
r"""(?:height|min-height)\s*:\s*(?:[1-3]\d|4[0-3])px""",
|
|
325
|
+
r"""(?:height|minHeight)\s*:\s*(?:[1-3]\d|4[0-3])\b""",
|
|
326
|
+
],
|
|
327
|
+
"fix": "Set minimum height to 44px for all interactive elements",
|
|
328
|
+
"severity": "critical",
|
|
329
|
+
"platforms": ["all"],
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
"id": "TOUCH-002",
|
|
333
|
+
"category": "touch",
|
|
334
|
+
"description": "Missing cursor: pointer on clickable element",
|
|
335
|
+
"patterns": [
|
|
336
|
+
r"""<button(?!.*cursor).*>""",
|
|
337
|
+
r"""<a\s+(?!.*cursor).*>""",
|
|
338
|
+
],
|
|
339
|
+
"fix": "Add cursor: pointer to all clickable elements",
|
|
340
|
+
"severity": "low",
|
|
341
|
+
"platforms": ["html", "vue", "svelte"],
|
|
342
|
+
},
|
|
343
|
+
# ── Icons ───────────────────────────────────────────────────────────
|
|
344
|
+
{
|
|
345
|
+
"id": "ICON-001",
|
|
346
|
+
"category": "icons",
|
|
347
|
+
"description": "Emoji used as UI icon — use SF Symbols or SVG icons instead",
|
|
348
|
+
"patterns": [
|
|
349
|
+
r"""[\U0001F50D\U0001F3E0\u2699\uFE0F\U0001F4E6\U0001F4AC\U0001F514\u2764\u2B50\U0001F4DD\U0001F527\u2795\u2796\u274C\u2714\u2716\U0001F504\U0001F5D1\u270F\U0001F4CB\U0001F4C4]""",
|
|
350
|
+
],
|
|
351
|
+
"fix": "Replace emoji with SF Symbols (iOS/macOS) or SVG/icon-font icons",
|
|
352
|
+
"severity": "medium",
|
|
353
|
+
"platforms": ["all"],
|
|
354
|
+
},
|
|
355
|
+
# ── Dark Mode ───────────────────────────────────────────────────────
|
|
356
|
+
{
|
|
357
|
+
"id": "DARK-001",
|
|
358
|
+
"category": "dark-mode",
|
|
359
|
+
"description": "No prefers-color-scheme media query — dark mode not supported in CSS",
|
|
360
|
+
"patterns": [], # handled by file-level check
|
|
361
|
+
"fix": "Add @media (prefers-color-scheme: dark) { ... } with dark mode colors",
|
|
362
|
+
"severity": "high",
|
|
363
|
+
"platforms": ["css", "scss"],
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
"id": "DARK-002",
|
|
367
|
+
"category": "dark-mode",
|
|
368
|
+
"description": "No colorScheme / useColorScheme handling — dark mode not supported",
|
|
369
|
+
"patterns": [], # handled by file-level check
|
|
370
|
+
"fix": "Import and use useColorScheme hook or Appearance API for dark mode support",
|
|
371
|
+
"severity": "high",
|
|
372
|
+
"platforms": ["react"],
|
|
373
|
+
},
|
|
374
|
+
]
|
|
375
|
+
|
|
376
|
+
# ---------------------------------------------------------------------------
|
|
377
|
+
# Expo / React Native Specific Violation Rules
|
|
378
|
+
# ---------------------------------------------------------------------------
|
|
379
|
+
|
|
380
|
+
EXPO_RULES = [
|
|
381
|
+
{
|
|
382
|
+
"id": "EXPO-001",
|
|
383
|
+
"category": "expo",
|
|
384
|
+
"description": "Using TouchableOpacity instead of Pressable",
|
|
385
|
+
"patterns": [
|
|
386
|
+
r"""\bTouchableOpacity\b""",
|
|
387
|
+
],
|
|
388
|
+
"fix": "Use Pressable, not TouchableOpacity",
|
|
389
|
+
"severity": "high",
|
|
390
|
+
"platforms": ["react-native", "nativewind"],
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
"id": "EXPO-002",
|
|
394
|
+
"category": "expo",
|
|
395
|
+
"description": "Missing expo-haptics for interactive elements",
|
|
396
|
+
"patterns": [
|
|
397
|
+
r"""onPress\s*=\s*\{(?!.*[Hh]aptic)""",
|
|
398
|
+
],
|
|
399
|
+
"fix": "Add haptic feedback with expo-haptics",
|
|
400
|
+
"severity": "low",
|
|
401
|
+
"platforms": ["react-native", "nativewind"],
|
|
402
|
+
},
|
|
403
|
+
{
|
|
404
|
+
"id": "EXPO-003",
|
|
405
|
+
"category": "expo",
|
|
406
|
+
"description": "Using Image from react-native instead of expo-image",
|
|
407
|
+
"patterns": [
|
|
408
|
+
r"""import\s+.*\bImage\b.*from\s+['"]react-native['"]""",
|
|
409
|
+
r"""from\s+['"]react-native['"]\s*.*\bImage\b""",
|
|
410
|
+
],
|
|
411
|
+
"fix": "Use expo-image for better performance and blurhash",
|
|
412
|
+
"severity": "high",
|
|
413
|
+
"platforms": ["react-native", "nativewind"],
|
|
414
|
+
},
|
|
415
|
+
{
|
|
416
|
+
"id": "EXPO-004",
|
|
417
|
+
"category": "expo",
|
|
418
|
+
"description": "Missing borderCurve: 'continuous' on rounded elements",
|
|
419
|
+
"patterns": [
|
|
420
|
+
r"""borderRadius\s*:\s*\d+(?!.*borderCurve)""",
|
|
421
|
+
],
|
|
422
|
+
"fix": "Add borderCurve: 'continuous' for Apple squircle corners",
|
|
423
|
+
"severity": "medium",
|
|
424
|
+
"platforms": ["react-native", "nativewind"],
|
|
425
|
+
},
|
|
426
|
+
]
|
|
427
|
+
|
|
428
|
+
# ---------------------------------------------------------------------------
|
|
429
|
+
# Scanner
|
|
430
|
+
# ---------------------------------------------------------------------------
|
|
431
|
+
|
|
432
|
+
def scan_directory(path, project_context=None):
|
|
433
|
+
"""Recursively find all UI files, returning (filepath, framework) tuples.
|
|
434
|
+
|
|
435
|
+
When *project_context* is provided (from detect_project_context()), .tsx
|
|
436
|
+
and .jsx files are re-tagged to "react-native" or "nativewind" so that
|
|
437
|
+
Expo-specific rules are applied to them.
|
|
438
|
+
"""
|
|
439
|
+
path = Path(path)
|
|
440
|
+
results = []
|
|
441
|
+
if not path.is_dir():
|
|
442
|
+
return results
|
|
443
|
+
|
|
444
|
+
framework_override = (project_context or {}).get("framework_override")
|
|
445
|
+
|
|
446
|
+
for item in path.rglob("*"):
|
|
447
|
+
# Skip ignored directories
|
|
448
|
+
if any(part in IGNORED_DIRS for part in item.parts):
|
|
449
|
+
continue
|
|
450
|
+
if not item.is_file():
|
|
451
|
+
continue
|
|
452
|
+
ext = item.suffix.lower()
|
|
453
|
+
if ext in UI_FILE_EXTENSIONS:
|
|
454
|
+
framework = UI_FILE_EXTENSIONS[ext]
|
|
455
|
+
# Override framework for .tsx/.jsx files in Expo/RN projects
|
|
456
|
+
if framework_override and ext in (".tsx", ".jsx", ".ts"):
|
|
457
|
+
framework = framework_override
|
|
458
|
+
# For .ts files, only include if they contain JSX-like patterns
|
|
459
|
+
if ext == ".ts":
|
|
460
|
+
try:
|
|
461
|
+
content = item.read_text(errors="ignore")
|
|
462
|
+
if not re.search(r"<\w+[\s/>]|jsx|tsx", content):
|
|
463
|
+
continue
|
|
464
|
+
except Exception:
|
|
465
|
+
continue
|
|
466
|
+
results.append((item, framework))
|
|
467
|
+
return results
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def analyze_file(filepath, framework):
|
|
471
|
+
"""Analyze a single file for HIG violations."""
|
|
472
|
+
filepath = Path(filepath)
|
|
473
|
+
try:
|
|
474
|
+
content = filepath.read_text(errors="ignore")
|
|
475
|
+
except Exception:
|
|
476
|
+
return []
|
|
477
|
+
|
|
478
|
+
lines = content.split("\n")
|
|
479
|
+
violations = []
|
|
480
|
+
|
|
481
|
+
all_rules = list(HIG_RULES)
|
|
482
|
+
if framework in ("react-native", "nativewind"):
|
|
483
|
+
all_rules = all_rules + list(EXPO_RULES)
|
|
484
|
+
|
|
485
|
+
for rule in all_rules:
|
|
486
|
+
platforms = rule.get("platforms", ["all"])
|
|
487
|
+
if "all" not in platforms and framework not in platforms:
|
|
488
|
+
continue
|
|
489
|
+
|
|
490
|
+
# Pattern-based checks
|
|
491
|
+
for pattern in rule["patterns"]:
|
|
492
|
+
for i, line in enumerate(lines, 1):
|
|
493
|
+
if re.search(pattern, line, re.IGNORECASE):
|
|
494
|
+
violations.append({
|
|
495
|
+
"rule_id": rule["id"],
|
|
496
|
+
"category": rule["category"],
|
|
497
|
+
"severity": rule["severity"],
|
|
498
|
+
"description": rule["description"],
|
|
499
|
+
"file": str(filepath),
|
|
500
|
+
"line": i,
|
|
501
|
+
"current": line.strip(),
|
|
502
|
+
"fix": rule["fix"],
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
# File-level checks (dark mode)
|
|
506
|
+
if framework in ("css", "scss") and not re.search(r"prefers-color-scheme", content):
|
|
507
|
+
# Only flag if file has actual styles
|
|
508
|
+
if re.search(r"[{;]", content):
|
|
509
|
+
dark_rule = next(r for r in HIG_RULES if r["id"] == "DARK-001")
|
|
510
|
+
violations.append({
|
|
511
|
+
"rule_id": "DARK-001",
|
|
512
|
+
"category": dark_rule["category"],
|
|
513
|
+
"severity": dark_rule["severity"],
|
|
514
|
+
"description": dark_rule["description"],
|
|
515
|
+
"file": str(filepath),
|
|
516
|
+
"line": 1,
|
|
517
|
+
"current": "(file-level: no dark mode support detected)",
|
|
518
|
+
"fix": dark_rule["fix"],
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
if framework == "react" and not re.search(r"useColorScheme|colorScheme|Appearance|dark\s*mode", content, re.IGNORECASE):
|
|
522
|
+
# Only flag if file has style-like content
|
|
523
|
+
if re.search(r"style|color|background|theme", content, re.IGNORECASE):
|
|
524
|
+
dark_rule = next(r for r in HIG_RULES if r["id"] == "DARK-002")
|
|
525
|
+
violations.append({
|
|
526
|
+
"rule_id": "DARK-002",
|
|
527
|
+
"category": dark_rule["category"],
|
|
528
|
+
"severity": dark_rule["severity"],
|
|
529
|
+
"description": dark_rule["description"],
|
|
530
|
+
"file": str(filepath),
|
|
531
|
+
"line": 1,
|
|
532
|
+
"current": "(file-level: no dark mode handling detected)",
|
|
533
|
+
"fix": dark_rule["fix"],
|
|
534
|
+
})
|
|
535
|
+
|
|
536
|
+
return violations
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def scan_project(path):
|
|
540
|
+
"""Full project scan — returns dict with files_scanned, violations, summary."""
|
|
541
|
+
path = Path(path)
|
|
542
|
+
project_context = detect_project_context(path)
|
|
543
|
+
files = scan_directory(path, project_context=project_context)
|
|
544
|
+
all_violations = []
|
|
545
|
+
for filepath, framework in files:
|
|
546
|
+
violations = analyze_file(filepath, framework)
|
|
547
|
+
all_violations.extend(violations)
|
|
548
|
+
result = {
|
|
549
|
+
"files_scanned": len(files),
|
|
550
|
+
"violations": all_violations,
|
|
551
|
+
"summary": summarize(all_violations),
|
|
552
|
+
}
|
|
553
|
+
if project_context["framework_override"]:
|
|
554
|
+
result["project_context"] = project_context
|
|
555
|
+
return result
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def summarize(violations):
|
|
559
|
+
"""Count violations by category and severity, compute HIG score."""
|
|
560
|
+
by_category = {}
|
|
561
|
+
by_severity = {"critical": 0, "high": 0, "medium": 0, "low": 0}
|
|
562
|
+
for v in violations:
|
|
563
|
+
cat = v["category"]
|
|
564
|
+
by_category[cat] = by_category.get(cat, 0) + 1
|
|
565
|
+
by_severity[v["severity"]] += 1
|
|
566
|
+
score = max(
|
|
567
|
+
0,
|
|
568
|
+
100
|
|
569
|
+
- by_severity["critical"] * 5
|
|
570
|
+
- by_severity["high"] * 3
|
|
571
|
+
- by_severity["medium"] * 1
|
|
572
|
+
- by_severity["low"] * 0.5,
|
|
573
|
+
)
|
|
574
|
+
return {
|
|
575
|
+
"by_category": by_category,
|
|
576
|
+
"by_severity": by_severity,
|
|
577
|
+
"score": round(score),
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
# ---------------------------------------------------------------------------
|
|
582
|
+
# Grade
|
|
583
|
+
# ---------------------------------------------------------------------------
|
|
584
|
+
|
|
585
|
+
def _grade(score):
|
|
586
|
+
if score >= 90:
|
|
587
|
+
return "A"
|
|
588
|
+
elif score >= 80:
|
|
589
|
+
return "B"
|
|
590
|
+
elif score >= 70:
|
|
591
|
+
return "C"
|
|
592
|
+
elif score >= 60:
|
|
593
|
+
return "D"
|
|
594
|
+
else:
|
|
595
|
+
return "F"
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
# ---------------------------------------------------------------------------
|
|
599
|
+
# Category Impact Descriptions
|
|
600
|
+
# ---------------------------------------------------------------------------
|
|
601
|
+
|
|
602
|
+
_CATEGORY_IMPACT = {
|
|
603
|
+
"colors": "Replace hardcoded colors with Apple semantic tokens",
|
|
604
|
+
"typography": "Update font stack and sizes to Apple type scale",
|
|
605
|
+
"spacing": "Align to 4pt grid",
|
|
606
|
+
"corners": "Use Apple corner radii",
|
|
607
|
+
"animation": "Replace easing with Apple curves",
|
|
608
|
+
"touch": "Increase touch targets to 44px minimum",
|
|
609
|
+
"icons": "Replace emoji icons with SF Symbols or SVG",
|
|
610
|
+
"dark-mode": "Add dark mode support",
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
# ---------------------------------------------------------------------------
|
|
614
|
+
# Report Generator
|
|
615
|
+
# ---------------------------------------------------------------------------
|
|
616
|
+
|
|
617
|
+
_SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3}
|
|
618
|
+
_SEVERITY_LABEL = {"critical": "CRITICAL", "high": "HIGH", "medium": "MEDIUM", "low": "LOW"}
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def generate_report(scan_result, output_path=None):
|
|
622
|
+
"""Generate an AI-actionable redesign report in markdown format.
|
|
623
|
+
|
|
624
|
+
Returns the report string. If *output_path* is given, also writes to disk.
|
|
625
|
+
"""
|
|
626
|
+
violations = scan_result["violations"]
|
|
627
|
+
summary = scan_result["summary"]
|
|
628
|
+
files_scanned = scan_result["files_scanned"]
|
|
629
|
+
score = summary["score"]
|
|
630
|
+
grade = _grade(score)
|
|
631
|
+
total = len(violations)
|
|
632
|
+
sev = summary["by_severity"]
|
|
633
|
+
|
|
634
|
+
lines = []
|
|
635
|
+
lines.append("# OneMore Redesign Report [BETA]")
|
|
636
|
+
lines.append("")
|
|
637
|
+
lines.append(f"**Files scanned:** {files_scanned}")
|
|
638
|
+
lines.append(f"**HIG Score:** {score}/100 (Grade: {grade})")
|
|
639
|
+
lines.append(
|
|
640
|
+
f"**Violations:** {total} total "
|
|
641
|
+
f"({sev['critical']} critical, {sev['high']} high, "
|
|
642
|
+
f"{sev['medium']} medium, {sev['low']} low)"
|
|
643
|
+
)
|
|
644
|
+
lines.append("")
|
|
645
|
+
|
|
646
|
+
# ── Summary table ───────────────────────────────────────────────────
|
|
647
|
+
lines.append("## Summary")
|
|
648
|
+
lines.append("")
|
|
649
|
+
if summary["by_category"]:
|
|
650
|
+
lines.append("| Category | Violations | Impact |")
|
|
651
|
+
lines.append("|----------|-----------|--------|")
|
|
652
|
+
for cat, count in sorted(summary["by_category"].items(), key=lambda x: -x[1]):
|
|
653
|
+
impact = _CATEGORY_IMPACT.get(cat, "Review and fix")
|
|
654
|
+
lines.append(f"| {cat.replace('-', ' ').title()} | {count} | {impact} |")
|
|
655
|
+
lines.append("")
|
|
656
|
+
else:
|
|
657
|
+
lines.append("No violations found. Your project is HIG-compliant!")
|
|
658
|
+
lines.append("")
|
|
659
|
+
|
|
660
|
+
# ── Violations by file ──────────────────────────────────────────────
|
|
661
|
+
if violations:
|
|
662
|
+
lines.append("## Violations by File")
|
|
663
|
+
lines.append("")
|
|
664
|
+
|
|
665
|
+
# Group by file
|
|
666
|
+
by_file = {}
|
|
667
|
+
for v in violations:
|
|
668
|
+
by_file.setdefault(v["file"], []).append(v)
|
|
669
|
+
|
|
670
|
+
for filepath in sorted(by_file):
|
|
671
|
+
lines.append(f"### {filepath}")
|
|
672
|
+
lines.append("")
|
|
673
|
+
file_violations = sorted(
|
|
674
|
+
by_file[filepath],
|
|
675
|
+
key=lambda v: (_SEVERITY_ORDER.get(v["severity"], 9), v["line"]),
|
|
676
|
+
)
|
|
677
|
+
for v in file_violations:
|
|
678
|
+
label = _SEVERITY_LABEL.get(v["severity"], v["severity"].upper())
|
|
679
|
+
lines.append(f"**[{label}] {v['rule_id']}** (line {v['line']})")
|
|
680
|
+
lines.append("```")
|
|
681
|
+
lines.append(f"Current: {v['current']}")
|
|
682
|
+
lines.append(f"Fix: {v['fix']}")
|
|
683
|
+
lines.append(f"Reason: {v['description']}")
|
|
684
|
+
lines.append("```")
|
|
685
|
+
lines.append("")
|
|
686
|
+
|
|
687
|
+
# ── Quick fix guide ─────────────────────────────────────────────────
|
|
688
|
+
lines.append("## Quick Fix Guide")
|
|
689
|
+
lines.append("")
|
|
690
|
+
lines.append("To apply these changes, ask your AI agent:")
|
|
691
|
+
lines.append('"Read the redesign report at ./redesign-report.md and apply all fixes"')
|
|
692
|
+
lines.append("")
|
|
693
|
+
lines.append("---")
|
|
694
|
+
lines.append("*Generated by OneMore [BETA] — Apple HIG Design Intelligence*")
|
|
695
|
+
lines.append("")
|
|
696
|
+
|
|
697
|
+
report = "\n".join(lines)
|
|
698
|
+
|
|
699
|
+
if output_path:
|
|
700
|
+
Path(output_path).write_text(report, encoding="utf-8")
|
|
701
|
+
|
|
702
|
+
return report
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
# ---------------------------------------------------------------------------
|
|
706
|
+
# CLI Entry Point
|
|
707
|
+
# ---------------------------------------------------------------------------
|
|
708
|
+
|
|
709
|
+
def cli_main(args=None):
|
|
710
|
+
"""Handle `onemore redesign <path> [options]`."""
|
|
711
|
+
import argparse
|
|
712
|
+
|
|
713
|
+
parser = argparse.ArgumentParser(
|
|
714
|
+
prog="onemore redesign",
|
|
715
|
+
description="Scan a project for Apple HIG violations and generate a redesign report.",
|
|
716
|
+
)
|
|
717
|
+
parser.add_argument(
|
|
718
|
+
"path",
|
|
719
|
+
nargs="?",
|
|
720
|
+
default=".",
|
|
721
|
+
help="Directory to scan (default: current directory)",
|
|
722
|
+
)
|
|
723
|
+
parser.add_argument(
|
|
724
|
+
"--output", "-o",
|
|
725
|
+
metavar="FILE",
|
|
726
|
+
help="Output file path (default: redesign-report.md in scanned directory)",
|
|
727
|
+
)
|
|
728
|
+
parser.add_argument(
|
|
729
|
+
"--json",
|
|
730
|
+
action="store_true",
|
|
731
|
+
dest="json_output",
|
|
732
|
+
help="Output as JSON instead of markdown",
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
parsed = parser.parse_args(args)
|
|
736
|
+
scan_path = Path(parsed.path).resolve()
|
|
737
|
+
|
|
738
|
+
if not scan_path.is_dir():
|
|
739
|
+
print(f"Error: '{scan_path}' is not a directory.", file=sys.stderr)
|
|
740
|
+
return 1
|
|
741
|
+
|
|
742
|
+
result = scan_project(scan_path)
|
|
743
|
+
|
|
744
|
+
if parsed.json_output:
|
|
745
|
+
print(json.dumps(result, indent=2, default=str))
|
|
746
|
+
else:
|
|
747
|
+
output_path = parsed.output
|
|
748
|
+
if not output_path:
|
|
749
|
+
output_path = scan_path / "redesign-report.md"
|
|
750
|
+
report = generate_report(result, output_path=output_path)
|
|
751
|
+
print(report)
|
|
752
|
+
print(f"\nReport saved to {output_path}", file=sys.stderr)
|
|
753
|
+
|
|
754
|
+
return 0
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
if __name__ == "__main__":
|
|
758
|
+
sys.exit(cli_main())
|