← Public packages
@kentcdodds/invisible-software
Invisible software: a phone-only step-through demo plus a chart of AI agent benchmark progress.
src/c/lib.js
154 lines · 10.3 KB · JavaScript// 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()
},
}