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
303 lines
12 KiB
JavaScript
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 }
|
|
})()
|