← Public packages
@kentcdodds/invisible-software
Invisible software: a phone-only step-through demo plus a chart of AI agent benchmark progress.
scripts/find-steps.mjs
89 lines · 4.1 KB · JavaScript// 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.')
}