#!/usr/bin/env python3 """ Verify that all modal/overlay UI components close on Escape key. Enforces .cursor/rules/ui/modal-escape-policy.mdc: - Any modal/overlay UI MUST close on Escape. - A "modal" includes UI using createPortal, meld-modal-overlay, or *Modal* components. This script: 1. Finds modal components (filename pattern or content indicators) 2. Checks for Escape handling (useEscapeToClose, e.key === "Escape", etc.) 3. Reports modal components that lack Escape handling Scope: ui/src/**/*.{ts,tsx} """ import fnmatch import os import re import sys # ----------------------------------------------------------------------------- # Modal component detection # ----------------------------------------------------------------------------- # Filename patterns that indicate a modal/overlay component MODAL_FILENAME_PATTERNS = ("*Modal*", "*Popup*", "*Overlay*") def matches_modal_filename(filename: str) -> bool: """Return True if filename matches any modal pattern (tsx only; excludes hooks/helpers).""" if not filename.endswith(".tsx"): return False return any(fnmatch.fnmatch(filename, p) for p in MODAL_FILENAME_PATTERNS) # Content patterns: use word boundary to avoid false positives (e.g. createPortalRoot) HAS_CREATE_PORTAL = re.compile(r"\bcreatePortal\s*\(", re.MULTILINE) HAS_MELD_MODAL_OVERLAY = re.compile(r"meld-modal-overlay", re.MULTILINE) # Escape handling indicators HAS_USE_ESCAPE_TO_CLOSE = re.compile(r"useEscapeToClose\s*\(\s*\{", re.MULTILINE) HAS_KEY_ESCAPE = re.compile(r'[ek]\.key\s*===\s*["\']Escape["\']', re.MULTILINE) HAS_ON_KEY_DOWN_ESCAPE = re.compile(r"onKeyDown\s*=\s*\{[^}]*[ek]\.key\s*===\s*[\"']Escape[\"']", re.DOTALL) # Handles: onKeyDown={(e) => e.key === "Escape" && ...} HAS_INLINE_KEY_ESCAPE = re.compile(r"onKeyDown=\{[^}]*\.key\s*===\s*[\"']Escape[\"']", re.DOTALL) def has_create_portal(content: str) -> bool: """Return True if file uses createPortal(...) from react-dom.""" return bool(HAS_CREATE_PORTAL.search(content)) def has_meld_modal_overlay(content: str) -> bool: """Return True if file uses meld-modal-overlay class.""" return bool(HAS_MELD_MODAL_OVERLAY.search(content)) def is_modal_component(filepath: str, content: str) -> bool: """ Return True if the file is a modal/overlay component per policy. Only .tsx files are considered (React components); .ts hooks/reducers are excluded. A file is a modal if: - Filename matches *Modal*.tsx, *Popup*.tsx, *Overlay*.tsx, OR - Content contains createPortal(...), OR - Content contains meld-modal-overlay (as className, not selector strings) """ filename = os.path.basename(filepath) if matches_modal_filename(filename): return True if has_create_portal(content): return True if has_meld_modal_overlay(content): return True return False def has_escape_handling(content: str) -> bool: """ Return True if the file implements Escape-to-close behavior. Indicators: - useEscapeToClose({ onEscape: ... }) - e.key === "Escape" or e.key === 'Escape' - onKeyDown with Escape check """ if HAS_USE_ESCAPE_TO_CLOSE.search(content): return True if HAS_KEY_ESCAPE.search(content): return True if HAS_ON_KEY_DOWN_ESCAPE.search(content): return True if HAS_INLINE_KEY_ESCAPE.search(content): return True return False # Components that are modal-like but exempt (e.g. wrappers that delegate to children) # Add basenames only; paths are normalized for comparison. ALLOWLIST = frozenset( { "GalleryModals.tsx", # Wrapper that delegates Escape to child modals "ImageViewer.tsx", # Delegates Escape to useImageViewerLogic/useViewerKeyboardShortcuts } ) def is_allowlisted(filepath: str) -> bool: """Return True if the file is in the allowlist.""" basename = os.path.basename(filepath) return basename in ALLOWLIST # ----------------------------------------------------------------------------- # Main check logic # ----------------------------------------------------------------------------- def find_modal_components(ui_src: str) -> list[tuple[str, str]]: """Return list of (filepath, content) for files that are modal components.""" modals: list[tuple[str, str]] = [] for root, _dirs, files in os.walk(ui_src): for f in files: if not f.endswith(".tsx"): continue filepath = os.path.join(root, f) try: content = open(filepath, encoding="utf-8").read() except OSError as e: print(f"Warning: could not read {filepath}: {e}", file=sys.stderr) continue if is_modal_component(filepath, content): modals.append((filepath, content)) return modals def main() -> None: base_dir = os.getcwd() ui_src = os.path.join(base_dir, "ui", "src") if not os.path.isdir(ui_src): print(f"Directory not found: {ui_src}") sys.exit(1) print("Checking modal Escape handling per .cursor/rules/ui/modal-escape-policy.mdc...") modals = find_modal_components(ui_src) violations: list[str] = [] for filepath, content in modals: if is_allowlisted(filepath): continue if not has_escape_handling(content): rel = os.path.relpath(filepath, base_dir) violations.append(rel) if violations: print("\n[!] Modal/overlay components without Escape-to-close handling:") for v in sorted(violations): print(f" - {v}") print("\nPolicy: All modals MUST close on Escape.") print("Add useEscapeToClose({ onEscape: ... }) or handle key === 'Escape'.") print("See .cursor/rules/ui/modal-escape-policy.mdc") sys.exit(1) print("Modal Escape check passed.") sys.exit(0) if __name__ == "__main__": main()