// 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)`); } } export default report("layout");