Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ coverage/
*.log
.DS_Store
test-results/
screenshots/
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"build:project-stats": "node scripts/build-project-stats.js",
"build:js": "node scripts/build-js.js",
"build:css": "node scripts/build-css.js",
"build": "npm run build:milestones && npm run build:changelog && npm run build:project-stats && npm run build:js && npm run build:css"
"build": "npm run build:milestones && npm run build:changelog && npm run build:project-stats && npm run build:js && npm run build:css",
"screenshots": "node scripts/screenshots.js"
},
"devDependencies": {
"@playwright/test": "1.59.1",
Expand Down
4 changes: 4 additions & 0 deletions playwright.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ module.exports = defineConfig({
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'mobile-chrome',
use: { ...devices['Pixel 5'] },
},
],
webServer: {
command: 'npx serve . -p 3000 -s',
Expand Down
99 changes: 99 additions & 0 deletions scripts/screenshots.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
#!/usr/bin/env node
// scripts/screenshots.js
// Headlessly capture desktop and mobile screenshots of the site.
//
// Usage:
// npm run screenshots
//
// Screenshots are saved to the `screenshots/` directory (gitignored).
// The script spins up a local static server on port 3001 automatically,
// so it can be run without a pre-existing server. To target a running
// server instead, set the SCREENSHOT_URL environment variable:
// SCREENSHOT_URL=https://nitrocode.github.io/token-deathclock/ npm run screenshots

'use strict';

const { chromium } = require('@playwright/test');
const { spawn } = require('child_process');
const path = require('path');
const fs = require('fs');

const OUT_DIR = path.join(process.cwd(), 'screenshots');
const PORT = 3001;
const BASE_URL = process.env.SCREENSHOT_URL || `http://localhost:${PORT}`;

/** Viewport + context configs to capture */
const CONFIGS = [
{
name: 'desktop',
viewport: { width: 1280, height: 800 },
isMobile: false,
deviceScaleFactor: 1,
},
{
name: 'mobile',
viewport: { width: 390, height: 844 },
isMobile: true,
deviceScaleFactor: 3,
},
];

/** Milliseconds to wait for the local server to start */
const SERVER_STARTUP_MS = 2000;

async function waitMs(/** @type {number} */ ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}

async function main() {
fs.mkdirSync(OUT_DIR, { recursive: true });

// Only start a local server when using the default localhost URL
let serverProcess = null;
if (!process.env.SCREENSHOT_URL) {
serverProcess = spawn(
'npx', ['serve', '.', '-p', String(PORT), '-s'],
{ stdio: 'ignore', detached: false }
);
// Give the server time to start
await waitMs(SERVER_STARTUP_MS);
}

let exitCode = 0;

try {
const browser = await chromium.launch();

for (const cfg of CONFIGS) {
const context = await browser.newContext({
viewport: cfg.viewport,
isMobile: cfg.isMobile,
deviceScaleFactor: cfg.deviceScaleFactor,
});

const page = await context.newPage();
await page.goto(BASE_URL);
await page.waitForLoadState('networkidle');

const outFile = path.join(OUT_DIR, `${cfg.name}.png`);
await page.screenshot({ path: outFile, fullPage: false });
console.log(`[screenshots] Saved ${outFile}`);

await context.close();
}

await browser.close();
console.log(`[screenshots] Done. Files are in: ${OUT_DIR}`);
} catch (err) {
console.error('[screenshots] Error:', err instanceof Error ? err.message : String(err));
exitCode = 1;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} finally {
if (serverProcess) {
serverProcess.kill();
}
}

process.exit(exitCode);
}

main();
2 changes: 1 addition & 1 deletion styles.css

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion styles/base.css
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@
/* Enforce [hidden] even when a class sets an explicit display value */
[hidden] { display: none !important; }

html { scroll-behavior: smooth; }
/* overflow-x on both html and body prevents horizontal scroll and ensures
position:fixed elements anchor to the viewport on iOS Safari, where
overflow on body alone can cause fixed elements to position relative to
the body scroll container instead of the viewport. */
html { scroll-behavior: smooth; overflow-x: hidden; }

body {
font-family: 'Share Tech Mono', 'Courier New', monospace;
Expand Down
6 changes: 6 additions & 0 deletions styles/scary-features.css
Original file line number Diff line number Diff line change
Expand Up @@ -866,10 +866,16 @@

@media (max-width: 480px) {
.grim-reaper svg { width: 55px; }
/* Reduce the peek offset on small screens so the reaper isn't cut off too much */
.grim-reaper { transform: translateX(-8px); }
.grim-reaper:hover { transform: translateX(0); }
}

@media (prefers-reduced-motion: reduce) {
.grim-reaper svg,
.reaper-eye-inner { animation: none; }
/* transition: none keeps the hover snap instant; the mobile translateX(-8px)
offset (set above) still applies so the reaper remains partially peeking
even on reduced-motion devices — hover simply snaps it to translateX(0). */
.grim-reaper { transition: none; }
}
56 changes: 56 additions & 0 deletions tests/e2e/death-clock.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,62 @@ test.describe('AI Death Clock — end-to-end', () => {
});
});

// ── Mobile layout: fixed elements must stay within the viewport ───────────
// These tests use a narrow (390 × 844) viewport to catch regressions where
// position:fixed elements are clipped off-screen on small screens.

test.describe('mobile layout — fixed elements within viewport', () => {
const MOBILE_VIEWPORT = { width: 390, height: 844 };

test.use({ viewport: MOBILE_VIEWPORT });

test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
});

test('GitHub corner banner is fully within the viewport on mobile', async ({ page }) => {
const vp = page.viewportSize();
const bbox = await page.locator('.github-corner').boundingBox();
expect(bbox).not.toBeNull();
// The corner must not extend beyond the right edge of the viewport
expect(bbox.x + bbox.width).toBeLessThanOrEqual(vp.width + 1); // +1 px rounding tolerance
// The corner must not extend above the top of the viewport
expect(bbox.y).toBeGreaterThanOrEqual(-1);
// At least part of the element must be visible (not fully off to the right)
expect(bbox.x).toBeLessThan(vp.width);
});

test('grim reaper is not excessively cut off on the left on mobile', async ({ page }) => {
const vp = page.viewportSize();
const bbox = await page.locator('#grim-reaper').boundingBox();
expect(bbox).not.toBeNull();
// The reaper's right edge must be within the viewport (it should be visible)
expect(bbox.x + bbox.width).toBeGreaterThan(0);
// Allow a small intentional peek offset but no more than half the element width
const cutOff = -bbox.x; // pixels hidden to the left (positive = cut off)
expect(cutOff).toBeLessThan(bbox.width / 2);
});

test('Share Your Doom button is fully within the viewport on mobile', async ({ page }) => {
// Reveal the panel immediately via the ?share=true query param
await page.goto('/?share=true');
await page.waitForLoadState('networkidle');

const vp = page.viewportSize();
const btn = page.locator('#shareDoomBtn');
await expect(btn).toBeVisible();
const bbox = await btn.boundingBox();
expect(bbox).not.toBeNull();
// Button must not overflow right edge
expect(bbox.x + bbox.width).toBeLessThanOrEqual(vp.width + 1); // +1 px rounding tolerance
// Button must not overflow left edge
expect(bbox.x).toBeGreaterThanOrEqual(-1); // +1 px rounding tolerance
// Button must not overflow bottom edge
expect(bbox.y + bbox.height).toBeLessThanOrEqual(vp.height + 1); // +1 px rounding tolerance
});
});

// ── Mobile tab-bar visibility ─────────────────────────────────────────────────

test.describe('Mobile tab bar — 375 px viewport', () => {
Expand Down
Loading