check / check (push) Waiting to run
The Go module moves from backend/ to the root (go.mod, go.sum, .golangci.yml, cmd/, internal/), the page's source to web/, and nginx's two files to configs/. backend/ is gone: its LICENSE and .dockerignore are deleted, and its .gitignore entries, README and scripts are folded into the root ones. script/build builds the server binary after the frontend; script/run and script/clean move up from backend/script/; make test, lint, fmt and fmt-check do what the backend's did. Vite's root is web/ and it still builds into dist/. go.mod ignores node_modules/, whose npm packages can ship .go files. script/bootstrap no longer installs gcc, which only the host Go tests needed. Model: opus-5-5
226 lines
8.1 KiB
JavaScript
226 lines
8.1 KiB
JavaScript
// 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, "web/src/styles.css"), "utf8"),
|
|
"web/src/styles.css",
|
|
),
|
|
...mediaConditionsFromMarkup([
|
|
{
|
|
path: "web/src/main.js",
|
|
text: readFileSync(join(root, "web/src/main.js"), "utf8"),
|
|
},
|
|
{
|
|
path: "web/index.html",
|
|
text: readFileSync(join(root, "web/index.html"), "utf8"),
|
|
},
|
|
]),
|
|
];
|
|
|
|
if (conditions.length === 0) {
|
|
throw new Error(
|
|
"no responsive breakpoints found in web/src/styles.css, " +
|
|
"web/src/main.js or web/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),
|
|
};
|
|
}
|