ui-copilot / backend /rules /interactivity_rules.py
ksri77's picture
feat: V1.1 β€” Conversion, UX Quality, Performance, Interactivity rule categories
94278cd
Raw
History Blame Contribute Delete
6.4 kB
"""
Interactivity rules β€” detect static UIs that feel unresponsive: no hover states,
no CSS transitions, focus outline removed without replacement, no keyboard
skip link for long-nav pages.
"""
from __future__ import annotations
from backend.models.issue import Category, Issue, Severity
_CAT = Category.INTERACTIVITY
def analyze(parsed_page: dict, thresholds: dict) -> list[Issue]:
issues: list[Issue] = []
_check_hover_states(parsed_page, issues)
_check_transitions(parsed_page, issues)
_check_focus_outline_removed(parsed_page, issues)
return issues
# ── INT1 β€” missing hover states ───────────────────────────────────────────────
def _check_hover_states(parsed_page: dict, issues: list[Issue]) -> None:
has_hover = parsed_page.get("has_hover_styles", False)
buttons = parsed_page.get("buttons", [])
inputs = parsed_page.get("inputs", [])
cta_texts = parsed_page.get("cta_texts", [])
# Only flag pages with interactive elements
interactive_count = len(buttons) + len(inputs) + len(cta_texts)
if not has_hover and interactive_count >= 2:
issues.append(Issue(
rule_id="INT1_missing_hover_states",
category=_CAT,
severity=Severity.MEDIUM,
confidence=0.75,
message=(
f"No :hover CSS rules detected on a page with {interactive_count} "
"interactive elements β€” buttons/links give no visual feedback on hover."
),
recommendation=(
"Add :hover styles to all clickable elements: "
"button:hover { background-color: darken(primary, 10%); transform: translateY(-1px); } "
"a:hover { text-decoration: underline; color: darken(link, 15%); }. "
"Hover states should always change at least one visual property."
),
evidence=f"has_hover_styles=False, interactive_elements={interactive_count}",
estimated_time="45 minutes",
why=(
"Hover states are the primary affordance signal for desktop users β€” "
"they confirm 'this element is clickable' before the click. "
"Without hover feedback, users hesitate: 'Is this a button or just text?'. "
"Desktop users spend ~200ms on hover before clicking β€” that's 200ms of "
"expected feedback. Missing it makes the UI feel broken or unfinished."
),
references=["Nielsen Norman Group β€” Affordances", "WCAG 2.4.13", "Material Design States"],
))
# ── INT2 β€” no CSS transitions ─────────────────────────────────────────────────
def _check_transitions(parsed_page: dict, issues: list[Issue]) -> None:
has_transitions = parsed_page.get("has_transitions", False)
buttons = parsed_page.get("buttons", [])
inputs = parsed_page.get("inputs", [])
interactive_count = len(buttons) + len(inputs)
if not has_transitions and interactive_count >= 2:
issues.append(Issue(
rule_id="INT2_no_transitions",
category=_CAT,
severity=Severity.LOW,
confidence=0.70,
message=(
f"No CSS transitions or animations detected on a page with "
f"{interactive_count} interactive elements."
),
recommendation=(
"Add subtle transitions to interactive elements: "
"button { transition: background-color 150ms ease, transform 100ms ease; } "
"input:focus { transition: border-color 200ms ease, box-shadow 200ms ease; }. "
"Keep durations 100–300ms for state changes, 200–500ms for reveals."
),
evidence=f"has_transitions=False, interactive_elements={interactive_count}",
estimated_time="30 minutes",
why=(
"Instant state changes (button click, input focus, modal open) feel "
"abrupt and cheap. Micro-transitions (100–300ms) communicate state "
"change while feeling polished and intentional. Google's Material Design "
"and Apple's HIG both mandate motion as a core design primitive. "
"Studies show subtle motion increases perceived quality and user trust."
),
references=["Material Design Motion", "Apple HIG Animation", "val Head β€” CSS Animations"],
))
# ── INT3 β€” focus outline removed ─────────────────────────────────────────────
def _check_focus_outline_removed(parsed_page: dict, issues: list[Issue]) -> None:
outline_removed = parsed_page.get("has_focus_outline_removed", False)
if outline_removed:
issues.append(Issue(
rule_id="INT3_focus_outline_removed",
category=Category.ACCESSIBILITY,
severity=Severity.HIGH,
confidence=0.90,
message=(
"CSS contains `outline: none` or `outline: 0` β€” "
"keyboard focus indicator is removed, breaking keyboard navigation."
),
recommendation=(
"Never remove outline globally. Instead: "
":focus { outline: none; } "
":focus-visible { outline: 3px solid #005fcc; outline-offset: 2px; } "
"This hides the ring for mouse users while preserving it for keyboard users."
),
evidence="has_focus_outline_removed=True",
estimated_time="20 minutes",
why=(
"Removing outline: none without a replacement means keyboard users "
"lose all focus visibility β€” they cannot tell which element is active. "
"This is one of the most common accessibility anti-patterns and a direct "
"WCAG 2.4.7 (Level AA) violation. It makes the UI completely unusable for "
"keyboard-only users, including many people with motor disabilities."
),
references=["WCAG 2.4.7", "WebAIM Keyboard Accessibility", "CSS Tricks :focus-visible"],
))