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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/SKILL.md +180 -0
  3. package/bin/onemore.js +23 -0
  4. package/data/audit/hig-checklist.csv +39 -0
  5. package/data/components/content.csv +17 -0
  6. package/data/components/controls.csv +21 -0
  7. package/data/components/feedback.csv +17 -0
  8. package/data/components/input.csv +11 -0
  9. package/data/components/navigation.csv +15 -0
  10. package/data/foundations/colors.csv +38 -0
  11. package/data/foundations/corners.csv +13 -0
  12. package/data/foundations/elevation.csv +17 -0
  13. package/data/foundations/spacing.csv +21 -0
  14. package/data/foundations/typography.csv +26 -0
  15. package/data/patterns/animation.csv +24 -0
  16. package/data/patterns/gestures.csv +11 -0
  17. package/data/patterns/interaction.csv +16 -0
  18. package/data/patterns/layout.csv +20 -0
  19. package/data/platforms/ios.csv +21 -0
  20. package/data/platforms/macos.csv +16 -0
  21. package/data/platforms/visionos.csv +11 -0
  22. package/data/platforms/watchos.csv +11 -0
  23. package/data/platforms/web-apple.csv +21 -0
  24. package/data/reasoning/apple-reasoning.csv +16 -0
  25. package/data/stacks/astro.csv +21 -0
  26. package/data/stacks/flutter.csv +29 -0
  27. package/data/stacks/html-tailwind.csv +26 -0
  28. package/data/stacks/nativewind.csv +26 -0
  29. package/data/stacks/nextjs.csv +26 -0
  30. package/data/stacks/nuxtjs.csv +21 -0
  31. package/data/stacks/react-native.csv +26 -0
  32. package/data/stacks/react.csv +26 -0
  33. package/data/stacks/shadcn.csv +25 -0
  34. package/data/stacks/svelte.csv +21 -0
  35. package/data/stacks/swiftui.csv +31 -0
  36. package/data/stacks/uikit.csv +21 -0
  37. package/data/stacks/vue.csv +21 -0
  38. package/package.json +51 -0
  39. package/scripts/__init__.py +0 -0
  40. package/scripts/__pycache__/__init__.cpython-314.pyc +0 -0
  41. package/scripts/__pycache__/core.cpython-314.pyc +0 -0
  42. package/scripts/__pycache__/design_system.cpython-314.pyc +0 -0
  43. package/scripts/__pycache__/exporter.cpython-314.pyc +0 -0
  44. package/scripts/__pycache__/platforms.cpython-314.pyc +0 -0
  45. package/scripts/__pycache__/redesign.cpython-314.pyc +0 -0
  46. package/scripts/core.py +242 -0
  47. package/scripts/design_system.py +291 -0
  48. package/scripts/exporter.py +717 -0
  49. package/scripts/platforms.py +309 -0
  50. package/scripts/redesign.py +758 -0
  51. 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())