// 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 } })()