Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
8f4cded
fix(home): keep the hero mock and its flags inside the viewport below…
nish3451 Aug 14, 2026
2342fb4
Merge branch 'main' into fix/hero-mock-240px-overflow
nish3451 Aug 14, 2026
166d80d
Merge branch 'main' into fix/hero-mock-240px-overflow
nish3451 Aug 14, 2026
97e14d2
Merge branch 'main' into fix/hero-mock-240px-overflow
nish3451 Aug 14, 2026
0570b7d
Merge branch 'main' into fix/hero-mock-240px-overflow
nish3451 Aug 14, 2026
e7cdb83
Merge branch 'main' into fix/hero-mock-240px-overflow
nish3451 Aug 14, 2026
c43bc72
Merge branch 'main' into fix/hero-mock-240px-overflow
nish3451 Aug 14, 2026
5e41aac
Merge branch 'main' into fix/hero-mock-240px-overflow
nish3451 Aug 14, 2026
810fdf3
Merge branch 'main' into fix/hero-mock-240px-overflow
nish3451 Aug 14, 2026
d5da86b
Merge branch 'main' into fix/hero-mock-240px-overflow
nish3451 Aug 14, 2026
0c40dad
Merge branch 'main' into fix/hero-mock-240px-overflow
nish3451 Aug 14, 2026
d56c564
Merge branch 'main' into fix/hero-mock-240px-overflow
nish3451 Aug 14, 2026
d516481
Merge branch 'main' into fix/hero-mock-240px-overflow
nish3451 Aug 14, 2026
7949484
Merge branch 'main' into fix/hero-mock-240px-overflow
nish3451 Aug 14, 2026
ea2b453
Merge branch 'main' into fix/hero-mock-240px-overflow
nish3451 Aug 14, 2026
f733a32
Merge branch 'main' into fix/hero-mock-240px-overflow
nish3451 Aug 14, 2026
de53aa2
Merge branch 'main' into fix/hero-mock-240px-overflow
nish3451 Aug 14, 2026
c7202d4
Merge branch 'main' into fix/hero-mock-240px-overflow
nish3451 Aug 14, 2026
429929a
Merge branch 'main' into fix/hero-mock-240px-overflow
nish3451 Aug 14, 2026
5d43432
Merge branch 'main' into fix/hero-mock-240px-overflow
nish3451 Aug 14, 2026
df10443
Merge branch 'main' into fix/hero-mock-240px-overflow
nish3451 Aug 14, 2026
977246a
Merge branch 'main' into fix/hero-mock-240px-overflow
nish3451 Aug 14, 2026
45da77b
Merge branch 'main' into fix/hero-mock-240px-overflow
nish3451 Aug 14, 2026
73a0343
Merge branch 'main' into fix/hero-mock-240px-overflow
nish3451 Aug 14, 2026
f8ed382
Merge branch 'main' into fix/hero-mock-240px-overflow
nish3451 Aug 14, 2026
5ead43a
Merge branch 'main' into fix/hero-mock-240px-overflow
nish3451 Aug 14, 2026
fc6e2a8
Merge branch 'main' into fix/hero-mock-240px-overflow
nish3451 Aug 14, 2026
973b651
Merge branch 'main' into fix/hero-mock-240px-overflow
nish3451 Aug 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 13 additions & 3 deletions public/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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}
Expand Down
150 changes: 150 additions & 0 deletions scripts/test-narrow-viewport.mjs
Original file line number Diff line number Diff line change
@@ -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.");
Loading