From 53592ad928414f8f1479ec52b88bfb9c81118f55 Mon Sep 17 00:00:00 2001 From: yasman Date: Wed, 16 Sep 2026 16:41:53 +0700 Subject: [PATCH] Harden accessibility regression coverage --- CHANGELOG.md | 1 + scripts/validate_site.py | 37 +++++++++++++++ tests/test_site_browser.py | 96 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 134 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2383c6b..e9f67dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Formatnya mengikuti prinsip [Keep a Changelog](https://keepachangelog.com/) dan ### Changed +- Phase 5 accessibility/regression memperluas browser contract ke keyboard focus/activation, focus-not-obscured, reduced-motion, viewport 320/360/390/430/820px, touch target mobile, serta core color contrast checks. - Mobile Phase 4 menghapus persistent bottom dock dan `mobile.js`, menjadikan setup small-screen task-first dengan sticky 4-step rail, target sentuh minimal 44px, header lebih fokus, serta regression 390px/320px; responsive homepage CSS juga dikunci agar kurung stylesheet seimbang. - Setup desktop dirombak menjadi workspace 4 langkah yang lebih padat: sticky step navigation, pack summary ringkas, action hierarchy lebih jelas, dan course pack sebagai accordion rows; browser regression mengunci desktop state sekaligus mobile no-overflow. - Homepage Phase 2 mengganti fake ChatGPT phone mockup dengan live Ramu pack preview, menambahkan jalur Pack vs Ramu Starter, memadatkan workflow/layer hierarchy, dan mengubah course catalog menjadi resource list yang lebih tenang di desktop maupun mobile. diff --git a/scripts/validate_site.py b/scripts/validate_site.py index ceb8456..81a43c2 100644 --- a/scripts/validate_site.py +++ b/scripts/validate_site.py @@ -3,6 +3,7 @@ from __future__ import annotations +import re import sys from pathlib import Path @@ -26,6 +27,18 @@ def require_text(path: Path, needles: list[str]) -> None: fail(f"{path.relative_to(ROOT)} kehilangan marker {needle!r}") +def relative_luminance(value: str) -> float: + raw = value.lstrip("#") + rgb = [int(raw[i:i + 2], 16) / 255 for i in (0, 2, 4)] + linear = [c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 for c in rgb] + return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2] + + +def contrast_ratio(first: str, second: str) -> float: + a, b = relative_luminance(first), relative_luminance(second) + return (max(a, b) + 0.05) / (min(a, b) + 0.05) + + def main() -> int: try: catalog = load_pack_index() @@ -112,6 +125,30 @@ def main() -> int: "@media (max-width: 430px)", ]) + foundations_text = (SITE / "foundations.css").read_text(encoding="utf-8") if (SITE / "foundations.css").is_file() else "" + motion_text = (SITE / "motion.css").read_text(encoding="utf-8") if (SITE / "motion.css").is_file() else "" + tokens = dict(re.findall(r"--([a-z0-9-]+):\s*(#[0-9a-fA-F]{6})\s*;", foundations_text)) + contrast_contracts = [ + ("text", "bg", 4.5), + ("text-muted", "bg", 4.5), + ("text-muted", "surface", 4.5), + ("accent", "surface", 4.5), + ("focus", "bg", 3.0), + ("focus", "surface", 3.0), + ("surface", "accent-strong", 4.5), + ] + for foreground, background, minimum in contrast_contracts: + if foreground not in tokens or background not in tokens: + fail(f"Core color token hilang untuk contrast contract: {foreground}/{background}") + continue + ratio = contrast_ratio(tokens[foreground], tokens[background]) + if ratio < minimum: + fail(f"Contrast {foreground}/{background} hanya {ratio:.2f}:1; minimum {minimum:.1f}:1") + if ":focus-visible" not in foundations_text or "outline: 2px solid var(--focus)" not in foundations_text: + fail("Foundation CSS kehilangan explicit focus-visible ring.") + if "@media (prefers-reduced-motion: reduce)" not in motion_text: + fail("Motion CSS kehilangan prefers-reduced-motion contract.") + app_text = (SITE / "app.js").read_text(encoding="utf-8") if (SITE / "app.js").is_file() else "" for forbidden in ( "const PACK_BASE", diff --git a/tests/test_site_browser.py b/tests/test_site_browser.py index 3aa5ba8..d297fbe 100644 --- a/tests/test_site_browser.py +++ b/tests/test_site_browser.py @@ -51,6 +51,35 @@ def assert_no_page_errors(page: Page, errors: list[str], label: str) -> None: raise AssertionError(f"{label} menghasilkan page error: {errors}") +def tab_until(page: Page, selector: str, limit: int = 40) -> None: + page.evaluate("document.activeElement?.blur()") + for _ in range(limit): + page.keyboard.press("Tab") + if page.evaluate("selector => document.activeElement?.matches(selector)", selector): + return + raise AssertionError(f"Keyboard Tab tidak mencapai {selector}.") + + +def assert_focus_not_obscured(page: Page, selector: str, label: str) -> None: + locator = page.locator(selector).first + locator.scroll_into_view_if_needed() + locator.focus() + result = locator.evaluate("""el => { + const rect = el.getBoundingClientRect(); + const overlays = [...document.querySelectorAll('.site-header, .setup-nav')].filter(node => { + const style = getComputedStyle(node); + return (style.position === 'sticky' || style.position === 'fixed') && !node.contains(el); + }); + const overlaps = overlays.some(node => { + const other = node.getBoundingClientRect(); + return rect.left < other.right && rect.right > other.left && rect.top < other.bottom && rect.bottom > other.top; + }); + return { top: rect.top, bottom: rect.bottom, height: innerHeight, overlaps }; + }""") + if result["top"] < 0 or result["bottom"] > result["height"] or result["overlaps"]: + raise AssertionError(f"Focus {label} obscured/outside viewport: {result}") + + def main() -> int: with tempfile.TemporaryDirectory(prefix="ramu-browser-") as tmp: stage = Path(tmp) @@ -249,6 +278,73 @@ def main() -> int: if narrow_page.evaluate("document.documentElement.scrollWidth - window.innerWidth") > 1: raise AssertionError("Setup pecah pada viewport 320px.") narrow_context.close() + + # Phase 5: keyboard, focus, reduced motion, dan breakpoint accessibility contracts. + access_context = browser.new_context(viewport={"width": 1280, "height": 900}) + access_page = access_context.new_page() + access_page.goto(f"{base_url}/", wait_until="networkidle") + tab_until(access_page, "[data-pack-picker-trigger]") + focus_style = access_page.locator("[data-pack-picker-trigger]").evaluate( + "el => ({style: getComputedStyle(el).outlineStyle, width: parseFloat(getComputedStyle(el).outlineWidth)})" + ) + if focus_style["style"] == "none" or focus_style["width"] < 2: + raise AssertionError(f"Keyboard focus ring tidak cukup terlihat: {focus_style}") + access_page.keyboard.press("Enter") + expect(access_page.locator("[data-pack-picker-trigger]")).to_have_attribute("aria-expanded", "true") + access_page.keyboard.press("Escape") + + access_page.goto(f"{base_url}/setup.html?pack={default_entry['id']}", wait_until="networkidle") + details = access_page.locator("#setup-courses .setup-course").nth(1) + summary = details.locator("summary") + summary.focus() + before_open = details.evaluate("el => el.open") + access_page.keyboard.press("Enter") + if details.evaluate("el => el.open") == before_open: + raise AssertionError("Course disclosure tidak dapat dioperasikan lewat keyboard Enter.") + copy_button = access_page.locator("#copy-instructions") + copy_button.focus() + access_page.keyboard.press("Enter") + access_page.wait_for_function("document.querySelector('#copy-status').textContent.trim().length > 0") + assert_focus_not_obscured(access_page, "#copy-instructions", "copy instructions desktop") + assert_focus_not_obscured(access_page, "#setup-courses .setup-course summary", "course summary desktop") + access_context.close() + + reduced_context = browser.new_context(viewport={"width": 390, "height": 844}, reduced_motion="reduce") + reduced_page = reduced_context.new_page() + reduced_page.goto(f"{base_url}/", wait_until="networkidle") + if not reduced_page.evaluate("matchMedia('(prefers-reduced-motion: reduce)').matches"): + raise AssertionError("Browser reduced-motion context tidak aktif.") + transition_ms = reduced_page.locator("[data-pack-picker-trigger]").evaluate("""el => { + const raw = getComputedStyle(el).transitionDuration.split(',')[0].trim(); + return raw.endsWith('ms') ? parseFloat(raw) : parseFloat(raw) * 1000; + }""") + if transition_ms > 1: + raise AssertionError(f"Reduced-motion masih menyisakan transition panjang: {transition_ms}ms") + reduced_page.locator("[data-pack-picker-trigger]").focus() + reduced_page.keyboard.press("Enter") + expect(reduced_page.locator("[data-pack-picker-trigger]")).to_have_attribute("aria-expanded", "true") + reduced_page.keyboard.press("Escape") + reduced_context.close() + + for width, height in ((360, 780), (430, 844), (820, 900)): + responsive_context = browser.new_context(viewport={"width": width, "height": height}) + responsive_page = responsive_context.new_page() + responsive_page.goto(f"{base_url}/", wait_until="networkidle") + if responsive_page.evaluate("document.documentElement.scrollWidth - window.innerWidth") > 1: + raise AssertionError(f"Homepage overflow pada viewport {width}px.") + responsive_page.goto(f"{base_url}/setup.html?pack={default_entry['id']}", wait_until="networkidle") + if responsive_page.evaluate("document.documentElement.scrollWidth - window.innerWidth") > 1: + raise AssertionError(f"Setup overflow pada viewport {width}px.") + nav_position = responsive_page.locator(".setup-nav").evaluate("el => getComputedStyle(el).position") + expected_position = "sticky" if width <= 720 else "static" + if nav_position != expected_position: + raise AssertionError(f"Setup nav {width}px = {nav_position}, expected {expected_position}.") + if width <= 720: + step_links = responsive_page.locator('.setup-nav a[href^="#langkah-"]') + if any(step_links.nth(i).evaluate("el => el.getBoundingClientRect().height") < 44 for i in range(4)): + raise AssertionError(f"Touch target setup <44px pada viewport {width}px.") + assert_focus_not_obscured(responsive_page, "#copy-instructions", f"copy instructions {width}px") + responsive_context.close() browser.close() finally: server.shutdown()