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

src/c/lib.js

154 lines · 10.3 KB · JavaScript
Raw
// Shared slide runtime. Every slide is a <section class="slide"> with its own
// deterministic timeline: CSS animations are paused and seeked, and JS hooks
// registered with onTime handle text and state changes. All times are seconds.
window.clamp = (v, a = 0, b = 1) => Math.min(b, Math.max(a, v))
window.prog = (t, t0, t1) => clamp((t - t0) / (t1 - t0))
window.ease = (x) => 1 - Math.pow(1 - x, 3)
window.easeInOut = (x) => (x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2)

const icon = {
	signal: `<svg width="19" height="12" viewBox="0 0 19 12"><rect x="0" y="8" width="3.2" height="4" rx="1"/><rect x="5" y="5.5" width="3.2" height="6.5" rx="1"/><rect x="10" y="3" width="3.2" height="9" rx="1"/><rect x="15" y="0" width="3.2" height="12" rx="1"/></svg>`,
	wifi: `<svg width="17" height="12" viewBox="0 0 17 12"><path d="M8.5 2.3c2.4 0 4.6.9 6.3 2.5l1.2-1.2C14 1.6 11.4.5 8.5.5S3 1.6 1 3.6l1.2 1.2C3.9 3.2 6.1 2.3 8.5 2.3zm0 3.4c1.5 0 2.8.6 3.9 1.5l1.2-1.2C12.3 4.8 10.5 4 8.5 4S4.7 4.8 3.4 6l1.2 1.2c1.1-.9 2.4-1.5 3.9-1.5zm0 3.4c-.7 0-1.3.3-1.8.7L8.5 11.6l1.8-1.8c-.5-.4-1.1-.7-1.8-.7z"/></svg>`,
	battery: `<svg width="27" height="13" viewBox="0 0 27 13"><rect x=".5" y=".5" width="23" height="12" rx="3.8" fill="none" stroke="currentColor" opacity=".4"/><rect x="2" y="2" width="20" height="9" rx="2.5"/><path d="M25 4.5v4c.8-.3 1.3-1.1 1.3-2s-.5-1.7-1.3-2z" opacity=".45"/></svg>`,
	lock: `<svg width="11" height="13" viewBox="0 0 11 13"><path d="M2 5.5V4a3.5 3.5 0 0 1 7 0v1.5h.3c.7 0 1.2.5 1.2 1.2v5.1c0 .7-.5 1.2-1.2 1.2H1.7C1 13 .5 12.5.5 11.8V6.7c0-.7.5-1.2 1.2-1.2H2zm1.4 0h4.2V4a2.1 2.1 0 0 0-4.2 0v1.5z"/></svg>`,
	reload: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M20 12a8 8 0 1 1-2.3-5.7M20 4v4.5h-4.5"/></svg>`,
	back: `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M15 5l-7 7 7 7"/></svg>`,
	fwd: `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5l7 7-7 7"/></svg>`,
	share: `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12M8 7l4-4 4 4M6 11H5v10h14V11h-1"/></svg>`,
	book: `<svg width="24" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 5c3-1.5 7-1.5 10 1 3-2.5 7-2.5 10-1v14c-3-1.5-7-1.5-10 1-3-2.5-7-2.5-10-1z M12 6v14"/></svg>`,
	tabs: `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><rect x="3" y="7" width="14" height="14" rx="3"/><path d="M7 7V6a3 3 0 0 1 3-3h8a3 3 0 0 1 3 3v8a3 3 0 0 1-3 3h-1"/></svg>`,
	chevL: `<svg width="14" height="22" viewBox="0 0 14 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M11 3L3 12l8 9"/></svg>`,
	dots: `<svg width="22" height="6" viewBox="0 0 22 6"><circle cx="3" cy="3" r="2.6"/><circle cx="11" cy="3" r="2.6"/><circle cx="19" cy="3" r="2.6"/></svg>`,
	plus: `<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M12 4v16M4 12h16"/></svg>`,
	mic: `<svg width="16" height="20" viewBox="0 0 16 22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="4.5" y="1" width="7" height="13" rx="3.5" fill="currentColor"/><path d="M1.5 10.5a6.5 6.5 0 0 0 13 0M8 17v4"/></svg>`,
	wave: `<svg width="20" height="18" viewBox="0 0 20 18" fill="currentColor"><rect x="1" y="6" width="2.4" height="6" rx="1.2"/><rect x="5.3" y="3" width="2.4" height="12" rx="1.2"/><rect x="9.6" y="0" width="2.4" height="18" rx="1.2"/><rect x="13.9" y="4" width="2.4" height="10" rx="1.2"/><rect x="18" y="7" width="2" height="4" rx="1"/></svg>`,
	send: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20V5M5 11l7-7 7 7"/></svg>`,
	flash: `<svg width="20" height="24" viewBox="0 0 20 24" fill="currentColor"><path d="M6 1h8l-1 4H7zM6.5 6h7c.3 3-2 4-2 7v9c0 .6-.4 1-1 1h-1c-.6 0-1-.4-1-1v-9c0-3-2.3-4-2-7z"/></svg>`,
	camera: `<svg width="24" height="20" viewBox="0 0 24 20" fill="currentColor"><path d="M8 1h8l1.8 2.6H21a2.5 2.5 0 0 1 2.5 2.5v10.4A2.5 2.5 0 0 1 21 19H3a2.5 2.5 0 0 1-2.5-2.5V6.1A2.5 2.5 0 0 1 3 3.6h3.2zM12 6.3a4.5 4.5 0 1 0 0 9 4.5 4.5 0 0 0 0-9z"/></svg>`,
	check: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg>`,
	fork: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="5" r="2.3"/><circle cx="18" cy="5" r="2.3"/><circle cx="12" cy="19" r="2.3"/><path d="M6 7.3v1.2c0 1.7 1.3 3 3 3h6c1.7 0 3-1.3 3-3V7.3M12 11.5v5.2"/></svg>`,
	cube: `<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"><path d="M12 2.8l8 4.4v9.6l-8 4.4-8-4.4V7.2z M4 7.2l8 4.5 8-4.5 M12 11.7v9.5"/></svg>`,
	arrowUR: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M7 17L17 7M8 7h9v9"/></svg>`,
	menu: `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M4 6.5h16M4 12h16M4 17.5h16"/></svg>`,
}
window.icon = icon

function fillChrome(root) {
	for (const el of root.querySelectorAll('.statusbar')) {
		el.innerHTML = `<span class="sb-time">${el.dataset.time ?? '9:41'}</span><span class="sb-right">${icon.signal}${icon.wifi}${icon.battery}</span>`
	}
	for (const el of root.querySelectorAll('.safari-top')) {
		el.innerHTML = `<div class="url-pill"><span class="aa"><small>A</small>A</span><span class="url-wrap"><span class="url-lock">${icon.lock}</span><span class="url-text">${el.dataset.url}</span></span><span class="url-reload">${icon.reload}</span><span class="url-progress"></span></div>`
	}
	for (const el of root.querySelectorAll('.safari-bottom')) {
		el.innerHTML = `<span>${icon.back}</span><span class="dim">${icon.fwd}</span><span>${icon.share}</span><span>${icon.book}</span><span>${icon.tabs}</span>`
	}
	for (const el of root.querySelectorAll('[data-icon]')) {
		el.innerHTML = icon[el.dataset.icon]
	}
}

function createTimeline(root) {
	const hooks = []
	const rests = []
	const onTime = (fn) => hooks.push(fn)
	const tl = {
		root,
		onTime,
		// Still beats between the stages of a slide, as [[time, seconds], ...]: anything that
		// starts at or after `time` starts `seconds` later, so the stage before it finishes and
		// can hold before the next one begins. CSS animations on the slide shift automatically
		// once its setup has run; hooks map their own times through at().
		beats(list) {
			rests.push(...list)
			return tl.at
		},
		at: (t) => t + rests.reduce((sum, [b, r]) => (t >= b - 1e-9 ? sum + r : sum), 0),
		shiftAnimations() {
			if (!rests.length) return
			for (const a of root.getAnimations({ subtree: true })) {
				a.effect.updateTiming({ delay: tl.at(a.effect.getTiming().delay / 1000) * 1000 })
			}
		},
		seek(t) {
			for (const h of hooks) h(t)
			for (const a of root.getAnimations({ subtree: true })) {
				a.pause()
				a.currentTime = t * 1000
			}
		},
		// Typewriter: reveals `text` into `el` from t0 at `cps` chars/sec.
		typer(el, text, t0, cps = 16) {
			onTime((t) => {
				const n = Math.floor(clamp((t - t0) * cps, 0, text.length))
				el.textContent = text.slice(0, n)
			})
			return t0 + text.length / cps
		},
		// Swap element HTML between states at given times: [[t, html], ...]
		states(el, list) {
			onTime((t) => {
				let cur = list[0][1]
				for (const [ts, v] of list) if (t >= ts) cur = v
				if (el.__cur !== cur) {
					el.innerHTML = cur
					el.__cur = cur
				}
			})
		},
		// Toggle a class within [t0, t1)
		classAt: (el, cls, t0, t1 = Infinity) => onTime((t) => el.classList.toggle(cls, t >= t0 && t < t1)),
		// Touch indicator that tracks the center of `target` (selector or element).
		tapOn(target, t0, dur = 0.6) {
			const screen = root.querySelector('.screen')
			const tap = document.createElement('div')
			tap.className = 'tap'
			tap.style.animation = `tap ${dur}s ease ${t0}s both`
			screen.appendChild(tap)
			onTime((t) => {
				if (t < t0 - 0.05 || t > t0 + dur) return
				const el = typeof target === 'string' ? root.querySelector(target) : target
				const s = screen.getBoundingClientRect(), r = el.getBoundingClientRect()
				const k = s.width / screen.offsetWidth
				tap.style.left = (r.left + r.width / 2 - s.left) / k + 'px'
				tap.style.top = (r.top + r.height / 2 - s.top) / k + 'px'
			})
		},
		// Reveals `layer` by expanding a rounded clip from `target`'s rect to the full screen.
		openFrom(layer, target, t0, dur = 0.6) {
			const screen = root.querySelector('.screen')
			onTime((t) => {
				layer.style.opacity = t < t0 ? 0 : 1
				if (t < t0) return
				const o = easeInOut(prog(t, t0, t0 + dur))
				if (o >= 1) { layer.style.clipPath = 'none'; return }
				const el = typeof target === 'string' ? root.querySelector(target) : target
				const s = screen.getBoundingClientRect(), r = el.getBoundingClientRect(), k = s.width / screen.offsetWidth
				const top = (r.top - s.top) / k, left = (r.left - s.left) / k
				const right = screen.offsetWidth - (r.right - s.left) / k, bottom = screen.offsetHeight - (r.bottom - s.top) / k
				layer.style.clipPath = `inset(${top * (1 - o)}px ${right * (1 - o)}px ${bottom * (1 - o)}px ${left * (1 - o)}px round ${20 + o * 36}px)`
			})
		},
		mountFinder: (body, t0, tMax) => window.mountFinder(tl, body, t0, tMax),
	}
	return tl
}

const timelines = new Map()
window.deck = {
	timeline(root) {
		let tl = timelines.get(root)
		if (!tl) {
			fillChrome(root)
			tl = createTimeline(root)
			timelines.set(root, tl)
		}
		return tl
	},
	// Called from a <script> inside a slide's <section> while the page parses.
	slide(setup) {
		const tl = this.timeline(document.currentScript.closest('.slide'))
		setup(tl)
		tl.shiftAnimations()
	},
}