""" Performance rules — detect HTML/CSS signals that hurt page load speed and Core Web Vitals without requiring a real browser: missing font-display, images without alt/dimensions/srcset, and absent responsive breakpoints. """ from __future__ import annotations from backend.models.issue import Category, Issue, Severity _CAT = Category.PERFORMANCE def analyze(parsed_page: dict, thresholds: dict) -> list[Issue]: t = thresholds.get("performance", {}) issues: list[Issue] = [] _check_font_display(parsed_page, issues) _check_image_alt(parsed_page, issues) _check_image_dimensions(parsed_page, issues) _check_responsive_images(parsed_page, issues) _check_mobile_breakpoints(parsed_page, t, issues) return issues # ── P1 — missing font-display ───────────────────────────────────────────────── def _check_font_display(parsed_page: dict, issues: list[Issue]) -> None: web_fonts = parsed_page.get("web_fonts", []) has_display = parsed_page.get("has_font_display", False) if web_fonts and not has_display: issues.append(Issue( rule_id="P1_missing_font_display", category=_CAT, severity=Severity.MEDIUM, confidence=0.90, message=( f"{len(web_fonts)} custom font(s) loaded via @font-face " f"({', '.join(web_fonts[:3])}) without font-display declaration." ), recommendation=( "Add `font-display: swap;` to every @font-face block. " "This renders text immediately with a fallback font while the custom " "font loads, eliminating Flash of Invisible Text (FOIT)." ), evidence=f"web_fonts={web_fonts[:3]}, has_font_display=False", estimated_time="10 minutes", why=( "Without font-display, browsers block text rendering until the font file " "downloads — causing FOIT (invisible text for 0–3 seconds). " "font-display: swap shows fallback text instantly and swaps in the web font " "when ready. Google's Lighthouse audit flags this, and it directly impacts " "First Contentful Paint (FCP) and Core Web Vitals scores." ), references=["MDN font-display", "Google Lighthouse", "web.dev/font-display"], )) # ── P2 — images missing alt text ───────────────────────────────────────────── def _check_image_alt(parsed_page: dict, issues: list[Issue]) -> None: images = parsed_page.get("images", []) missing_alt = [img for img in images if not img.get("has_alt")] if missing_alt: issues.append(Issue( rule_id="P2_missing_image_alt", category=Category.ACCESSIBILITY, # SEO + a11y overlap, primary is accessibility severity=Severity.HIGH, confidence=0.95, message=( f"{len(missing_alt)} image(s) missing alt attribute. " "Screen readers will announce the filename; search engines can't index the content." ), recommendation=( "Add descriptive alt text to every informational image: " "alt='Bar chart showing Q3 revenue growth of 42%'. " "For purely decorative images use alt='' (empty string)." ), evidence=f"images_missing_alt={len(missing_alt)}, total_images={len(images)}", estimated_time="30 minutes", why=( "Alt text is the single most impactful accessibility improvement for " "image-heavy pages. Screen readers read it aloud; without it users hear " "'image.png' or nothing. Google also uses alt text for image search indexing " "— missing alt text is a direct SEO penalty. WCAG 1.1.1 (Level A)." ), references=["WCAG 1.1.1", "Google Image Best Practices", "WebAIM"], )) # ── P3 — images missing explicit dimensions (CLS) ──────────────────────────── def _check_image_dimensions(parsed_page: dict, issues: list[Issue]) -> None: images = parsed_page.get("images", []) no_dims = [ img for img in images if not img.get("has_width") or not img.get("has_height") ] if no_dims: issues.append(Issue( rule_id="P3_layout_shift_images", category=_CAT, severity=Severity.MEDIUM, confidence=0.85, message=( f"{len(no_dims)} image(s) missing explicit width/height attributes — " "causes Cumulative Layout Shift (CLS) as content jumps when images load." ), recommendation=( "Add width and height attributes to every matching the image's " "natural aspect ratio: .... " "CSS will still respect max-width: 100%; the browser reserves space before load." ), evidence=f"images_no_dimensions={len(no_dims)}, total_images={len(images)}", estimated_time="20 minutes", why=( "Without dimensions, the browser doesn't know how much space to reserve " "for an image — content below the image shifts down when it loads (CLS). " "CLS above 0.1 is a Core Web Vitals failure and directly impacts " "Google Search ranking. Layout shift is also jarring for users — " "they may click the wrong element as the page reflows." ), references=["web.dev/cls", "Google Core Web Vitals", "MDN Aspect Ratio Box"], )) # ── P4 — responsive images (srcset) ────────────────────────────────────────── def _check_responsive_images(parsed_page: dict, issues: list[Issue]) -> None: images = parsed_page.get("images", []) no_srcset = [img for img in images if not img.get("has_srcset")] if len(no_srcset) >= 2: issues.append(Issue( rule_id="P4_missing_srcset", category=_CAT, severity=Severity.LOW, confidence=0.75, message=( f"{len(no_srcset)} image(s) lack srcset — mobile users download " "full desktop-size images, wasting bandwidth and slowing load time." ), recommendation=( "Add srcset with multiple resolutions: " "srcset='img-400.jpg 400w, img-800.jpg 800w, img-1200.jpg 1200w' " "sizes='(max-width: 600px) 400px, 800px'. " "Tools like Sharp or Cloudinary can auto-generate responsive variants." ), evidence=f"images_no_srcset={len(no_srcset)}, total_images={len(images)}", estimated_time="2 hours", why=( "A 2MB desktop hero image served to a 375px phone wastes ~1.8MB of data. " "On 4G, that's an extra 1–2 second delay. srcset lets the browser pick " "the right image for the viewport — a critical optimisation for mobile " "users who make up 60%+ of web traffic. Google Lighthouse flags this as " "'Serve images in next-gen formats' and 'Properly size images'." ), references=["MDN Responsive Images", "Google Lighthouse", "web.dev/uses-responsive-images"], )) # ── P5 — no responsive CSS breakpoints ─────────────────────────────────────── def _check_mobile_breakpoints(parsed_page: dict, t: dict, issues: list[Issue]) -> None: breakpoints = parsed_page.get("media_query_breakpoints", []) required = t.get("required_breakpoints", [768]) missing = [bp for bp in required if not any(abs(b - bp) <= 64 for b in breakpoints)] if missing: issues.append(Issue( rule_id="P5_missing_breakpoints", category=_CAT, severity=Severity.HIGH, confidence=0.80, message=( f"No @media query found near {missing}px breakpoint(s). " "The layout may not adapt for mobile/tablet viewports." ), recommendation=( f"Add @media (max-width: {missing[0]}px) rules to adapt typography, " "layout, and spacing for smaller screens. " "Minimum: one breakpoint at 768px (tablet) and one at 480px (phone)." ), evidence=f"detected_breakpoints={breakpoints}, missing={missing}", estimated_time="3 hours", why=( "60%+ of global web traffic is on mobile. A page without @media queries " "renders its desktop layout on phones — creating the horizontal-overflow, " "tiny-text, and overlapping-element issues that drive immediate bounce. " "Google's mobile-first indexing means a non-responsive page ranks lower " "in search regardless of its desktop score." ), references=["Google Mobile-First Indexing", "MDN Responsive Design", "StatCounter Mobile Traffic"], ))