← Public packages
@kentcdodds/invisible-software
Invisible software: a phone-only step-through demo plus a chart of AI agent benchmark progress.
src/deck.html
243 lines · 10.2 KB · HTML<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Invisible software demo</title>
<script>
// Kody may serve this page with or without a trailing slash; assets always live in c/ beside it.
{
const dir = location.pathname.endsWith('/') ? location.pathname : location.pathname + '/'
const base = document.createElement('base')
base.href = dir + 'c/'
document.head.append(base)
}
</script>
<link rel="icon" href="icons/favicon.ico" sizes="any">
<link rel="icon" type="image/png" sizes="32x32" href="icons/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="icons/favicon-16x16.png">
<link rel="apple-touch-icon" sizes="180x180" href="icons/apple-touch-icon.png">
<link rel="stylesheet" href="lib.css">
<link rel="stylesheet" href="vc-app.css">
<link rel="stylesheet" href="chat.css">
<script src="lib.js"></script>
<script src="vc-finder.js"></script>
<style>
html, body { margin: 0; height: 100%; background: #fff; overflow: hidden; }
/* Phone only: the stage is exactly the phone, centered and scaled to fit; everything else on a slide is hidden. */
#stage { position: absolute; left: 50%; top: 50%; width: 417px; height: 876px; transform-origin: 0 0; background: none; overflow: visible; }
#stage > .slide { position: absolute; inset: 0; width: auto; height: auto; background: none !important; overflow: visible; }
#stage > .slide > :not(.phone):not(style):not(script) { display: none !important; }
#stage > .slide > .phone { left: 0 !important; top: 0 !important; transform: none !important; animation: none !important; opacity: 1 !important; }
#stage > .slide { opacity: 0; visibility: hidden; transition: opacity .35s ease, visibility 0s linear .35s; }
#stage > .slide.on { opacity: 1; visibility: visible; transition: opacity .35s ease; }
#progress { display: none; position: absolute; left: 0; bottom: 0; height: 3px; width: 0; background: rgb(255 255 255 / 0.28); mix-blend-mode: difference; z-index: 5; transition: width .3s ease; }
</style>
</head>
<body>
<div id="stage">
<!-- slides -->
<div id="progress"></div>
</div>
<script>
// Each slide declares its motion segments in seconds: data-steps="[[from, to], ...]", each starting
// and ending at rest, and data-human="[n, ...]": the 1-based segments that begin with a person acting
// (typing, sending, tapping, opening a link). A deck step is one human segment plus every computer
// segment after it (replies, apps opening, notifications), even across slides; they play by
// themselves with their natural pauses. Opener steps (the first step, and any segment listed in a
// slide's data-opener) start a scene with no human action, e.g. a notification arriving.
// The URL hash names the step: `#<slide label>.<n>` (1-based), e.g. `#3.2`, and lands on its settled end.
const stage = document.getElementById('stage')
const progress = document.getElementById('progress')
const SLIDES = [...stage.querySelectorAll(':scope > .slide')].map((root) => ({
root,
label: root.dataset.label,
duration: Number(root.dataset.duration),
segs: JSON.parse(root.dataset.steps),
human: JSON.parse(root.dataset.human || '[]'),
opener: JSON.parse(root.dataset.opener || '[]'),
tl: deck.timeline(root),
}))
const SEGS = SLIDES.flatMap((s, si) => s.segs.map(([from, to], i) => ({ slide: si, from, to, human: s.human.includes(i + 1), opener: s.opener.includes(i + 1) })))
const STEPS = []
for (const seg of SEGS) {
if (seg.human || seg.opener || !STEPS.length) {
const n = STEPS.filter((st) => st.slide === seg.slide).length + 1
STEPS.push({ slide: seg.slide, from: seg.from, id: `${SLIDES[seg.slide].label}.${n}`, human: seg.human, opener: seg.opener || !STEPS.length, segs: [] })
}
STEPS[STEPS.length - 1].segs.push(seg)
}
for (const st of STEPS) {
const last = st.segs[st.segs.length - 1]
st.endSlide = last.slide
st.to = last.to
}
function stepId(k) {
return k < 0 ? 'start' : STEPS[k].id
}
function parseHash(hash) {
const h = decodeURIComponent(hash.replace(/^#/, '')).trim().toLowerCase()
if (!h || h === 'start') return -1
const byId = STEPS.findIndex((s) => s.id === h)
if (byId >= 0) return byId
const slide = SLIDES.findIndex((s) => s.label === h)
if (slide >= 0) return STEPS.findIndex((s) => s.slide === slide)
return -1
}
// Navigation pushes a history entry so back/forward walk the steps; restoring from the URL replaces.
function syncUrl(push = true) {
const hash = '#' + stepId(state.step)
if (location.hash !== hash) history[push ? 'pushState' : 'replaceState'](null, '', location.pathname + location.search + hash)
}
function fit() {
const s = Math.min(innerWidth / 477, innerHeight / 936)
stage.style.transform = `scale(${s}) translate(-50%, -50%)`
}
addEventListener('resize', fit)
fit()
const state = { step: -1, slide: 0, t: 0, playing: false, ready: false }
let raf = 0
function seek(si, t) {
state.slide = si
state.t = t
SLIDES[si].tl.seek(t)
SLIDES.forEach((s, i) => s.root.classList.toggle('on', i === si))
}
function setStep(k, push = true) {
state.step = k
progress.style.width = ((k + 1) / STEPS.length) * 100 + '%'
syncUrl(push)
}
function hold(k, push = true) {
cancelAnimationFrame(raf)
state.playing = false
if (k < 0) seek(0, 0)
else seek(STEPS[k].endSlide, STEPS[k].to)
setStep(k, push)
}
// Plays a step's segments in order. Within a slide, time runs straight through, so the pauses
// between segments stay natural. Moving to another slide waits a beat for the cross-fade.
const SLIDE_GAP = 0.7
function play(k) {
const st = STEPS[k]
const plan = []
let clock = 0
st.segs.forEach((seg, i) => {
const prevSeg = st.segs[i - 1]
if (prevSeg && prevSeg.slide === seg.slide) {
plan[plan.length - 1].to = seg.to
clock += seg.to - prevSeg.to
plan[plan.length - 1].end = clock
} else {
if (prevSeg) clock += SLIDE_GAP
plan.push({ slide: seg.slide, from: seg.from, to: seg.to, start: clock, end: clock + (seg.to - seg.from) })
clock += seg.to - seg.from
}
})
cancelAnimationFrame(raf)
state.playing = true
seek(plan[0].slide, plan[0].from)
setStep(k)
const start = performance.now()
const tick = (now) => {
const el = (now - start) / 1000
// Between slides: hold the old slide on its last frame for half the gap, then cross-fade
// (CSS, .35s) to the new slide, which starts moving once the gap is over.
let part = plan[0], prevPart = null
for (const p of plan) if (el >= p.start - (p === plan[0] ? 0 : SLIDE_GAP)) { if (p !== plan[0]) prevPart = part; part = p }
if (prevPart && el < part.start - SLIDE_GAP / 2) seek(prevPart.slide, prevPart.to)
else seek(part.slide, Math.min(part.to, part.from + Math.max(0, el - part.start)))
if (el < clock) raf = requestAnimationFrame(tick)
else {
seek(st.endSlide, st.to)
state.playing = false
}
}
raf = requestAnimationFrame(tick)
}
// Next mid-sequence settles the current step at its end, then plays the following one.
// At the very end it just settles the last step.
function next() {
if (!state.ready) return
if (state.playing) seek(STEPS[state.step].endSlide, STEPS[state.step].to)
if (state.step < STEPS.length - 1) play(state.step + 1)
else if (state.playing) hold(state.step)
}
function prev() {
if (!state.ready) return
if (state.step >= 0) hold(state.step - 1)
}
addEventListener('keydown', (e) => {
if (['ArrowRight', ' ', 'PageDown', 'Enter'].includes(e.key)) { e.preventDefault(); next() }
else if (['ArrowLeft', 'PageUp', 'Backspace'].includes(e.key)) { e.preventDefault(); prev() }
else if (e.key === 'Home') hold(-1)
else if (e.key === 'End') hold(STEPS.length - 1)
else if (e.key === 'f' || e.key === 'F') {
if (document.fullscreenElement) document.exitFullscreen()
else document.documentElement.requestFullscreen().catch(() => {})
}
})
// A plain click advances; selecting text (dragging, double-clicking, or clicking away an
// existing selection) does not.
// The advance waits out the double-click window so the first click of a double-click doesn't count.
let down = null
let pendingClick = 0
addEventListener('mousedown', (e) => {
clearTimeout(pendingClick)
down = { x: e.clientX, y: e.clientY, hadSelection: !getSelection().isCollapsed }
})
addEventListener('click', (e) => {
const d = down
down = null
if (e.button !== 0 || e.detail > 1 || !d || d.hadSelection) return
if (Math.hypot(e.clientX - d.x, e.clientY - d.y) > 4 || !getSelection().isCollapsed) return
pendingClick = setTimeout(() => { if (getSelection().isCollapsed) next() }, 250)
})
function restoreFromUrl() {
if (!state.ready) return
const k = parseHash(location.hash)
if (k !== state.step || state.playing) hold(k, false)
else syncUrl(false)
}
addEventListener('popstate', restoreFromUrl)
addEventListener('hashchange', restoreFromUrl)
// For tooling: the animations running at slide time t (finite ones mid-flight, plus every
// looping one), and a fingerprint of the slide's DOM so JS-driven motion shows up as a
// changed fingerprint between samples.
function shown(el, root) {
for (let e = el; e && e !== root; e = e.parentElement) {
const cs = getComputedStyle(e)
if (cs.display === 'none' || cs.visibility === 'hidden' || Number(cs.opacity) < 0.02) return false
}
return true
}
function probe(si, t) {
const { tl } = SLIDES[si]
tl.seek(t)
// Only the phone is on screen, so only the phone counts as motion.
const root = SLIDES[si].root.querySelector('.phone')
const ms = t * 1000
const active = [], looping = []
for (const a of root.getAnimations({ subtree: true })) {
const c = a.effect.getComputedTiming()
const el = a.effect.target
const name = `${a.animationName ?? 'animation'} .${(el?.className?.baseVal ?? el?.className ?? '').toString().split(' ')[0]}`
if (c.iterations === Infinity) { if (ms > c.delay && shown(el, root)) looping.push(name) }
else if (ms > c.delay + 1 && ms < c.delay + c.activeDuration - 1) active.push(name)
}
const html = root.innerHTML
let sig = 0
for (let i = 0; i < html.length; i++) sig = (sig * 31 + html.charCodeAt(i)) | 0
return { active, looping, sig }
}
const ready = document.fonts.ready.then(() => Promise.all([...stage.querySelectorAll('img')].map((i) => i.decode().catch(() => {}))))
ready.then(() => { state.ready = true; hold(parseHash(location.hash), false) })
window.__slideshow = { SLIDES, SEGS, STEPS, state, next, prev, hold, parseHash, stepId, probe }
</script>
</body>
</html>