From 8f4cdedba616f5329d3a42a0392fcebecb8f1b2b Mon Sep 17 00:00:00 2001 From: nish3451 Date: Fri, 14 Aug 2026 07:14:17 +0530 Subject: [PATCH] fix(home): keep the hero mock and its flags inside the viewport below 320px MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The .browser/.mock appraisal hero card overflows below ~252px viewports: the absolutely-positioned nowrap .flag annotations ("1 NO PRICE, ANYWHERE", "2 ONE IDEA, TEN HOOKS", "3 NOTHING ANSWERED") stretch the card past the viewport (doc scrollWidth 252 at a 240px viewport; flags to right=304). The 320/360/390 sweep never caught it because the flags only overflow below ~320px. Give the .spec grid column and the card min-width:0 so the column can shrink below its content's intrinsic width, cap the card at max-width:100%, let the flag text wrap (white-space:normal, max-width:100%), and in the mobile block anchor the flags to the card's right padding edge so they always fall inside it. Let the two-column spec rows and the who-banner label wrap at the same sub-340px widths. Add scripts/test-narrow-viewport.mjs, a real-Chromium regression serving the homepage (/, /appraisal, /desk — the latter two resolve to the homepage) over node:http that asserts, at 240/260/280/320px viewports, that doc scrollWidth equals clientWidth, the hero card's right edge stays inside the viewport, and every flag stays inside the card. Wired into npm test as test:narrow alongside the sibling test:narrow-pages. Co-authored-by: CommandCodeBot --- package.json | 3 +- public/index.css | 16 +++- scripts/test-narrow-viewport.mjs | 150 +++++++++++++++++++++++++++++++ 3 files changed, 165 insertions(+), 4 deletions(-) create mode 100644 scripts/test-narrow-viewport.mjs diff --git a/package.json b/package.json index b7bf8d13..35b8f44d 100644 --- a/package.json +++ b/package.json @@ -8,10 +8,11 @@ "dev": "wrangler dev --remote --ip 127.0.0.1 --port 8788 --persist-to /tmp/tinystudio-agent-worker", "check": "node scripts/check-site.mjs", "check:render-blocking": "node scripts/check-render-blocking.mjs", - "test": "npm run check && npm run test:headings && npm run test:sitemap && npm run test:worker && npm run test:ui && npm run test:contract && npm run test:viewport && npm run test:narrow-pages", + "test": "npm run check && npm run test:headings && npm run test:sitemap && npm run test:worker && npm run test:ui && npm run test:contract && npm run test:viewport && npm run test:narrow-pages && npm run test:narrow", "test:headings": "node --test scripts/test-heading-hierarchy.mjs", "test:viewport": "node --test scripts/test-first-viewport-audience.mjs", "test:narrow-pages": "node scripts/test-narrow-viewport-pages.mjs", + "test:narrow": "node scripts/test-narrow-viewport.mjs", "test:contract": "node --test scripts/test-product-contract.mjs", "test:sitemap": "node --test scripts/test-sitemap.mjs", "test:worker": "node --test scripts/test-agent-worker.mjs", diff --git a/public/index.css b/public/index.css index 17d7a430..3c80183e 100644 --- a/public/index.css +++ b/public/index.css @@ -83,20 +83,21 @@ font-size:12px;line-height:1.6;color:#8C8171;max-width:56ch} /* ── annotated specimen ────────────────────────── */ - .spec{margin-top:112px;display:grid;grid-template-columns:1.06fr .94fr;gap:56px;align-items:start} + .spec{margin-top:112px;display:grid;grid-template-columns:1.06fr .94fr;gap:56px;align-items:start;min-width:0} .browser{background:linear-gradient(176deg,#FFFDF8,#F7F1E6);border:1px solid var(--line);border-radius:10px; overflow:hidden;position:relative;box-shadow:0 1px 0 rgba(255,255,255,.9) inset,0 40px 84px -46px rgba(25,20,16,.34); - transition:transform var(--slow) var(--ease),box-shadow var(--slow) var(--ease)} + transition:transform var(--slow) var(--ease),box-shadow var(--slow) var(--ease);min-width:0;max-width:100%} .browser:hover{transform:translateY(-6px)} .chrome{display:flex;gap:6px;padding:12px 15px;border-bottom:1px solid var(--hair);background:rgba(235,225,207,.5)} .dot{width:8px;height:8px;border-radius:50%;background:#D6C9B2} .mock{padding:30px 30px 42px;position:relative} .bar{background:#E4DACA;border-radius:3px} .flag{position:absolute;display:flex;align-items:center;gap:8px;font-family:'Karla',sans-serif; - font-size:9px;letter-spacing:.16em;text-transform:uppercase;font-weight:700;color:var(--sig);white-space:nowrap} + font-size:9px;letter-spacing:.16em;text-transform:uppercase;font-weight:700;color:var(--sig);white-space:normal;max-width:100%} .pin{width:17px;height:17px;border-radius:50%;border:1px solid var(--sig);display:flex; align-items:center;justify-content:center;font-size:9px;background:rgba(176,74,46,.09)} .lk{position:absolute;height:1px;background:var(--sig);opacity:.42} + .specbody{min-width:0} .specbody h2{font-family:'Fraunces',serif;font-weight:200;font-size:42px;letter-spacing:-.028em;line-height:1.1;max-width:14ch} .specbody>p{margin-top:18px;font-size:15.5px;line-height:1.75;color:var(--muted);max-width:44ch} .rows{margin-top:32px} @@ -259,9 +260,18 @@ .stop{padding-right:0} .stop .d{margin-top:0} .who{flex-direction:column;align-items:flex-start;gap:16px;padding:30px 26px} + .who .sc{white-space:normal} .offer{flex-direction:column;gap:32px} .price{flex-shrink:1;text-align:left} .price .big{font-size:52px} + /* below 340px the two-column spec rows (label + nowrap value) cannot both + fit on one line; let the value wrap under the label instead of pushing + the document sideways */ + .row{flex-wrap:wrap;row-gap:6px} + /* below 340px the annotated specimen is one column: the absolutely + positioned flags would push past the card edge, so anchor them to the + card's right padding edge and let their text wrap inside */ + .mock .flag{left:auto;right:30px;max-width:calc(100% - 60px)} .faq h2{font-size:34px} .q{grid-template-columns:1fr;gap:14px} footer{flex-direction:column;align-items:flex-start;gap:10px} diff --git a/scripts/test-narrow-viewport.mjs b/scripts/test-narrow-viewport.mjs new file mode 100644 index 00000000..7039050c --- /dev/null +++ b/scripts/test-narrow-viewport.mjs @@ -0,0 +1,150 @@ +// TinyStudio — narrow-viewport overflow regression for the homepage hero mock +// (sol-sweep finding product-live/tinystudio-io-hero-mock-240px-overflow). +// +// The `.browser`/`.mock` hero preview card and its `.flag` annotations have no +// minimum-width cap, so below ~252px the absolutely-positioned nowrap flags +// stretched the card past the viewport and the whole document shifted +// sideways (doc scrollWidth 252 at a 240px viewport). Earlier sweeps only +// tested 320/360/390, so the sub-320 overflow was never caught. +// +// This check runs real Chromium against the homepage served statically (the +// routes /, /appraisal and /desk — the latter two resolve to the homepage on +// the live site) and asserts, at 240/260/280/320px viewports: +// +// 1. documentElement.scrollWidth === documentElement.clientWidth (no +// document-level horizontal overflow), +// 2. the `.browser`/`.mock` card's right edge stays inside the viewport, +// 3. every `.flag` annotation's right edge stays inside the card. +// +// The card is decorative; the failure mode it guards is the document shifting +// sideways on real small-mobile devices. +// +// Run: node scripts/test-narrow-viewport.mjs (or `npm run test:narrow`) + +import http from "node:http"; +import { readFileSync, statSync } from "node:fs"; +import { extname, join, normalize } from "node:path"; +import { fileURLToPath } from "node:url"; +import { chromium } from "playwright"; + +const PUBLIC_DIR = fileURLToPath(new URL("../public/", import.meta.url)); +const VIEWPORTS = [240, 260, 280, 320]; +// The packet's named routes: the home hero-mock card is served at /, and +// /appraisal and /desk resolve to the same homepage (the live site serves +// the homepage for those paths). The distinct appraisal/desk pages (/audit, +// /agents) are covered by the sibling test:narrow-pages regression. +const ROUTES = ["/", "/appraisal", "/desk"]; + +const CONTENT_TYPES = { + ".html": "text/html; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".txt": "text/plain; charset=utf-8", + ".md": "text/markdown; charset=utf-8", + ".xml": "application/xml; charset=utf-8", + ".ico": "image/x-icon" +}; + +function startServer() { + return new Promise((resolve) => { + const server = http.createServer((req, res) => { + const url = new URL(req.url, "http://127.0.0.1"); + let rel = decodeURIComponent(url.pathname); + // Mirrors the live site: /appraisal and /desk serve the homepage. + if (rel === "/" || rel === "/appraisal" || rel === "/desk") rel = "/index.html"; + const file = normalize(join(PUBLIC_DIR, rel)); + if (!file.startsWith(PUBLIC_DIR)) { + res.writeHead(403).end("forbidden"); + return; + } + try { + statSync(file); + } catch { + res.writeHead(404).end("not found"); + return; + } + res.writeHead(200, { "Content-Type": CONTENT_TYPES[extname(file)] ?? "application/octet-stream" }); + res.end(readFileSync(file)); + }); + server.listen(0, "127.0.0.1", () => resolve(server)); + }); +} + +const failures = []; +const rows = []; + +const server = await startServer(); +const port = server.address().port; +const origin = `http://127.0.0.1:${port}`; + +const browser = await chromium.launch({ headless: true }); + +try { + for (const width of VIEWPORTS) { + for (const route of ROUTES) { + const page = await browser.newPage({ viewport: { width, height: 844 }, isMobile: true }); + await page.goto(`${origin}${route}`, { waitUntil: "domcontentloaded", timeout: 30000 }); + await page.waitForTimeout(600); + + const state = await page.evaluate(() => { + const card = document.querySelector(".browser,.mock"); + const cardRect = card?.getBoundingClientRect(); + const flags = Array.from(document.querySelectorAll(".flag")).map((el) => ({ + text: (el.innerText || "").slice(0, 40), + right: el.getBoundingClientRect().right + })); + return { + sw: document.documentElement.scrollWidth, + cw: document.documentElement.clientWidth, + cardRight: cardRect ? cardRect.right : null, + flags + }; + }); + + const problems = []; + if (state.sw !== state.cw) { + problems.push(`document scrollWidth ${state.sw} != clientWidth ${state.cw}`); + } + if (state.cardRight === null) { + problems.push("homepage carries no .browser/.mock hero card"); + } else if (Math.round(state.cardRight) > state.cw) { + problems.push(`hero card right edge ${Math.round(state.cardRight)} exceeds viewport ${state.cw}`); + } + if (state.cardRight !== null) { + for (const flag of state.flags) { + if (Math.round(flag.right) > Math.round(state.cardRight)) { + problems.push(`flag "${flag.text}" right edge ${Math.round(flag.right)} exceeds card right edge ${Math.round(state.cardRight)}`); + } + } + } + + rows.push({ route, width, sw: state.sw, cw: state.cw, cardRight: state.cardRight ? Math.round(state.cardRight) : null, problems }); + if (problems.length) failures.push(`route ${route} @ ${width}px: ${problems.join("; ")}`); + await page.close(); + } + } +} finally { + await browser.close(); + server.close(); +} + +console.log("Narrow-viewport hero mock check (real Chromium):"); +console.table( + rows.map((r) => ({ + route: r.route, + viewport: `${r.width}px`, + "doc sw": r.sw, + "doc cw": r.cw, + "card right": r.cardRight, + result: r.problems.length ? "FAIL" : "PASS" + })) +); + +if (failures.length) { + console.error("\nNarrow-viewport failures:"); + for (const failure of failures) console.error(`- ${failure}`); + process.exit(1); +} +console.log("All narrow viewports keep the hero mock and its flags inside the viewport.");