Initial commit 🚀

This commit is contained in:
Felix Klement committed 2026-10-09 14:00:40 +02:00
commit fd58a8d32f
235 files changed
+19775

No files matched your search

Binary file not shown.
+18
View File
@@ -0,0 +1,18 @@
import sharp from 'sharp';
import { readdirSync } from 'node:fs';
import { join } from 'node:path';
const [,, dir, out, colsArg = '4', wArg = '360'] = process.argv;
const cols = Number(colsArg), w = Number(wArg), h = Math.round(w * 0.66);
const files = readdirSync(dir).filter(f => /\.(jpe?g|png|webp)$/i.test(f)).sort();
const tiles = [];
for (const [i, f] of files.entries()) {
try {
const buf = await sharp(join(dir, f)).resize(w, h, { fit: 'cover' }).toBuffer();
const label = Buffer.from(`<svg width="${w}" height="24"><rect width="${w}" height="24" fill="black" opacity="0.6"/><text x="6" y="17" font-family="sans-serif" font-size="14" fill="white">${i}: ${f.slice(0, 40)}</text></svg>`);
const tile = await sharp(buf).composite([{ input: label, top: h - 24, left: 0 }]).toBuffer();
tiles.push({ input: tile, left: (i % cols) * w, top: Math.floor(i / cols) * h });
} catch (e) { console.error('skip', f, e.message); }
}
const rows = Math.ceil(tiles.length / cols);
await sharp({ create: { width: cols * w, height: rows * h, channels: 3, background: '#222' } }).composite(tiles).jpeg({ quality: 80 }).toFile(out);
console.log('wrote', out, files.length, 'images');
+21
View File
@@ -0,0 +1,21 @@
import { chromium } from 'playwright';
/* Jumps to card 3 via its dot, then back to card 1 via its dot, and reports where the page rests. */
const [,, url, width = '1440', height = '900'] = process.argv;
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: Number(width), height: Number(height) } });
await page.goto(url, { waitUntil: 'networkidle' });
const snaps = await page.evaluate(() => Array.from(document.querySelectorAll('[data-deck-snap]')).map((a) => Math.round(a.getBoundingClientRect().top + window.scrollY - parseFloat(getComputedStyle(a).scrollMarginTop))));
const dots = page.locator('[data-deck-dots] a');
await page.evaluate((y) => window.scrollTo(0, y), snaps[2]);
await page.waitForTimeout(800);
const before = await page.evaluate(() => Math.round(window.scrollY));
await dots.nth(0).click();
await page.waitForTimeout(1500);
const back = await page.evaluate(() => Math.round(window.scrollY));
await dots.nth(3).click();
await page.waitForTimeout(1500);
const fwd = await page.evaluate(() => Math.round(window.scrollY));
const active = await page.evaluate(() => Array.from(document.querySelectorAll('[data-deck-dots] a')).map((a) => a.getAttribute('aria-current') === 'true'));
const hint = await page.evaluate(() => { const h = document.querySelector('[data-deck-hint]'); return { href: h?.getAttribute('href'), last: h?.hasAttribute('data-last') }; });
console.log({ snaps, before, backTo1: back, expected1: snaps[0], fwdTo4: fwd, expected4: snaps[3], active, hint });
await browser.close();
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Turns a generated clip into a seamless looping hero background:
# the clip plays forward, then backward, so the loop point cannot be seen.
# Usage: encode-hero-video.sh <source.mp4> <out-basename> [width]
set -euo pipefail
FFMPEG="${FFMPEG:-ffmpeg}"
SRC="$1"; OUT="$2"; W="${3:-1920}"
FILTER="[0:v]scale=${W}:-2:flags=lanczos,fps=25,setpts=PTS-STARTPTS,split[a][b];[b]reverse,trim=start_frame=1,setpts=PTS-STARTPTS[r];[a][r]concat=n=2:v=1[v]"
"$FFMPEG" -y -i "$SRC" -filter_complex "$FILTER" -map "[v]" -an \
-c:v libx264 -profile:v high -crf 30 -preset slow -pix_fmt yuv420p -movflags +faststart "${OUT}.mp4"
"$FFMPEG" -y -i "$SRC" -filter_complex "$FILTER" -map "[v]" -an \
-c:v libvpx-vp9 -crf 40 -b:v 0 -row-mt 1 -deadline good -cpu-used 2 "${OUT}.webm"
ls -lh "${OUT}.mp4" "${OUT}.webm"
+39
View File
@@ -0,0 +1,39 @@
import { chromium } from 'playwright';
/* Checks the hero video: does it load and play, is it gated off on phones and under reduced
motion, and does the hero fill the viewport. */
const [,, url, prefix] = process.argv;
const browser = await chromium.launch();
async function probe(label, opts, shots = []) {
const ctx = await browser.newContext(opts);
const page = await ctx.newPage();
const requested = [];
page.on('request', (r) => { if (/\.(mp4|webm)$/.test(r.url())) requested.push(r.url().split('/').pop()); });
await page.goto(url, { waitUntil: 'networkidle' });
await page.waitForTimeout(2500);
const info = await page.evaluate(() => {
const v = document.querySelector('video');
const hero = document.querySelector('.hero');
return {
hasVideo: !!v,
ready: v?.hasAttribute('data-ready') ?? null,
paused: v?.paused ?? null,
currentTime: v ? Math.round(v.currentTime * 10) / 10 : null,
videoSize: v ? `${v.videoWidth}x${v.videoHeight}` : null,
heroHeight: Math.round(hero.getBoundingClientRect().height),
viewport: window.innerHeight,
headerH: Math.round(document.querySelector('header').getBoundingClientRect().height),
};
});
for (const t of shots) {
await page.waitForTimeout(t);
await page.screenshot({ path: `${prefix}-${label}-${t}.png` });
}
console.log(label, JSON.stringify({ ...info, requested }));
await ctx.close();
}
await probe('desktop', { viewport: { width: 1440, height: 900 } }, [0, 2500]);
await probe('mobile', { viewport: { width: 390, height: 844 } });
await probe('reduced', { viewport: { width: 1440, height: 900 }, reducedMotion: 'reduce' });
await browser.close();
+20
View File
@@ -0,0 +1,20 @@
import { chromium } from 'playwright';
/* Reports where the chevron hint sits at the first snap and whether anything covers it. */
const [,, url, width = '1440', height = '900'] = process.argv;
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: Number(width), height: Number(height) } });
await page.goto(url, { waitUntil: 'networkidle' });
const snaps = await page.evaluate(() => Array.from(document.querySelectorAll('[data-deck-snap]')).map((a) => Math.round(a.getBoundingClientRect().top + window.scrollY - parseFloat(getComputedStyle(a).scrollMarginTop))));
await page.evaluate((y) => window.scrollTo(0, y), snaps[0]);
await page.waitForTimeout(800);
const info = await page.evaluate(() => {
const h = document.querySelector('[data-deck-hint]');
const r = h.getBoundingClientRect();
const cs = getComputedStyle(h);
const svg = h.querySelector('svg').getBoundingClientRect();
const atPoint = document.elementFromPoint(window.innerWidth / 2, window.innerHeight - 10);
const next = document.querySelectorAll('[data-deck-card]')[1].getBoundingClientRect();
return { hint: { top: r.top, bottom: r.bottom, h: r.height }, svg: { top: svg.top, bottom: svg.bottom }, position: cs.position, display: cs.display, bottom: cs.bottom, zIndex: cs.zIndex, opacity: cs.opacity, atPoint: atPoint && (atPoint.tagName + '.' + atPoint.className), nextCardTop: next.top, innerHeight: window.innerHeight };
});
console.log(JSON.stringify(info, null, 1));
await browser.close();
+14
View File
@@ -0,0 +1,14 @@
import { chromium } from 'playwright';
/* Hovers one node group of a schematic and captures the drawing. */
const [,, url, out, selector, group] = process.argv;
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
await page.goto(url, { waitUntil: 'networkidle' });
const svg = page.locator(selector).first();
await svg.scrollIntoViewIfNeeded();
await page.waitForTimeout(3500);
await svg.locator(`[data-group="${group}"] .s-ring`).first().hover({ force: true, position: { x: 6, y: 36 } });
await page.waitForTimeout(400);
await svg.screenshot({ path: out });
await browser.close();
console.log('hovered', group);
+14
View File
@@ -0,0 +1,14 @@
import { chromium } from 'playwright';
const browser = await chromium.launch();
/* Phone: the sweep should still run, the hover state should not exist. */
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
const page = await ctx.newPage();
await page.goto(process.argv[2], { waitUntil: 'networkidle' });
await page.locator('.stages__list').scrollIntoViewIfNeeded();
await page.waitForTimeout(2000);
console.log('mobile shown:', JSON.stringify(await page.evaluate(() =>
Array.from(document.querySelectorAll('[data-reveal]')).map((o) => o.hasAttribute('data-shown')))));
console.log('grid opacity (no hover):', await page.evaluate(() =>
getComputedStyle(document.querySelector('.grid-object__base')).opacity));
await page.screenshot({ path: process.argv[3] });
await browser.close();
+16
View File
@@ -0,0 +1,16 @@
import { chromium } from 'playwright';
/* Scrolls a target into view and captures it at several moments, to see a draw-in happen. */
const [,, url, prefix, selector, width = '1440', times = '150,700,1600,3200'] = process.argv;
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: Number(width), height: 900 } });
await page.goto(url, { waitUntil: 'networkidle' });
const el = page.locator(selector).first();
await el.scrollIntoViewIfNeeded();
const t0 = Date.now();
for (const t of times.split(',').map(Number)) {
const wait = t - (Date.now() - t0);
if (wait > 0) await page.waitForTimeout(wait);
await el.screenshot({ path: `${prefix}-${t}.png` });
}
await browser.close();
console.log('captured', selector);
+31
View File
@@ -0,0 +1,31 @@
import { chromium } from 'playwright';
/* For every grid object: does the drawn geometry fit inside its viewBox, and is the drawing
centred in the space its card gives it. */
const [,, url, selector, width = '1440'] = process.argv;
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: Number(width), height: 1000 } });
await page.goto(url, { waitUntil: 'networkidle' });
const rows = await page.evaluate((sel) => {
return Array.from(document.querySelectorAll(sel)).map((svg, i) => {
const vb = svg.viewBox.baseVal;
const b = svg.querySelector('g').getBBox();
const overflow = {
left: +(vb.x - b.x).toFixed(1),
right: +((b.x + b.width) - (vb.x + vb.width)).toFixed(1),
top: +(vb.y - b.y).toFixed(1),
bottom: +((b.y + b.height) - (vb.y + vb.height)).toFixed(1),
};
const clipped = Object.values(overflow).some((v) => v > 0.5);
/* Vertical centring: the gap above the drawing vs the gap below it, inside the card. */
const card = svg.closest('li');
const title = card.querySelector('h2, h3');
const body = card.querySelector('p');
const r = svg.getBoundingClientRect();
const above = +(r.top - title.getBoundingClientRect().bottom).toFixed(1);
const below = +(body.getBoundingClientRect().top - r.bottom).toFixed(1);
return { i, viewBox: `${vb.x} ${vb.y} ${vb.width} ${vb.height}`, clipped, overflow, above, below, offBy: +(above - below).toFixed(1) };
});
}, selector);
console.table(rows);
console.log('any clipped:', rows.some((r) => r.clipped));
await browser.close();
+39
View File
@@ -0,0 +1,39 @@
import { chromium } from 'playwright';
/* Does the panel start at the title and end with the list, and hold that box across steps? */
const [,, url, prefix, width = '1440', height = '900'] = process.argv;
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: Number(width), height: Number(height) } });
await page.goto(url, { waitUntil: 'networkidle' });
const count = await page.evaluate(() => document.querySelectorAll('[data-trigger]').length);
const rows = [];
for (let i = 0; i < count; i++) {
await page.evaluate((i) => {
const t = document.querySelectorAll('[data-trigger]')[i];
const r = t.getBoundingClientRect();
window.scrollTo(0, window.scrollY + r.top + r.height / 2 - window.innerHeight / 2);
}, i);
await page.waitForTimeout(900);
rows.push(await page.evaluate(() => {
const title = document.querySelector('.how__title').getBoundingClientRect();
const steps = Array.from(document.querySelectorAll('[data-step]'));
const last = steps[steps.length - 1].getBoundingClientRect();
const fig = document.querySelector('[data-figure][data-active]').getBoundingClientRect();
const sticky = document.querySelector('.how__sticky').getBoundingClientRect();
const container = document.querySelector('.how__sticky');
const cs = getComputedStyle(container);
const rightEdge = sticky.right - parseFloat(cs.paddingRight);
return {
active: steps.findIndex((s) => s.hasAttribute('data-active')),
panel: `${Math.round(fig.width)}x${Math.round(fig.height)}`,
topVsTitle: Math.round(fig.top - title.top),
bottomVsList: Math.round(fig.bottom - last.bottom),
rightVsEdge: Math.round(fig.right - rightEdge),
listFits: Math.round(sticky.bottom - last.bottom),
spaceAbove: Math.round(fig.top - sticky.top),
spaceBelow: Math.round(sticky.bottom - fig.bottom),
};
}));
await page.screenshot({ path: `${prefix}-${i}.png` });
}
console.table(rows);
await browser.close();
+18
View File
@@ -0,0 +1,18 @@
import { chromium } from 'playwright';
const [,, url, prefix, selector, width = '390'] = process.argv;
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: Number(width), height: 900 } });
await page.goto(url, { waitUntil: 'networkidle' });
const root = page.locator(selector);
const tabs = root.locator('[role="tab"]');
const n = await tabs.count();
for (let i = 0; i < n; i++) {
await tabs.nth(i).click();
await page.waitForTimeout(500);
const panel = root.locator('[role="tabpanel"][data-open]').first();
await panel.scrollIntoViewIfNeeded();
await page.waitForTimeout(200);
await panel.screenshot({ path: `${prefix}-${i}.png` });
}
console.log('panels', n);
await browser.close();
+40
View File
@@ -0,0 +1,40 @@
import { chromium } from 'playwright';
/* Walks the pinned section step by step: which step is active, does the text column fit
inside the pinned area, and is the panel where it should be. */
const [,, url, prefix, width = '1440', height = '900'] = process.argv;
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: Number(width), height: Number(height) } });
await page.goto(url, { waitUntil: 'networkidle' });
const count = await page.evaluate(() => document.querySelectorAll('[data-trigger]').length);
const rows = [];
for (let i = 0; i < count; i++) {
await page.evaluate((i) => {
const t = document.querySelectorAll('[data-trigger]')[i];
const r = t.getBoundingClientRect();
window.scrollTo(0, window.scrollY + r.top + r.height / 2 - window.innerHeight / 2);
}, i);
await page.waitForTimeout(900);
const info = await page.evaluate(() => {
const steps = Array.from(document.querySelectorAll('[data-step]'));
const active = steps.findIndex((s) => s.hasAttribute('data-active'));
const sticky = document.querySelector('.how__sticky').getBoundingClientRect();
const text = document.querySelector('.how__text');
const first = text.getBoundingClientRect();
const last = steps[steps.length - 1].getBoundingClientRect();
const fig = document.querySelector('[data-figure][data-active]');
const f = fig ? fig.getBoundingClientRect() : null;
const svg = fig ? fig.querySelector('svg').getBoundingClientRect() : null;
return {
active,
textTop: Math.round(first.top - sticky.top),
spaceBelowList: Math.round(sticky.bottom - last.bottom),
panel: f ? `${Math.round(f.width)}x${Math.round(f.height)}` : null,
drawing: svg ? `${Math.round(svg.width)}x${Math.round(svg.height)}` : null,
panelInside: f ? (f.top >= sticky.top - 1 && f.bottom <= sticky.bottom + 1) : null,
};
});
rows.push({ step: i, ...info });
await page.screenshot({ path: `${prefix}-${i}.png` });
}
console.table(rows);
await browser.close();
+158
View File
@@ -0,0 +1,158 @@
"""Give every paper in src/data/research.json its own Chladni figure and colour field.
Each paper gets `figure` (parameters for src/scripts/chladni.ts) and `field` (three colour tokens
and their positions), seeded from the paper id so a re-run gives the same result and a new paper
gets its own. Generation follows the rules learned while hand-tuning the first figures:
- plate windows stay clear of both axes and of any coordinate where n·t and m·t are both whole
(the field is zero along a straight line there), plates are never mirrored (the seams read as
straight lines), and the
weights avoid a = ±b (which zeroes the diagonals u = ±v);
- hover keeps both plate terms in play (a single sin·sin term is a straight grid);
- source figures use three to six sources (two sources draw a straight bisector), spread over
enough of a wavelength that they interfere (radius × frequency >= 0.6 for five or six sources,
0.35 for three or four); packed tighter they collapse into plain concentric rings;
- one colour patch sits in the middle of the card, so no field is mostly black;
- line density, the share of grid cells a nodal line passes through, stays within the range of
the hand-picked figures, so no card is near-empty or a scribble.
A paper that already has a figure keeps it unless --regenerate is passed, so hand edits survive.
Usage: python3 scripts/research-figures.py [--regenerate] [--report]
"""
import hashlib, json, math, random, sys
PATH = 'src/data/research.json'
ASPECT = 3 # the card canvas is 3:1
Q = 61 # grid for the density check (the site renders at 121)
COLOURS = [
'logo-deep', 'logo-mid', 'logo-teal', 'logo-cyan',
'field-indigo', 'field-petrol', 'field-sea', 'field-steel', 'field-dusk',
'field-cobalt', 'field-ocean', 'field-glacier', 'field-aqua', 'field-jade', 'field-slate', 'field-plum',
]
# Light tones carry the glow; at most one per field, so every card keeps a dark side.
LIGHT = {'logo-cyan', 'field-glacier', 'field-aqua', 'logo-teal'}
def field_fn(f, shape=None):
s = {**f, **(shape or {})}
if f['mode'] == 'plate':
a, b, n, m = s['a'], s['b'], s['n'], s['m']
rot = s.get('rotation', 0)
c, si = math.cos(rot), math.sin(rot)
ox, oy = f['origin']
def z(x, y):
dx, dy = x - ox, y - oy
u, v = ox + dx * c - dy * si, oy + dx * si + dy * c
return a * math.sin(math.pi * n * u) * math.sin(math.pi * m * v) + b * math.sin(math.pi * m * u) * math.sin(math.pi * n * v)
return z
pts = []
for i in range(f['sources']):
t = i / f['sources'] * 2 * math.pi - math.pi / 2 + s.get('spin', 0)
pts.append((f['origin'][0] + s['radius'] * math.cos(t), f['origin'][1] + s['radius'] * math.sin(t)))
k = 2 * math.pi * s['frequency']
inv = 1 / s['elasticity']
return lambda x, y: sum(math.sin(k * (s['phase'] - inv * math.hypot(x - px, y - py))) for px, py in pts)
def density(f, shape=None):
z = field_fn(f, shape)
h = f['length']; w = h * ASPECT
x0, y0 = f['origin'][0] - w / 2, f['origin'][1] - h / 2
g = [[z(x0 + c * w / (Q - 1), y0 + r * h / (Q - 1)) > 0 for c in range(Q)] for r in range(Q)]
cells = sum(1 for r in range(Q - 1) for c in range(Q - 1) if len({g[r][c], g[r][c + 1], g[r + 1][c], g[r + 1][c + 1]}) > 1)
return cells / (Q - 1) ** 2
def straight_lines(n, m, origin, hx, hy):
"""True if the window crosses a coordinate where n·t and m·t are both whole: both terms of the
plate vanish there for every value of the other coordinate, which draws a straight line."""
for centre, half in ((origin[0], hx), (origin[1], hy)):
steps = 400
for i in range(steps + 1):
t = centre - half + 2 * half * i / steps
if abs(n * t - round(n * t)) < 0.03 and abs(m * t - round(m * t)) < 0.03:
return True
return False
def rnd(rng, lo, hi, step=0.01):
return round(rng.uniform(lo, hi) / step) * step
def make_plate(rng, rotated):
while True:
n, m = rnd(rng, 1.1, 4.6), rnd(rng, 1.1, 4.6)
if abs(n - m) < 0.35: continue
length = rnd(rng, 1.0, 2.8)
if not (2.4 <= max(n, m) * length <= 8.5): continue
a = rnd(rng, 0.8, 1.7) * rng.choice([-1, 1])
b = rnd(rng, 0.4, 1.5) * rng.choice([-1, 1])
if abs(abs(a) - abs(b)) < 0.25: continue
rotation = rnd(rng, 0.2, 1.3) if rotated else 0
# Keep the whole (rotated) window off both axes.
half_diag = math.hypot(length * ASPECT / 2, length / 2) if rotated else None
hx = half_diag if rotated else length * ASPECT / 2
hy = half_diag if rotated else length / 2
origin = [-round(hx + rnd(rng, 0.3, 2.5), 2), round(hy + rnd(rng, 0.3, 2.5), 2)]
f = {'mode': 'plate', 'a': a, 'b': b, 'n': n, 'm': m, 'origin': origin, 'length': length}
if rotated: f['rotation'] = rotation
if straight_lines(n, m, origin, hx, hy): continue
# Hover: flip the dominant weight and bring it down to 40-75 % of the other. Never near zero
# (a single sin·sin term draws a grid of straight lines) and never near a = ±b.
ha = None
for _ in range(20):
cand = round(-math.copysign(1, a) * abs(b) * rng.uniform(0.4, 0.75), 2)
if abs(abs(cand) - abs(b)) >= 0.2:
ha = cand; break
if ha is None: continue
f['hover'] = {'a': ha}
return f
def make_sources(rng):
while True:
count = rng.choice([3, 3, 4, 4, 5, 6])
length = rnd(rng, 1.8, 2.8)
f = {
'mode': 'sources', 'sources': count,
'frequency': rnd(rng, 1.0, 2.5), 'phase': rnd(rng, 0, 0.5), 'elasticity': 1,
'radius': rnd(rng, 0.3, min(1.1, length / 2.2)),
'origin': [rnd(rng, -1.3, 1.3), rnd(rng, -0.4, 0.4)], 'length': length,
'spin': rnd(rng, 0, 2 * math.pi / count),
}
if f['radius'] * f['frequency'] < (0.6 if count >= 5 else 0.35): continue
f['hover'] = {'phase': round(f['phase'] + rnd(rng, 0.35, 0.55), 2), 'spin': round(f['spin'] + rnd(rng, 0.35, 0.65), 2)}
return f
def make_field(rng):
while True:
cols = rng.sample(COLOURS, 3)
if sum(c in LIGHT for c in cols) <= 1: break
pos = [[rnd(rng, 30, 70, 1), rnd(rng, 30, 70, 1)]] + [[rnd(rng, 5, 95, 1), rnd(rng, 5, 95, 1)] for _ in range(2)]
return {'colors': cols, 'positions': pos}
def generate(pid, lo, hi):
rng = random.Random(int(hashlib.sha256(pid.encode()).hexdigest(), 16))
for _ in range(200):
kind = rng.choices(['sources', 'plate', 'rotated'], [0.45, 0.35, 0.2])[0]
f = make_sources(rng) if kind == 'sources' else make_plate(rng, kind == 'rotated')
d0, d1 = density(f), density(f, f['hover'])
if lo <= d0 <= hi and lo * 0.8 <= d1 <= hi * 1.2:
return f, make_field(rng), d0
raise SystemExit('no acceptable figure for ' + pid)
if __name__ == '__main__':
data = json.load(open(PATH))
# Density band, calibrated on the 18 hand-picked figures (0.24 to 0.51 on this grid), without
# the two busiest, which read as scribbles at small card sizes.
lo, hi = 0.23, 0.45
regen = '--regenerate' in sys.argv
for p in data['papers']:
if 'figure' in p and not regen: continue
p['figure'], p['field'], d = generate(p['id'], lo, hi)
if '--report' in sys.argv: print(f"{d:.3f} {p['figure']['mode']:8} {p['id'][:50]}")
json.dump(data, open(PATH, 'w'), indent=2, ensure_ascii=False)
+19
View File
@@ -0,0 +1,19 @@
import { chromium } from 'playwright';
const [,, url, prefix, width = '1440', height = '900', max = '12'] = process.argv;
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: Number(width), height: Number(height) } });
await page.goto(url, { waitUntil: 'networkidle', timeout: 60000 });
// trigger lazy loading by scrolling through
const total = await page.evaluate(() => document.documentElement.scrollHeight);
for (let y = 0; y < total; y += 600) { await page.evaluate((y) => window.scrollTo(0, y), y); await page.waitForTimeout(150); }
await page.evaluate(() => window.scrollTo(0, 0));
await page.waitForTimeout(800);
const h = Number(height);
const n = Math.min(Number(max), Math.ceil(total / h));
for (let i = 0; i < n; i++) {
await page.evaluate((y) => window.scrollTo(0, y), i * h);
await page.waitForTimeout(700);
await page.screenshot({ path: `${prefix}-${String(i).padStart(2, '0')}.png`, fullPage: false });
}
console.log('total height', total, 'shots', n);
await browser.close();
+12
View File
@@ -0,0 +1,12 @@
import { chromium } from 'playwright';
const [,, url, out, width = '1440', full = 'true'] = process.argv;
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: Number(width), height: 900 }, deviceScaleFactor: 1 });
await page.goto(url, { waitUntil: 'networkidle', timeout: 60000 });
const total = await page.evaluate(() => document.documentElement.scrollHeight);
for (let y = 0; y < total; y += 500) { await page.evaluate((y) => window.scrollTo(0, y), y); await page.waitForTimeout(120); }
await page.evaluate(() => window.scrollTo(0, 0));
await page.waitForTimeout(1200);
await page.screenshot({ path: out, fullPage: full === 'true' });
await browser.close();
console.log('saved', out);
+28
View File
@@ -0,0 +1,28 @@
import { chromium } from 'playwright';
/* Scrolls to positions around each deck card's snap point and reports where the page comes to rest. */
const [,, url, width = '1440', height = '900'] = process.argv;
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: Number(width), height: Number(height) } });
await page.goto(url, { waitUntil: 'networkidle' });
const info = await page.evaluate(() => {
const cards = Array.from(document.querySelectorAll('[data-deck-snap]'));
const cs = getComputedStyle(document.documentElement);
return cards.map((c, i) => {
const st = getComputedStyle(c);
return { i, naturalTop: c.offsetTop + (c.offsetParent?.getBoundingClientRect().top ?? 0) + window.scrollY, marginTop: st.scrollMarginTop, snapAlign: st.scrollSnapAlign, snapStop: st.scrollSnapStop, htmlSnap: cs.scrollSnapType };
});
});
console.log(JSON.stringify(info, null, 1));
// expected snap points: naturalTop - scrollMarginTop
const results = [];
for (const c of info) {
const expected = Math.round(c.naturalTop - parseFloat(c.marginTop));
for (const off of [-220, 180, 320, 352]) {
await page.evaluate((y) => window.scrollTo({ top: y, behavior: 'auto' }), expected + off);
await page.waitForTimeout(1200);
const y = await page.evaluate(() => Math.round(window.scrollY));
results.push({ card: c.i, expected, offset: off, landed: y, snapped: Math.abs(y - expected) < 2 });
}
}
console.table(results);
await browser.close();
+13
View File
@@ -0,0 +1,13 @@
import { chromium } from 'playwright';
const [,, url, prefix, anchor, width = '1440', height = '900', step = '450', count = '10'] = process.argv;
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: Number(width), height: Number(height) } });
await page.goto(url, { waitUntil: 'networkidle' });
const start = await page.evaluate((sel) => document.querySelector(sel).getBoundingClientRect().top + window.scrollY, anchor);
for (let i = 0; i < Number(count); i++) {
await page.evaluate((y) => window.scrollTo(0, y), start + i * Number(step));
await page.waitForTimeout(i === 0 ? 3000 : 1800);
await page.screenshot({ path: `${prefix}-${String(i).padStart(2, '0')}.png` });
}
await browser.close();
console.log('stack shots', count);
+13
View File
@@ -0,0 +1,13 @@
import { chromium } from 'playwright';
const [,, url, prefix, anchor, width = '1440', height = '900', step = '450', count = '10'] = process.argv;
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: Number(width), height: Number(height) } });
await page.goto(url, { waitUntil: 'networkidle' });
const start = await page.evaluate((sel) => document.querySelector(sel).getBoundingClientRect().top + window.scrollY, anchor);
for (let i = 0; i < Number(count); i++) {
await page.evaluate((y) => window.scrollTo(0, y), start + i * Number(step));
await page.waitForTimeout(i === 0 ? 2500 : 1500);
await page.screenshot({ path: `${prefix}-${String(i).padStart(2, '0')}.png` });
}
await browser.close();
console.log('stack shots', count);
+14
View File
@@ -0,0 +1,14 @@
import { chromium } from 'playwright';
/* Scrolls from an anchor downwards in steps, capturing the viewport each time. */
const [,, url, prefix, anchor, width = '1440', height = '900', step = '450', count = '10'] = process.argv;
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: Number(width), height: Number(height) } });
await page.goto(url, { waitUntil: 'networkidle' });
const start = await page.evaluate((sel) => document.querySelector(sel).getBoundingClientRect().top + window.scrollY, anchor);
for (let i = 0; i < Number(count); i++) {
await page.evaluate((y) => window.scrollTo(0, y), start + i * Number(step));
await page.waitForTimeout(i === 0 ? 3000 : 1800);
await page.screenshot({ path: `${prefix}-${String(i).padStart(2, '0')}.png` });
}
await browser.close();
console.log('stack shots', count);
+24
View File
@@ -0,0 +1,24 @@
import { chromium } from 'playwright';
/* Does the pinned panel hold from the first step to the last, and let go cleanly after? */
const [,, url, prefix, width = '1440', height = '900'] = process.argv;
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: Number(width), height: Number(height) } });
await page.goto(url, { waitUntil: 'networkidle' });
const geo = await page.evaluate(() => {
const sc = document.querySelector('.how__scroller').getBoundingClientRect();
return { top: Math.round(sc.top + window.scrollY), height: Math.round(sc.height) };
});
const marks = { 'enter': geo.top, 'mid': geo.top + geo.height / 2, 'lastStep': geo.top + geo.height - (window?.innerHeight ?? 900), 'exit': geo.top + geo.height - 200 };
for (const [name, y] of Object.entries(marks)) {
await page.evaluate((y) => window.scrollTo(0, y), y);
await page.waitForTimeout(800);
const info = await page.evaluate(() => {
const st = document.querySelector('.how__sticky').getBoundingClientRect();
const active = Array.from(document.querySelectorAll('[data-step]')).findIndex((s) => s.hasAttribute('data-active'));
return { stickyTop: Math.round(st.top), stickyBottom: Math.round(st.bottom), active };
});
console.log(name.padEnd(9), JSON.stringify(info));
await page.screenshot({ path: `${prefix}-${name}.png` });
}
console.log('scroller', JSON.stringify(geo));
await browser.close();
+18
View File
@@ -0,0 +1,18 @@
import { chromium } from 'playwright';
const [,, url, prefix, selector, width = '1440'] = process.argv;
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: Number(width), height: 1100 } });
await page.goto(url, { waitUntil: 'networkidle' });
const root = page.locator(selector);
const tabs = root.locator('[role="tab"]');
const n = await tabs.count();
for (let i = 0; i < n; i++) {
await tabs.nth(i).click();
await page.waitForTimeout(450);
const stage = root.locator('[data-stage-item][data-active]').first();
await stage.scrollIntoViewIfNeeded();
await page.waitForTimeout(200);
await stage.screenshot({ path: `${prefix}-${i}.png` });
}
console.log('tabs', n);
await browser.close();
+17
View File
@@ -0,0 +1,17 @@
import { chromium } from 'playwright';
const [,, url, out] = process.argv;
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
await page.goto(url, { waitUntil: 'networkidle' });
const tabs = page.locator('[role="tab"]');
await tabs.nth(0).focus();
await page.keyboard.press('ArrowDown');
await page.waitForTimeout(500);
const selected = await page.evaluate(() => Array.from(document.querySelectorAll('[role="tab"]')).map(t => t.getAttribute('aria-selected')));
const activeImg = await page.evaluate(() => Array.from(document.querySelectorAll('[data-stage-item]')).map(p => p.hasAttribute('data-active')));
const focused = await page.evaluate(() => document.activeElement?.textContent?.trim());
console.log({ selected, activeImg, focused });
await page.locator('#sectors').scrollIntoViewIfNeeded();
await page.waitForTimeout(600);
await page.screenshot({ path: out, fullPage: false });
await browser.close();