Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
37 changes: 37 additions & 0 deletions scripts/validate_site.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from __future__ import annotations

import re
import sys
from pathlib import Path

Expand All @@ -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()
Expand Down Expand Up @@ -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",
Expand Down
96 changes: 96 additions & 0 deletions tests/test_site_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down