Files
decePubClient/tests/e2e/Mobile/fit.js
T
thepraandClaude Opus 5.5 8f15d0d5c1 Phone flows: the menu, a dialog, a new post, a reply and a reaction by thumb
FlowTests drive the narrowest phone through what landed so far, each opened menu, sheet or dialog checked to be whole
on the screen with 44px targets (fit.js measures inside a scope now): the menu closes after a choice, an outside tap
and Escape; "Liked by" rises from the bottom and closes from its button; a new post is written in the sheet the round
button opens from a scrolled feed, with the words focused; a reply is written in a sheet; a reaction is picked from the
React sheet, through the one-time public notice, and taken back. Cells client.mobile.flow.*. The menu's dark-mode
toggle gets a 44px hit area and a name, and the public notice dialog marks itself for the tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 23:19:59 +02:00

303 lines
12 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(s) what doesn't fit: sideways overflow, boxes beyond the screen, cut popovers, small targets, small text;
// with a selector, the targets inside what it matches only (an open menu or dialog)
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
}
// the part of the screen an element's clipping ancestors let through: where a tap can reach it at all
function clipOf(el) {
const box = { left: 0, top: 0, right: innerWidth, bottom: innerHeight }
if (style(el).position === "fixed")
return box
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()
box.left = Math.max(box.left, clip.left)
box.right = Math.min(box.right, clip.right)
box.top = Math.max(box.top, clip.top)
box.bottom = Math.min(box.bottom, clip.bottom)
}
if (s.position === "fixed")
break
}
return box
}
// an element inside a nested clipping box (a sideways strip, a truncated name) doesn't widen the scroller holding it
function clippedWithin(el, scroller) {
for (let a = el.parentElement; a && a !== scroller; a = a.parentElement)
if (style(a).overflowX !== "visible")
return true
return false
}
// 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]") && !clippedWithin(el, scroller)) {
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(scope) {
const small = [], tiny = []
const within = scope ? [...document.querySelectorAll(scope)].flatMap(s => [...s.querySelectorAll(candidates)]) : document.querySelectorAll(candidates)
for (const el of within) {
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
}
// a probe beyond what the scroller shows (a card half under the feed's top edge) can't be tapped there anyway
const clip = clipOf(el)
const failed = [], hit = {}
for (const [dx, dy, side] of [[-21, 0, "left"], [21, 0, "right"], [0, -21, "up"], [0, 21, "down"]]) {
const px = Math.min(clip.right - 1, Math.max(clip.left, x + dx)), py = Math.min(clip.bottom - 1, Math.max(clip.top, y + dy))
if (!reaches(el, px, py)) {
failed.push(side)
const other = document.elementFromPoint(px, py)
hit[side] = other ? name(other) : null
}
}
if (failed.length > 0)
small.push({ ...describe(el, own), failed, hit })
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(scope) {
styles = new Map()
const { small, tiny } = targets(scope)
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 }
})()