// Viewport derivation for the responsive-layout harness. // // The widths tested are read out of the CSS the application actually // ships, not taken from a list of popular phone models. A generic 375px // "phone" test sails straight past an off-by-one error at a media query // boundary, which is the classic way a responsive layout breaks, so // every breakpoint found in the sources is probed three times: one pixel // below it, exactly on it, and one pixel above it. // // Nothing here hardcodes 768. If someone adds a second media block or // starts using Tailwind responsive prefixes, that breakpoint starts // being covered without this file being edited. import { readFileSync } from "node:fs"; import { join } from "node:path"; // Tailwind CSS v4 default breakpoints, in rem. The app currently uses // none of these prefixes, so the whole table is inert until someone // writes an `md:`-prefixed utility class. const TAILWIND_BREAKPOINT_REM = { sm: 40, md: 48, lg: 64, xl: 80, "2xl": 96, }; // The app does not override the root font size, so rem and em in media // queries resolve against the browser default. const ROOT_FONT_SIZE_PX = 16; // Extract every min-width / max-width condition from the @media blocks in // a stylesheet. Returns e.g. [{ type: "max", px: 768, source: "..." }]. export function mediaConditionsFromCss(css, source) { const conditions = []; for (const block of css.matchAll(/@media([^{]+)\{/g)) { const features = block[1].matchAll( /\(\s*(min|max)-width\s*:\s*([\d.]+)(px|rem|em)\s*\)/g, ); for (const feature of features) { const scale = feature[3] === "px" ? 1 : ROOT_FONT_SIZE_PX; conditions.push({ type: feature[1], px: Math.round(Number(feature[2]) * scale), source, }); } } return conditions; } // Extract the breakpoints implied by Tailwind responsive prefixes used in // markup. A prefix only counts when it opens a utility class, so `text-sm` // does not masquerade as the `sm:` breakpoint. export function mediaConditionsFromMarkup(sources) { const conditions = []; for (const { path, text } of sources) { for (const [name, rem] of Object.entries(TAILWIND_BREAKPOINT_REM)) { const used = new RegExp( `(^|["'\\s])${name}:[a-z0-9[\\](),_./%-]+`, "m", ).test(text); if (used) { conditions.push({ type: "min", px: rem * ROOT_FONT_SIZE_PX, source: path, }); } } } return conditions; } // Whether a given width should be rendering the app's narrow (stacked) // layout. // // Two breakpoint styles can be answered from the condition list alone: // // - Desktop-first, which is what the app ships today: the wide layout is // unconditional and every narrow rule lives in a `max-width` block, so // a width is narrow exactly when one of those blocks matches. Note that // `max-width: 768px` matches *at* 768 — getting this inclusive boundary // wrong in either direction is what the three-widths-per-breakpoint // sweep exists to catch. // - Mobile-first, which is what Tailwind's `sm:`/`md:` prefixes are: the // stacked layout is the unconditional base and a `min-width` block is // what widens it, so a width is narrow exactly when it sits below every // `min-width` breakpoint. // // A mix of the two cannot be resolved from the breakpoints alone — which // block owns the host-row reflow is a property of the rules inside it, not // of the condition — so this throws rather than guessing. Guessing is how // the wrong expectation gets applied at the right widths and the whole // sweep quietly verifies nothing. export function expectsStackedLayout(width, conditions) { const kinds = new Set(conditions.map((c) => c.type)); for (const kind of kinds) { if (kind !== "max" && kind !== "min") { throw new Error( `unsupported media condition type "${kind}" in ` + "expectsStackedLayout (test/viewport/viewports.js)", ); } } if (kinds.has("max") && kinds.has("min")) { throw new Error( "the app now mixes max-width and min-width breakpoints (" + conditions .map((c) => `${c.type}-width ${c.px}px in ${c.source}`) .join(", ") + "), so which layout a width should be showing can no longer " + "be inferred from the breakpoint list; teach " + "expectsStackedLayout in test/viewport/viewports.js which " + "block owns the host-row reflow", ); } if (kinds.has("min")) { return !conditions.some((c) => width >= c.px); } return conditions.some((c) => width <= c.px); } // Viewports that are not derived from a breakpoint. Each one is here for // a stated reason; none of them is a stand-in for "a phone". const ANCHOR_VIEWPORTS = [ { name: "floor-portrait", width: 320, height: 568, deviceScaleFactor: 2, touch: true, why: "320px is the narrowest viewport still in mainstream use; nothing has to work below it", }, { name: "phone-landscape-narrow", width: 667, height: 375, deviceScaleFactor: 2, touch: true, why: "phone rotated to landscape, still inside the narrow layout", }, { name: "phone-landscape-wide", width: 844, height: 390, deviceScaleFactor: 3, touch: true, why: "large phone rotated to landscape: crosses into the wide layout while still being a touch device", }, { name: "desktop", width: 1280, height: 800, deviceScaleFactor: 1, touch: false, why: "desktop baseline", }, ]; export function deriveViewports(root) { const conditions = [ ...mediaConditionsFromCss( readFileSync(join(root, "src/styles.css"), "utf8"), "src/styles.css", ), ...mediaConditionsFromMarkup([ { path: "src/main.js", text: readFileSync(join(root, "src/main.js"), "utf8"), }, { path: "index.html", text: readFileSync(join(root, "index.html"), "utf8"), }, ]), ]; if (conditions.length === 0) { throw new Error( "no responsive breakpoints found in src/styles.css, src/main.js or " + "index.html — either the responsive layout was deleted or this " + "derivation has stopped matching the sources", ); } const viewports = new Map(); const add = (viewport) => { const key = `${viewport.width}x${viewport.height}`; if (!viewports.has(key)) viewports.set(key, viewport); }; for (const condition of conditions) { for (const [offset, label] of [ [-1, "below"], [0, "at"], [+1, "above"], ]) { const width = condition.px + offset; add({ name: `${condition.type}-width-${condition.px}-${label}`, width, // Tall enough that the whole app is laid out in one column // without the viewport height influencing wrapping. height: 1024, deviceScaleFactor: 2, touch: true, why: `${offset === 0 ? "exactly on" : `1px ${label}`} the ${condition.type}-width: ${condition.px}px breakpoint declared in ${condition.source}`, }); } } for (const anchor of ANCHOR_VIEWPORTS) add(anchor); return { conditions, viewports: [...viewports.values()] .map((viewport) => ({ ...viewport, expectStacked: expectsStackedLayout(viewport.width, conditions), })) .sort((a, b) => a.width - b.width || a.height - b.height), }; }