Skip to content
← Public packages

@kentcdodds/invisible-software

Invisible software: a phone-only step-through demo plus a chart of AI agent benchmark progress.

FollowForkOpen app

scripts/find-steps.mjs

89 lines · 4.1 KB · JavaScript
Raw
// Finds where each slide is in motion, so steps start and end only at rest.
// A moment is "moving" when a finite CSS animation is running, a looping one is running
// that won't still be running at the slide's end (a spinner, typing dots), or the slide's
// DOM changes between samples (JS-driven motion). Ambient loops, like a Live ping that runs
// to the end of the slide, are ignored.
// Usage: node scripts/find-steps.mjs [url] [--why] [--write]
//   Prints each slide's steps (--why adds what moves in each). --write saves them as
//   data-steps in src/slides/.
// Needs `playwright` resolvable from here; set CHROMIUM_PATH to pick a binary.
import { readdirSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { chromium } from 'playwright'

const args = process.argv.slice(2)
const write = args.includes('--write')
const verbose = args.includes('--why')
const url = args.find((a) => !a.startsWith('--')) ?? 'http://localhost:4310/packages/invisible-software/demo'
const DT = 0.05
// Motion runs closer together than this are one step; the eye reads them as one movement.
const JOIN_GAP = 0.2
// A step shorter than this is a blip (a label swap, a badge flip), so it joins the step before it.
const MIN_STEP = 0.4

const browser = await chromium.launch({ executablePath: process.env.CHROMIUM_PATH || undefined })
const page = await browser.newPage({ viewport: { width: 1920, height: 1080 } })
await page.goto(url)
await page.waitForFunction(() => window.__slideshow?.state.ready, null, { timeout: 60000 })

const slides = await page.evaluate((DT) => window.__slideshow.SLIDES.map((s, si) => {
	const n = Math.round(s.duration / DT)
	const samples = []
	for (let i = 0; i <= n; i++) samples.push(window.__slideshow.probe(si, +(i * DT).toFixed(3)))
	return { label: s.label, duration: s.duration, samples }
}), DT)
await browser.close()

const round = (t) => +t.toFixed(2)
const result = slides.map(({ label, duration, samples }) => {
	const ambient = new Set(samples.at(-1).looping)
	const why = samples.map((s, i) => [
		...s.active,
		...s.looping.filter((n) => !ambient.has(n)),
		...(i + 1 < samples.length && s.sig !== samples[i + 1].sig ? ['dom'] : []),
	])
	const moving = why.map((w) => w.length > 0)
	const runs = []
	for (let i = 0; i < moving.length; i++) {
		if (!moving[i]) continue
		let j = i
		while (j + 1 < moving.length && moving[j + 1]) j++
		// The run starts from the last still frame before it and ends on the first still frame after it.
		runs.push([round(Math.max(0, i - 1) * DT), round(Math.min(j + 1, moving.length - 1) * DT)])
		i = j
	}
	const steps = []
	for (const r of runs) {
		const last = steps.at(-1)
		if (last && r[0] - last[1] < JOIN_GAP) last[1] = r[1]
		else steps.push([...r])
	}
	for (let i = steps.length - 1; i > 0; i--) {
		if (steps[i][1] - steps[i][0] < MIN_STEP) { steps[i - 1][1] = steps[i][1]; steps.splice(i, 1) }
	}
	if (steps.length > 1 && steps[0][1] - steps[0][0] < MIN_STEP) { steps[1][0] = steps[0][0]; steps.shift() }
	const reasons = steps.map(([a, b]) => {
		const count = {}
		why.slice(Math.round(a / DT), Math.round(b / DT)).flat().forEach((n) => (count[n] = (count[n] ?? 0) + 1))
		return Object.entries(count).sort((x, y) => y[1] - x[1]).slice(0, 4).map(([n]) => n).join(', ')
	})
	const tail = moving.at(-1)
	return { label, duration, steps, reasons, endsMoving: tail }
})

for (const r of result) {
	console.log(`slide ${r.label} (${r.duration}s): ${r.steps.length} steps${r.endsMoving ? '  WARNING: still moving at the end' : ''}`)
	r.steps.forEach(([a, b], i) => console.log(`  ${r.label}.${i + 1}  ${a}-${b}  ${verbose ? r.reasons[i] : ''}`))
}

if (write) {
	const dir = new URL('../src/slides/', import.meta.url).pathname
	for (const file of readdirSync(dir).filter((f) => f.endsWith('.html'))) {
		const path = join(dir, file)
		const src = readFileSync(path, 'utf8')
		const label = src.match(/data-label="([^"]+)"/)[1]
		const r = result.find((x) => x.label === label)
		writeFileSync(path, src.replace(/data-steps="[^"]*"/, `data-steps="${JSON.stringify(r.steps)}"`))
	}
	console.log('Wrote data-steps to src/slides/. Run npm run build.')
}