// Structural facts about the markup and stylesheet that no amount of reading
// the diff reliably catches: a panel lost while shuffling tabs, a comment half
// removed, a label that truncates on a phone nobody here owns.
//
// The width arithmetic is an estimate, not a measurement — there is no browser
// in this loop. Layout numbers are read out of style.css so they cannot drift
// from the real ones, text is sized from per-character advances, and the pass
// mark insists on a few pixels of headroom because the estimate is only good
// to a few percent. It will catch a sixth tab or a longer label; it will not
// settle a two-pixel question.
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { suite, eq, ok, report } from "./assert.mjs";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const html = readFileSync(join(root, "src", "index.html"), "utf8");
const css = readFileSync(join(root, "src", "style.css"), "utf8");
// ---------------------------------------------------------------- stylesheet
suite("the stylesheet is structurally intact");
{
let depth = 0, pairs = 0, stray = 0;
for (let i = 0; i < css.length - 1; i++) {
if (css[i] === "/" && css[i + 1] === "*") { depth++; pairs++; i++; }
else if (css[i] === "*" && css[i + 1] === "/") { depth--; if (depth < 0) stray++; i++; }
}
eq(depth, 0, `comments open and close in pairs (${pairs})`);
eq(stray, 0, "no stray comment terminators");
const bare = css.replace(/\/\*[\s\S]*?\*\//g, "");
eq((bare.match(/\{/g) || []).length, (bare.match(/\}/g) || []).length, "braces balance");
eq(/\*\//.test(bare), false, "no orphaned comment tails left by an edit");
// Author rules that set a display beat the user agent's [hidden] rule, so
// hidden elements keep rendering without this. It has caught five so far.
ok(/^\[hidden\] \{ display: none !important; \}$/m.test(css),
"the global [hidden] rule is present");
}
// ------------------------------------------------------------------ the tabs
suite("every panel lives in exactly one tab");
{
const EXPECTED = [
"overview", "sleep-wake", "history",
"sleep-daily", "sleep-timeline", "sleep-trend",
"walks", "walk-timeline", "walk-trend",
"timing", "counts",
"weight", "training", "notes",
];
const main = html.slice(html.indexOf(" "), html.indexOf(" "));
let current = null;
const placement = new Map();
const wrappers = [];
for (const line of main.split("\n")) {
const open = line.match(/^ {6}
/.test(line) && current) { current = null; continue; }
const panel = line.match(/data-panel="([^"]+)"/);
if (panel && /^ {8} !placement.has(k));
const duplicated = EXPECTED.filter(k => (placement.get(k) || []).length > 1);
const loose = EXPECTED.filter(k => (placement.get(k) || [null])[0] === null);
const unexpected = [...placement.keys()].filter(k => !EXPECTED.includes(k));
eq(missing, [], "no panel has gone missing");
eq(duplicated, [], "no panel appears twice");
eq(loose, [], "no panel sits outside a tab");
eq(unexpected, [], "no unaccounted-for panel has appeared");
const buttons = [...html.matchAll(/class="tab" role="tab" data-tab="([^"]+)"/g)].map(m => m[1]);
eq(buttons, wrappers, "a button for each tab, in the same order");
const ariaOk = buttons.every(t =>
html.includes(`id="tab-${t}" aria-controls="tabpanel-${t}"`) &&
html.includes(`id="tabpanel-${t}" role="tabpanel" aria-labelledby="tab-${t}"`));
ok(ariaOk, "aria-controls and aria-labelledby paired on every tab");
// renderWalkPatterns hides these two by hand, and reaches for them by tag.
for (const k of ["walk-timeline", "walk-trend"]) {
ok(new RegExp(`]*data-panel="${k}"`).test(html),
`"${k}" is still a , which renderWalkPatterns queries for`);
}
}
// ----------------------------------------------------------------- the widths
const ADV = { cap: 0.72, lower: 0.52, digit: 0.6, thin: 0.28 };
const NARROW = { i: 0.26, l: 0.26, t: 0.35, j: 0.26, f: 0.32, r: 0.37 };
const em = (s) => [...s].reduce((n, ch) => {
if (ch >= "0" && ch <= "9") return n + ADV.digit;
if (ch === ":" || ch === " ") return n + ADV.thin;
if (ch >= "A" && ch <= "Z") return n + ADV.cap;
if (ch === "←" || ch === "→") return n + 0.6;
return n + (NARROW[ch] ?? ADV.lower);
}, 0);
const EMOJI = 18;
const BODY_GUTTER = 16;
const rule = (re) => (css.match(re) || [, ""])[1] || "";
const px = (block, prop, dflt) => {
const m = block.match(new RegExp(`(?:^|[;{\\s])${prop}:\\s*([\\d.]+)(rem|px)`));
return m ? (m[2] === "rem" ? parseFloat(m[1]) * 16 : parseFloat(m[1])) : dflt;
};
const DEVICES = [
["Galaxy Fold cover", 280], ["iPhone SE 1 / small Android", 320],
["Galaxy S / common Android", 360], ["iPhone SE 2-3", 375],
["iPhone 14 / Pixel", 393], ["iPhone 14 Plus", 428],
];
const narrowBlock = rule(/max-width: 370px\)\s*\{([\s\S]*?)\n\}/);
const inNarrow = (sel) => narrowBlock.match(new RegExp(`${sel} \\{[^}]*\\}`))?.[0] ?? "";
suite("the tab labels fit without truncating");
{
const labels = [...html.matchAll(/class="tab" role="tab"[^>]*>([^<]+) m[1]);
const wide = { gap: px(rule(/\n\.tabs \{([^}]*)\}/), "gap", 4), barPad: 4, tabPad: 4,
font: px(rule(/\n\.tabs \.tab \{([^}]*)\}/), "font-size", 13.6) };
const narrow = { gap: px(inNarrow("\\.tabs"), "gap", wide.gap),
barPad: px(inNarrow("\\.tabs"), "padding-left", wide.barPad),
tabPad: px(inNarrow("\\.tabs \\.tab"), "padding-left", wide.tabPad),
font: px(inNarrow("\\.tabs \\.tab"), "font-size", wide.font) };
const widest = labels.reduce((a, b) => (em(a) > em(b) ? a : b));
for (const [name, w] of DEVICES) {
const v = w <= 370 ? narrow : wide;
const room = (Math.min(w, 720) - 2 * BODY_GUTTER - 2 * v.barPad
- (labels.length - 1) * v.gap) / labels.length - 2 * v.tabPad;
const need = em(widest) * v.font;
ok(room - need >= 2, `${String(w).padStart(4)}px ${name}: "${widest}" fits ` +
`(${room.toFixed(0)}px of room, ${need.toFixed(0)}px needed)`);
}
}
suite("both day-bar timers fit on a row of their own");
{
// The bar may wrap when it must; what it must not do is squash the timers.
const pillCss = rule(/\n\.bar-clock \{([^}]*)\}/);
const wide = { font: px(pillCss, "font-size", 16), padX: 9, gap: px(pillCss, "gap", 4) };
const narrowPill = inNarrow("\\.bar-clock");
const narrow = { font: px(narrowPill, "font-size", wide.font),
padX: px(narrowPill, "padding", wide.padX), gap: px(narrowPill, "gap", wide.gap) };
const barPadX = 10, groupGap = 6;
const worst = "3:12:45"; // both timers past an hour
for (const [name, w] of DEVICES) {
const v = w <= 370 ? narrow : wide;
const pill = EMOJI + v.gap + em(worst) * v.font + 2 * v.padX;
const room = Math.min(w, 720) - 2 * BODY_GUTTER - 2 * barPadX;
ok(room - (2 * pill + groupGap) >= 2,
`${String(w).padStart(4)}px ${name}: two timers fit ` +
`(${room.toFixed(0)}px of room, ${(2 * pill + groupGap).toFixed(0)}px needed)`);
}
}
// Anything wider than the screen makes the whole document wider than the
// viewport, and a phone responds by letting you zoom out — which is how this
// class of bug is usually noticed, long after it was introduced.
suite("nothing can push the page wider than the screen");
{
// Free text the user types has no width limit of its own. A long unbroken
// token — a URL in a note, a chemical name off a food bag — sets a flex
// item's content-based minimum, or simply spills out of its box, and either
// way it widens the document. Every element that renders user input needs a
// break rule; this is the list, and it is easier to extend than to remember.
const USER_TEXT = [
"\\.event \\.note", // a note on a history row
"\\.event \\.note-text", // the Notes log
"\\.ex-name", // exercise names
"\\.ex-note", // exercise instructions
"\\.guest-item-label", // the label on a guest link
];
for (const sel of USER_TEXT) {
const block = rule(new RegExp(`\\n${sel}[^{]*\\{([^}]*)\\}`));
ok(/overflow-wrap:\s*(anywhere|break-word)/.test(block),
`${sel.replace(/\\/g, "")} can break a long unbroken word`);
}
// The month grid is the one panel positioned against something narrower than
// the page. Centred on the date button it hung off the right of a phone; it
// is anchored to the day bar instead, which spans the content width.
const dayCal = rule(/\n\.day-cal \{([^}]*)\}/);
ok(/right:/.test(dayCal) && !/left:\s*50%/.test(dayCal),
"the month grid is edge-anchored, not centred on the date button");
ok(!/max-width:[^;]*vw/.test(dayCal),
"…and bounded by its container rather than by the viewport");
const main = html.slice(html.indexOf('", html.indexOf('')));
ok(/id="day-cal"/.test(main), "…and sits inside the day bar, which is what it is measured against");
// A fixed width wider than the narrowest content box cannot fit by
// definition. 320px phone, less the body's two 16px gutters.
const NARROWEST = 320 - 2 * BODY_GUTTER;
const tooWide = [...css.matchAll(/(?:^|[;{\s])(width|min-width):\s*(\d{3,})px/g)]
.filter(m => Number(m[2]) > NARROWEST)
.map(m => `${m[1]}: ${m[2]}px`);
eq(tooWide, [], `no fixed width exceeds a ${NARROWEST}px content box`);
}
export default report("layout");