The browser fixture opens phones from Playwright's own device presets (360x800, iPhone SE 375x667, Pixel 7 412x839, and the Pixel 7 held sideways), with touch, a coarse pointer and reduced motion, and a phone session fails at once if the page doesn't see a touch screen. Mobile/fit.js measures a page one screen at a time down its main scroller: sideways scroll, boxes past the screen's sides, open menus and dialogs cut or outside the screen, targets without a 44px hit area (hit-tested at their centre and 21px around it, so a crowding neighbour fails them too) and control text under 12px. RouteSweepTests runs every feed tab, threads of each kind the town holds, profiles, search, Explore, lists, groups, filters, scheduled posts, settings and administration (every section opened) and the login page on the three portrait phones, as client.mobile.fit.* and client.mobile.touch.* cells; screenshots, findings and a contact sheet go to tests/e2e/out/mobile/. DesktopUnchangedTests holds desktop's control sizes. Category=Mobile. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
268 lines
10 KiB
JavaScript
268 lines
10 KiB
JavaScript
// The phone tests' measurements (Mobile/Fit.cs), added to every page of a phone session as an init script. Each call
|
|
// returns plain data, so no element handle outlives a scroll: Virtualize recycles the feed's cards.
|
|
// settle() waits for fonts, for the page to stop changing, two frames and the pictures in view
|
|
// primary() marks the page's main scroller, the one the sweep steps through
|
|
// step(f) scrolls it by f of its height; false at its end
|
|
// measure() what doesn't fit: sideways overflow, boxes beyond the screen, cut popovers, small targets, small text
|
|
window.__fit = (() => {
|
|
const scrolls = value => value === "auto" || value === "scroll"
|
|
const shown = el => el.checkVisibility({ opacityProperty: true, visibilityProperty: true })
|
|
let styles = new Map()
|
|
const style = el => {
|
|
let found = styles.get(el)
|
|
if (!found)
|
|
styles.set(el, found = getComputedStyle(el))
|
|
return found
|
|
}
|
|
|
|
// a short name for an element: its id, else its first data-* attribute, else tags and classes up to three levels
|
|
function name(el, depth = 0) {
|
|
if (el === document.scrollingElement || el === document.documentElement || el === document.body)
|
|
return "document"
|
|
if (el.id)
|
|
return "#" + el.id
|
|
const data = [...el.attributes].find(a => a.name.startsWith("data-") && !a.name.startsWith("data-fit"))
|
|
const own = el.tagName.toLowerCase() + (data ? `[${data.name}]` : [...el.classList].slice(0, 3).map(c => "." + c).join(""))
|
|
const parent = el.parentElement
|
|
return data || depth >= 2 || !parent || parent === document.body ? own : `${name(parent, depth + 1)} > ${own}`
|
|
}
|
|
|
|
const text = el => (el.getAttribute("aria-label") || el.getAttribute("title") || el.innerText || el.value || "")
|
|
.trim().replace(/\s+/g, " ").slice(0, 40)
|
|
|
|
// the card an element is in (its ActivityPub id), for finding it again
|
|
const card = el => el.closest("[data-uri]")?.getAttribute("data-uri") ?? null
|
|
|
|
function describe(el, box) {
|
|
return {
|
|
element: name(el), text: text(el), card: card(el),
|
|
box: [box.left, box.top, box.width, box.height].map(Math.round)
|
|
}
|
|
}
|
|
|
|
function scrollers() {
|
|
const found = [document.scrollingElement]
|
|
for (const el of document.body.querySelectorAll("*")) {
|
|
const s = style(el)
|
|
if ((scrolls(s.overflowX) || scrolls(s.overflowY)) && el.clientWidth > 0 && el.clientHeight > 0 && shown(el))
|
|
found.push(el)
|
|
}
|
|
return found
|
|
}
|
|
|
|
// the part of an element's box that can be seen: cut by every ancestor that clips, up to the screen for a fixed one
|
|
function visibleRect(el) {
|
|
const own = el.getBoundingClientRect()
|
|
const box = { left: own.left, top: own.top, right: own.right, bottom: own.bottom }
|
|
if (style(el).position !== "fixed")
|
|
for (let a = el.parentElement; a && a !== document.body && a !== document.documentElement; a = a.parentElement) {
|
|
const s = style(a)
|
|
if (s.overflowX !== "visible" || s.overflowY !== "visible") {
|
|
const clip = a.getBoundingClientRect()
|
|
if (s.overflowX !== "visible") {
|
|
box.left = Math.max(box.left, clip.left)
|
|
box.right = Math.min(box.right, clip.right)
|
|
}
|
|
if (s.overflowY !== "visible") {
|
|
box.top = Math.max(box.top, clip.top)
|
|
box.bottom = Math.min(box.bottom, clip.bottom)
|
|
}
|
|
}
|
|
if (s.position === "fixed")
|
|
break
|
|
}
|
|
return box
|
|
}
|
|
|
|
// sideways scroll in the document or in a scroller that isn't meant to scroll sideways (data-scroll-x), with the
|
|
// element reaching furthest right
|
|
function overflow() {
|
|
const found = []
|
|
for (const scroller of scrollers()) {
|
|
if (scroller.hasAttribute?.("data-scroll-x"))
|
|
continue
|
|
const extra = scroller.scrollWidth - scroller.clientWidth
|
|
if (extra <= 1)
|
|
continue
|
|
const limit = scroller === document.scrollingElement ? innerWidth : scroller.getBoundingClientRect().right
|
|
let worst = null, right = limit + 0.5
|
|
for (const el of scroller.querySelectorAll("*")) {
|
|
const r = el.getBoundingClientRect()
|
|
if (r.width > 0 && r.right > right && !el.closest("[data-scroll-x]")) {
|
|
worst = el
|
|
right = r.right
|
|
}
|
|
}
|
|
found.push({
|
|
element: name(scroller), extra: Math.round(extra),
|
|
culprit: worst ? { ...describe(worst, worst.getBoundingClientRect()), past: Math.round(right - limit) } : null
|
|
})
|
|
}
|
|
return found
|
|
}
|
|
|
|
// what can be seen of it lies past the screen's sides: the outermost such element only
|
|
function beyond() {
|
|
const found = [], reported = []
|
|
for (const el of document.body.querySelectorAll("*")) {
|
|
if (found.length >= 30)
|
|
break
|
|
if (el.closest("[data-scroll-x]") !== null && !el.hasAttribute("data-scroll-x"))
|
|
continue
|
|
const own = el.getBoundingClientRect()
|
|
if (own.width <= 1 && own.height <= 1 || reported.some(r => r.contains(el)) || !shown(el))
|
|
continue
|
|
const seen = visibleRect(el)
|
|
if (seen.right - seen.left <= 0.5 || seen.bottom - seen.top <= 0.5)
|
|
continue
|
|
if (seen.right > innerWidth + 1 || seen.left < -1) {
|
|
reported.push(el)
|
|
found.push(describe(el, own))
|
|
}
|
|
}
|
|
return found
|
|
}
|
|
|
|
// open menus, dialogs, sheets and tips must be whole: inside the screen and not cut by what holds them
|
|
function popovers() {
|
|
const found = []
|
|
for (const el of document.querySelectorAll(".dropdown-content, .modal-open > .modal-box, [data-sheet], [data-nav-panel], .tooltip-content")) {
|
|
if (!shown(el))
|
|
continue
|
|
const own = el.getBoundingClientRect()
|
|
if (own.width < 1 || own.height < 1)
|
|
continue
|
|
const seen = visibleRect(el)
|
|
const outside = own.left < -1 || own.right > innerWidth + 1 || own.top < -1 || own.bottom > innerHeight + 1
|
|
const cut = seen.left > own.left + 1 || seen.right < own.right - 1 || seen.top > own.top + 1 || seen.bottom < own.bottom - 1
|
|
if (outside || cut)
|
|
found.push({ ...describe(el, own), outside, cut })
|
|
}
|
|
return found
|
|
}
|
|
|
|
const candidates = "a[href], button, select, textarea, summary, [role=button], [role=tab], [role=link], [tabindex='0'], "
|
|
+ "input:not([type=hidden]), label:has(> input[type=checkbox]), label:has(> input[type=radio]), label:has(> .toggle)"
|
|
|
|
function exempt(el) {
|
|
return el.tagName === "A" && el.closest(".post-content") !== null
|
|
|| el.matches("video, audio, :disabled, [aria-disabled=true], .btn-disabled, .modal-backdrop > button")
|
|
|| el.closest("[aria-hidden=true], [data-touch-exempt], [inert]") !== null
|
|
// a checkbox, radio or toggle inside a label is measured as its label
|
|
|| el.matches("input[type=checkbox], input[type=radio]") && el.closest("label") !== null
|
|
}
|
|
|
|
// a tap at this point reaches the element: on it, inside it, on its label's control, or on a label holding it
|
|
function reaches(el, x, y) {
|
|
const hit = document.elementFromPoint(x, y)
|
|
if (!hit)
|
|
return false
|
|
if (hit === el || el.contains(hit))
|
|
return true
|
|
if (el.tagName === "LABEL" && el.control && (hit === el.control || el.control.contains(hit)))
|
|
return true
|
|
const label = hit.closest("label")
|
|
return label !== null && label.contains(el)
|
|
}
|
|
|
|
// a target needs a 44x44 CSS px hit area (Apple's guideline, WCAG 2.5.5): the centre and four points 21px away must
|
|
// reach it, so a neighbour reaching into that square fails it as much as a small box does. Only targets a tap can reach
|
|
// now are judged; one under an overlay or out of view is judged at another step
|
|
function targets() {
|
|
const small = [], tiny = []
|
|
for (const el of document.querySelectorAll(candidates)) {
|
|
if (exempt(el) || !shown(el))
|
|
continue
|
|
const own = el.getBoundingClientRect()
|
|
if (own.width < 1 || own.height < 1)
|
|
continue
|
|
const x = own.left + own.width / 2, y = own.top + own.height / 2
|
|
if (x < 0 || x >= innerWidth || y < 0 || y >= innerHeight)
|
|
continue
|
|
const seen = visibleRect(el)
|
|
if (x < seen.left || x > seen.right || y < seen.top || y > seen.bottom || !reaches(el, x, y))
|
|
continue
|
|
if (el.matches("input[type=range]")) {
|
|
if (own.height < 28 || own.width < 120)
|
|
small.push({ ...describe(el, own), failed: ["range"] })
|
|
continue
|
|
}
|
|
const failed = []
|
|
for (const [dx, dy, side] of [[-21, 0, "left"], [21, 0, "right"], [0, -21, "up"], [0, 21, "down"]]) {
|
|
const px = Math.min(innerWidth - 1, Math.max(0, x + dx)), py = Math.min(innerHeight - 1, Math.max(0, y + dy))
|
|
if (!reaches(el, px, py))
|
|
failed.push(side)
|
|
}
|
|
if (failed.length > 0)
|
|
small.push({ ...describe(el, own), failed })
|
|
if (text(el) && el.innerText?.trim() && parseFloat(style(el).fontSize) < 12)
|
|
tiny.push({ ...describe(el, own), fontSize: parseFloat(style(el).fontSize) })
|
|
}
|
|
return { small, tiny }
|
|
}
|
|
|
|
function measure() {
|
|
styles = new Map()
|
|
const { small, tiny } = targets()
|
|
const result = { url: location.pathname + location.search, overflow: overflow(), beyond: beyond(), popovers: popovers(), small, tiny }
|
|
styles = new Map()
|
|
return result
|
|
}
|
|
|
|
function primary() {
|
|
styles = new Map()
|
|
document.querySelectorAll("[data-fit-primary]").forEach(e => e.removeAttribute("data-fit-primary"))
|
|
let best = null, area = 0
|
|
for (const el of scrollers()) {
|
|
if (el.scrollHeight <= el.clientHeight + 4)
|
|
continue
|
|
const size = el.clientWidth * el.clientHeight
|
|
if (size > area) {
|
|
best = el
|
|
area = size
|
|
}
|
|
}
|
|
best ??= document.scrollingElement
|
|
best.setAttribute("data-fit-primary", "")
|
|
styles = new Map()
|
|
return name(best)
|
|
}
|
|
|
|
function step(fraction) {
|
|
const el = document.querySelector("[data-fit-primary]") ?? document.scrollingElement
|
|
const before = el.scrollTop
|
|
el.scrollTo({ top: before + el.clientHeight * fraction, behavior: "instant" })
|
|
return el.scrollTop > before + 1
|
|
}
|
|
|
|
async function settle() {
|
|
await document.fonts.ready
|
|
await new Promise(resolve => {
|
|
let timer
|
|
const done = () => {
|
|
observer.disconnect()
|
|
clearTimeout(timer)
|
|
clearTimeout(cap)
|
|
resolve()
|
|
}
|
|
const observer = new MutationObserver(() => {
|
|
clearTimeout(timer)
|
|
timer = setTimeout(done, 400)
|
|
})
|
|
observer.observe(document.body, { subtree: true, childList: true, characterData: true, attributes: true })
|
|
timer = setTimeout(done, 400)
|
|
const cap = setTimeout(done, 8000)
|
|
})
|
|
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
|
|
const until = Date.now() + 3000
|
|
const inView = img => {
|
|
const r = img.getBoundingClientRect()
|
|
return r.bottom > 0 && r.top < innerHeight && r.right > 0 && r.left < innerWidth
|
|
}
|
|
while (Date.now() < until && [...document.images].some(img => inView(img) && !img.complete))
|
|
await new Promise(resolve => setTimeout(resolve, 100))
|
|
}
|
|
|
|
return { settle, primary, step, measure }
|
|
})()
|