:{content}
+ }}}
+createRoot(document.getElementById('root')!).render(mode==='static'?:mode==='loop'?:mode==='failure'?:mode==='scene'?:);
diff --git a/artifacts/validation/stageshow/local-2026-11-09/fixture-scene.css b/artifacts/validation/stageshow/local-2026-11-09/fixture-scene.css
new file mode 100644
index 0000000..38dd0f6
--- /dev/null
+++ b/artifacts/validation/stageshow/local-2026-11-09/fixture-scene.css
@@ -0,0 +1,11 @@
+.validation-scene .ss-stage{background:#f0efed;overflow:hidden}
+.validation-window{position:absolute;left:28px;top:28px;width:600px;height:340px}
+.validation-content{width:440px;padding:24px;box-sizing:border-box}
+.validation-content h2{font:600 20px system-ui;margin:0 0 12px;color:#242424}
+.validation-content p{font:13px system-ui;margin:0 0 24px;color:#666}
+.validation-track{height:8px;background:#e8e7e5;border-radius:4px;margin-bottom:24px;overflow:hidden}
+.validation-progress{height:8px;background:#20865d;transform-origin:left;transition:transform .6s ease}
+.validation-save{height:40px;width:100%;border:0;border-radius:8px;background:#2872df;color:white;display:grid;place-items:center;font:600 13px system-ui;position:relative}
+.validation-save span{grid-area:1/1;transition:opacity .3s ease}
+.validation-card{position:absolute;left:24px;top:24px;width:310px;height:300px;background:white;border-radius:12px}
+.validation-card .validation-content{width:100%}
diff --git a/artifacts/validation/stageshow/local-2026-11-09/fontconfig.xml b/artifacts/validation/stageshow/local-2026-11-09/fontconfig.xml
new file mode 100644
index 0000000..7fe2288
--- /dev/null
+++ b/artifacts/validation/stageshow/local-2026-11-09/fontconfig.xml
@@ -0,0 +1,3 @@
+
+
+/home/maroun.guest/validation/sysroot/usr/share/fonts/home/maroun.guest/validation/font-cache
diff --git a/artifacts/validation/stageshow/local-2026-11-09/guest-deps.log b/artifacts/validation/stageshow/local-2026-11-09/guest-deps.log
new file mode 100644
index 0000000..be22b4f
--- /dev/null
+++ b/artifacts/validation/stageshow/local-2026-11-09/guest-deps.log
@@ -0,0 +1,87 @@
+ linux-vdso.so.1 (0x0000ffffaa65c000)
+ libdl.so.2 => /lib/aarch64-linux-gnu/libdl.so.2 (0x0000ffff9a840000)
+ libpthread.so.0 => /lib/aarch64-linux-gnu/libpthread.so.0 (0x0000ffff9a810000)
+ libglib-2.0.so.0 => /lib/aarch64-linux-gnu/libglib-2.0.so.0 (0x0000ffff9a6b0000)
+ libgobject-2.0.so.0 => /lib/aarch64-linux-gnu/libgobject-2.0.so.0 (0x0000ffff9a630000)
+ libnss3.so => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libnss3.so (0x0000ffff9a4d0000)
+ libnssutil3.so => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libnssutil3.so (0x0000ffff9a480000)
+ libsmime3.so => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libsmime3.so (0x0000ffff9a430000)
+ libnspr4.so => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libnspr4.so (0x0000ffff9a3d0000)
+ libdbus-1.so.3 => /lib/aarch64-linux-gnu/libdbus-1.so.3 (0x0000ffff9a350000)
+ libatk-1.0.so.0 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libatk-1.0.so.0 (0x0000ffff9a300000)
+ libatk-bridge-2.0.so.0 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libatk-bridge-2.0.so.0 (0x0000ffff9a2a0000)
+ libcups.so.2 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libcups.so.2 (0x0000ffff9a1e0000)
+ libgio-2.0.so.0 => /lib/aarch64-linux-gnu/libgio-2.0.so.0 (0x0000ffff99fd0000)
+ libexpat.so.1 => /lib/aarch64-linux-gnu/libexpat.so.1 (0x0000ffff99f80000)
+ libdrm.so.2 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libdrm.so.2 (0x0000ffff99f40000)
+ libxcb.so.1 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libxcb.so.1 (0x0000ffff99ef0000)
+ libxkbcommon.so.0 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libxkbcommon.so.0 (0x0000ffff99e80000)
+ libX11.so.6 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libX11.so.6 (0x0000ffff99d20000)
+ libXcomposite.so.1 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libXcomposite.so.1 (0x0000ffff99d00000)
+ libXdamage.so.1 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libXdamage.so.1 (0x0000ffff99cd0000)
+ libXext.so.6 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libXext.so.6 (0x0000ffff99c90000)
+ libXfixes.so.3 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libXfixes.so.3 (0x0000ffff99c60000)
+ libXrandr.so.2 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libXrandr.so.2 (0x0000ffff99c40000)
+ libgbm.so.1 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libgbm.so.1 (0x0000ffff99c10000)
+ libpango-1.0.so.0 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libpango-1.0.so.0 (0x0000ffff99b80000)
+ libcairo.so.2 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libcairo.so.2 (0x0000ffff99a50000)
+ libudev.so.1 => /lib/aarch64-linux-gnu/libudev.so.1 (0x0000ffff99a00000)
+ libasound.so.2 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libasound.so.2 (0x0000ffff998e0000)
+ libm.so.6 => /lib/aarch64-linux-gnu/libm.so.6 (0x0000ffff99830000)
+ libatspi.so.0 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libatspi.so.0 (0x0000ffff997d0000)
+ libgcc_s.so.1 => /lib/aarch64-linux-gnu/libgcc_s.so.1 (0x0000ffff99790000)
+ libc.so.6 => /lib/aarch64-linux-gnu/libc.so.6 (0x0000ffff995e0000)
+ /lib/ld-linux-aarch64.so.1 (0x0000ffffaa610000)
+ libpcre2-8.so.0 => /lib/aarch64-linux-gnu/libpcre2-8.so.0 (0x0000ffff99530000)
+ libffi.so.8 => /lib/aarch64-linux-gnu/libffi.so.8 (0x0000ffff99500000)
+ libplc4.so => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libplc4.so (0x0000ffff994d0000)
+ libplds4.so => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libplds4.so (0x0000ffff994a0000)
+ libsystemd.so.0 => /lib/aarch64-linux-gnu/libsystemd.so.0 (0x0000ffff993b0000)
+ libgssapi_krb5.so.2 => /lib/aarch64-linux-gnu/libgssapi_krb5.so.2 (0x0000ffff99340000)
+ libavahi-common.so.3 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libavahi-common.so.3 (0x0000ffff99310000)
+ libavahi-client.so.3 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libavahi-client.so.3 (0x0000ffff992e0000)
+ libgnutls.so.30 => /lib/aarch64-linux-gnu/libgnutls.so.30 (0x0000ffff99090000)
+ libz.so.1 => /lib/aarch64-linux-gnu/libz.so.1 (0x0000ffff99050000)
+ libgmodule-2.0.so.0 => /lib/aarch64-linux-gnu/libgmodule-2.0.so.0 (0x0000ffff99020000)
+ libmount.so.1 => /lib/aarch64-linux-gnu/libmount.so.1 (0x0000ffff98f90000)
+ libselinux.so.1 => /lib/aarch64-linux-gnu/libselinux.so.1 (0x0000ffff98f40000)
+ libXau.so.6 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libXau.so.6 (0x0000ffff98f20000)
+ libXdmcp.so.6 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libXdmcp.so.6 (0x0000ffff98f00000)
+ libXrender.so.1 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libXrender.so.1 (0x0000ffff98ee0000)
+ libwayland-server.so.0 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libwayland-server.so.0 (0x0000ffff98ea0000)
+ libfribidi.so.0 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libfribidi.so.0 (0x0000ffff98e70000)
+ libthai.so.0 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libthai.so.0 (0x0000ffff98e50000)
+ libharfbuzz.so.0 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libharfbuzz.so.0 (0x0000ffff98d30000)
+ libpixman-1.so.0 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libpixman-1.so.0 (0x0000ffff98c70000)
+ libfontconfig.so.1 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libfontconfig.so.1 (0x0000ffff98c00000)
+ libfreetype.so.6 => /lib/aarch64-linux-gnu/libfreetype.so.6 (0x0000ffff98b20000)
+ libpng16.so.16 => /lib/aarch64-linux-gnu/libpng16.so.16 (0x0000ffff98ac0000)
+ libxcb-shm.so.0 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libxcb-shm.so.0 (0x0000ffff98a90000)
+ libxcb-render.so.0 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libxcb-render.so.0 (0x0000ffff98a60000)
+ libXi.so.6 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libXi.so.6 (0x0000ffff98a30000)
+ libcap.so.2 => /lib/aarch64-linux-gnu/libcap.so.2 (0x0000ffff98a00000)
+ libgcrypt.so.20 => /lib/aarch64-linux-gnu/libgcrypt.so.20 (0x0000ffff98900000)
+ liblzma.so.5 => /lib/aarch64-linux-gnu/liblzma.so.5 (0x0000ffff988b0000)
+ libzstd.so.1 => /lib/aarch64-linux-gnu/libzstd.so.1 (0x0000ffff987f0000)
+ liblz4.so.1 => /lib/aarch64-linux-gnu/liblz4.so.1 (0x0000ffff987a0000)
+ libkrb5.so.3 => /lib/aarch64-linux-gnu/libkrb5.so.3 (0x0000ffff986b0000)
+ libk5crypto.so.3 => /lib/aarch64-linux-gnu/libk5crypto.so.3 (0x0000ffff98660000)
+ libcom_err.so.2 => /lib/aarch64-linux-gnu/libcom_err.so.2 (0x0000ffff98630000)
+ libkrb5support.so.0 => /lib/aarch64-linux-gnu/libkrb5support.so.0 (0x0000ffff98600000)
+ libp11-kit.so.0 => /lib/aarch64-linux-gnu/libp11-kit.so.0 (0x0000ffff984b0000)
+ libidn2.so.0 => /lib/aarch64-linux-gnu/libidn2.so.0 (0x0000ffff98460000)
+ libunistring.so.2 => /lib/aarch64-linux-gnu/libunistring.so.2 (0x0000ffff98290000)
+ libtasn1.so.6 => /lib/aarch64-linux-gnu/libtasn1.so.6 (0x0000ffff98250000)
+ libnettle.so.8 => /lib/aarch64-linux-gnu/libnettle.so.8 (0x0000ffff981e0000)
+ libhogweed.so.6 => /lib/aarch64-linux-gnu/libhogweed.so.6 (0x0000ffff98170000)
+ libgmp.so.10 => /lib/aarch64-linux-gnu/libgmp.so.10 (0x0000ffff980d0000)
+ libblkid.so.1 => /lib/aarch64-linux-gnu/libblkid.so.1 (0x0000ffff98050000)
+ libbsd.so.0 => /lib/aarch64-linux-gnu/libbsd.so.0 (0x0000ffff98010000)
+ libdatrie.so.1 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libdatrie.so.1 (0x0000ffff97fe0000)
+ libgraphite2.so.3 => /home/maroun.guest/validation/sysroot/usr/lib/aarch64-linux-gnu/libgraphite2.so.3 (0x0000ffff97fa0000)
+ libbrotlidec.so.1 => /lib/aarch64-linux-gnu/libbrotlidec.so.1 (0x0000ffff97f70000)
+ libgpg-error.so.0 => /lib/aarch64-linux-gnu/libgpg-error.so.0 (0x0000ffff97f20000)
+ libkeyutils.so.1 => /lib/aarch64-linux-gnu/libkeyutils.so.1 (0x0000ffff97ef0000)
+ libresolv.so.2 => /lib/aarch64-linux-gnu/libresolv.so.2 (0x0000ffff97ec0000)
+ libmd.so.0 => /lib/aarch64-linux-gnu/libmd.so.0 (0x0000ffff97e90000)
+ libbrotlicommon.so.1 => /lib/aarch64-linux-gnu/libbrotlicommon.so.1 (0x0000ffff97e50000)
diff --git a/artifacts/validation/stageshow/local-2026-11-09/guest-final-state.txt b/artifacts/validation/stageshow/local-2026-11-09/guest-final-state.txt
new file mode 100644
index 0000000..1f819df
--- /dev/null
+++ b/artifacts/validation/stageshow/local-2026-11-09/guest-final-state.txt
@@ -0,0 +1,150 @@
+(sd-pam)
+COMMAND
+acpi_thermal_pm
+agetty
+blkcg_punt_bio
+cpuhp/0
+cpuhp/1
+cpuhp/2
+cpuhp/3
+dbus-daemon
+ext4-rsv-conver
+hwrng
+inet_frag_wq
+ipv6_addrconf
+irq/14-ACPI:Event
+jbd2/vda1-8
+kauditd
+kblockd
+kcompactd0
+kdevtmpfs
+khugepaged
+khungtaskd
+khvcd
+kintegrityd
+ksmd
+ksoftirqd/0
+ksoftirqd/1
+ksoftirqd/2
+ksoftirqd/3
+kstrp
+kswapd0
+kthreadd
+kthrotld
+kworker/0:0-events
+kworker/0:0H-events_highpri
+kworker/0:1-inet_frag_wq
+kworker/0:1H-kblockd
+kworker/0:2-mm_percpu_wq
+kworker/1:0-events
+kworker/1:0H-events_highpri
+kworker/1:1-mm_percpu_wq
+kworker/1:1H-kblockd
+kworker/1:3-inet_frag_wq
+kworker/2:0-events
+kworker/2:0H-events_highpri
+kworker/2:1-inet_frag_wq
+kworker/2:1H-kblockd
+kworker/2:2-mm_percpu_wq
+kworker/3:0-events
+kworker/3:0H-events_highpri
+kworker/3:1-events
+kworker/3:1H-kblockd
+kworker/3:2-inet_frag_wq
+kworker/u8:0-ext4-rsv-conversion
+kworker/u8:2-events_unbound
+kworker/u8:3-events_unbound
+kworker/u9:0
+migration/0
+migration/1
+migration/2
+migration/3
+mld
+mm_percpu_wq
+netns
+nvme-delete-wq
+nvme-reset-wq
+nvme-wq
+oom_reaper
+ps
+rcu_gp
+rcu_par_gp
+rcu_sched
+rcu_tasks_rude_kthread
+rcu_tasks_trace_kthread
+sh
+slub_flushwq
+sort
+sshd
+systemd
+systemd-journal
+systemd-logind
+systemd-network
+systemd-resolve
+systemd-timesyn
+systemd-udevd
+tpm_dev_wq
+unattended-upgr
+uniq
+writeback
+authorized_keys
+092ee4aa51f4f38df3c5ba35708ef288a533a6c9b7078fcb5b71897d6ac5e731 runtime/package-lock.json
+48e9ee06dc76b378bf6f3b701f72a844a2db33f5056086cd5ab2642fa394d271 runtime/scripts/browser.mjs
+354200847958a4573eeb0da9ba4e061fc4f2ec1eecebf3df9b91f05e747f08a3 runtime/scripts/film.mjs
+fd0b74f8a95669cc79ff7635d05329413bf6859fa5db35cf8de3b270981f977c browsers/chromium-1140/chrome-linux/chrome
+alsa-topology-conf=1.2.5.1-2
+libasound2-data=1.2.8-1
+libasound2=1.2.8-1+b1
+alsa-ucm-conf=1.2.8-1
+at-spi2-common=2.46.0-5
+libxau6=1:1.0.9-1
+libxdmcp6=1:1.1.2-3
+libxcb1=1.15-1
+libx11-data=2:1.8.4-2+deb12u2
+libx11-6=2:1.8.4-2+deb12u2
+libxext6=2:1.3.4-1+b1
+libxi6=2:1.8-1+b1
+libatspi2.0-0=2.46.0-5
+x11-common=1:7.7+23
+libxtst6=2:1.2.3-1.1
+dbus-user-session=1.14.10-1~deb12u1
+libdconf1=0.40.0-4
+dconf-service=0.40.0-4
+dconf-gsettings-backend=0.40.0-4
+gsettings-desktop-schemas=43.0-1
+at-spi2-core=2.46.0-5
+fonts-dejavu-core=2.37-6
+fonts-liberation=1:1.07.4-11
+fontconfig-config=2.14.1-4
+libfontconfig1=2.14.1-4
+fontconfig=2.14.1-4
+libatk1.0-0=2.46.0-5
+libatk-bridge2.0-0=2.46.0-5
+libavahi-common-data=0.8-10+deb12u1
+libavahi-common3=0.8-10+deb12u1
+libavahi-client3=0.8-10+deb12u1
+libpixman-1-0=0.42.2-1
+libxcb-render0=1.15-1
+libxcb-shm0=1.15-1
+libxrender1=1:0.9.10-1.1
+libcairo2=1.16.0-7
+libcups2=2.4.2-3+deb12u9
+libdatrie1=0.2.13-2+b1
+libdrm-common=2.4.114-1
+libdrm2=2.4.114-1+b1
+libfribidi0=1.0.8-2.1
+libwayland-server0=1.21.0-1
+libgbm1=22.3.6-1+deb12u2
+libgraphite2-3=1.3.14-1+deb12u1
+libharfbuzz0b=6.0.0+dfsg-3
+libnspr4=2:4.35-1
+libnss3=2:3.87.1-1+deb12u4
+libthai-data=0.1.29-1
+libthai0=0.1.29-1
+libpango-1.0-0=1.50.12+ds-1
+libxcomposite1=1:0.4.5-1
+libxdamage1=1:1.1.6-1
+libxfixes3=1:6.0.0-2
+xkb-data=2.35.1-1
+libxkbcommon0=1.5.0-1
+libxrandr2=2:1.5.2-2+b1
diff --git a/artifacts/validation/stageshow/local-2026-11-09/loop-video.log b/artifacts/validation/stageshow/local-2026-11-09/loop-video.log
new file mode 100644
index 0000000..e69de29
diff --git a/artifacts/validation/stageshow/local-2026-11-09/loop-video.mjs b/artifacts/validation/stageshow/local-2026-11-09/loop-video.mjs
new file mode 100644
index 0000000..0d6f941
--- /dev/null
+++ b/artifacts/validation/stageshow/local-2026-11-09/loop-video.mjs
@@ -0,0 +1,19 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import {launch} from './scripts/browser.mjs';
+const out=new URL('../outputs/',import.meta.url).pathname;
+const browser=await launch();
+try{
+ const context=await browser.newContext({viewport:{width:640,height:360},recordVideo:{dir:out,size:{width:640,height:360}}});
+ const p=await context.newPage();const errors=[];p.on('pageerror',e=>errors.push(e.message));
+ await p.goto(new URL('./acceptance.html?mode=scene',import.meta.url).href);const start=Date.now();const samples=[];
+ for(let at=0;at<=7200;at+=600){
+ await p.waitForTimeout(Math.max(0,start+at-Date.now()));
+ samples.push(await p.locator('.ss-stage').getAttribute('data-mark'));
+ }
+ await p.waitForTimeout(Math.max(0,start+8000-Date.now()));
+ const video=p.video();await context.close();await video.saveAs(out+'loop-ui.webm');
+ assert.equal(errors.length,0);const marks=samples.filter((s,i)=>s&&s!==samples[i-1]);
+ assert(marks.filter(x=>x==='saved').length>=2);assert(marks.filter(x=>x==='reset').length>=2);
+ fs.writeFileSync(out+'loop-video.json',JSON.stringify({samples,marks,errors,targetDurationMs:8000,passed:true},null,2));
+}finally{await browser.close()}
diff --git a/artifacts/validation/stageshow/local-2026-11-09/npm-setup.log b/artifacts/validation/stageshow/local-2026-11-09/npm-setup.log
new file mode 100644
index 0000000..703c77c
--- /dev/null
+++ b/artifacts/validation/stageshow/local-2026-11-09/npm-setup.log
@@ -0,0 +1,2 @@
+
+added 75 packages in 2s
diff --git a/artifacts/validation/stageshow/local-2026-11-09/offline-exec.py b/artifacts/validation/stageshow/local-2026-11-09/offline-exec.py
new file mode 100644
index 0000000..51e7640
--- /dev/null
+++ b/artifacts/validation/stageshow/local-2026-11-09/offline-exec.py
@@ -0,0 +1,41 @@
+#!/usr/bin/env python3
+"""Inherited unprivileged seccomp: only UNIX/netlink sockets, never IP sockets."""
+import ctypes
+import errno
+import os
+import platform
+import sys
+
+if platform.machine() != 'aarch64':
+ raise SystemExit('offline filter supports only reviewed Linux aarch64')
+
+class Filter(ctypes.Structure):
+ _fields_ = [('code', ctypes.c_ushort), ('jt', ctypes.c_ubyte),
+ ('jf', ctypes.c_ubyte), ('k', ctypes.c_uint)]
+
+class Program(ctypes.Structure):
+ _fields_ = [('len', ctypes.c_ushort), ('filter', ctypes.POINTER(Filter))]
+
+# Audit architecture check; socket syscall=198; domain is argument zero.
+# Every newly created socket must be AF_UNIX or AF_NETLINK. No IP sockets are
+# inherited from this fresh SSH command. NNP prevents exec privilege gains.
+raw = [(0x20, 0, 0, 4), (0x15, 1, 0, 0xc00000b7),
+ (0x06, 0, 0, 0x80000000), (0x20, 0, 0, 0),
+ (0x15, 0, 1, 425), (0x06, 0, 0, 0x00050000 | errno.EPERM),
+ (0x15, 0, 5, 198), (0x20, 0, 0, 16),
+ (0x15, 3, 0, 1), (0x15, 2, 0, 16),
+ (0x06, 0, 0, 0x00050000 | errno.EPERM),
+ (0x06, 0, 0, 0x80000000), (0x06, 0, 0, 0x7fff0000)]
+filters = (Filter * len(raw))(*(Filter(*v) for v in raw))
+program = Program(len(raw), filters)
+libc = ctypes.CDLL(None, use_errno=True)
+# No inherited IP socket may bypass the socket-creation rule. SSH gives this
+# command only pipes for stdio; close every other inherited descriptor.
+for fd in range(3):
+ if os.readlink('/proc/self/fd/' + str(fd)).startswith('socket:'):
+ raise SystemExit('refusing inherited socket on stdio')
+os.closerange(3, 65536)
+for args in [(38, 1, 0, 0, 0), (22, 2, ctypes.byref(program), 0, 0)]:
+ if libc.prctl(*args):
+ raise OSError(ctypes.get_errno(), 'offline seccomp enforcement failed')
+os.execvp(sys.argv[1], sys.argv[1:])
diff --git a/artifacts/validation/stageshow/local-2026-11-09/outputs/animated.log b/artifacts/validation/stageshow/local-2026-11-09/outputs/animated.log
new file mode 100644
index 0000000..70135ef
--- /dev/null
+++ b/artifacts/validation/stageshow/local-2026-11-09/outputs/animated.log
@@ -0,0 +1,9 @@
+16 frames every 700ms -> /home/maroun.guest/validation/outputs/animated.png
+coverage stage-1: 674 frames, 11.63s, largest gap 170.3ms (inconclusive)
+ incomplete: 10 sampling gap(s) exceeded 27ms; motion during those gaps was not judged
+[stage-1] lopsided: span.fe-button "Create accountCreati" has 106px on the left and 112px on the right (8 frames from 5.6s)
+[stage-1] jump: ss-field__chars "m" width +8.0px in one frame at 1.04s [type], 12 times
+[stage-1] jump: ss-field__text width -22.0px in one frame at 3.80s [fix]
+[stage-1] appears with no entrance: svg.ss-field__ok shows at full size and opacity in one frame; fade it in, or grow it out of what it comes from (a chip budding off a value scales from it as it fades in: Fly with held), never pop it in whole
+film: inconclusive; watch the motion and inspect every image before approval
+rehearsal not passed: inconclusive
diff --git a/artifacts/validation/stageshow/local-2026-11-09/outputs/browser-acceptance.json b/artifacts/validation/stageshow/local-2026-11-09/outputs/browser-acceptance.json
new file mode 100644
index 0000000..194f021
--- /dev/null
+++ b/artifacts/validation/stageshow/local-2026-11-09/outputs/browser-acceptance.json
@@ -0,0 +1,110 @@
+{
+ "browser": "130.0.6723.31",
+ "sandbox": {
+ "renderers": [
+ {
+ "pid": "1323",
+ "command": [
+ "/home/maroun.guest/validation/browsers/chromium-1140/chrome-linux/chrome --type=renderer --headless=old --disable-dev-shm-usage --disable-back-forward-cache --disable-background-timer-throttling --disable-breakpad --enable-automation --force-color-profile=srgb --remote-debugging-pipe --allow-pre-commit-input --blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4 --ozone-platform=headless --disable-databases --lang=en-US --num-raster-threads=2 --enable-main-frame-before-activation --renderer-client-id=4 --time-ticks-at-unix-epoch=-1794251841367838 --launch-time-ticks=291130229 --shared-files",
+ ""
+ ],
+ "status": "Name:\tchrome\nUmask:\t0022\nState:\tS (sleeping)\nTgid:\t1323\nNgid:\t0\nPid:\t1323\nPPid:\t1289\nTracerPid:\t0\nUid:\t502\t502\t502\t502\nGid:\t1000\t1000\t1000\t1000\nFDSize:\t128\nGroups:\t1000 \nNStgid:\t1323\t4\t1\nNSpid:\t1323\t4\t1\nNSpgid:\t1283\t0\t0\nNSsid:\t1283\t0\t0\nVmPeak:\t1212526620 kB\nVmSize:\t1211691176 kB\nVmLck:\t 0 kB\nVmPin:\t 0 kB\nVmHWM:\t 95732 kB\nVmRSS:\t 95732 kB\nRssAnon:\t 17572 kB\nRssFile:\t 78160 kB\nRssShmem:\t 0 kB\nVmData:\t 341044 kB\nVmStk:\t 268 kB\nVmExe:\t 175088 kB\nVmLib:\t 30076 kB\nVmPTE:\t 668 kB\nVmSwap:\t 0 kB\nHugetlbPages:\t 0 kB\nCoreDumping:\t0\nTHP_enabled:\t1\nThreads:\t9\nSigQ:\t0/15389\nSigPnd:\t0000000000000000\nShdPnd:\t0000000000000000\nSigBlk:\t0000000000000000\nSigIgn:\t0000000000001002\nSigCgt:\t0000000140014eed\nCapInh:\t0000000000000000\nCapPrm:\t0000000000000000\nCapEff:\t0000000000000000\nCapBnd:\t000001ffffffffff\nCapAmb:\t0000000000000000\nNoNewPrivs:\t1\nSeccomp:\t2\nSeccomp_filters:\t2\nSpeculation_Store_Bypass:\tvulnerable\nSpeculationIndirectBranch:\tunknown\nCpus_allowed:\tf\nCpus_allowed_list:\t0-3\nMems_allowed:\t0001\nMems_allowed_list:\t0\nvoluntary_ctxt_switches:\t18\nnonvoluntary_ctxt_switches:\t25\n"
+ }
+ ],
+ "verified": "renderer PID namespace + additional Chromium seccomp filter + NNP, no sandbox-disabling flags"
+ },
+ "network": [
+ {
+ "host": "1.1.1.1",
+ "error": "EPERM"
+ },
+ {
+ "host": "::1",
+ "error": "EPERM"
+ }
+ ],
+ "pages": [
+ {
+ "at": 239,
+ "state": "app.northwind.co/signup\nCreate your account\nWork email\nWe'll send a link to confirm it\nAdd the domain, like name@company.com\nPassword\n••••••••••••••\nCreate account\nCreating account\nAccount created"
+ },
+ {
+ "at": 3005,
+ "state": "app.northwind.co/signup\nCreate your account\nWork email\nmaya@northwind\nWe'll send a link to confirm it\nAdd the domain, like name@company.com\nPassword\n••••••••••••••\nCreate account\nCreating account\nAccount created"
+ },
+ {
+ "at": 7009,
+ "state": "app.northwind.co/signup\nCreate your account\nWork email\nmaya@northwind.co\nWe'll send a link to confirm it\nAdd the domain, like name@company.com\nPassword\n••••••••••••••\nCreate account\nCreating account\nAccount created"
+ },
+ {
+ "at": 11006,
+ "state": "app.northwind.co/signup\nCreate your account\nWork email\nmaya@northwind.co\nWe'll send a link to confirm it\nAdd the domain, like name@company.com\nPassword\n••••••••••••••\nCreate account\nCreating account\nAccount created"
+ },
+ {
+ "reduced": true,
+ "width": 640,
+ "layout": "wide",
+ "state": "true",
+ "stable": true
+ },
+ {
+ "reduced": true,
+ "width": 390,
+ "layout": "compact",
+ "state": "true",
+ "stable": true
+ }
+ ],
+ "durationTargetMs": 12000,
+ "commandLine": [
+ "/home/maroun.guest/validation/browsers/chromium-1140/chrome-linux/chrome",
+ "--disable-field-trial-config",
+ "--disable-background-networking",
+ "--disable-background-timer-throttling",
+ "--disable-backgrounding-occluded-windows",
+ "--disable-back-forward-cache",
+ "--disable-breakpad",
+ "--disable-client-side-phishing-detection",
+ "--disable-component-extensions-with-background-pages",
+ "--disable-component-update",
+ "--no-default-browser-check",
+ "--disable-default-apps",
+ "--disable-dev-shm-usage",
+ "--disable-extensions",
+ "--disable-features=ImprovedCookieControls,LazyFrameLoading,GlobalMediaControls,DestroyProfileOnBrowserClose,MediaRouter,DialMediaRouteProvider,AcceptCHFrame,AutoExpandDetailsElement,CertificateTransparencyComponentUpdater,AvoidUnnecessaryBeforeUnloadCheckSync,Translate,HttpsUpgrades,PaintHolding,ThirdPartyStoragePartitioning,LensOverlay,PlzDedicatedWorker",
+ "--allow-pre-commit-input",
+ "--disable-hang-monitor",
+ "--disable-ipc-flooding-protection",
+ "--disable-popup-blocking",
+ "--disable-prompt-on-repost",
+ "--disable-renderer-backgrounding",
+ "--force-color-profile=srgb",
+ "--metrics-recording-only",
+ "--no-first-run",
+ "--enable-automation",
+ "--password-store=basic",
+ "--use-mock-keychain",
+ "--no-service-autorun",
+ "--export-tagged-pdf",
+ "--disable-search-engine-choice-screen",
+ "--unsafely-disable-devtools-self-xss-warnings",
+ "--headless=old",
+ "--hide-scrollbars",
+ "--mute-audio",
+ "--blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4",
+ "--user-data-dir=/tmp/playwright_chromiumdev_profile-VHJKHY",
+ "--remote-debugging-pipe",
+ "--no-startup-window",
+ "--use-gl=angle",
+ "--use-angle=swiftshader-webgl",
+ "--ozone-platform=headless",
+ "--allow-pre-commit-input"
+ ],
+ "recordedWallMs": 12018,
+ "resources": [
+ "file:///home/maroun.guest/validation/runtime/acceptance.html"
+ ],
+ "pageErrors": [],
+ "passed": true,
+ "browserClosed": true
+}
diff --git a/artifacts/validation/stageshow/local-2026-11-09/outputs/caption-audit.json b/artifacts/validation/stageshow/local-2026-11-09/outputs/caption-audit.json
new file mode 100644
index 0000000..38e86c0
--- /dev/null
+++ b/artifacts/validation/stageshow/local-2026-11-09/outputs/caption-audit.json
@@ -0,0 +1,11 @@
+{
+ "payloads": [
+ "open",
+ " & 'single' \"double\"",
+ " & 'single' \"double\" +0.7s",
+ "end 2.20s"
+ ],
+ "injected": 0,
+ "executed": 0,
+ "escaped": true
+}
\ No newline at end of file
diff --git a/artifacts/validation/stageshow/local-2026-11-09/outputs/compact.log b/artifacts/validation/stageshow/local-2026-11-09/outputs/compact.log
new file mode 100644
index 0000000..a9a7844
--- /dev/null
+++ b/artifacts/validation/stageshow/local-2026-11-09/outputs/compact.log
@@ -0,0 +1,7 @@
+still -> /home/maroun.guest/validation/outputs/compact.png
+coverage stage-1: 1 frames, 0.00s, largest gap 0.0ms (complete)
+[stage-1] lopsided: span.fe-button "Create accountCreati" has 67px on the left and 73px on the right (still)
+[stage-1] framing: div.ss-window ends 16px short of the right edge; give it a full gutter (28px) or run it well past the edge so it is clearly masked (still)
+[stage-1] framing: div.ss-window shows 326x392, narrower than tall for a desktop window; change the crop (bleed it) or the layout rather than squash the app (still)
+film: issues; still capture only, motion was not judged
+rehearsal failed: 2 never-intentional line(s) above
diff --git a/artifacts/validation/stageshow/local-2026-11-09/outputs/decoded-final.png b/artifacts/validation/stageshow/local-2026-11-09/outputs/decoded-final.png
new file mode 100644
index 0000000..499b7c1
Binary files /dev/null and b/artifacts/validation/stageshow/local-2026-11-09/outputs/decoded-final.png differ
diff --git a/artifacts/validation/stageshow/local-2026-11-09/outputs/decoded-loop.png b/artifacts/validation/stageshow/local-2026-11-09/outputs/decoded-loop.png
new file mode 100644
index 0000000..87b496b
Binary files /dev/null and b/artifacts/validation/stageshow/local-2026-11-09/outputs/decoded-loop.png differ
diff --git a/artifacts/validation/stageshow/local-2026-11-09/outputs/decoded-storyboard.png b/artifacts/validation/stageshow/local-2026-11-09/outputs/decoded-storyboard.png
new file mode 100644
index 0000000..8096c18
Binary files /dev/null and b/artifacts/validation/stageshow/local-2026-11-09/outputs/decoded-storyboard.png differ
diff --git a/artifacts/validation/stageshow/local-2026-11-09/outputs/failure.json b/artifacts/validation/stageshow/local-2026-11-09/outputs/failure.json
new file mode 100644
index 0000000..ebf5938
--- /dev/null
+++ b/artifacts/validation/stageshow/local-2026-11-09/outputs/failure.json
@@ -0,0 +1,35 @@
+{
+ "status": "issues",
+ "mode": "motion",
+ "stages": [
+ {
+ "key": "stage-1",
+ "label": "ss-fit",
+ "coverage": {
+ "status": "complete",
+ "frames": 181,
+ "durationMs": 2999.29999999993,
+ "medianMs": 16.599999999976717,
+ "maxGapMs": 21.20000000006985,
+ "skippedGaps": 0,
+ "cameraFrames": 0,
+ "reasons": [],
+ "framingSamples": 4,
+ "layout": {
+ "controls": 0,
+ "paddedContainers": 0,
+ "compositionCrops": 0
+ }
+ },
+ "findings": [
+ "unclipped: span runs 120px past the stage's right edge and nothing clips it; give the stage overflow: hidden so the crop is yours (4 frames from open)",
+ "framing: target span \"bad\" is cut by the crop; what the story touches stays whole in frame (4 frames from open)",
+ "framing: real words span \"Clipped action\" are cut by the crop; only filler bleeds, so pull them inside or make them a placeholder (4 frames from open)"
+ ],
+ "manual": []
+ }
+ ],
+ "errors": [],
+ "manualReviewRequired": true,
+ "exitCode": 1
+}
diff --git a/artifacts/validation/stageshow/local-2026-11-09/outputs/film-results.json b/artifacts/validation/stageshow/local-2026-11-09/outputs/film-results.json
new file mode 100644
index 0000000..1ffb04b
--- /dev/null
+++ b/artifacts/validation/stageshow/local-2026-11-09/outputs/film-results.json
@@ -0,0 +1,22 @@
+[
+ {
+ "case": "representative-still",
+ "exitCode": 0,
+ "expectedFailure": false
+ },
+ {
+ "case": "representative-compact",
+ "exitCode": 0,
+ "expectedFailure": false
+ },
+ {
+ "case": "representative-loop",
+ "exitCode": 2,
+ "expectedFailure": false
+ },
+ {
+ "case": "failure",
+ "exitCode": 1,
+ "expectedFailure": true
+ }
+]
diff --git a/artifacts/validation/stageshow/local-2026-11-09/outputs/loop-probe-final.json b/artifacts/validation/stageshow/local-2026-11-09/outputs/loop-probe-final.json
new file mode 100644
index 0000000..372d373
--- /dev/null
+++ b/artifacts/validation/stageshow/local-2026-11-09/outputs/loop-probe-final.json
@@ -0,0 +1,20 @@
+{
+ "programs": [
+
+ ],
+ "stream_groups": [
+
+ ],
+ "streams": [
+ {
+ "codec_name": "h264",
+ "width": 640,
+ "height": 360,
+ "r_frame_rate": "25/1",
+ "nb_read_frames": "200"
+ }
+ ],
+ "format": {
+ "duration": "8.000000"
+ }
+}
diff --git a/artifacts/validation/stageshow/local-2026-11-09/outputs/loop-ui.mp4 b/artifacts/validation/stageshow/local-2026-11-09/outputs/loop-ui.mp4
new file mode 100644
index 0000000..14b4d0a
Binary files /dev/null and b/artifacts/validation/stageshow/local-2026-11-09/outputs/loop-ui.mp4 differ
diff --git a/artifacts/validation/stageshow/local-2026-11-09/outputs/loop-video.json b/artifacts/validation/stageshow/local-2026-11-09/outputs/loop-video.json
new file mode 100644
index 0000000..8afd1b2
--- /dev/null
+++ b/artifacts/validation/stageshow/local-2026-11-09/outputs/loop-video.json
@@ -0,0 +1,27 @@
+{
+ "samples": [
+ null,
+ null,
+ "saved",
+ "saved",
+ "saved",
+ "reset",
+ "reset",
+ "saved",
+ "saved",
+ "saved",
+ "reset",
+ "reset",
+ "saved"
+ ],
+ "marks": [
+ "saved",
+ "reset",
+ "saved",
+ "reset",
+ "saved"
+ ],
+ "errors": [],
+ "targetDurationMs": 8000,
+ "passed": true
+}
\ No newline at end of file
diff --git a/artifacts/validation/stageshow/local-2026-11-09/outputs/loop.log b/artifacts/validation/stageshow/local-2026-11-09/outputs/loop.log
new file mode 100644
index 0000000..e24656c
--- /dev/null
+++ b/artifacts/validation/stageshow/local-2026-11-09/outputs/loop.log
@@ -0,0 +1,7 @@
+24 frames every 600ms -> /home/maroun.guest/validation/outputs/loop.png
+coverage stage-1: 869 frames, 14.65s, largest gap 54.6ms (inconclusive)
+ incomplete: 33 sampling gap(s) exceeded 27ms; motion during those gaps was not judged
+[stage-1] framing: div.ss-window ends 16px short of the right edge; give it a full gutter (40px) or run it well past the edge so it is clearly masked (24 frames from 0.0s)
+[stage-1] framing: div.ss-window shows 326x360, narrower than tall for a desktop window; change the crop (bleed it) or the layout rather than squash the app (24 frames from 0.0s)
+film: inconclusive; watch the motion and inspect every image before approval
+rehearsal failed: 2 never-intentional line(s) above
diff --git a/artifacts/validation/stageshow/local-2026-11-09/outputs/representative-compact-initial.json b/artifacts/validation/stageshow/local-2026-11-09/outputs/representative-compact-initial.json
new file mode 100644
index 0000000..a372c38
--- /dev/null
+++ b/artifacts/validation/stageshow/local-2026-11-09/outputs/representative-compact-initial.json
@@ -0,0 +1,30 @@
+{
+ "status": "issues",
+ "mode": "still",
+ "stages": [
+ {
+ "key": "stage-1",
+ "label": "ss-fit validation-scene",
+ "coverage": {
+ "status": "complete",
+ "frames": 1,
+ "durationMs": 0,
+ "maxGapMs": 0,
+ "reasons": [],
+ "framingSamples": 1,
+ "layout": {
+ "controls": 1,
+ "paddedContainers": 1,
+ "compositionCrops": 0
+ }
+ },
+ "findings": [
+ "unclipped: div.validation-card runs 44px past the stage's bottom edge and nothing clips it; give the stage overflow: hidden so the crop is yours (still)"
+ ],
+ "manual": []
+ }
+ ],
+ "errors": [],
+ "manualReviewRequired": false,
+ "exitCode": 0
+}
diff --git a/artifacts/validation/stageshow/local-2026-11-09/outputs/representative-compact.json b/artifacts/validation/stageshow/local-2026-11-09/outputs/representative-compact.json
new file mode 100644
index 0000000..e90d69e
--- /dev/null
+++ b/artifacts/validation/stageshow/local-2026-11-09/outputs/representative-compact.json
@@ -0,0 +1,28 @@
+{
+ "status": "no-issues-detected",
+ "mode": "still",
+ "stages": [
+ {
+ "key": "stage-1",
+ "label": "ss-fit validation-scene",
+ "coverage": {
+ "status": "complete",
+ "frames": 1,
+ "durationMs": 0,
+ "maxGapMs": 0,
+ "reasons": [],
+ "framingSamples": 1,
+ "layout": {
+ "controls": 1,
+ "paddedContainers": 1,
+ "compositionCrops": 0
+ }
+ },
+ "findings": [],
+ "manual": []
+ }
+ ],
+ "errors": [],
+ "manualReviewRequired": false,
+ "exitCode": 0
+}
diff --git a/artifacts/validation/stageshow/local-2026-11-09/outputs/representative-compact.png b/artifacts/validation/stageshow/local-2026-11-09/outputs/representative-compact.png
new file mode 100644
index 0000000..e7400f8
Binary files /dev/null and b/artifacts/validation/stageshow/local-2026-11-09/outputs/representative-compact.png differ
diff --git a/artifacts/validation/stageshow/local-2026-11-09/outputs/representative-loop-final.json b/artifacts/validation/stageshow/local-2026-11-09/outputs/representative-loop-final.json
new file mode 100644
index 0000000..f69044f
--- /dev/null
+++ b/artifacts/validation/stageshow/local-2026-11-09/outputs/representative-loop-final.json
@@ -0,0 +1,31 @@
+{
+ "status": "no-issues-detected",
+ "mode": "motion",
+ "stages": [
+ {
+ "key": "stage-1",
+ "label": "ss-fit validation-scene",
+ "coverage": {
+ "status": "complete",
+ "frames": 468,
+ "durationMs": 7779,
+ "medianMs": 16.699999999953434,
+ "maxGapMs": 23.400000000023283,
+ "skippedGaps": 0,
+ "cameraFrames": 0,
+ "reasons": [],
+ "framingSamples": 8,
+ "layout": {
+ "controls": 1,
+ "paddedContainers": 1,
+ "compositionCrops": 0
+ }
+ },
+ "findings": [],
+ "manual": []
+ }
+ ],
+ "errors": [],
+ "manualReviewRequired": true,
+ "exitCode": 0
+}
diff --git a/artifacts/validation/stageshow/local-2026-11-09/outputs/representative-loop-final.png b/artifacts/validation/stageshow/local-2026-11-09/outputs/representative-loop-final.png
new file mode 100644
index 0000000..57cca89
Binary files /dev/null and b/artifacts/validation/stageshow/local-2026-11-09/outputs/representative-loop-final.png differ
diff --git a/artifacts/validation/stageshow/local-2026-11-09/outputs/representative-still-initial.json b/artifacts/validation/stageshow/local-2026-11-09/outputs/representative-still-initial.json
new file mode 100644
index 0000000..d3c7681
--- /dev/null
+++ b/artifacts/validation/stageshow/local-2026-11-09/outputs/representative-still-initial.json
@@ -0,0 +1,30 @@
+{
+ "status": "issues",
+ "mode": "still",
+ "stages": [
+ {
+ "key": "stage-1",
+ "label": "ss-fit validation-scene",
+ "coverage": {
+ "status": "complete",
+ "frames": 1,
+ "durationMs": 0,
+ "maxGapMs": 0,
+ "reasons": [],
+ "framingSamples": 1,
+ "layout": {
+ "controls": 1,
+ "paddedContainers": 1,
+ "compositionCrops": 0
+ }
+ },
+ "findings": [
+ "unclipped: div.ss-window runs 68px past the stage's right edge and nothing clips it; give the stage overflow: hidden so the crop is yours (still)"
+ ],
+ "manual": []
+ }
+ ],
+ "errors": [],
+ "manualReviewRequired": false,
+ "exitCode": 0
+}
diff --git a/artifacts/validation/stageshow/local-2026-11-09/outputs/representative-still.json b/artifacts/validation/stageshow/local-2026-11-09/outputs/representative-still.json
new file mode 100644
index 0000000..e90d69e
--- /dev/null
+++ b/artifacts/validation/stageshow/local-2026-11-09/outputs/representative-still.json
@@ -0,0 +1,28 @@
+{
+ "status": "no-issues-detected",
+ "mode": "still",
+ "stages": [
+ {
+ "key": "stage-1",
+ "label": "ss-fit validation-scene",
+ "coverage": {
+ "status": "complete",
+ "frames": 1,
+ "durationMs": 0,
+ "maxGapMs": 0,
+ "reasons": [],
+ "framingSamples": 1,
+ "layout": {
+ "controls": 1,
+ "paddedContainers": 1,
+ "compositionCrops": 0
+ }
+ },
+ "findings": [],
+ "manual": []
+ }
+ ],
+ "errors": [],
+ "manualReviewRequired": false,
+ "exitCode": 0
+}
diff --git a/artifacts/validation/stageshow/local-2026-11-09/outputs/representative-still.png b/artifacts/validation/stageshow/local-2026-11-09/outputs/representative-still.png
new file mode 100644
index 0000000..abba5b9
Binary files /dev/null and b/artifacts/validation/stageshow/local-2026-11-09/outputs/representative-still.png differ
diff --git a/artifacts/validation/stageshow/local-2026-11-09/outputs/sample-ui.mp4 b/artifacts/validation/stageshow/local-2026-11-09/outputs/sample-ui.mp4
new file mode 100644
index 0000000..f7d16fe
Binary files /dev/null and b/artifacts/validation/stageshow/local-2026-11-09/outputs/sample-ui.mp4 differ
diff --git a/artifacts/validation/stageshow/local-2026-11-09/outputs/video-probe-final.json b/artifacts/validation/stageshow/local-2026-11-09/outputs/video-probe-final.json
new file mode 100644
index 0000000..a302419
--- /dev/null
+++ b/artifacts/validation/stageshow/local-2026-11-09/outputs/video-probe-final.json
@@ -0,0 +1,20 @@
+{
+ "programs": [
+
+ ],
+ "stream_groups": [
+
+ ],
+ "streams": [
+ {
+ "codec_name": "h264",
+ "width": 640,
+ "height": 480,
+ "r_frame_rate": "25/1",
+ "nb_read_frames": "300"
+ }
+ ],
+ "format": {
+ "duration": "12.000000"
+ }
+}
diff --git a/artifacts/validation/stageshow/local-2026-11-09/publication-redactions.json b/artifacts/validation/stageshow/local-2026-11-09/publication-redactions.json
new file mode 100644
index 0000000..4640478
--- /dev/null
+++ b/artifacts/validation/stageshow/local-2026-11-09/publication-redactions.json
@@ -0,0 +1,20 @@
+[
+ {
+ "path": "artifacts/validation/stageshow/local-2026-11-09/vm-final.json",
+ "original_sha256": "8fe9cb0b5a9f65ac1cf7ce55b04ba1920fc7ea94f3253628b20f8969d99b7dc4",
+ "published_sha256": "24a1e4627b52300169ca453dcc52a3f4af642122e60899f9785384470793d854",
+ "redaction": "host home path replaced by literal $HOME; raw local evidence retained in original local-only commit"
+ },
+ {
+ "path": "artifacts/validation/stageshow/local-2026-11-09/vm-start.log",
+ "original_sha256": "635ae64e0af5a36068995bc340ba823c176f9c30617bd851910a01ef1021b3d8",
+ "published_sha256": "50877a796aff049d2323b2e451bc4302fe8797b28b067dfd7186057e1bad0c2c",
+ "redaction": "host home path replaced by literal $HOME; raw local evidence retained in original local-only commit"
+ },
+ {
+ "path": "artifacts/validation/stageshow/local-2026-11-09/vm-restart.log",
+ "original_sha256": "0f754d89c3fa1eae5597454cc04d8df6c31dc87f357c785a3b4546bc345ec6f7",
+ "published_sha256": "e0234777cc5e45e64370d2142404216db4d100a57b7acf8812ccf4b319304920",
+ "redaction": "host home path replaced by literal $HOME; raw local evidence retained in original local-only commit"
+ }
+]
diff --git a/artifacts/validation/stageshow/local-2026-11-09/run-films.py b/artifacts/validation/stageshow/local-2026-11-09/run-films.py
new file mode 100644
index 0000000..038047d
--- /dev/null
+++ b/artifacts/validation/stageshow/local-2026-11-09/run-films.py
@@ -0,0 +1,39 @@
+"""Run exact retained film helpers, including deliberate gate and caption probes."""
+import json
+import os
+from pathlib import Path
+import subprocess
+
+base = Path(__file__).resolve().parent
+runtime = base / 'runtime'
+out = base / 'outputs'
+out.mkdir(exist_ok=True)
+url = (runtime / 'acceptance.html').as_uri()
+cases = [
+ ('representative-still', '?mode=scene', ['--still', '--gate']),
+ ('representative-compact', '?mode=scene', ['--still', '--reduced', '--width', '390', '--gate']),
+ ('representative-loop', '?mode=scene', ['--every', '1000', '--count', '8', '--gate']),
+ ('failure', '?mode=failure', ['--states', '--states-for', '2200', '--every', '300', '--gate']),
+]
+results = []
+for name, query, args in cases:
+ env = dict(os.environ)
+ if name == 'failure':
+ env['NODE_OPTIONS'] = '--import=' + str(runtime / 'audit-hooks.mjs')
+ env['CAPTION_AUDIT'] = str(out / 'caption-audit.json')
+ command = ['node', 'scripts/film.mjs', url + query, '--out', str(out / (name + '.png')),
+ '--report', str(out / (name + '.json')), *args]
+ result = subprocess.run(command, cwd=runtime, env=env, text=True, capture_output=True, timeout=90)
+ (out / (name + '.log')).write_text(result.stdout + result.stderr)
+ results.append({'case': name, 'exitCode': result.returncode, 'expectedFailure': name == 'failure'})
+ print(json.dumps(results[-1]), flush=True)
+(out / 'film-results.json').write_text(json.dumps(results, indent=2) + '\n')
+caption = json.loads((out / 'caption-audit.json').read_text())
+assert caption['escaped'] and caption['injected'] == 0 and caption['executed'] == 0
+assert any(' /home/maroun.guest/validation/outputs/representative-still.png
+coverage stage-1: 1 frames, 0.00s, largest gap 0.0ms (complete)
+film: no-issues-detected; still capture only, motion was not judged
+rehearsal passed (necessary, not sufficient: references/verify.md and the fresh eye in review.md still stand)
diff --git a/artifacts/validation/stageshow/local-2026-11-09/typecheck-offline.log b/artifacts/validation/stageshow/local-2026-11-09/typecheck-offline.log
new file mode 100644
index 0000000..1f90159
--- /dev/null
+++ b/artifacts/validation/stageshow/local-2026-11-09/typecheck-offline.log
@@ -0,0 +1,3 @@
+
+> stageshow@0.1.0 typecheck
+> tsc --noEmit -p playground
diff --git a/artifacts/validation/stageshow/local-2026-11-09/typecheck.log b/artifacts/validation/stageshow/local-2026-11-09/typecheck.log
new file mode 100644
index 0000000..1f90159
--- /dev/null
+++ b/artifacts/validation/stageshow/local-2026-11-09/typecheck.log
@@ -0,0 +1,3 @@
+
+> stageshow@0.1.0 typecheck
+> tsc --noEmit -p playground
diff --git a/artifacts/validation/stageshow/local-2026-11-09/vm-final.json b/artifacts/validation/stageshow/local-2026-11-09/vm-final.json
new file mode 100644
index 0000000..477bb9f
--- /dev/null
+++ b/artifacts/validation/stageshow/local-2026-11-09/vm-final.json
@@ -0,0 +1 @@
+{"name":"stack-stageshow-20261109","hostname":"lima-stack-stageshow-20261109","status":"Stopped","dir":"$HOME/.lima/stack-stageshow-20261109","vmType":"vz","arch":"aarch64","cpus":4,"memory":4294967296,"disk":12884901888,"sshLocalPort":51575,"sshConfigFile":"$HOME/.lima/stack-stageshow-20261109/ssh.config","config":{"vmType":"vz","os":"Linux","arch":"aarch64","images":[{"location":"https://cloud.debian.org/images/cloud/bookworm/20260712-2537/debian-12-genericcloud-arm64-20260712-2537.qcow2","arch":"aarch64","digest":"sha512:52eb678130e85a9bf9f3cf4f7181e060fa60a5db45373717474c5ff9305645c5e6cab8b05ca042bcfa7921d2ca29cf9fd2e815f75abe2d168557ad7207af127b"}],"cpus":4,"memory":"4GiB","disk":"12GiB","mountInotify":false,"ssh":{"localPort":0,"loadDotSSHPubKeys":false,"forwardAgent":false,"forwardX11":false,"forwardX11Trusted":false},"firmware":{"legacyBIOS":false},"audio":{"device":"","interface":""},"video":{"display":"none"},"upgradePackages":false,"containerd":{"system":false,"user":false,"archives":[{"location":"https://github.com/containerd/nerdctl/releases/download/v2.3.5/nerdctl-full-2.3.5-linux-amd64.tar.gz","arch":"x86_64","digest":"sha256:b697295c623639734aaab737523c808fd3cc8d3046039fd94fff1744e4c317aa"},{"location":"https://github.com/containerd/nerdctl/releases/download/v2.3.5/nerdctl-full-2.3.5-linux-arm64.tar.gz","arch":"aarch64","digest":"sha256:6e4b687f1d138e750a3c8372abc0f81d3d7490b6359c48c0562fc7dfe98859b2"}]},"guestInstallPrefix":"/usr/local","hostResolver":{"enabled":false,"ipv6":false},"propagateProxyEnv":false,"caCerts":{"removeDefaults":false},"plain":true,"timezone":"","nestedVirtualization":false,"user":{"name":"maroun","comment":"Maroun","home":"/home/maroun.guest","shell":"/bin/bash","uid":502,"passwordlessSudo":true},"tpm":false},"sshAddress":"127.0.0.1","protected":false,"limaVersion":"2.2.0","HostOS":"darwin","HostArch":"aarch64","LimaHome":"$HOME/.lima","IdentityFile":"$HOME/.lima/_config/user"}
diff --git a/artifacts/validation/stageshow/local-2026-11-09/vm-restart.log b/artifacts/validation/stageshow/local-2026-11-09/vm-restart.log
new file mode 100644
index 0000000..5ae59dd
--- /dev/null
+++ b/artifacts/validation/stageshow/local-2026-11-09/vm-restart.log
@@ -0,0 +1,15 @@
+time="2026-11-09T11:30:00-08:00" level=info msg="Using the existing instance `stack-stageshow-20261109`"
+time="2026-11-09T11:30:00-08:00" level=info msg="Starting the instance `stack-stageshow-20261109` with internal VM driver `vz`"
+time="2026-11-09T11:30:01-08:00" level=info msg="[hostagent] hostagent socket created at $HOME/.lima/stack-stageshow-20261109/ha.sock"
+time="2026-11-09T11:30:01-08:00" level=info msg="[hostagent] Starting VZ (hint: to watch the boot progress, see `$HOME/.lima/stack-stageshow-20261109/serial*.log`)"
+time="2026-11-09T11:30:01-08:00" level=info msg="[hostagent] [VZ] - vm state change: running"
+time="2026-11-09T11:30:06-08:00" level=info msg="[hostagent] SSH server does not seem to be running on vsock port, using usernet forwarder"
+time="2026-11-09T11:30:07-08:00" level=info msg="SSH Local Port: 49747"
+time="2026-11-09T11:30:07-08:00" level=info msg="[hostagent] Running in plain mode. Mounts, dynamic port forwarding, containerd, etc. will be ignored. Guest agent will not be running."
+time="2026-11-09T11:30:07-08:00" level=info msg="[hostagent] Waiting for the essential requirement 1 of 2: `ssh`"
+time="2026-11-09T11:30:07-08:00" level=info msg="[hostagent] The essential requirement 1 of 2 is satisfied"
+time="2026-11-09T11:30:07-08:00" level=info msg="[hostagent] Waiting for the essential requirement 2 of 2: `Explicitly start ssh ControlMaster`"
+time="2026-11-09T11:30:07-08:00" level=info msg="[hostagent] The essential requirement 2 of 2 is satisfied"
+time="2026-11-09T11:30:07-08:00" level=info msg="[hostagent] Waiting for the final requirement 1 of 1: `boot scripts must have finished`"
+time="2026-11-09T11:30:07-08:00" level=info msg="[hostagent] The final requirement 1 of 1 is satisfied"
+time="2026-11-09T11:30:07-08:00" level=info msg="READY. Run `ssh -F `$HOME/.lima/stack-stageshow-20261109/ssh.config` lima-stack-stageshow-20261109` to open the shell."
diff --git a/artifacts/validation/stageshow/local-2026-11-09/vm-start.log b/artifacts/validation/stageshow/local-2026-11-09/vm-start.log
new file mode 100644
index 0000000..066a860
--- /dev/null
+++ b/artifacts/validation/stageshow/local-2026-11-09/vm-start.log
@@ -0,0 +1,15 @@
+time="2026-11-09T11:17:19-08:00" level=info msg="Using the existing instance `stack-stageshow-20261109`"
+time="2026-11-09T11:17:19-08:00" level=info msg="Starting the instance `stack-stageshow-20261109` with internal VM driver `vz`"
+time="2026-11-09T11:17:19-08:00" level=info msg="[hostagent] hostagent socket created at $HOME/.lima/stack-stageshow-20261109/ha.sock"
+time="2026-11-09T11:17:19-08:00" level=info msg="[hostagent] Starting VZ (hint: to watch the boot progress, see `$HOME/.lima/stack-stageshow-20261109/serial*.log`)"
+time="2026-11-09T11:17:20-08:00" level=info msg="[hostagent] [VZ] - vm state change: running"
+time="2026-11-09T11:17:26-08:00" level=info msg="[hostagent] SSH server does not seem to be running on vsock port, using usernet forwarder"
+time="2026-11-09T11:17:27-08:00" level=info msg="SSH Local Port: 59973"
+time="2026-11-09T11:17:27-08:00" level=info msg="[hostagent] Running in plain mode. Mounts, dynamic port forwarding, containerd, etc. will be ignored. Guest agent will not be running."
+time="2026-11-09T11:17:27-08:00" level=info msg="[hostagent] Waiting for the essential requirement 1 of 2: `ssh`"
+time="2026-11-09T11:17:27-08:00" level=info msg="[hostagent] The essential requirement 1 of 2 is satisfied"
+time="2026-11-09T11:17:27-08:00" level=info msg="[hostagent] Waiting for the essential requirement 2 of 2: `Explicitly start ssh ControlMaster`"
+time="2026-11-09T11:17:27-08:00" level=info msg="[hostagent] The essential requirement 2 of 2 is satisfied"
+time="2026-11-09T11:17:27-08:00" level=info msg="[hostagent] Waiting for the final requirement 1 of 1: `boot scripts must have finished`"
+time="2026-11-09T11:17:27-08:00" level=info msg="[hostagent] The final requirement 1 of 1 is satisfied"
+time="2026-11-09T11:17:27-08:00" level=info msg="READY. Run `ssh -F `$HOME/.lima/stack-stageshow-20261109/ssh.config` lima-stack-stageshow-20261109` to open the shell."
diff --git a/artifacts/validation/stageshow/local-2026-11-09/vm-stop.log b/artifacts/validation/stageshow/local-2026-11-09/vm-stop.log
new file mode 100644
index 0000000..4939fa3
--- /dev/null
+++ b/artifacts/validation/stageshow/local-2026-11-09/vm-stop.log
@@ -0,0 +1,9 @@
+time="2026-11-09T11:30:21-08:00" level=info msg="Sending SIGINT to hostagent process 52513"
+time="2026-11-09T11:30:21-08:00" level=info msg="Waiting for the host agent and the driver processes to shut down"
+time="2026-11-09T11:30:21-08:00" level=info msg="[hostagent] Received SIGINT, shutting down the host agent"
+time="2026-11-09T11:30:21-08:00" level=info msg="[hostagent] Shutting down the host agent"
+time="2026-11-09T11:30:21-08:00" level=info msg="[hostagent] Shutting down VZ"
+time="2026-11-09T11:30:24-08:00" level=info msg="[hostagent] [VZ] - vm state change: stopped"
+time="2026-11-09T11:30:24-08:00" level=error msg="[hostagent] accept tcp 127.0.0.1:49747: use of closed network connection"
+time="2026-11-09T11:30:24-08:00" level=info msg="Waiting for the instance to shut down"
+time="2026-11-09T11:30:25-08:00" level=info msg="The instance stack-stageshow-20261109 has shut down"
diff --git a/artifacts/validation/stageshow/local-2026-11-09/vm.yaml b/artifacts/validation/stageshow/local-2026-11-09/vm.yaml
new file mode 100644
index 0000000..c606531
--- /dev/null
+++ b/artifacts/validation/stageshow/local-2026-11-09/vm.yaml
@@ -0,0 +1,22 @@
+vmType: vz
+arch: aarch64
+cpus: 4
+memory: 4GiB
+disk: 12GiB
+images:
+- location: https://cloud.debian.org/images/cloud/bookworm/20260712-2537/debian-12-genericcloud-arm64-20260712-2537.qcow2
+ arch: aarch64
+ digest: sha512:52eb678130e85a9bf9f3cf4f7181e060fa60a5db45373717474c5ff9305645c5e6cab8b05ca042bcfa7921d2ca29cf9fd2e815f75abe2d168557ad7207af127b
+plain: true
+mounts: []
+containerd:
+ system: false
+ user: false
+ssh:
+ loadDotSSHPubKeys: false
+ forwardAgent: false
+ forwardX11: false
+propagateProxyEnv: false
+hostResolver:
+ enabled: false
+portForwards: []
diff --git a/config/runtime-targets.json b/config/runtime-targets.json
index 8126a7a..6bdeeba 100644
--- a/config/runtime-targets.json
+++ b/config/runtime-targets.json
@@ -1,6 +1,6 @@
{
"schema_version": 1,
- "catalog_digest": "aa9bf5a311f1ada9ee219cc5d40c93b1928808a04d40ed97e0d3654a9fe1762a",
+ "catalog_digest": "f5aea9cb9218e379ee2b5c03a9b0967dd7701c840e5a5cdd48bddfe7c6ec9a8a",
"targets": [
{
"name": "claude",
diff --git a/registry/capabilities.json b/registry/capabilities.json
index 8d106c0..009a997 100644
--- a/registry/capabilities.json
+++ b/registry/capabilities.json
@@ -9234,6 +9234,77 @@
"validation_class": "high-risk",
"visibility": "extended"
},
+ {
+ "artifact_type": "skill",
+ "audit_status": "reviewed",
+ "canonical_name": "stageshow",
+ "commands": [
+ "stack.design"
+ ],
+ "context": {
+ "inputs": [
+ "request",
+ "project-design-contract",
+ "permitted-content"
+ ],
+ "outputs": [
+ "product-illustration-source",
+ "verification-limitations"
+ ]
+ },
+ "disposition": {
+ "evidence_gap": null,
+ "next_review_trigger": "Revalidate on helper, dependency/browser, safety contract or runtime export changes; rehearse each project copy.",
+ "status": "keep"
+ },
+ "domain": "design",
+ "family": "design",
+ "lifecycle": "active",
+ "overlaps": [
+ "emil-design-eng",
+ "review-animations",
+ "design-intelligence"
+ ],
+ "ownership": {
+ "package": "imported-skills",
+ "provider": "benjitaylor",
+ "source_path": "skills/imported/benjitaylor/stageshow/SKILL.md"
+ },
+ "provenance": {
+ "license": "MIT; retained artwork has separate MIT/Apache-2.0/CC0 notices; excluded artwork and trademark rights are not covered",
+ "posture": "imported-upstream",
+ "source_identity": "github:benjitaylor/skills:stageshow@802057409787133703c56001080169e347305572"
+ },
+ "purpose": "Create authored still or animated product UI illustrations and cursor stories with the pinned Stageshow React kit and permitted references.",
+ "role": "workflow",
+ "runtimes": {
+ "publish_targets": [
+ "claude",
+ "codex"
+ ],
+ "supported": [
+ "claude",
+ "codex"
+ ]
+ },
+ "schema_version": 1,
+ "source": {
+ "skill_path": "skills/imported/benjitaylor/stageshow/SKILL.md"
+ },
+ "trust_class": "local-mutation",
+ "validation": {
+ "evidence": [
+ "skills/imported/benjitaylor/stageshow/STACK-REVIEW.md",
+ "skills/imported/benjitaylor/stageshow/source.json",
+ "tests/test_stageshow_intake.py",
+ "tests/test_stageshow_browser_launcher.py",
+ "artifacts/validation/stageshow/2026-11-09-accepted.json"
+ ],
+ "status": "validated"
+ },
+ "validation_class": "representative-route",
+ "visibility": "extended"
+ },
{
"alias_of": "cdo-taste-skill",
"artifact_type": "skill",
@@ -9996,7 +10067,7 @@
"catalog_kind": "stack-capability-catalog",
"schema_version": 1,
"summary": {
- "callable_entrypoint_count": 158,
- "capability_count": 158
+ "callable_entrypoint_count": 159,
+ "capability_count": 159
}
}
diff --git a/registry/migrations/2026-07-23-skill-layout.json b/registry/migrations/2026-07-23-skill-layout.json
index 0702174..f1c99eb 100644
--- a/registry/migrations/2026-07-23-skill-layout.json
+++ b/registry/migrations/2026-07-23-skill-layout.json
@@ -731,6 +731,11 @@
"destination": "skills/delivery/stack-refresh-pr-mode/SKILL.md",
"source": "skills/stack-refresh-pr-mode/SKILL.md"
},
+ {
+ "canonical_name": "stageshow",
+ "destination": "skills/imported/benjitaylor/stageshow/SKILL.md",
+ "source": "skills/imported/benjitaylor/stageshow/SKILL.md"
+ },
{
"canonical_name": "taste-skill-suite-taste-skill",
"destination": "skills/imported/taste-skill-suite/taste-skill/SKILL.md",
diff --git a/registry/routing-rules.json b/registry/routing-rules.json
index df2c9f3..b5b16ff 100644
--- a/registry/routing-rules.json
+++ b/registry/routing-rules.json
@@ -6,7 +6,7 @@
"rules": [
{"command": "stack.explore", "intents": ["explore", "idea", "strategy", "research"]},
{"command": "stack.plan", "intents": ["plan", "brainstorm", "specification"]},
- {"command": "stack.design", "intents": ["design", "ui", "motion", "critique"]},
+ {"command": "stack.design", "intents": ["design", "ui", "motion", "critique", "stageshow", "showcase video", "showcase work", "showcase our work", "showcase my work", "product demo", "ui walkthrough", "demo video"]},
{"command": "stack.design.intelligence", "subcommand": "collect", "intents": ["collect design inspiration", "collect design intelligence", "collect bookmarks"]},
{"command": "stack.design.intelligence", "subcommand": "digest", "intents": ["digest design intelligence", "weekly design digest", "summarize design inspiration"]},
{"command": "stack.design.intelligence", "subcommand": "retrieve", "intents": ["retrieve inspiration", "relevant inspiration", "show me relevant inspiration", "inspiration for this screen", "task context retrieval"], "requires_context": ["task-context"]},
diff --git a/skills/design/cdo/SKILL.md b/skills/design/cdo/SKILL.md
index 1d72bd4..e3f20c8 100644
--- a/skills/design/cdo/SKILL.md
+++ b/skills/design/cdo/SKILL.md
@@ -15,6 +15,11 @@ Enrich plan files with design specifications for compound-engineering execution.
---
+For product demos, UI walkthroughs and videos showcasing product work, consider
+Stageshow automatically through the runtime skill catalog. Read the showcase
+video route in `instructions/skill-routing.md`; actual video needs recording,
+decoding and visual inspection beyond PNG filming.
+
## Input
CDO reads a **plan file** from CPO:
diff --git a/skills/design/cdo/instructions/skill-routing.md b/skills/design/cdo/instructions/skill-routing.md
index a3f1e08..d17c899 100644
--- a/skills/design/cdo/instructions/skill-routing.md
+++ b/skills/design/cdo/instructions/skill-routing.md
@@ -68,3 +68,21 @@ Run after EVERY code generation:
6. **FAVICON** — For new projects: generate complete favicon set
**Quality gate:** "Would a staff engineer approve this?" — if not, iterate.
+
+## Showcase videos
+
+For videos that showcase product or UI work, consider Stageshow before authoring
+the scenes from scratch. Adapt its scene composition, cursor choreography and
+motion to the project's actual design and the permitted content.
+
+Check its catalog lifecycle and validation status before using its runtime.
+When it is active and validated, load it through the runtime's skill catalog.
+When it is a held candidate, use only the source guidance permitted by its
+integration contract and report the missing video validation. When it is absent,
+report that gap. A showcase-video request does not lift an execution hold or
+authorize installation or publication.
+
+Stageshow's current filming helper exports PNG stills and contact sheets. Actual
+video recording and encoding are separate delivery steps. Deliver a requested
+video only after exporting and inspecting the clip; a build, still or contact
+sheet does not establish that the video works.
diff --git a/skills/imported/benjitaylor/stageshow/LICENSE b/skills/imported/benjitaylor/stageshow/LICENSE
new file mode 100644
index 0000000..5c9588b
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Benji Taylor
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/skills/imported/benjitaylor/stageshow/NOTICE.md b/skills/imported/benjitaylor/stageshow/NOTICE.md
new file mode 100644
index 0000000..23205e7
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/NOTICE.md
@@ -0,0 +1,26 @@
+# Notices
+
+Stageshow source is MIT licensed; see `LICENSE`.
+
+Product and operating-system names, familiar device geometry, app icons, and
+illustrated interfaces are used as examples. Those names and marks remain
+with their owners; the Stageshow license does not grant trademark rights or
+imply endorsement. Adapt examples to the product you are illustrating.
+
+The Radix-inspired look uses publicly documented color and spacing values;
+its source is identified in `examples/looks/radix.css`. The app icons in `assets/app-icons.ts` are artwork from their respective app
+bundles, as identified in that file; they are not covered by the Stageshow MIT
+license. They remain illustrative references to those products. The Wikipedia
+study includes two local photographs dedicated under CC0 1.0 (also used
+in the Finder, Telegram and annotation studies); their authors,
+original source pages and modifications are recorded in
+[`examples/wikipedia/PHOTOS.md`](examples/wikipedia/PHOTOS.md). Other scenes
+use CSS and vector illustrations. No third-party fonts are bundled, and the
+playground does not fetch fonts or photographs from external services at runtime.
+
+The local brand registry in `kit/brands/` includes SVG artwork from SVGL (MIT), payment-icons (Apache 2.0), Cryptocurrency Icons (CC0 1.0), and official Linear, American Express and Circle assets. Per-file sources, hashes and license files are listed in [`kit/brands/SOURCES.md`](kit/brands/SOURCES.md). The original artwork and brand usage terms remain with their respective owners.
+
+`Phone frame="photo"` is optional. Supply your own permitted bezel image via
+`--ss-phone-photo-light: url(...)` and optionally `--ss-phone-photo-dark`.
+Without it, the kit uses its CSS-drawn `pro` frame. No bezel photographs are
+bundled.
diff --git a/skills/imported/benjitaylor/stageshow/README.md b/skills/imported/benjitaylor/stageshow/README.md
new file mode 100644
index 0000000..fc1c5cc
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/README.md
@@ -0,0 +1,75 @@
+# Stageshow
+
+A skill and a small React kit for product illustrations, cursor stories, and
+motion design. Start with `SKILL.md`; follow its links for the task at hand.
+
+## Preview
+
+Requires Node 20.19 or later. From this folder:
+
+```sh
+npm install
+npm run dev
+```
+
+On macOS and Linux, open **http://stageshow.localhost:1355**. On Windows,
+Vite starts directly; open the local URL it prints (normally **http://127.0.0.1:5173**).
+Portless is installed automatically on supported platforms. If npm skipped
+optional dependencies on macOS or Linux, run `npm install --include=optional`.
+
+The playground includes the gold
+examples, nine visual styles, and labeled bad/good teaching pairs.
+Pause, replay, slow down, or advance one frame. Phone changes the available
+width to 358px; Reduced motion shows each scene's still. The viewer and its
+controls stay outside the example's isolated clock.
+
+## Use the kit
+
+Copy `kit/` into your React project. Its components import their own CSS:
+
+```tsx
+import { Stage, Window, type Act } from './kit'
+```
+
+Copy an example as a starting point. Keep its relative kit imports, copy any
+assets it imports, and adapt the composition and look to your product. These
+are authored illustrations: a scripted `a.click()` draws feedback; `a.set()`
+changes the scene. They do not automate real application controls.
+
+Real-product studies (Figma, Linear, Slack, Raycast and others) are included in
+`examples/` and listed under **Real products** in the playground. Some import
+`lucide-react`, which the playground installs. When copying a study into a
+different project, keep its local assets and install that icon dependency or
+adapt the icons.
+
+The kit supports React 18 and later. It needs no third-party animation runtime. The
+playground uses Vite; filming uses `playwright-core` and locally installed
+Chrome (or `CHROME_PATH`). Those tools are not runtime dependencies of a demo.
+
+## Verify
+
+```sh
+npm run typecheck
+npm run check:css
+node scripts/film.mjs '' --select '.your-demo' --gate
+node scripts/film.mjs '' --select '.your-demo' --width 390 --gate
+node scripts/film.mjs '' --select '.your-demo' --states --gate
+```
+
+For the bundled viewer, use `?capture=hero-demo` as the filming URL. The ordinary
+viewer contains an iframe; use the direct capture route on native clocks. See
+`references/rehearse.md` for coverage, phone, hover, reduced-motion, and visual
+review. No automated clean result replaces watching the motion.
+
+## Contents
+
+- `kit/`: portable React source and styles.
+- `examples/`: gold scenes and reference specimens.
+- `teaching/`: explicitly flawed/corrected pairs, separate from gold.
+- `references/`: design judgment, APIs, and verification guidance.
+- `assets/`: local icon assets used by examples.
+- `scripts/`: verification tools, independent of the host's markup.
+- `playground/`: the standalone local viewer.
+
+The skill, kit, examples and verification tools are included in this folder.
+See `NOTICE.md` for third-party names and optional assets.
diff --git a/skills/imported/benjitaylor/stageshow/SKILL.md b/skills/imported/benjitaylor/stageshow/SKILL.md
new file mode 100644
index 0000000..21ddf4e
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/SKILL.md
@@ -0,0 +1,198 @@
+---
+name: stageshow
+description: Build faux product UI that looks like a real product, as a still illustration or an animated demo with a cursor. Browser and app windows, dashboards, phones, notifications, terminals, bento grids and cursor walkthroughs, framed, masked and cropped the way a marketing page, feature card or docs hero needs them. Use whenever someone wants a fake or mocked UI, a product demo or walkthrough animation, a hero or feature-card illustration of an app, a product shot in the style of a polished marketing site, or an animated cursor doing things in an interface, even if they never say "faux UI". React and plain CSS.
+---
+
+## Stack integration contract
+
+This is a pinned, active Stack workflow for authored product UI illustrations
+and videos. Isolated synthetic browser/video validation passed on November 9,
+2026 with matching Chromium and its sandbox enabled. Validation covers the
+recorded representative routes, not every retained example or arbitrary host.
+Read [Stack review and adaptations](STACK-REVIEW.md) before using the resources.
+
+- Follow the user's task and the receiving project's design contract. These
+ illustration presets do not govern a shipping product's real interaction UI.
+- Work in an authorized project copy. Keep this imported package unchanged;
+ copy examples and required kit files into the project rather than editing Stack.
+- Resolve existing action-specific authorization before setup or execution.
+ Invoking this skill does not authorize npm/npx installs, lifecycle scripts,
+ browser downloads, imported helper execution, a Vite/Portless listener, proxy
+ changes, or external calls. Request missing approval for the exact command,
+ target and effects; do not repeat approval already granted for that action.
+- Local source patches keep rehearsal failures in the final exit status, escape
+ contact-sheet labels and explicitly enable the Chromium sandbox. The filming
+ helpers passed representative isolated checks. Their exit code or
+ `rehearsal passed` text alone is not acceptance evidence. When blocked, hand
+ over the component/storyboard and state precisely what was not checked.
+- For any later authorized preview, use reviewed local content, an explicit
+ loopback origin/port and serving directory, bounded resources and a trusted
+ isolated browser executable. Do not reuse a signed-in profile, cookies,
+ credentials or authentication state. Review navigation, redirects and resource
+ origins before allowing network access; do not pass arbitrary work URLs.
+- Use synthetic or expressly permitted work content. Keep captures, DOM labels,
+ logs and exports in an approved local output directory without overwriting
+ unrelated files. Do not upload, publish, share or send work material unless
+ that exact destination and action are authorized. A local export is not a
+ request for publication.
+- Preserve LICENSE, NOTICE.md, artwork sources and applicable separate licenses.
+ App-bundle icons and five official-only brand assets are omitted. Use only
+ available examples/marks or user-supplied permitted artwork; do not fetch the
+ omitted artwork automatically or imply a brand endorsement. Phone status
+ symbols are generic Stack-authored geometry, not Apple's copied glyph paths.
+
+Filming remains strict: sampling gaps are inconclusive, and retained examples
+can have layout or motion findings. Fix and recheck each project copy.
+`film.mjs` produces PNGs, not encoded video: record an actual clip separately,
+decode it, inspect its motion and final state, and verify duration/dimensions.
+The accepted sample and limitations are recorded in `STACK-REVIEW.md`.
+
+The upstream workflow below is conditional on this contract. Commands are
+reference instructions, not authorization to run them. The omitted examples
+are `hero-demo`, `bento`, `phones`, `parts` and `kit-extras`; choose a retained
+example instead. `source.json` records the exact subset and every adaptation.
+
+# Stageshow
+
+Miniature product UI: a believable interface, framed in its space, as one still or a short animation in which one thing happens. The viewer recognizes the screen and sees the thing happen.
+
+## The one idea
+
+**It reads as a real product, simplified to what the viewer needs, framed with intent, and every motion has a cause.**
+
+Five ways it fails:
+
+- **Too much.** Real text in every row, numbers everywhere, everything packed. The more detail, the more it looks generated.
+- **Wireframe.** Two gray blobs and a button. Built too small, or stripped of what made it recognizable.
+- **Boxed.** A whole window shrunk into a card with even margins: a screenshot pasted in, not a product shot.
+- **Flat.** Every object the same weight in the same bordered card. The eye has nowhere to land.
+- **Bland.** A gray window with a cursor, whatever the brief said. The brief's look and energy (a blueprint, glass over color, a Dynamic Island, a mascot) are the point; take what it asks for and build it to the rules, as `examples/hero-demo` does with its typed popup and terminal satellite (a satellite is a smaller second object, a receipt, phone or detail card, straddling the hero's edge).
+
+The fix: a full, structured screen in quiet grays, real words only on what the story is about, placeholder lines for the rest, room to breathe, a crop that puts the eye where the story is. Simplify the words, not the context. An empty corner, a void under a list, rows spread to fill a pane, a window floating on a plate (a backdrop or tinted field behind it): unfinished, not calm.
+
+These are authored illustrations, not browser automation: `a.click()` draws the cursor pressing, `a.set()` changes the scene.
+
+## How hard to press
+
+Build it the way a product designer builds a launch page they will be judged on. The gold examples in `examples/` are the bar, not a reference: if yours is the emptier, flatter or smaller one beside the closest gold, that is the first fix. Then look at the frame the way the most exacting designer you know would, the one who screenshots the one wrong thing and sends it back with "what is this?": a dot off its avatar, a unit on its own line, a grabber under the island, copy touching a card. If you can picture that screenshot, you have found the next fix.
+
+## Workflow
+
+Most of the quality is decided before any code. Write the plan first: where it sits, the focal object, the action, the payoff, what can be left out.
+
+1. **Medium and place.** Still or animation (loop, once in view, or hover). The page, its ground, its width. Design at the size it will be seen, about 720 × 340 for a hero beside copy (`hero-demo`), 900 to 1100 wide for a full-width hero (`linear`), 400 × 300 for a card, shown at 1:1; never under about 320 wide for a demo, though a bento tile or a satellite card may be smaller. Every demo that can sit in a phone column ships a `compact` design at the column's width (358), a redesign not a squeeze (`references/framing.md`, Small formats and the page). A grid of tiles is one set: plan it with `references/bento.md`.
+2. **Look.** With the product's code or site at hand, map its tokens to the kit's (`references/looks.md`); if the developer asks for their own components, use them inside the stage instead of the kit's small parts. Otherwise a theme (light, dark, blueprint, glass), a palette (accent, mono) and a fidelity: `minimal` is all bars, `standard` is words where they carry meaning and bars elsewhere, `realistic` is real content everywhere. Default light, accent, standard; go realistic at hero scale, for a named real product, or a still whose subject is a list or page. Write filler once with `Text filler` and the fidelity decides. The product's own icons, its real chrome, the content that makes it recognizable. A brief with a style (kawaii, paper, a poster color) gets that style in full, still built to these rules.
+3. **The story.** Its shape (`references/motion.md`, Story): how it feels to use, a flow, or what the system does. Three to five beats and a payoff, 10 to 16 seconds. No cursor move you can't explain. Data never rewinds in view without a cause. Between a richer story and a calmer frame, choose the frame.
+4. **Start from an example.** Find the closest gold example (the Gold table below) and film it (`node scripts/film.mjs "/?capture=" --select '#example' --still --out gold.png`; `npm run dev` serves the preview). Copy its folder into your project as the starting point; don't rewrite it from a glance. The quickest loop, in an authorized project copy: copy it to `examples//` there, where the preview picks it up and `?capture=` films it, then move it into the host when it passes and fix its kit import path. Keep its structure: the stage size, the crop and bleed, row and type sizes, spacing, the script's beats, the kit parts. Now read `references/craft.md` in full: it is short, and it holds the still-frame rules the gold can't show you, the cases your gold doesn't happen to contain. Then replace its look and content with the brief's product: the tokens from step 2, the product's own icons, chrome and words. A copy that only changes the name is not done: it should read as the brief's product, not the gold's. If no gold tells your story, choose in two parts: the layout first (pick it from the table in `references/framing.md`; each layout names its example), and copy that example even though its story differs; then borrow the motion for your beats from the example whose story is closest, and build what neither has in the same style. Then frame it: crop to the panel the story happens in, judged in the final visible crop (`references/framing.md` has the rules and the layout table). The short form: one gutter, a full one or well past; only filler bleeds; what opens stays in; windows keep their shape; the page is the ground, so no plate under a hero on a white or dark page and no window whole with air on four sides; `unframed` only when the developer asks; no dead corners. Film one still at both widths beside the gold's; if yours is emptier, flatter or more boxed, fix the frame before any motion.
+5. **The screen.** Count the real strings, usually 3 to 7 (`references/craft.md`, Restraint): a budget for words, not elements. Build with the kit's tokens, `Bar`, `Text` and `Row`. Fit every control's text before it moves. Mark what the cursor touches with `data-target` and `data-cursor`; measure with `rect()`, never hard-code.
+6. **Choreograph.** The `Act` API. Keep what appears mounted and toggle it so exits animate (`Bubble` and `Fly` hand off explicitly). Animate the relationship, not just the object: a new row carries the rows below it, a surface that resizes never stretches its text. Beats overlap, groups stagger, the payoff holds, the loop ends where it began or resets with a visible cause.
+7. **Rehearse.** Set `poster` first: most people see only that frame (a thumbnail, reduced motion, a screenshot), so it is the beat that tells the story on its own, never one caught mid-change, with the result left on screen rather than tidied away (`references/motion.md`, Story). Then film the loop, not just a still: the contact sheet at the shown width and at 390, and step through every press, menu, morph and the loop seam (`references/rehearse.md`). A still can't show a pop. Read every frame, fix the largest visible fault first (framing, then hierarchy, then motion, then polish), re-film. Then hold it next to the closest gold and answer the whole-composition and style pairs in `references/teaching.md` as questions about your still (what does the eye land on first, and is it the result? is one window, tile or device clearly leading?); any no is the largest fault. `film.mjs --gate` ending `rehearsal passed` is necessary, not sufficient. Then run the last pass in `references/rehearse.md`: a fresh-eye review (`references/review.md`) and `REHEARSAL.md` beside the component (template in `references/verify.md`, Hand over): the gold you copied, what you changed, what you did not check.
+
+Faults that come back every round, each with its rule; check them before handing over:
+
+- **Content that swaps** instead of moving (`references/motion.md`, Changing text and values).
+- **Gray bars where the eye lands;** filler belongs at the edges (`references/craft.md`, Restraint).
+- **A plate behind the window, or a window floating** with air on four sides; the window or phone fills at least two thirds of the stage and runs off an edge (`references/framing.md`, rule 5 and Small formats and the page).
+- **Empty bands, cut controls and shadows** (`references/craft.md`, Restraint; `references/framing.md`, rule 4).
+- **Stock AI and made-up logos:** sparkles, purple, a letter in a colored square (`references/craft.md`, Icons and logos).
+- **Busy rows and uneven insets** (`references/craft.md`, Restraint and Spacing and alignment).
+- **Long holds and a reset with no cause** (`references/motion.md`, Loops).
+- **A selection that isn't under the I-beam** (`references/motion.md`, Drags and selection).
+- **A status bar or home indicator drawn by hand;** the Phone draws both (`references/craft.md`, Chrome and devices).
+- **A centered amount centered with its sign** (`references/craft.md`, Spacing and alignment).
+
+## Examples
+
+Film the closest one before you start (step 4). The gold examples are the quality bar; product studies are for borrowing an interaction, never another product's identity; specimen sheets are catalogs, not compositions. Only after the Stack setup and execution gates are satisfied, the upstream preview uses `npm install` and `npm run dev`. `teaching/` holds bad/good pairs (`references/teaching.md`) for diagnosing a problem; the bad halves are never starting points.
+
+### Gold
+
+| Need | Example | What to learn |
+|---|---|---|
+| Still or feature card | `static-card` | Quiet hierarchy, a hard crop, one satellite, real words only on the selected customer, its neighbors and the total |
+| Dark still | `dark-bento` | One signal said once; album art keeps it a music app |
+| Animated hero, a gesture | `hero-demo` | Five beats, named targets, an annotation, a terminal coming forward |
+| Choreography, no cursor | `payout-flow` | Slow overlapping arrivals, reading time, a payoff that holds |
+| Content changing in place | `choreography` | Retained tracks, soft masks, dependent timing |
+| AI answer | `ai-chat` | The question sent, Thinking, the answer streaming in with its height, sources last; no sparkles, no purple |
+| AI with cards | `ai-cards` | A multiple-choice card answered and kept as the record, a card of options opening in the thread, booked from its own button |
+| Form validation | `form-error` | An error on the field that caused it, cleared by fixing it, then the button confirms on itself |
+| Drag to reorder | `reorder` | A row lifted by its grip, the others making room, dropped into place |
+| Chart scrub | `chart-scrub` | A hairline and dot that follow the pointer, the headline value rolling to the day under it |
+| Edit in a table | `table-edit` | A cell edited in place, then the total it feeds rolls a beat later |
+| Loading to content | `loading` | A skeleton at the content's own geometry, rows resolving top to bottom, real chrome throughout |
+| Dark hero | `dark-hero` | Depth from surface steps on black, one lit color, a status that changes on its own pill |
+| Live collaborators | `presence` | Each person one color everywhere, names riding their cursors, work shown in the doc's own marks |
+| System story | `subscriptions` | A chip flies into a row, MRR rolls, a second scene is the reset |
+| Close-up | `close-up` | One control at 1.4x with its window's chrome, the row whole, a large cursor to match |
+| Many of one | `many-of-one` | A grid of one thing off every edge, one tile in focus by depth, the only one with words |
+| Fade crop | `fade-crop` | A window fading out at its foot, over filler, instead of running off an edge |
+| Panel over product | `panel-annotation` | A settings panel over the product's margin, one leader from the setting to what it changed |
+| Overlapping windows | `overlap` | A card in front of a window, its edge on the window's sidebar line |
+| Stacked cards | `stacked-cards` | Two components at one width, a small gap, the lower one off the bottom |
+| Opposite corners | `opposite-corners` | A bento tile: copy bottom-left, the product hung from the top-right corner |
+| Tilted stack | `tilted-stack` | A loose pile of paper, small alternating angles, only the top card has words |
+| Corner chip | `corner-chip` | A small chip centered on a window's corner, half outside |
+| Monochrome | `monochrome` | Other companies' products in a row, grayscale, faded at the foot |
+| Row of windows | `window-row` | Sessions side by side at one height: focus by depth, neighbors cut by the stage, only filler cropped |
+| Window and phone | `device-pair` | A dashboard and a lock screen telling one event; the phone straddles over margin, never over the figure |
+| Related objects | `story` | An invoice, a plan, a connector, rolling totals |
+| Calendar drag | `calendar` | A populated week, one focal event, a measured pickup |
+| Free dragging | `freeform-drag` | Grab offset, a measured grid, a drop slot that snaps |
+| Phone app | `phone-touch` | A card opens into its screen as one object, and the loop folds back |
+| Dynamic Island | `dynamic-island` | One black shape, its content a separate layer |
+| Messages | `messages` | Composer, typing, receipts, a bubble's lifetime |
+| Cursors | `cursors`, `cursor-morph`, `cursor-resize` | Five styles, element-driven kinds, resize edges and corners, no cursor at all |
+| Annotations | `annotations` | Leaders, dimensions, flows and a moving target; `references/annotations.md` |
+| Feature grid | `bento` | One hero, one window, a stat, an object, a diagram |
+| Dark ruled grid | `ruled-bento` | Shared rules and technical imagery |
+| Consumer grid | `staggered-bento` | Offset tiles, graphic art, physical objects |
+| Image-led hero grid | `hero-grid` | Different grounds and materials composed as one picture |
+| Upload | `upload` | A file dragged in from the desktop, an upload row with progress that settles as a file row |
+| Share | `share` | A share popover over a document: an email becomes a chip, a role menu, a person added to the list |
+| Swipe | `swipe` | A phone swipe to archive: press, swipe, a throw at the finger's speed, the gap closes |
+
+### Product studies
+
+Recognizable products (Figma, Linear, Slack, Raycast, Finder, Spotify, Notion, Gmail, Google's apps, Office, Wikipedia, Telegram, iMessage): read `references/products.md` first. Borrow the interaction, not another product's identity.
+
+| Need | Example | What to learn |
+|---|---|---|
+| Design tool | `figma` | Canvas selection, a color picker, a second cursor |
+| Issue tracker | `linear` | Command menu and assignment |
+| Chat | `slack` | Messages, a reaction, a thread reply |
+| Launcher | `raycast` | Search and clipboard history over a blurred desktop |
+| Files | `finder`, `finder-glass` | Quick Look over a file list; a file moved into a folder; the same window in frosted glass |
+| Music, dark | `spotify` | A dark desktop app with album art |
+| Docs and editors | `notion`, `google-docs` | A slash command; a comment and its reply |
+| Email | `gmail` | Hover actions, archive, an undo toast |
+| Calendar, real app | `google-calendar` | Drag to create an event, the quick-create popup |
+| Office | `powerpoint`, `excel` | Slide editing; AutoSum and a chart |
+| Browser page | `wikipedia` | Search suggestions in a browser |
+| Phone conversations | `telegram`, `imessage` | Native chat apps on an iPhone |
+
+### Specimen and reference sheets
+
+Catalogs of parts and grounds, described in `references/kit.md` (Specimen sheets). Look up a part here; compose from a gold example.
+
+| Need | Example | What to learn |
+|---|---|---|
+| Kit specimens | `chrome`, `phones`, `controls`, `parts`, `kit-extras` | Every frame and part |
+| Glass | `glass` | Frosted surfaces over a color field |
+| Backgrounds | `backgrounds` | One interface over twenty grounds; `references/backgrounds.md` |
+| Logos | `brand-marks` | Real marks, optically sized, on light and dark; `references/brands.md` |
+| A product's look | `looks` | Type, shape and color working together |
+
+## The kit
+
+Copy `kit/` into the project and import from `kit/index.ts`. React, react-dom and lucide-react (for `Icon`, which `Field` and `Chip` use); each part brings its own CSS. It does the parts that are hard to get right by hand and leaves the product's look to you. `references/kit.md` has every part's props and rules; read the entries for the parts you use.
+
+- **The stage.** `Stage`: design size and scale-to-fit, `compact`, scene state, playback, the look, the cursor. The script gets an `Act` (`set`, `wait`, `move`, `click`, `type` and the rest: `references/kit.md`, Act).
+- **Frames.** `Window`, `Phone` with `LockScreen` and `Notification`, `Keyboard`, `Thread` with `Bubble`, `Stamp`, `Receipt`. Real geometry; never draw a window, device or bubble by hand.
+- **Motion.** `Collapse`, `Roll`, `Swap`, `Count`, `Connector`, `Fly`, `Follow`, `States`, `Stream`, `Popover`, `Scroll`, `Camera`, `Expand`. They handle the frames a cold hand gets wrong.
+- **Small parts.** `Bar`, `Text`, `Row` (every list is made of it), `Avatar`, `Pin`, `Icon`, `Menu`, `Field`, `Segmented`, `Toggle`, `Tabs`, `Badge`, `Chip`, `BrandMark`, `Code`, `Chart`. Quiet starting points; restyle to the product, never reshape a kit part's geometry from outside.
+- **`stageshow.css`**: tokens, looks, edge fades, chrome, phones, keycaps.
+
+## The references
+
+`references/framing.md`: the framing rules and the layout table. `references/craft.md`: the still frame (restraint, type, spacing, icons, color, chrome). `references/motion.md`: story, loops, pace, the cursor and drags. `references/verify.md`: the checklist with a you-see / you-do table. `references/rehearse.md`: how to film, and the film lines that are never intentional.
diff --git a/skills/imported/benjitaylor/stageshow/STACK-REVIEW.md b/skills/imported/benjitaylor/stageshow/STACK-REVIEW.md
new file mode 100644
index 0000000..f67bba7
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/STACK-REVIEW.md
@@ -0,0 +1,176 @@
+# Stageshow source intake
+
+Source: https://github.com/benjitaylor/skills/tree/802057409787133703c56001080169e347305572/stageshow
+Author: Benji Taylor. Upstream version: 0.1.0. Revision: `802057409787133703c56001080169e347305572`.
+
+## Decision and scope
+
+The user explicitly requested this skill for Stack and work use. Retain its
+authored product-illustration guidance, React kit and permitted examples as one
+design workflow under `stack.design`. It complements CDO/Emil motion guidance;
+it does not become a general product-design authority or real-app automation.
+The initial import remained `candidate` until isolated validation. The November 9
+acceptance below supersedes that hold and authorizes local Claude/Codex exports.
+Repository activation and actual runtime installation remain separate evidence.
+
+## Provenance and adaptations
+
+Every retrieved file was verified against GitHub's immutable Git blob identity.
+`source.json` records upstream and imported SHA-256 hashes, omissions and changes.
+LICENSE and upstream NOTICE.md are preserved unchanged. The retained brand
+artwork retains its per-file sources and MIT, Apache 2.0 or CC0 license texts;
+these do not grant trademark rights. Retained photographs keep PHOTOS.md.
+
+- Add the Stack integration contract to SKILL.md; make setup and editing-copy
+ instructions conditional on authorization and the runtime hold.
+- Omit app-bundle icon artwork, its three dependent examples, hero-demo's
+ unlicensed copied toolbar artwork, and kit-extras' omitted dependencies.
+- Omit official-only Linear, American Express and Circle artwork whose usage
+ terms are not bundled, and remove their three brand-registry entries.
+- Replace only Phone's three private status glyph functions with newly authored
+ generic geometry. Keep the component API and CSS classes. Native glyph
+ fidelity is deliberately not claimed.
+- Filter the playground catalog by available example modules so omitted
+ resources are not offered as runnable examples. Upstream example tables and
+ notices describe the original collection; the Stack contract and source
+ manifest control this imported subset.
+- Normalize one retained SVG line ending and one Markdown EOF blank line for
+ repository formatting checks; the SVG records its modification and its artwork
+ geometry is unchanged.
+- Apply two local safety/reliability patches in scripts/film.mjs: fold a
+ failed gate into the report's final exit status, and HTML-escape labels before
+ contact-sheet interpolation. Do not print a passed gate for a nonzero report.
+ At initial intake, all other helper source, package metadata and the lockfile
+ were preserved, and no upstream setup, dependency lifecycle, build, browser,
+ preview or filming code was run. See the follow-up below for the later launcher
+ patch and validation attempts.
+
+## Runtime hold and evidence limits
+
+At the pinned upstream revision, scripts/film.mjs overwrites a failed gate exit
+status and interpolates page-controlled labels into contact-sheet HTML. The two
+local patches address those source paths: a gate failure raises result.exitCode
+before report serialization and final assignment; all five HTML delimiter
+characters in labels are escaped before interpolation. Static/stdlib regression
+checks cover the source policy, failure-status combinations and markup-bearing
+labels without importing or executing the tool. These are not reproduced browser
+outcomes. State capture still observes only the first selected stage; that behavior
+is unchanged. Do not use the helpers as acceptance evidence until independent
+representative runtime validation passes.
+
+The preview uses Portless/Vite, which starts a local listener; its Vite config
+allows the parent directory. Filming launches installed Chrome through
+playwright-core (or CHROME_PATH), accepts arbitrary URLs and writes captures and
+reports. Setup may download dependencies and run transitive lifecycle scripts.
+No browser downloader is configured in scripts/browser.mjs. None of these
+effects is authorized merely by importing or invoking the skill.
+
+## Promotion evidence still needed
+
+In a separately authorized isolated project: review the exact setup actions,
+serving directory/origin and permitted synthetic content; validate the local
+filming patches; typecheck/build the retained subset; verify a representative still,
+loop, compact layout, reduced-motion state, output containment, truthful gate
+failure, and clean browser/server shutdown. Record independent visual review.
+Then review activation and managed Claude/Codex publication separately.
+
+## Validation follow-up: 2026-11-01
+
+The user authorized continuing isolated validation with synthetic content, a
+matching official browser, its sandbox enabled and no test network access.
+They reported that dependency installation, typecheck and build passed in a
+prior attempt, but the disposable VM's browser-image download failed because
+guest DNS did not work. That VM was removed. The newer setup receipts were not
+located in this checkout or the available saved-file search, so those results
+remain user-reported rather than independently verified here. The denied
+`/mnt/lima-cidata/network-config` read was not repeated. No privilege elevation
+or attempt to bypass that denial was made.
+
+Source review found that `scripts/browser.mjs` omitted `chromiumSandbox`, which
+Playwright defaults to false. A third local patch explicitly sets it to true
+for both the Chrome channel and `CHROME_PATH`. No retry with weaker options is
+provided. `tests/test_stageshow_browser_launcher.py` executes that wrapper with
+a fake Playwright module: default selection, explicit path, launch failure and
+missing dependency. The three sandbox assertions failed before the patch; all
+four tests pass afterward. These tests never launch a browser and do not prove
+that a platform can run its sandbox.
+
+A new Lima VM definition used plain mode, no host mounts, no SSH agent/public
+key forwarding, no proxy environment propagation and no container runtime.
+Creation stopped before boot because the current execution permissions denied
+Lima's image-cache write. Separate HTTPS probes could not resolve the official
+Debian image and Microsoft browser-image hosts from this shell. The unbooted VM
+definition was deleted. No browser, listener or test network was started.
+
+The proposed next route is to fetch verified VM/browser assets during setup in
+an environment that permits those downloads, copy only the pinned package and
+synthetic fixture into a fresh VM, and run validation with enforced network
+denial. That route has not been executed or validated here. Keep the browser
+sandbox enabled and stop on any isolation failure; do not change host security
+settings, reuse a signed-in browser, or retry the denied diagnostic read.
+
+`film.mjs` exports PNG stills/contact sheets, not an encoded video. Acceptance
+also requires recording an actual synthetic UI clip with the matching browser
+and verifying the resulting video: successful decode, duration, dimensions,
+changing frames, expected final state and visual review. No video artifact or
+browser acceptance receipt exists from this follow-up. The candidate and empty
+publication-target list remain unchanged. After validation, use the documented
+catalog/compiler procedure; do not bypass its clean-source requirements or
+publish runtimes under this task's prohibition on publication.
+
+The bounded follow-up receipt is
+`artifacts/validation/stageshow/2026-11-01.json`.
+
+## Accepted isolated validation: 2026-11-09
+
+Maroun authorized official pinned setup downloads, isolated synthetic tests and
+normal local installation into both Stack runtimes in Codex chat
+`01a1c1bb-c674-7013-a1ee-dcaf8ede8354`. That run prohibited external publication.
+
+The accepted receipt is `artifacts/validation/stageshow/2026-11-09-accepted.json`.
+A disposable Lima 2.2.0 Debian 12 arm64 VM used a digest-pinned official image,
+no host shares, no SSH agent/private-key forwarding, an allowlisted environment
+and no signed-in state. Node 22.22.3 matched the official SHA-256 list. Exact
+lockfile dependencies used `npm ci --ignore-scripts`; matching Playwright-core
+1.48.2 installed official Chromium 130.0.6723.31 (revision 1140). Signed Debian
+packages were version-pinned and extracted into the non-root user's directory;
+no sudo or privilege elevation was used.
+
+An inherited seccomp rule with no-new-privileges denied IP sockets and io_uring
+setup before all acceptance commands. IPv4/IPv6 socket attempts returned EPERM
+and an external browser navigation failed. Synthetic pages were fully inlined
+local files, so no HTTP server or external resource was needed. Renderer status
+proved a PID namespace and additional Chromium seccomp filtering; actual launch
+arguments omitted sandbox-disabling switches. The source launcher was unchanged.
+
+Acceptance includes a decoded 12-second 640×480 H.264 UI clip (300 frames at
+25 fps), showing typing, error, correction, submission and “Account created”;
+an 8-second 640×360 loop (200 frames) traversing saved/reset twice; wide and
+390px compact reduced-motion posters stable across captures; representative
+stills; and a complete, no-findings loop rehearsal under unchanged thresholds.
+Native Playwright recordings include encoder tail time; delivered clips trim
+only that tail to the declared duration. Decoded frames were visually inspected.
+
+A deliberately clipped target returned exit 1 in both the process and report,
+printed rehearsal failure and never printed success. A markup-bearing state
+label round-tripped as literal caption text, with zero injected elements or
+executed handlers. Outputs stayed under the designated validation output root;
+source helper/lockfile hashes matched afterward. Browsers closed and the VM
+stopped. No prohibited diagnostic was retried or host security setting changed.
+
+Limits: this validates representative routes, not the whole example catalog.
+Exploratory retained form/loading examples produced compact framing findings
+and/or motion-sampling gaps on the VM. Those reports are preserved; they were
+not reclassified or used as passed gates. Project copies still need rehearsal
+and visual review. The filming helper remains a PNG exporter; actual encoded
+video requires the separate recording/decoding step. Runtime installation is
+verified by the owner-only normal installer receipt, not by this source note.
+
+## Canonical publication authorization: 2026-11-12
+
+After the source-only acceptance, the owner authorized publishing and merging
+this reviewed integration, then installing both local Stack runtimes through
+the normal procedure. The canonical-main provenance gate remains unchanged.
+The original local-only commit and raw evidence remain preserved locally;
+published VM diagnostics redact host home paths. Runtime availability still
+requires successful canonical installation and both discovery checks.
diff --git a/skills/imported/benjitaylor/stageshow/capability.json b/skills/imported/benjitaylor/stageshow/capability.json
new file mode 100644
index 0000000..7a9dc69
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/capability.json
@@ -0,0 +1,71 @@
+{
+ "artifact_type": "skill",
+ "audit_status": "reviewed",
+ "canonical_name": "stageshow",
+ "commands": [
+ "stack.design"
+ ],
+ "context": {
+ "inputs": [
+ "request",
+ "project-design-contract",
+ "permitted-content"
+ ],
+ "outputs": [
+ "product-illustration-source",
+ "verification-limitations"
+ ]
+ },
+ "disposition": {
+ "evidence_gap": null,
+ "next_review_trigger": "Revalidate on helper, dependency/browser, safety contract or runtime export changes; rehearse each project copy.",
+ "status": "keep"
+ },
+ "domain": "design",
+ "family": "design",
+ "lifecycle": "active",
+ "overlaps": [
+ "emil-design-eng",
+ "review-animations",
+ "design-intelligence"
+ ],
+ "ownership": {
+ "package": "imported-skills",
+ "provider": "benjitaylor",
+ "source_path": "skills/imported/benjitaylor/stageshow/SKILL.md"
+ },
+ "provenance": {
+ "license": "MIT; retained artwork has separate MIT/Apache-2.0/CC0 notices; excluded artwork and trademark rights are not covered",
+ "posture": "imported-upstream",
+ "source_identity": "github:benjitaylor/skills:stageshow@802057409787133703c56001080169e347305572"
+ },
+ "purpose": "Create authored still or animated product UI illustrations and cursor stories with the pinned Stageshow React kit and permitted references.",
+ "role": "workflow",
+ "runtimes": {
+ "publish_targets": [
+ "claude",
+ "codex"
+ ],
+ "supported": [
+ "claude",
+ "codex"
+ ]
+ },
+ "schema_version": 1,
+ "source": {
+ "skill_path": "skills/imported/benjitaylor/stageshow/SKILL.md"
+ },
+ "trust_class": "local-mutation",
+ "validation": {
+ "evidence": [
+ "skills/imported/benjitaylor/stageshow/STACK-REVIEW.md",
+ "skills/imported/benjitaylor/stageshow/source.json",
+ "tests/test_stageshow_intake.py",
+ "tests/test_stageshow_browser_launcher.py",
+ "artifacts/validation/stageshow/2026-11-09-accepted.json"
+ ],
+ "status": "validated"
+ },
+ "validation_class": "representative-route",
+ "visibility": "extended"
+}
diff --git a/skills/imported/benjitaylor/stageshow/examples/ai-cards/cards.css b/skills/imported/benjitaylor/stageshow/examples/ai-cards/cards.css
new file mode 100644
index 0000000..3fdebbb
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/ai-cards/cards.css
@@ -0,0 +1,273 @@
+.aic .ss-stage {
+ overflow: hidden;
+ background: var(--ss-well);
+}
+
+/* Pinned top and sides, off the bottom just under the composer */
+.aic-win {
+ position: absolute;
+ top: 28px;
+ left: 40px;
+ width: 560px;
+ height: 540px;
+}
+
+.aic-body {
+ display: grid;
+ grid-template-rows: 1fr auto;
+ height: 100%;
+ padding: 16px 20px 56px;
+ background: var(--ss-surface);
+}
+
+/* Anchored at the bottom over the composer; the earlier turn goes up out of
+ the window as the cards open */
+.aic-thread {
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-end;
+ gap: 14px;
+ min-height: 0;
+ padding-bottom: 20px;
+ overflow: hidden;
+ /* The earlier turns fade as they go up out of the window, never sliced
+ by its edge */
+ mask-image: linear-gradient(to bottom, transparent, #000 24px);
+}
+
+/* Everything in the thread rides up as the cards open: each keeps a layer
+ of its own, so its words don't step on whole pixels as it moves */
+.aic-thread > *,
+.aic-choice {
+ will-change: transform;
+}
+
+.aic-past {
+ display: grid;
+ gap: 8px;
+}
+
+.aic-past-q {
+ justify-self: end;
+}
+
+.aic-q {
+ display: flex;
+ justify-content: flex-end;
+}
+
+.aic-q .ss-text {
+ padding: 8px 14px;
+ border-radius: 16px;
+ background: var(--ss-ink-1);
+ color: var(--ss-text-strong);
+ font-size: 13px;
+}
+
+.aic-a {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ gap: 10px;
+}
+
+.aic-line {
+ white-space: normal;
+ text-wrap: pretty;
+}
+
+.aic-line,
+.aic-thinking {
+ color: var(--ss-text-strong);
+ font-size: 13px;
+}
+
+.aic-thinking {
+ color: var(--ss-text);
+}
+
+/* Multiple choice: one row of fixed options. A pick fills one and draws its
+ check; the rest go quiet but stay, as the record of the answer */
+.aic-choices {
+ display: flex;
+ gap: 8px;
+}
+
+.aic-choice {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ height: 32px;
+ /* The round check reads as further in than a letter: its side is 2px
+ tighter, so both ends look the same */
+ padding: 0 12px 0 10px;
+ border-radius: 10px;
+ box-shadow: inset 0 0 0 1px var(--ss-line);
+ color: var(--ss-text-strong);
+ font-size: 13px;
+ font-weight: 500;
+ transition: background-color 0.25s ease, box-shadow 0.25s ease, color 0.25s ease, opacity 0.3s ease;
+}
+
+.aic-reply {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+}
+
+/* The pick is marked in the accent, not the success green: choosing is not
+ finishing */
+.aic-choice {
+ --ss-success: var(--ss-accent);
+}
+
+.aic-choice__mark {
+ display: grid;
+ color: var(--ss-text);
+}
+
+.aic-choice__mark .ss-check:not([data-on]) .ss-check__ring {
+ stroke: var(--ss-line-strong, var(--ss-line));
+}
+
+.aic-choice[data-on] {
+ background: var(--ss-accent-soft);
+ box-shadow: inset 0 0 0 1.5px var(--ss-accent);
+ color: var(--ss-accent-text);
+}
+
+.aic-choices[data-answered] .aic-choice:not([data-on]) {
+ opacity: 0.45;
+}
+
+/* The flights card: rows of one height, a radio, the price on the right */
+.aic-card {
+ display: grid;
+ gap: 2px;
+ padding: 6px;
+ border-radius: 14px;
+ box-shadow: inset 0 0 0 1px var(--ss-line);
+}
+
+.aic-flight {
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ align-items: center;
+ gap: 12px;
+ height: 46px;
+ padding: 0 14px 0 12px;
+ border-radius: 9px;
+ transition: background-color 0.25s ease, box-shadow 0.25s ease, opacity 0.3s ease;
+}
+
+.aic-flight[data-on] {
+ background: var(--ss-accent-soft);
+ box-shadow: inset 0 0 0 1.5px var(--ss-accent);
+}
+
+.aic-card[data-booked] .aic-flight:not([data-on]) {
+ opacity: 0.45;
+}
+
+.aic-radio {
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ box-shadow: inset 0 0 0 1.5px var(--ss-line-strong, var(--ss-line));
+ transition: box-shadow 0.25s cubic-bezier(0.22, 1, 0.36, 1);
+}
+
+.aic-flight[data-on] .aic-radio {
+ box-shadow: inset 0 0 0 5px var(--ss-accent);
+}
+
+.aic-flight__main {
+ display: grid;
+ gap: 5px;
+}
+
+.aic-times {
+ color: var(--ss-text-strong);
+ font-size: 13px;
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+ text-box: trim-both cap alphabetic;
+}
+
+.aic-arrow {
+ color: var(--ss-text);
+ font-weight: 400;
+}
+
+.aic-airline {
+ color: var(--ss-text);
+ font-size: 11px;
+ text-box: trim-both cap alphabetic;
+}
+
+.aic-price {
+ color: var(--ss-text-strong);
+ font-size: 13px;
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+}
+
+/* The card's action: one cell, every label a layer in it that crossfades,
+ so nothing moves sideways; the fill eases from quiet to accent to done */
+.aic-book {
+ display: grid;
+ grid-template-areas: 'stack';
+ place-items: center;
+ height: 38px;
+ margin-top: 4px;
+ border-radius: 9px;
+ background: var(--ss-ink-1);
+ color: var(--ss-text);
+ font-size: 13px;
+ font-weight: 600;
+ transition: background-color 0.3s ease, color 0.3s ease;
+}
+
+.aic-book[data-ready] {
+ background: var(--ss-accent);
+ color: #fff;
+}
+
+.aic-book[data-done] {
+ background: var(--ss-success);
+}
+
+.aic-layer {
+ grid-area: stack;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ opacity: 0;
+ filter: blur(2px);
+ transition: opacity 0.25s ease, filter 0.25s ease;
+ text-box: trim-both cap alphabetic;
+}
+
+.aic-layer[data-on] {
+ opacity: 1;
+ filter: none;
+ transition-delay: 0.08s;
+}
+
+.aic-book .ss-check__ring { stroke: rgb(255 255 255 / 0.45); }
+.aic-book .ss-check__arc { stroke: #fff; }
+.aic-book .ss-check__fill { fill: #fff; }
+.aic-book .ss-check__tick { stroke: var(--ss-success); }
+
+/* Narrow: a gutter each side, the window the stage's width */
+.aic .ss-stage[data-layout='compact'] .aic-win {
+ left: 16px;
+ width: 326px;
+ height: 600px;
+}
+
+.aic .ss-stage[data-layout='compact'] .aic-body {
+ padding-inline: 14px;
+}
+
+.aic .ss-stage[data-layout='compact'] .aic-choice {
+ padding: 0 10px 0 8px;
+}
diff --git a/skills/imported/benjitaylor/stageshow/examples/ai-cards/index.tsx b/skills/imported/benjitaylor/stageshow/examples/ai-cards/index.tsx
new file mode 100644
index 0000000..937a535
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/ai-cards/index.tsx
@@ -0,0 +1,133 @@
+import { Bar, Check, Collapse, Field, Stage, Text, Window, type Act } from '../../kit'
+import './cards.css'
+
+// An assistant that answers with cards you act on. Beats: asked for a flight,
+// it asks which day with a multiple-choice card; a day is picked; it searches,
+// then a card of three flights opens; one is picked and booked from the card,
+// its button going busy, then Booked. Plays once; the still is the payoff.
+//
+// What makes interactive cards read:
+// - A card is part of the answer, not a popup: it opens in the thread with
+// its own height (Collapse with a drop reveal) and the thread moves up as
+// one, the way the text above it did.
+// - An answered question stays on the page as the record: the chosen option
+// keeps its fill and a check, the others go quiet. Nothing is removed, so
+// nothing below jumps.
+// - The card's own button says what will happen with the choice ("Book for
+// £142"), then confirms on itself: busy, then Booked. No toast, no second
+// card. Every label sits in the button's one cell and crossfades, so
+// nothing in it moves sideways.
+// - Every option has a fixed size from the start; a pick changes color and
+// a mark, never a width.
+// - The earlier turn is placeholder: the eye lands on the cards.
+//
+// Real strings: the request, the assistant's two lines, the days, the three
+// flights, the button.
+
+const DAYS = ['Mon 13', 'Tue 14', 'Wed 15']
+const FLIGHTS = [
+ { dep: '07:10', arr: '09:55', line: 'Halcyon', price: 168 },
+ { dep: '08:40', arr: '11:25', line: 'Northwind', price: 142 },
+ { dep: '13:20', arr: '16:05', line: 'Aster', price: 155 },
+]
+
+type S = { day: number | null; phase: 'ask' | 'search' | 'flights'; pick: number | null; book: 'idle' | 'busy' | 'done' }
+
+const initial: S = { day: null, phase: 'ask', pick: null, book: 'idle' }
+const poster: S = { day: 1, phase: 'flights', pick: 1, book: 'done' }
+
+async function script(a: Act) {
+ await a.wait(900)
+ a.mark('day')
+ await a.click({ at: 'day-1', x: 0.82, y: 0.7 })
+ a.put({ day: 1 })
+ await a.wait(500)
+ a.mark('search')
+ a.put({ phase: 'search' })
+ await a.wait(1200)
+ a.mark('flights')
+ a.put({ phase: 'flights' })
+ await a.wait(1100)
+ a.mark('pick')
+ await a.click({ at: 'flight-1', x: 0.4, y: 0.5 })
+ a.put({ pick: 1 })
+ await a.wait(650)
+ a.mark('book')
+ await a.click({ at: 'book', x: 0.72, y: 0.7 })
+ a.put({ book: 'busy' })
+ await a.wait(1200)
+ a.mark('done')
+ a.put({ book: 'done' })
+ await a.move({ wide: { x: 612, y: 430 }, compact: { x: 330, y: 300 } })
+ await a.wait(2600)
+}
+
+export default function AiCards() {
+ return (
+
+ {(s) => {
+ const pick = s.pick === null ? null : FLIGHTS[s.pick]!
+ return (
+
+
+
+ {/* Earlier turns as placeholder: they fill the window
+ at the start and go up out of it as the cards open */}
+ {[[130, '70%', '48%'], [150, '76%', '52%'], [190, '84%', '64%']].map(([q, a1, a2], i) => (
+
+
+
+
+
+ ))}
+
Find me a flight to Lisbon next week
+
+ Which day do you want to fly out?
+
+ {DAYS.map((d, i) => (
+
+
+ {d}
+
+ ))}
+
+
+ {/* Searching and the flights share one slot in the thread, so
+ the gap above them is the thread's one gap */}
+
+
+ Searching flights
+
+
+
+ Three nonstops on Tuesday, all 2h 45m:
+
+ {FLIGHTS.map((f, i) => (
+
+
+
+ {f.dep} → {f.arr}
+ {f.line} · LHR to LIS
+
+ £{f.price}
+
+ ))}
+
+ Choose a flight
+ Book for £{pick?.price ?? 142}
+
+ Booked
+
+
+
+
+
+
+
+
+
+ )
+ }}
+
+ )
+}
diff --git a/skills/imported/benjitaylor/stageshow/examples/ai-chat/chat.css b/skills/imported/benjitaylor/stageshow/examples/ai-chat/chat.css
new file mode 100644
index 0000000..0f889a5
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/ai-chat/chat.css
@@ -0,0 +1,111 @@
+.ac .ss-stage {
+ overflow: hidden;
+ background: var(--ss-well);
+}
+
+/* Pinned top and sides, off the bottom just under the composer */
+.ac-win {
+ position: absolute;
+ top: 28px;
+ left: 56px;
+ width: 528px;
+ height: 420px;
+}
+
+.ac-body {
+ display: grid;
+ grid-template-rows: 1fr auto;
+ height: 100%;
+ /* The composer ends 28px above the stage's edge: the window runs off
+ below it, never at it */
+ padding: 16px 20px 56px;
+ background: var(--ss-surface);
+}
+
+/* The thread sits at the bottom, over the composer, the way chat apps
+ anchor it; older content goes up out of the window */
+.ac-thread {
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-end;
+ gap: 14px;
+ min-height: 0;
+ padding-bottom: 20px;
+ overflow: hidden;
+}
+
+.ac-turn--past {
+ display: grid;
+ gap: 8px;
+}
+
+.ac-past-q {
+ justify-self: end;
+}
+
+.ac-q {
+ display: flex;
+ justify-content: flex-end;
+}
+
+.ac-q .ss-text {
+ max-width: 76%;
+ white-space: normal;
+ text-wrap: pretty;
+ padding: 8px 14px;
+ border-radius: 16px;
+ background: var(--ss-ink-1);
+ color: var(--ss-text-strong);
+ font-size: 13px;
+}
+
+.ac-a {
+ display: grid;
+ gap: 10px;
+ opacity: 0;
+ transition: opacity 0.25s ease;
+}
+
+.ac-a[data-on] {
+ opacity: 1;
+}
+
+.ac-thinking {
+ color: var(--ss-text);
+ font-size: 13px;
+}
+
+.ac-stream {
+ color: var(--ss-text-strong);
+ font-size: 13px;
+ line-height: 1.55;
+}
+
+.ac-sources {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ opacity: 0;
+ translate: 0 4px;
+ transition: opacity 0.35s ease, translate 0.45s cubic-bezier(0.22, 1, 0.36, 1);
+}
+
+.ac-sources[data-on] {
+ opacity: 1;
+ translate: 0 0;
+}
+
+.ac-source {
+ padding: 4px 10px;
+ border-radius: 999px;
+ box-shadow: inset 0 0 0 1px var(--ss-line);
+ color: var(--ss-text);
+ font-size: 11px;
+}
+
+/* Narrow: a gutter each side, the window full width of the stage */
+.ac .ss-stage[data-layout='compact'] .ac-win {
+ left: 16px;
+ width: 326px;
+ height: 520px;
+}
diff --git a/skills/imported/benjitaylor/stageshow/examples/ai-chat/index.tsx b/skills/imported/benjitaylor/stageshow/examples/ai-chat/index.tsx
new file mode 100644
index 0000000..011c291
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/ai-chat/index.tsx
@@ -0,0 +1,134 @@
+import {
+ Bar,
+ Collapse,
+ Field,
+ Stage,
+ States,
+ Stream,
+ Text,
+ Window,
+ type Act,
+} from "../../kit";
+import "./chat.css";
+
+// An assistant answering in a desktop window. Beats: a question is typed into
+// the composer and sent; it lands as the user's message; a short Thinking
+// state; the answer streams in word by word; its sources arrive under it.
+// Plays once; the still is the payoff.
+//
+// What makes streaming read:
+// - Every word is laid out from the start (Stream), so the answer grows in
+// place: no line reflows, the window never jumps as text arrives.
+// - The answer streams fast enough to feel like a model, slow enough to
+// read: about 25 words a second, a beat after Thinking.
+// - The sent message carries what follows (Collapse with a drop reveal), so
+// the thread moves as one, and the composer empties as it sends.
+// - The earlier turn is placeholder: the eye lands on the last exchange.
+//
+// Real strings: the question, the answer, the two sources, the placeholder.
+
+const Q = "Why did checkout conversion drop last week?";
+const A =
+ "Conversion fell from 3.4% to 2.7% after the new address step went live on Tuesday. Mobile Safari accounts for most of the drop: the postcode field rejects spaces, so UK customers see an error and leave. Allowing spaces should recover most of it.";
+const WORDS = A.split(" ").length;
+
+type S = {
+ draft: string;
+ sent: boolean;
+ phase: "idle" | "thinking" | "answer";
+ words: number;
+ sources: boolean;
+};
+
+const initial: S = {
+ draft: "",
+ sent: false,
+ phase: "idle",
+ words: 0,
+ sources: false,
+};
+const poster: S = {
+ draft: "",
+ sent: true,
+ phase: "answer",
+ words: WORDS,
+ sources: true,
+};
+
+async function script(a: Act) {
+ await a.wait(700);
+ a.mark("type");
+ await a.type("draft", Q, { interval: 38 });
+ await a.wait(400);
+ a.mark("send");
+ a.put({ sent: true, draft: "" });
+ await a.wait(450);
+ a.put({ phase: "thinking" });
+ await a.wait(1100);
+ a.mark("stream");
+ a.put({ phase: "answer" });
+ for (let i = 1; i <= WORDS; i++) {
+ a.put({ words: i });
+ await a.wait(40);
+ }
+ await a.wait(300);
+ a.mark("sources");
+ a.put({ sources: true });
+ await a.wait(2600);
+}
+
+export default function AiChat() {
+ return (
+
+ {(s) => (
+
+
+
+
+
+
+
+
+
+
+ {Q}
+
+
+ {/* Thinking is one line; the answer opens to its own height as
+ Thinking closes, so the thread grows once, smoothly, and the
+ earlier turns move up with it. Before the send neither takes
+ any room */}
+
+ Thinking
+
+
+
+
+
+ Checkout funnel, last 14 days
+ Errors by field
+
+ }}
+
+}
diff --git a/skills/imported/benjitaylor/stageshow/examples/annotations/index.tsx b/skills/imported/benjitaylor/stageshow/examples/annotations/index.tsx
new file mode 100644
index 0000000..2b2780c
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/annotations/index.tsx
@@ -0,0 +1,281 @@
+import { useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
+import { Connector, Follow, Icon, Stage, States, type Act, type Rect } from '../../kit'
+import './annotations.css'
+import { CollectionStudy, DetailStudy } from './composed'
+
+const lakePhoto = new URL('../wikipedia/kawaguchiko.jpg', import.meta.url).href
+
+const studies = [
+ { id: 'collect', title: 'Usage into an invoice', note: 'Connect three usage sources to one invoice. Blue dots keep flowing after its first update; a matching shared delivery shows where those inputs meet.' },
+ { id: 'detail', title: 'A payout and its detail', note: 'Select one payout. A dotted link opens its settlement; a quieter solid leader explains arrival without replacing the product view.' },
+ { id: 'chart', title: 'A chart point', note: 'Select one result. The leader joins the measured point to its explanation, then leaves when the selection clears.' },
+ { id: 'measure', title: 'Selection guides', note: 'Resize a card by its handle. Its guides and dimensions belong to the same box, including on the return drag.' },
+ { id: 'flow', title: 'A request and its reply', note: 'Send a billing event. The solid request points out; the dashed acknowledgement returns only after the receipt arrives.' },
+ { id: 'branch', title: 'A shared processing graph', note: 'Store one asset, then send it along a shared trunk to three jobs. Each branch meets its destination cleanly; Clear jobs dismisses the result.' },
+ { id: 'stream', title: 'An ongoing event stream', note: 'Connect the feed once. A quiet route stays fixed while moving dashes show events arriving in the activity table; reduced motion keeps its direction visible.' },
+ { id: 'follow', title: 'Following a layer', note: 'Arrange a selected layer. Its annotation travels and resizes with the object, then fades intact when selection clears.' },
+] as const
+type Study = typeof studies[number]['id']
+const initialStudy = (): Study => {
+ const value = typeof window === 'undefined' ? null : new URL(window.location.href).searchParams.get('annotation')
+ return studies.find(study => study.id === value)?.id ?? 'collect'
+}
+const point = (r: Rect) => ({ x: r.x + r.width / 2, y: r.y + r.height / 2 })
+
+type ChartState = { selected: boolean }
+async function chartScript(a: Act) {
+ a.mark('start'); await a.wait(650)
+ await a.move('point'); await a.click(); a.mark('callout')
+ await a.set({ selected: true }); await a.move('chart-rest', { duration: 360 }); await a.wait(2440)
+ await a.click(); a.mark('clear')
+ await a.set({ selected: false }); await a.wait(700); a.mark('done')
+ await a.wait(700)
+}
+function ChartStudy({ width }: { width: number }) {
+ return
+ {(s, rect) => {
+ const p = rect('point'), note = rect('chart-note')
+ return <>
+
+ API requestsLast 7 days
+
+ {/* Object, leader and note share Follow's changing coordinate space.
+ Reading the destination rect for an independent line would detach it
+ while Follow is still traveling toward that destination. */}
+
+
+}
diff --git a/skills/imported/benjitaylor/stageshow/examples/backgrounds/backgrounds.css b/skills/imported/benjitaylor/stageshow/examples/backgrounds/backgrounds.css
new file mode 100644
index 0000000..f9504b6
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/backgrounds/backgrounds.css
@@ -0,0 +1,81 @@
+.bg-gallery {
+ container-type: inline-size;
+ color: #242831;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+ -webkit-font-smoothing: antialiased;
+
+ & .bg-intro { max-width: 480px; margin: 0 0 28px; }
+ & .bg-intro h1 { margin: 0 0 10px; font-size: 26px; line-height: 32px; font-weight: 600; letter-spacing: -.035em; }
+ & .bg-intro p { margin: 0; color: #72747c; font-size: 14px; line-height: 21px; text-wrap: pretty; }
+ & .bg-grid { display: grid; grid-template-columns: minmax(0, 1fr); gap: 30px 20px; }
+ @container (min-width: 640px) { & .bg-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
+ & .bg-study { min-width: 0; }
+ & .bg-picture { overflow: hidden; border-radius: 14px; }
+ & .bg-study h2 { margin: 13px 0 5px; font-size: 14px; line-height: 20px; font-weight: 600; letter-spacing: -.015em; }
+ & .bg-study > p { max-width: 350px; margin: 0; font-size: 12px; line-height: 18px; color: #72747c; text-wrap: pretty; }
+ & .ss-stage { background: transparent; }
+ & .bg-ground { position: absolute; inset: 0; display: grid; place-items: center; overflow: hidden; }
+ & .bg-ground--tint { background: #dfe2f4; }
+ & .bg-ground--field { background: linear-gradient(110deg, #ebaa91 0 32%, #d9e5e8 32% 100%); }
+ & .bg-ground--light { background: radial-gradient(ellipse at 0% 0%, #b6cebc, transparent 62%), radial-gradient(ellipse at 100% 100%, #e8b9a3, transparent 65%), #f5efdb; }
+ & .bg-ground--grid { background-color: #e6eef5; background-image: linear-gradient(#4a789915 1px, transparent 1px), linear-gradient(90deg, #4a789915 1px, transparent 1px); background-size: 24px 24px; background-position: center; }
+ & .bg-ground--architecture { background: #e9e6dc; }
+ & .bg-lines { position: absolute; inset: 0; fill: none; stroke: #919c8c; stroke-width: .8; opacity: .5; }
+ & .bg-ground--dark { background: radial-gradient(ellipse at 20% 0%, #454b50, transparent 75%), #191d21; }
+ & .bg-ground--paper { background: repeating-linear-gradient(0deg, #a9936506 0 1px, transparent 1px 4px), repeating-linear-gradient(92deg, #ffffff18 0 1px, transparent 1px 7px), #eee6d3; }
+ & .bg-ground--graphic { background: #d9e5e8; }
+ & .bg-geometry { position: absolute; inset: 0; overflow: hidden; }
+ & .bg-geometry > span { position: absolute; width: 260px; height: 260px; right: -100px; top: -155px; border: 36px solid #263a42; border-radius: 50%; box-sizing: border-box; }
+ & .bg-geometry > i { position: absolute; width: 260px; height: 260px; left: -125px; bottom: -170px; border-radius: 50%; background: #d1a23f; }
+
+ /* A dot grid that fades toward the edges, so the card sits in a clear middle */
+ & .bg-ground--dots { background: #f4f4f2; }
+ & .bg-ground--dots::before { content: ''; position: absolute; inset: 0; background-image: radial-gradient(circle, #9a9a94 1px, transparent 1.4px); background-size: 16px 16px; background-position: center; mask-image: radial-gradient(ellipse 75% 80% at 50% 50%, #000 30%, transparent 85%); opacity: .7; }
+ & .bg-ground--halftone { background: #f2ede4; }
+ & .bg-halftone { position: absolute; inset: 0; fill: #d4673f; }
+ & .bg-ground--blueprint { background-color: #1d3f72; background-image: linear-gradient(#ffffff2e 1px, transparent 1px), linear-gradient(90deg, #ffffff2e 1px, transparent 1px), linear-gradient(#ffffff12 1px, transparent 1px), linear-gradient(90deg, #ffffff12 1px, transparent 1px); background-size: 80px 80px, 80px 80px, 16px 16px, 16px 16px; background-position: center; }
+ & .bg-ground--grain { background: linear-gradient(160deg, #f6c7a8 0%, #d9a3c4 55%, #a9a7dc 100%); }
+ & .bg-grain { position: absolute; inset: 0; opacity: .7; mix-blend-mode: soft-light; }
+ & .bg-ground--conic { background: conic-gradient(from 160deg at 104% -12%, #ffcf70, #ff7f6e, #c86fd8, #6f8ff0, #6fd6c8, #ffcf70); }
+ & .bg-ground--stripes { background: repeating-linear-gradient(135deg, #1f2a3a10 0 1px, transparent 1px 7px), #eef0ec; }
+
+ & .bg-ground--diagonal { background: linear-gradient(130deg, #6253e6 0%, #aa51d2 32%, #e96d94 68%, #ffa36e 100%); }
+ & .bg-ground--radial { background: radial-gradient(ellipse 110% 130% at 4% 7%, #fff2a0 0%, #ffd162 24%, #ff9557 48%, #ee587a 74%, #b83f78 100%); }
+ & .bg-ground--mesh { background: radial-gradient(ellipse 85% 90% at 3% 10%, #b4efb4 0%, #7ad8c8 32%, transparent 74%), radial-gradient(ellipse 90% 100% at 100% 90%, #bba0ed 0%, #9981df 38%, transparent 76%), linear-gradient(135deg, #78cfdc, #5c8ed3 58%, #7270c3); }
+ & .bg-ground--night { background: radial-gradient(ellipse 95% 100% at 0% 100%, #187f83 0%, #173d57 42%, transparent 78%), radial-gradient(ellipse 95% 110% at 100% 0%, #a84179 0%, #532858 40%, transparent 78%), #171a34; }
+ & .bg-ground--bands { background: linear-gradient(122deg, #1c315f 0% 47%, #3d74bd 47% 82%, #cbdfee 82% 100%); }
+ & .bg-ground--spectrum { background: linear-gradient(112deg, #17a99c 0%, #369bbd 20%, #6268cf 42%, #aa60c6 63%, #eb7f9c 83%, #f5af69 100%); }
+
+ /* Above any texture the ground draws */
+ & .bg-digest { position: relative; z-index: 1; width: 232px; box-sizing: border-box; padding: 16px; border-radius: 12px; color: #323840; background: #fff; box-shadow: 0 1px 1px #1c25320a, 0 8px 24px -12px #24344235; }
+ & .bg-digest-heading { display: flex; align-items: center; gap: 10px; }
+ & .bg-digest-icon { display: grid; place-items: center; flex: none; width: 34px; height: 34px; border-radius: 9px; color: #687086; background: #f0f2f7; }
+ & .bg-digest-heading > div { display: flex; flex-direction: column; gap: 4px; }
+ & .bg-digest-heading strong { font-size: 14px; line-height: 17px; font-weight: 600; letter-spacing: -.025em; }
+ & .bg-digest-heading > div > span { font-size: 10px; line-height: 13px; color: #787f89; }
+ & .bg-digest-recipient { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 19px; padding-bottom: 14px; border-bottom: 1px solid #edf0f2; font-size: 11px; line-height: 16px; }
+ & .bg-digest-recipient > span { color: #7c838c; }
+ & .bg-digest-recipient > strong { display: flex; align-items: center; gap: 6px; font-weight: 500; }
+ & .bg-digest-count { display: inline-grid; place-items: center; width: 17px; height: 17px; border-radius: 5px; font-size: 10px; color: #6b7280; background: #f1f3f5; }
+ & .bg-digest-footer { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 13px; font-size: 10px; line-height: 14px; }
+ & .bg-digest-footer > span { display: flex; align-items: center; gap: 5px; }
+ & .bg-digest-footer > span > i { width: 5px; height: 5px; border-radius: 50%; background: #42a47d; }
+ & .bg-digest-button { padding: 5px 7px; border-radius: 5px; background: #f2f4f6; color: #525d6b; font-weight: 500; }
+ & .bg-ground--dark .bg-digest { background: linear-gradient(145deg, #3c4248, #30353b); color: #f0f2f3; box-shadow: inset 0 1px 0 #ffffff26, inset 0 0 0 1px #ffffff0b, 0 16px 28px -14px #000b; }
+ & .bg-ground--dark .bg-digest-icon { color: #c6ced8; background: #ffffff0d; }
+ & .bg-ground--dark .bg-digest-heading > div > span,
+ & .bg-ground--dark .bg-digest-recipient > span { color: #b0b8c1; }
+ & .bg-ground--dark .bg-digest-recipient { border-color: #ffffff13; }
+ & .bg-ground--dark .bg-digest-count,
+ & .bg-ground--dark .bg-digest-button { color: #cbd2d9; background: #ffffff0c; }
+ & .bg-ground--paper .bg-digest { background: #fffdf8; box-shadow: 0 1px 2px #6e502b17, 0 12px 24px -15px #6e502b50; }
+ & .bg-ground--night .bg-digest { box-shadow: 0 1px 0 #ffffff80, 0 18px 32px -14px #080e2ba6; }
+ & .bg-ground--bands .bg-digest { background: #f1f6ffb8; backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); color: #243348; box-shadow: inset 0 1px 0 #ffffffb3, inset 0 0 0 1px #ffffff4d, 0 12px 28px -14px #10254c66; }
+ & .bg-ground--bands .bg-digest-icon,
+ & .bg-ground--bands .bg-digest-button,
+ & .bg-ground--bands .bg-digest-count { color: #44566d; background: #ffffff4d; }
+ & .bg-ground--bands .bg-digest-heading > div > span,
+ & .bg-ground--bands .bg-digest-recipient > span { color: #3b5068; }
+ & .bg-ground--bands .bg-digest-recipient { border-color: #344b6a26; }
+ & .bg-ground--blueprint .bg-digest { box-shadow: 0 1px 0 #ffffff40, 0 18px 32px -14px #04122ca6; }
+}
diff --git a/skills/imported/benjitaylor/stageshow/examples/backgrounds/index.tsx b/skills/imported/benjitaylor/stageshow/examples/backgrounds/index.tsx
new file mode 100644
index 0000000..caaed30
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/backgrounds/index.tsx
@@ -0,0 +1,85 @@
+import { useLayoutEffect, useRef, useState } from 'react'
+import { Icon, Stage } from '../../kit'
+import './backgrounds.css'
+
+// The object stays constant so the ground's contribution is easy to see.
+// These are authored examples, not a fixed set of themes or presets.
+const studies = [
+ { id: 'diagonal', title: 'Diagonal gradient', note: 'Violet moves through pink into coral. A white surface keeps the content quiet.' },
+ { id: 'radial', title: 'Radial light', note: 'A broad yellow light meets orange and rose at the edges, without a glow around the card.' },
+ { id: 'mesh', title: 'Blended color fields', note: 'Mint, blue and violet meet in large fields. Their lightness changes gradually behind the UI.' },
+ { id: 'night', title: 'Color on dark', note: 'Teal and magenta light sit at opposite edges. The clear foreground holds its contrast.' },
+ { id: 'bands', title: 'Bands and glass', note: 'Broad ink-blue and cobalt bands leave a narrow pale edge. Their tint stays visible through the glass.' },
+ { id: 'spectrum', title: 'Multicolor sweep', note: 'A wide sweep connects cool and warm hues. The card supplies one stable reading area.' },
+ { id: 'tint', title: 'Tint', note: 'Lavender gives a white surface enough separation without a heavy shadow.' },
+ { id: 'field', title: 'Two color fields', note: 'A warm band and a cool field meet behind the card, away from its content.' },
+ { id: 'dots', title: 'Dot grid', note: 'A fine dot grid, fading out toward the edges, gives a canvas feel. The dots stay lighter than any line in the card.' },
+ { id: 'halftone', title: 'Halftone', note: 'Dots grow toward one corner and vanish toward the card, so the texture frames the UI rather than sitting under it.' },
+ { id: 'blueprint', title: 'Blueprint', note: 'Major and minor rules on deep blue. Two weights of line give scale; the white card is the only solid thing.' },
+ { id: 'grain', title: 'Grain', note: 'A soft two-color gradient with fine grain over it, so the color reads as a material, not a screen fill.' },
+ { id: 'conic', title: 'Conic sweep', note: 'Color turns around a point off the card’s corner. The sweep gives direction without a hard band.' },
+ { id: 'stripes', title: 'Fine stripes', note: 'Hairline diagonals on a pale ground: texture you feel more than see, quieter than the card’s own divider.' },
+ { id: 'light', title: 'Soft light', note: 'Sage, peach and cream share a light value, with a calm center for the UI.' },
+ { id: 'grid', title: 'Grid', note: 'Fine blue rules establish scale. Their contrast stays below the product’s.' },
+ { id: 'architecture', title: 'Architectural lines', note: 'A few precise edges give the scene direction without filling every gap.' },
+ { id: 'dark', title: 'Dark material', note: 'A lifted graphite surface separates from the ground through value and edge light.' },
+ { id: 'paper', title: 'Warm paper', note: 'Low contrast fibers and a warm shadow support a quiet, tangible surface.' },
+ { id: 'graphic', title: 'Graphic geometry', note: 'Ink, ochre and pale blue frame the object with deliberate, substantial crops.' },
+] as const
+
+function Digest() {
+ return
+
Weekly digestEvery Friday at 09:00
+
RecipientsDesign team 6
+
ScheduledPreview
+
+}
+
+// A real halftone: one dot per cell on a 10px grid, each sized by its distance
+// from the top right corner, down to nothing before it reaches the card
+function Halftone() {
+ const dots: { x: number; y: number; r: number }[] = []
+ for (let y = 5; y < 252; y += 10) for (let x = 5; x < 400; x += 10) {
+ const d = Math.hypot(400 - x, y) / 260
+ const r = 3.4 * (1 - d) ** 1.4
+ if (r > 0.35) dots.push({ x, y, r })
+ }
+ return
+}
+
+function Study({ study }: { study: typeof studies[number] }) {
+ const ref = useRef(null)
+ const [width, setWidth] = useState(288)
+ useLayoutEffect(() => {
+ const node = ref.current
+ if (!node) return
+ const measure = () => setWidth(node.clientWidth)
+ measure()
+ const observer = new ResizeObserver(measure)
+ observer.observe(node)
+ return () => observer.disconnect()
+ }, [])
+ return
+
+
+
+
+
+
+ )
+ }}
+
+ )
+}
diff --git a/skills/imported/benjitaylor/stageshow/examples/chart-scrub/index.tsx b/skills/imported/benjitaylor/stageshow/examples/chart-scrub/index.tsx
new file mode 100644
index 0000000..49f0e41
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/chart-scrub/index.tsx
@@ -0,0 +1,133 @@
+import { useRef } from 'react'
+import { Chart, Roll, Stage, Window, chartPoints, type Act } from '../../kit'
+import './scrub.css'
+
+// Scrubbing a chart: the pointer runs along a revenue line and the headline
+// reads out each day it passes. Beats: the pointer comes onto the chart, a
+// hairline and a dot pick up the nearest day, the headline's label and value
+// change to that day, the pointer runs right to the peak and rests; then it
+// leaves and the headline returns to the total. Loops.
+//
+// What makes a scrub read as one gesture:
+// - The headline is the readout. No tooltip box over the line: the value
+// that is already the biggest thing on the card changes, so the eye stays
+// where it already was.
+// - Only the digits that change roll, and they roll quickly (UI feedback,
+// not a story beat): $4,920 to $5,240 holds the "$" and moves the rest.
+// - One stroke. The pointer runs the line in a single eased move (a path
+// along the points, not hop after hop), and the day under it updates as it
+// goes; a run of short moves reads as stuttering.
+// - The readout is discrete, the motion is not: the value is always a real
+// day (the nearest one), while the hairline sits at the pointer and the
+// dot rides the drawn curve under it, read off the line's own path. A dot
+// snapping day to day trails the hand.
+// - Over a plot the pointer is a crosshair, as charting apps show it.
+// - The day under the pointer is the only thing that's emphasized. The line
+// doesn't dim; the axis doesn't move.
+//
+// Real strings: the card's title, the range, the day names, every value.
+
+type S = { day: number | null; x: number | null }
+
+const DAYS = [3240, 3610, 3480, 4120, 3950, 4630, 4410, 5240, 4920, 5810, 5530, 6320, 6050, 6890]
+const NAMES = ['Oct 15', 'Oct 16', 'Oct 17', 'Oct 18', 'Oct 19', 'Oct 20', 'Oct 21', 'Oct 22', 'Oct 23', 'Oct 24', 'Oct 25', 'Oct 26', 'Oct 27', 'Oct 28']
+const TOTAL = DAYS.reduce((n, d) => n + d, 0)
+const money = (n: number) => `$${n.toLocaleString('en-US')}`
+
+const W = { wide: 492, compact: 280 }
+const H = 150
+
+async function script(a: Act) {
+ await a.wait(900)
+ const box = a.rect('plot')
+ const pts = chartPoints({ data: DAYS, width: box.width, height: H, dot: false })
+ const at = (i: number) => ({ x: box.x + pts[i]!.x, y: box.y + pts[i]!.y + 18 })
+ // Onto the line a third of the way along, then a steady run to the peak
+ a.mark('hover')
+ await a.move(at(4), { duration: 700 })
+ a.put({ day: 4, x: pts[4]!.x })
+ await a.wait(700)
+ a.mark('scrub')
+ // One eased stroke, straight as a hand runs, from day 4 to the last. The
+ // hairline follows the pointer; the readout is the day nearest it
+ await a.move(at(DAYS.length - 1), {
+ duration: 1500,
+ path: (e, from, to) => ({ x: from.x + (to.x - from.x) * e, y: from.y + (to.y - from.y) * e }),
+ onMove: (pt) => {
+ const x = Math.max(0, Math.min(box.width, pt.x - box.x))
+ let near = 0
+ for (let i = 1; i < pts.length; i++) if (Math.abs(pts[i]!.x - x) < Math.abs(pts[near]!.x - x)) near = i
+ a.put({ day: near, x })
+ },
+ })
+ await a.wait(1900)
+ // Off the chart: the headline returns to the total
+ a.mark('leave')
+ // The readout lets go as the pointer crosses the plot's edge; the hairline
+ // and dot fade where they were, rather than jumping as they go
+ let gone = false
+ await a.move({ x: box.x + box.width + 40, y: box.y + box.height + 60 }, {
+ duration: 600,
+ onMove: (pt) => {
+ if (!gone && (pt.x > box.x + box.width || pt.y > box.y + box.height)) {
+ gone = true
+ a.put({ day: null })
+ }
+ },
+ })
+ await a.wait(1600)
+}
+
+// The y of the drawn line at x: a short search along the path's length
+function yOn(path: SVGPathElement | null | undefined, x: number) {
+ if (!path) return null
+ let lo = 0
+ let hi = path.getTotalLength()
+ for (let i = 0; i < 24; i++) {
+ const mid = (lo + hi) / 2
+ if (path.getPointAtLength(mid).x < x) lo = mid
+ else hi = mid
+ }
+ return path.getPointAtLength(hi).y
+}
+
+export default function ChartScrub() {
+ const plot = useRef(null)
+ return (
+ width={640} height={400} compact={{ below: 640, width: 358, height: 400 }} initial={{ day: null, x: null }} poster={{ day: 13, x: null }} script={script} cursor={{ at: { x: 560, y: 360 } }} className="cs">
+ {(s, _rect, layout) => {
+ const w = layout === 'compact' ? W.compact : W.wide
+ const pts = chartPoints({ data: DAYS, width: w, height: H, dot: false })
+ // The hairline at the pointer (or on the day, for the still); the dot
+ // on the curve beneath it
+ const hx = s.x ?? (s.day === null ? null : pts[s.day]!.x)
+ const on = s.day !== null
+ const hy = hx === null ? null : (yOn(plot.current?.querySelector('.ss-chart__line'), hx) ?? pts[s.day ?? 0]!.y)
+ const p = hx === null || hy === null ? null : { x: hx, y: hy }
+ return (
+
+
+}
diff --git a/skills/imported/benjitaylor/stageshow/examples/close-up/close.css b/skills/imported/benjitaylor/stageshow/examples/close-up/close.css
new file mode 100644
index 0000000..f306bf5
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/close-up/close.css
@@ -0,0 +1,51 @@
+.cu .ss-stage {
+ overflow: hidden;
+ background: var(--ss-well);
+}
+
+/* The whole window at 1.4x, inset 18px top and sides, running off the
+ bottom: the subject row is whole across, label and toggle */
+.cu-zoom {
+ position: absolute;
+ top: 18px;
+ left: 18px;
+ transform: scale(1.4);
+ transform-origin: 0 0;
+}
+
+.cu-win {
+ width: 260px;
+ height: 240px;
+}
+
+.cu-list {
+ padding: 6px 16px;
+}
+
+.cu-row {
+ padding: 12px 0;
+ border-top: 1px solid var(--ss-line);
+ border-radius: 0;
+}
+
+.cu-row:first-child {
+ border-top: 0;
+}
+
+.cu-title {
+ color: var(--ss-text-strong);
+ font-size: 13px;
+ font-weight: 500;
+}
+
+.cu-sub {
+ color: var(--ss-text);
+ font-size: 11px;
+}
+
+/* Narrow: a touch less zoom, so the row stays whole across with the same
+ inset each side */
+.cu .ss-stage[data-layout='compact'] .cu-zoom {
+ left: 12px;
+ transform: scale(1.3);
+}
diff --git a/skills/imported/benjitaylor/stageshow/examples/close-up/index.tsx b/skills/imported/benjitaylor/stageshow/examples/close-up/index.tsx
new file mode 100644
index 0000000..016c735
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/close-up/index.tsx
@@ -0,0 +1,76 @@
+import { Bar, Row, Stage, States, Text, Toggle, Window, type Act } from '../../kit'
+import './close.css'
+
+// A close-up: one setting, drawn at 1.4 times true size and cropped hard, for
+// a feature card about a single control. Beats: the cursor clicks the toggle,
+// it turns on, the line under it changes to say what that did. Plays once;
+// the still is the payoff.
+//
+// What makes a close-up read as a close-up, not a small screen:
+// - The whole window is scaled, chrome and all, not the control alone: the
+// traffic lights and the title bar at the top left say this is a crop of a
+// real window. A bare toggle at twice its size reads as an icon.
+// - The window runs off the bottom only. The subject row is whole across,
+// label and toggle: a close-up that crops its own control has lost the
+// point. The row above and below it are context.
+// - The cursor is the large style, so it matches the zoom; a true-size arrow
+// on a 1.4x window reads as a speck.
+// - One real row. Its neighbors keep their anatomy as placeholder lines and
+// toggles that stay off: an accent on filler would be the heaviest mark in
+// the frame on a row with no words.
+//
+// Real strings: the window title, the setting, its two descriptions.
+
+type S = { on: boolean }
+
+const initial: S = { on: false }
+const poster: S = { on: true }
+
+async function script(a: Act) {
+ await a.wait(900)
+ a.mark('click')
+ await a.click({ at: 'toggle', x: 0.5, y: 0.5 })
+ a.put({ on: true })
+ // Off the knob, so the payoff isn't under the pointer
+ await a.wait(500)
+ await a.move({ at: 'toggle', x: 0.5, y: 2.6 })
+ await a.wait(2100)
+}
+
+export default function CloseUp() {
+ return (
+
+ {(s) => (
+
+
+
+ } subtitle={}>
+
+
+ Two-factor authentication}
+ subtitle={
+ Ask for a code at sign in,
+ on: On for every sign in,
+ }}
+ />
+ }
+ >
+
+
+ } subtitle={}>
+
+
+
+
+
+ )}
+
+ )
+}
diff --git a/skills/imported/benjitaylor/stageshow/examples/controls/controls.css b/skills/imported/benjitaylor/stageshow/examples/controls/controls.css
new file mode 100644
index 0000000..393d893
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/controls/controls.css
@@ -0,0 +1,155 @@
+/* A specimen sheet: an even grid of tiles, one part each. Not a composition to copy. */
+.ct .ss-stage {
+ background: var(--ss-well);
+}
+
+.ct-grid {
+ position: absolute;
+ inset: 24px;
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ grid-template-rows: repeat(2, 1fr);
+ gap: 16px;
+}
+
+.ct-tile {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ min-height: 0;
+ padding: 16px;
+ border-radius: 14px;
+ background: var(--ss-raised);
+ box-shadow: var(--ss-shadow-card), 0 0 0 1px var(--ss-line-faint);
+}
+
+.ct-name {
+ font: 500 10.5px/14px ui-monospace, 'SF Mono', monospace;
+ color: var(--ss-text-faint);
+}
+
+.ct-body {
+ display: grid;
+ flex: 1;
+ align-content: center;
+ gap: 14px;
+ min-height: 0;
+}
+
+.ct-switch {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ font-size: 12.5px;
+ color: var(--ss-text-strong);
+}
+
+.ct-code {
+ margin: 0 -16px;
+}
+
+.ct-cols {
+ display: grid;
+ /* Fixed equal columns: sized by content, they'd shift as the card changes column */
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+}
+
+.ct-col {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding: 6px;
+ border-radius: 10px;
+ background: var(--ss-ink-1);
+}
+
+.ct-card {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: flex-start;
+ gap: 8px;
+ height: 50px;
+ padding: 10px;
+ border-radius: 8px;
+ background: var(--ss-surface);
+ box-shadow: var(--ss-shadow-card), 0 0 0 1px var(--ss-line-faint);
+}
+
+.ct-card .ss-badge {
+ height: 14px;
+ padding: 0 5px;
+ font-size: 9px;
+}
+
+.ct-carry {
+ position: absolute;
+ z-index: 10;
+ isolation: isolate;
+}
+
+/* The whole card tilts and lifts, contents and all: a surface that tilts
+ under level text reads as a bug. It keeps its own layer from the start
+ (will-change), so its text never hops onto the pixel grid as it lands */
+.ct-carry .ct-card {
+ background: var(--ss-surface);
+ box-shadow: var(--ss-shadow-float), 0 0 0 1px var(--ss-line-faint);
+ transform: rotate(-2deg);
+ transform-origin: 72% 24%;
+ will-change: transform;
+ transition: transform 0.2s ease-out, box-shadow 0.2s ease-out;
+}
+
+.ct-carry[data-settle] .ct-card {
+ box-shadow: var(--ss-shadow-card), 0 0 0 1px var(--ss-line-faint);
+ transform: none;
+}
+
+.ct-list {
+ height: 140px;
+}
+
+.ct-row {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ height: 28px;
+ font-size: 12.5px;
+ color: var(--ss-text-strong);
+}
+
+.ct-rest {
+ position: absolute;
+ right: 40px;
+ bottom: 40px;
+}
+
+.ct-fade {
+ transition: opacity 0.2s ease;
+}
+
+.ct-fade[data-out] {
+ opacity: 0;
+}
+
+/* The card returning to A fades in as its slot opens, not after */
+.ct-fade-in {
+ animation: ct-in 0.45s ease-out both;
+}
+
+@keyframes ct-in {
+ from { opacity: 0; }
+}
+
+/* Keep specimen labels at their authored size on a narrow host. */
+.ct .ss-stage[data-layout="compact"] .ct-grid {
+ inset: 16px;
+ grid-template-columns: minmax(0, 1fr);
+ grid-template-rows: repeat(6, 200px);
+}
+
+.ct .ss-stage[data-layout="compact"] .ct-code-focus {
+ width: max-content;
+ max-width: 100%;
+}
diff --git a/skills/imported/benjitaylor/stageshow/examples/controls/index.tsx b/skills/imported/benjitaylor/stageshow/examples/controls/index.tsx
new file mode 100644
index 0000000..1fd19af
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/controls/index.tsx
@@ -0,0 +1,201 @@
+import { Badge, Bar, Camera, Chart, Code, Field, Segmented, Scroll, Slot, Stage, Tabs, Toggle, type Act, type Rect } from '../../kit'
+import type { ReactNode } from 'react'
+import './controls.css'
+
+// A specimen sheet for the controls, Code, Chart, Slot, scrolling and the
+// camera, one tile each. A catalog, not a composition: for framing, copy the
+// gold examples (hero-demo, static-card, story, payout-flow).
+
+const DATA = [12, 18, 15, 24, 22, 31, 28, 36]
+const MORE = [12, 18, 15, 24, 22, 31, 34, 44]
+const DIFF = [
+ { text: 'function load(id) {' },
+ { text: ' let r = fetch(id)', kind: 'del' as const },
+ { text: ' let r = cache.get(id)', kind: 'add' as const },
+ { text: ' return r.json()' },
+ { text: '}' },
+]
+const ROWS = ['Acme', 'Globex', 'Initech', 'Umbrella', 'Hooli', 'Vandelay', 'Stark', 'Wayne']
+
+type S = {
+ email: string
+ focus: boolean
+ seg: number
+ on: boolean
+ tab: number
+ lines: number
+ data: number[]
+ carry: { x: number; y: number; w: number; settle?: boolean } | null
+ moved: boolean
+ leaving: boolean
+ focusTile: Rect | null
+}
+
+const initial: S = { email: '', focus: false, seg: 0, on: false, tab: 0, lines: 0, data: DATA, carry: null, moved: false, leaving: false, focusTile: null }
+const POSTER: S = { ...initial, email: 'ada@acme.dev', seg: 1, on: true, tab: 1, lines: 5, data: MORE, moved: true }
+
+async function script(a: Act) {
+ await a.wait(500)
+ a.mark('field')
+ await a.click('email')
+ await a.set({ focus: true })
+ await a.type('email', 'ada@acme.dev')
+ await a.wait(300)
+
+ a.mark('controls')
+ // Pressed beside the word, never on it
+ await a.click({ at: 'seg-1', x: 0.84, y: 0.7 })
+ await a.set({ seg: 1, focus: false })
+ await a.click('toggle')
+ await a.set({ on: true })
+ await a.click('tab-1')
+ await a.set({ tab: 1 })
+
+ a.mark('code')
+ a.move('rest')
+ for (let i = 1; i <= DIFF.length; i++) {
+ a.put({ lines: i })
+ await a.wait(140)
+ }
+ await a.set({ data: MORE })
+ await a.wait(600)
+
+ a.mark('slot')
+ // Picked up off center, as a hand does; the grab offset comes from the
+ // same fractions, so the card never jumps under the hand
+ const GRAB = { x: 0.72, y: 0.24 }
+ await a.move({ at: 'card', ...GRAB })
+ const card = a.rect('card')
+ // Aim at where the card will sit once the slot has opened (the column's
+ // first place), not at the slot itself, which is still opening mid-drag
+ const col = a.rect('col-b')
+ const end = { x: col.x + 6, y: col.y + 6 }
+ const grab = { x: card.width * GRAB.x, y: card.height * GRAB.y }
+ await a.drag({ x: end.x + grab.x, y: end.y + grab.y }, (at) => {
+ a.put({ carry: { x: at.x - grab.x, y: at.y - grab.y, w: card.width } })
+ })
+ // Settle the tilt and lift in place, then swap in the real card, unmoved
+ await a.set({ carry: { ...end, w: card.width, settle: true } })
+ await a.wait(220)
+ await a.set({ carry: null, moved: true })
+ await a.wait(400)
+
+ a.mark('scroll')
+ await a.scroll('list', 'row-5')
+ await a.wait(500)
+
+ a.mark('camera')
+ await a.move('rest')
+ await a.set({ focusTile: a.rect(a.layout === 'compact' ? 'code-detail' : 't-code', { layout: true }) })
+ await a.wait(2200)
+ await a.set({ focusTile: null })
+ await a.wait(1400)
+
+ a.mark('exit')
+ // Undo on screen, never at the seam: the list scrolls back, the card fades
+ // out of B as its slot closes and back into A as that one opens
+ await a.scroll('list', 0)
+ await a.set({ leaving: true })
+ await a.wait(220)
+ await a.set({ ...initial, data: DATA })
+ await a.move('rest')
+ await a.wait(900)
+}
+
+function Tile({ name, target, children }: { name: string; target?: string; children: ReactNode }) {
+ return (
+
+ )}
+
+
+ >
+ )}
+
+ )
+}
diff --git a/skills/imported/benjitaylor/stageshow/examples/corner-chip/chip.css b/skills/imported/benjitaylor/stageshow/examples/corner-chip/chip.css
new file mode 100644
index 0000000..90aadd2
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/corner-chip/chip.css
@@ -0,0 +1,60 @@
+.cc .ss-stage {
+ overflow: hidden;
+ background: var(--ss-well);
+}
+
+/* Pinned top-left, off the bottom; its top-right corner at (412, 56) */
+.cc-win {
+ position: absolute;
+ top: 56px;
+ left: 40px;
+ width: 372px;
+ height: 340px;
+}
+
+.cc-list {
+ display: grid;
+ padding: 8px;
+ background: var(--ss-surface);
+ height: 100%;
+ align-content: start;
+}
+
+/* The chip: its center on the window's corner, a level above */
+.cc-chip {
+ position: absolute;
+ top: 56px;
+ left: 412px;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 12px 6px 6px;
+ border-radius: 999px;
+ background: var(--ss-raised, var(--ss-surface));
+ box-shadow: var(--ss-shadow-satellite);
+ opacity: 0;
+ translate: -50% calc(-50% + 8px);
+ transition: opacity 0.4s ease, translate 0.8s cubic-bezier(0.22, 1, 0.36, 1);
+}
+
+.cc-chip[data-on] {
+ opacity: 1;
+ translate: -50% -50%;
+}
+
+.cc-chip__text {
+ color: var(--ss-text-strong);
+ font-size: 12px;
+ font-weight: 500;
+ white-space: nowrap;
+}
+
+/* Narrow: the window narrower, its corner pulled in so the chip stays whole */
+.cc .ss-stage[data-layout='compact'] .cc-win {
+ left: 16px;
+ width: 240px;
+}
+
+.cc .ss-stage[data-layout='compact'] .cc-chip {
+ left: 256px;
+}
diff --git a/skills/imported/benjitaylor/stageshow/examples/corner-chip/index.tsx b/skills/imported/benjitaylor/stageshow/examples/corner-chip/index.tsx
new file mode 100644
index 0000000..a625e6a
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/corner-chip/index.tsx
@@ -0,0 +1,55 @@
+import { Avatar, Bar, Facepile, Row, Stage, Text, Window, type Act } from '../../kit'
+import './chip.css'
+
+// A satellite chip pinned to a corner: a support inbox with "3 agents
+// online" hanging off its top-right corner. Beats: the chip rises into its
+// corner after the window has settled. Plays once; the still is the payoff.
+//
+// What makes a corner chip read as pinned, not floating:
+// - Its center sits on the window's corner, or within a few px of it: about
+// half outside, half over the chrome. Over the window's middle it covers
+// content; well clear of the corner it floats off.
+// - It sits over chrome or filler only, never over the window's words.
+// - It is small and says one thing, with one visual (the agents' faces), on
+// a raised surface a level above the window.
+// - The window is pinned top-left and runs off the bottom; the chip's corner
+// keeps a gutter to the stage's right edge so the chip is whole.
+//
+// Real strings: the chip, the inbox title and the top conversation.
+
+type S = { on: boolean }
+
+async function script(a: Act) {
+ await a.wait(600)
+ a.put({ on: true })
+ await a.wait(2600)
+}
+
+export default function CornerChip() {
+ return (
+
+ {(s) => (
+ <>
+
+
+ }
+ title={Priya Nair}
+ meta={2m}
+ subtitle={Can I move my plan to annual billing?}
+ />
+ {[180, 150, 200, 140].map((w, i) => (
+ } title={} meta={} subtitle={} />
+ ))}
+
+
+
+
+ 3 agents online
+
+ >
+ )}
+
+ )
+}
diff --git a/skills/imported/benjitaylor/stageshow/examples/cursor-morph/README.md b/skills/imported/benjitaylor/stageshow/examples/cursor-morph/README.md
new file mode 100644
index 0000000..2432b98
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/cursor-morph/README.md
@@ -0,0 +1,15 @@
+# Cursor morph
+
+The cursor picks its own style: real, large, circle, arrow, then real again (the fifth, `touch`, is for phones).
+Each silhouette is resampled to 72 perimeter points and morphed with the
+same damped spring (damping ratio 0.62, angular frequency 15). Color and
+stroke width are clamped while geometry can overshoot. Retargeting starts
+from the current outline, and unmounting cancels the pending frame.
+
+The portal rides the kit cursor for position and press feedback. State lives
+in the Stage; the shape does not keep a second selected-style state. Reduced
+motion settles directly. The compact layout uses a 318px control in a 358px
+stage, preserving 14px labels.
+
+Use this when a cursor changes *identity*. Ordinary pointer/hand/ibeam changes
+should use the kit's cursor kinds.
diff --git a/skills/imported/benjitaylor/stageshow/examples/cursor-morph/cursor-morph.css b/skills/imported/benjitaylor/stageshow/examples/cursor-morph/cursor-morph.css
new file mode 100644
index 0000000..dda65f6
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/cursor-morph/cursor-morph.css
@@ -0,0 +1,52 @@
+.cm-stage .ss-stage { background: var(--ss-well); border-radius: 16px; }
+/* Cursor styles share one moving outline. */
+.cm-frame {
+ position: absolute;
+ inset: 0;
+ display: grid;
+ place-items: center;
+}
+
+.cm-seg.ss-seg {
+ width: 400px;
+ padding: 3px;
+ border-radius: 12px;
+}
+
+.cm-stage [data-layout="compact"] .cm-seg.ss-seg {
+ width: 318px;
+}
+
+.cm-seg .ss-seg__thumb {
+ top: 3px;
+ bottom: 3px;
+ left: 3px;
+ width: calc((100% - 6px) / var(--n));
+ border-radius: 9px;
+}
+
+.cm-seg .ss-seg__option {
+ height: 40px;
+ font-size: 14px;
+}
+
+/* This demo draws its own cursor (cursor-morph.tsx) inside the kit's, which
+ still moves and presses it; the kit's glyphs stay hidden */
+.cm-stage .ss-cursor__press {
+ visibility: hidden;
+}
+
+.cm-morph {
+ position: absolute;
+ top: -16px;
+ left: -16px;
+ overflow: visible;
+ filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.28));
+ transform-origin: 16px 16px;
+ transition: scale 0.26s cubic-bezier(0.22, 1, 0.36, 1);
+}
+
+.cm-stage .ss-cursor[data-pressed="true"] .cm-morph {
+ scale: 0.86;
+ transition: scale 0.08s ease-out;
+}
diff --git a/skills/imported/benjitaylor/stageshow/examples/cursor-morph/cursor-morph.tsx b/skills/imported/benjitaylor/stageshow/examples/cursor-morph/cursor-morph.tsx
new file mode 100644
index 0000000..2fe2575
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/cursor-morph/cursor-morph.tsx
@@ -0,0 +1,127 @@
+'use client'
+
+import { useLayoutEffect, useRef } from 'react'
+import { createPortal } from 'react-dom'
+import { useReducedMotion } from '../../kit'
+
+export type MorphStyle = 'real' | 'large' | 'circle' | 'arrow'
+
+// One outline that becomes each cursor style in turn, for the cursor picker:
+// the pointer swells into the large pointer, folds into the glass circle,
+// then sharpens into the solid arrowhead. Every shape is resampled to the
+// same points, starting at the hotspot and running the same way round, so a
+// change of style moves each point to its place in the next shape.
+
+type Pt = [number, number]
+type Look = { fill: number[]; stroke: number[]; sw: number }
+
+const N = 72
+
+/** Points evenly along a closed polygon, from its first vertex */
+function sample(poly: Pt[], scale = 1): Pt[] {
+ const p = poly.map(([x, y]) => [x * scale, y * scale] as Pt)
+ const seg = p.map((a, i) => {
+ const b = p[(i + 1) % p.length]
+ return Math.hypot(b[0] - a[0], b[1] - a[1])
+ })
+ const total = seg.reduce((s, l) => s + l, 0)
+ const out: Pt[] = []
+ let i = 0
+ let walked = 0
+ for (let k = 0; k < N; k++) {
+ const d = (k / N) * total
+ while (walked + seg[i] < d) walked += seg[i++]
+ const a = p[i]
+ const b = p[(i + 1) % p.length]
+ const t = seg[i] ? (d - walked) / seg[i] : 0
+ out.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t])
+ }
+ return out
+}
+
+// The macOS pointer, tip at the hotspot, down its left edge first
+const POINTER: Pt[] = [[0, 0], [0, 16.5], [4, 12.8], [6.6, 18.6], [9.2, 17.5], [6.7, 11.8], [12, 11.8]]
+// The solid arrowhead, the same way round
+const HEAD: Pt[] = [[0, 0], [5.6, 15.5], [7.8, 8.8], [14.5, 6.6]]
+// The glass circle about the hotspot, from its upper left, the same way round
+const CIRCLE: Pt[] = Array.from({ length: N }, (_, k) => {
+ const a = ((225 - (k / N) * 360) * Math.PI) / 180
+ return [Math.cos(a) * 11, Math.sin(a) * 11] as Pt
+})
+
+const SHAPES: Record = {
+ real: { pts: sample(POINTER), look: { fill: [0, 0, 0, 1], stroke: [255, 255, 255, 1], sw: 1.5 } },
+ large: { pts: sample(POINTER, 1.5), look: { fill: [0, 0, 0, 1], stroke: [255, 255, 255, 1], sw: 2.2 } },
+ circle: { pts: CIRCLE, look: { fill: [118, 118, 128, 0.34], stroke: [255, 255, 255, 0.55], sw: 0.8 } },
+ arrow: { pts: sample(HEAD), look: { fill: [17, 17, 17, 1], stroke: [17, 17, 17, 1], sw: 2.6 } },
+}
+
+// A damped spring with a little overshoot, settled by about 0.5s
+function spring(t: number) {
+ const z = 0.62
+ const w = 15
+ const wd = w * Math.sqrt(1 - z * z)
+ return 1 - Math.exp(-z * w * t) * (Math.cos(wd * t) + ((z * w) / wd) * Math.sin(wd * t))
+}
+
+const rgba = (c: number[]) => `rgba(${c.map((v, i) => (i < 3 ? Math.round(v) : v.toFixed(3))).join(',')})`
+const path = (pts: Pt[]) => `M${pts.map(([x, y]) => `${x.toFixed(2)} ${y.toFixed(2)}`).join('L')}Z`
+
+function Shape({ style }: { style: MorphStyle }) {
+ const ref = useRef(null)
+ const now = useRef({ pts: SHAPES[style].pts, look: SHAPES[style].look })
+ const reduced = useReducedMotion()
+ const initial = useRef(now.current)
+ useLayoutEffect(() => {
+ const el = ref.current
+ const to = SHAPES[style]
+ if (!el || !to) return
+ if (reduced) {
+ now.current = to
+ el.setAttribute('d', path(to.pts))
+ el.setAttribute('fill', rgba(to.look.fill))
+ el.setAttribute('stroke', rgba(to.look.stroke))
+ el.setAttribute('stroke-width', String(to.look.sw))
+ return
+ }
+ const from = { pts: now.current.pts.map((p) => [...p] as Pt), look: now.current.look }
+ const mix = (a: number, b: number, k: number) => a + (b - a) * k
+ let raf = 0
+ const t0 = performance.now()
+ const tick = (ms: number) => {
+ const t = Math.min((ms - t0) / 1000, 0.7)
+ const k = t >= 0.7 ? 1 : spring(t)
+ // Color and stroke don't overshoot
+ const c = Math.max(0, Math.min(1, k))
+ const pts = from.pts.map((p, i) => [mix(p[0], to.pts[i][0], k), mix(p[1], to.pts[i][1], k)] as Pt)
+ const look = {
+ fill: from.look.fill.map((v, i) => mix(v, to.look.fill[i], c)),
+ stroke: from.look.stroke.map((v, i) => mix(v, to.look.stroke[i], c)),
+ sw: mix(from.look.sw, to.look.sw, c),
+ }
+ now.current = { pts, look }
+ el.setAttribute('d', path(pts))
+ el.setAttribute('fill', rgba(look.fill))
+ el.setAttribute('stroke', rgba(look.stroke))
+ el.setAttribute('stroke-width', look.sw.toFixed(2))
+ if (t < 0.7) raf = requestAnimationFrame(tick)
+ }
+ raf = requestAnimationFrame(tick)
+ return () => cancelAnimationFrame(raf)
+ }, [style, reduced])
+ // React keeps the initial attributes stable; the tween owns subsequent
+ // geometry, including an interrupted transition's current outline.
+ const s = initial.current
+ return (
+
+ )
+}
+
+/** Draws the morphing cursor inside the stage's own cursor, so it rides the
+ * kit's movement and press. `at` is any element inside the stage. */
+export function CursorMorph({ at, style }: { at: HTMLElement | null; style: MorphStyle }) {
+ const host = at?.closest('.ss-stage')?.querySelector('.ss-cursor')
+ return host ? createPortal(, host) : null
+}
diff --git a/skills/imported/benjitaylor/stageshow/examples/cursor-morph/index.tsx b/skills/imported/benjitaylor/stageshow/examples/cursor-morph/index.tsx
new file mode 100644
index 0000000..7a8397b
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/cursor-morph/index.tsx
@@ -0,0 +1,53 @@
+'use client'
+
+import { useEffect, useRef, useState } from 'react'
+import { Segmented, Stage, type Act } from '../../kit'
+import { CursorMorph, type MorphStyle } from './cursor-morph'
+import './cursor-morph.css'
+
+// The cursor styles, picked by the cursor itself: four options, and each
+// click turns the cursor into the one it chose. Round the four and back to
+// the default, so the loop ends where it began. Here the cursor is one
+// outline that morphs from style to style (cursor-morph.tsx).
+
+const STYLES: MorphStyle[] = ['real', 'large', 'circle', 'arrow']
+const LABELS = ['Real', 'Large', 'Circle', 'Arrow']
+const ORDER = [1, 2, 3, 0]
+const REST = { x: 150, y: 150 }
+
+type S = { value: number }
+
+async function script(a: Act) {
+ await a.wait(600)
+ for (const k of ORDER) {
+ await a.click({ at: `opt-${k}`, x: 0.5, y: 0.62 })
+ await a.set({ value: k })
+ await a.wait(1000)
+ }
+ await a.move(REST)
+ await a.wait(600)
+}
+
+export default function CursorMorphDemo() {
+ const frame = useRef(null)
+ const [at, setAt] = useState(null)
+ useEffect(() => setAt(frame.current), [])
+ return (
+
+ width={550}
+ height={200}
+ compact={{ below: 540, width: 358, height: 200 }}
+ initial={{ value: 0 }}
+ script={script}
+ cursor={{ at: REST, style: 'real' }}
+ className="cm-stage"
+ >
+ {(s) => (
+
+
+ )}
+
+ )
+}
+
+// The status mark: spinner and dot share one 8px cell and crossfade, so
+// the word beside it never moves; the cell is the spinner's own size, so
+// the pill's padding reads the same on both sides
+function Mark({ ready }: { ready: boolean }) {
+ return (
+
+
+
+
+ )
+}
+
+// The status word. The pill keeps its left edge and only its right edge
+// moves: the cell eases to the width of the word that is showing. Getting
+// shorter, the old word fades out before the edge reaches it; getting
+// longer, the edge opens first and the new word fades into the room.
+function Label({ ready }: { ready: boolean }) {
+ const words = useRef([])
+ const [w, setW] = useState([])
+ useLayoutEffect(() => setW(words.current.map(e => e.offsetWidth)), [])
+ const on = ready ? 1 : 0
+ const shrink = w.length === 2 && w[on]! < w[1 - on]!
+ return (
+
+ {['Building', 'Ready'].map((t, i) => (
+ { if (e) words.current[i] = e }} className="dh-word" data-on={i === on || undefined}>{t}
+ ))}
+
+ )
+}
+
+function Row({ lead, msg, hash, when, status, time, current }: { lead?: boolean; msg: string; hash: string; when: string; status: ReactNode; time: ReactNode; current: boolean }) {
+ return (
+
+
+
+ {msg}
+ Current
+
+ {/* Each icon is held with its words, and the pairs sit a clear
+ gap apart, so no word runs into the next icon */}
+
+ main
+ {hash}
+ {when}
+
+
+ {status}
+ {time}
+
+ )
+}
+
+function Side() {
+ return (
+
+ )
+}
diff --git a/skills/imported/benjitaylor/stageshow/examples/device-pair/index.tsx b/skills/imported/benjitaylor/stageshow/examples/device-pair/index.tsx
new file mode 100644
index 0000000..62ffd98
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/device-pair/index.tsx
@@ -0,0 +1,83 @@
+import { Badge, Bar, Chart, Icon, LockScreen, Notification, Phone, Stage, Text, Window, WALLPAPER } from '../../kit'
+import './payments.css'
+
+// "Get paid anywhere": a device pair. The dashboard is pinned top-left and
+// runs off the bottom; the phone straddles its right edge over empty margin
+// and placeholder rows, so the revenue figure and its chart stay clear. Real words:
+// Revenue, the total, the change, and the notification. Narrow, the sidebar
+// goes and both devices keep their true size: the window runs off the right,
+// the phone stands a gutter in from it, whole across, its notification whole.
+
+const REVENUE = [18, 22, 20, 27, 25, 31, 29, 36, 34, 41, 39, 47]
+
+const ROWS = [
+ { name: 74, amount: 40 },
+ { name: 58, amount: 46 },
+ { name: 82, amount: 36 },
+ { name: 64, amount: 44 },
+ { name: 70, amount: 38 },
+]
+
+const WALL = WALLPAPER
+
+export default function PaymentsStatic() {
+ return (
+
+ {() => (
+ <>
+
+
+
+
+
+
+
+ Revenue
+
+
+
+ $12,604.80
+ +12.4%
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {ROWS.map((row, i) => (
+
+
+
+
+
+ ))}
+
+
+
+
+
+
+ {/* The product's own app sends it: the dashboard's bolt, not Mail */}
+ } title="Payment received" body="$249.00 from Oak & Ivy" />
+
+
+ >
+ )}
+
+ )
+}
diff --git a/skills/imported/benjitaylor/stageshow/examples/device-pair/payments.css b/skills/imported/benjitaylor/stageshow/examples/device-pair/payments.css
new file mode 100644
index 0000000..38c42b0
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/device-pair/payments.css
@@ -0,0 +1,150 @@
+/* The section background is the stage: light gray, no box. One gutter for
+ the window's top and left and the phone's right. */
+.ps-section .ss-stage {
+ --gutter: 32px;
+ overflow: hidden;
+ background: var(--ss-well);
+}
+
+/* Pinned top-left, runs about a quarter past the bottom */
+.ps-window {
+ position: absolute;
+ top: var(--gutter);
+ left: var(--gutter);
+ width: 496px;
+ height: 460px;
+ background: var(--ss-surface);
+ border-radius: 10px;
+ box-shadow: var(--ss-shadow-hero);
+}
+
+.ps-window .ss-window__body {
+ display: flex;
+}
+
+.ps-sidebar {
+ display: flex;
+ flex-direction: column;
+ flex-shrink: 0;
+ gap: 4px;
+ width: 112px;
+ padding: 16px 10px;
+ border-right: 1px solid var(--ss-line);
+ background: var(--ss-canvas);
+}
+
+.ps-logo {
+ /* A plain wordmark: a fictional company gets no invented mark */
+ display: flex;
+ align-items: center;
+ height: 20px;
+ margin: 0 0 12px 6px;
+ color: var(--ss-text-strong);
+ font-size: 13px;
+ font-weight: 600;
+}
+
+.ps-nav {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ height: 24px;
+ padding: 0 6px;
+ border-radius: 6px;
+}
+
+.ps-nav[data-active] {
+ background: var(--ss-surface);
+ box-shadow: 0 0 0 1px var(--ss-line);
+}
+
+.ps-main {
+ flex: 1;
+ min-width: 0;
+ padding: 20px 22px;
+}
+
+.ps-top {
+ display: grid;
+ grid-template-columns: 224px;
+}
+
+.ps-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.ps-label {
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--ss-text);
+}
+
+.ps-figure {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin: 8px 0 10px;
+}
+
+.ps-total {
+ font-size: 24px;
+ font-weight: 600;
+ letter-spacing: -0.02em;
+ line-height: 1.2;
+ color: var(--ss-text-strong);
+ font-variant-numeric: tabular-nums;
+}
+
+.ps-chart {
+ display: block;
+ margin-top: 16px;
+}
+
+.ps-axis {
+ display: flex;
+ justify-content: space-between;
+ margin-top: 8px;
+}
+
+.ps-list {
+ margin-top: 28px;
+ padding-top: 18px;
+ border-top: 1px solid var(--ss-line);
+}
+
+.ps-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ height: 34px;
+ border-bottom: 1px solid var(--ss-line-faint);
+}
+
+/* Straddles the window's right edge, about half outside, over empty margin
+ and placeholder rows; its top sits in the upper half of the stage and the stage crops its
+ base. Stronger shadow than the window: a level above. */
+.ps-phone {
+ position: absolute;
+ top: 84px;
+ right: var(--gutter);
+ box-shadow: var(--ss-shadow-satellite), 0 30px 50px -20px rgba(50, 50, 93, 0.35);
+}
+
+/* Narrow: the sidebar goes; the window runs off the right under the phone */
+.ps-section .ss-stage[data-layout='compact'] {
+ --gutter: 16px;
+}
+
+.ps-section .ss-stage[data-layout='compact'] .ps-sidebar {
+ display: none;
+}
+
+.ps-section .ss-stage[data-layout='compact'] .ps-window {
+ width: 420px;
+}
+
+.ps-section .ss-stage[data-layout='compact'] .ps-phone {
+ top: 150px;
+}
diff --git a/skills/imported/benjitaylor/stageshow/examples/dynamic-island/index.tsx b/skills/imported/benjitaylor/stageshow/examples/dynamic-island/index.tsx
new file mode 100644
index 0000000..54f5565
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/dynamic-island/index.tsx
@@ -0,0 +1,260 @@
+import { useId, useRef, type CSSProperties } from 'react'
+import { Phone, LockScreen, Roll, Stage, Swap, type Act } from '../../kit'
+import './island.css'
+
+// A food delivery's Live Activity in the Dynamic Island, on the top of an
+// iPhone rising from the foot of the stage. The island is the story; the
+// lock screen behind it supplies the device context.
+//
+// Everything is in screen points on the kit's iPhone (402 × 874), round the
+// screen's island: x 153.5 to 248.5, y 14.5, 95 × 37 (craft.md,
+// Chrome and devices). The island is one black shape whose box (left, top,
+// width, height, radius) springs between presentations: underdamped with a
+// small overshoot when it grows, critically damped and quicker when it
+// shrinks, a beat late so the content has cleared. Content is a separate
+// layer above the shape at its final positions, so nothing in it stretches:
+// each item blurs, shrinks and fades out first, and the new items sharpen in
+// once the shape is most of the way there. A second activity (a timer) buds
+// off as a circle through a soft neck: a goo filter on the black shapes only.
+//
+// Real strings: the minutes, "Preparing" then "Picked up", "Sam is your courier",
+// the order number. Everything else is a shape.
+
+/** A damped spring as a CSS linear() easing */
+function spring(zeta: number, omega: number, ms: number, n = 48) {
+ const wd = omega * Math.sqrt(1 - zeta * zeta)
+ const pts: string[] = []
+ for (let i = 0; i <= n; i++) {
+ const t = (i / n) * (ms / 1000)
+ const x = i === n ? 1 : 1 - Math.exp(-zeta * omega * t) * (Math.cos(wd * t) + ((zeta * omega) / wd) * Math.sin(wd * t))
+ pts.push(x.toFixed(4))
+ }
+ return `linear(${pts.join(', ')})`
+}
+
+// Grow: about 4% overshoot, settled by 0.5s. Shrink: no overshoot, 0.38s.
+const GROW = { ease: spring(0.7, 15, 500), ms: 500 }
+const SHRINK = { ease: spring(0.98, 19, 380), ms: 380 }
+
+type Mode = 'idle' | 'compact' | 'open' | 'split'
+type Box = { l: number; t: number; w: number; h: number; r: number }
+
+const CX = 201
+const BASE: Box = { l: 153.5, t: 14.5, w: 95, h: 37, r: 18.5 }
+const GEO: Record = {
+ idle: BASE,
+ // Wide enough that the trailing minutes clear the camera (at 248.5)
+ compact: { l: CX - 103, t: 14.5, w: 206, h: 37, r: 18.5 },
+ // Minimal, with a second activity: the island grows on its leading side only
+ split: { l: 153.5 - 44, t: 14.5, w: 95 + 44, h: 37, r: 18.5 },
+ // Expanded: inset 13 from the screen's sides and 10 from its top, so its
+ // corners run concentric with the display's (64 - 12 = 52)
+ open: { l: 13, t: 10, w: 376, h: 172, r: 52 },
+}
+const SIZE: Record = { idle: 0, split: 1, compact: 2, open: 3 }
+// The detached circle, 8pt clear of the island's trailing end
+const BUD = { l: 248.5 + 8, t: 14.5, d: 37 }
+
+type S = { mode: Mode; eta: number; prog: number; label: string; press: boolean; timer: number }
+const START: S = { mode: 'compact', eta: 8, prog: 0.22, label: 'Preparing', press: false, timer: 0 }
+const POSTER: S = { ...START, mode: 'open', eta: 7, prog: 0.4 }
+
+// A takeaway bag, solid (small glyphs are filled), with its handle cut out
+const BAG =
+ 'M8.2 7.2V6.6a3.8 3.8 0 0 1 7.6 0v.6h2.1c.8 0 1.4.6 1.5 1.4l.9 10.2a2 2 0 0 1-2 2.2H5.7a2 2 0 0 1-2-2.2l.9-10.2c.1-.8.7-1.4 1.5-1.4Zm1.8 0h4V6.6a2 2 0 0 0-4 0Z'
+
+function Bag({ size }: { size: number }) {
+ return (
+
+ )
+}
+
+const boxStyle = (b: Box, grow: boolean): CSSProperties => ({
+ left: b.l,
+ top: b.t,
+ width: b.w,
+ height: b.h,
+ borderRadius: b.r,
+ transitionDuration: `${grow ? GROW.ms : SHRINK.ms}ms`,
+ transitionTimingFunction: grow ? GROW.ease : SHRINK.ease,
+ // Shrinking waits for the content to clear, so nothing paints past the edge
+ transitionDelay: grow ? '0ms' : '120ms',
+})
+
+function Island({ s }: { s: S }) {
+ const gooId = useId()
+ // Spring by direction: overshoot only when growing (an overshoot while
+ // shrinking would dip the shape inside the bezel's own island)
+ const last = useRef<{ mode: Mode; grow: boolean }>({ mode: s.mode, grow: true })
+ if (last.current.mode !== s.mode) last.current = { mode: s.mode, grow: SIZE[s.mode] > SIZE[last.current.mode] }
+ const grow = last.current.grow
+ // In the plain pill, tuck the dormant circle inside its trailing cap.
+ // Its compact position would protrude beyond the narrower idle shape.
+ const budLeft = s.mode === 'split' ? BUD.l : s.mode === 'idle' ? BASE.l + BASE.w - BUD.d : BUD.l - 30
+ const min = (n: number) => `${n} min`
+ return (
+
+
+
+
+
+
+
+ {/* Compact: the bag leading, the minutes trailing */}
+
+
+
+ {/* Minimal: only the bag, on the leading side */}
+
+
+
+
+ {min(s.eta)}
+
+
+ {/* The timer's circle and its ring */}
+
+
+
+
+ {/* Expanded: the whole body as one group, in once the shape is mostly there */}
+
+
+
+
+ {s.label}
+
+
+ {min(s.eta)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Sam is your courier
+ #2041
+
+
+
+ )
+}
+
+/**
+ * The loop, about 16s, one thing at a time:
+ * 1.6s the minutes roll down in the compact island
+ * 3.0s a finger presses and holds on the island; it squeezes in a touch
+ * 3.9s it springs open into the Live Activity; the body sharpens in
+ * 5.6s the order is picked up: the label changes, the minutes roll and the bag rides up the track
+ * 8.0s it folds back to compact
+ * 9.4s a timer starts: the island goes minimal and the timer buds off
+ * as a circle through a soft neck; its ring fills
+ * 13.6s the timer ends: the circle is drawn back in, compact again
+ * 15.0s the order arrives: the island draws in to its plain pill, and
+ * the next order's activity grows out of it, so the loop meets itself
+ */
+const script = async (a: Act) => {
+ await a.wait(1600)
+ a.put({ eta: 7 })
+ await a.wait(1400)
+ a.mark('press')
+ const r = a.rect('island')
+ const point = r ? { x: r.x + r.width * 0.5, y: r.y + r.height * 0.55 } : 'island'
+ await a.move(point, { duration: 420 })
+ a.put({ press: true })
+ // A long press: the finger holds where it landed
+ await a.drag(point, undefined, { duration: 420 })
+ a.put({ press: false, mode: 'open' })
+ a.mark('open')
+ // The finger lifts; a touch cursor shows only where it taps, so it goes
+ // back to where it started unseen, and the loop meets itself
+ void a.move(a.layout === 'compact' ? { x: 268, y: 400 } : { x: 470, y: 460 }, { duration: 300 })
+ await a.wait(1700)
+ a.put({ label: 'Picked up', eta: 6, prog: 0.58 })
+ await a.wait(2400)
+ a.mark('fold')
+ a.put({ mode: 'compact' })
+ await a.wait(1400)
+ a.mark('timer')
+ a.put({ mode: 'split' })
+ await a.wait(500)
+ a.put({ timer: 1 })
+ await a.wait(3700)
+ a.mark('merge')
+ a.put({ mode: 'compact' })
+ await a.wait(1400)
+ a.mark('end')
+ a.put({ mode: 'idle' })
+ await a.wait(600)
+ a.snap({ ...START, mode: 'idle' })
+ await a.wait(200)
+ a.put({ mode: 'compact' })
+ await a.wait(900)
+}
+
+export default function DynamicIsland() {
+ return (
+
+ width={640}
+ height={400}
+ compact={{ below: 560, width: 358, height: 340 }}
+ initial={START}
+ poster={POSTER}
+ script={script}
+ cursor={{ at: { wide: { x: 470, y: 460 }, compact: { x: 268, y: 400 } }, style: 'touch' }}
+ className="di"
+ >
+ {(s, _rect, layout) => {
+ const compact = layout === 'compact'
+ const pw = compact ? 326 : 470
+ const w = compact ? 358 : 640
+ return (
+
+ ))}
+ {Array.from({ length: g.rows }, (_, i) => {
+ const r = i + 1
+ return (
+
+ )
+ })}
+
+ {/* The selection: one green frame with the fill handle, gliding as Excel's cell cursor does */}
+
+
+
+
+
+
+
+ )
+}
+
+function Row({ r, n, sel, active, total }: { r: number; n: number; sel: boolean; active: boolean; total: boolean }) {
+ const data = r >= 2 && r <= 7 ? ROWS[r - 2] : null
+ let a: React.ReactNode = null
+ let b: React.ReactNode = null
+ if (r === 1) {
+ a = Region
+ b = Q3 Sales
+ } else if (data) {
+ a = data[0]
+ b = fmt(data[1])
+ } else if (r === 8) {
+ a = Total
+ b = (
+
+ {fmt(SUM)}
+
+ )
+ }
+ return (
+ <>
+
+ {r}
+
+
{a}
+
+ {b}
+
+ {Array.from({ length: n - 2 }, (_, i) => (
+
+ ))}
+ >
+ )
+}
+
+function ChartObject({ s, g }: { s: S; g: (typeof GEO)['wide'] }) {
+ const { w, h } = g.plot
+ const n = ROWS.length
+ const gap = Math.max(2, Math.round(w / n / 4))
+ const bw = (w - gap * (n - 1)) / n
+ const MAX = 80000
+ const ticks = [0, 20000, 40000, 60000, 80000]
+ return (
+
+
+ Q3 Sales by Region
+
+ Total
+ {/* Reserve the final formatted width so the label stays still as digits arrive. */}
+ {fmt(SUM)}
+
+
+
+
+ )
+}
+
+function Caret() {
+ return (
+
+ )
+}
diff --git a/skills/imported/benjitaylor/stageshow/examples/fade-crop/fade.css b/skills/imported/benjitaylor/stageshow/examples/fade-crop/fade.css
new file mode 100644
index 0000000..4d81271
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/fade-crop/fade.css
@@ -0,0 +1,80 @@
+.fc .ss-stage {
+ overflow: hidden;
+ background: var(--ss-well);
+}
+
+/* The mask lives on a wrapper with 40px of room, so the window keeps its
+ shadow on the top and sides. The last third fades to transparent. */
+.fc-mask {
+ position: absolute;
+ inset: 0;
+ padding: 40px 72px 0;
+ mask-image: linear-gradient(to bottom, #000 58%, transparent 96%);
+}
+
+.fc-win {
+ width: 100%;
+ height: 420px;
+}
+
+.fc-win .ss-window__body {
+ background: var(--ss-surface);
+}
+
+.fc-body {
+ padding: 20px 24px;
+}
+
+.fc-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 14px;
+}
+
+.fc-title {
+ color: var(--ss-text-strong);
+ font-size: 15px;
+ font-weight: 600;
+}
+
+.fc-row {
+ padding: 10px 0;
+ border-top: 1px solid var(--ss-line);
+ /* A divided list, not a hover fill: square ends on the rule */
+ border-radius: 0;
+}
+
+
+.fc-commit {
+ color: var(--ss-text-strong);
+ font-size: 13px;
+ font-weight: 500;
+}
+
+.fc-branch,
+.fc-age {
+ color: var(--ss-text);
+ font-size: 12px;
+}
+
+/* Narrow: a gutter each side, the window keeps its size and runs off the
+ right; the commit and the status stay whole */
+.fc .ss-stage[data-layout='compact'] .fc-mask {
+ padding: 32px 0 0 16px;
+}
+
+.fc .ss-stage[data-layout='compact'] .fc-win {
+ width: 420px;
+}
+
+.fc .ss-stage[data-layout='compact'] .fc-age {
+ display: none;
+}
+
+/* One lead column, so every row's text starts on the same line whatever the
+ status says */
+.fc-row .ss-row__lead {
+ width: 76px;
+ justify-content: flex-start;
+}
diff --git a/skills/imported/benjitaylor/stageshow/examples/fade-crop/index.tsx b/skills/imported/benjitaylor/stageshow/examples/fade-crop/index.tsx
new file mode 100644
index 0000000..8dd39e0
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/fade-crop/index.tsx
@@ -0,0 +1,58 @@
+import { Badge, Bar, Row, Stage, Text, Window } from '../../kit'
+import './fade.css'
+
+// A fade crop: a deployments page that sinks into the page instead of running
+// off an edge. A still.
+//
+// What makes the fade read as deliberate:
+// - Only the foot fades. The top and both sides are whole and hard-edged, so
+// the window is plainly a window, placed, not a smear.
+// - It's a mask, not a color laid over the foot, so the window fades to
+// transparent and dissolves into whatever it sits on.
+// - Only filler is under the fade: the real rows sit in the top half, the
+// placeholder rows carry on into the fade so the list reads as going on.
+// - The mask is on a wrapper with room around the window, so the window
+// keeps its shadow on the top and sides; a mask on the window itself would
+// clip the shadow off.
+//
+// Real strings: the project, the newest deployment (status, branch, commit,
+// age), and the one before it.
+
+const ROWS = [
+ { status: 'Ready', branch: 'main', commit: 'Fix checkout redirect loop', age: '2m ago', tone: 'success' as const },
+ { status: 'Ready', branch: 'pricing-copy', commit: 'New annual plan wording', age: '1h ago', tone: 'success' as const },
+]
+
+const FILL = [
+ [132, 168],
+ [96, 140],
+ [148, 120],
+ [110, 176],
+]
+
+export default function FadeCrop() {
+ return (
+
+ {() => (
+
+
+
+ {/* Hover and selection on the canvas, from the measured button */}
+
+
+
+
+
+
+
+
+ {/* Maya, on the canvas with us */}
+
+
+ Maya
+
+
+ {/* The color picker, beside the properties panel */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {hex(s.hue)}
+
+
+
+
+
+ >
+ )
+ }}
+
+ )
+}
diff --git a/skills/imported/benjitaylor/stageshow/examples/finder-glass/finder-glass.css b/skills/imported/benjitaylor/stageshow/examples/finder-glass/finder-glass.css
new file mode 100644
index 0000000..eba792e
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/finder-glass/finder-glass.css
@@ -0,0 +1,430 @@
+/* Finder in the Liquid Glass design (macOS Tahoe), column view, matched to a
+ real macOS 26 screenshot (2x, halved to points, then drawn at about 0.95).
+ Pinned with a bottom bleed on a blue Tahoe ground: the window sits one
+ gutter in from the top, left and right, so the whole toolbar shows, and
+ runs off the bottom. Everything is nested under .fgl so it can sit on a
+ page beside the finder (.fq) and figma (.fg) examples.
+
+ The kit draws the macOS 26 chrome: sidebarStyle="floating" (the inset
+ panel, the window's 24 radius), lightsSize="large", barSize="tall" (the
+ 64px icon-and-label toolbar) and greys="cool" (the panel's cool gray). */
+
+.fgl .ss-stage {
+ overflow: hidden;
+ border-radius: 14px;
+ /* One cool family throughout: the ground, the glass and the grays */
+ --ss-text: #8a8b91;
+ --ss-text-strong: #1d1d1f;
+ --ss-text-faint: #a1a2a8;
+ --ss-icon: #3d3e44;
+ --ss-icon-faint: #b6b7bd;
+ --fgl-glass: rgba(255, 255, 255, 0.9);
+ --fgl-btn-shadow: 0 0 0 0.5px rgba(0, 0, 0, 0.07), inset 0 0.5px 0 rgba(255, 255, 255, 1), 0 1px 2px rgba(20, 30, 60, 0.05), 0 5px 16px rgba(20, 30, 60, 0.09);
+ /* A Tahoe wallpaper: deep blue, lighter toward the top left */
+ background:
+ radial-gradient(110% 80% at 10% 0%, #cfe0f7 0%, rgba(207, 224, 247, 0) 62%),
+ radial-gradient(90% 110% at 100% 100%, #2f6fd0 0%, rgba(47, 111, 208, 0) 72%),
+ linear-gradient(165deg, #b4ccf0 0%, #7ea6e6 50%, #4f86dc 100%);
+}
+
+/* ---- The window: a soft wide shadow, deeper than the kit's for the blue
+ ground ---- */
+
+.fgl .fgl-window {
+ position: absolute;
+ box-shadow:
+ 0 0 0 0.5px rgba(0, 0, 0, 0.16),
+ 0 24px 60px -10px rgba(10, 30, 80, 0.38),
+ 0 8px 20px -10px rgba(10, 30, 80, 0.24);
+}
+
+/* The toolbar's buttons start close to the panel, as Finder sets them */
+.fgl .fgl-window .ss-window__main > .ss-window__bar {
+ padding-inline: 12px 14px;
+}
+
+/* ---- Toolbar ---- */
+
+.fgl .fgl-lead {
+ display: flex;
+ align-items: flex-start;
+ gap: 14px;
+}
+
+.fgl .fgl-tools {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+}
+
+.fgl .fgl-tool {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 4px;
+}
+
+.fgl .fgl-tool__label {
+ font-size: 10.5px;
+ line-height: 13px;
+ color: var(--ss-text);
+ white-space: nowrap;
+}
+
+.fgl .fgl-cap,
+.fgl .fgl-round {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ height: 32px;
+ border-radius: 16px;
+ background: var(--fgl-glass);
+ box-shadow: var(--fgl-btn-shadow);
+ color: var(--ss-icon);
+}
+
+.fgl .fgl-cap {
+ gap: 4px;
+ padding: 0 11px 0 12px;
+}
+
+.fgl .fgl-round {
+ width: 32px;
+}
+
+.fgl .fgl-nav {
+ gap: 0;
+ padding: 0 4px;
+}
+
+.fgl .fgl-nav > svg {
+ width: 30px;
+}
+
+.fgl .fgl-nav__rule {
+ width: 1px;
+ height: 16px;
+ background: rgba(0, 0, 0, 0.1);
+}
+
+.fgl .fgl-off {
+ color: var(--ss-icon-faint);
+}
+
+.fgl .fgl-heading {
+ display: flex;
+ align-items: center;
+ height: 32px;
+ font-size: 15px;
+ font-weight: 700;
+ letter-spacing: -0.01em;
+ color: var(--ss-text-strong);
+ white-space: nowrap;
+}
+
+/* ---- Sidebar ---- */
+
+.fgl .fgl-side {
+ display: flex;
+ flex-direction: column;
+ padding: 4px 8px 0;
+}
+
+.fgl .fgl-side__label {
+ display: block;
+ margin: 12px 10px 5px;
+ font-size: 10.5px;
+ font-weight: 600;
+ line-height: 13px;
+ color: var(--ss-text);
+}
+
+.fgl .fgl-side__item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ height: 27px;
+ padding: 0 10px 0 9px;
+ border-radius: 9px;
+ font-size: 12.5px;
+ color: var(--ss-text-strong);
+}
+
+.fgl .fgl-side__icon {
+ display: grid;
+ place-items: center;
+ width: 18px;
+ color: var(--ss-icon);
+}
+
+.fgl .fgl-side__cloud {
+ margin-left: auto;
+ color: var(--ss-text-faint);
+}
+
+/* The current place: a soft gray capsule, as in an inactive or focused-away
+ sidebar */
+.fgl .fgl-side__item[data-current] {
+ background: rgba(0, 0, 0, 0.06);
+}
+
+/* ---- Path bar: one rounded capsule with the folder centered, and a + ---- */
+
+.fgl .fgl-body {
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.fgl .fgl-path {
+ display: flex;
+ flex-shrink: 0;
+ align-items: center;
+ gap: 8px;
+ margin: 4px 14px 8px 12px;
+}
+
+.fgl .fgl-path__tab {
+ display: flex;
+ flex: 1;
+ align-items: center;
+ justify-content: center;
+ height: 26px;
+ border-radius: 13px;
+ background: rgba(0, 0, 0, 0.025);
+ box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.07);
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--ss-text-strong);
+}
+
+.fgl .fgl-path__add {
+ display: grid;
+ place-items: center;
+ flex-shrink: 0;
+ width: 26px;
+ height: 26px;
+ border-radius: 13px;
+ background: var(--fgl-glass);
+ box-shadow: var(--fgl-btn-shadow);
+ color: var(--ss-icon);
+}
+
+/* ---- Columns, split by hairlines ---- */
+
+.fgl .fgl-cols {
+ display: grid;
+ flex: 1;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ border-top: 1px solid rgba(0, 0, 0, 0.07);
+}
+
+.fgl .fgl-cols:not([data-docs]) {
+ /* Compact: two narrow columns, so the second is in view and the rest of
+ the row stays blank, as Finder leaves it */
+ grid-template-columns: 112px 112px;
+}
+
+.fgl .fgl-cols[data-docs] {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.fgl .fgl-col {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ padding: 6px 0;
+ /* A layer it keeps, so the column's text never hops as it slides in */
+ will-change: transform;
+ transition: opacity 0.16s ease, transform 0.16s ease;
+}
+
+/* Every column closes with a hairline on its right, the last one too */
+.fgl .fgl-col {
+ border-right: 1px solid rgba(0, 0, 0, 0.07);
+}
+
+/* The last column comes in with its folder's selection: a short slide from
+ the column before it, already moving on its first frame */
+.fgl .fgl-col:not([data-open]) {
+ opacity: 0;
+ transform: translateX(-10px);
+}
+
+.fgl .fgl-col[data-open] {
+ transition: opacity 0.3s ease-out, transform 0.42s cubic-bezier(0.25, 0.8, 0.25, 1);
+}
+
+.fgl .fgl-col:not([data-open]) .fgl-row {
+ visibility: hidden;
+ transition: visibility 0s 0.16s;
+}
+
+.fgl .fgl-row {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ height: 21px;
+ margin: 0 7px;
+ padding: 0 7px 0 8px;
+ border-radius: 6px;
+ font-size: 12.5px;
+ color: var(--ss-text-strong);
+ transition: background-color 0.12s ease, color 0.12s ease;
+}
+
+.fgl .fgl-row__name {
+ white-space: nowrap;
+}
+
+.fgl .fgl-row__chev {
+ flex-shrink: 0;
+ margin-left: auto;
+ color: #a9aab0;
+ transition: color 0.12s ease;
+}
+
+/* Focus moved on: the gray capsule */
+.fgl .fgl-row[data-sel="quiet"] {
+ background: rgba(0, 0, 0, 0.07);
+}
+
+/* The column with focus: blue */
+.fgl .fgl-row[data-sel="focus"] {
+ background: var(--ss-accent);
+ color: #fff;
+}
+
+.fgl .fgl-row[data-sel="focus"] .fgl-row__chev {
+ color: #fff;
+}
+
+/* ---- File icons ---- */
+
+.fgl .fgl-folder {
+ flex-shrink: 0;
+ color: #3f9ae8;
+ fill: #8cc7f5;
+}
+
+.fgl .fgl-doc {
+ flex-shrink: 0;
+ color: #a7a8ae;
+ fill: #fff;
+}
+
+.fgl .fgl-thumb {
+ display: block;
+ flex-shrink: 0;
+ width: 16px;
+ height: 12px;
+ border-radius: 2px;
+ overflow: hidden;
+ box-shadow: 0 0 0 1px #fff, 0 0 0 1.5px rgba(0, 0, 0, 0.14);
+}
+
+.fgl .fgl-photo {
+ display: block;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ object-position: center;
+}
+
+/* ---- Quick Look: zooms out of the thumbnail and back into it ---- */
+
+.fgl .fgl-ql {
+ position: absolute;
+ z-index: 20;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ border-radius: 18px;
+ background: #f5f6f9;
+ box-shadow: 0 0 0 0.5px rgba(0, 0, 0, 0.16), 0 26px 60px -10px rgba(10, 30, 80, 0.4), 0 10px 22px -10px rgba(10, 30, 80, 0.24);
+ opacity: 0;
+ pointer-events: none;
+ will-change: transform;
+ --fgl-close: 0.34s cubic-bezier(0.4, 0, 0.2, 1);
+ --fgl-open: 0.46s cubic-bezier(0.3, 0.75, 0.1, 1);
+ /* Closing waits for the bar's words to fade before the box moves */
+ transition:
+ left var(--fgl-close) 0.1s, top var(--fgl-close) 0.1s, width var(--fgl-close) 0.1s, height var(--fgl-close) 0.1s, border-radius var(--fgl-close) 0.1s,
+ opacity 0.2s ease-in 0.24s;
+}
+
+.fgl .fgl-ql[data-open] {
+ opacity: 1;
+ transition:
+ left var(--fgl-open), top var(--fgl-open), width var(--fgl-open), height var(--fgl-open), border-radius var(--fgl-open),
+ opacity 0.24s ease-out;
+}
+
+/* The bar shrinks with the window; its words fade out first and back in once
+ the zoom has landed: text moved by layout snaps to whole pixels, so it is
+ never seen while the box is still gliding */
+.fgl .fgl-ql__bar > * {
+ opacity: 0;
+ transition: opacity 0.12s ease;
+}
+
+.fgl .fgl-ql[data-open] .fgl-ql__bar > * {
+ opacity: 1;
+ transition: opacity 0.18s ease 0.5s;
+}
+
+.fgl .fgl-ql__bar {
+ position: relative;
+ display: flex;
+ flex-shrink: 0;
+ align-items: center;
+ gap: 6px;
+ height: 38px;
+ overflow: hidden;
+ padding: 0 7px;
+ color: var(--ss-icon);
+ transition: height var(--fgl-close) 0.1s;
+}
+
+.fgl .fgl-ql[data-open] .fgl-ql__bar {
+ transition: height var(--fgl-open);
+}
+
+.fgl .fgl-ql__title + * {
+ margin-left: auto;
+}
+
+.fgl .fgl-ql__round {
+ width: 26px;
+ height: 26px;
+}
+
+/* Centered on the window, as macOS does, not in the space between two
+ buttons on the left and one on the right */
+.fgl .fgl-ql__title {
+ position: absolute;
+ left: 50%;
+ transform: translateX(-50%);
+ max-width: calc(100% - 140px);
+ overflow: hidden;
+ text-align: center;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 12.5px;
+ font-weight: 600;
+ color: var(--ss-text-strong);
+}
+
+.fgl .fgl-ql__open {
+ height: 26px;
+ padding: 0 11px;
+ font-size: 11.5px;
+ color: var(--ss-text-strong);
+ white-space: nowrap;
+}
+
+.fgl .fgl-ql__photo {
+ flex: 1;
+ min-height: 0;
+}
diff --git a/skills/imported/benjitaylor/stageshow/examples/finder-glass/index.tsx b/skills/imported/benjitaylor/stageshow/examples/finder-glass/index.tsx
new file mode 100644
index 0000000..2426b93
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/finder-glass/index.tsx
@@ -0,0 +1,349 @@
+import { type ReactNode } from 'react'
+import {
+ AppWindow,
+ ChevronDown,
+ ChevronLeft,
+ ChevronRight,
+ ChevronsUpDown,
+ CircleArrowDown,
+ Clock,
+ Cloud,
+ Columns3,
+ Ellipsis,
+ File,
+ FileText,
+ Folder,
+ House,
+ LayoutGrid,
+ Maximize2,
+ Monitor,
+ Plus,
+ Search,
+ Share,
+ Tag,
+ Users,
+ X,
+} from 'lucide-react'
+import { Bar, Stage, Text, Window, type Act, type Layout, States } from '../../kit'
+import './finder-glass.css'
+
+// Finder in the Liquid Glass design (macOS Tahoe, 26), in column view, drawn
+// from a real macOS 26 screenshot: a floating rounded sidebar panel inset in
+// the window that holds the larger traffic lights, a toolbar with no bar of
+// its own (separate glass buttons, each with its label under it, the icon and
+// text mode), the path bar as one rounded capsule with a round + beside it,
+// and columns split by hairlines.
+//
+// The story: in films, a click on japan slides its column in and the title
+// follows; a click picks a photo in it; Space opens Quick Look out of its
+// thumbnail and Space again zooms it back; a click on films in the first
+// column closes the japan column, which is exactly where the loop began.
+//
+// Icons: Finder has no web icon pack, so lucide is used deliberately for its
+// SF Symbols-like outlines (one family, one stroke).
+
+type S = { lis: boolean; file: boolean; ql: boolean }
+
+const initial: S = { lis: false, file: false, ql: false }
+
+const FILE = 'Fuji.jpg'
+
+type Geo = {
+ win: { x: number; y: number; w: number; h: number }
+ side: number
+ /** The folders shown as columns, left to right: films, japan and before them, in wide, Documents */
+ docs: boolean
+ ql: { x: number; y: number; w: number }
+}
+
+const GEO: Record = {
+ wide: { win: { x: 40, y: 40, w: 780, h: 480 }, side: 196, docs: true, ql: { x: 328, y: 142, w: 270 } },
+ compact: { win: { x: 20, y: 20, w: 460, h: 400 }, side: 132, docs: false, ql: { x: 20, y: 108, w: 232 } },
+}
+
+const QL_BAR = 38
+const qlHeight = (w: number) => QL_BAR + Math.round((w * 3) / 4)
+
+// Column rows: what each row is, and how long its placeholder name runs
+type Kind = 'folder' | 'doc' | 'image'
+type Row = { kind: Kind; name: number | string; target?: string }
+
+const DOCUMENTS: Row[] = [
+ { kind: 'folder', name: 74 },
+ { kind: 'folder', name: 96 },
+ { kind: 'folder', name: 'films', target: 'films' },
+ { kind: 'folder', name: 62 },
+ { kind: 'doc', name: 104 },
+ { kind: 'doc', name: 82 },
+ { kind: 'doc', name: 92 },
+]
+
+const FILMS: Row[] = [
+ { kind: 'folder', name: 58 },
+ { kind: 'folder', name: 'japan', target: 'japan' },
+ { kind: 'folder', name: 72 },
+ { kind: 'doc', name: 96 },
+ { kind: 'doc', name: 80 },
+ { kind: 'doc', name: 88 },
+]
+
+const LISBON: Row[] = [
+ { kind: 'image', name: 70 },
+ { kind: 'image', name: 88 },
+ { kind: 'image', name: FILE, target: 'file' },
+ { kind: 'image', name: 64 },
+ { kind: 'image', name: 80 },
+]
+
+async function script(a: Act) {
+ await a.wait(700)
+
+ // 1. Open japan: its column slides in and the title follows
+ a.mark('folder')
+ await a.click({ at: 'japan', x: 0.62, y: 0.6 })
+ await a.set({ lis: true })
+ await a.wait(900)
+
+ // 2. Pick the photo in it
+ a.mark('file')
+ await a.click({ at: 'file', x: 0.66, y: 0.6 })
+ await a.set({ file: true })
+ await a.wait(600)
+
+ // 3. Space: Quick Look zooms out of the thumbnail
+ a.mark('open')
+ await a.key('space')
+ await a.set({ ql: true })
+ await a.wait(2400)
+
+ // 4. Space again: it zooms back into the row
+ a.mark('close')
+ await a.key('space')
+ await a.set({ ql: false })
+ await a.wait(800)
+
+ // 5. Back to films: the japan column closes, as at the start
+ a.mark('back')
+ // (compact has no first column: Back does it, aimed at the back half)
+ await a.click(a.layout === 'wide' ? { at: 'films', x: 0.66, y: 0.6 } : { at: 'back', x: 0.4, y: 0.56 })
+ await a.set({ lis: false, file: false })
+ await a.wait(1400)
+}
+
+export default function FinderGlassDemo() {
+ return (
+
+ width={860}
+ height={440}
+ compact={{ below: 560, width: 400, height: 340 }}
+ initial={initial}
+ script={script}
+ accent="#0a6de6"
+ greys="cool"
+ cursor={{ at: { wide: { at: 'films', x: 0.66, y: 0.6 }, compact: { at: 'back', x: 0.4, y: 0.56 } } }}
+ className="fgl"
+ >
+ {(s, rect, layout) => {
+ const g = GEO[layout]
+ const wide = layout === 'wide'
+ const qh = qlHeight(g.ql.w)
+ // Quick Look closed sits on the thumbnail, shrunk to its width. It
+ // zooms by layout, not transform, so its text lands without a hop
+ const t = rect('thumb')
+ const k = t.width ? t.width / g.ql.w : 0.05
+ const box = s.ql
+ ? { left: g.ql.x, top: g.ql.y, width: g.ql.w, height: qh, borderRadius: 18 }
+ : { left: t.x, top: t.y + t.height / 2 - (qh * k) / 2, width: g.ql.w * k, height: qh * k, borderRadius: 18 * k }
+ return (
+ <>
+ }
+ lead={
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ toolbar={
+ wide ? (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ) : undefined
+ }
+ body={{ className: 'fgl-body', 'data-crop': true }}
+ >
+
+
+
+
+
+
+
+
+
+ {g.docs && (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {FILE}
+ {g.ql.w >= 300 && Open with Preview}
+
+
+
+
+
+
+ >
+ )
+ }}
+
+ )
+}
+
+// Toolbar glyphs: one size and stroke in every glass button
+const tb = { size: 16, strokeWidth: 1.6 }
+
+/**
+ * The folder's name in the title and the path bar: both names share one grid
+ * cell and crossfade, so the box is already as wide as the longer one and
+ * nothing beside it moves.
+ */
+function Names({ on, align = 'start' }: { on: boolean; align?: 'start' | 'center' }) {
+ return
+}
+
+/** A toolbar item in icon-and-text mode: the glass button, its label under it. */
+function Tool({ label, children }: { label: string; children: ReactNode }) {
+ return (
+
+ {children}
+ {label}
+
+ )
+}
+
+function Sidebar() {
+ const ic = { size: 16, strokeWidth: 1.6 }
+ const item = (icon: ReactNode, label: string, extra?: { current?: boolean; cloud?: boolean }) => (
+
+ )
+}
+
+type Sel = 'focus' | 'quiet' | undefined
+
+/**
+ * One column. `sel` maps a row's target to how it is selected: `focus` is the
+ * blue of the column with the keyboard, `quiet` the gray capsule a column
+ * keeps once focus has moved right. `open` brings the column in (it is always
+ * mounted, so it leaves as it came).
+ */
+function Column({ rows, sel, open = true }: { rows: Row[]; sel: Record; open?: boolean }) {
+ return (
+
+ )
+}
+
+function FileIcon({ kind }: { kind: Kind }) {
+ if (kind === 'folder') return
+ if (kind === 'doc') return
+ return (
+
+
+
+ )
+}
+
+// The same local photograph is shared by its thumbnail and Quick Look.
+const PHOTO = new URL('../wikipedia/kawaguchiko.jpg', import.meta.url).href
+const ALT_PHOTO = new URL('../wikipedia/fuji.jpg', import.meta.url).href
+function Photo({ className, alt }: { className?: string; alt?: boolean }) {
+ return
+}
+
+/** The drag image uses the same frame's callback point, with no second RAF. */
+function DragImage({ ghost, children }: { ghost: Ghost; children: ReactNode }) {
+ const ref = useRef(null)
+ const over = useRef(null)
+ const following = ghost.on && !ghost.dropped
+ const { x: ox, y: oy } = ghost.off
+ const { x, y } = ghost.at
+ useLayoutEffect(() => {
+ const root = ref.current?.closest('.ss-stage')
+ let next: HTMLElement | null = null
+ if (following && root) {
+ const box = root.getBoundingClientRect()
+ const scale = box.width / root.offsetWidth || 1
+ const hit = document.elementFromPoint(box.left + x * scale, box.top + y * scale)?.closest('[data-drop]') ?? null
+ if (hit && root.contains(hit)) next = hit
+ }
+ if (next !== over.current) {
+ over.current?.removeAttribute('data-over')
+ next?.setAttribute('data-over', '')
+ over.current = next
+ }
+ }, [following, x, y])
+ useLayoutEffect(() => () => over.current?.removeAttribute('data-over'), [])
+ return (
+
+
+ {children}
+
+
+ )
+}
diff --git a/skills/imported/benjitaylor/stageshow/examples/form-error/form.css b/skills/imported/benjitaylor/stageshow/examples/form-error/form.css
new file mode 100644
index 0000000..2d36349
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/form-error/form.css
@@ -0,0 +1,127 @@
+.fe .ss-stage {
+ overflow: hidden;
+ background: var(--ss-well);
+}
+
+/* Pinned top and sides, off the bottom */
+.fe-win {
+ position: absolute;
+ top: 28px;
+ left: 60px;
+ width: 440px;
+ height: 420px;
+}
+
+.fe-card {
+ display: grid;
+ align-content: start;
+ gap: 4px;
+ height: 100%;
+ padding: 28px 40px;
+ background: var(--ss-surface);
+}
+
+.fe-title {
+ margin-bottom: 14px;
+ color: var(--ss-text-strong);
+ font-size: 18px;
+ font-weight: 600;
+}
+
+.fe-group {
+ display: grid;
+ gap: 6px;
+}
+
+/* One line, always there, so the error never moves the form */
+.fe-lane {
+ height: 18px;
+}
+
+.fe-msg {
+ color: var(--ss-text);
+ font-size: 11px;
+}
+
+.fe-msg--error {
+ color: var(--ss-danger-text, var(--ss-danger));
+}
+
+.fe-button {
+ display: grid;
+ place-items: center;
+ height: 38px;
+ margin-top: 6px;
+ border-radius: 8px;
+ background: var(--ss-accent);
+ color: #fff;
+ font-size: 13px;
+ font-weight: 600;
+ transition: background-color 0.3s ease;
+}
+
+.fe-button[data-done] {
+ background: var(--ss-success);
+}
+
+/* Layers in one cell, crossfading in place */
+.fe-button {
+ grid-template-areas: 'stack';
+}
+
+.fe-layer {
+ grid-area: stack;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ opacity: 0;
+ filter: blur(2px);
+ transition: opacity 0.25s ease, filter 0.25s ease;
+ text-box: trim-both cap alphabetic;
+}
+
+.fe-layer[data-on] {
+ opacity: 1;
+ filter: none;
+ transition-delay: 0.08s;
+}
+
+.fe-labels {
+ display: grid;
+ justify-items: start;
+}
+
+.fe-label {
+ grid-area: 1 / 1;
+ opacity: 0;
+ filter: blur(2px);
+ transition: opacity 0.25s ease, filter 0.25s ease;
+}
+
+.fe-label[data-on] {
+ opacity: 1;
+ filter: none;
+ transition-delay: 0.08s;
+}
+
+.fe-button .ss-check__ring { stroke: rgb(255 255 255 / 0.45); }
+.fe-button .ss-check__arc { stroke: #fff; }
+.fe-button .ss-check__fill { fill: #fff; }
+.fe-button .ss-check__tick { stroke: var(--ss-success); }
+
+/* Narrow: a gutter each side */
+.fe .ss-stage[data-layout='compact'] .fe-win {
+ left: 16px;
+ width: 326px;
+}
+
+.fe .ss-stage[data-layout='compact'] .fe-card {
+ padding: 24px 22px;
+}
+
+/* The label trimmed to cap height, so the Check centers on the letters */
+
+/* Air between one field's message lane and the next field's label */
+.fe-group + .fe-group {
+ margin-top: 8px;
+}
diff --git a/skills/imported/benjitaylor/stageshow/examples/form-error/index.tsx b/skills/imported/benjitaylor/stageshow/examples/form-error/index.tsx
new file mode 100644
index 0000000..48ad555
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/form-error/index.tsx
@@ -0,0 +1,97 @@
+import { Check, Field, Stage, States, Text, Window, type Act } from '../../kit'
+import './form.css'
+
+// A sign-up form catching a mistake. Beats: an email is typed without its
+// ending; the field turns to error and the line under it says why; the
+// ending is typed and the field turns valid; Create account sends, and the
+// button settles on done. Plays once; the still is the payoff.
+//
+// What makes validation read:
+// - The message has a lane of its own under the field from the start: the
+// hint and the error swap in it (States), so the form never grows or
+// shifts when the error arrives.
+// - The error is said by the ring and the line together, in the danger ink,
+// and it leaves the moment the value is fixed: the ring turns calm with a
+// check, the line goes back to the hint.
+// - Nothing in the button moves sideways. It is full width, so every state
+// fits where it is: the idle label and the busy group (check and label)
+// are two layers in one place that crossfade, and inside the group the
+// check holds still while "Creating account" crossfades to "Account
+// created" beside it. Re-centering a group as its width eases slides the
+// icon and the words across the button for no reason.
+//
+// Real strings: the title, the labels, the typed email, the hint and the
+// error, the button.
+
+type S = { email: string; field: 'email' | 'none'; status: 'none' | 'error' | 'valid'; sending: boolean; done: boolean }
+
+const initial: S = { email: '', field: 'email', status: 'none', sending: false, done: false }
+const poster: S = { email: 'maya@northwind.co', field: 'none', status: 'valid', sending: true, done: true }
+
+async function script(a: Act) {
+ await a.wait(700)
+ a.mark('type')
+ await a.type('email', 'maya@northwind', { interval: 55 })
+ await a.wait(300)
+ a.mark('error')
+ a.put({ status: 'error' })
+ await a.wait(1400)
+ a.mark('fix')
+ // Only the ending is typed: a.type would clear the field and start over
+ for (const end of ['maya@northwind.', 'maya@northwind.c', 'maya@northwind.co']) {
+ a.put({ email: end })
+ await a.wait(110)
+ }
+ a.put({ status: 'valid' })
+ await a.wait(700)
+ a.mark('send')
+ await a.click({ at: 'create', x: 0.5, y: 0.5 })
+ a.put({ field: 'none', sending: true })
+ await a.wait(1200)
+ a.put({ done: true })
+ await a.move({ at: 'create', x: 0.75, y: 1.9 })
+ await a.wait(2400)
+}
+
+export default function FormError() {
+ return (
+
+ {(s) => (
+
+
+ Create your account
+
+
+
+ We'll send a link to confirm it,
+ error: Add the domain, like name@company.com,
+ }}
+ />
+
+
+
+
+
+
+
+ Create account
+
+
+ {/* Left-aligned in a cell as wide as the longer label: the
+ shorter sits 2px off center, which no eye sees */}
+
+ Creating account
+ Account created
+
+
+
+
+
+ )}
+
+ )
+}
diff --git a/skills/imported/benjitaylor/stageshow/examples/freeform-drag/drag.css b/skills/imported/benjitaylor/stageshow/examples/freeform-drag/drag.css
new file mode 100644
index 0000000..e112596
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/freeform-drag/drag.css
@@ -0,0 +1,131 @@
+/* All rules belong to this example, including its responsive variants. */
+.fd {
+ & .ss-stage {
+ overflow: hidden;
+ background: var(--ss-well);
+ }
+
+ .fd-window {
+ position: absolute;
+ top: 24px;
+ left: 24px;
+ width: 560px;
+ height: 340px;
+ box-shadow: var(--ss-shadow-hero);
+ }
+
+ .fd-window .ss-window__body {
+ background: var(--ss-surface);
+ }
+
+ .fd-head {
+ display: grid;
+ grid-template-columns: repeat(5, 104px);
+ margin-left: 52px;
+ padding: 10px 0 8px;
+ border-bottom: 1px solid var(--ss-line);
+ font: 500 11px/1 var(--ss-font);
+ color: var(--ss-text);
+ }
+
+ .fd-head span {
+ padding-left: 6px;
+ }
+
+ .fd-body {
+ display: flex;
+ }
+
+ .fd-hours {
+ display: grid;
+ grid-auto-rows: 40px;
+ width: 52px;
+ padding: 0 8px;
+ font: 500 10px/1 var(--ss-font);
+ color: var(--ss-text-faint);
+ text-align: right;
+ }
+
+ .fd-hours span {
+ transform: translateY(-4px);
+ }
+
+ .fd-grid {
+ position: relative;
+ width: 520px;
+ height: 240px;
+ background:
+ linear-gradient(var(--ss-line) 1px, transparent 1px) 0 0 / 100% 40px,
+ linear-gradient(90deg, var(--ss-line) 1px, transparent 1px) 0 0 / 104px 100%;
+ }
+
+ .fd-busy {
+ position: absolute;
+ padding: 8px;
+ border-radius: 6px;
+ background: var(--ss-ink-1);
+ }
+
+ /* The drop slot: where the card will land. Jumps between cells, only fades
+ in and out. */
+ .fd-target {
+ position: absolute;
+ top: 0;
+ left: 0;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.12s ease;
+ border: 1.5px dashed color-mix(in srgb, var(--ss-accent) 55%, transparent);
+ border-radius: 6px;
+ background: var(--ss-accent-soft);
+ }
+
+ .fd-card {
+ position: absolute;
+ top: 0;
+ left: 0;
+ z-index: 10;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ padding: 7px 8px;
+ border-left: 3px solid var(--ss-accent);
+ border-radius: 6px;
+ background: color-mix(in srgb, var(--ss-accent) 12%, var(--ss-surface));
+ font: 600 11px/1.3 var(--ss-font);
+ color: color-mix(in srgb, var(--ss-accent) 70%, #000);
+ /* Translate before scale, so lifting never scales its stage position. */
+ will-change: translate;
+ transition: translate 0.32s cubic-bezier(0.22, 1, 0.36, 1), box-shadow 0.2s ease, scale 0.2s ease;
+ }
+
+ /* Carried: no position transition, so it stays exactly under the cursor */
+ .fd-card[data-dragging] {
+ transition: box-shadow 0.2s ease, scale 0.2s ease;
+ }
+
+ .fd-card[data-lifted] {
+ box-shadow: var(--ss-shadow-lift);
+ scale: 1.03;
+ }
+
+ .fd-card__people {
+ display: flex;
+ }
+
+ .fd-card__people .ss-avatar + .ss-avatar {
+ margin-left: -4px;
+ box-shadow: 0 0 0 1.5px var(--ss-surface);
+ }
+
+ .fd-target[data-show] {
+ opacity: 1;
+ }
+
+ /* The phone is a closer calendar viewport, not a smaller calendar. The two
+ days involved in the drag stay fully visible; neighboring days bleed. */
+ & .ss-stage[data-layout='compact'] .fd-window { left: 16px; top: 16px; }
+ & .ss-stage[data-layout='compact'] .fd-head { transform: translateX(-104px); }
+ & .ss-stage[data-layout='compact'] .fd-grid { margin-left: -104px; }
+ & .ss-stage[data-layout='compact'] .fd-hours { position: relative; z-index: 1; flex: none; background: var(--ss-surface); }
+}
diff --git a/skills/imported/benjitaylor/stageshow/examples/freeform-drag/index.tsx b/skills/imported/benjitaylor/stageshow/examples/freeform-drag/index.tsx
new file mode 100644
index 0000000..317e870
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/freeform-drag/index.tsx
@@ -0,0 +1,135 @@
+import { Stage, Window, Bar, Avatar, cellOf, type Act, type Point, type Rect } from '../../kit'
+import './drag.css'
+
+// Freeform drag with a drop target. The card follows the cursor exactly; the
+// dashed slot shows where it will land. As in real calendars, the slot never
+// travels: it appears in the landing cell, jumps straight to the next cell
+// when that changes, and is hidden while the card is still over its own cell.
+// Anything that slides after the card reads as a second object chasing it.
+// cellOf makes cell borders sticky, so a drag along a border doesn't flicker.
+
+const COLS = 4
+const ROWS = 6
+const COL_W = 104
+const ROW_H = 40
+const START = { col: 1, row: 3 }
+const END = { col: 2, row: 1 }
+
+type Cell = { col: number; row: number }
+type S = { cell: Cell; lifted: boolean; at: Point | null; target: Cell | null }
+
+const cellRect = (grid: Rect, c: Cell): Rect => ({
+ x: grid.x + c.col * COL_W + 3,
+ y: grid.y + c.row * ROW_H + 2,
+ width: COL_W - 6,
+ height: ROW_H * 2 - 4,
+})
+
+async function move(a: Act, from: Cell, to: Cell) {
+ const grid = a.rect('grid', { layout: true })
+ const card = cellRect(grid, from)
+ const grab = { x: card.x + card.width * 0.35, y: card.y + 22 }
+ await a.move(grab)
+ const dest = cellRect(grid, to)
+ let prev: Cell = from
+ await a.drag(
+ { x: dest.x + (grab.x - card.x), y: dest.y + (grab.y - card.y) },
+ (p) => {
+ const at = { x: p.x - (grab.x - card.x), y: p.y - (grab.y - card.y) }
+ // The card's center decides the cell, with sticky borders
+ const col = cellOf(at.x + card.width / 2 - grid.x, COL_W, prev.col)
+ const row = cellOf(at.y + ROW_H / 2 - grid.y, ROW_H, prev.row)
+ prev = { col: Math.max(0, Math.min(COLS - 1, col)), row: Math.max(0, Math.min(ROWS - 2, row)) }
+ // drag() has pressed and closed the hand before its first callback.
+ // Pick up and move in that same frame, never while the hand is open.
+ a.put({ lifted: true, at, target: prev })
+ },
+ { duration: 1100 },
+ )
+ await a.set({ lifted: false, at: null, target: null, cell: prev })
+}
+
+const script = async (a: Act) => {
+ await a.wait(500)
+ await move(a, START, END)
+ await a.wait(1200)
+ // Back again: a caused reset, and a second diagonal in the other direction
+ await move(a, END, START)
+ await a.move('rest')
+ await a.wait(600)
+}
+
+// Friday runs off the window's right edge, so the grid never stops short
+const DAYS = ['Mon 20', 'Tue 21', 'Wed 22', 'Thu 23', 'Fri 24']
+const HOURS = ['9 AM', '10 AM', '11 AM', '12 PM', '1 PM', '2 PM']
+const BUSY: [number, number, number][] = [
+ [0, 0, 2],
+ [3, 1, 1],
+ [0, 4, 1],
+ [3, 3, 2],
+]
+
+export default function FreeformDrag() {
+ return (
+
+ width={560}
+ height={340}
+ compact={{ below: 500, width: 358, height: 340 }}
+ initial={{ cell: START, lifted: false, at: null, target: null }}
+ poster={{ cell: END, lifted: false, at: null, target: null }}
+ script={script}
+ cursor={{ at: 'rest' }}
+ className="fd"
+ >
+ {(s, rect, layout) => {
+ const grid = rect('grid')
+ const card = s.at ? { ...cellRect(grid, s.cell), x: s.at.x, y: s.at.y } : cellRect(grid, s.cell)
+ return (
+ <>
+
+
+
+ {/* Each state is a centered layer in one cell, crossfading:
+ Publish, then the pending check alone, then the done check
+ with Live. Nothing resizes or slides across the button */}
+ Publish
+
+ Live
+
+
The best tools get out of the way. Start with space, then add structure.
+
01
A quieter starting point
Less setup. More time to explore.
+
Interview notes4 sources
+
+
+}
diff --git a/skills/imported/benjitaylor/stageshow/examples/many-of-one/index.tsx b/skills/imported/benjitaylor/stageshow/examples/many-of-one/index.tsx
new file mode 100644
index 0000000..89a2f0e
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/many-of-one/index.tsx
@@ -0,0 +1,64 @@
+import { Badge, Bar, Stage, Text } from '../../kit'
+import './many.css'
+
+// Many of one: a grid of the same small thing, one of them in focus. Here,
+// preview deployments, one per branch. A still.
+//
+// What makes repetition read as an image, not a wall:
+// - Every tile is the same anatomy at the same size: a thumbnail, a line, a
+// status. The sameness is the point; vary only the bar lengths.
+// - The grid runs off every edge, so it reads as going on. Every edge cuts a
+// tile by a third or more, never to a sliver. The focus tile sits off
+// center, a column right of the middle: centered, the outer columns would be
+// slivers.
+// - The focus tile is the only one with words and color. It stays in its
+// cell, the same size, and comes forward by depth: the hero shadow and a
+// ring in the accent. The others are quiet, not faded or blurred.
+//
+// Real strings: the focus tile's branch, its commit and its status.
+
+const COLS = 5
+const ROWS = 4
+const FOCUS = { col: 2, row: 1 }
+
+const W = [[96, 64], [80, 52], [104, 70], [72, 58], [90, 46]]
+
+export default function ManyOfOne() {
+ return (
+
+ {() => (
+
+ )}
+
+ )
+}
diff --git a/skills/imported/benjitaylor/stageshow/examples/many-of-one/many.css b/skills/imported/benjitaylor/stageshow/examples/many-of-one/many.css
new file mode 100644
index 0000000..dc4b2c6
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/many-of-one/many.css
@@ -0,0 +1,88 @@
+.mo .ss-stage {
+ overflow: hidden;
+ background: var(--ss-well);
+}
+
+/* Five columns of 184 with 16 between, offset up and left so every edge
+ cuts a tile by a third or more */
+.mo-grid {
+ position: absolute;
+ top: -78px;
+ left: -116px;
+ display: grid;
+ grid-template-columns: repeat(5, 184px);
+ gap: 16px;
+}
+
+.mo-tile {
+ display: grid;
+ gap: 8px;
+ padding: 8px 8px 12px;
+ border-radius: 12px;
+ background: var(--ss-surface);
+ box-shadow: var(--ss-shadow-card);
+}
+
+.mo-tile[data-focus] {
+ position: relative;
+ z-index: 1;
+ box-shadow: var(--ss-shadow-hero), 0 0 0 1.5px var(--ss-accent);
+}
+
+.mo-thumb {
+ height: 92px;
+ border-radius: 6px;
+ background: var(--ss-ink-1);
+}
+
+/* The focus tile's thumbnail is a tiny page: a heading block and two lines */
+.mo-thumb--focus {
+ display: grid;
+ align-content: start;
+ gap: 6px;
+ padding: 14px 12px;
+ background: linear-gradient(180deg, var(--ss-accent-soft), var(--ss-surface));
+}
+
+.mo-thumb__head {
+ width: 70%;
+ height: 10px;
+ border-radius: 5px;
+ background: var(--ss-accent);
+}
+
+.mo-thumb__line {
+ width: 86%;
+ height: 6px;
+ border-radius: 3px;
+ background: var(--ss-ink-3);
+}
+
+.mo-thumb__line--short {
+ width: 54%;
+}
+
+.mo-meta {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ min-height: 20px;
+ padding: 0 2px;
+}
+
+.mo-branch {
+ color: var(--ss-text-strong);
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.mo-commit {
+ padding: 0 2px;
+ color: var(--ss-text);
+ font-size: 11px;
+}
+
+/* Narrow: the same grid, shifted so the focus tile sits in the middle */
+.mo .ss-stage[data-layout='compact'] .mo-grid {
+ left: -321px;
+}
diff --git a/skills/imported/benjitaylor/stageshow/examples/messages/index.tsx b/skills/imported/benjitaylor/stageshow/examples/messages/index.tsx
new file mode 100644
index 0000000..70a9b82
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/messages/index.tsx
@@ -0,0 +1,90 @@
+import { Stage, Phone, Thread, Bubble, Stamp, Receipt, type Act, Avatar } from '../../kit'
+import './messages.css'
+
+// A conversation at true iOS size: your reply is typed into the composer and
+// sent, the bubble forming out of the field's words; the receipt turns to
+// Read, they start typing, and the dots grow into their reply. The new
+// conversation holds once complete; the host can explicitly replay it.
+
+type S = { draft: string; sent: boolean; delivered: boolean; read: boolean; typing: boolean; reply: boolean }
+const initial: S = { draft: '', sent: false, delivered: false, read: false, typing: false, reply: false }
+// Reduced motion shows the whole exchange
+const poster: S = { ...initial, sent: true, delivered: true, read: true, reply: true }
+const MINE = 'Yes! See you at 7'
+
+async function script(a: Act) {
+ await a.wait(700)
+ await a.click('compose')
+ await a.type('draft', MINE, { interval: 60 })
+ await a.wait(450)
+ await a.click('send')
+ // Clear the field in the same update that adds the bubble: the words lift out of it
+ await a.set({ sent: true, draft: '' })
+ await a.wait(600)
+ await a.set({ delivered: true })
+ await a.wait(900)
+ await a.set({ read: true })
+ await a.wait(700)
+ await a.set({ typing: true })
+ await a.wait(1600)
+ await a.set({ typing: false, reply: true })
+ await a.wait(2600)
+}
+
+function ArrowUp() {
+ return (
+
+ )
+}
+
+export default function Messages() {
+ return (
+
+ {(s) => (
+
+
+
+ Maya
+
+
+ Today 9:38
+ Are we still on for tonight?
+ I booked the place on 5th
+ {s.sent && (
+
+ {MINE}
+
+ )}
+ {s.read ? 'Read 9:41' : 'Delivered'}
+ {(s.typing || s.reply) && (
+
+ Perfect, I'll grab us a table by the window
+
+ )}
+
+
+ Replies in one place
+ Every channel lands in one inbox, threaded.
+
+ >
+ )}
+
+ )
+}
diff --git a/skills/imported/benjitaylor/stageshow/examples/overlap/index.tsx b/skills/imported/benjitaylor/stageshow/examples/overlap/index.tsx
new file mode 100644
index 0000000..8a7981f
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/overlap/index.tsx
@@ -0,0 +1,78 @@
+import { Badge, Bar, Row, Stage, Text, Window } from '../../kit'
+import './overlap.css'
+
+// Overlapping windows: a checkout card in front of the dashboard it reports
+// to. A still. The story is two views of one payment: the buyer's side in
+// front, the merchant's behind.
+//
+// What makes an overlap read as placed, not dropped:
+// - The front card crosses the back window's left edge and stops inside its
+// sidebar, a clear strip of sidebar showing before the table: it covers
+// filler, never the table. An edge that lands exactly on the sidebar's
+// line reads as an accident (near-tangent); either clear it like this or
+// overlap far enough to be plainly deliberate.
+// - The front card is smaller and a level above: a stronger shadow, no
+// chrome. Only one surface has chrome; two sets of traffic lights fight.
+// - The back window is pinned top and runs off the bottom, ending before
+// the stage's right edge so the payment's badge is whole; the card runs
+// off nothing. The eye reads front to back: pay, then paid.
+// - The payment in the card is the top row of the table behind it, the one
+// real row in the merchant's list, so the two surfaces tell one event.
+//
+// Real strings: the card's product, price and button; the table's title,
+// its top row and the badge. Everything else is placeholder.
+
+const SIDEBAR = 150
+
+export default function Overlap() {
+ return (
+
+ {(_s, _rect, layout) => {
+ const side = layout === 'compact' ? 120 : SIDEBAR
+ return (
+ <>
+
+
+ Subscribe
+
+ >
+ )
+ }}
+
+ )
+}
diff --git a/skills/imported/benjitaylor/stageshow/examples/overlap/overlap.css b/skills/imported/benjitaylor/stageshow/examples/overlap/overlap.css
new file mode 100644
index 0000000..33c52d6
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/overlap/overlap.css
@@ -0,0 +1,134 @@
+.ov .ss-stage {
+ overflow: hidden;
+ background: var(--ss-well);
+}
+
+/* The back window: pinned top, off the bottom, a gutter on the right */
+.ov-win {
+ position: absolute;
+ top: 32px;
+ left: 120px;
+ width: 480px;
+ height: 420px;
+}
+
+.ov-app {
+ display: grid;
+ grid-template-columns: var(--ov-side) 1fr;
+ height: 100%;
+ background: var(--ss-surface);
+}
+
+.ov-side {
+ display: grid;
+ align-content: start;
+ gap: 12px;
+ padding: 18px 16px;
+ border-right: 1px solid var(--ss-line);
+ background: var(--ss-well);
+}
+
+.ov-main {
+ display: grid;
+ align-content: start;
+ padding: 18px 24px;
+}
+
+.ov-title {
+ margin-bottom: 10px;
+ color: var(--ss-text-strong);
+ font-size: 15px;
+ font-weight: 600;
+}
+
+.ov-row {
+ height: 52px;
+ border-radius: 0;
+ border-bottom: 1px solid var(--ss-line-faint);
+}
+
+.ov-amount {
+ color: var(--ss-text-strong);
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.ov-who {
+ color: var(--ss-text);
+ font-size: 11px;
+}
+
+/* The front card: a level above the window, on the stage's 32px gutter, its
+ right edge 24px short of the sidebar's line (120 + 150 = 270) so a strip
+ of sidebar shows */
+.ov-card {
+ position: absolute;
+ top: 112px;
+ left: 32px;
+ width: 214px;
+ display: grid;
+ gap: 4px;
+ padding: 18px 18px 20px;
+ border-radius: 12px;
+ background: var(--ss-surface);
+ box-shadow: var(--ss-shadow-satellite);
+}
+
+.ov-product {
+ color: var(--ss-text);
+ font-size: 12px;
+ font-weight: 500;
+}
+
+.ov-price {
+ margin-bottom: 12px;
+ color: var(--ss-text-strong);
+ font-size: 22px;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+}
+
+.ov-per {
+ color: var(--ss-text);
+ font-size: 12px;
+ font-weight: 400;
+ letter-spacing: 0;
+}
+
+.ov-fields {
+ display: grid;
+ gap: 8px;
+}
+
+.ov-pay {
+ display: grid;
+ place-items: center;
+ height: 36px;
+ margin-top: 12px;
+ border-radius: 8px;
+ background: var(--ss-accent);
+ color: #fff;
+ font-size: 13px;
+ font-weight: 600;
+}
+
+/* Narrow: the window moves in, a narrower sidebar, and the card's right edge
+ stops 24px short of its line (90 + 120 = 210) */
+.ov .ss-stage[data-layout='compact'] .ov-win {
+ left: 90px;
+ width: 280px;
+}
+
+.ov .ss-stage[data-layout='compact'] .ov-main {
+ padding: 18px 28px 18px 16px;
+}
+
+.ov .ss-stage[data-layout='compact'] .ov-card {
+ left: 16px;
+ width: 170px;
+ padding: 16px 14px 16px;
+}
+
+.ov .ss-stage[data-layout='compact'] .ov-price {
+ font-size: 19px;
+}
diff --git a/skills/imported/benjitaylor/stageshow/examples/panel-annotation/index.tsx b/skills/imported/benjitaylor/stageshow/examples/panel-annotation/index.tsx
new file mode 100644
index 0000000..5e761fb
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/panel-annotation/index.tsx
@@ -0,0 +1,110 @@
+import { Bar, Connector, Roll, Stage, Text, Window, type Act } from '../../kit'
+import './panel.css'
+
+// A settings panel over the product it changes, with one annotation tying a
+// setting to what it does. Beats: the cursor picks a new accent in the
+// Branding panel; the checkout's Pay button takes the color; a leader draws
+// from the accent row to the button. Plays once; the still is the payoff.
+//
+// What makes a panel-over-product picture read:
+// - Two jobs, two weights. The checkout is the product, large, behind; the
+// panel is the tool, smaller, in front, straddling the checkout's right
+// edge over its empty margin, never over the form.
+// - The panel and the thing it changes share one color change: the swatch,
+// the hex and the button move together, so the cause is plain before the
+// line arrives.
+// - One leader, after the change: out of the panel's bottom edge under the
+// chosen swatch, down through empty ground, then into the button's right
+// edge, ending short of it. It never runs over the panel's or the form's
+// content. It explains which setting did that; a line per row would be a
+// diagram, not a picture.
+// - Narrow, there is no clear path for a leader: the panel sits above the
+// checkout, over its chrome only, and the shared color carries the story.
+//
+// Real strings: the merchant, the amount, Pay, the panel's title and its two
+// color rows.
+
+type S = { accent: number; linked: boolean }
+
+const ACCENTS = [
+ { hex: '#2563EB', name: 'blue' },
+ { hex: '#7C3AED', name: 'violet' },
+ { hex: '#059669', name: 'green' },
+ { hex: '#EA580C', name: 'orange' },
+]
+
+const initial: S = { accent: 0, linked: false }
+const poster: S = { accent: 1, linked: true }
+
+async function script(a: Act) {
+ await a.wait(1000)
+ a.mark('pick')
+ await a.click({ at: 'swatch-1', x: 0.5, y: 0.5 })
+ a.put({ accent: 1 })
+ await a.wait(500)
+ a.mark('link')
+ a.put({ linked: true })
+ await a.move({ at: 'panel', x: 0.6, y: 1.15 })
+ await a.wait(2600)
+}
+
+export default function PanelAnnotation() {
+ return (
+
+ {(s, rect, layout) => {
+ const accent = ACCENTS[s.accent]!.hex
+ const panel = rect('panel')
+ const swatch = rect(`swatch-${s.accent}`)
+ const pay = rect('pay')
+ return (
+ <>
+
+
+ Northwind
+ $60.00
+
+
+
+
+
+
+
+
+ Pay $60.00
+
+
+
+
+ Branding
+ How your brand shows on checkout
+
+ Brand color
+ #111827
+
+
+ Accent color
+
+
+ {accent}
+
+
+
+ {ACCENTS.map((c, i) => (
+
+ ))}
+
+
+
+
+
+
+
+ {layout !== 'compact' && panel.width > 0 && pay.width > 0 && (
+
+ )}
+ >
+ )
+ }}
+
+ )
+}
diff --git a/skills/imported/benjitaylor/stageshow/examples/panel-annotation/panel.css b/skills/imported/benjitaylor/stageshow/examples/panel-annotation/panel.css
new file mode 100644
index 0000000..971bd42
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/panel-annotation/panel.css
@@ -0,0 +1,152 @@
+.pa .ss-stage {
+ overflow: hidden;
+ background: var(--ss-well);
+}
+
+/* The product: pinned top-left, off the bottom */
+.pa-win {
+ position: absolute;
+ top: 32px;
+ left: 32px;
+ width: 340px;
+ height: 460px;
+}
+
+.pa-checkout {
+ display: grid;
+ gap: 6px;
+ padding: 24px 28px;
+ background: var(--ss-surface);
+ height: 100%;
+}
+
+.pa-merchant {
+ color: var(--ss-text);
+ font-size: 12px;
+ font-weight: 500;
+}
+
+.pa-amount {
+ margin-bottom: 12px;
+ color: var(--ss-text-strong);
+ font-size: 26px;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+}
+
+.pa-fields {
+ display: grid;
+ gap: 10px;
+}
+
+.pa-pair {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 10px;
+}
+
+.pa-pay {
+ display: grid;
+ place-items: center;
+ height: 38px;
+ margin-top: 14px;
+ border-radius: 8px;
+ background: var(--pa-accent);
+ color: #fff;
+ font-size: 13px;
+ font-weight: 600;
+ transition: background-color 0.45s ease;
+}
+
+/* The tool: smaller, in front, straddling the checkout's right edge over its
+ margin; a level above, so a stronger shadow */
+.pa-panel {
+ position: absolute;
+ top: 84px;
+ left: 352px;
+ width: 248px;
+ display: grid;
+ gap: 2px;
+ padding: 16px 18px 14px;
+ border-radius: 12px;
+ background: var(--ss-surface);
+ box-shadow: var(--ss-shadow-satellite);
+}
+
+.pa-title {
+ color: var(--ss-text-strong);
+ font-size: 13px;
+ font-weight: 600;
+}
+
+.pa-hint {
+ margin-bottom: 8px;
+ color: var(--ss-text);
+ font-size: 11px;
+}
+
+.pa-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ min-height: 36px;
+ border-top: 1px solid var(--ss-line);
+}
+
+.pa-label {
+ color: var(--ss-text-strong);
+ font-size: 12px;
+}
+
+.pa-value {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 3px 8px 3px 4px;
+ border-radius: 6px;
+ box-shadow: 0 0 0 1px var(--ss-line);
+ color: var(--ss-text);
+ font: 500 11px ui-monospace, SFMono-Regular, Menlo, monospace;
+}
+
+.pa-chip {
+ width: 16px;
+ height: 16px;
+ border-radius: 4px;
+ transition: background-color 0.45s ease;
+}
+
+.pa-swatches {
+ display: flex;
+ gap: 8px;
+ padding: 2px 0 12px;
+}
+
+.pa-swatch {
+ width: 22px;
+ height: 22px;
+ border-radius: 50%;
+ box-shadow: 0 0 0 2px var(--ss-surface), 0 0 0 2px transparent;
+ transition: box-shadow 0.25s ease;
+}
+
+.pa-swatch[data-on] {
+ box-shadow: 0 0 0 2px var(--ss-surface), 0 0 0 3.5px var(--ss-text-strong);
+}
+
+/* Narrow: the panel on top, the checkout under it full width with only its
+ chrome covered, so the form and Pay stay whole */
+.pa .ss-stage[data-layout='compact'] .pa-win {
+ top: 196px;
+ left: 16px;
+ width: 326px;
+}
+
+.pa .ss-stage[data-layout='compact'] .pa-panel {
+ top: 16px;
+ left: 94px;
+}
+
+/* The leader is annotation, not product: neutral ink, so it never reads as
+ the accent the panel just replaced */
+.pa .ss-connector { --ss-accent: var(--ss-text-strong); }
diff --git a/skills/imported/benjitaylor/stageshow/examples/payout-flow/index.tsx b/skills/imported/benjitaylor/stageshow/examples/payout-flow/index.tsx
new file mode 100644
index 0000000..fe61114
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/payout-flow/index.tsx
@@ -0,0 +1,136 @@
+import { Stage, Bar, Check, Count, Roll, Connector, Icon, Swap, type Act } from '../../kit'
+import './pace.css'
+
+// A cursorless payout story played once as it scrolls into view.
+// Things reveal, then move to make room for what comes next; groups stagger;
+// a line ties the new card to the row it came from; the payoff is annotated.
+// This scene uses long arrivals and overlapping beats. Other stories can
+// need different timing; see references/choreography.md.
+
+type S = { step: number; sent: boolean }
+const START: S = { step: 0, sent: false }
+
+const ROWS = [
+ { amount: '$12,400.00', w: 54 },
+ { amount: '$8,915.20', w: 46 },
+ { amount: '$21,060.75', w: 60 },
+ { amount: '$5,330.00', w: 40 },
+]
+
+const script = async (a: Act) => {
+ await a.wait(300)
+ a.mark('reveal')
+ a.put({ step: 1 }) // the balance rises in, centered, and counts up
+ await a.wait(700)
+ a.put({ step: 2 }) // payout rows stagger in under it
+ await a.wait(1500)
+ a.mark('move')
+ a.put({ step: 3 }) // it slides aside as the payout card arrives
+ await a.wait(1300)
+ a.put({ step: 4 }) // the line draws from the row to the card
+ await a.wait(1100)
+ a.mark('send')
+ a.put({ step: 5 }) // sending
+ await a.wait(1300)
+ a.put({ sent: true })
+ await a.wait(500)
+ a.put({ step: 6 }) // the annotation lands
+}
+
+export default function PayoutFlow() {
+ return (
+ width={640} height={400} compact={{ below: 600, width: 358, height: 610 }} initial={START} play="once" poster={{ step: 6, sent: true }} script={script} className="sp">
+ {(s, rect, layout) => {
+ const compact = layout === 'compact'
+ const row = rect('row-0')
+ const card = rect('instant')
+ return (
+ <>
+
+ = 1 || undefined} data-aside={s.step >= 3 || undefined}>
+ Available balance
+ = 1 ? 84210.5 : 0} duration={1400} format={money} />
+
+
+ Payouts
+
+
+ {ROWS.map((r, i) => (
+
= 2 || undefined}
+ style={{ transitionDelay: s.step >= 2 ? `${i * 90}ms` : '0ms' }}
+ >
+
+
+ {r.amount}
+ {/* The payout's own row settles when it sends, closing the loop with the line */}
+
+ {i === 0 ? {s.sent ? 'Paid' : 'Pending'} : 'Paid'}
+
+
+ ))}
+
+
+ {/* A full column beside the balance, top-aligned: no dead corners.
+ Its contents build in a stagger once it has landed. */}
+ = 3 || undefined}>
+
+
+
+
+ Instant payout
+
+
$12,400.00
+
+
+ To
+
+
+ Chase ···· 4821
+
+
+
+ Fee
+ $124.00
+
+
+ {/* Each state is its own centered group, crossfading in place: nothing
+ ever re-centers or slides sideways between states */}
+
+
+ Pay out now
+
+ {/* Sending and Sent are one group: one Check turns pending, then
+ fills and draws its tick. The label is a Swap, so its box
+ eases to the new word and the group re-centers with it: a
+ box held at "Sending" would leave "Sent" off center */}
+ = 5 || undefined}>
+
+ {s.sent ? 'Sent' : 'Sending'}
+
+
+
+ )
+ }}
+
+ )
+}
diff --git a/skills/imported/benjitaylor/stageshow/examples/powerpoint/pp.css b/skills/imported/benjitaylor/stageshow/examples/powerpoint/pp.css
new file mode 100644
index 0000000..93881b6
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/powerpoint/pp.css
@@ -0,0 +1,514 @@
+/* PowerPoint for Mac. The stage clips the window: pinned top-left and right
+ on one 30px gutter, running off the bottom. */
+
+.pp-stage .ss-stage {
+ overflow: hidden;
+ background: var(--ss-well);
+ border-radius: 14px;
+}
+
+.pp-window {
+ position: absolute;
+ background: var(--ss-surface);
+ box-shadow: var(--ss-shadow-hero);
+}
+
+.pp-window .ss-window__body {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ flex: 1;
+}
+
+.pp-spacer { flex: 1; }
+
+/* ---- Tabs ---- */
+
+.pp-tabs {
+ display: flex;
+ flex-shrink: 0;
+ align-items: center;
+ gap: 18px;
+ height: 30px;
+ padding: 0 16px;
+ background: var(--ss-chrome);
+}
+
+.pp-window[data-layout="compact"] .pp-tabs { gap: 14px; padding: 0 12px; }
+
+.pp-tab {
+ position: relative;
+ font-size: 11.5px;
+ line-height: 30px;
+ color: var(--ss-text);
+ white-space: nowrap;
+}
+
+.pp-tab[data-active] {
+ color: var(--ss-accent);
+ font-weight: 600;
+}
+
+.pp-tab[data-active]::after {
+ content: "";
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: 3px;
+ height: 2px;
+ border-radius: 1px;
+ background: var(--ss-accent);
+}
+
+.pp-share {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 54px;
+ height: 20px;
+ border-radius: 5px;
+ background: var(--ss-accent);
+}
+
+.pp-share-bar { background: rgba(255, 255, 255, 0.6) !important; }
+
+/* ---- Ribbon ---- */
+
+.pp-ribbon {
+ display: flex;
+ flex-shrink: 0;
+ align-items: center;
+ gap: 14px;
+ height: 62px;
+ padding: 0 16px;
+ background: var(--ss-chrome);
+ border-bottom: 1px solid var(--ss-line);
+ color: var(--ss-icon);
+}
+
+.pp-window[data-layout="compact"] .pp-ribbon { gap: 10px; padding: 0 12px; }
+
+.pp-big {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 7px;
+ width: 40px;
+}
+
+.pp-sep {
+ width: 1px;
+ height: 40px;
+ background: var(--ss-line);
+}
+
+.pp-group {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.pp-row {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ height: 20px;
+}
+
+.pp-icons { gap: 11px; }
+
+.pp-field {
+ display: inline-flex;
+ align-items: center;
+ height: 20px;
+ padding: 0 7px;
+ border-radius: 5px;
+ box-shadow: inset 0 0 0 1px var(--ss-line);
+}
+
+.pp-designer {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 5px;
+ width: 64px;
+ height: 52px;
+ border-radius: 7px;
+ transition: background 0.15s ease;
+}
+
+.pp-designer[data-hover],
+.pp-designer[data-open] {
+ background: var(--ss-ink-2);
+}
+
+.pp-designer-icon { color: var(--ss-accent); }
+
+.pp-designer-label {
+ font-size: 10.5px;
+ line-height: 1.3;
+ color: var(--ss-text-strong);
+}
+
+/* ---- Rail, canvas, notes ---- */
+
+.pp-main {
+ position: relative;
+ display: flex;
+ flex: 1;
+ min-height: 0;
+}
+
+.pp-rail {
+ position: relative;
+ flex-shrink: 0;
+ background: var(--ss-canvas);
+ border-right: 1px solid var(--ss-line);
+}
+
+.pp-thumb {
+ position: absolute;
+ left: 0;
+ right: 0;
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ padding-left: 10px;
+}
+
+.pp-window[data-layout="compact"] .pp-thumb { gap: 5px; padding-left: 6px; }
+
+.pp-num {
+ width: 10px;
+ font-size: 10px;
+ line-height: 1.3;
+ color: var(--ss-text-faint);
+ text-align: right;
+}
+
+.pp-window[data-layout="compact"] .pp-num { width: 7px; font-size: 9px; }
+
+.pp-thumb-box {
+ border-radius: 3px;
+ box-shadow: 0 0 0 1px var(--ss-line), 0 1px 2px rgba(0, 0, 0, 0.05);
+ transition: box-shadow 0.18s ease;
+}
+
+.pp-thumb-box[data-selected] {
+ box-shadow: 0 0 0 2px var(--ss-accent), 0 1px 2px rgba(0, 0, 0, 0.05);
+}
+
+.pp-mini {
+ overflow: hidden;
+ border-radius: 3px;
+ background: #fff;
+}
+
+.pp-work {
+ position: relative;
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.pp-canvas {
+ position: relative;
+ flex-shrink: 0;
+ background: var(--ss-well);
+}
+
+.pp-fit {
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 640px;
+ height: 360px;
+ transform-origin: 0 0;
+ transition: transform 0.6s cubic-bezier(0.3, 0.75, 0.1, 1);
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05), 0 4px 14px rgba(0, 0, 0, 0.08);
+}
+
+/* A slide swap: the old one fades out, the new one fades in with a small rise. */
+.pp-page {
+ position: absolute;
+ inset: 0;
+ opacity: 0;
+ /* The outgoing slide stays solid under the incoming one, then drops away */
+ transition: opacity 0s linear 0.4s;
+}
+
+.pp-page[data-on] {
+ z-index: 1;
+ opacity: 1;
+ transition: opacity 0.35s ease;
+ animation: pp-rise 0.45s cubic-bezier(0.25, 0.8, 0.25, 1);
+}
+
+@keyframes pp-rise {
+ from { transform: translateY(8px); }
+}
+
+.pp-notes {
+ display: flex;
+ align-items: center;
+ flex-shrink: 0;
+ align-items: flex-start;
+ height: 64px;
+ padding: 14px 24px 0;
+ background: var(--ss-surface);
+ border-top: 1px solid var(--ss-line);
+}
+
+.pp-window[data-layout="compact"] .pp-notes { height: 56px; padding: 12px 14px 0; }
+
+.pp-status {
+ display: flex;
+ align-items: center;
+ flex-shrink: 0;
+ height: 26px;
+ padding: 0 16px;
+ background: var(--ss-canvas);
+ border-top: 1px solid var(--ss-line);
+}
+
+/* ---- Designer pane ---- */
+
+.pp-pane {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ padding: 0 16px;
+ background: var(--ss-surface);
+ border-left: 1px solid var(--ss-line);
+ transform: translateX(100%);
+ transition: transform 0.6s cubic-bezier(0.3, 0.75, 0.1, 1);
+}
+
+.pp-pane[data-open] { transform: none; }
+
+.pp-window[data-layout="compact"] .pp-pane { gap: 10px; padding: 0 12px; }
+
+.pp-pane-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ height: 40px;
+ flex-shrink: 0;
+}
+
+.pp-window[data-layout="compact"] .pp-pane-head { height: 34px; }
+
+.pp-pane-title {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--ss-text-strong);
+}
+
+.pp-window[data-layout="compact"] .pp-pane-title { font-size: 12px; }
+
+.pp-close {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 22px;
+ height: 22px;
+ margin-right: -4px;
+ border-radius: 5px;
+ color: var(--ss-icon);
+ transition: background 0.15s ease;
+}
+
+.pp-close[data-hover] { background: var(--ss-ink-2); }
+
+.pp-idea {
+ flex-shrink: 0;
+ border-radius: 4px;
+ box-shadow: 0 0 0 1px var(--ss-line);
+ opacity: 0;
+ transform: translateY(10px);
+ transition:
+ opacity 0.4s ease var(--d, 0ms),
+ transform 0.6s cubic-bezier(0.25, 0.8, 0.25, 1) var(--d, 0ms),
+ box-shadow 0.15s ease;
+}
+
+.pp-idea .pp-mini { border-radius: 4px; }
+
+.pp-idea[data-show] { opacity: 1; transform: none; }
+
+.pp-idea[data-hover] { box-shadow: 0 0 0 2px var(--ss-ink-5); }
+
+.pp-idea[data-picked] { box-shadow: 0 0 0 2px var(--ss-accent); }
+
+/* ---- The slide (640 × 360) ---- */
+
+.pp-slide {
+ position: relative;
+ width: 640px;
+ height: 360px;
+ overflow: hidden;
+ background: #fff;
+ font-family: var(--ss-font);
+ --move: 0.9s cubic-bezier(0.45, 0, 0.1, 1);
+}
+
+.pp-panel {
+ position: absolute;
+ left: 300px;
+ top: 0;
+ width: 340px;
+ height: 360px;
+ background: #1f2b3a;
+ transform: translateX(100%);
+ transition: transform var(--move);
+}
+
+.pp-panel[data-show] { transform: none; }
+
+.pp-photo {
+ position: absolute;
+ overflow: hidden;
+ transition: left var(--move), top var(--move), width var(--move), height var(--move);
+}
+
+.pp-photo-svg {
+ display: block;
+ width: 100%;
+ height: 100%;
+}
+
+.pp-shade {
+ position: absolute;
+ inset: 0;
+ background: linear-gradient(180deg, rgba(15, 22, 32, 0) 35%, rgba(15, 22, 32, 0.72) 100%);
+ transition: opacity 0.5s ease;
+}
+
+.pp-rule {
+ position: absolute;
+ left: 336px;
+ top: 108px;
+ width: 44px;
+ height: 4px;
+ border-radius: 2px;
+ background: #e8834f;
+ transform: scaleX(0);
+ transform-origin: left;
+ transition: transform 0.5s cubic-bezier(0.25, 0.8, 0.25, 1);
+}
+
+.pp-rule[data-show] {
+ transform: none;
+ transition-delay: 0.6s;
+}
+
+.pp-title {
+ position: absolute;
+ left: 0;
+ top: 0;
+ font-size: 32px;
+ font-weight: 600;
+ line-height: 1.2;
+ letter-spacing: -0.01em;
+ white-space: nowrap;
+ color: #1f1f1f;
+ transition: transform var(--move), color 0.25s ease 0.15s;
+}
+
+.pp-slide[data-light] .pp-title {
+ color: #fff;
+ transition: transform var(--move), color 0.25s ease 0.55s;
+}
+
+.pp-bullets {
+ position: absolute;
+ left: 0;
+ top: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 22px;
+ transition: transform var(--move) 0.05s;
+}
+
+.pp-bullet {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+}
+
+.pp-dot {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: #3a3a3a;
+ transition: background 0.25s ease 0.15s;
+}
+
+.pp-line {
+ height: 10px;
+ border-radius: 5px;
+ background: #dcdcdc;
+ transition: background 0.25s ease 0.15s;
+}
+
+.pp-slide[data-light] .pp-dot { background: #e8834f; transition-delay: 0.6s; }
+.pp-slide[data-light] .pp-line { background: rgba(255, 255, 255, 0.32); transition-delay: 0.6s; }
+
+/* Slide 1: the title slide */
+.pp-slide[data-n="1"] {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 22px;
+}
+
+.pp-s1-title {
+ font-size: 40px;
+ font-weight: 600;
+ letter-spacing: -0.015em;
+ line-height: 1.2;
+ color: #1f1f1f;
+}
+
+.pp-s1-sub {
+ width: 220px;
+ height: 12px;
+ border-radius: 6px;
+ background: #dcdcdc;
+}
+
+/* Later slides: a title and a few lines */
+.pp-slide[data-n="other"] {
+ display: flex;
+ flex-direction: column;
+ gap: 26px;
+ padding: 52px 56px;
+}
+
+.pp-o-title {
+ height: 24px;
+ margin-bottom: 16px;
+ border-radius: 6px;
+ background: #cfcfcf;
+}
+
+.pp-o-row {
+ height: 12px;
+ border-radius: 6px;
+ background: #e2e2e2;
+}
+
+/* Narrow, the window runs off the left by the rail's width (78px): the tab
+ row and ribbon start that far in, so they begin in frame with Home, never
+ a word sliced at the stage edge. Paste goes to make room. */
+.pp-window[data-layout="compact"] .pp-tabs,
+.pp-window[data-layout="compact"] .pp-ribbon { padding-left: 94px; }
+.pp-window[data-layout="compact"] .pp-ribbon > :first-child { display: none; }
diff --git a/skills/imported/benjitaylor/stageshow/examples/presence/index.tsx b/skills/imported/benjitaylor/stageshow/examples/presence/index.tsx
new file mode 100644
index 0000000..cb354e0
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/presence/index.tsx
@@ -0,0 +1,130 @@
+import type { CSSProperties } from 'react'
+import { Bar, Facepile, Stage, Window, type Act, type Rect } from '../../kit'
+import './presence.css'
+
+// Presence: two teammates working in the same doc. Beats: Maya's cursor
+// comes to a sentence and selects it, her color sweeping across it under
+// her name; Jon's cursor comes to the next line and types two words with a
+// caret in his color; both rest. Loops; the reset fades their edits out
+// while the cursors are off at the margins.
+//
+// What makes presence read as people, not decoration:
+// - Each person is one color everywhere: their face in the pile, their
+// cursor, their name flag, their selection, their caret. Two people, two
+// colors, neither of them the doc's accent.
+// - The name rides the cursor, just below and right of its tip, so the eye
+// reads who as well as where. A bare colored arrow is just a second
+// pointer.
+// - They move like hands: an ease in and out over most of a second, never
+// linear, never in step with each other.
+// - Their work is in the document's own marks: a selection is a text
+// highlight, typing is words arriving at a caret. No bubbles, no badges.
+// - There is no own-cursor: the viewer is watching the others.
+//
+// Real strings: the doc's title, the two sentences in play, the typed words,
+// the two names. The rest is placeholder lines at a paragraph's measure.
+
+type Spot = 'rest' | 'start' | 'end' | 'ins' | 'away'
+type S = { maya: Spot; jon: Spot; sel: number; jonText: string; caret: boolean }
+
+const initial: S = { maya: 'rest', jon: 'rest', sel: 0, jonText: '', caret: false }
+const PEOPLE = { maya: '#d1478a', jon: '#2f9e8f' }
+
+async function script(a: Act) {
+ await a.wait(700)
+ a.mark('maya')
+ a.put({ maya: 'start' })
+ await a.wait(1000)
+ a.put({ maya: 'end', sel: 1 })
+ await a.wait(500)
+ a.mark('jon')
+ a.put({ jon: 'ins' })
+ // His caret appears when he gets there, as his click places it
+ await a.wait(1100)
+ a.put({ caret: true })
+ await a.wait(250)
+ await a.type('jonText', ' and EU', { interval: 110 })
+ await a.wait(2400)
+ a.mark('reset')
+ a.put({ maya: 'away', jon: 'away', caret: false })
+ await a.wait(700)
+ a.put({ sel: 0, jonText: '' })
+ await a.wait(700)
+ a.put({ maya: 'rest', jon: 'rest' })
+ await a.wait(900)
+}
+
+function spot(who: 'maya' | 'jon', at: Spot, sel: Rect, ins: Rect, doc: Rect): { x: number; y: number } {
+ // A name flag never leaves the doc: about 60px from its right edge at most
+ const right = doc.x + doc.width - 64
+ if (!sel.width) return { x: -100, y: -100 }
+ if (who === 'maya') {
+ if (at === 'start') return { x: sel.x, y: sel.y + sel.height * 0.6 }
+ if (at === 'end') return { x: sel.x + sel.width, y: sel.y + sel.height * 0.6 }
+ if (at === 'away') return { x: sel.x - 60, y: sel.y - 70 }
+ return { x: sel.x + 40, y: sel.y - 50 }
+ }
+ if (at === 'ins') return { x: Math.min(right, ins.x + 2), y: ins.y + ins.height * 0.75 }
+ if (at === 'away') return { x: Math.min(right, ins.x + 120), y: ins.y + 100 }
+ return { x: Math.min(right, ins.x + 70), y: ins.y + 74 }
+}
+
+export default function Presence() {
+ return (
+ width={660} height={420} compact={{ below: 660, width: 358, height: 420 }} initial={initial} poster={{ maya: 'end', jon: 'ins', sel: 1, jonText: ' and EU', caret: true }} script={script} className="pr">
+ {(s, rect) => {
+ const sel = rect('sel')
+ const ins = rect('ins')
+ const doc = rect('doc')
+ const m = spot('maya', s.maya, sel, ins, doc)
+ const j = spot('jon', s.jon, sel, ins, doc)
+ return (
+ <>
+ }>
+
+
Launch plan
+
+
+
+
+
+
+
+ Pricing goes live on Thursday.
+ {' '}
+ Checkout opens in the US
+ {/* Where Jon clicked: his words arrive to the right of it */}
+
+
+ {s.jonText}
+
+
+ .
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )
+ }}
+
+ )
+}
+
+function Peer({ name, color, at, slow }: { name: string; color: string; at: { x: number; y: number }; slow?: boolean }) {
+ return (
+
+
+ {name}
+
+ )
+}
diff --git a/skills/imported/benjitaylor/stageshow/examples/presence/presence.css b/skills/imported/benjitaylor/stageshow/examples/presence/presence.css
new file mode 100644
index 0000000..ca401ff
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/presence/presence.css
@@ -0,0 +1,135 @@
+.pr .ss-stage {
+ overflow: hidden;
+ background: var(--ss-well);
+}
+
+/* One window, inset at the top, off the bottom */
+.pr-win {
+ position: absolute;
+ top: 40px;
+ left: 50%;
+ width: 580px;
+ translate: -50% 0;
+}
+
+.pr .ss-stage[data-layout='compact'] .pr-win {
+ top: 16px;
+ width: 326px;
+}
+
+.pr-doc {
+ min-height: 440px;
+ padding: 34px 44px 0;
+ background: var(--ss-surface);
+}
+
+.pr .ss-stage[data-layout='compact'] .pr-doc {
+ padding: 24px 22px 0;
+}
+
+.pr-title {
+ margin: 0 0 18px;
+ color: var(--ss-text-strong);
+ font-size: 24px;
+ font-weight: 650;
+ letter-spacing: -0.01em;
+}
+
+.pr-lines {
+ display: grid;
+ gap: 11px;
+ margin: 6px 0;
+}
+
+.pr-p {
+ margin: 16px 0 18px;
+ color: var(--ss-text-strong);
+ font-size: 15px;
+ line-height: 1.65;
+}
+
+/* Maya's selection: her color sweeps across the sentence under her cursor.
+ The same duration and curve as her cursor's move (.pr-peer), so the
+ highlight's edge is always at the tip; a quicker sweep runs ahead of her */
+.pr-sel {
+ border-radius: 2px;
+ background-image: linear-gradient(color-mix(in srgb, var(--who) 22%, transparent), color-mix(in srgb, var(--who) 22%, transparent));
+ background-repeat: no-repeat;
+ background-size: 0% 100%;
+ /* A deselect is instant, as in the app; only selecting sweeps */
+ transition: none;
+ -webkit-box-decoration-break: clone;
+ box-decoration-break: clone;
+}
+
+.pr-sel[data-on] {
+ background-size: 100% 100%;
+ transition: background-size 0.95s cubic-bezier(0.45, 0, 0.25, 1);
+}
+
+.pr-anchor {
+ display: inline-block;
+ width: 0;
+ height: 1em;
+ vertical-align: -0.15em;
+}
+
+.pr-typed {
+ color: var(--ss-text-strong);
+}
+
+.pr-caret {
+ display: inline-block;
+ width: 2px;
+ height: 1.15em;
+ margin: 0 -2px 0 0;
+ vertical-align: -0.2em;
+ background: var(--who);
+ opacity: 0;
+}
+
+.pr-caret[data-on] {
+ opacity: 1;
+}
+
+/* A teammate's cursor: their color, their name riding below the tip. It
+ eases like a hand; the tip is the point */
+.pr-peer {
+ position: absolute;
+ top: 0;
+ left: 0;
+ z-index: 10;
+ pointer-events: none;
+ will-change: transform;
+ transition: transform 0.95s cubic-bezier(0.45, 0, 0.25, 1);
+}
+
+.pr-peer[data-slow] {
+ transition-duration: 1.1s;
+}
+
+.pr-peer svg {
+ display: block;
+ margin: -1.5px 0 0 -1.5px;
+ filter: drop-shadow(0 1px 1.5px rgb(0 0 0 / 0.2));
+}
+
+.pr-flag {
+ position: absolute;
+ top: 16px;
+ left: 11px;
+ padding: 3px 7px;
+ border-radius: 6px;
+ background: var(--who);
+ color: #fff;
+ font-size: 11px;
+ font-weight: 600;
+ line-height: 1.2;
+ white-space: nowrap;
+}
+
+/* One color per person: the pile's faces match the cursors. Ana is in the
+ doc but not in shot, so her face stays quiet */
+.pr-pile .ss-facepile__face:nth-child(1) .ss-avatar { background: #d1478a !important; }
+.pr-pile .ss-facepile__face:nth-child(2) .ss-avatar { background: #2f9e8f !important; }
+.pr-pile .ss-facepile__face:nth-child(3) .ss-avatar { background: var(--ss-ink-3) !important; color: var(--ss-text) !important; }
diff --git a/skills/imported/benjitaylor/stageshow/examples/raycast/index.tsx b/skills/imported/benjitaylor/stageshow/examples/raycast/index.tsx
new file mode 100644
index 0000000..51ddab0
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/raycast/index.tsx
@@ -0,0 +1,249 @@
+import { AppWindow, ArrowLeft, BookOpen, Globe, Search, Calculator, Calendar, ChevronDown, ClipboardList, Folder, Image, Link, Music, Settings, Text as TextIcon, type LucideIcon } from 'lucide-react'
+import { Bar, Collapse, Stage, Text, type Act, type Layout } from '../../kit'
+import './raycast.css'
+
+// Raycast over a blurred desktop. Type "clip", Clipboard History is the first
+// result, Enter pushes the command, the list opens with its preview pane,
+// Down moves to a copied color, Esc pops back to root search (the loop).
+
+type S = { query: string; view: 'root' | 'clip'; sel: number }
+
+const initial: S = { query: '', view: 'root', sel: 0 }
+
+type Row = { id: string; icon: LucideIcon; tile: string; title: number; sub: number; acc: number }
+
+// Root rows. Clipboard History is its own row; the rest are placeholders.
+const ROWS: Row[] = [
+ { id: 'r1', icon: Folder, tile: '#3d8bfd', title: 52, sub: 0, acc: 56 },
+ { id: 'r2', icon: Calendar, tile: '#ff5f57', title: 64, sub: 40, acc: 50 },
+ { id: 'r3', icon: Music, tile: '#fc3c64', title: 44, sub: 0, acc: 56 },
+ { id: 'r4', icon: Settings, tile: '#8e8e93', title: 78, sub: 46, acc: 50 },
+ { id: 'r5', icon: AppWindow, tile: '#5e5ce6', title: 58, sub: 0, acc: 56 },
+ { id: 'r6', icon: Calculator, tile: '#ff9f0a', title: 70, sub: 0, acc: 50 },
+ { id: 'r7', icon: Image, tile: '#30b0c7', title: 86, sub: 52, acc: 50 },
+ { id: 'r8', icon: Folder, tile: '#3d8bfd', title: 66, sub: 0, acc: 56 },
+]
+
+// Which rows survive each query, in the order Raycast ranks them.
+function shown(q: string): string[] {
+ if (q === '') return ['r1', 'r2', 'r3', 'r4', 'r5', 'r6', 'r7', 'r8']
+ if (q === 'c') return ['ch', 'r2', 'r6', 'r3']
+ if (q === 'cl') return ['ch', 'r6', 'r7']
+ return ['ch', 'r7']
+}
+
+const FALLBACK = [
+ { icon: Globe, tile: '#0a84ff', title: 92, acc: 50 },
+ { icon: Search, tile: '#636366', title: 74, acc: 56 },
+ { icon: BookOpen, tile: '#34c759', title: 60, acc: 50 },
+]
+
+type Entry = { kind: 'text' | 'color' | 'link' | 'image'; label?: string; w: number }
+const ENTRIES: Entry[] = [
+ { kind: 'text', label: 'Ship the new onboarding on Thursday', w: 0 },
+ { kind: 'color', label: '#FF6363', w: 0 },
+ { kind: 'link', w: 96 },
+ { kind: 'text', w: 120 },
+ { kind: 'image', w: 82 },
+ { kind: 'text', w: 104 },
+ { kind: 'link', w: 74 },
+]
+
+async function script(a: Act) {
+ await a.wait(1000)
+ a.mark('type')
+ await a.type('query', 'clip', { interval: 150 })
+ await a.wait(900)
+ a.mark('open')
+ await a.key('enter')
+ await a.set({ view: 'clip', sel: 0 })
+ await a.wait(2200)
+ a.mark('down')
+ await a.key('down')
+ await a.set({ sel: 1 })
+ await a.wait(2400)
+ a.mark('back')
+ await a.key('esc')
+ await a.set({ view: 'root', query: '', sel: 0 })
+ await a.wait(1400)
+}
+
+function EntryIcon({ e }: { e: Entry }) {
+ if (e.kind === 'color') return
+ const I = e.kind === 'link' ? Link : e.kind === 'image' ? Image : TextIcon
+ return
+}
+
+function Root({ s }: { s: S }) {
+ const vis = shown(s.query)
+ const first = vis[0]
+ return (
+
+
+
+ {s.query && {s.query}}
+
+
+ Search for apps and commands...
+
+
+
+ }
+ >
+ Halite speaks the Postgres wire protocol, so your driver already works.
+
+ )
+}
+
+export default function RuledBento() {
+ return (
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/skills/imported/benjitaylor/stageshow/examples/share/index.tsx b/skills/imported/benjitaylor/stageshow/examples/share/index.tsx
new file mode 100644
index 0000000..e9f2955
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/share/index.tsx
@@ -0,0 +1,221 @@
+import { Check as Tick, ChevronDown, FileText, Lock } from 'lucide-react'
+import { Avatar, Bar, Collapse, Facepile, Field, Menu, Popover, Roll, Row, Stage, Text, Window, type Act, type Layout } from '../../kit'
+import './share.css'
+
+// A document editor's Share popover. Beats: Share opens the popover over the
+// document, an email is typed and committed as a chip with Enter, the role
+// menu sets Can edit, Invite drops the new person in at the top of the access
+// list. Share closes the popover; the list is reset while it is out of sight.
+//
+// What makes it hold up frame by frame:
+// - The popover is measured from the Share button: its right edge is the
+// button's right edge, its top 8px below it. Nothing is hard-coded.
+// - The role picker sits inside the field, as wide as its longest label, so
+// Can view to Can edit moves nothing; only the word changes. The field is
+// wide enough for the whole email as text and as a chip.
+// - The role menu opens under its trigger and runs to the popover's inner
+// edge, so it covers the role column whole: no word peeks out beside it.
+// - The document behind is placeholder bars, so the popover's edge only ever
+// crosses filler, never a word.
+// - Every person is the same two-line Row, the new one included.
+// - The Enter keycap shows over the document, clear of the popover.
+//
+// Real strings: the title, the popover's labels, the email, the roles and the
+// people. The document behind is placeholder.
+
+type S = {
+ open: boolean
+ email: string
+ typing: boolean
+ chip: boolean
+ menu: boolean
+ role: string
+ hover: number | undefined
+ added: boolean
+}
+
+const initial: S = { open: false, email: '', typing: false, chip: false, menu: false, role: 'Can view', hover: undefined, added: false }
+
+// The still is the payoff: the popover open with the new person added
+const poster: S = { ...initial, open: true, added: true }
+
+const DOC = 'Launch brief'
+const EMAIL = 'priya@lumen.co'
+const ROLES = ['Can view', 'Can comment', 'Can edit']
+const EDIT = ROLES.indexOf('Can edit')
+
+async function script(a: Act) {
+ await a.wait(600)
+
+ // 1. Share opens the popover; the field takes focus
+ a.mark('open')
+ await a.click({ at: 'share', x: 0.5, y: 0.55 })
+ await a.set({ open: true })
+ await a.wait(700)
+
+ // 2. Type the email, then Enter commits it as a chip in place
+ a.mark('type')
+ await a.click({ at: 'field', x: 0.3, y: 0.5 })
+ await a.set({ typing: true })
+ await a.type('email', EMAIL, { interval: 55 })
+ await a.set({ typing: false })
+ await a.wait(250)
+ await a.key('enter')
+ await a.set({ chip: true })
+ await a.wait(600)
+
+ // 3. The role menu: open it, run down to Can edit, pick it
+ a.mark('role')
+ await a.click({ at: 'role', x: 0.45, y: 0.5 })
+ await a.set({ menu: true })
+ await a.wait(450)
+ await a.move({ at: `role-${EDIT}`, x: 0.4, y: 0.55 })
+ await a.wait(300)
+ await a.click()
+ await a.set({ menu: false, role: 'Can edit' })
+ await a.wait(500)
+
+ // 4. Invite: the person drops in at the top of the list, then the field
+ // clears. Two beats: one cause, one effect at a time, so the eye follows
+ // the chip into the list before the field empties behind it
+ a.mark('invite')
+ await a.click({ at: 'invite', x: 0.5, y: 0.55 })
+ await a.set({ added: true })
+ await a.wait(350)
+ await a.set({ chip: false, email: '', role: 'Can view' })
+ await a.wait(1850)
+
+ // 5. Share again closes it; the list resets out of sight
+ a.mark('close')
+ await a.click({ at: 'share', x: 0.5, y: 0.55 })
+ await a.set({ open: false })
+ await a.wait(450)
+ await a.snap({ added: false })
+ await a.wait(500)
+}
+
+type Geo = { win: { x: number; y: number; w: number; h: number }; pop: number }
+
+// Wide: one 24px gutter above and either side of the window, which runs off
+// the bottom. Compact: the same at the column's width, a narrower popover.
+const GEO: Record = {
+ wide: { win: { x: 24, y: 24, w: 432, h: 360 }, pop: 344 },
+ compact: { win: { x: 16, y: 20, w: 326, h: 400 }, pop: 310 },
+}
+
+export default function ShareDemo() {
+ return (
+
+ width={480}
+ height={340}
+ compact={{ below: 440, width: 358, height: 380 }}
+ initial={initial}
+ poster={poster}
+ script={script}
+ keys={{ wide: 'bottom-left', compact: 'bottom-left' }}
+ cursor={{ at: { at: 'doc', x: 0.3, y: 0.6 } }}
+ className="sh"
+ >
+ {(s, rect, layout) => {
+ const g = GEO[layout]
+ const share = rect('share')
+ const pop = { left: share.x + share.width - g.pop, top: share.y + share.height + 8, width: g.pop }
+ return (
+ <>
+
+
+ {DOC}
+
+ }
+ toolbar={
+
+
+
+
+ Share
+
+
+ }
+ body={{ className: 'sh-body', 'data-crop': true }}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {`Share “${DOC}”`}
+
+
+
+
+ {s.role}
+
+
+
+ }
+ />
+
+ Invite
+
+
+
+
People with access
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )
+ }}
+
+ )
+}
+
+/** The role menu sits 6px under the field, from its trigger's left edge to
+ * 6px past the popover's inner edge (over the role column), in the popover's own coordinates. */
+function roleMenu(rect: (name: string) => { x: number; y: number; width: number; height: number }, pop: { left: number; top: number; width: number }) {
+ const r = rect('role')
+ const f = rect('field')
+ const left = r.x - pop.left - 4
+ return { left, top: f.y - pop.top + f.height + 6, width: pop.width - 10 - left }
+}
+
+function Person({ name, email, role }: { name: string; email: string; role: string }) {
+ return } title={name} subtitle={email} meta={{role}} />
+}
diff --git a/skills/imported/benjitaylor/stageshow/examples/share/share.css b/skills/imported/benjitaylor/stageshow/examples/share/share.css
new file mode 100644
index 0000000..58918db
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/share/share.css
@@ -0,0 +1,209 @@
+/* A document editor, light. The window sits one gutter in from the top and
+ both sides and runs off the bottom; the Share popover is the hero. */
+
+.sh .ss-stage {
+ overflow: hidden;
+ background: var(--ss-well);
+}
+
+.sh-window {
+ position: absolute;
+ background: var(--ss-surface);
+ box-shadow: var(--ss-shadow-hero);
+}
+
+/* ---- Toolbar ---- */
+
+/* A 52px unified toolbar, as recent macOS document windows have: room after
+ the lights, and the Share button inset the same 10.5px from the top and the
+ right edge */
+.sh-window > .ss-window__bar {
+ height: 52px;
+ padding-right: 10.5px;
+}
+
+.sh-lead {
+ display: flex;
+ margin-left: 8px;
+ align-items: center;
+ gap: 7px;
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--ss-text-strong);
+}
+
+.sh-lead svg {
+ color: var(--ss-icon);
+}
+
+.sh-tools {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.sh-share {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ height: 30px;
+ padding: 0 12px 0 10px;
+ border-radius: 8px;
+ background: var(--ss-accent);
+ color: #fff;
+ font-size: 12px;
+ font-weight: 600;
+ transition: filter 0.15s ease;
+}
+
+.sh-share[data-hover],
+.sh-share[data-on] {
+ filter: brightness(0.92);
+}
+
+/* ---- The document behind ---- */
+
+.sh-body {
+ background: var(--ss-surface);
+}
+
+.sh-doc {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ padding: 26px 28px;
+}
+
+.sh-doc__title {
+ margin-bottom: 8px;
+}
+
+.sh-doc__gap {
+ height: 6px;
+}
+
+/* ---- The popover ---- */
+
+.sh-pop {
+ padding: 0;
+ font-size: 12.5px;
+ --ss-popover-shadow: var(--ss-shadow-float);
+}
+
+.sh-pop__head {
+ padding: 14px 16px 10px;
+}
+
+.sh-pop__title {
+ font-size: 13.5px;
+ font-weight: 600;
+ color: var(--ss-text-strong);
+}
+
+.sh-invite {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 0 16px;
+}
+
+.sh-field {
+ flex: 1;
+ min-width: 0;
+}
+
+/* Inside the field, pinned right. The slot is as wide as the longest label
+ it shows, so the field never moves; the hover surface is the picker
+ itself, label and chevron with an even inset, and grows to the left with
+ the label as the Roll measures it */
+.sh-role {
+ display: flex;
+ flex-shrink: 0;
+ justify-content: flex-end;
+ width: 96px;
+ margin-right: -6px;
+}
+
+.sh-role__hit {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ height: 24px;
+ padding: 0 6px 0 7px;
+ border-radius: 6px;
+ color: var(--ss-text);
+ white-space: nowrap;
+ transition: background-color 0.15s ease;
+}
+
+.sh-role__hit svg {
+ flex-shrink: 0;
+ color: var(--ss-icon);
+}
+
+.sh-role__hit[data-hover],
+.sh-role__hit[data-on] {
+ background: var(--ss-ink-2);
+}
+
+.sh-invite__btn {
+ display: inline-flex;
+ flex-shrink: 0;
+ align-items: center;
+ height: var(--ss-control);
+ padding: 0 13px;
+ border-radius: var(--ss-radius-md);
+ background: var(--ss-ink-2);
+ color: var(--ss-text);
+ font-weight: 600;
+ transition: background-color 0.2s ease, color 0.2s ease;
+}
+
+.sh-invite__btn[data-ready] {
+ background: var(--ss-accent);
+ color: #fff;
+}
+
+.sh-access {
+ padding: 12px 16px 6px;
+}
+
+.sh-access__label {
+ margin-bottom: 2px;
+ font-size: 11.5px;
+ font-weight: 500;
+ color: var(--ss-text);
+}
+
+/* Rows bleed into the popover's inset so their avatars sit on its 16px edge */
+.sh-people {
+ margin: 0 -6px;
+}
+
+.sh-person__role {
+ color: var(--ss-text);
+}
+
+/* ---- The role menu ---- */
+
+.sh-menu__item {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.sh-menu__tick {
+ display: grid;
+ place-items: center;
+ width: 12px;
+ color: var(--ss-accent);
+}
+
+/* Compact: a narrower role picker leaves the chip its whole email */
+.sh .ss-stage[data-layout="compact"] .sh-role {
+ width: 86px;
+}
+
+.sh .ss-stage[data-layout="compact"] .sh-invite__btn {
+ padding: 0 11px;
+}
diff --git a/skills/imported/benjitaylor/stageshow/examples/slack/index.tsx b/skills/imported/benjitaylor/stageshow/examples/slack/index.tsx
new file mode 100644
index 0000000..ac719ed
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/slack/index.tsx
@@ -0,0 +1,462 @@
+import {
+ ArrowLeft,
+ ArrowRight,
+ AtSign,
+ Bell,
+ Bookmark,
+ ChevronDown,
+ CircleHelp,
+ Clock,
+ Ellipsis,
+ EllipsisVertical,
+ Forward,
+ Hash,
+ Headphones,
+ House,
+ MessageSquareText,
+ MessagesSquare,
+ Mic,
+ Plus,
+ Search,
+ SendHorizontal,
+ Smile,
+ SmilePlus,
+ SquarePen,
+ Type,
+ X,
+ type LucideIcon,
+} from 'lucide-react'
+import { Bar, Collapse, Stage, Text, Window, type Act } from '../../kit'
+import './slack.css'
+
+// Slack, in #design. Maya's message arrives, the user hovers it and taps 👀
+// from the quick reactions, opens the thread and replies. Closing the thread
+// leaves the reaction and reply summary in place until the host's Replay.
+//
+// Icons: Slack's own set isn't available, so Lucide is used on purpose: its
+// rounded 1.75 stroke is the closest match to Slack's outline glyphs.
+
+type S = {
+ arrived: boolean
+ reacted: boolean
+ thread: boolean
+ focus: boolean
+ typing: boolean
+ draft: string
+ replied: boolean
+}
+
+const initial: S = { arrived: true, reacted: false, thread: false, focus: false, typing: false, draft: '', replied: false }
+
+// Under reduced motion: the finished moment, reacted and replied in the open thread
+const poster: S = { ...initial, arrived: true, reacted: true, thread: true, replied: true }
+
+const MESSAGE = 'The new onboarding flow is ready for review. Can you take a look before standup?'
+const REPLY = "On it, I'll leave notes in the file"
+const REST = { x: 560, y: 250 }
+
+async function script(a: Act) {
+ // Opens on the channel with Maya's message already in.
+ await a.wait(1200)
+
+ // Hover the message: the row lights up and its toolbar shows
+ a.mark('hover')
+ await a.move({ at: 'msg', x: 0.42, y: 0.55 })
+ await a.wait(500)
+
+ // 👀 from the quick reactions
+ await a.move({ at: 'react-eyes', x: 0.82, y: 0.86 })
+ await a.wait(220)
+ a.mark('react')
+ await a.click()
+ await a.set({ reacted: true })
+ await a.wait(1000)
+
+ // Reply in thread
+ await a.move({ at: 'reply-btn', x: 0.78, y: 0.82 })
+ await a.wait(260)
+ a.mark('thread')
+ await a.click()
+ await a.set({ thread: true })
+ await a.wait(800)
+
+ // Write the reply
+ await a.move({ at: 'tbox', x: 0.62, y: 0.3 })
+ await a.click()
+ await a.set({ focus: true })
+ await a.wait(350)
+ await a.set({ typing: true })
+ await a.type('draft', REPLY, { interval: 52 })
+ await a.set({ typing: false })
+ await a.wait(450)
+ a.mark('send')
+ await a.key('enter')
+ await a.set({ draft: '', replied: true })
+ await a.wait(500)
+ // Step off the reply as it lands, so the pointer isn't parked on its words
+ const box = a.rect('tbox')
+ await a.move({ x: box.x + box.width * 0.7, y: box.y + box.height + 60 })
+ await a.wait(1800)
+
+ // Close the thread and hold the result. Replay is the explicit reset.
+ a.mark('exit')
+ await a.move({ at: 'tclose', x: 0.5, y: 0.5 })
+ await a.wait(200)
+ await a.click()
+ await a.set({ thread: false, focus: false })
+ await a.wait(500)
+ await a.move(REST)
+ await a.wait(1500)
+ a.mark('done')
+}
+
+function I({ icon: Glyph, size = 16 }: { icon: LucideIcon; size?: number }) {
+ return
+}
+
+/* ---- Pieces ---- */
+
+function Face({ who, size = 36 }: { who: 'maya' | 'alex' | 'p1' | 'p2' | 'p3'; size?: number }) {
+ const letter = { maya: 'M', alex: 'A', p1: 'J', p2: 'R', p3: 'T' }[who]
+ return (
+
+ {letter}
+
+ )
+}
+
+function SideBar({ w, light }: { w: number; light?: boolean }) {
+ // The sidebar is aubergine, so its placeholders are a light purple from the same family
+ return
+}
+
+function Sidebar() {
+ return (
+
+ )
+}
+
+function Rail() {
+ const items: { icon: LucideIcon; label: string; active?: boolean }[] = [
+ { icon: House, label: 'Home', active: true },
+ { icon: MessagesSquare, label: 'DMs' },
+ { icon: Bell, label: 'Activity' },
+ { icon: Ellipsis, label: 'More' },
+ ]
+ return (
+
+ )
+}
+
+function Older({ who, name, lines }: { who: 'p1' | 'p2'; name: number; lines: number[] }) {
+ return (
+
+ )}
+
+ )
+}
diff --git a/skills/imported/benjitaylor/stageshow/examples/stacked-cards/stacked.css b/skills/imported/benjitaylor/stageshow/examples/stacked-cards/stacked.css
new file mode 100644
index 0000000..556ba5c
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/stacked-cards/stacked.css
@@ -0,0 +1,89 @@
+.sk .ss-stage {
+ overflow: hidden;
+ background: var(--ss-well);
+}
+
+/* One column, centered, the second card off the bottom */
+.sk-stack {
+ position: absolute;
+ top: 56px;
+ left: 50%;
+ display: grid;
+ gap: 12px;
+ width: 340px;
+ translate: -50% 0;
+}
+
+.sk-card {
+ display: grid;
+ gap: 10px;
+ padding: 18px 20px;
+ border-radius: 12px;
+ background: var(--ss-surface);
+ box-shadow: var(--ss-shadow-card);
+}
+
+.sk-plan {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 6px;
+}
+
+.sk-mark {
+ display: grid;
+ place-items: center;
+ width: 30px;
+ height: 30px;
+ border-radius: 8px;
+ background: var(--ss-accent-soft);
+ color: var(--ss-accent);
+}
+
+.sk-name {
+ color: var(--ss-text-strong);
+ font-size: 13px;
+ font-weight: 600;
+}
+
+.sk-sub,
+.sk-label {
+ color: var(--ss-text);
+ font-size: 12px;
+}
+
+.sk-usage {
+ display: flex;
+ justify-content: space-between;
+}
+
+.sk-value {
+ color: var(--ss-text-strong);
+ font-size: 12px;
+ font-weight: 500;
+}
+
+.sk-who {
+ display: grid;
+ gap: 2px;
+}
+
+.sk-chart {
+ display: block;
+ margin-top: 4px;
+}
+
+/* The second card runs on past the stage's bottom edge, under the axis */
+.sk-card:last-child {
+ padding-bottom: 80px;
+}
+
+/* A flat accent: the usage is the point, not the fill */
+.sk .ss-meter__fill {
+ background: var(--ss-accent);
+}
+
+/* Narrow: the column fills the stage less a 16px gutter each side */
+.sk .ss-stage[data-layout='compact'] .sk-stack {
+ width: 326px;
+}
diff --git a/skills/imported/benjitaylor/stageshow/examples/staggered-bento/grid.css b/skills/imported/benjitaylor/stageshow/examples/staggered-bento/grid.css
new file mode 100644
index 0000000..b92423d
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/staggered-bento/grid.css
@@ -0,0 +1,534 @@
+/* All rules belong to this example, including its responsive variants. */
+.staggered-bento {
+ /* Wayline's features section: a staggered pair on white. Everything nests
+ under &. Two columns of rounded tiles on a gap, tall and
+ short alternating so the rows never line up across the columns. Each
+ picture is its own Stage at 1:1, pinned to its tile's bottom. */
+ --pad: 30px;
+ --gap: 18px;
+ --tile: #f5f5f7;
+ --ink: #1d1d1f;
+ --ink-2: #6e6e73;
+ --accent: #f2643b;
+ --short: 310px;
+ --tall: 518px;
+ container-type: inline-size;
+ /* No padding of its own: on a page, the tiles span the content column */
+ box-sizing: border-box;
+ background: #fff;
+ font-family: var(--sg-font, inherit);
+ -webkit-font-smoothing: antialiased;
+
+ & .sg-grid {
+ display: grid;
+ gap: var(--gap);
+ max-width: 984px;
+ margin: 0 auto;
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ /* Narrow: one column, the tiles in reading order, hero first */
+ & .sg-col {
+ display: contents;
+ }
+
+ & .sg-tile--coast { order: 1; height: 460px; }
+ & .sg-tile--private { order: 2; height: 230px; }
+ & .sg-tile--stat { order: 3; height: 250px; }
+ & .sg-tile--voice { order: 4; height: 310px; }
+ & .sg-tile--book { order: 5; height: 300px; }
+ & .sg-tile--day { order: 6; height: 500px; }
+
+ /* Two columns: tall, short, short on the left; short, short, tall on the right */
+ @container (min-width: 600px) {
+ & .sg-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ & .sg-col {
+ display: flex;
+ flex-direction: column;
+ gap: var(--gap);
+ }
+
+ & .sg-tile { height: var(--short); }
+ & .sg-tile--coast,
+ & .sg-tile--day { height: var(--tall); }
+ }
+
+ @container (min-width: 900px) {
+ & .sg-grid {
+ --pad: 36px;
+ --gap: 20px;
+ --short: 320px;
+ --tall: 560px;
+ }
+ }
+
+ @container (max-width: 599px) {
+ & .sg-grid {
+ --pad: 26px;
+ }
+ }
+
+ /* ---- The tile: one frame for all six ---- */
+
+ & .sg-tile {
+ position: relative;
+ overflow: hidden;
+ border-radius: 28px;
+ background: var(--tile);
+ }
+
+ & .sg-copy {
+ position: relative;
+ z-index: 1;
+ max-width: 380px;
+ margin: 0;
+ padding: 30px var(--pad) 0;
+ font-size: 19px;
+ line-height: 26px;
+ letter-spacing: -0.016em;
+ font-weight: 500;
+ color: var(--ink-2);
+ text-wrap: pretty;
+ }
+
+ & .sg-copy b {
+ font-weight: 600;
+ color: var(--ink);
+ }
+
+ /* Over the photo's sky */
+ & .sg-tile[data-photo] .sg-copy {
+ color: rgba(255, 255, 255, 0.72);
+ }
+
+ & .sg-tile[data-photo] .sg-copy b {
+ color: #fff;
+ }
+
+ /* ---- Pictures: pinned to the tile's bottom ---- */
+
+ & .sg-picture {
+ position: absolute;
+ bottom: 0;
+ }
+
+ & .sg-picture--coast {
+ left: 50%;
+ width: 600px;
+ margin-left: -300px;
+ }
+
+ & .sg-picture--small,
+ & .sg-picture--phone {
+ left: 0;
+ width: 100%;
+ }
+
+ & .sg-stage .ss-stage {
+ overflow: hidden;
+ background: transparent;
+ }
+
+ /* ---- Coast (the still hero picture) ---- */
+
+ & .sg-art {
+ position: absolute;
+ inset: 0;
+ }
+
+ & .sg-art__svg {
+ position: absolute;
+ inset: 0;
+ display: block;
+ }
+
+ /* One column: the copy wraps to more lines, so the drawn takes sit lower
+ (their bottoms are open water and more tiles, which can go) */
+ @container (max-width: 599px) {
+ & .sg-picture--coast {
+ bottom: -40px;
+ }
+ }
+
+ /* The tile's ground matches the picture's top, so nothing shows round it */
+ & .sg-tile--coast:has([data-art='azulejo']) { background: #1f3f8f; }
+
+ /* ---- Private (statement) ---- */
+
+ & .sg-statement {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ box-sizing: border-box;
+ height: 100%;
+ padding: 30px var(--pad) 32px;
+ }
+
+ & .sg-lock {
+ color: #0a7cff;
+ }
+
+ & .sg-statement__text {
+ max-width: 340px;
+ margin: 0;
+ font-size: 30px;
+ line-height: 34px;
+ letter-spacing: -0.024em;
+ font-weight: 600;
+ color: #a1a1a6;
+ text-wrap: balance;
+ }
+
+ & .sg-statement__text b {
+ font-weight: 600;
+ color: var(--ink);
+ }
+
+ /* ---- Distance (stat) ---- */
+
+ & .sg-stat {
+ display: flex;
+ flex-direction: column;
+ box-sizing: border-box;
+ height: 100%;
+ padding: 30px var(--pad) 32px;
+ }
+
+ & .sg-stat-lead {
+ font-size: 19px;
+ line-height: 26px;
+ letter-spacing: -0.016em;
+ font-weight: 600;
+ color: var(--ink);
+ }
+
+ & .sg-stat-number {
+ display: block;
+ margin-top: auto;
+ font-size: 60px;
+ line-height: 1;
+ font-weight: 600;
+ letter-spacing: -0.04em;
+ color: var(--ink);
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+ }
+
+ /* The exact final text sets the slot, so adding digits never kicks the unit
+ sideways. Its settled size and alignment remain the same as plain text. */
+ & .sg-stat-value {
+ display: inline-grid;
+ text-align: right;
+ }
+
+ & .sg-stat-value > * { grid-area: 1 / 1; }
+ & .sg-stat-sizer { visibility: hidden; }
+
+ & .sg-stat-unit {
+ margin-left: 6px;
+ font-size: 32px;
+ letter-spacing: -0.02em;
+ }
+
+ & .sg-stat-label {
+ max-width: 260px;
+ margin-top: 12px;
+ font-size: 15px;
+ line-height: 21px;
+ color: var(--ink-2);
+ text-wrap: pretty;
+ }
+
+ /* ---- Voice (component) ---- */
+
+ & .sg-note {
+ position: absolute;
+ top: 16px;
+ left: var(--pad);
+ box-sizing: border-box;
+ width: calc(100% - var(--pad) * 2);
+ height: 200px;
+ padding: 14px 14px 0;
+ /* A fragment with no chrome of its own: it dissolves where the tile crops it */
+ -webkit-mask-image: linear-gradient(180deg, #000 150px, transparent 200px);
+ mask-image: linear-gradient(180deg, #000 150px, transparent 200px);
+ border-radius: 26px;
+ background: #fff;
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.04), 0 14px 34px -14px rgba(40, 30, 20, 0.24), 0 2px 6px -2px rgba(0, 0, 0, 0.05);
+ }
+
+ & .sg-recorder {
+ display: grid;
+ grid-template-columns: 48px minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 14px;
+ padding-right: 8px;
+ }
+
+ & .sg-transcript {
+ margin: 16px 6px 0;
+ font-size: 15px;
+ line-height: 21px;
+ font-weight: 500;
+ letter-spacing: -0.01em;
+ color: var(--ink);
+ }
+
+ & .sg-transcript-rest {
+ display: flex;
+ flex-direction: column;
+ gap: 9px;
+ margin: 12px 6px 0;
+ }
+
+ & .sg-transcript-rest span {
+ height: 7px;
+ border-radius: 4px;
+ background: #ececf0;
+ }
+
+ & .sg-rec {
+ display: grid;
+ place-items: center;
+ width: 48px;
+ height: 48px;
+ border-radius: 24px;
+ background: #ff3b30;
+ }
+
+ & .sg-rec__stop {
+ width: 16px;
+ height: 16px;
+ border-radius: 4px;
+ background: #fff;
+ }
+
+ & .sg-wave {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ height: 30px;
+ }
+
+ & .sg-wave__bar {
+ flex: 1;
+ height: calc(var(--h) * 100%);
+ min-height: 3px;
+ border-radius: 2px;
+ background: #1d1d1f;
+ transform: scaleY(0.12);
+ will-change: transform;
+ transition: transform 0.5s cubic-bezier(0.25, 0.8, 0.25, 1);
+ transition-delay: calc(var(--i) * 40ms);
+ }
+
+ & .sg-note[data-on] .sg-wave__bar {
+ transform: none;
+ }
+
+ & .sg-rec__time {
+ font-size: 15px;
+ font-weight: 600;
+ color: var(--ink);
+ font-variant-numeric: tabular-nums;
+ }
+
+ /* ---- Book (object) ---- */
+
+ & .sg-books {
+ position: absolute;
+ inset: 0;
+ }
+
+ & .sg-book {
+ position: absolute;
+ box-sizing: border-box;
+ border-radius: 4px 8px 8px 4px;
+ will-change: transform;
+ transition: transform 1.1s cubic-bezier(0.25, 0.8, 0.25, 1);
+ }
+
+ & .sg-book--back {
+ top: 34px;
+ left: calc(var(--pad) + 86px);
+ width: 150px;
+ height: 196px;
+ background: linear-gradient(90deg, #c9b590 0, #dccaa6 12px, #e4d4b2 100%);
+ box-shadow: 0 0 0 1px rgba(80, 60, 30, 0.12), 0 14px 26px -12px rgba(60, 40, 20, 0.35);
+ transform: translateY(70px);
+ transition-delay: 0.08s;
+ }
+
+ & .sg-book--front {
+ top: 22px;
+ left: var(--pad);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ width: 160px;
+ height: 210px;
+ /* Centered on the cover, not the spine */
+ padding: 52px 0 0 14px;
+ background:
+ repeating-linear-gradient(0deg, rgba(255, 255, 255, 0.03) 0 1px, transparent 1px 3px),
+ repeating-linear-gradient(90deg, rgba(0, 0, 0, 0.05) 0 1px, transparent 1px 3px),
+ linear-gradient(160deg, #3b6b5c 0%, #2c5548 60%, #234539 100%);
+ box-shadow: 0 0 0 1px rgba(20, 40, 30, 0.3), 0 22px 36px -14px rgba(20, 40, 30, 0.55), 0 4px 10px -4px rgba(0, 0, 0, 0.2);
+ color: #e8d3a0;
+ transform: translateY(60px);
+ }
+
+ & .sg-books[data-on] .sg-book {
+ transform: none;
+ }
+
+ & .sg-book__spine {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ width: 14px;
+ border-radius: 4px 0 0 4px;
+ background: linear-gradient(90deg, rgba(0, 0, 0, 0.28), rgba(0, 0, 0, 0.06) 70%, rgba(255, 255, 255, 0.08));
+ }
+
+ & .sg-book__title {
+ font-size: 13px;
+ font-weight: 600;
+ letter-spacing: 0.24em;
+ text-transform: uppercase;
+ /* Tracking adds space after the last letter too; take it back so the
+ word centers on its ink */
+ margin-right: -0.24em;
+ }
+
+ & .sg-book__by {
+ margin-top: 8px;
+ font-size: 10px;
+ font-weight: 500;
+ letter-spacing: 0.08em;
+ margin-right: -0.08em;
+ color: #c9b98e;
+ }
+
+ /* ---- Day (the one phone) ---- */
+
+ & .sg-phone {
+ position: absolute;
+ top: 26px;
+ left: var(--pad);
+ will-change: transform;
+ }
+
+ /* Keep the clock legible inside the smallest, guttered phone. */
+ @container (max-width: 340px) {
+ & .sg-phone .ss-phone__time { font-size: 19px; }
+ }
+
+ & .sg-page {
+ position: absolute;
+ inset: 0;
+ padding: 64px 24px 0;
+ background: #fff;
+ }
+
+ & .sg-page__head {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding: 0 0 16px;
+ }
+
+ & .sg-page__eyebrow {
+ font-size: 19px;
+ font-weight: 600;
+ color: var(--accent);
+ }
+
+ & .sg-page__title {
+ font-size: 34px;
+ line-height: 40px;
+ font-weight: 700;
+ letter-spacing: -0.02em;
+ color: var(--ink);
+ }
+
+ & .sg-mapcard {
+ overflow: hidden;
+ height: 190px;
+ border-radius: 20px;
+ }
+
+ & .sg-mapcard svg {
+ display: block;
+ }
+
+ & .sg-route {
+ /* A gap longer than the path, so no zero-length dash leaves a round cap behind */
+ stroke-dasharray: 1 2;
+ stroke-dashoffset: 1.5;
+ transition: stroke-dashoffset 1.4s cubic-bezier(0.45, 0, 0.2, 1) 0.1s;
+ }
+
+ & .sg-phone[data-on] .sg-route {
+ stroke-dashoffset: 0;
+ }
+
+ & .sg-route__end {
+ opacity: 0;
+ transition: opacity 0.3s ease 1.4s;
+ }
+
+ & .sg-phone[data-on] .sg-route__end {
+ opacity: 1;
+ }
+
+ & .sg-thumbs {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 8px;
+ margin-top: 12px;
+ }
+
+ & .sg-thumb {
+ height: 108px;
+ border-radius: 14px;
+ }
+
+ /* Three small photos: a green hill, a yellow palace wall, a mossy wood */
+ & .sg-thumb--a {
+ background:
+ radial-gradient(120% 70% at 30% 100%, #3d6b45 40%, transparent 41%),
+ radial-gradient(90% 60% at 80% 100%, #567f4c 45%, transparent 46%),
+ linear-gradient(180deg, #a9c8de 0%, #dfe9e6 70%);
+ }
+
+ & .sg-thumb--b {
+ background:
+ linear-gradient(180deg, transparent 0 34%, #e8b64c 34% 100%),
+ repeating-linear-gradient(90deg, transparent 0 14px, rgba(120, 70, 10, 0.25) 14px 20px),
+ linear-gradient(180deg, #8fb6d6 0%, #cfe0ea 100%);
+ }
+
+ & .sg-thumb--c {
+ background:
+ radial-gradient(60% 40% at 50% 20%, #c7d79a 0%, transparent 70%),
+ linear-gradient(180deg, #4b6b3a 0%, #2e4a2a 60%, #1d3020 100%);
+ }
+
+ & .sg-lines {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ margin-top: 20px;
+ }
+
+ & .sg-lines span {
+ height: 8px;
+ border-radius: 4px;
+ background: #e8e8ed;
+ }
+}
diff --git a/skills/imported/benjitaylor/stageshow/examples/staggered-bento/index.tsx b/skills/imported/benjitaylor/stageshow/examples/staggered-bento/index.tsx
new file mode 100644
index 0000000..32a151c
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/staggered-bento/index.tsx
@@ -0,0 +1,370 @@
+import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
+import { Count, Icon, Phone, Stage, useReducedMotion } from '../../kit'
+import type { Act } from '../../kit'
+import './grid.css'
+
+// A staggered pair, light: the features section of Wayline, an invented
+// travel journal for iPhone. Two columns of rounded tiles, tall and short
+// alternating so no row lines up across the columns (Apple's MacBook grid).
+//
+// Tile Column Size Kind The one thing Moves
+// Coast left tall Picture a blue-and-white tile pattern (still)
+// Private right short Statement one sentence, a blue lock (still)
+// Distance left short Stat "38,604 km" this year counts up
+// Voice right short Component the voice-note recorder the waveform rises
+// Book left short Object the printed, clothbound trip rises into the crop
+// Day right tall Hero UI one phone, one day's page the route draws
+//
+// One phone and no window: two of six tiles are UI. The coast is the one
+// tile with its own ground. Across and down, no kind sits next to itself.
+// Each picture is its own Stage at 1:1, pinned to its tile's bottom. The
+// coast holds still; each tile that moves plays one beat, once, 110ms after
+// the one before it in reading order.
+
+const STEP = 110
+const LEAD = 450
+
+type Beat = { on: boolean }
+const before: Beat = { on: false }
+const after: Beat = { on: true }
+const beat = (i: number) => async (a: Act) => {
+ await a.wait(LEAD + i * STEP)
+ await a.set({ on: true })
+}
+
+function Tile({ area, title, children, picture, photo }: { area: string; title: ReactNode; children?: ReactNode; picture?: ReactNode; photo?: boolean }) {
+ return (
+
+ {picture}
+ {title ? (
+
+ {title} {children}
+
+ ) : (
+ children
+ )}
+
+ )
+}
+
+function useInView(delay: number) {
+ const ref = useRef(null)
+ const [seen, setSeen] = useState(false)
+ useEffect(() => {
+ const el = ref.current
+ if (!el) return
+ let t = 0
+ const io = new IntersectionObserver(([e]) => {
+ if (!e.isIntersecting) return
+ io.disconnect()
+ t = window.setTimeout(() => setSeen(true), delay)
+ }, { threshold: 0.4 })
+ io.observe(el)
+ return () => {
+ io.disconnect()
+ clearTimeout(t)
+ }
+ }, [delay])
+ return [ref, seen] as const
+}
+
+function usePictureWidth() {
+ const ref = useRef(null)
+ const [size, setSize] = useState({ width: 340, gutter: 26 })
+ useLayoutEffect(() => {
+ const node = ref.current
+ if (!node) return
+ const measure = () => setSize({ width: node.clientWidth, gutter: parseFloat(getComputedStyle(node).getPropertyValue('--pad')) })
+ measure()
+ const observer = new ResizeObserver(measure)
+ observer.observe(node)
+ return () => observer.disconnect()
+ }, [])
+ return [ref, size] as const
+}
+
+/* ---------------- Coast (the hero picture) ---------------- */
+
+// One still, a blue-and-white tile pattern: a different visual role from
+// every neighboring tile, with no external image or asset dependency.
+function Coast() {
+ return (
+ Every trip, written up for you.}
+ picture={
+ <>
+
+
+ {() => (
+
+
+
+ )}
+
+
+ >
+ }
+ >
+ Wayline turns your photos into a journal, a page a day.
+
+ )
+}
+
+/* Azulejo: one tile drawn once and repeated by an SVG pattern. The motif is
+ the classic lattice of lenses: arcs of circles centered on the tile corners,
+ meeting in a cross at each tile's center, so the joins across the grout
+ make one continuous net. A cobalt field above, a frieze, then the field
+ of tiles running off the bottom and sides. */
+const AZ = { blue: '#1f3f8f', wash: '#8ea5d4', white: '#f6f3ec', grout: '#d3d9e5' }
+const T = 80
+// A lens from (0,-L) to (0,L), half-width w: both arcs share radius r
+const lens = (L: number, w: number) => {
+ const r = (L * L + w * w) / (2 * w)
+ return `M0 ${-L} A${r} ${r} 0 0 1 0 ${L} A${r} ${r} 0 0 1 0 ${-L} Z`
+}
+const CORNERS = [[0, 0], [T, 0], [0, T], [T, T]]
+
+function Azulejo() {
+ const tileId = useId()
+ const friezeId = useId()
+ const P = 300 // the field's top edge, on a row of tile corners
+ const F = 40 // the frieze: a row of half tiles
+ return (
+
+ )
+}
+
+/* ---------------- Private (statement) ---------------- */
+
+function Private() {
+ return (
+
+
+
+
+ Private by design. Your journal stays on your iPhone.
+
+ }
+ >
+ Clothbound, at your door in a week.
+
+ )
+}
+
+/* ---------------- Day (hero UI: the one phone) ---------------- */
+
+function Day() {
+ const [ref, { width, gutter }] = usePictureWidth()
+ return (
+ A page for every day.}
+ picture={
+
+ width={width} height={400} initial={before} poster={after} play="once" script={beat(5)} className="sg-stage">
+ {(s) => (
+ // Centered in its tile: the one device in the grid stands in
+ // the middle of its card, running off the bottom
+
+
+
+ Day 2 · Tuesday
+ Sintra
+
+
+ {/* Fills the card at the page width, so its edge lines up with the photos under it */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+
+ }
+ >
+ Route, photos and notes, laid out for you.
+
+ )
+}
+
+export default function StaggeredBento() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/skills/imported/benjitaylor/stageshow/examples/static-card/card.css b/skills/imported/benjitaylor/stageshow/examples/static-card/card.css
new file mode 100644
index 0000000..f2709c8
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/static-card/card.css
@@ -0,0 +1,192 @@
+/* All rules belong to this example, including its responsive variants. */
+.sc-card {
+ /* The card is the stage: it clips the window, so the bleed is a clean crop.
+ One gutter for every edge something sits near: the window's top, and the
+ detail card's left and bottom. */
+ & .ss-stage {
+ --gutter: 24px;
+ overflow: hidden;
+ border-radius: 16px;
+ background: var(--ss-well);
+ }
+
+ /* Pinned top-left with a gutter on the left for the detail card; runs about
+ a third past the right edge and off the bottom. */
+ .sc-window {
+ position: absolute;
+ top: var(--gutter);
+ left: 104px;
+ width: 460px;
+ height: 320px;
+ background: var(--ss-surface);
+ border-radius: 10px;
+ box-shadow: var(--ss-shadow-hero);
+ }
+
+ /* Keep the selected customer whole in the narrow crop; only filler bleeds. */
+ & [data-layout="compact"] .sc-window { left: 68px; }
+
+ .sc-window .ss-window__body {
+ display: flex;
+ }
+
+ .sc-sidebar {
+ display: flex;
+ flex-direction: column;
+ flex-shrink: 0;
+ gap: 4px;
+ width: 120px;
+ padding: 16px 10px;
+ border-right: 1px solid var(--ss-line);
+ background: var(--ss-canvas);
+ }
+
+ .sc-nav {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ height: 24px;
+ padding: 0 6px;
+ border-radius: 6px;
+ }
+
+ .sc-main {
+ flex: 1;
+ min-width: 0;
+ padding: 16px 18px;
+ }
+
+ .sc-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 12px;
+ }
+
+ .sc-title {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--ss-text-strong);
+ }
+
+ .sc-list {
+ display: flex;
+ flex-direction: column;
+ }
+
+ /* Kit Rows at this list's pitch: a hairline between, the selection inset */
+ .sc-row {
+ align-content: center;
+ height: 36px;
+ margin: 0 -8px;
+ padding: 0 8px;
+ border-radius: 8px;
+ border-bottom: 1px solid var(--ss-line-faint);
+ transition: background 0.15s ease;
+ }
+
+ .sc-row[data-selected] {
+ background: var(--ss-accent-soft);
+ border-bottom-color: transparent;
+ }
+
+ .sc-name {
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--ss-text-strong);
+ }
+
+ .sc-row .ss-avatar,
+ .sc-avatar-large {
+ font-weight: 600;
+ }
+
+ /* The satellite: straddles the window's lower-left edge, over filler rows
+ and clear of the selection, with a stronger shadow than the window so it
+ sits a level above it. */
+ .sc-detail {
+ position: absolute;
+ bottom: var(--gutter);
+ left: var(--gutter);
+ /* Ends a full gutter short of the sidebar divider (104 + 120 = 224) rather
+ than nearly touching it */
+ width: 176px;
+ padding: 14px 16px 10px;
+ border-radius: 12px;
+ background: var(--ss-surface);
+ box-shadow: var(--ss-shadow-satellite);
+ }
+
+ .sc-detail-head {
+ display: flex;
+ align-items: center;
+ /* 26 + 8 + the name (106) leaves the 144px row a few pixels of air */
+ gap: 8px;
+ padding-bottom: 12px;
+ margin-bottom: 2px;
+ border-bottom: 1px solid var(--ss-line);
+ }
+
+ .sc-detail-who {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ }
+
+ .sc-detail-name {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--ss-text-strong);
+ }
+
+ .sc-detail-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ height: 28px;
+ }
+
+ .sc-detail-row.total {
+ margin-top: 4px;
+ padding-top: 4px;
+ border-top: 1px solid var(--ss-line);
+ height: 32px;
+ }
+
+ .sc-detail-label {
+ font-size: 11px;
+ color: var(--ss-text);
+ }
+
+ .sc-detail-total {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--ss-text-strong);
+ font-variant-numeric: tabular-nums;
+ }
+
+ .sc-nav__label {
+ font-size: 10.5px;
+ line-height: 14px;
+ color: var(--ss-text);
+ }
+
+ .sc-nav[data-on] .sc-nav__label {
+ color: var(--ss-text-strong);
+ font-weight: 600;
+ }
+
+ .sc-rowname {
+ font-size: 11px;
+ font-weight: 400;
+ line-height: 14px;
+ color: var(--ss-text);
+ }
+
+ .sc-rowamount {
+ margin-left: auto;
+ font-size: 11px;
+ line-height: 14px;
+ color: var(--ss-text-faint);
+ }
+}
diff --git a/skills/imported/benjitaylor/stageshow/examples/static-card/index.tsx b/skills/imported/benjitaylor/stageshow/examples/static-card/index.tsx
new file mode 100644
index 0000000..037014a
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/static-card/index.tsx
@@ -0,0 +1,82 @@
+import { Bar, Stage, Text, Window, Row, Avatar } from '../../kit'
+import './card.css'
+
+// A feature card (400 × 300) with the product masked inside it: the window is
+// pinned top-left, runs off the right and bottom, and a detail card straddles
+// its left edge over the sidebar, about half outside. Real words only on the
+// selected customer, its neighbors and the total; bars everywhere else.
+
+// Three real rows (the selected one and its neighbors), bars only on the
+// rows the crop takes
+const ROWS: { selected?: boolean; name?: number | string; amount?: number | string }[] = [
+ { name: 'Halvorsen & Co', amount: '$1,240' },
+ { selected: true },
+ { name: 'Lumen Studio', amount: '$860' },
+ { name: 84, amount: 38 },
+ { name: 64, amount: 48 },
+ { name: 78, amount: 40 },
+]
+const NAV = ['Overview', 'Customers', 'Invoices', 'Reports']
+
+export default function StaticCard() {
+ return (
+
+ {() => (
+ <>
+
+ {/* The sidebar is what the detail card covers, so the rows stay readable */}
+
+
= 2 || undefined}>
+ Tokens used, last 30 days
+ {/* The kit's Chart: bars grow in a stagger when the usage arrives */}
+ = 4} />
+
+ Sep 21
+ Oct 21
+
+
+
+ {/* From the invoice header, across and down onto the plan's top
+ edge: it never crosses the card it points at */}
+ {ref.width > 0 && plan.width > 0 && (
+ = 3}
+ from={{ x: ref.x + ref.width + 8, y: ref.y + ref.height / 2 }}
+ to={{ x: plan.x + plan.width * 0.7, y: plan.y }}
+ route="h"
+ />
+ )}
+
+ >
+ )
+ }}
+
+ )
+}
diff --git a/skills/imported/benjitaylor/stageshow/examples/story/story.css b/skills/imported/benjitaylor/stageshow/examples/story/story.css
new file mode 100644
index 0000000..0b7359b
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/story/story.css
@@ -0,0 +1,168 @@
+/* All rules belong to this example, including its responsive variants. */
+.st {
+ & .ss-stage {
+ --ss-accent: #635bff;
+ --ss-accent-2: #e15bff;
+ --ss-accent-soft: rgba(99, 91, 255, 0.1);
+ --gutter: 28px;
+ overflow: hidden;
+ border-radius: 14px;
+ background: #f6f8fb;
+ font: 12px/1.4 var(--ss-font);
+ color: var(--ss-text-strong);
+ }
+
+ /* The color lives in the background, low and to one side */
+ .st-wash {
+ position: absolute;
+ inset: 0;
+ background:
+ radial-gradient(60% 70% at 15% 105%, rgba(99, 91, 255, 0.45), transparent 70%),
+ radial-gradient(40% 50% at 95% 110%, rgba(255, 186, 90, 0.4), transparent 70%);
+ }
+
+ .st-strong { font-weight: 600; }
+ .st-muted { color: var(--ss-text); }
+
+ /* Entrances: rise and fade. Everything is in the DOM from the start. */
+ .st-invoice,
+ .st-plan,
+ .st-chart {
+ position: absolute;
+ opacity: 0;
+ /* The text and its card travel on one retained layer. */
+ transform: translateY(14px);
+ /* Long entrances settle softly while related arrivals overlap. */
+ will-change: transform;
+ transition: opacity 0.8s ease, transform 1.1s cubic-bezier(0.45, 0, 0.1, 1);
+ }
+
+ .st-invoice[data-in],
+ .st-plan[data-in],
+ .st-chart[data-in] {
+ opacity: 1;
+ transform: translate(0, 0);
+ transition-timing-function: ease, cubic-bezier(0.25, 0.8, 0.25, 1);
+ }
+
+ /* The plan arrives from the side it straddles */
+ .st-plan { transform: translateX(18px); }
+ .st-chart { transition-delay: 0.35s; transform: translateX(18px); }
+
+ /* Exits run in reverse: satellites leave first, the invoice last */
+ .st-invoice:not([data-in]) { transition-delay: 0.5s; }
+ .st-plan:not([data-in]) { transition-delay: 0.22s; }
+ .st-chart:not([data-in]) { transition-delay: 0s; }
+
+ .st-invoice {
+ top: var(--gutter);
+ left: var(--gutter);
+ width: 340px;
+ height: 480px;
+ box-shadow: var(--ss-shadow-hero);
+ }
+
+ .st-invoice .ss-window__bar { display: none; }
+ .st-invoice .ss-window__body { background: var(--ss-surface); }
+
+ .st-inv { padding: 22px 24px; }
+ .st-inv__head { display: flex; align-items: center; gap: 16px; margin-bottom: 18px; }
+ .st-inv__title { font-size: 15px; font-weight: 600; }
+ .st-inv__meta { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 26px; }
+ .st-inv__meta > div { display: grid; gap: 7px; }
+
+ .st-inv__due {
+ margin-bottom: 18px;
+ font-size: 18px;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+ }
+
+ .st-inv__due span { font-weight: 600; }
+
+ .st-inv__row {
+ /* Qty sits in a fixed column that ends before the plan card's edge, so
+ the satellite only covers empty row */
+ display: grid;
+ grid-template-columns: 120px 1fr;
+ align-items: center;
+ height: 34px;
+ border-bottom: 1px solid var(--ss-line);
+ }
+
+ .st-inv__row--head { height: 26px; color: var(--ss-text); font-size: 11px; }
+ .st-qty { color: var(--ss-text); }
+
+ /* Satellites straddle the invoice's right edge */
+ .st-plan,
+ .st-chart {
+ left: 300px;
+ width: 236px;
+ padding: 16px;
+ border-radius: 10px;
+ background: var(--ss-raised);
+ box-shadow: var(--ss-shadow-satellite);
+ }
+
+ .st-plan { top: 92px; }
+
+ .st-plan__head { display: flex; gap: 10px; align-items: center; margin-bottom: 14px; line-height: 1.35; }
+
+ .st-plan__mark {
+ display: grid;
+ place-items: center;
+ width: 30px;
+ height: 30px;
+ border-radius: 8px;
+ box-shadow: inset 0 0 0 1px var(--ss-line);
+ color: var(--ss-accent);
+ }
+
+ .st-plan__price { display: flex; justify-content: space-between; margin-bottom: 12px; }
+
+ /* The kit's Segmented at the plan card's size: its thumb slides between
+ the tiers */
+ .st-seg {
+ margin-bottom: 14px;
+ border-radius: 8px;
+ background: var(--ss-ink-1);
+ }
+
+ .st-seg .ss-seg__option {
+ height: 24px;
+ font-size: 11px;
+ }
+
+ .st-seg .ss-seg__thumb { border-radius: 6px; }
+
+ /* One gutter under the plan. The card bleeds off the bottom just past its
+ axis labels, so nothing important is cropped and no empty card is left over. */
+ .st-chart { top: 274px; height: 220px; }
+
+ .st-bars {
+ display: block;
+ margin-top: 14px;
+ }
+
+ .st-axis {
+ display: flex;
+ justify-content: space-between;
+ margin-top: 8px;
+ color: var(--ss-text);
+ font-size: 10px;
+ }
+
+
+ .st-inv__due, .st-qty { transition: opacity 180ms ease; }
+ .st-inv[data-reset] .st-inv__due, .st-inv[data-reset] .st-qty { opacity: 0; }
+
+ /* On a phone, the story reads down the invoice instead of beside it. */
+ & .ss-stage[data-layout='compact'] { --gutter: 16px; }
+ & .ss-stage[data-layout='compact'] .st-invoice { width: 310px; height: 640px; }
+ & .ss-stage[data-layout='compact'] .st-inv { padding: 20px; }
+ & .ss-stage[data-layout='compact'] .st-inv__meta { gap: 8px; }
+ & .ss-stage[data-layout='compact'] .st-plan,
+ & .ss-stage[data-layout='compact'] .st-chart { left: 40px; width: 302px; }
+ & .ss-stage[data-layout='compact'] .st-plan { top: 250px; }
+ & .ss-stage[data-layout='compact'] .st-chart { top: 432px; }
+}
diff --git a/skills/imported/benjitaylor/stageshow/examples/subscriptions/index.tsx b/skills/imported/benjitaylor/stageshow/examples/subscriptions/index.tsx
new file mode 100644
index 0000000..3df83f4
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/subscriptions/index.tsx
@@ -0,0 +1,252 @@
+import { Avatar, Bar, Chart, Collapse, Count, Fly, Roll, Spinner, Stage, States, Window, type Act, type Rect } from '../../kit'
+import './subs.css'
+
+// A system story, no cursor. A customer subscribes on a checkout, the price
+// rides into the company's billing dashboard as the new row's amount, and MRR
+// rolls up. It happens twice, then the scene changes to a second company and
+// happens once more: a different business on the same product, which is also
+// how the loop resets without rewinding. The checkout and the window are the
+// two places; the chip is the only thing that travels between them.
+
+type Pulse = { name: string; initial: string; plan: string; price: number }
+type Scene = { org: string; tile: string; mrr: number; history: number[]; pulses: Pulse[] }
+
+const SCENES: Scene[] = [
+ {
+ org: 'Northwind',
+ tile: 'N',
+ mrr: 48210,
+ history: [31, 33, 32, 36, 38, 37, 41, 44, 43, 46, 48.2],
+ pulses: [
+ { name: 'Maya Lin', initial: 'M', plan: 'Pro', price: 49 },
+ { name: 'Studio Kite', initial: 'S', plan: 'Team', price: 99 },
+ ],
+ },
+ {
+ org: 'Brightside',
+ tile: 'B',
+ mrr: 12480,
+ history: [6, 6.4, 7.1, 7, 8.2, 8.8, 9.1, 10.4, 10.9, 11.6, 12.48],
+ pulses: [{ name: 'Leo Park', initial: 'L', plan: 'Plus', price: 29 }],
+ },
+]
+
+type Pay = 'idle' | 'busy' | 'paid' | 'done'
+type S = {
+ scene: number
+ // per scene: rows opened, rows landed, which pulse the checkout shows, its button
+ open: [number, number]
+ landed: [number, number]
+ k: [number, number]
+ pay: [Pay, Pay]
+ fly: null | { scene: number; i: number; from: Rect; to: Rect; go: boolean }
+}
+
+const START: S = { scene: 0, open: [0, 0], landed: [0, 0], k: [0, 0], pay: ['idle', 'idle'], fly: null }
+const POSTER: S = { ...START, open: [2, 0], landed: [2, 0], k: [1, 0], pay: ['done', 'idle'] }
+
+const money = (n: number) => '$' + Math.round(n).toLocaleString('en-US')
+const set2 = (pair: [T, T], i: number, v: T): [T, T] => (i === 0 ? [v, pair[1]] : [pair[0], v])
+
+async function pulse(a: Act, sc: number, i: number) {
+ await a.set((s) => ({ pay: set2(s.pay, sc, 'busy') }))
+ await a.wait(700)
+ // Paid: the chip lifts off the button at once and the new row's space opens
+ // under it while it travels. Where it lands is the top of the list as it is
+ // now (the new row opens there), at the right edge where amounts sit.
+ const list = a.rect(`list-${sc}`, { layout: true })
+ const row = a.rect(`row-${sc}-${i}`, { layout: true })
+ const pill = a.rect(`pill-${sc}-${i}`, { layout: true })
+ const b = a.rect(`pay-${sc}`, { layout: true })
+ const size = { width: pill.width, height: pill.height }
+ // The row keeps its full layout height inside Collapse. Measure the pill's
+ // actual inset, including the row's border, so the handoff has no half-pixel hop.
+ const to = { ...pill, y: list.y + pill.y - row.y }
+ const from = { x: b.x + b.width / 2 - pill.width / 2, y: b.y + b.height / 2 - pill.height / 2, ...size }
+ await a.set((s) => ({ pay: set2(s.pay, sc, 'paid'), fly: { scene: sc, i, from, to, go: false } }))
+ // The chip buds off the button before it sets off
+ await a.wait(200)
+ await a.set((s) => ({ open: set2(s.open, sc, i + 1), fly: { ...s.fly!, go: true } }))
+ await a.wait(900)
+ // Landed: the real pill takes over in the same frame the chip goes
+ await a.set((s) => ({ fly: null, landed: set2(s.landed, sc, i + 1), pay: set2(s.pay, sc, 'done') }))
+}
+
+async function script(a: Act) {
+ await a.wait(900)
+ a.mark('first')
+ await pulse(a, 0, 0)
+ await a.wait(700)
+ // The checkout moves on to the next customer: only the plan and price change
+ await a.set((s) => ({ pay: set2(s.pay, 0, 'idle'), k: set2(s.k, 0, 1) }))
+ await a.wait(1000)
+ a.mark('second')
+ await pulse(a, 0, 1)
+ await a.wait(2000)
+
+ a.mark('scene')
+ await a.set({ scene: 1 })
+ await a.wait(700)
+ // Northwind resets while it's out of sight
+ await a.snap((s) => ({ open: set2(s.open, 0, 0), landed: set2(s.landed, 0, 0), k: set2(s.k, 0, 0), pay: set2(s.pay, 0, 'idle') }))
+ await a.wait(500)
+ await pulse(a, 1, 0)
+ await a.wait(2000)
+
+ await a.set({ scene: 0 })
+ await a.wait(700)
+ await a.snap((s) => ({ open: set2(s.open, 1, 0), landed: set2(s.landed, 1, 0), pay: set2(s.pay, 1, 'idle') }))
+ await a.wait(300)
+}
+
+function Checkout({ sc, s }: { sc: number; s: S }) {
+ const scene = SCENES[sc]
+ const p = scene.pulses[s.k[sc]]
+ return (
+
+
+
+ {scene.tile}
+
+
+ {scene.org} {p.plan}
+
+
+
+ {`$${p.price}`}
+ per month
+
+
+
+
+
+
+
+
+
+
+
+ ,
+ // The amount is the feedback while it leaves the button. Showing
+ // the success label underneath would put two messages in one place.
+ paid: null,
+ done: (
+
+
+ Subscribed
+
+ ),
+ }}
+ />
+
+
+ )
+}
+
+// Filler rows that were there before the story: they keep their place below
+const OLD = [
+ { name: 62, amount: 34 },
+ { name: 78, amount: 40 },
+ { name: 54, amount: 34 },
+ { name: 70, amount: 40 },
+]
+
+function Dashboard({ sc, s, compact }: { sc: number; s: S; compact: boolean }) {
+ const scene = SCENES[sc]
+ const added = scene.pulses.slice(0, s.landed[sc]).reduce((t, p) => t + p.price, 0)
+ const mrr = scene.mrr + added
+ const data = [...scene.history.slice(0, -1), mrr / 1000]
+ return (
+
+
+
+
+
+ {/* Compact drops the sidebar, so the company moves up beside the title */}
+
+ {scene.tile}
+
+ Subscriptions
+
+
+
+
+
+ MRR
+
+
+
+
+
+ {/* Newest first: each new subscriber opens at the top and pushes the rest down */}
+ {scene.pulses
+ .map((p, i) => ({ p, i }))
+ .reverse()
+ .map(({ p, i }) => (
+ i} reveal="clip">
+
+ Mount Fuji is an active stratovolcano located on the Japanese island of{' '}
+ Honshu, with a summit elevation of 3,776.24 m (12,389 ft). It is the highest
+ mountain in Japan, and the second-highest volcano on any Asian island.
+
+
+ It is located about 100 km southwest of Tokyo, from which it is visible on clear
+ days. It has an exceptionally symmetrical cone, which is covered in snow for about five months of the year.
+
+
Etymology
+
+ The current kanji for Mount Fuji, 富 and 士, mean wealth or abundant and a man of a
+ certain status respectively.
+
+ The lake is within the borders of the Fuji-Hakone-Izu National Park, and has the
+ longest shoreline of any of the Fuji Five Lakes.
+
+
History
+
+ The lake has no natural outlet, and flooding of settlements on its shores was a problem until a canal was
+ completed in 1914 to connect it to a tributary of the Sagami River.
+
+
+ >
+ )
+}
+
+function Infobox({ name, tone, img, caption, rows }: { name: string; tone: 'mountain' | 'water'; img: 'fuji' | 'lake'; caption: string; rows: [string, ReactNode][] }) {
+ return (
+
+ )
+}
+
+/* ---- Thumbnails ---- */
+
+// Every suggestion has a photo, as on wikipedia.org. Lake Kawaguchi's is
+// the real one its article opens with; the others are tight crops of the
+// demo's two photos (a hillside, open water, a shoreline at dusk), which at
+// 40px read as their own photos. Kawaupaka's real thumbnail is black and
+// white, so its crop is too. The novel has Codex's placeholder, as an
+// article without an image does.
+const CROPS: Record, { img: 'lake' | 'fuji'; size: number; at: string; filter?: string }> = {
+ baikal: { img: 'lake', size: 320, at: '0% 46%', filter: 'saturate(0.75)' },
+ tahoe: { img: 'lake', size: 260, at: '100% 88%' },
+ victoria: { img: 'fuji', size: 360, at: '0% 30%', filter: 'brightness(1.1)' },
+ superior: { img: 'fuji', size: 460, at: '100% 100%' },
+ kaweah: { img: 'lake', size: 380, at: '92% 52%' },
+ kawaupaka: { img: 'fuji', size: 320, at: '100% 72%', filter: 'grayscale(1) contrast(1.1)' },
+}
+
+function ThumbImage({ kind }: { kind: Thumb }) {
+ if (kind === 'photo') return
+ if (kind === 'none')
+ return (
+
+
+
+ )
+ const c = CROPS[kind]
+ return
+}
+
+/* ---- Logo and icons ---- */
+
+/* Wikipedia's mark, drawn small and plain: a globe with a few puzzle seams,
+ next to the wordmark set in a serif. */
+function Logo({ compact }: { compact: boolean }) {
+ return (
+
+
+ {!compact && (
+
+
+ WIKIPEDIA
+
+ The Free Encyclopedia
+
+ )}
+
+ )
+}
+
+// Codex, Wikimedia's icon set (MIT), at its native 20px grid
+const CDX: Record = {
+ menu: ,
+ search: ,
+ ellipsis: ,
+ listBullet: ,
+ expand: ,
+ language: (
+
+ ),
+ image: (
+ <>
+
+
+
+ >
+ ),
+ articleSearch: ,
+}
+
+function CdxIcon({ name, size = 20, className }: { name: string; size?: number; className?: string }) {
+ return (
+
+ )
+}
diff --git a/skills/imported/benjitaylor/stageshow/examples/wikipedia/kawaguchiko.jpg b/skills/imported/benjitaylor/stageshow/examples/wikipedia/kawaguchiko.jpg
new file mode 100644
index 0000000..7ef94ca
Binary files /dev/null and b/skills/imported/benjitaylor/stageshow/examples/wikipedia/kawaguchiko.jpg differ
diff --git a/skills/imported/benjitaylor/stageshow/examples/wikipedia/wiki.css b/skills/imported/benjitaylor/stageshow/examples/wikipedia/wiki.css
new file mode 100644
index 0000000..121f374
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/wikipedia/wiki.css
@@ -0,0 +1,577 @@
+/* Wikipedia (Vector 2022) in a browser on a docs plate. Pinned with a 32px
+ gutter on the left, right and top; the window runs off the bottom. Colors
+ and the search menu's geometry are Codex's own values (measured on
+ en.wikipedia.org, October 2026), all one cool neutral family. */
+
+.wk-stage .ss-stage {
+ --gutter: 32px;
+ --wk-text: #202122;
+ --wk-subtle: #54595d;
+ --wk-placeholder: #72777d;
+ --wk-link: #36c;
+ --wk-border: #a2a9b1;
+ --wk-border-soft: #c8ccd1;
+ --wk-soft: #f8f9fa;
+ --wk-hover: #eaecf0;
+ --wk-active: #dadde3;
+ --wk-serif: 'Linux Libertine', 'Georgia', 'Times', serif;
+ --wk-sans: -apple-system, 'Helvetica Neue', Arial, sans-serif;
+ overflow: hidden;
+ background: var(--ss-well);
+}
+
+.wk-window {
+ position: absolute;
+ top: var(--gutter);
+ left: var(--gutter);
+ right: var(--gutter);
+ height: 640px;
+ background: #fff;
+ border-radius: 10px;
+ box-shadow: var(--ss-shadow-hero);
+}
+
+.wk-page {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ padding: 0 24px;
+ background: #fff;
+ color: var(--wk-text);
+ font-family: var(--wk-sans);
+ font-size: 14px;
+ line-height: 1.6;
+}
+
+.wk-spacer {
+ flex: 1;
+}
+
+.wk-icon {
+ display: block;
+ flex-shrink: 0;
+ color: var(--wk-text);
+}
+
+/* ---- Header ---- */
+
+.wk-header {
+ position: relative;
+ z-index: 2;
+ display: flex;
+ flex-shrink: 0;
+ align-items: center;
+ gap: 16px;
+ height: 58px;
+}
+
+.wk-logo {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.wk-logo svg {
+ display: block;
+}
+
+.wk-wordmark {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ font-family: var(--wk-serif);
+ color: #000;
+ line-height: 1;
+}
+
+.wk-wordmark__name {
+ font-size: 17px;
+ letter-spacing: 0.06em;
+}
+
+.wk-wordmark__name small {
+ font-size: 13.5px;
+}
+
+.wk-wordmark__tag {
+ margin-top: 3px;
+ font-size: 9.5px;
+ white-space: nowrap;
+ letter-spacing: 0.01em;
+}
+
+.wk-search {
+ position: relative;
+ display: flex;
+ margin-left: 8px;
+}
+
+.wk-field {
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: 316px;
+ height: 32px;
+ padding: 0 10px;
+ border: 1px solid var(--wk-border);
+ border-radius: 2px 0 0 2px;
+ transition: border-color 0.1s ease, box-shadow 0.1s ease;
+}
+
+/* Codex's focus: a progressive-blue border doubled by an inset ring */
+.wk-field[data-focus] {
+ border-color: var(--wk-link);
+ box-shadow: inset 0 0 0 1px var(--wk-link);
+}
+
+.wk-field[data-menu] {
+ border-bottom-left-radius: 0;
+}
+
+.wk-field__icon {
+ color: var(--wk-placeholder);
+}
+
+.wk-field__text {
+ position: relative;
+ flex: 1;
+ min-width: 0;
+ height: 22px;
+}
+
+.wk-ph,
+.wk-q {
+ position: absolute;
+ inset: 0 auto 0 0;
+ display: flex;
+ align-items: center;
+ white-space: nowrap;
+}
+
+/* The placeholder leaves on the first key, as a real field's does; it
+ comes back with a fade when the page changes and the field is reset */
+.wk-ph {
+ color: var(--wk-placeholder);
+ /* Comes back once the query has gone, so the two never overlap */
+ transition: opacity 0.25s ease 0.14s;
+}
+
+.wk-ph[data-hide] {
+ opacity: 0;
+ transition: none;
+}
+
+.wk-q {
+ opacity: 0;
+ transition: opacity 0.14s ease;
+}
+
+.wk-q[data-show] {
+ opacity: 1;
+ transition: none;
+}
+
+.wk-caret {
+ width: 1px;
+ height: 17px;
+ margin-left: 1px;
+ background: var(--wk-text);
+ opacity: 0;
+}
+
+.wk-caret[data-on] {
+ animation: wk-blink 1.06s steps(1) infinite;
+}
+
+.wk-caret[data-on][data-typing] {
+ animation: none;
+ opacity: 1;
+}
+
+@keyframes wk-blink {
+ 0% { opacity: 1; }
+ 50% { opacity: 0; }
+}
+
+.wk-button {
+ display: flex;
+ align-items: center;
+ height: 32px;
+ padding: 0 12px;
+ border: 1px solid var(--wk-placeholder);
+ border-left: 0;
+ border-radius: 0 2px 2px 0;
+ background: var(--wk-soft);
+ font-weight: 700;
+}
+
+.wk-userlink {
+ color: var(--wk-link);
+ white-space: nowrap;
+}
+
+/* ---- The typeahead menu ----
+ Hangs from the field's bottom edge at the field's width, square at the
+ top, with Codex's hairline and soft shadow. */
+
+.wk-menu.ss-popover {
+ --ss-popover-bg: #fff;
+ /* The hairline sits inside the box, so the menu's edges line up with the
+ field's, as on wikipedia.org; no top line, the field's border is it */
+ --ss-popover-shadow: inset 1px 0 0 var(--wk-border), inset -1px 0 0 var(--wk-border), inset 0 -1px 0 var(--wk-border), 0 4px 4px rgba(0, 0, 0, 0.06), 0 0 8px rgba(0, 0, 0, 0.06);
+ top: 32px;
+ left: 0;
+ width: 316px;
+ padding: 0;
+ border-radius: 0 0 2px 2px;
+}
+
+.wk-menu__list {
+ /* Rows run the menu's full width, as Codex's do (measured on
+ wikipedia.org: no menu padding, 8px 12px inside each row) */
+ padding: 0;
+ /* One lasting layer for the rows, so their text never hops in its box */
+ will-change: transform;
+}
+
+/* Rows are links; Codex's hover is a square fill the full width of the menu */
+.wk-result {
+ height: 60px;
+ transition: background-color 0.1s ease;
+}
+
+.wk-result[data-hover] {
+ background: var(--wk-hover);
+}
+
+.wk-result[data-pressed] {
+ background: var(--wk-active);
+}
+
+.wk-swap.ss-swap {
+ display: block;
+ height: 60px;
+}
+
+.wk-swap .ss-swap__in,
+.wk-swap .ss-swap__out {
+ width: 100%;
+}
+
+.wk-result__inner {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ height: 60px;
+ padding: 0 12px;
+}
+
+.wk-thumb {
+ display: flex;
+ flex-shrink: 0;
+ align-items: center;
+ justify-content: center;
+ width: 40px;
+ height: 40px;
+ overflow: hidden;
+ border: 1px solid var(--wk-border-soft);
+ border-radius: 2px;
+ background: var(--wk-soft) center / cover;
+}
+
+.wk-thumb svg {
+ display: block;
+}
+
+.wk-thumb[data-empty] .wk-icon {
+ color: var(--wk-placeholder);
+}
+
+.wk-result__text {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ line-height: 22px;
+}
+
+.wk-result__title {
+ overflow: hidden;
+ font-weight: 700;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.wk-result__desc {
+ overflow: hidden;
+ color: var(--wk-subtle);
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.wk-menu__footer {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-height: 38px;
+ padding: 8px 12px;
+ line-height: 22px;
+}
+
+.wk-menu__footer-icon {
+ display: flex;
+ justify-content: center;
+ width: 40px;
+}
+
+.wk-menu__footer-icon .wk-icon {
+ color: var(--wk-subtle);
+}
+
+.wk-menu__footer-text {
+ min-width: 0;
+}
+
+/* ---- Pages: a navigation crossfades the article under the header ---- */
+
+.wk-pages {
+ position: relative;
+ flex: 1;
+}
+
+.wk-pg {
+ position: absolute;
+ inset: 0;
+ opacity: 0;
+ transform: translateY(8px);
+ /* Kept on its layer, so the article's text doesn't hop as the rise lands */
+ will-change: transform;
+ transition: opacity 0.2s ease, transform 0s linear 0.2s;
+}
+
+.wk-pg[data-show] {
+ opacity: 1;
+ transform: none;
+ transition: opacity 0.45s cubic-bezier(0.25, 0.8, 0.25, 1) 0.1s, transform 0.7s cubic-bezier(0.25, 0.8, 0.25, 1) 0.1s;
+}
+
+/* ---- Title, tabs ---- */
+
+.wk-titlebar {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-top: 10px;
+ padding-bottom: 2px;
+ border-bottom: 1px solid var(--wk-border);
+}
+
+.wk-title {
+ margin: 0;
+ font-family: var(--wk-serif);
+ font-size: 28.8px;
+ font-weight: 400;
+ line-height: 1.375;
+ white-space: nowrap;
+}
+
+.wk-lang {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ color: var(--wk-link);
+ font-weight: 700;
+ white-space: nowrap;
+}
+
+.wk-lang .wk-icon {
+ color: var(--wk-link);
+}
+
+.wk-tabs {
+ display: flex;
+ align-items: stretch;
+ gap: 16px;
+ height: 40px;
+}
+
+.wk-tab {
+ display: flex;
+ align-items: center;
+ white-space: nowrap;
+ border-bottom: 2px solid transparent;
+}
+
+.wk-tab[data-selected] {
+ border-bottom-color: var(--wk-text);
+}
+
+.wk-blue {
+ color: var(--wk-link);
+}
+
+/* ---- Article ---- */
+
+.wk-tagline {
+ margin: 6px 0 8px;
+ font-size: 12.3px;
+ color: var(--wk-subtle);
+}
+
+.wk-content p {
+ margin: 0 0 10px;
+}
+
+.wk-link {
+ color: var(--wk-link);
+}
+
+.wk-infobox {
+ float: right;
+ width: 232px;
+ margin: 10px 0 12px 20px;
+ padding: 3px;
+ border: 1px solid var(--wk-border);
+ background: var(--wk-soft);
+ font-size: 12.3px;
+ line-height: 1.5;
+}
+
+/* Wikipedia tints an infobox's title by subject */
+.wk-infobox__name {
+ margin-bottom: 4px;
+ padding: 2px 4px;
+ text-align: center;
+ font-size: 15.5px;
+ font-weight: 700;
+ line-height: 1.4;
+}
+
+.wk-infobox__name[data-tone="mountain"] {
+ background: #e7dcc3;
+}
+
+.wk-infobox__name[data-tone="water"] {
+ background: #cedeff;
+}
+
+/* Real photos, both CC0 on Wikimedia Commons: "Fuji Kawaguchi 465.JPG" and
+ "Kawaguchiko, Part III - Kawaguchiko6782.jpg" */
+[data-img="fuji"] {
+ background: #9fb6cc url('./fuji.jpg') center 30% / cover;
+}
+
+[data-img="lake"] {
+ background: #c9d4dc url('./kawaguchiko.jpg') center / cover;
+}
+
+.wk-infobox__img {
+ display: block;
+ width: 220px;
+ height: 146px;
+ margin: 0 auto;
+}
+
+.wk-infobox__caption {
+ padding: 4px 4px 6px;
+ text-align: center;
+ font-size: 11.5px;
+}
+
+.wk-infobox table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.wk-infobox th,
+.wk-infobox td {
+ padding: 3px 4px;
+ vertical-align: top;
+ text-align: left;
+}
+
+.wk-infobox th {
+ width: 42%;
+ font-weight: 700;
+}
+
+.wk-figure {
+ height: 150px;
+ margin: 4px 0 12px;
+ border: 1px solid var(--wk-border-soft);
+}
+
+/* ---- Compact (narrow containers) ---- */
+
+.ss-stage[data-layout="compact"] .wk-window {
+ --gutter: 16px;
+}
+.ss-stage[data-layout="compact"] .wk-page {
+ padding: 0 16px;
+}
+.ss-stage[data-layout="compact"] .wk-header {
+ gap: 12px;
+}
+.ss-stage[data-layout="compact"] .wk-search {
+ flex: 1;
+ min-width: 0;
+ margin-left: 0;
+}
+.ss-stage[data-layout="compact"] .wk-field {
+ width: 100%;
+ border-radius: 2px;
+}
+.ss-stage[data-layout="compact"] .wk-field[data-menu] {
+ border-radius: 2px 2px 0 0;
+}
+.ss-stage[data-layout="compact"] .wk-menu.ss-popover {
+ width: 100%;
+}
+.ss-stage[data-layout="compact"] .wk-title {
+ font-size: 24px;
+}
+.wk-window .ss-window__url {
+ min-width: 0;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+/* The refine crossfades each row in place: the new row starts to show as the
+ old one leaves, so no row is ever empty (the kit's Swap holds the new text
+ back for its first 30%, which leaves a blank row mid-swap) */
+.wk-swap .ss-swap__in {
+ animation-name: wk-row-in;
+}
+
+.wk-swap .ss-swap__out {
+ animation-name: wk-row-out;
+}
+
+@keyframes wk-row-in { from { opacity: 0; } to { opacity: 1; } }
+
+@keyframes wk-row-out { to { opacity: 0; } }
+
+.wk-h2 {
+ margin: 18px 0 8px;
+ padding-bottom: 2px;
+ border-bottom: 1px solid var(--wk-border);
+ font-family: var(--wk-serif);
+ font-size: 21px;
+ font-weight: 400;
+ line-height: 1.375;
+}
+
+/* Vector's collapsed page tools are a vertical ellipsis */
+.wk-kebab {
+ transform: rotate(90deg);
+}
+.ss-stage[data-layout="compact"] .wk-header > .wk-spacer {
+ display: none;
+}
+
+/* Picking a result navigates, so the menu goes quickly rather than hanging
+ half-faded over the article underneath (the kit's 0.3s close) */
+.wk-menu.ss-popover:not([data-show]) {
+ transition: opacity 0.15s ease, transform 0.15s cubic-bezier(0.4, 0, 1, 1);
+}
diff --git a/skills/imported/benjitaylor/stageshow/examples/window-row/index.tsx b/skills/imported/benjitaylor/stageshow/examples/window-row/index.tsx
new file mode 100644
index 0000000..fc2fceb
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/window-row/index.tsx
@@ -0,0 +1,87 @@
+import { Badge, Bar, Check, Row, Stage, States, Text, Window, type Act } from '../../kit'
+import './row.css'
+
+// A row of windows: three agent sessions side by side at one height. The
+// middle one is the story and the only one with words; the outer two run off
+// both sides of the stage, still working, as placeholder. Beats: the middle
+// session ticks through its steps, then lands on Ready for review. Plays
+// once; the still is the payoff.
+//
+// What makes a row read as one picture:
+// - Same height and top for every window, one gap between them. The outer
+// ones are cut by the stage by about a third, so the row reads as going on.
+// - Focus by depth, not by dimming the neighbors: the middle window has the
+// hero shadow and sits in front; the outer ones keep the quieter window
+// shadow. Faded or blurred neighbors read as a mistake.
+// - The neighbors are busy but quiet: a spinner and bars, no title, no words
+// to read. The stage cuts them, and it may only cut filler.
+// - The windows run off the bottom just under the result, so no window has a
+// dead strip below its content.
+//
+// Real strings: the middle session's title, its steps and the result.
+
+type S = { done: number; ready: boolean }
+
+const initial: S = { done: 0, ready: false }
+const poster: S = { done: 4, ready: true }
+
+const STEPS = ['Read the auth middleware', 'Found the redirect loop', 'Patched the session check', 'Tests passing, 48 of 48']
+
+async function script(a: Act) {
+ await a.wait(900)
+ for (let i = 1; i <= STEPS.length; i++) {
+ a.mark(`step-${i}`)
+ a.put({ done: i })
+ await a.wait(i === STEPS.length ? 700 : 950)
+ }
+ a.mark('ready')
+ a.put({ ready: true })
+ await a.wait(2400)
+}
+
+// The neighbors: the same rows as the hero, bars where the words are
+function Busy({ rows }: { rows: number[] }) {
+ return (
+
+ {/* One Check per step, mounted throughout: ring, then pending, then
+ done, so each hand-over is the kit's own animation */}
+ {STEPS.map((step, i) => (
+ } title={{step}} />
+ ))}
+
+ Working,
+ ready: Ready for review,
+ }}
+ />
+
+
+
+
+
+
+ >
+ )}
+
+ )
+}
diff --git a/skills/imported/benjitaylor/stageshow/examples/window-row/row.css b/skills/imported/benjitaylor/stageshow/examples/window-row/row.css
new file mode 100644
index 0000000..2462d43
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/examples/window-row/row.css
@@ -0,0 +1,63 @@
+/* Three windows at one height and top, one 20px gap. The middle one is
+ centered; the outer two run off both sides by about a third. */
+.wr .ss-stage {
+ overflow: hidden;
+ background: var(--ss-well);
+}
+
+.wr-win {
+ position: absolute;
+ top: 32px;
+ width: 300px;
+ height: 340px;
+}
+
+.wr-win--hero {
+ left: 210px;
+ z-index: 1;
+ box-shadow: var(--ss-shadow-hero);
+}
+
+.wr-win--left {
+ left: -110px;
+}
+
+.wr-win--right {
+ left: 530px;
+}
+
+.wr-body {
+ display: grid;
+ gap: 12px;
+ padding: 18px 20px 0;
+}
+
+
+.wr-label {
+ color: var(--ss-text);
+ font-size: 13px;
+ transition: color 0.3s ease;
+}
+
+.wr-step[data-on] .wr-label,
+.wr-step[data-next] .wr-label {
+ color: var(--ss-text-strong);
+}
+
+.wr-foot {
+ margin-top: 6px;
+ padding-top: 16px;
+ border-top: 1px solid var(--ss-line);
+}
+
+/* Narrow: no room for a row. The neighbors would be slivers, so the hero
+ stands alone, a gutter in from each side, and runs off the bottom */
+.wr .ss-stage[data-layout='compact'] .wr-win--left,
+.wr .ss-stage[data-layout='compact'] .wr-win--right {
+ display: none;
+}
+
+.wr .ss-stage[data-layout='compact'] .wr-win--hero {
+ left: 16px;
+ width: 326px;
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/act.ts b/skills/imported/benjitaylor/stageshow/kit/act.ts
new file mode 100644
index 0000000..10bb568
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/act.ts
@@ -0,0 +1,669 @@
+import { flushSync } from 'react-dom'
+import type { Dispatch, SetStateAction } from 'react'
+import type { CursorKind } from './cursor'
+import { capsOf, type Keys } from './keys'
+import type { Layout } from './looks'
+import { EMPTY, drawnScale, nameOf, layoutRectOf, rectOf, thumb, type Point, type Rect, type Target } from './measure'
+import { bezier } from './ease'
+import { clamp, easeInOutCubic, hash, warn } from './util'
+
+// The touch dot lingers a beat after a lift, then goes
+const fingerGone = (el: HTMLElement | null | undefined) => { if (!el) return; window.setTimeout(() => el.removeAttribute('data-touching'), 240) }
+
+// The script API: the act a demo's script is handed each run. It drives the
+// scene state and the cursor, measures targets, and stops the moment its
+// signal aborts (the stage left the screen, or the layout changed).
+
+export interface Act {
+ /** Merge a patch into scene state. Resolves once it has painted, so rects are fresh. */
+ set(patch: Partial | ((s: S) => Partial)): Promise
+ /** Like set, without waiting. For per-frame updates inside drag. */
+ put(patch: Partial | ((s: S) => Partial)): void
+ wait(ms: number): Promise
+ /**
+ * Where a target is now, in stage pixels. With `layout`, where it sits in
+ * the layout, ignoring transforms: use that to aim at something that is
+ * still scaling or sliding in.
+ */
+ rect(name: string, opts?: { layout?: boolean }): Rect
+ /**
+ * The character offset a text selection reaches with the pointer at `at`,
+ * the way the OS works it out: above the text is its start, below is its
+ * end, beside a line clamps to that line, between lines takes the nearer.
+ * Call it in a drag's onMove and the highlight stays under the I-beam from
+ * the first pixel: `a.drag(end, (at) => a.put({ sel: a.caret('para', at) }))`.
+ */
+ caret(name: string, at: Point): number
+ center(name: string): Point
+ /** Move the cursor in a hand-like arc. Hovering a target marks it data-hover. */
+ /**
+ * `path` sets the route in stage coordinates (eased 0 to 1 progress, the
+ * same contract as drag's) instead of the shallow arc: a hand running
+ * along a chart line. `onMove(at, from, t)` gets each point on the way, as drag's does, for anything
+ * that follows the pointer (a scrubbed day, a hover readout).
+ */
+ move(to: Target, opts?: { duration?: number; path?: (progress: number, from: Point, to: Point) => Point; onMove?: (at: Point, from: Point, t: number) => void }): Promise
+ /**
+ * Move (if given a target) and press. `double` presses twice, as a double
+ * click does (open a file, select a word). `right` is a secondary click: it
+ * looks like any press, so what opens at the pointer (a context menu) is
+ * what tells it; pressed elements get data-pressed="right" to tell them apart.
+ */
+ click(to?: Target, opts?: { double?: boolean; right?: boolean }): Promise
+ /**
+ * Scroll a target that clips its content (give it the class ss-scroll), the
+ * way a trackpad does: the cursor stays put and the content moves. `to` is
+ * a scroll offset in px, or the name of a target inside it to bring to the
+ * top (less `offset`). Once scrolled, its edges fade over what's hidden.
+ * A visible cursor moves into the area first if it isn't over it already,
+ * and holds still while the content scrolls under it.
+ */
+ scroll(name: string, to: number | string, opts?: { duration?: number; offset?: number }): Promise
+ cursor(kind: CursorKind): Promise
+ /**
+ * Apply a patch with transitions off, for resets nothing on screen should
+ * explain. Pass a target name to switch transitions off only inside it.
+ */
+ snap(patch: Partial | ((s: S) => Partial), scope?: string): Promise
+ /**
+ * Show a keystroke as keycaps: 'cmd+k', 'enter', 'shift+cmd+p'. Resolves
+ * as the key comes back up, the moment an app would react; the caps fade
+ * on their own.
+ */
+ key(combo: string): Promise
+ /**
+ * Start fn for each item, `gap` ms apart (60 by default), and wait for them all. Staggered
+ * starts read as organic where a single block reads as mechanical.
+ */
+ stagger(items: T[], fn: (item: T, i: number) => Promise | void, gap?: number): Promise
+ /** Name the current beat. Sets data-mark on the stage so a film can start there (film.mjs --at). */
+ mark(name: string): void
+ /** Type text into a string key of state, one character at a time, `interval` ms apart on average (35 by default). */
+ type(key: keyof S, text: string, opts?: { interval?: number }): Promise
+ /** Press, hold and move in the same shallow, hand-like arc as move(), reporting each frame. The cursor
+ * dips as it presses and closes into the grab hand at the bottom of the dip, and opens on release by itself:
+ * don't switch to 'grab' before a drag, a hovering cursor is never a fist.
+ * I-beam and crosshair selections keep their kind. Use cursor: 'preserve'
+ * when the product keeps its current cursor during a drag, such as an arrow
+ * creating a calendar event. This changes the cursor only, not the path.
+ * For constrained mechanics, path receives eased progress (0..1) and returns
+ * stage coordinates shared by the hand and onMove. Its endpoints must match
+ * from and to. Use a circular path for a dial, or a straight path for a rail. */
+ drag(to: Target, onMove?: (at: Point, from: Point, t: number) => void, opts?: { duration?: number; cursor?: 'preserve'; path?: (progress: number, from: Point, to: Point) => Point }): Promise
+ /** Put the cursor there at once, with no travel. Only behind a cut, where nothing on screen has to explain it. */
+ place(to: Target): void
+ /**
+ * Bring the cursor in from outside the frame, already moving, and settle
+ * it on `to`: it starts past the frame edge nearest the target and never
+ * fades in mid-frame. The frame is the stage.
+ */
+ enter(to: Target, opts?: { duration?: number }): Promise
+ readonly signal: AbortSignal
+ /** 'wide' or 'compact': which design is showing, so a script can aim or pace per layout. */
+ readonly layout: Layout
+}
+
+export type Script = (act: Act) => Promise
+
+export class Stopped extends Error {}
+
+type Ref = { current: T }
+
+/** What an act works with: the stage's elements, refs and setters, read when the act is made. */
+export interface ActEnv {
+ rootRef: { readonly current: HTMLDivElement | null }
+ cursorRef: { readonly current: HTMLDivElement | null }
+ pos: Ref
+ hovered: Ref
+ kindRef: Ref
+ keysRef: Ref
+ dragging: Ref
+ autoUsed: Ref
+ autoRef: Ref<((p: Point) => void) | null>
+ scaleRef: Ref
+ speed: number
+ layout: Layout
+ cursor?: { ripple?: boolean; style?: string }
+ resolve: (t: Target) => Point
+ place: (p: Point) => void
+ remeasure: () => void
+ ripple: (at: Point) => void
+ setState: Dispatch>
+ setKind: Dispatch>
+ setKeys: Dispatch>
+ setLastKeys: Dispatch>
+}
+
+// Each run gets its own act, bound to its own signal, so a canceled run can
+// never pick up the next run's signal and keep going.
+export function createAct(signal: AbortSignal, env: ActEnv): Act {
+ const { rootRef, cursorRef, pos, hovered, kindRef, keysRef, dragging, autoUsed, autoRef, scaleRef, speed, layout, cursor, resolve, place, remeasure, ripple, setState, setKind, setKeys, setLastKeys } = env
+ const entering = bezier('enter')
+ const check = () => {
+ if (signal.aborted) throw new Stopped()
+ }
+ // Both clocks belong to this run. A settled wait releases its listener;
+ // abort cancels the pending work before another run can take over.
+ const pause = (schedule: (done: () => void) => T, cancel: (id: T) => void) =>
+ new Promise((res, rej) => {
+ check()
+ const abort = () => {
+ cancel(id)
+ signal.removeEventListener('abort', abort)
+ rej(new Stopped())
+ }
+ const id = schedule(() => {
+ signal.removeEventListener('abort', abort)
+ if (signal.aborted) rej(new Stopped())
+ else res()
+ })
+ signal.addEventListener('abort', abort, { once: true })
+ })
+ const frame = () => pause((done) => requestAnimationFrame(done), cancelAnimationFrame)
+ const wait = (ms: number) => pause((done) => setTimeout(done, ms / speed), clearTimeout)
+ // Release transient DOM/ref state synchronously on abort. The guarded
+ // finally call cannot then clear a new run's press or drag ownership.
+ const own = (release: () => void) => {
+ let live = true
+ const done = () => {
+ if (!live) return
+ live = false
+ signal.removeEventListener('abort', done)
+ release()
+ }
+ signal.addEventListener('abort', done, { once: true })
+ return done
+ }
+ const tween = async (ms: number, step: (t: number) => void) => {
+ const start = performance.now()
+ const dur = ms / speed
+ for (;;) {
+ check()
+ const t = Math.min(1, (performance.now() - start) / dur)
+ step(t)
+ if (t === 1) return
+ await frame()
+ }
+ }
+ // Hover is the target and every data-target around it, as in a browser:
+ // a row stays hovered while the cursor is on a button inside it.
+ const chain = (el: Element | null) => {
+ const out: Element[] = []
+ for (let n = el; n && n !== rootRef.current; n = n.parentElement) if (n.hasAttribute('data-target')) out.push(n)
+ return out
+ }
+ // Kind from what's under the cursor, as a real one works: any element
+ // marked data-cursor="hand" | "ibeam" | ... sets the kind while the cursor
+ // is over it, switching the moment it crosses the edge. Stages that mark
+ // nothing keep the kind the script sets.
+ const auto = (p: Point) => {
+ const root = rootRef.current
+ if (!root) return
+ const marked = root.querySelectorAll('[data-cursor]')
+ // A stage that never marks anything keeps the script's kind. One that
+ // did falls back to the pointer once its marked elements are gone, so
+ // a hand doesn't outlive the thing it was over.
+ if (!marked.length && !autoUsed.current) return
+ if (marked.length) autoUsed.current = true
+ const box = root.getBoundingClientRect()
+ const s = box.width / root.offsetWidth || 1
+ // What it shows over anything unmarked: the pointer, or the stage's own
+ // base kind (a launch film keeps the hand throughout)
+ const base = (root.dataset.cursorBase as CursorKind | undefined) ?? 'pointer'
+ let next: CursorKind = base
+ // What is actually on top under the point decides, as a real pointer
+ // works: a button in a popover over a text area is not text. Walk up
+ // from the topmost element to the nearest marked one.
+ // Every layer, with pointer-events forced on for the test: demos turn
+ // them off on overlays (a menu, margin buttons), and the cursor must
+ // still read what is visibly on top, not the text beneath it. Layers
+ // that are see-through (faded out) are skipped.
+ root.setAttribute('data-hit', '')
+ const stack = document.elementsFromPoint(box.left + p.x * s, box.top + p.y * s)
+ root.removeAttribute('data-hit')
+ const top = stack.find((el) => {
+ if (!root.contains(el) || el.closest('.ss-cursor, .ss-keys, .ss-ripple')) return false
+ // A layer that turned pointer-events off counts only where it is a
+ // control or a target; a bare overlay (connectors, a flight layer)
+ // must not cover what is under it
+ if (getComputedStyle(el).pointerEvents === 'none' && !el.closest('[data-cursor], [data-target], button, a[href], [role="button"], [role="menuitem"], [role="option"], [role="tab"]')) return false
+ let o = 1
+ for (let n: Element | null = el; n && n !== root.parentElement; n = n.parentElement) {
+ const cs = getComputedStyle(n)
+ if (cs.visibility === 'hidden') return false
+ o *= Number(cs.opacity)
+ }
+ return o > 0.15
+ })
+ if (top) {
+ // A control inside a marked area (a button over text) is the pointer
+ // again unless it's marked itself, as on a real screen
+ const n = top.closest('[data-cursor], button, a[href], [role="button"], [role="menuitem"], [role="option"], [role="tab"]')
+ next = n && root.contains(n) && n.dataset.cursor ? (n.dataset.cursor as CursorKind) : base
+ if (next !== kindRef.current) setKind(next)
+ return
+ }
+ // Off screen (nothing to hit-test): judge by boxes alone
+ marked.forEach((n) => {
+ const r = n.getBoundingClientRect()
+ const x = (r.left - box.left) / s
+ const y = (r.top - box.top) / s
+ // Later (inner) matches win, so a link inside a text block is a hand
+ if (p.x >= x && p.x <= x + r.width / s && p.y >= y && p.y <= y + r.height / s) next = n.dataset.cursor as CursorKind
+ })
+ if (next !== kindRef.current) setKind(next)
+ }
+ autoRef.current = auto
+ // A run starts in the kind of what the cursor rests on, so a loop's first
+ // frame matches its last
+ if (!dragging.current) auto(pos.current)
+ const hover = (el: Element | null) => {
+ if (hovered.current === el) return
+ const next = chain(el)
+ chain(hovered.current).forEach((n) => next.includes(n) || n.removeAttribute('data-hover'))
+ next.forEach((n) => n.setAttribute('data-hover', ''))
+ hovered.current = el
+ }
+ // Stage keeps the last key row through resets; a new Act must not reuse its identity.
+ let keyId = keysRef.current.id
+ let carets = 0
+ const targetEl = (t: Target) => {
+ const name = nameOf(t)
+ return name === null ? null : rootRef.current?.querySelector(`[data-target="${name}"]`) ?? null
+ }
+
+ const a: Act = {
+ get signal() {
+ return signal
+ },
+ put(patch) {
+ if (!signal.aborted) setState((s) => ({ ...s, ...(typeof patch === 'function' ? patch(s) : patch) }))
+ },
+ async set(patch) {
+ check()
+ a.put(patch)
+ await frame()
+ await frame()
+ // The scene changed under a still pointer: a button that left, a menu
+ // that opened. Re-read what is under it now, as a real cursor would
+ autoRef.current?.(pos.current)
+ check()
+ },
+ wait: async (ms) => {
+ check()
+ await wait(ms)
+ },
+ rect: (name, opts) => {
+ const root = rootRef.current
+ const el = root?.querySelector(`[data-target="${name}"]`)
+ if (!root || !el) {
+ warn(`no element with data-target="${name}"`)
+ return EMPTY
+ }
+ return opts?.layout ? layoutRectOf(root, el) : rectOf(root, el, scaleRef.current)
+ },
+ center: (name) => resolve(name),
+ caret(name, at) {
+ carets++
+ const el = targetEl(name) as HTMLElement | null
+ const root = rootRef.current
+ if (!el || !root) return 0
+ const box = root.getBoundingClientRect()
+ const k = box.width / root.offsetWidth || 1
+ const x = box.left + at.x * k
+ const y = box.top + at.y * k
+ // Measure the element's own characters rather than hit-testing, so
+ // overlays (a rewrite, a highlight layer, the cursor) never get in the way
+ const chars: { left: number; right: number; mid: number; top: number; bottom: number; at: number }[] = []
+ const walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
+ const r = document.createRange()
+ let offset = 0
+ // Text that can't be seen isn't selectable: a rewrite waiting at opacity 0
+ // over the original would otherwise pull the caret onto its own lines
+ const hidden = (node: Node) => {
+ for (let p = node.parentElement; p; p = p === el ? null : p.parentElement) {
+ const cs = getComputedStyle(p)
+ if (cs.opacity === '0' || cs.visibility === 'hidden' || cs.display === 'none') return true
+ }
+ return false
+ }
+ for (let n = walk.nextNode(); n; n = walk.nextNode()) {
+ const text = n.textContent ?? ''
+ if (hidden(n)) continue
+ for (let c = 0; c < text.length; c++) {
+ r.setStart(n, c)
+ r.setEnd(n, c + 1)
+ const b = r.getBoundingClientRect()
+ if (b.width || b.height) chars.push({ left: b.left, right: b.right, mid: (b.left + b.right) / 2, top: b.top, bottom: b.bottom, at: offset + c })
+ }
+ offset += text.length
+ }
+ if (!chars.length) return 0
+ if (y < chars[0].top) return 0
+ if (y > chars[chars.length - 1].bottom) return offset
+ // The visual line nearest the pointer, then the boundary nearest it on that line
+ let line = chars[0]
+ for (const ch of chars) if (Math.abs((ch.top + ch.bottom) / 2 - y) < Math.abs((line.top + line.bottom) / 2 - y)) line = ch
+ const row = chars.filter((ch) => Math.abs(ch.top - line.top) < (line.bottom - line.top) / 2)
+ const next = row.find((ch) => ch.mid > x)
+ return next ? next.at : row[row.length - 1].at + 1
+ },
+ async move(to, opts) {
+ check()
+ const from = { ...pos.current }
+ const end = resolve(to)
+ const dist = Math.hypot(end.x - from.x, end.y - from.y)
+ if (dist < 0.5) return hover(targetEl(to))
+ // Leave hover only on what the cursor actually leaves: moving from a
+ // row onto a button inside it keeps the row hovered all the way
+ const dest = chain(targetEl(to))
+ hover(chain(hovered.current).find((n) => dest.includes(n)) ?? null)
+ // A hand travels in a shallow arc, quicker over short hops
+ const ms = opts?.duration ?? clamp(260 + dist * 0.9, 300, 650)
+ const curve = easeInOutCubic
+ const bow = Math.min(dist * 0.08, 24)
+ const nx = -(end.y - from.y) / dist
+ const ny = (end.x - from.x) / dist
+ await tween(ms, (t) => {
+ const e = curve(t)
+ const lift = Math.sin(Math.PI * e) * bow
+ const p = opts?.path ? opts.path(e, from, end) : { x: from.x + (end.x - from.x) * e + nx * lift, y: from.y + (end.y - from.y) * e + ny * lift }
+ place(p)
+ auto(p)
+ if (opts?.onMove) flushSync(() => opts.onMove!(p, from, t))
+ })
+ check()
+ hover(targetEl(to))
+ },
+ async click(to, opts) {
+ check()
+ if (to !== undefined) {
+ await a.move(to)
+ await wait(120)
+ }
+ check()
+ const el = to !== undefined ? targetEl(to) : hovered.current
+ const cursorEl = cursorRef.current
+ const finger = cursor?.style === 'touch'
+ const release = own(() => {
+ cursorEl?.removeAttribute('data-pressed')
+ el?.removeAttribute('data-pressed')
+ if (finger) fingerGone(cursorEl)
+ })
+ try {
+ // A finger is only there when it touches: it shows just before, and goes after it lifts
+ if (finger) { cursorEl?.setAttribute('data-touching', ''); await wait(140); check() }
+ for (let i = 0; i < (opts?.double ? 2 : 1); i++) {
+ if (i) await wait(70)
+ check()
+ cursorEl?.setAttribute('data-pressed', 'true')
+ if (cursor?.ripple) ripple(pos.current)
+ el?.setAttribute('data-pressed', opts?.right ? 'right' : '')
+ await wait(opts?.double ? 80 : 100)
+ check()
+ cursorEl?.removeAttribute('data-pressed')
+ el?.removeAttribute('data-pressed')
+ }
+ } finally {
+ release()
+ }
+ },
+ async scroll(name, to, opts) {
+ check()
+ const el = targetEl(name) as HTMLElement | null
+ if (!el) return
+ const from = el.scrollTop
+ let end = typeof to === 'number' ? to : from
+ if (typeof to === 'string') {
+ const t = targetEl(to) as HTMLElement | null
+ if (t) end = from + (t.getBoundingClientRect().top - el.getBoundingClientRect().top) / drawnScale(rootRef.current, scaleRef.current) - (opts?.offset ?? 0)
+ }
+ end = clamp(end, 0, el.scrollHeight - el.clientHeight)
+ // You can only scroll what the pointer is over: a visible cursor
+ // outside the area moves into it first, then holds still while the
+ // content moves under it
+ if (cursor) {
+ const box = rectOf(rootRef.current!, el, scaleRef.current || 1)
+ const p = pos.current
+ const inside = p.x >= box.x + 8 && p.x <= box.x + box.width - 8 && p.y >= box.y + 8 && p.y <= box.y + box.height - 8
+ if (!inside) {
+ await this.move({ x: box.x + box.width * 0.6, y: box.y + box.height * 0.55 })
+ await this.wait(120)
+ }
+ }
+ // A Scroll's overlay bar shows while it moves and fades once it rests
+ const wrap = el.parentElement?.classList.contains('ss-scroller') ? el.parentElement : null
+ wrap?.setAttribute('data-scrolling', '')
+ const release = own(() => wrap?.removeAttribute('data-scrolling'))
+ try {
+ await tween(opts?.duration ?? clamp(420 + Math.abs(end - from) * 1.2, 500, 1400), (t) => {
+ el.scrollTop = from + (end - from) * easeInOutCubic(t)
+ el.toggleAttribute('data-scrolled', el.scrollTop > 0.5)
+ thumb(el)
+ })
+ check()
+ } finally {
+ release()
+ }
+ remeasure()
+ autoRef.current?.(pos.current)
+ },
+ async snap(patch, scope) {
+ check()
+ const el = scope ? rootRef.current?.querySelector(`[data-target="${scope}"]`) : rootRef.current
+ el?.setAttribute('data-instant', '')
+ const release = own(() => el?.removeAttribute('data-instant'))
+ try {
+ a.put(patch)
+ await frame()
+ await frame()
+ check()
+ } finally {
+ release()
+ }
+ },
+ async key(combo) {
+ check()
+ const caps = capsOf(combo)
+ const id = ++keyId
+ const k = keysRef.current
+ setLastKeys(k.caps.length ? { ...k, show: false, down: false } : null)
+ setKeys({ id, caps, show: false, down: false })
+ await frame()
+ await frame()
+ check()
+ setKeys({ id, caps, show: true, down: false })
+ await wait(180)
+ check()
+ setKeys({ id, caps, show: true, down: true })
+ await wait(140)
+ check()
+ setKeys({ id, caps, show: true, down: false })
+ // The caps linger and fade without holding up the script.
+ void wait(600).then(() => {
+ if (keyId === id && !signal.aborted) setKeys({ id, caps, show: false, down: false })
+ }, (error) => {
+ if (!(error instanceof Stopped)) console.error('[stageshow] script error', error)
+ })
+ },
+ async stagger(items, fn, gap = 60) {
+ await Promise.all(
+ items.map(async (item, i) => {
+ if (i) await wait(gap * i)
+ check()
+ await fn(item, i)
+ }),
+ )
+ },
+ mark(name) {
+ if (!signal.aborted) rootRef.current?.setAttribute('data-mark', name)
+ },
+ async cursor(k) {
+ check()
+ setKind(k)
+ await frame()
+ },
+ async type(key, text, opts) {
+ const interval = opts?.interval ?? 35
+ for (let i = 0; i <= text.length; i++) {
+ check()
+ setState((s) => ({ ...s, [key]: text.slice(0, i) }))
+ // Uneven but repeatable rhythm, so every loop types the same way
+ await wait(interval * (0.7 + 0.6 * hash(i)))
+ }
+ },
+ place(to) {
+ if (signal.aborted) return
+ const p = resolve(to)
+ place(p)
+ autoRef.current?.(p)
+ },
+ async enter(to, opts) {
+ check()
+ const end = resolve(to)
+ // The frame: the stage, in stage px
+ const root = rootRef.current!
+ const f = { x: 0, y: 0, w: root.offsetWidth, h: root.offsetHeight }
+ // Out past the edge nearest the target, far enough that the hand is hidden
+ const out = 40
+ const gaps = [end.x - f.x, f.x + f.w - end.x, end.y - f.y, f.y + f.h - end.y]
+ const side = gaps.indexOf(Math.min(...gaps))
+ const from = side === 0 ? { x: f.x - out, y: end.y + f.h * 0.12 } : side === 1 ? { x: f.x + f.w + out * 0.4, y: end.y + f.h * 0.12 } : side === 2 ? { x: end.x - f.w * 0.06, y: f.y - out } : { x: end.x + f.w * 0.06, y: f.y + f.h + out * 0.4 }
+ place(from)
+ hover(null)
+ await tween(opts?.duration ?? 700, (t) => {
+ const e = entering(t)
+ const p = { x: from.x + (end.x - from.x) * e, y: from.y + (end.y - from.y) * e }
+ place(p)
+ auto(p)
+ })
+ check()
+ hover(targetEl(to))
+ },
+ async drag(to, onMove, opts) {
+ check()
+ const from = { ...pos.current }
+ const end = resolve(to)
+ const dist = Math.hypot(end.x - from.x, end.y - from.y)
+ hover(null)
+ // The hand closes only while the button is held, as a real cursor's
+ // does: on press here, and back to what it was on release. Over text
+ // (I-beam) or a canvas (crosshair) a drag selects, so it keeps its kind.
+ const before = kindRef.current
+ const selects = before === 'ibeam' || before === 'crosshair'
+ const closesHand = !selects && opts?.cursor !== 'preserve'
+ // A finger swipes in one motion: it lands and is already moving, fast
+ // first and slowing as it goes. A mouse presses, then carries.
+ const finger = cursor?.style === 'touch'
+ // Gathers speed over the first quarter, then one speed to the lift,
+ // still moving when it leaves so the throw carries straight on
+ const fingerEase = (t: number) => { const a = 0.25, k = 1 / (1 - a / 2); return t < a ? (t * t) / (2 * a) * k : (t - a / 2) * k }
+ if (finger) { cursorRef.current?.setAttribute('data-touching', ''); await wait(140); check() }
+ // The trail: one streak from where the finger was a few frames ago to
+ // where it is now, turned along the motion and fading toward its tail
+ const streak = finger ? cursorRef.current?.querySelector('.ss-cursor__streak') ?? null : null
+ const trail: Point[] = []
+ const drawTrail = (at: Point) => {
+ trail.unshift({ ...at })
+ if (trail.length > 9) trail.pop()
+ const h = trail[trail.length - 1]!
+ const dx = at.x - h.x, dy = at.y - h.y
+ const len = Math.min(Math.hypot(dx, dy), 100)
+ if (!streak) return
+ streak.style.width = `${len}px`
+ streak.style.left = `${-len}px`
+ streak.style.transform = `rotate(${Math.atan2(dy, dx)}rad)`
+ }
+ dragging.current = true
+ const cursorEl = cursorRef.current
+ const release = own(() => {
+ dragging.current = false
+ cursorEl?.removeAttribute('data-pressed')
+ cursorEl?.removeAttribute('data-swiping')
+ if (finger) fingerGone(cursorEl)
+ })
+ try {
+ // The press dips first and the hand closes at the bottom of it, so
+ // the change of shape sits inside the press
+ cursorEl?.setAttribute('data-pressed', 'true')
+ // A finger presses first, seen as a press, and then swipes
+ await wait(finger ? 170 : 60)
+ if (finger) { if (streak) streak.style.width = '0px'; cursorEl?.setAttribute('data-swiping', '') }
+ check()
+ if (closesHand && !finger) setKind('grab')
+ if (!finger) await wait(70)
+ check()
+ cursorEl?.removeAttribute('data-pressed')
+ // Carried by a hand: the same shallow arc and ease as a move, a little
+ // slower for the weight, never a ruler-straight slide
+ // A selection is precise: straight, so the caret never leaves the text
+ const bow = selects ? 0 : Math.min(dist * 0.06, 18)
+ const nx = dist ? -(end.y - from.y) / dist : 0
+ const ny = dist ? (end.x - from.x) / dist : 0
+ // A selection across lines drops into its last line first, then
+ // sweeps along it, as a hand does. A straight diagonal runs back past
+ // the anchor on the first line (selecting nothing, or backwards) and
+ // then fills everything at once as it crosses into the next.
+ const lines = selects && Math.abs(end.y - from.y) > 6
+ // Ending left of where it started, no path reads right: the beam runs
+ // back along the first line or past the end on the last
+ if (lines && end.y > from.y && end.x < from.x - 2) warn('a text selection that ends left of where it starts overshoots and shrinks back; select text that starts a line, or fits on one')
+ const caretsBefore = carets
+ // A flick is brief, about a quarter second; the throw does the rest
+ await tween(finger ? (opts?.duration ?? clamp(180 + dist * 0.5, 240, 300)) : (opts?.duration ?? clamp(360 + dist * 1.1, 450, 900)), (t) => {
+ // A finger gathers speed for a few frames and then runs at one
+ // speed until it lifts, still moving: what it threw carries on at
+ // that speed and eases off. A finger that slows before it lifts
+ // makes the throw jolt; one that eases out early reads as a pause
+ const e = finger ? fingerEase(t) : easeInOutCubic(t)
+ const lift = Math.sin(Math.PI * e) * bow
+ // The drop: y arrives early, x mostly late (a curve through (from.x, end.y))
+ const ey = lines ? 1 - (1 - e) * (1 - e) : e
+ const ex = lines ? e * e : e
+ const at = opts?.path?.(e, from, end) ?? { x: from.x + (end.x - from.x) * ex + nx * lift, y: from.y + (end.y - from.y) * ey + ny * lift }
+ // The cursor and whatever follows it (a card, a selection) paint in
+ // the same frame: render the scene now, not a frame behind
+ flushSync(() => onMove?.(at, from, t))
+ place(at)
+ if (finger) drawTrail(at)
+ })
+ // A selection grown by time or a word count drifts off the I-beam,
+ // ahead of it on one line and behind on the next
+ if (selects && onMove && carets === caretsBefore) warn('selection drift: a text selection drag never read a.caret(), so the highlight is not under the I-beam; set it from a.caret(name, at) in onMove')
+ check()
+ if (closesHand && !finger) setKind(before === 'grab' ? 'pointer' : before)
+ } finally {
+ release()
+ }
+ // What's under the cursor now decides (the thing it was over may have moved or gone)
+ autoRef.current?.(end)
+ },
+ layout,
+ }
+ // A beat started without await (a move alongside other beats) rejects
+ // with Stopped when the stage stops; that is expected, so it must not
+ // surface as an unhandled rejection. Any other error is reported here,
+ // since nobody may be awaiting it. Awaited calls still see the rejection.
+ for (const k of Object.keys(a) as (keyof Act)[]) {
+ const fn = a[k]
+ if (typeof fn !== 'function') continue
+ ;(a as unknown as Record)[k] = (...args: unknown[]) => {
+ const out = (fn as (...x: unknown[]) => unknown).apply(a, args)
+ if (out instanceof Promise)
+ out.catch((e) => {
+ if (!(e instanceof Stopped)) console.error('[stageshow] script error', e)
+ })
+ return out
+ }
+ }
+ return a
+}
+
+// A loop that doesn't end where it began jumps on every repeat.
+export function checkSeam(end: object, initial: object, at: Point, rest: Point | null) {
+ const changed = Object.keys(initial).filter(
+ (k) => JSON.stringify((end as Record)[k]) !== JSON.stringify((initial as Record)[k]),
+ )
+ if (changed.length) warn(`loop seam: state still differs from initial at the end of the script (${changed.join(', ')})`)
+ if (rest && Math.hypot(at.x - rest.x, at.y - rest.y) > 1) warn('loop seam: cursor does not end where it starts')
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/1password-dark.svg b/skills/imported/benjitaylor/stageshow/kit/brands/1password-dark.svg
new file mode 100644
index 0000000..b88d47c
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/brands/1password-dark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/1password.svg b/skills/imported/benjitaylor/stageshow/kit/brands/1password.svg
new file mode 100644
index 0000000..49b1596
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/brands/1password.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/LICENSE-CRYPTO-FLAT.txt b/skills/imported/benjitaylor/stageshow/kit/brands/LICENSE-CRYPTO-FLAT.txt
new file mode 100644
index 0000000..0e259d4
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/brands/LICENSE-CRYPTO-FLAT.txt
@@ -0,0 +1,121 @@
+Creative Commons Legal Code
+
+CC0 1.0 Universal
+
+ CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
+ LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
+ ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
+ INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
+ REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
+ PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
+ THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
+ HEREUNDER.
+
+Statement of Purpose
+
+The laws of most jurisdictions throughout the world automatically confer
+exclusive Copyright and Related Rights (defined below) upon the creator
+and subsequent owner(s) (each and all, an "owner") of an original work of
+authorship and/or a database (each, a "Work").
+
+Certain owners wish to permanently relinquish those rights to a Work for
+the purpose of contributing to a commons of creative, cultural and
+scientific works ("Commons") that the public can reliably and without fear
+of later claims of infringement build upon, modify, incorporate in other
+works, reuse and redistribute as freely as possible in any form whatsoever
+and for any purposes, including without limitation commercial purposes.
+These owners may contribute to the Commons to promote the ideal of a free
+culture and the further production of creative, cultural and scientific
+works, or to gain reputation or greater distribution for their Work in
+part through the use and efforts of others.
+
+For these and/or other purposes and motivations, and without any
+expectation of additional consideration or compensation, the person
+associating CC0 with a Work (the "Affirmer"), to the extent that he or she
+is an owner of Copyright and Related Rights in the Work, voluntarily
+elects to apply CC0 to the Work and publicly distribute the Work under its
+terms, with knowledge of his or her Copyright and Related Rights in the
+Work and the meaning and intended legal effect of CC0 on those rights.
+
+1. Copyright and Related Rights. A Work made available under CC0 may be
+protected by copyright and related or neighboring rights ("Copyright and
+Related Rights"). Copyright and Related Rights include, but are not
+limited to, the following:
+
+ i. the right to reproduce, adapt, distribute, perform, display,
+ communicate, and translate a Work;
+ ii. moral rights retained by the original author(s) and/or performer(s);
+iii. publicity and privacy rights pertaining to a person's image or
+ likeness depicted in a Work;
+ iv. rights protecting against unfair competition in regards to a Work,
+ subject to the limitations in paragraph 4(a), below;
+ v. rights protecting the extraction, dissemination, use and reuse of data
+ in a Work;
+ vi. database rights (such as those arising under Directive 96/9/EC of the
+ European Parliament and of the Council of 11 March 1996 on the legal
+ protection of databases, and under any national implementation
+ thereof, including any amended or successor version of such
+ directive); and
+vii. other similar, equivalent or corresponding rights throughout the
+ world based on applicable law or treaty, and any national
+ implementations thereof.
+
+2. Waiver. To the greatest extent permitted by, but not in contravention
+of, applicable law, Affirmer hereby overtly, fully, permanently,
+irrevocably and unconditionally waives, abandons, and surrenders all of
+Affirmer's Copyright and Related Rights and associated claims and causes
+of action, whether now known or unknown (including existing as well as
+future claims and causes of action), in the Work (i) in all territories
+worldwide, (ii) for the maximum duration provided by applicable law or
+treaty (including future time extensions), (iii) in any current or future
+medium and for any number of copies, and (iv) for any purpose whatsoever,
+including without limitation commercial, advertising or promotional
+purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
+member of the public at large and to the detriment of Affirmer's heirs and
+successors, fully intending that such Waiver shall not be subject to
+revocation, rescission, cancellation, termination, or any other legal or
+equitable action to disrupt the quiet enjoyment of the Work by the public
+as contemplated by Affirmer's express Statement of Purpose.
+
+3. Public License Fallback. Should any part of the Waiver for any reason
+be judged legally invalid or ineffective under applicable law, then the
+Waiver shall be preserved to the maximum extent permitted taking into
+account Affirmer's express Statement of Purpose. In addition, to the
+extent the Waiver is so judged Affirmer hereby grants to each affected
+person a royalty-free, non transferable, non sublicensable, non exclusive,
+irrevocable and unconditional license to exercise Affirmer's Copyright and
+Related Rights in the Work (i) in all territories worldwide, (ii) for the
+maximum duration provided by applicable law or treaty (including future
+time extensions), (iii) in any current or future medium and for any number
+of copies, and (iv) for any purpose whatsoever, including without
+limitation commercial, advertising or promotional purposes (the
+"License"). The License shall be deemed effective as of the date CC0 was
+applied by Affirmer to the Work. Should any part of the License for any
+reason be judged legally invalid or ineffective under applicable law, such
+partial invalidity or ineffectiveness shall not invalidate the remainder
+of the License, and in such case Affirmer hereby affirms that he or she
+will not (i) exercise any of his or her remaining Copyright and Related
+Rights in the Work or (ii) assert any associated claims and causes of
+action with respect to the Work, in either case contrary to Affirmer's
+express Statement of Purpose.
+
+4. Limitations and Disclaimers.
+
+ a. No trademark or patent rights held by Affirmer are waived, abandoned,
+ surrendered, licensed or otherwise affected by this document.
+ b. Affirmer offers the Work as-is and makes no representations or
+ warranties of any kind concerning the Work, express, implied,
+ statutory or otherwise, including without limitation warranties of
+ title, merchantability, fitness for a particular purpose, non
+ infringement, or the absence of latent or other defects, accuracy, or
+ the present or absence of errors, whether or not discoverable, all to
+ the greatest extent permissible under applicable law.
+ c. Affirmer disclaims responsibility for clearing rights of other persons
+ that may apply to the Work or any use thereof, including without
+ limitation any person's Copyright and Related Rights in the Work.
+ Further, Affirmer disclaims responsibility for obtaining any necessary
+ consents, permissions or other rights required for any use of the
+ Work.
+ d. Affirmer understands and acknowledges that Creative Commons is not a
+ party to this document and has no duty or obligation with respect to
+ this CC0 or use of the Work.
diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/LICENSE-PAYMENT-ICONS.txt b/skills/imported/benjitaylor/stageshow/kit/brands/LICENSE-PAYMENT-ICONS.txt
new file mode 100644
index 0000000..261eeb9
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/brands/LICENSE-PAYMENT-ICONS.txt
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/LICENSE-SIMPLE-ICONS.txt b/skills/imported/benjitaylor/stageshow/kit/brands/LICENSE-SIMPLE-ICONS.txt
new file mode 100644
index 0000000..70d4a7b
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/brands/LICENSE-SIMPLE-ICONS.txt
@@ -0,0 +1,30 @@
+# CC0 1.0 Universal
+
+## Statement of Purpose
+
+The laws of most jurisdictions throughout the world automatically confer exclusive Copyright and Related Rights (defined below) upon the creator and subsequent owner(s) (each and all, an “owner”) of an original work of authorship and/or a database (each, a “Work”).
+
+Certain owners wish to permanently relinquish those rights to a Work for the purpose of contributing to a commons of creative, cultural and scientific works (“Commons”) that the public can reliably and without fear of later claims of infringement build upon, modify, incorporate in other works, reuse and redistribute as freely as possible in any form whatsoever and for any purposes, including without limitation commercial purposes. These owners may contribute to the Commons to promote the ideal of a free culture and the further production of creative, cultural and scientific works, or to gain reputation or greater distribution for their Work in part through the use and efforts of others.
+
+For these and/or other purposes and motivations, and without any expectation of additional consideration or compensation, the person associating CC0 with a Work (the “Affirmer”), to the extent that he or she is an owner of Copyright and Related Rights in the Work, voluntarily elects to apply CC0 to the Work and publicly distribute the Work under its terms, with knowledge of his or her Copyright and Related Rights in the Work and the meaning and intended legal effect of CC0 on those rights.
+
+1. Copyright and Related Rights. A Work made available under CC0 may be protected by copyright and related or neighboring rights (“Copyright and Related Rights”). Copyright and Related Rights include, but are not limited to, the following:
+ 1. the right to reproduce, adapt, distribute, perform, display, communicate, and translate a Work;
+ 2. moral rights retained by the original author(s) and/or performer(s);
+ 3. publicity and privacy rights pertaining to a person’s image or likeness depicted in a Work;
+ 4. rights protecting against unfair competition in regards to a Work, subject to the limitations in paragraph 4(i), below;
+ 5. rights protecting the extraction, dissemination, use and reuse of data in a Work;
+ 6. database rights (such as those arising under Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, and under any national implementation thereof, including any amended or successor version of such directive); and
+ 7. other similar, equivalent or corresponding rights throughout the world based on applicable law or treaty, and any national implementations thereof.
+
+2. Waiver. To the greatest extent permitted by, but not in contravention of, applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and unconditionally waives, abandons, and surrenders all of Affirmer’s Copyright and Related Rights and associated claims and causes of action, whether now known or unknown (including existing as well as future claims and causes of action), in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the “Waiver”). Affirmer makes the Waiver for the benefit of each member of the public at large and to the detriment of Affirmer’s heirs and successors, fully intending that such Waiver shall not be subject to revocation, rescission, cancellation, termination, or any other legal or equitable action to disrupt the quiet enjoyment of the Work by the public as contemplated by Affirmer’s express Statement of Purpose.
+
+3. Public License Fallback. Should any part of the Waiver for any reason be judged legally invalid or ineffective under applicable law, then the Waiver shall be preserved to the maximum extent permitted taking into account Affirmer’s express Statement of Purpose. In addition, to the extent the Waiver is so judged Affirmer hereby grants to each affected person a royalty-free, non transferable, non sublicensable, non exclusive, irrevocable and unconditional license to exercise Affirmer’s Copyright and Related Rights in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the “License”). The License shall be deemed effective as of the date CC0 was applied by Affirmer to the Work. Should any part of the License for any reason be judged legally invalid or ineffective under applicable law, such partial invalidity or ineffectiveness shall not invalidate the remainder of the License, and in such case Affirmer hereby affirms that he or she will not (i) exercise any of his or her remaining Copyright and Related Rights in the Work or (ii) assert any associated claims and causes of action with respect to the Work, in either case contrary to Affirmer’s express Statement of Purpose.
+
+4. Limitations and Disclaimers.
+ 1. No trademark or patent rights held by Affirmer are waived, abandoned, surrendered, licensed or otherwise affected by this document.
+ 2. Affirmer offers the Work as-is and makes no representations or warranties of any kind concerning the Work, express, implied, statutory or otherwise, including without limitation warranties of title, merchantability, fitness for a particular purpose, non infringement, or the absence of latent or other defects, accuracy, or the present or absence of errors, whether or not discoverable, all to the greatest extent permissible under applicable law.
+ 3. Affirmer disclaims responsibility for clearing rights of other persons that may apply to the Work or any use thereof, including without limitation any person’s Copyright and Related Rights in the Work. Further, Affirmer disclaims responsibility for obtaining any necessary consents, permissions or other rights required for any use of the Work.
+ 4. Affirmer understands and acknowledges that Creative Commons is not a party to this document and has no duty or obligation with respect to this CC0 or use of the Work.
+
+For more information, please see .
diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/LICENSE-SVGL.txt b/skills/imported/benjitaylor/stageshow/kit/brands/LICENSE-SVGL.txt
new file mode 100644
index 0000000..5a24d8e
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/brands/LICENSE-SVGL.txt
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2022 Pablo Hdez
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/SOURCES.md b/skills/imported/benjitaylor/stageshow/kit/brands/SOURCES.md
new file mode 100644
index 0000000..6f223db
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/brands/SOURCES.md
@@ -0,0 +1,57 @@
+# Brand artwork sources
+
+These SVGs are local copies of the original artwork supplied by the following sources. Their colors and proportions are unchanged. Source revisions and hashes make each asset traceable.
+
+- [SVGL](https://github.com/pheralb/svgl), MIT. See `LICENSE-SVGL.txt`.
+- [Linear brand assets](https://linear.app/brand): official light and dark logomarks, downloaded 25 October 2026. Subject to Linear’s published brand usage terms.
+- [American Express](https://www.americanexpress.com/en-us/): the official blue-box asset from its versioned static asset package, downloaded 25 October 2026. Subject to American Express’s brand usage terms.
+- [Circle brand kit](https://www.circle.com/pressroom): official USDC token logo, downloaded 25 October 2026. Subject to Circle’s brand usage policy.
+- [Cryptocurrency Icons](https://github.com/spothq/cryptocurrency-icons), CC0 1.0. Flat Bitcoin and Ethereum artwork; see `LICENSE-CRYPTO-FLAT.txt`.
+- [Payment icons](https://github.com/aaronfagan/svg-credit-card-payment-icons), Apache 2.0. See `LICENSE-PAYMENT-ICONS.txt`.
+- [Simple Icons](https://github.com/simple-icons/simple-icons), CC0 1.0, for the bank marks (Chase, Wells Fargo, Bank of America, Barclays, HSBC, Monzo, Revolut), downloaded 27 October 2026, and Google Docs, downloaded 28 October 2026, all at commit `90646e4bf7f3`; each monochrome glyph is given the brand's own hex from the Simple Icons data as its fill. See `LICENSE-SIMPLE-ICONS.txt`.
+
+The asset repositories’ software licenses do not confer ownership of the brands. These marks identify the respective products; follow their brand guidelines for the intended use.
+
+| Asset | Source | SHA-256 |
+|---|---|---|
+| `visa.svg` | [Pinned source](https://raw.githubusercontent.com/aaronfagan/svg-credit-card-payment-icons/6dd023ae32415ed7b01bf809f15a25613a52098c/logo/visa.svg) | `8d837bb887be5ac1a959c8bfc8874dc35d1571d5953efa560b1f317ed6f56bb4` |
+| `mastercard.svg` | [Pinned source](https://raw.githubusercontent.com/aaronfagan/svg-credit-card-payment-icons/6dd023ae32415ed7b01bf809f15a25613a52098c/logo/mastercard.svg) | `1c8f56fb410ec06e8467423cec0fb9fe54d6b18eba703591548d3159e744496b` |
+| `discover.svg` | [Pinned source](https://raw.githubusercontent.com/aaronfagan/svg-credit-card-payment-icons/6dd023ae32415ed7b01bf809f15a25613a52098c/logo/discover.svg) | `8ef8d5002bc7364c2468f24156a6cb5bd9321a4461f4fda5de5aa21cfc9a13ca` |
+| `paypal.svg` | [Pinned source](https://raw.githubusercontent.com/pheralb/svgl/f9b726cee1d1f3c697a18c880185e89d9a55bb70/static/library/paypal.svg) | `be46ba3ede93ac6d513f00947d6aba825e450497b1a47a8bd408e90d6ee829c7` |
+| `stripe.svg` | [Pinned source](https://raw.githubusercontent.com/pheralb/svgl/f9b726cee1d1f3c697a18c880185e89d9a55bb70/static/library/stripe.svg) | `d1dd829f330c3c17a02c7356e2e41f77f6ecad3f72a9b2bf11b649703c66e30a` |
+| `notion.svg` | [Pinned source](https://raw.githubusercontent.com/pheralb/svgl/f9b726cee1d1f3c697a18c880185e89d9a55bb70/static/library/notion.svg) | `0d94471649fb9badd434394846079c279876fdd224cf49ed46b6c09fbd3a041f` |
+| `gmail.svg` | [Pinned source](https://raw.githubusercontent.com/pheralb/svgl/f9b726cee1d1f3c697a18c880185e89d9a55bb70/static/library/gmail.svg) | `6e0a1db3ce3893a809c3293bf0780902cad270ed443bb118cfcbc40647e272ab` |
+| `google-calendar.svg` | [Pinned source](https://raw.githubusercontent.com/pheralb/svgl/f9b726cee1d1f3c697a18c880185e89d9a55bb70/static/library/google-calendar.svg) | `643e0b6b34b278cf062bedcf798f894b7e8fa7b665793736664c5cf57f9f7873` |
+| `1password.svg` | [Pinned source](https://raw.githubusercontent.com/pheralb/svgl/f9b726cee1d1f3c697a18c880185e89d9a55bb70/static/library/1password-light.svg) | `36ef31dc8e1ea5a2a7f4a1a53dc874c61170056bdacacd24ea4fa4c8cd050af8` |
+| `1password-dark.svg` | [Pinned source](https://raw.githubusercontent.com/pheralb/svgl/f9b726cee1d1f3c697a18c880185e89d9a55bb70/static/library/1password-dark.svg) | `ad593cc8ca4fb8e3ae066fdd4dd3d86cb9084cad8b9092275e3ecd27fb35cc65` |
+| `figma.svg` | [Pinned source](https://raw.githubusercontent.com/pheralb/svgl/f9b726cee1d1f3c697a18c880185e89d9a55bb70/static/library/figma.svg) | `5bcdea3417293412845b8298fe357fce3b192e8ff112d9b3fef315bc5cd127f6` |
+| `slack.svg` | [Pinned source](https://raw.githubusercontent.com/pheralb/svgl/f9b726cee1d1f3c697a18c880185e89d9a55bb70/static/library/slack.svg) | `29734796b3a85f9d0e03150d53142fab0b7f994ae80c7e3e1efd0bef52c12f5d` |
+| `github.svg` | [Pinned source](https://raw.githubusercontent.com/pheralb/svgl/f9b726cee1d1f3c697a18c880185e89d9a55bb70/static/library/github_light.svg) | `64568845c83d5e840e6a1fb7af1939fed14709636427ce9be2fbcdc14ac5e021` |
+| `github-dark.svg` | [Pinned source](https://raw.githubusercontent.com/pheralb/svgl/f9b726cee1d1f3c697a18c880185e89d9a55bb70/static/library/github_dark.svg) | `83adf1cacb564250654ccf60caadd8aaf852d9de47936befb7cb61b674ace7e8` |
+| `dropbox-glyph.svg` | The glyph from `dropbox.svg` below in Dropbox blue, with the square field removed | derived |
+| `dropbox.svg` | [Pinned source](https://raw.githubusercontent.com/pheralb/svgl/f9b726cee1d1f3c697a18c880185e89d9a55bb70/static/library/dropbox.svg) | `ef6eeed72f74fcd89674afd62dcf451eaf48c360da516679b08c1e99a8ea0819` |
+| `google-drive.svg` | [Pinned source](https://raw.githubusercontent.com/pheralb/svgl/f9b726cee1d1f3c697a18c880185e89d9a55bb70/static/library/drive.svg) | `2a0272583e3ea138ba5b809d9fa7d099b7b31e18221d5550c4e6f0d8866d4f84` |
+| `google-sheets.svg` | [Pinned source](https://raw.githubusercontent.com/pheralb/svgl/f9b726cee1d1f3c697a18c880185e89d9a55bb70/static/library/google-sheets.svg) | `1e610a408605d9cfc1e90683ef25f360e7eaa6eb89c4c54dcab40a19b5c72562` |
+| `google-slides.svg` | [Pinned source](https://raw.githubusercontent.com/pheralb/svgl/f9b726cee1d1f3c697a18c880185e89d9a55bb70/static/library/google-slides.svg) | `328414886ebfaeea95e460cdda3fd015c143c910399799654b0f73e1e9b1c558` |
+| `google-meet.svg` | [Pinned source](https://raw.githubusercontent.com/pheralb/svgl/f9b726cee1d1f3c697a18c880185e89d9a55bb70/static/library/google-meet.svg) | `78740599528aa18810c9ab7e854fd1607679bccf3ee7a00a622f9dce2b5d8473` |
+| `zoom.svg` | [Pinned source](https://raw.githubusercontent.com/pheralb/svgl/f9b726cee1d1f3c697a18c880185e89d9a55bb70/static/library/zoom.svg) | `5d1bf4d38bc5fc437578292dafc3cb4a678682e3124f1e2910d817aee9658615` |
+| `vercel.svg` | [Pinned source](https://raw.githubusercontent.com/pheralb/svgl/f9b726cee1d1f3c697a18c880185e89d9a55bb70/static/library/vercel.svg) | `565f40e56abbd9c9f31e113c45e90ce456cc112d4811f2f49d1d5041fe501b7c` |
+| `vercel-dark.svg` | [Pinned source](https://raw.githubusercontent.com/pheralb/svgl/f9b726cee1d1f3c697a18c880185e89d9a55bb70/static/library/vercel_dark.svg) | `a9ae5ad4ea10c39122cc6b07927132f9c684dd2f1a7bef5337b0eb7364e139ae` |
+| `shopify.svg` | [Pinned source](https://raw.githubusercontent.com/pheralb/svgl/f9b726cee1d1f3c697a18c880185e89d9a55bb70/static/library/shopify.svg) | `86e19afda708cead229b714d25de147ef0b920cfea807c5b2933b30d17a234db` |
+| `linear.svg` | [Official source](https://static.linear.app/design-assets/Linear-Brand-Assets.zip?v=3) | `e683e95a5eb9b360ae06360c0350fd1b5fcf3d2222ef9de8e71fa0bc413dccc3` |
+| `linear-dark.svg` | [Official source](https://static.linear.app/design-assets/Linear-Brand-Assets.zip?v=3) | `32f9883452188b39787f8242c7bfce723ded255f35f13254e727871d3dae2fd1` |
+| `amex.svg` | [Official source](https://www.aexp-static.com/cdaas/one/statics/axp-static-assets/1.8.0/package/dist/img/logos/dls-logo-bluebox-solid.svg) | `028f643755987211bf2f3add6c62ae1870a888cf2f4fe3040a4fac7dce2543ab` |
+| `solana.svg` | [Source](https://raw.githubusercontent.com/pheralb/svgl/f9b726cee1d1f3c697a18c880185e89d9a55bb70/static/library/sol.svg) | `d5ae3179116a3e643c131489e305cd71a6eb2f0d58e20f3c4189f741eed9ac91` |
+| `usdc.svg` | [Source](https://6778953.fs1.hubspotusercontent-na1.net/hubfs/6778953/Pressroom/brandkit/logo-downloads/usdc.zip) | `fe4f9d5f34ef4ebeb5d80e1f5ff63dcaf5a0d5495f4adf206e4c3011d1f5c57d` |
+| `tether.svg` | [Source](https://raw.githubusercontent.com/pheralb/svgl/f9b726cee1d1f3c697a18c880185e89d9a55bb70/static/library/tether.svg) | `14c4ce4de02e430893b51b6fd6c814d1c32a2001d5a0f8962304652f30773a17` |
+| `amex-tile.svg` | [Official source](https://www.aexp-static.com/cdaas/one/statics/axp-static-assets/1.8.0/package/dist/img/logos/dls-logo-stack-white.svg) | `56b8e90244c34621e294d3357edfef9a1467e501773ed21b25dc6367ab3d7803` |
+| `bitcoin.svg` | [Pinned source](https://raw.githubusercontent.com/spothq/cryptocurrency-icons/1a63530be6e374711a8554f31b17e4cb92c25fa5/svg/color/btc.svg) | `5a8131ecdf855b12cb56080aeeeefea266976529c45b2d58c284a13b7519f4ca` |
+| `ethereum.svg` | [Pinned source](https://raw.githubusercontent.com/spothq/cryptocurrency-icons/1a63530be6e374711a8554f31b17e4cb92c25fa5/svg/color/eth.svg) | `1f94df8533f61806f7b17eaf9cd28678cdba66e1d82a9ca8f9fb38d35a907e9c` |
+| `google-docs.svg` | [Pinned source](https://raw.githubusercontent.com/simple-icons/simple-icons/90646e4bf7f3b89df6c37e65baf3e7bc759a517c/icons/googledocs.svg), brand hex #4285F4 applied as the fill | `880b39680b64812e5203f366c08779e86c824e3a6264a8eefca2a56afbf79fa6` |
+| `chase.svg` | [Pinned source](https://raw.githubusercontent.com/simple-icons/simple-icons/90646e4bf7f3b89df6c37e65baf3e7bc759a517c/icons/chase.svg), brand hex #117ACA applied as the fill | `1323e4a9d6bd891ca4d519fae8c74385240572c9968e750b2a753fe481a2a4a9` |
+| `wellsfargo.svg` | [Pinned source](https://raw.githubusercontent.com/simple-icons/simple-icons/90646e4bf7f3b89df6c37e65baf3e7bc759a517c/icons/wellsfargo.svg), brand hex #D71E28 applied as the fill | `20624a3e07aafd1e5b0805d36a74f077a3e3eb2c296fcc3ec5ae1b66e7adf94b` |
+| `bankofamerica.svg` and `bankofamerica-dark.svg` | [Pinned source](https://raw.githubusercontent.com/simple-icons/simple-icons/90646e4bf7f3b89df6c37e65baf3e7bc759a517c/icons/bankofamerica.svg), brand hex #012169 applied as the fill (white on dark) | `4af97000b06a925cf8ce6359855de2fbc502c59edcb10d26302e231d86fb8a02` |
+| `barclays.svg` | [Pinned source](https://raw.githubusercontent.com/simple-icons/simple-icons/90646e4bf7f3b89df6c37e65baf3e7bc759a517c/icons/barclays.svg), brand hex #00AEEF applied as the fill | `25330b9967dcfbe89e77cee37bfd2c76ce7073173a303a045df32ab2012f1b42` |
+| `hsbc.svg` | [Pinned source](https://raw.githubusercontent.com/simple-icons/simple-icons/90646e4bf7f3b89df6c37e65baf3e7bc759a517c/icons/hsbc.svg), brand hex #DB0011 applied as the fill | `d0c04fb9e0cba69e3ad9ac896241200aa8beb8a16f3271715860c27c558006bf` |
+| `monzo.svg` and `monzo-dark.svg` | [Pinned source](https://raw.githubusercontent.com/simple-icons/simple-icons/90646e4bf7f3b89df6c37e65baf3e7bc759a517c/icons/monzo.svg), brand hex #14233C applied as the fill (white on dark) | `91a20ce29789ac2e4bd6debd251c9f965eea0945a78f3e94e704e4cb0dd7cce0` |
+| `revolut.svg` and `revolut-dark.svg` | [Pinned source](https://raw.githubusercontent.com/simple-icons/simple-icons/90646e4bf7f3b89df6c37e65baf3e7bc759a517c/icons/revolut.svg), brand hex #191C1F applied as the fill (white on dark) | `cfb3427243ad5a0315fa2b20ef3302d290a3cb55529c994485dd78ae7d4cb646` |
diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/bankofamerica-dark.svg b/skills/imported/benjitaylor/stageshow/kit/brands/bankofamerica-dark.svg
new file mode 100644
index 0000000..d4c4416
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/brands/bankofamerica-dark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/bankofamerica.svg b/skills/imported/benjitaylor/stageshow/kit/brands/bankofamerica.svg
new file mode 100644
index 0000000..123c151
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/brands/bankofamerica.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/barclays.svg b/skills/imported/benjitaylor/stageshow/kit/brands/barclays.svg
new file mode 100644
index 0000000..d59cdba
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/brands/barclays.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/bitcoin.svg b/skills/imported/benjitaylor/stageshow/kit/brands/bitcoin.svg
new file mode 100644
index 0000000..5dc8a39
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/brands/bitcoin.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/chase.svg b/skills/imported/benjitaylor/stageshow/kit/brands/chase.svg
new file mode 100644
index 0000000..36159d4
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/brands/chase.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/discover.svg b/skills/imported/benjitaylor/stageshow/kit/brands/discover.svg
new file mode 100644
index 0000000..8b2bdcb
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/brands/discover.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/dropbox-glyph.svg b/skills/imported/benjitaylor/stageshow/kit/brands/dropbox-glyph.svg
new file mode 100644
index 0000000..bb858da
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/brands/dropbox-glyph.svg
@@ -0,0 +1 @@
+
diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/dropbox.svg b/skills/imported/benjitaylor/stageshow/kit/brands/dropbox.svg
new file mode 100644
index 0000000..c4161d9
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/brands/dropbox.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/ethereum.svg b/skills/imported/benjitaylor/stageshow/kit/brands/ethereum.svg
new file mode 100644
index 0000000..c761cef
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/brands/ethereum.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/figma.svg b/skills/imported/benjitaylor/stageshow/kit/brands/figma.svg
new file mode 100644
index 0000000..78a05a9
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/brands/figma.svg
@@ -0,0 +1,14 @@
+
diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/github-dark.svg b/skills/imported/benjitaylor/stageshow/kit/brands/github-dark.svg
new file mode 100644
index 0000000..c454d05
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/brands/github-dark.svg
@@ -0,0 +1,3 @@
+
diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/github.svg b/skills/imported/benjitaylor/stageshow/kit/brands/github.svg
new file mode 100644
index 0000000..a808c6c
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/brands/github.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/gmail.svg b/skills/imported/benjitaylor/stageshow/kit/brands/gmail.svg
new file mode 100644
index 0000000..fc19b56
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/brands/gmail.svg
@@ -0,0 +1,11 @@
+
diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/google-calendar.svg b/skills/imported/benjitaylor/stageshow/kit/brands/google-calendar.svg
new file mode 100644
index 0000000..1170be3
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/brands/google-calendar.svg
@@ -0,0 +1,17 @@
+
diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/google-docs.svg b/skills/imported/benjitaylor/stageshow/kit/brands/google-docs.svg
new file mode 100644
index 0000000..1207781
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/brands/google-docs.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/google-drive.svg b/skills/imported/benjitaylor/stageshow/kit/brands/google-drive.svg
new file mode 100644
index 0000000..a30564e
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/brands/google-drive.svg
@@ -0,0 +1,8 @@
+
\ No newline at end of file
diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/google-meet.svg b/skills/imported/benjitaylor/stageshow/kit/brands/google-meet.svg
new file mode 100644
index 0000000..2b9eff5
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/brands/google-meet.svg
@@ -0,0 +1,16 @@
+
diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/google-sheets.svg b/skills/imported/benjitaylor/stageshow/kit/brands/google-sheets.svg
new file mode 100644
index 0000000..e2a924c
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/brands/google-sheets.svg
@@ -0,0 +1,55 @@
+
diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/google-slides.svg b/skills/imported/benjitaylor/stageshow/kit/brands/google-slides.svg
new file mode 100644
index 0000000..0208da1
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/brands/google-slides.svg
@@ -0,0 +1,61 @@
+
diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/hsbc.svg b/skills/imported/benjitaylor/stageshow/kit/brands/hsbc.svg
new file mode 100644
index 0000000..b3456dd
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/brands/hsbc.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/index.ts b/skills/imported/benjitaylor/stageshow/kit/brands/index.ts
new file mode 100644
index 0000000..699537a
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/brands/index.ts
@@ -0,0 +1,210 @@
+/** Local product artwork. No network lookup or runtime icon service. */
+export type BrandAsset = {
+ name: string
+ category: 'Payments' | 'Banks' | 'Apps' | 'Crypto'
+ light: string
+ dark: string
+ tileArtwork?: string
+ tileBackground?: string
+ proportion: number
+ scale: number
+}
+
+export const brandMarks = {
+ 'visa': {
+ name: "Visa", category: 'Payments',
+ light: new URL('./visa.svg', import.meta.url).href,
+ dark: new URL('./visa.svg', import.meta.url).href,
+ proportion: 1.56, scale: 0.9,
+ },
+ 'mastercard': {
+ name: "Mastercard", category: 'Payments',
+ light: new URL('./mastercard.svg', import.meta.url).href,
+ dark: new URL('./mastercard.svg', import.meta.url).href,
+ proportion: 1.56, scale: 0.9,
+ },
+ 'discover': {
+ name: "Discover", category: 'Payments',
+ light: new URL('./discover.svg', import.meta.url).href,
+ dark: new URL('./discover.svg', import.meta.url).href,
+ proportion: 1.56, scale: 0.9,
+ },
+ 'paypal': {
+ name: "PayPal", category: 'Payments',
+ light: new URL('./paypal.svg', import.meta.url).href,
+ dark: new URL('./paypal.svg', import.meta.url).href,
+ proportion: 1, scale: 0.64,
+ },
+ 'stripe': {
+ name: "Stripe", category: 'Payments',
+ light: new URL('./stripe.svg', import.meta.url).href,
+ dark: new URL('./stripe.svg', import.meta.url).href,
+ proportion: 1, scale: 0.64,
+ },
+ 'notion': {
+ name: "Notion", category: 'Apps',
+ light: new URL('./notion.svg', import.meta.url).href,
+ dark: new URL('./notion.svg', import.meta.url).href,
+ proportion: 1, scale: 0.68,
+ },
+ 'gmail': {
+ name: "Gmail", category: 'Apps',
+ light: new URL('./gmail.svg', import.meta.url).href,
+ dark: new URL('./gmail.svg', import.meta.url).href,
+ proportion: 1, scale: 0.64,
+ },
+ 'google-calendar': {
+ name: "Google Calendar", category: 'Apps',
+ light: new URL('./google-calendar.svg', import.meta.url).href,
+ dark: new URL('./google-calendar.svg', import.meta.url).href,
+ proportion: 1, scale: 0.64,
+ },
+ 'google-docs': {
+ name: "Google Docs", category: 'Apps',
+ light: new URL('./google-docs.svg', import.meta.url).href,
+ dark: new URL('./google-docs.svg', import.meta.url).href,
+ proportion: 1, scale: 0.64,
+ },
+ '1password': {
+ name: "1Password", category: 'Apps',
+ light: new URL('./1password.svg', import.meta.url).href,
+ dark: new URL('./1password-dark.svg', import.meta.url).href,
+ proportion: 1, scale: 0.64,
+ },
+ 'figma': {
+ name: "Figma", category: 'Apps',
+ light: new URL('./figma.svg', import.meta.url).href,
+ dark: new URL('./figma.svg', import.meta.url).href,
+ proportion: 1, scale: 0.68,
+ },
+ 'slack': {
+ name: "Slack", category: 'Apps',
+ light: new URL('./slack.svg', import.meta.url).href,
+ dark: new URL('./slack.svg', import.meta.url).href,
+ proportion: 1, scale: 0.64,
+ },
+ 'github': {
+ name: "GitHub", category: 'Apps',
+ light: new URL('./github.svg', import.meta.url).href,
+ dark: new URL('./github-dark.svg', import.meta.url).href,
+ proportion: 1, scale: 0.64,
+ },
+ 'dropbox': {
+ name: "Dropbox", category: 'Apps',
+ // The source artwork is the glyph on a square blue field; shown as the
+ // blue glyph alone, so it sits on a tile like the other app marks
+ light: new URL('./dropbox-glyph.svg', import.meta.url).href,
+ dark: new URL('./dropbox-glyph.svg', import.meta.url).href,
+ proportion: 1, scale: 0.72,
+ },
+ 'google-drive': {
+ name: "Google Drive", category: 'Apps',
+ light: new URL('./google-drive.svg', import.meta.url).href,
+ dark: new URL('./google-drive.svg', import.meta.url).href,
+ proportion: 1, scale: 0.64,
+ },
+ 'google-sheets': {
+ name: "Google Sheets", category: 'Apps',
+ light: new URL('./google-sheets.svg', import.meta.url).href,
+ dark: new URL('./google-sheets.svg', import.meta.url).href,
+ proportion: 1, scale: 0.74,
+ },
+ 'google-slides': {
+ name: "Google Slides", category: 'Apps',
+ light: new URL('./google-slides.svg', import.meta.url).href,
+ dark: new URL('./google-slides.svg', import.meta.url).href,
+ proportion: 1, scale: 0.74,
+ },
+ 'google-meet': {
+ name: "Google Meet", category: 'Apps',
+ light: new URL('./google-meet.svg', import.meta.url).href,
+ dark: new URL('./google-meet.svg', import.meta.url).href,
+ proportion: 1, scale: 0.64,
+ },
+ 'zoom': {
+ name: "Zoom", category: 'Apps',
+ light: new URL('./zoom.svg', import.meta.url).href,
+ dark: new URL('./zoom.svg', import.meta.url).href,
+ proportion: 1, scale: 0.64,
+ },
+ 'vercel': {
+ name: "Vercel", category: 'Apps',
+ light: new URL('./vercel.svg', import.meta.url).href,
+ dark: new URL('./vercel-dark.svg', import.meta.url).href,
+ proportion: 1, scale: 0.64,
+ },
+ 'shopify': {
+ name: "Shopify", category: 'Apps',
+ light: new URL('./shopify.svg', import.meta.url).href,
+ dark: new URL('./shopify.svg', import.meta.url).href,
+ proportion: 1, scale: 0.68,
+ },
+ 'bitcoin': {
+ name: 'Bitcoin', category: 'Crypto',
+ light: new URL('./bitcoin.svg', import.meta.url).href,
+ dark: new URL('./bitcoin.svg', import.meta.url).href,
+ proportion: 1, scale: 0.68,
+ },
+ 'ethereum': {
+ name: 'Ethereum', category: 'Crypto',
+ light: new URL('./ethereum.svg', import.meta.url).href,
+ dark: new URL('./ethereum.svg', import.meta.url).href,
+ proportion: 1, scale: 0.68,
+ },
+ 'solana': {
+ name: 'Solana', category: 'Crypto',
+ light: new URL('./solana.svg', import.meta.url).href,
+ dark: new URL('./solana.svg', import.meta.url).href,
+ proportion: 1, scale: 0.68,
+ },
+ 'tether': {
+ name: 'Tether', category: 'Crypto',
+ light: new URL('./tether.svg', import.meta.url).href,
+ dark: new URL('./tether.svg', import.meta.url).href,
+ proportion: 1, scale: 0.68,
+ },
+ 'chase': {
+ name: "Chase", category: 'Banks',
+ light: new URL('./chase.svg', import.meta.url).href,
+ dark: new URL('./chase.svg', import.meta.url).href,
+ proportion: 1, scale: 0.8,
+ },
+ 'wellsfargo': {
+ name: "Wells Fargo", category: 'Banks',
+ light: new URL('./wellsfargo.svg', import.meta.url).href,
+ dark: new URL('./wellsfargo.svg', import.meta.url).href,
+ proportion: 1, scale: 0.8,
+ },
+ 'bankofamerica': {
+ name: "Bank of America", category: 'Banks',
+ light: new URL('./bankofamerica.svg', import.meta.url).href,
+ dark: new URL('./bankofamerica-dark.svg', import.meta.url).href,
+ proportion: 1, scale: 0.8,
+ },
+ 'barclays': {
+ name: "Barclays", category: 'Banks',
+ light: new URL('./barclays.svg', import.meta.url).href,
+ dark: new URL('./barclays.svg', import.meta.url).href,
+ proportion: 1, scale: 0.8,
+ },
+ 'hsbc': {
+ name: "HSBC", category: 'Banks',
+ light: new URL('./hsbc.svg', import.meta.url).href,
+ dark: new URL('./hsbc.svg', import.meta.url).href,
+ proportion: 1, scale: 0.8,
+ },
+ 'monzo': {
+ name: "Monzo", category: 'Banks',
+ light: new URL('./monzo.svg', import.meta.url).href,
+ dark: new URL('./monzo-dark.svg', import.meta.url).href,
+ proportion: 1, scale: 0.8,
+ },
+ 'revolut': {
+ name: "Revolut", category: 'Banks',
+ light: new URL('./revolut.svg', import.meta.url).href,
+ dark: new URL('./revolut-dark.svg', import.meta.url).href,
+ proportion: 1, scale: 0.8,
+ },
+} as const satisfies Record
+
+export type BrandName = keyof typeof brandMarks
diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/mastercard.svg b/skills/imported/benjitaylor/stageshow/kit/brands/mastercard.svg
new file mode 100644
index 0000000..c93bd16
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/brands/mastercard.svg
@@ -0,0 +1,5 @@
+
diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/monzo-dark.svg b/skills/imported/benjitaylor/stageshow/kit/brands/monzo-dark.svg
new file mode 100644
index 0000000..d4be9b6
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/brands/monzo-dark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/monzo.svg b/skills/imported/benjitaylor/stageshow/kit/brands/monzo.svg
new file mode 100644
index 0000000..3967f94
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/brands/monzo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/notion.svg b/skills/imported/benjitaylor/stageshow/kit/brands/notion.svg
new file mode 100644
index 0000000..fd68d14
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/brands/notion.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/paypal.svg b/skills/imported/benjitaylor/stageshow/kit/brands/paypal.svg
new file mode 100644
index 0000000..cf14b63
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/brands/paypal.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/revolut-dark.svg b/skills/imported/benjitaylor/stageshow/kit/brands/revolut-dark.svg
new file mode 100644
index 0000000..273217b
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/brands/revolut-dark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/revolut.svg b/skills/imported/benjitaylor/stageshow/kit/brands/revolut.svg
new file mode 100644
index 0000000..18e1acb
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/brands/revolut.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/shopify.svg b/skills/imported/benjitaylor/stageshow/kit/brands/shopify.svg
new file mode 100644
index 0000000..ea2677e
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/brands/shopify.svg
@@ -0,0 +1,5 @@
+
diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/slack.svg b/skills/imported/benjitaylor/stageshow/kit/brands/slack.svg
new file mode 100644
index 0000000..6d14c72
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/brands/slack.svg
@@ -0,0 +1,24 @@
+
diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/solana.svg b/skills/imported/benjitaylor/stageshow/kit/brands/solana.svg
new file mode 100644
index 0000000..7f2031d
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/brands/solana.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/stripe.svg b/skills/imported/benjitaylor/stageshow/kit/brands/stripe.svg
new file mode 100644
index 0000000..468b5bf
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/brands/stripe.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/tether.svg b/skills/imported/benjitaylor/stageshow/kit/brands/tether.svg
new file mode 100644
index 0000000..3ddfd2a
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/brands/tether.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/vercel-dark.svg b/skills/imported/benjitaylor/stageshow/kit/brands/vercel-dark.svg
new file mode 100644
index 0000000..7596891
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/brands/vercel-dark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/vercel.svg b/skills/imported/benjitaylor/stageshow/kit/brands/vercel.svg
new file mode 100644
index 0000000..611c021
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/brands/vercel.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/visa.svg b/skills/imported/benjitaylor/stageshow/kit/brands/visa.svg
new file mode 100644
index 0000000..7d65ee0
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/brands/visa.svg
@@ -0,0 +1,3 @@
+
diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/wellsfargo.svg b/skills/imported/benjitaylor/stageshow/kit/brands/wellsfargo.svg
new file mode 100644
index 0000000..8048dbc
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/brands/wellsfargo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/skills/imported/benjitaylor/stageshow/kit/brands/zoom.svg b/skills/imported/benjitaylor/stageshow/kit/brands/zoom.svg
new file mode 100644
index 0000000..dd15a4a
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/brands/zoom.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/skills/imported/benjitaylor/stageshow/kit/chart.css b/skills/imported/benjitaylor/stageshow/kit/chart.css
new file mode 100644
index 0000000..86c8048
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/chart.css
@@ -0,0 +1,59 @@
+/* Chart (chart.tsx). */
+
+@layer stageshow {
+ .ss-chart {
+ display: block;
+ overflow: visible;
+ }
+
+ /* Draws in: a wipe over the line and its fill, led by the dot riding the
+ curve at the wipe's edge (Chart drives both on one timeline, through an SVG
+ clip rect in the chart's own units). Hiding fades the whole chart at once. */
+ .ss-chart__draw {
+ opacity: 0;
+ transition: opacity 0.25s ease;
+ }
+
+ .ss-chart[data-show] .ss-chart__draw {
+ opacity: 1;
+ transition: opacity 0.15s ease;
+ }
+
+ .ss-chart__line {
+ fill: none;
+ stroke: var(--ss-accent);
+ stroke-width: 2;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ transition: d 0.8s cubic-bezier(0.45, 0, 0.2, 1);
+ }
+
+ .ss-chart__area {
+ transition: d 0.8s cubic-bezier(0.45, 0, 0.2, 1);
+ }
+
+ .ss-chart__dot {
+ fill: var(--ss-accent);
+ stroke: var(--ss-surface);
+ stroke-width: 2;
+ opacity: 0;
+ transform: scale(0.4);
+ transform-box: fill-box;
+ transform-origin: center;
+ transition: opacity 0.2s ease, transform 0.2s ease, cx 0.8s cubic-bezier(0.45, 0, 0.2, 1), cy 0.8s cubic-bezier(0.45, 0, 0.2, 1);
+ }
+
+ .ss-chart[data-show] .ss-chart__dot {
+ opacity: 1;
+ transform: none;
+ }
+
+ .ss-chart__bar {
+ fill: var(--ss-accent);
+ transition: y 0.7s cubic-bezier(0.25, 0.8, 0.25, 1), height 0.7s cubic-bezier(0.25, 0.8, 0.25, 1);
+ }
+
+ .ss-chart[data-still] * {
+ transition: none !important;
+ }
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/chart.tsx b/skills/imported/benjitaylor/stageshow/kit/chart.tsx
new file mode 100644
index 0000000..a444ba6
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/chart.tsx
@@ -0,0 +1,221 @@
+import { useId, useLayoutEffect, useRef, type CSSProperties } from 'react'
+import { useReducedMotion } from './reduced-motion'
+import './chart.css'
+
+// Charts drawn the way product dashboards draw them: a smooth line with a
+// soft area and an end dot, or rounded bars. They draw in when shown and
+// ease between values when the data changes.
+
+export interface ChartProps {
+ data: number[]
+ kind?: 'line' | 'bars'
+ width: number
+ height: number
+ /** Draws in when it turns true. */
+ show?: boolean
+ /** The soft fill under a line. */
+ area?: boolean
+ /** A dot on the last point. */
+ dot?: boolean
+ /** The value at the top of the chart. Defaults to the data's maximum. */
+ max?: number
+ className?: string
+ style?: CSSProperties
+}
+
+/**
+ * Where a Chart with these props draws each point, in its own pixels: a
+ * line's points (the end dot sits on the last), or the top center of each
+ * bar. For a tooltip, a marker or a hairline on a point, so nothing repeats
+ * the chart's maths. Add the chart's offset in its container.
+ */
+export function chartPoints({ data, kind = 'line', width, height, dot = true, max }: Pick) {
+ const top = max ?? Math.max(...data, 1)
+ const pad = kind === 'line' && dot ? 4 : 1
+ const h = height - pad * 2
+ if (kind === 'bars') {
+ const gap = Math.max(2, Math.round(width / data.length / 4))
+ const bw = (width - gap * (data.length - 1)) / data.length
+ return data.map((v, i) => ({ x: i * (bw + gap) + bw / 2, y: height - Math.max(2, (v / top) * h) }))
+ }
+ return data.map((v, i) => ({ x: pad + (i / Math.max(1, data.length - 1)) * (width - pad * 2), y: pad + h - (v / top) * h }))
+}
+
+/** A smooth path through the points (Catmull-Rom as cubic Béziers), never overshooting the ends. */
+function smooth(p: { x: number; y: number }[]) {
+ if (!p.length) return ''
+ let d = `M${p[0].x} ${p[0].y}`
+ for (let i = 0; i < p.length - 1; i++) {
+ const a = p[i - 1] ?? p[i]
+ const b = p[i]
+ const c = p[i + 1]
+ const e = p[i + 2] ?? c
+ d += `C${b.x + (c.x - a.x) / 6} ${b.y + (c.y - a.y) / 6} ${c.x - (e.x - b.x) / 6} ${c.y - (e.y - b.y) / 6} ${c.x} ${c.y}`
+ }
+ return d
+}
+
+/**
+ * The same curve as smooth(), sampled at a fixed number of points. A path
+ * only eases into another with the same commands, so a line drawn from 8
+ * points and one from 9 would swap in a frame while the dot traveled.
+ * Sampling keeps every line the same shape of path, whatever the data's length.
+ */
+const SAMPLES = 48
+
+/** The draw-in's timing: the same curve the rest of the kit's travel uses. */
+const DRAW_MS = 1200
+function ease(t: number) {
+ // cubic-bezier(0.45, 0, 0.2, 1), solved for x by bisection
+ const b = (a: number, c: number, u: number) => 3 * a * u * (1 - u) ** 2 + 3 * c * u * u * (1 - u) + u ** 3
+ let lo = 0
+ let hi = 1
+ for (let i = 0; i < 20; i++) {
+ const m = (lo + hi) / 2
+ if (b(0.45, 0.2, m) < t) lo = m
+ else hi = m
+ }
+ return b(0, 1, (lo + hi) / 2)
+}
+
+/** The curve's height at x, from its sampled points. */
+function yAt(p: { x: number; y: number }[], x: number) {
+ let i = 1
+ while (i < p.length - 1 && p[i].x < x) i++
+ const a = p[i - 1]
+ const c = p[i]
+ const t = c.x === a.x ? 0 : Math.max(0, Math.min(1, (x - a.x) / (c.x - a.x)))
+ return a.y + (c.y - a.y) * t
+}
+
+function resample(p: { x: number; y: number }[]) {
+ if (p.length < 2) return p
+ const out: { x: number; y: number }[] = []
+ for (let s = 0; s <= SAMPLES; s++) {
+ const u = (s / SAMPLES) * (p.length - 1)
+ const i = Math.min(Math.floor(u), p.length - 2)
+ const t = u - i
+ const a = p[i - 1] ?? p[i]
+ const b = p[i]
+ const c = p[i + 1]
+ const e = p[i + 2] ?? c
+ // The Bézier segment smooth() draws between b and c
+ const c1 = { x: b.x + (c.x - a.x) / 6, y: b.y + (c.y - a.y) / 6 }
+ const c2 = { x: c.x - (e.x - b.x) / 6, y: c.y - (e.y - b.y) / 6 }
+ const m = 1 - t
+ const k = (p0: number, p1: number, p2: number, p3: number) => m * m * m * p0 + 3 * m * m * t * p1 + 3 * m * t * t * p2 + t * t * t * p3
+ out.push({ x: k(b.x, c1.x, c2.x, c.x), y: k(b.y, c1.y, c2.y, c.y) })
+ }
+ return out
+}
+
+/**
+ * A line or bar chart in a fixed box. A sparkline is a small line with no
+ * area or dot. Values change by easing the shape (the line morphs, bars grow),
+ * never by redrawing.
+ */
+export function Chart({ data, kind = 'line', width, height, show = true, area = true, dot = true, max, className, style }: ChartProps) {
+ const reduced = useReducedMotion()
+ const id = useId().replace(/:/g, '')
+ const drawRef = useRef(null)
+ const wipeRef = useRef(null)
+ const dotRef = useRef(null)
+ const samples = useRef<{ x: number; y: number }[]>([])
+ const shown = useRef(false)
+ // Draw-in: the dot leads and the line follows it. One timeline moves the
+ // wipe's edge and puts the dot on the curve at that edge, every frame.
+ useLayoutEffect(() => {
+ const was = shown.current
+ const g = drawRef.current
+ shown.current = show && data.length > 1 && !!g
+ if (!show || was || !g) return
+ const c = dotRef.current
+ const p = samples.current
+ if (reduced || p.length < 2) return
+ const x0 = p[0].x
+ const x1 = p[p.length - 1].x
+ const wipe = wipeRef.current
+ if (!wipe) return
+ // The clip is a rect in the chart's own units, so its edge is exactly the dot's x
+ const at = (e: number) => {
+ const x = x0 + (x1 - x0) * e
+ wipe.setAttribute('width', `${x + 6}`)
+ if (c) {
+ c.style.setProperty('cx', `${x}px`)
+ c.style.setProperty('cy', `${yAt(p, x)}px`)
+ }
+ }
+ g.style.transition = 'opacity 0.15s ease'
+ if (c) {
+ c.style.transition = 'opacity 0.2s ease, transform 0.3s cubic-bezier(0.34, 1.4, 0.64, 1)'
+ c.style.opacity = '1'
+ c.style.transform = 'none'
+ }
+ at(0)
+ const t0 = performance.now()
+ let drawing = true
+ let raf = requestAnimationFrame(function step(now) {
+ const t = Math.min(1, (now - t0) / DRAW_MS)
+ at(ease(t))
+ if (t < 1) raf = requestAnimationFrame(step)
+ else done()
+ })
+ // Back to the stylesheet: the end state matches it, so nothing moves
+ const done = () => {
+ drawing = false
+ wipe.setAttribute('width', `${width + 12}`)
+ g.style.removeProperty('transition')
+ if (c) for (const k of ['cx', 'cy', 'transition', 'opacity', 'transform']) c.style.removeProperty(k)
+ }
+ return () => {
+ cancelAnimationFrame(raf)
+ // An interrupted setup (including StrictMode's rehearsal) can draw
+ // again. A completed draw does not replay when motion is restored.
+ if (drawing) shown.current = false
+ done()
+ }
+ }, [show, reduced, kind, width, data.length > 1]) // eslint-disable-line react-hooks/exhaustive-deps
+ const top = max ?? Math.max(...data, 1)
+ const pad = kind === 'line' && dot ? 4 : 1
+ const h = height - pad * 2
+ if (!data.length) return
+ if (kind === 'bars') {
+ const gap = Math.max(2, Math.round(width / data.length / 4))
+ const bw = (width - gap * (data.length - 1)) / data.length
+ return (
+
+ )
+ }
+ const pts = chartPoints({ data, kind, width, height, dot, max })
+ const curve = resample(pts)
+ samples.current = curve
+ const line = smooth(curve)
+ const fill = `${line}L${pts[pts.length - 1].x} ${height}L${pts[0].x} ${height}Z`
+ const end = pts[pts.length - 1]
+ return (
+
+ )
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/clock.ts b/skills/imported/benjitaylor/stageshow/kit/clock.ts
new file mode 100644
index 0000000..e7cbb97
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/clock.ts
@@ -0,0 +1,265 @@
+// The stage clock. Everything on a stage moves on the browser's own clocks:
+// performance.now, requestAnimationFrame, setTimeout/setInterval, and CSS
+// transitions and animations on the compositor. With nothing attached, that
+// is all this file is: `clock` reads those same globals, so a stage plays in
+// real time exactly as it always has.
+//
+// A recorder or the playground runs installVirtualClock() in the page
+// before any of its code loads. From then on time only moves when the
+// recorder steps it, by exactly 1/fps: timers fire in time order, one
+// animation frame runs, and every CSS transition and animation is set to the
+// new time. Live playback instead calls advance() with the native RAF's
+// elapsed time, so its motion follows the display refresh rate. Each fixed
+// frame of a video is then the same on every render, a
+// timed beat lands on a known frame, and nothing drops or doubles.
+//
+// Chrome's own virtual time (Emulation.setVirtualTimePolicy) doesn't do this:
+// it moves timers but runs no animation frames and leaves CSS animations
+// where they are.
+
+/** What a recorder drives, at window.__stageshow.clock once installed. */
+export interface ClockControl {
+ readonly fps: number
+ /** Render updates since install; fixed frames when driven by step(). */
+ readonly frame: number
+ /** Virtual ms, what performance.now() returns. */
+ now(): number
+ /**
+ * Advance `frames` frames (1 by default). Per frame: timers due by the new
+ * time fire in order, each followed by the work it queued (React commits,
+ * promise chains); then one round of animation frames; then every CSS
+ * transition and animation is moved to the new time.
+ */
+ step(frames?: number): Promise
+ /** Advance live playback by elapsed milliseconds, rendering one update. */
+ advance(milliseconds: number): Promise
+ /** Let queued tasks run without moving time, and hold any CSS animation that started meanwhile. */
+ settle(): Promise
+ /** Stage measurement callbacks, separate from motion frames. */
+ requestLayoutFrame(callback: FrameRequestCallback): number
+ cancelLayoutFrame(id: number): void
+ readonly layoutPending: boolean
+ /** Run one round of layout callbacks without advancing time, timers or motion. */
+ flushLayout(): Promise
+}
+
+type Host = { __stageshow?: { clock?: ClockControl } }
+
+/**
+ * The clock the kit and its scenes read. Each call goes to the page's global
+ * at call time, so under a recorder it is the virtual clock and otherwise
+ * the real one.
+ */
+export const clock = {
+ now: (): number => performance.now(),
+ raf: (fn: FrameRequestCallback): number => requestAnimationFrame(fn),
+ cancelRaf: (id: number): void => cancelAnimationFrame(id),
+ // Layout must finish even when a preview is paused. Keep these callbacks
+ // separate: running arbitrary motion RAFs at a frozen timestamp can still
+ // advance a spring that integrates once per callback.
+ layoutRaf: (fn: FrameRequestCallback): number => clock.recording?.requestLayoutFrame(fn) ?? requestAnimationFrame(fn),
+ cancelLayoutRaf: (id: number): void => {
+ const recording = clock.recording
+ if (recording) recording.cancelLayoutFrame(id)
+ else cancelAnimationFrame(id)
+ },
+ timeout: (fn: () => void, ms: number): number => window.setTimeout(fn, ms),
+ clearTimeout: (id: number): void => window.clearTimeout(id),
+ /** The recorder's control while one drives the page, else null. */
+ get recording(): ClockControl | null {
+ return typeof window === 'undefined' ? null : ((window as unknown as Host).__stageshow?.clock ?? null)
+ },
+}
+
+/**
+ * Replace the page's clocks with a virtual one that only moves on step().
+ * Self-contained (a recorder can inject its source as an init script), so it
+ * must not reference anything outside its own body.
+ *
+ * `epoch` is the wall-clock time Date reports at frame 0, so a clock drawn
+ * in a scene reads the same on every render.
+ */
+export function installVirtualClock(opts: { fps?: number; epoch?: number; onLayout?: () => void } = {}): ClockControl {
+ const w = window as any
+ if (w.__stageshow?.clock) return w.__stageshow.clock
+ const fps = opts.fps ?? 60
+ const RealDate = Date
+ const epoch = opts.epoch ?? RealDate.now()
+ // Not 0: code that tests a timestamp for truthiness must still see one
+ const BASE = 1000
+ let vt = BASE
+ let frame = 0
+ let fixedAnchor = BASE
+ let fixedFrame = 0
+
+ type Timer = { id: number; at: number; seq: number; fn: unknown; args: unknown[]; every: number | null }
+ const timers = new Map()
+ let nextId = 1
+ let seq = 0
+ let frameCbs = new Map()
+ let layoutCbs = new Map()
+
+ const arm = (fn: unknown, ms: unknown, args: unknown[], every: boolean) => {
+ const d = Math.max(0, Number(ms) || 0)
+ const id = nextId++
+ timers.set(id, { id, at: vt + d, seq: seq++, fn, args, every: every ? Math.max(1, d) : null })
+ return id
+ }
+ w.setTimeout = (fn: unknown, ms?: unknown, ...args: unknown[]) => arm(fn, ms, args, false)
+ w.setInterval = (fn: unknown, ms?: unknown, ...args: unknown[]) => arm(fn, ms, args, true)
+ w.clearTimeout = w.clearInterval = (id: number) => void timers.delete(id)
+ w.requestAnimationFrame = (fn: FrameRequestCallback) => {
+ const id = nextId++
+ frameCbs.set(id, fn)
+ return id
+ }
+ w.cancelAnimationFrame = (id: number) => void frameCbs.delete(id)
+ performance.now = () => vt
+ const dateNow = () => Math.floor(epoch + (vt - BASE))
+ const VDate = function (this: unknown, ...a: unknown[]) {
+ if (!new.target) return new RealDate(dateNow()).toString()
+ return a.length ? new (RealDate as any)(...a) : new RealDate(dateNow())
+ } as unknown as DateConstructor
+ Object.assign(VDate, { prototype: RealDate.prototype, now: dateNow, parse: RealDate.parse, UTC: RealDate.UTC })
+ w.Date = VDate
+
+ // Queued work (React renders on a MessageChannel, effects after them,
+ // promise chains) runs between tasks; a few message round trips let it all
+ // land before time moves on.
+ const tick = () =>
+ new Promise((r) => {
+ const c = new MessageChannel()
+ c.port1.onmessage = () => {
+ c.port1.close()
+ c.port2.close()
+ r()
+ }
+ c.port2.postMessage(0)
+ })
+ // Anything that started meanwhile is held from here on (see scan below).
+ const settle = async () => {
+ for (let i = 0; i < 4; i++) await tick()
+ scan()
+ }
+ const run = (fn: unknown, args: unknown[]) => {
+ try {
+ // String callbacks are ignored: running them would need eval
+ if (typeof fn === 'function') fn(...args)
+ } catch (e) {
+ console.error(e)
+ }
+ }
+
+ // CSS transitions and animations: each one is held (playback rate 0) from
+ // the moment it is first seen, and its own clock is moved by hand with the
+ // virtual one. One that turned paused in CSS stays put; one that reaches its
+ // end is finished, so transitionend and animationend still fire.
+ type Held = { local: number; rate: number; born: number }
+ const held = new WeakMap()
+ const live = new Set()
+ const scan = () => {
+ for (const a of document.getAnimations()) {
+ if (held.has(a) || a.playState === 'finished' || a.playState === 'idle') continue
+ held.set(a, { local: 0, rate: a.playbackRate || 1, born: vt })
+ a.playbackRate = 0
+ a.currentTime = 0
+ live.add(a)
+ }
+ }
+ const moveAnimations = (from: number) => {
+ scan()
+ for (const a of Array.from(live)) {
+ const h = held.get(a)!
+ if (a.playState === 'idle' || a.playState === 'finished') {
+ live.delete(a)
+ continue
+ }
+ if (a.playState !== 'paused') h.local += (vt - Math.max(from, h.born)) * h.rate
+ const end = a.effect?.getComputedTiming().endTime
+ if (typeof end === 'number' && Number.isFinite(end) && h.local >= end) {
+ // Cancel a pending play task before restoring the rate. Otherwise a
+ // delayed transition can restart on the native timeline after finish().
+ a.pause()
+ a.playbackRate = h.rate
+ a.finish()
+ live.delete(a)
+ } else {
+ a.currentTime = h.local
+ }
+ }
+ }
+
+ const stepTo = async (to: number) => {
+ const from = vt
+ await settle()
+ // Timers due by `to`, in time order, each with the work it queued. A
+ // hair of slack, so a wait summed from frame-sized steps can't miss its
+ // frame by a rounding error
+ for (let guard = 0; guard < 10000; guard++) {
+ let next: Timer | null = null
+ for (const t of Array.from(timers.values())) if (t.at <= to + 1e-6 && (!next || t.at < next.at || (t.at === next.at && t.seq < next.seq))) next = t
+ if (!next) break
+ vt = next.at
+ if (next.every === null) timers.delete(next.id)
+ else {
+ next.at += next.every
+ next.seq = seq++
+ }
+ run(next.fn, next.args)
+ await settle()
+ }
+ vt = to
+ frame++
+ const cbs = frameCbs
+ const layout = layoutCbs
+ frameCbs = new Map()
+ layoutCbs = new Map()
+ for (const fn of Array.from(layout.values())) run(fn, [vt])
+ for (const fn of Array.from(cbs.values())) run(fn, [vt])
+ await settle()
+ moveAnimations(from)
+ }
+
+ const control: ClockControl = {
+ fps,
+ get frame() {
+ return frame
+ },
+ now: () => vt,
+ async step(n = 1) {
+ // Keep recorder timestamps exact, without accumulating rounded deltas.
+ for (let i = 0; i < n; i++) await stepTo(fixedAnchor + (++fixedFrame * 1000) / fps)
+ },
+ async advance(milliseconds) {
+ if (!Number.isFinite(milliseconds) || milliseconds < 0) throw new RangeError('Clock advance must be a finite, non-negative duration')
+ if (milliseconds === 0) return
+ await stepTo(vt + milliseconds)
+ // A manual fixed step after live playback starts at the current time.
+ fixedAnchor = vt
+ fixedFrame = 0
+ },
+ settle,
+ requestLayoutFrame(callback) {
+ const empty = layoutCbs.size === 0
+ const id = nextId++
+ layoutCbs.set(id, callback)
+ // A paused React/ResizeObserver commit can enqueue measurement work
+ // without changing any observed DOM attributes. Wake its host directly.
+ if (empty) opts.onLayout?.()
+ return id
+ },
+ cancelLayoutFrame: (id) => void layoutCbs.delete(id),
+ get layoutPending() { return layoutCbs.size > 0 },
+ async flushLayout() {
+ await settle()
+ // Snapshot once: a nested layout frame belongs to the next native
+ // paint, rather than an unbounded drain in this task.
+ const callbacks = layoutCbs
+ layoutCbs = new Map()
+ for (const fn of Array.from(callbacks.values())) run(fn, [vt])
+ await settle()
+ },
+ }
+ w.__stageshow = { ...(w.__stageshow ?? {}), clock: control }
+ return control
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/code.css b/skills/imported/benjitaylor/stageshow/kit/code.css
new file mode 100644
index 0000000..cfbf5ba
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/code.css
@@ -0,0 +1,87 @@
+/* Code (code.tsx). */
+
+@layer stageshow {
+ .ss-code {
+ --ss-code-key: #7c3aed;
+ --ss-code-str: #15803d;
+ --ss-code-num: #b45309;
+ --ss-code-fn: #2563eb;
+ font: 11.5px/19px ui-monospace, 'SF Mono', Menlo, monospace;
+ color: var(--ss-text-strong);
+ }
+
+ .ss-stage[data-theme="dark"] .ss-code {
+ --ss-code-key: #c4b5fd;
+ --ss-code-str: #86efac;
+ --ss-code-num: #fdba74;
+ --ss-code-fn: #93c5fd;
+ }
+
+ .ss-stage[data-palette="mono"] .ss-code,
+ .ss-stage[data-theme="blueprint"] .ss-code {
+ --ss-code-key: var(--ss-text-strong);
+ --ss-code-str: var(--ss-text);
+ --ss-code-num: var(--ss-text-strong);
+ --ss-code-fn: var(--ss-text-strong);
+ }
+
+ /* Lines not yet shown are laid out but hidden, so streaming never reflows */
+ .ss-code__line {
+ display: flex;
+ padding-right: 12px;
+ opacity: 0;
+ transition: opacity 0.3s ease;
+ }
+
+ .ss-code__line[data-on] {
+ opacity: 1;
+ }
+
+ .ss-code__num {
+ flex-shrink: 0;
+ width: 30px;
+ padding-right: 10px;
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+ color: var(--ss-text-faint);
+ }
+
+ /* Wide enough that the + or − stands clear of the code after it */
+ .ss-code__mark {
+ flex-shrink: 0;
+ width: 16px;
+ color: var(--ss-text-faint);
+ }
+
+ .ss-code__text {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ white-space: pre;
+ }
+
+ /* A line longer than the block fades at its right edge, never cut hard;
+ the diff tint behind it still runs the full width */
+ .ss-code__text[data-over] {
+ mask-image: linear-gradient(to right, #000 calc(100% - 14px), transparent);
+ }
+
+ .ss-code__line[data-kind="add"] { background: var(--ss-success-soft); }
+
+ .ss-code__line[data-kind="add"] .ss-code__mark { color: var(--ss-success-text); }
+
+ .ss-code__line[data-kind="del"] { background: var(--ss-danger-soft); }
+
+ .ss-code__line[data-kind="del"] .ss-code__mark { color: var(--ss-danger-text); }
+
+ .ss-code__key { color: var(--ss-code-key); }
+
+ .ss-code__str { color: var(--ss-code-str); }
+
+ .ss-code__num + .ss-code__mark + .ss-code__text .ss-code__num,
+ .ss-code__text .ss-code__num { width: auto; padding: 0; text-align: inherit; color: var(--ss-code-num); }
+
+ .ss-code__fn { color: var(--ss-code-fn); }
+
+ .ss-code__com { color: var(--ss-text-faint); }
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/code.tsx b/skills/imported/benjitaylor/stageshow/kit/code.tsx
new file mode 100644
index 0000000..37ef44d
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/code.tsx
@@ -0,0 +1,70 @@
+import { useLayoutEffect, useRef, type CSSProperties, type ReactNode } from 'react'
+import './code.css'
+
+// Code as the subject: real lines in a monospace block with line numbers,
+// quiet syntax color, and diff lines that read at a glance.
+
+export interface CodeLine {
+ text: string
+ /** add: a green line with a +. del: a red line with a −. */
+ kind?: 'add' | 'del'
+ /** false leaves this line's number blank (a wrapped continuation, a fold). */
+ number?: boolean
+}
+
+const KEYWORDS = /^(const|let|var|function|return|if|else|await|async|import|from|export|default|new|true|false|null|undefined|for|of|in|while|try|catch|throw|class|extends|type|interface|def|self|None|True|False)$/
+
+/** A light tokenizer: comments, strings, numbers, keywords, function calls. */
+function tokens(line: string): ReactNode[] {
+ const out: ReactNode[] = []
+ const re = /(\/\/.*$|#.*$)|('(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|`(?:[^`\\]|\\.)*`)|(\b\d[\d._]*\b)|([A-Za-z_$][\w$]*)(?=\s*\()|([A-Za-z_$][\w$]*)/g
+ let last = 0
+ let m: RegExpExecArray | null
+ let k = 0
+ while ((m = re.exec(line))) {
+ if (m.index > last) out.push(line.slice(last, m.index))
+ const [text, com, str, num, call, word] = m
+ const kind = com ? 'com' : str ? 'str' : num ? 'num' : call ? (KEYWORDS.test(call) ? 'key' : 'fn') : word && KEYWORDS.test(word) ? 'key' : null
+ out.push(kind ? {text} : text)
+ last = m.index + text.length
+ }
+ if (last < line.length) out.push(line.slice(last))
+ return out
+}
+
+/**
+ * Lines of code. `count` shows only the first n lines, the rest laid out but
+ * hidden, so a block can stream in (a build log, a generated function) without
+ * anything reflowing. Diff lines get a tint and a + or − in the gutter.
+ */
+export function Code({ lines, start = 1, numbers = true, count, highlight = true, className, style }: { lines: (CodeLine | string)[]; start?: number; numbers?: boolean; count?: number; highlight?: boolean; className?: string; style?: CSSProperties }) {
+ let n = start
+ // Only lines that really run past the block fade at the edge
+ const ref = useRef(null)
+ useLayoutEffect(() => {
+ const mark = () =>
+ ref.current?.querySelectorAll('.ss-code__text').forEach((t) => {
+ t.toggleAttribute('data-over', t.scrollWidth > t.clientWidth + 1)
+ })
+ mark()
+ const ro = new ResizeObserver(mark)
+ if (ref.current) ro.observe(ref.current)
+ return () => ro.disconnect()
+ })
+ return (
+
+ {lines.map((raw, i) => {
+ const line = typeof raw === 'string' ? { text: raw } : raw
+ // A removed line keeps the old number; the line after it reuses it
+ const num = line.kind === 'del' || line.number === false ? n : n++
+ return (
+
+ )
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/controls/Badge.css b/skills/imported/benjitaylor/stageshow/kit/controls/Badge.css
new file mode 100644
index 0000000..5d00dc6
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/controls/Badge.css
@@ -0,0 +1,56 @@
+/* Badge */
+
+@layer stageshow {
+ /* Small boxes with text keep a layer of their own. Pushed by a row opening
+ above them, or carried along by anything that moves by layout, their text
+ would step whole pixels while the fill glides, and hop about inside it */
+ .ss-badge {
+ will-change: transform;
+ }
+
+ .ss-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ height: 20px;
+ padding: 0 9px;
+ border-radius: var(--ss-radius-pill);
+ background: var(--ss-ink-2);
+ font-size: 11px;
+ font-weight: 500;
+ color: var(--ss-text-strong);
+ white-space: nowrap;
+ /* A tone change (Working to Ready) eases on the same pill, no swap */
+ transition: background-color 0.35s ease, color 0.35s ease;
+ }
+
+ /* A round mark before the words reads as further in than text does, so
+ its side is 2px tighter: the dot and the last letter then sit the same
+ distance from their ends */
+ .ss-badge[data-lead] {
+ padding-left: 7px;
+ }
+
+ .ss-badge[data-tone="accent"] { background: var(--ss-accent-soft); color: var(--ss-accent-text); }
+
+ .ss-badge[data-tone="success"] { background: var(--ss-success-soft); color: var(--ss-success-text); }
+
+ .ss-badge[data-tone="danger"] { background: var(--ss-danger-soft); color: var(--ss-danger-text); }
+
+ .ss-badge[data-tone="warning"] { background: var(--ss-warning-soft); color: var(--ss-warning-text); }
+
+ /* The label's line box trimmed to cap height and baseline, so the words
+ center on their own ink; a plain line box keeps descender room and sits
+ the text about 2px low in a 20px pill */
+ .ss-badge__label {
+ display: block;
+ text-box: trim-both cap alphabetic;
+ }
+
+ .ss-badge__dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: currentColor;
+ }
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/controls/Badge.tsx b/skills/imported/benjitaylor/stageshow/kit/controls/Badge.tsx
new file mode 100644
index 0000000..e7fde34
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/controls/Badge.tsx
@@ -0,0 +1,17 @@
+import type { HTMLAttributes, ReactNode } from 'react'
+import './Badge.css'
+
+/**
+ * A small status pill. `dot` adds a status dot before the label; `icon` puts
+ * a glyph there instead (a tick on Matched), beside the label rather than in
+ * it, so the label's trimmed box still centers the words on their ink.
+ */
+export function Badge({ tone = 'neutral', dot, icon, children, className, ...rest }: HTMLAttributes & { tone?: 'neutral' | 'accent' | 'success' | 'danger' | 'warning'; dot?: boolean; icon?: ReactNode }) {
+ return (
+
+ {dot && }
+ {icon}
+ {children}
+
+ )
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/controls/Field.css b/skills/imported/benjitaylor/stageshow/kit/controls/Field.css
new file mode 100644
index 0000000..9fe2ef1
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/controls/Field.css
@@ -0,0 +1,201 @@
+/* Field */
+
+@layer stageshow {
+ /* The box never grows past the width it was given: a chip opening, or any
+ content, clips inside the text area instead of pushing the trailing
+ control and the box edge out into whatever sits next to the field */
+ .ss-field {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ gap: 6px;
+ }
+
+ .ss-field__label {
+ font-size: 11.5px;
+ font-weight: 500;
+ color: var(--ss-text-strong);
+ }
+
+ /* The ring is a shadow, so focusing never moves the text */
+ .ss-field__box {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ height: var(--ss-control);
+ padding: 0 11px;
+ border-radius: var(--ss-radius-md);
+ background: var(--ss-surface);
+ box-shadow: inset 0 0 0 1px var(--ss-ink-4), 0 0 0 0 transparent;
+ font-size: 13px;
+ color: var(--ss-text-strong);
+ transition: box-shadow 0.2s ease, padding 0.35s cubic-bezier(0.45, 0, 0.1, 1);
+ }
+
+ /* A chip sits the same distance from every edge of the field: the box gives
+ up its left padding to the chip's own, at the same pace, so the text
+ inside never moves while the chip opens around it */
+ .ss-field__box:has(.ss-field__value[data-chip]) {
+ padding-left: calc((var(--ss-control) - 24px) / 2);
+ }
+
+ .ss-field[data-focused] .ss-field__box {
+ box-shadow: inset 0 0 0 1px var(--ss-accent), 0 0 0 3px var(--ss-accent-soft);
+ }
+
+ .ss-field[data-status="error"] .ss-field__box {
+ box-shadow: inset 0 0 0 1px var(--ss-danger), 0 0 0 3px var(--ss-danger-soft);
+ }
+
+ .ss-field__text {
+ position: relative;
+ display: flex;
+ flex: 1;
+ align-items: center;
+ min-width: 0;
+ height: 100%;
+ overflow: hidden;
+ white-space: pre;
+ }
+
+ /* Leaves the moment the first character lands; fades back when emptied */
+ .ss-field__placeholder {
+ position: absolute;
+ top: 50%;
+ left: 0;
+ color: var(--ss-text-faint);
+ translate: 0 -50%;
+ transition: opacity 0.2s ease;
+ }
+
+ .ss-field[data-filled] .ss-field__placeholder {
+ opacity: 0;
+ transition: none;
+ }
+
+ .ss-field__value[data-password] {
+ letter-spacing: 1.5px;
+ }
+
+ /* Committed as a chip in place: the text never swaps or changes weight; the
+ surface fades in around it, the padding opens it out, and the x grows in
+ beside it. Cleared, the whole chip fades holding its text. */
+ .ss-field__value {
+ display: inline-flex;
+ flex: none;
+ align-items: center;
+ height: 24px;
+ padding: 0;
+ border-radius: var(--ss-radius-pill);
+ background-color: transparent;
+ /* Released once it has faded: out of sight, so nothing needs to move */
+ transition: none;
+ }
+
+ /* Centered on the cap height, not the line box (which reads low) or the
+ x-height (which reads high once the words have ascenders: an email, a
+ name). Cap to baseline is what a real text field centers. The
+ placeholder is centered the same way, so they share a line. */
+ .ss-field__chars,
+ .ss-field__placeholder {
+ line-height: 1;
+ text-box: trim-both cap alphabetic;
+ }
+
+ .ss-field__chars {
+ display: block;
+ }
+
+ .ss-field__value[data-chip] {
+ padding: 0 calc(11px - (var(--ss-control) - 24px) / 2);
+ background-color: var(--ss-chip-bg, var(--ss-accent-soft));
+ color: var(--ss-chip-text, var(--ss-text-strong));
+ transition:
+ padding 0.35s cubic-bezier(0.45, 0, 0.1, 1),
+ background-color 0.3s ease,
+ color 0.3s ease;
+ }
+
+ .ss-field__value[data-chip][data-removable] {
+ padding-right: 4px;
+ }
+
+ .ss-field__value[data-leaving] {
+ opacity: 0;
+ transition: opacity 0.25s ease;
+ }
+
+ /* The placeholder waits for a cleared chip to finish fading, never crossing it */
+ .ss-field:has(.ss-field__value[data-leaving]) .ss-field__placeholder {
+ opacity: 0;
+ transition: none;
+ }
+
+ .ss-field__x {
+ display: inline-grid;
+ flex: none;
+ place-items: center;
+ /* Always whole: it slides out from under the text's end by margin, so its
+ icon is never sliced while the chip opens */
+ width: 16px;
+ height: 16px;
+ margin-left: -16px;
+ border-radius: 50%;
+ color: inherit;
+ opacity: 0;
+ transition: background-color 0.15s ease;
+ }
+
+ .ss-field__value[data-chip] .ss-field__x {
+ margin-left: 4px;
+ opacity: 0.75;
+ transition:
+ margin 0.35s cubic-bezier(0.45, 0, 0.1, 1),
+ opacity 0.25s ease 0.12s,
+ background-color 0.15s ease;
+ }
+
+ .ss-field__x[data-hover] {
+ opacity: 1;
+ background-color: var(--ss-ink-2);
+ transition-duration: 0s;
+ }
+
+ .ss-field__value[data-chip] + .ss-field__caret {
+ margin-left: 6px;
+ }
+
+ /* Solid while typing (each keystroke remounts it), blinking once it rests */
+ .ss-field__caret {
+ flex-shrink: 0;
+ transition: margin 0.35s cubic-bezier(0.45, 0, 0.1, 1);
+ width: 1.5px;
+ height: 16px;
+ margin-left: 1px;
+ border-radius: 1px;
+ background: var(--ss-accent);
+ animation: ss-caret 1.06s steps(1) 0.5s infinite;
+ }
+
+ @keyframes ss-caret {
+ 50% { opacity: 0; }
+ }
+
+ .ss-field__ok {
+ flex-shrink: 0;
+ }
+
+ .ss-field__ok path {
+ fill: none;
+ stroke: var(--ss-success);
+ stroke-width: 2;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ stroke-dasharray: 1;
+ /* Drawn at rest, so a still (animations off) still shows it */
+ animation: ss-draw 0.35s cubic-bezier(0.65, 0, 0.35, 1) both;
+ }
+
+ @keyframes ss-draw {
+ from { stroke-dashoffset: 1; }
+ }
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/controls/Field.tsx b/skills/imported/benjitaylor/stageshow/kit/controls/Field.tsx
new file mode 100644
index 0000000..b152e60
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/controls/Field.tsx
@@ -0,0 +1,94 @@
+import { useLayoutEffect, useRef, useState, type HTMLAttributes, type ReactNode } from 'react'
+import { Icon } from '../parts/Icon'
+import './Field.css'
+
+export interface FieldProps extends Omit, 'children'> {
+ label?: ReactNode
+ /** The typed value. Drive it with act.type(). */
+ value: string
+ placeholder?: string
+ /** Focus ring and a blinking caret. */
+ focused?: boolean
+ /** Dots instead of the value. */
+ password?: boolean
+ /** valid: a check at the end and a calm ring. error: the ring turns danger. */
+ status?: 'valid' | 'error'
+ /** The typed value committed as a chip, in place (a filter on Enter): the
+ * text stays where it was typed while a surface grows around it. Clear the
+ * value and the chip together; the chip fades out holding its text. */
+ chip?: boolean
+ /** Gives the chip a remove ×, with this data-target for the cursor. */
+ remove?: string
+ leading?: ReactNode
+ trailing?: ReactNode
+ /** Props for the input box itself, the thing to click: data-target, data-cursor. */
+ box?: HTMLAttributes & Record<`data-${string}`, unknown>
+}
+
+/**
+ * A text field: label, box, value and caret. The focus ring fades in without
+ * moving the text, the placeholder leaves the moment the first character
+ * lands (and fades back when emptied), and the caret sits solid while typing,
+ * blinking only once it rests. Put data-target on `box`, the part a cursor clicks.
+ */
+export function Field({ label, value, placeholder, focused, password, status, chip, remove, leading, trailing, box, className, style, ...rest }: FieldProps) {
+ // A cleared chip keeps its text while it fades, then lets go
+ // (decided while rendering, so not one frame passes without it)
+ const held = useRef('')
+ const text = useRef(null)
+ const [, release] = useState(0)
+ if (chip || value) held.current = chip ? value : ''
+ const let_go = () => {
+ if (!held.current) return
+ held.current = ''
+ release((n) => n + 1)
+ }
+ const out = !chip && !value && !!held.current
+ // Keep the text only while its fade is visible. A stage reset can disable
+ // the transition before it starts, or cancel it halfway through.
+ useLayoutEffect(() => {
+ const el = text.current
+ if (!out || !el) return
+ const check = () => {
+ if (Number(getComputedStyle(el).opacity) === 0) let_go()
+ }
+ const settle = (event: TransitionEvent) => {
+ if (event.target === el && event.propertyName === 'opacity') check()
+ }
+ el.addEventListener('transitionend', settle)
+ el.addEventListener('transitioncancel', settle)
+ check()
+ return () => {
+ el.removeEventListener('transitionend', settle)
+ el.removeEventListener('transitioncancel', settle)
+ }
+ }, [out])
+ const shown = out ? held.current : value
+ return (
+
+ {label && {label}}
+
+ {leading}
+
+ {placeholder && {placeholder}}
+
+ {password ? '•'.repeat(shown.length) : shown}
+ {remove !== undefined && (
+
+
+
+ )}
+
+ {/* Keyed on the value, so every keystroke restarts the blink from solid */}
+ {focused && }
+
+ {status === 'valid' && (
+
+ )}
+ {trailing}
+
+ )
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/controls/Slot.css b/skills/imported/benjitaylor/stageshow/kit/controls/Slot.css
new file mode 100644
index 0000000..d2cadf8
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/controls/Slot.css
@@ -0,0 +1,39 @@
+/* Slot */
+
+@layer stageshow {
+ .ss-slot {
+ flex-shrink: 0;
+ border-radius: var(--ss-radius-md);
+ transition: height 0.45s cubic-bezier(0.45, 0, 0.2, 1), margin-top 0.45s cubic-bezier(0.45, 0, 0.2, 1);
+ }
+
+ /* The dashed mark is drawn at the slot's full height and only fades: a
+ dashed border on a box whose height animates re-lays its dashes every
+ frame, and they flicker. It fades in as the slot finishes opening (the
+ clip only guards the last few pixels), and leaves first when it closes. */
+ .ss-slot {
+ position: relative;
+ }
+
+ .ss-slot__clip {
+ position: absolute;
+ inset: 0;
+ overflow: clip;
+ border-radius: inherit;
+ pointer-events: none;
+ }
+
+ .ss-slot__mark {
+ position: absolute;
+ inset: 0 0 auto;
+ border: 1.5px dashed var(--ss-ink-5);
+ border-radius: inherit;
+ opacity: 0;
+ transition: opacity 0.15s ease-out;
+ }
+
+ .ss-slot[data-open][data-dashed] .ss-slot__mark {
+ opacity: 1;
+ transition: opacity 0.16s ease-out 0.3s;
+ }
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/controls/Slot.tsx b/skills/imported/benjitaylor/stageshow/kit/controls/Slot.tsx
new file mode 100644
index 0000000..e77f6fe
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/controls/Slot.tsx
@@ -0,0 +1,25 @@
+import type { HTMLAttributes } from 'react'
+import './Slot.css'
+
+/**
+ * A gap in a list that opens and closes, for moving a card between lists: the
+ * source's slot closes behind the card as it's picked up, the destination's
+ * opens (dashed) where it will land, and the card drops into it. `gap` is the
+ * list's own gap, so a closed slot takes no space at all.
+ */
+export function Slot({ open, height, gap = 0, dashed, className, style, children, ...rest }: HTMLAttributes & { open: boolean; height: number; gap?: number; dashed?: boolean }) {
+ return (
+
+
+
+
+ {children}
+
+ )
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/controls/Tabs.css b/skills/imported/benjitaylor/stageshow/kit/controls/Tabs.css
new file mode 100644
index 0000000..1dbe40c
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/controls/Tabs.css
@@ -0,0 +1,33 @@
+/* Tabs */
+
+@layer stageshow {
+ .ss-tabs {
+ position: relative;
+ display: flex;
+ gap: 18px;
+ border-bottom: 1px solid var(--ss-line);
+ }
+
+ .ss-tabs__tab {
+ padding-bottom: 9px;
+ font-size: 12.5px;
+ font-weight: 500;
+ color: var(--ss-text);
+ transition: color 0.2s ease;
+ }
+
+ .ss-tabs__tab[data-on] {
+ color: var(--ss-text-strong);
+ }
+
+ .ss-tabs__bar {
+ position: absolute;
+ bottom: -1px;
+ left: 0;
+ width: 1px;
+ height: 2px;
+ background: var(--ss-text-strong);
+ transform-origin: 0 0;
+ transition: transform 0.35s cubic-bezier(0.22, 1, 0.36, 1);
+ }
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/controls/Tabs.tsx b/skills/imported/benjitaylor/stageshow/kit/controls/Tabs.tsx
new file mode 100644
index 0000000..b79bcd9
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/controls/Tabs.tsx
@@ -0,0 +1,26 @@
+import { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'
+import './Tabs.css'
+
+/**
+ * A row of tabs with an underline that slides to the active one, measured,
+ * so tabs can be any width. `targets` names each tab for the cursor.
+ */
+export function Tabs({ items, value, targets, className, style, ...rest }: { items: ReactNode[]; value: number; targets?: (string | undefined)[]; className?: string; style?: CSSProperties } & Record<`data-${string}`, unknown>) {
+ const row = useRef(null)
+ const [bar, setBar] = useState({ x: 0, w: 0 })
+ useLayoutEffect(() => {
+ const el = row.current?.children[value + 1] as HTMLElement | undefined
+ if (el) setBar({ x: el.offsetLeft, w: el.offsetWidth })
+ }, [value, items.length])
+ return (
+
+ {/* A 1px line scaled to the tab's width: it slides on transform alone */}
+
+ {items.map((t, i) => (
+
+ {t}
+
+ ))}
+
+ )
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/controls/Toggle.css b/skills/imported/benjitaylor/stageshow/kit/controls/Toggle.css
new file mode 100644
index 0000000..daff3e4
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/controls/Toggle.css
@@ -0,0 +1,46 @@
+/* Toggle */
+
+@layer stageshow {
+ .ss-toggle {
+ position: relative;
+ display: inline-block;
+ flex-shrink: 0;
+ width: 30px;
+ height: 18px;
+ border-radius: 9px;
+ background: var(--ss-ink-5);
+ transition: background-color 0.2s ease;
+ }
+
+ .ss-toggle[data-on] {
+ background: var(--ss-accent);
+ }
+
+ .ss-toggle__knob {
+ position: absolute;
+ top: 2px;
+ left: 2px;
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ background: #fff;
+ /* Light and tight: a heavy shadow grays the track beside the knob, so
+ the accent reads as see-through */
+ box-shadow: 0 0.5px 1.5px rgba(0, 0, 0, 0.14), 0 0 0 0.5px rgba(0, 0, 0, 0.04);
+ transition: transform 0.25s cubic-bezier(0.22, 1, 0.36, 1);
+ }
+
+ .ss-toggle[data-on] .ss-toggle__knob {
+ transform: translateX(12px);
+ }
+
+ .ss-stage[data-palette="mono"][data-theme="dark"] .ss-toggle[data-on] .ss-toggle__knob {
+ background: #111;
+ }
+
+ /* On blueprint the accent is white, so the knob takes the ground */
+ .ss-stage[data-theme="blueprint"] .ss-toggle[data-on] .ss-toggle__knob {
+ background: var(--ss-well);
+ box-shadow: none;
+ }
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/controls/Toggle.tsx b/skills/imported/benjitaylor/stageshow/kit/controls/Toggle.tsx
new file mode 100644
index 0000000..a952ac4
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/controls/Toggle.tsx
@@ -0,0 +1,11 @@
+import type { HTMLAttributes } from 'react'
+import './Toggle.css'
+
+/** A switch. The knob slides and the track takes the accent when on. */
+export function Toggle({ on, className, ...rest }: HTMLAttributes & { on: boolean }) {
+ return (
+
+
+
+ )
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/controls/index.ts b/skills/imported/benjitaylor/stageshow/kit/controls/index.ts
new file mode 100644
index 0000000..ded02d5
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/controls/index.ts
@@ -0,0 +1,11 @@
+// Form controls every product demo needs, with the motion done once: a text
+// field that types with a caret, a segmented control and tabs whose
+// highlight slides, a switch, a badge, and a slot that opens a gap in a list.
+// One file per part, each importing its own CSS.
+export { Field } from './Field'
+export type { FieldProps } from './Field'
+export { Segmented } from './Segmented'
+export { Toggle } from './Toggle'
+export { Tabs } from './Tabs'
+export { Badge } from './Badge'
+export { Slot } from './Slot'
diff --git a/skills/imported/benjitaylor/stageshow/kit/cursor.css b/skills/imported/benjitaylor/stageshow/kit/cursor.css
new file mode 100644
index 0000000..896640c
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/cursor.css
@@ -0,0 +1,256 @@
+/* The cursor (cursor.tsx): glyphs, the five styles, press, idle, ripples. */
+
+@layer stageshow {
+ .ss-cursor {
+ position: absolute;
+ top: 0;
+ left: 0;
+ z-index: 100;
+ pointer-events: none;
+ will-change: transform;
+ transition: opacity 0.25s ease;
+ }
+
+ /* Sat still too long while the script plays: it steps out of the shot, and
+ comes back the moment it moves */
+ .ss-cursor[data-idle] {
+ opacity: 0;
+ transition-duration: 0.6s;
+ }
+
+ .ss-cursor__press {
+ display: grid;
+ /* The glyphs' hotspots meet at 0,0; pressing keeps that contact fixed. */
+ transform-origin: 0 0;
+ /* Lets go softly, as a finger lifting */
+ transition: transform 0.26s cubic-bezier(0.22, 1, 0.36, 1);
+ }
+
+ .ss-cursor[data-pressed="true"] .ss-cursor__press {
+ transform: scale(0.9);
+ transition: transform 0.08s ease-out;
+ }
+
+ .ss-cursor__glyph {
+ grid-area: 1 / 1;
+ transform: scale(0);
+ opacity: 0;
+ transition: transform 0.2s ease, opacity 0.2s ease;
+ }
+
+ .ss-cursor__glyph[data-active="true"] {
+ transform: scale(var(--ss-cursor-size, 1));
+ opacity: 1;
+ }
+
+ /* Into and out of a resize cursor, the arrow and the triangles cross over
+ rather than pop: neither shrinks to nothing, and the ease is softer */
+ .ss-cursor__glyph[data-glyph^="resize"] {
+ transform: scale(calc(var(--ss-cursor-size, 1) * 0.5));
+ }
+
+ .ss-cursor[data-kind^="resize"] .ss-cursor__glyph:not([data-active="true"]) {
+ transform: scale(calc(var(--ss-cursor-size, 1) * 0.6));
+ }
+
+ .ss-cursor[data-kind^="resize"] .ss-cursor__glyph,
+ .ss-cursor__glyph[data-glyph^="resize"] {
+ transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.2s ease;
+ }
+
+ .ss-cursor__glyph[data-glyph^="resize"][data-active="true"] {
+ transform: scale(var(--ss-cursor-size, 1));
+ }
+
+ /* What it was leaves quickly, so no ghost of it trails the new shape */
+ .ss-cursor[data-kind^="resize"] .ss-cursor__glyph:not([data-active="true"]),
+ .ss-cursor:not([data-kind^="resize"]) .ss-cursor__glyph[data-glyph^="resize"] {
+ transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.1s ease;
+ }
+
+ /* The open and closed hand are one hand: between them it closes and opens in
+ place, and the press dip shows the grip, never one hand fading out as the
+ other grows in */
+ .ss-cursor:is([data-kind="open"], [data-kind="grab"]) .ss-cursor__glyph:is([data-glyph="open"], [data-glyph="grab"]) {
+ transform: scale(var(--ss-cursor-size, 1));
+ transition: opacity 0.08s linear;
+ }
+
+ .ss-cursor[data-style="large"] {
+ --ss-cursor-size: 1.5;
+ }
+
+ /* The I-beam marks a text line, so it stays near the text's size */
+ .ss-cursor[data-style="large"] .ss-cursor__glyph[data-glyph="ibeam"] {
+ --ss-cursor-size: 1.15;
+ }
+
+ /* Gestural: a soft dot, no cursor kinds. Shrinks on press, fills while held. */
+ .ss-cursor__dot {
+ display: block;
+ width: 22px;
+ height: 22px;
+ margin: -11px 0 0 -11px;
+ /* Glass, like the iPadOS pointer: no outline, a translucent tint that
+ blurs and lifts what's under it, a soft top highlight and a faint shadow.
+ The blur goes while pressed (below): the dot then sits on the thing
+ being pressed (a checkbox, a row's words) and a blur smears it */
+ border-radius: 50%;
+ background: rgba(118, 118, 128, 0.26);
+ backdrop-filter: blur(5px) saturate(1.8) brightness(1.04);
+ box-shadow:
+ inset 0 1px 1px rgba(255, 255, 255, 0.4),
+ inset 0 -1px 1px rgba(0, 0, 0, 0.05),
+ 0 2px 10px rgba(0, 0, 0, 0.1);
+ transition: background 0.15s ease;
+ }
+
+ /* Over text the glass circle narrows into an I-beam bar, as the iPad pointer does */
+ .ss-cursor__dot {
+ transition: background 0.15s ease, width 0.2s cubic-bezier(0.22, 1, 0.36, 1), height 0.2s cubic-bezier(0.22, 1, 0.36, 1),
+ margin 0.2s cubic-bezier(0.22, 1, 0.36, 1), border-radius 0.2s ease;
+ }
+
+ .ss-cursor[data-style="circle"][data-kind="ibeam"] .ss-cursor__dot {
+ width: 4px;
+ height: 20px;
+ margin: -10px 0 0 -2px;
+ border-radius: 2px;
+ background: rgba(60, 60, 67, 0.55);
+ }
+
+ .ss-stage[data-theme='dark'] .ss-cursor__dot {
+ background: rgba(255, 255, 255, 0.2);
+ box-shadow:
+ inset 0 1px 1px rgba(255, 255, 255, 0.3),
+ 0 2px 10px rgba(0, 0, 0, 0.3);
+ }
+
+ .ss-cursor[data-style="circle"][data-pressed="true"] .ss-cursor__press {
+ transform: scale(0.8);
+ }
+
+ .ss-cursor[data-style="circle"][data-pressed="true"] .ss-cursor__dot {
+ backdrop-filter: none;
+ background: rgba(118, 118, 128, 0.22);
+ }
+
+ .ss-stage[data-theme='dark'] .ss-cursor[data-style="circle"][data-pressed="true"] .ss-cursor__dot {
+ background: rgba(255, 255, 255, 0.18);
+ }
+
+ .ss-cursor[data-style="circle"][data-pressed="true"] .ss-cursor__dot,
+ .ss-cursor[data-style="circle"][data-kind="grab"] .ss-cursor__dot {
+ background: rgba(118, 118, 128, 0.4);
+ }
+
+ /* Touch: nothing between taps. A press lands a soft dot that fades as the
+ finger lifts; a drag keeps it while held (the kind is grab then). */
+ .ss-cursor[data-style="touch"] .ss-cursor__dot {
+ width: 34px;
+ height: 34px;
+ margin: -17px 0 0 -17px;
+ background: rgba(118, 118, 128, 0.14);
+ /* A finger, not glass: no backdrop blur, which smears the checkbox or
+ row it presses */
+ backdrop-filter: none;
+ /* Not there until it touches: the script shows it just before a press
+ or a swipe and lets it go after the lift */
+ opacity: 0;
+ transform: scale(0.7);
+ transition: opacity 0.4s ease, transform 0.45s cubic-bezier(0.22, 1, 0.36, 1);
+ }
+
+ /* The trail a finger leaves while it swipes: one soft streak from where
+ it was a few frames ago to where it is, fading toward its tail. The
+ script sizes and turns it each frame; it fades once the finger lifts */
+ .ss-cursor__streak {
+ position: absolute;
+ left: 0;
+ top: -13px;
+ width: 0;
+ height: 26px;
+ border-radius: 13px;
+ background: linear-gradient(to right, rgba(118, 118, 128, 0), rgba(118, 118, 128, 0.16));
+ transform-origin: 100% 50%;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.16s ease;
+ }
+
+ .ss-cursor[data-swiping] .ss-cursor__streak {
+ opacity: 1;
+ transition: none;
+ }
+
+ .ss-cursor[data-style="touch"][data-touching] .ss-cursor__dot {
+ opacity: 1;
+ transform: scale(1);
+ transition: opacity 0.14s ease, transform 0.16s cubic-bezier(0.22, 1, 0.36, 1);
+ }
+
+ .ss-cursor[data-style="touch"][data-pressed="true"] .ss-cursor__dot,
+ .ss-cursor[data-style="touch"][data-swiping] .ss-cursor__dot {
+ background: rgba(118, 118, 128, 0.22);
+ transform: scale(0.82);
+ transition: background 0.08s ease, transform 0.1s ease-out;
+ }
+
+ .ss-cursor__glyph svg {
+ display: block;
+ filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.3));
+ }
+
+ /* Arrow cursor: one solid, rounded arrowhead */
+ .ss-cursor__arrow path {
+ fill: var(--ss-cursor-ink, #111);
+ stroke: var(--ss-cursor-ink, #111);
+ stroke-width: 2.4;
+ stroke-linejoin: round;
+ }
+
+ .ss-cursor__arrow {
+ display: block;
+ filter: drop-shadow(0 1px 1.5px rgba(0, 0, 0, 0.18));
+ }
+
+ .ss-stage[data-theme="dark"] .ss-cursor__arrow,
+ .ss-stage[data-theme="blueprint"] .ss-cursor__arrow {
+ --ss-cursor-ink: #fff;
+ }
+
+ /* Line cursors (I-beam, crosshair) invert on a dark surface, as macOS's do */
+ .ss-stage[data-theme="dark"] .ss-cursor,
+ .ss-stage[data-theme="blueprint"] .ss-cursor {
+ --ss-cursor-line: #fff;
+ --ss-cursor-edge: rgba(0, 0, 0, 0.75);
+ }
+
+ /* Ripple clicks: a ring spreads from the point and fades */
+ .ss-ripple {
+ position: absolute;
+ z-index: 99;
+ width: 36px;
+ height: 36px;
+ margin: -18px 0 0 -18px;
+ border-radius: 50%;
+ border: 1.5px solid var(--ss-accent);
+ background: color-mix(in srgb, var(--ss-accent) 10%, transparent);
+ pointer-events: none;
+ animation: ss-ripple 0.55s cubic-bezier(0.25, 0.8, 0.25, 1) forwards;
+ }
+
+ @keyframes ss-ripple {
+ from { transform: scale(0.2); opacity: 1; }
+ to { transform: scale(1); opacity: 0; }
+ }
+
+ .ss-stage[data-palette="mono"] .ss-ripple {
+ border-color: var(--ss-text-strong);
+ background: transparent;
+ }
+
+ .ss-stage[data-hit] :is(.ss-cursor, .ss-cursor *) {
+ pointer-events: none !important;
+ }
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/cursor.tsx b/skills/imported/benjitaylor/stageshow/kit/cursor.tsx
new file mode 100644
index 0000000..ffd3f99
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/cursor.tsx
@@ -0,0 +1,103 @@
+import type { ReactNode, Ref } from 'react'
+import { MacCrosshair, MacGrab, MacIbeam, MacOpenHand, MacPointer, MacPointingHand, MacResizeEW, MacResizeNESW, MacResizeNS, MacResizeNWSE, MacWait } from './cursors'
+import type { Point } from './measure'
+import './cursor.css'
+
+// The cursor a stage draws: the glyph for each kind, where its hotspot sits,
+// the five styles, and the ripple a click can send out.
+
+/** pointer: the arrow. hand: the pointing hand, for links and buttons. open: the
+ * open hand over something draggable. grab: the closed hand while it's held.
+ * resize-ns, resize-ew: over an edge that drags. resize-nwse, resize-nesw:
+ * over a corner (top-left or bottom-right, and top-right or bottom-left). */
+export type CursorKind = 'pointer' | 'ibeam' | 'crosshair' | 'hand' | 'open' | 'grab' | 'wait' | 'resize-ns' | 'resize-ew' | 'resize-nwse' | 'resize-nesw'
+/**
+ * real: the macOS pointer. large: the same, 1.5x, for sites whose demos run
+ * small. circle: the iPad's glass pointer, always showing. touch: a finger on
+ * a phone, shown only where it touches (a soft dot that lands on press,
+ * stays while a drag is held, and fades), invisible between taps. arrow: a
+ * solid, rounded arrowhead, as illustrated product demos draw the pointer; it
+ * stays an arrow over links and while dragging, and turns to an I-beam only
+ * over text.
+ */
+export type CursorStyle = 'real' | 'large' | 'circle' | 'touch' | 'arrow'
+
+const GLYPHS: Record ReactNode> = {
+ pointer: MacPointer,
+ ibeam: MacIbeam,
+ crosshair: MacCrosshair,
+ hand: MacPointingHand,
+ open: MacOpenHand,
+ grab: MacGrab,
+ wait: MacWait,
+ 'resize-ns': MacResizeNS,
+ 'resize-ew': MacResizeEW,
+ 'resize-nwse': MacResizeNWSE,
+ 'resize-nesw': MacResizeNESW,
+}
+
+// Where each glyph's hotspot sits inside its box, so the point is the click.
+const HOTSPOT: Record = {
+ pointer: { x: 7.5, y: 5.25 },
+ ibeam: { x: 5, y: 8 },
+ crosshair: { x: 8.5, y: 8.5 },
+ hand: { x: 9.75, y: 3.25 },
+ open: { x: 11.7, y: 10.4 },
+ grab: { x: 12, y: 12 },
+ wait: { x: 8, y: 8 },
+ 'resize-ns': { x: 10.5, y: 10.5 },
+ 'resize-ew': { x: 10.5, y: 10.5 },
+ 'resize-nwse': { x: 10.5, y: 10.5 },
+ 'resize-nesw': { x: 10.5, y: 10.5 },
+}
+
+// The arrow style's pointer: one solid, rounded arrowhead, tip at the hotspot
+const ARROW_HOTSPOT: Point = { x: 4, y: 3 }
+function SolidArrow() {
+ return (
+
+ )
+}
+
+/** The cursor element. The stage moves it (a transform on cursorRef) and marks it pressed or idle. */
+export function Cursor({ cursorRef, style = 'real', kind }: { cursorRef: Ref; style?: CursorStyle; kind: CursorKind }) {
+ return (
+
+ {style === 'touch' && }
+
+ {(style === 'circle' || style === 'touch') && }
+ {style !== 'circle' && style !== 'touch' && (Object.keys(GLYPHS) as CursorKind[]).map((k) => {
+ const arrow = style === 'arrow'
+ // The arrow style has two glyphs: the arrow, and the I-beam over text
+ if (arrow && k !== 'pointer' && k !== 'ibeam') return null
+ const Glyph = arrow && k === 'pointer' ? SolidArrow : GLYPHS[k]
+ const hot = arrow && k === 'pointer' ? ARROW_HOTSPOT : HOTSPOT[k]
+ const shown = arrow && kind !== 'ibeam' ? 'pointer' : kind
+ return (
+
+
+
+ )
+ })}
+
+
+ )
+}
+
+/** A ring that spreads from the click point and fades, drawn under the cursor. */
+export function ripple(root: HTMLElement, at: Point) {
+ const r = document.createElement('span')
+ r.className = 'ss-ripple'
+ r.style.left = `${at.x}px`
+ r.style.top = `${at.y}px`
+ root.appendChild(r)
+ r.addEventListener('animationend', () => r.remove())
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/cursors.tsx b/skills/imported/benjitaylor/stageshow/kit/cursors.tsx
new file mode 100644
index 0000000..8dd5b8d
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/cursors.tsx
@@ -0,0 +1,106 @@
+// macOS system cursors. Hotspots are listed in HOTSPOT in stage.tsx.
+// The hands draw a little larger than the arrow, as they do on macOS.
+
+export function MacPointer() {
+ return (
+
+ )
+}
+
+export function MacIbeam() {
+ return (
+
+ )
+}
+
+export function MacCrosshair() {
+ return (
+
+ )
+}
+
+// The pointing hand, for links and buttons: a white glove, index finger up,
+// the other three fingers folded, thumb along the left. Hotspot at the fingertip.
+export function MacPointingHand() {
+ return (
+
+ )
+}
+
+// The open hand: over something that can be dragged, before it's pressed.
+export function MacOpenHand() {
+ return (
+
+ )
+}
+
+export function MacGrab() {
+ return (
+
+ )
+}
+
+export function MacWait() {
+ return (
+
+ )
+}
+
+// The resize cursors, measured from macOS's own: two rounded black
+// triangles, a 6pt gap between them, each with a 1pt white edge. The pair
+// points north-south, and turns for east-west and the two diagonals. Drawn
+// at the pointer's scale (a 28pt box at 21px), hotspot at the center.
+const RESIZE_TIP = 'M0-8.4 2.55-3.65H-2.55ZM0 8.4 2.55 3.65H-2.55Z'
+function MacResize({ turn }: { turn: number }) {
+ return (
+
+ )
+}
+export const MacResizeNS = () =>
+export const MacResizeEW = () =>
+export const MacResizeNWSE = () =>
+export const MacResizeNESW = () =>
diff --git a/skills/imported/benjitaylor/stageshow/kit/ease.ts b/skills/imported/benjitaylor/stageshow/kit/ease.ts
new file mode 100644
index 0000000..38c4e72
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/ease.ts
@@ -0,0 +1,65 @@
+// Named curves, as numbers (for tweens on the clock) and as CSS. The video
+// ones are measured from product launch films: one house curve for moves
+// inside a shot, a steeper one for moves that end in a cut, and an expo-out
+// settle for the shot that picks the move up.
+
+export type Bezier = readonly [number, number, number, number]
+
+export const EASE = {
+ /** Moves inside a shot (push-in, pan, a long cursor trip): peak at about 40%, long soft tail. 1.4 to 2.7 s. */
+ house: [0.65, 0, 0.1, 1],
+ /** The outgoing half of a move that ends in a cut: accelerates into it. */
+ through: [0.8, 0, 0.1, 1],
+ /** The incoming half after a cut: arrives at speed, settles. 0.6 to 0.8 s (1.6 s for a big pull-out). */
+ settle: [0.05, 0.85, 0.3, 1],
+ /** A cursor arriving from off-frame, already moving. */
+ enter: [0.1, 0.8, 0.3, 0.95],
+ /** One long cursor trip across the frame. */
+ trip: [0.78, 0, 0.17, 1],
+ /** The kit's camera push-in (Camera). */
+ camera: [0.45, 0, 0.1, 1],
+} as const satisfies Record
+
+export type EaseName = keyof typeof EASE
+
+export const css = (e: EaseName | Bezier) => {
+ const [a, b, c, d] = typeof e === 'string' ? EASE[e] : e
+ return `cubic-bezier(${a}, ${b}, ${c}, ${d})`
+}
+
+/** cubic-bezier(x1, y1, x2, y2) as a function of time 0..1, as CSS works it out. */
+export function bezier(e: EaseName | Bezier): (t: number) => number {
+ const [x1, y1, x2, y2] = typeof e === 'string' ? EASE[e] : e
+ const cx = 3 * x1, bx = 3 * (x2 - x1) - cx, ax = 1 - cx - bx
+ const cy = 3 * y1, by = 3 * (y2 - y1) - cy, ay = 1 - cy - by
+ const x = (u: number) => ((ax * u + bx) * u + cx) * u
+ const dx = (u: number) => (3 * ax * u + 2 * bx) * u + cx
+ return (t: number) => {
+ if (t <= 0) return 0
+ if (t >= 1) return 1
+ // Newton, falling back to bisection where the slope is flat
+ let u = t
+ for (let i = 0; i < 8; i++) {
+ const d = dx(u)
+ if (Math.abs(d) < 1e-6) break
+ const next = u - (x(u) - t) / d
+ if (next < 0 || next > 1) break
+ u = next
+ }
+ if (Math.abs(x(u) - t) > 1e-6) {
+ let lo = 0, hi = 1
+ for (let i = 0; i < 40; i++) {
+ u = (lo + hi) / 2
+ if (x(u) < t) lo = u
+ else hi = u
+ }
+ }
+ return ((ay * u + by) * u + cy) * u
+ }
+}
+
+/** The curve's slope as it starts (progress per unit time at t = 0). */
+export const startSlope = (e: EaseName | Bezier) => {
+ const [x1, y1] = typeof e === 'string' ? EASE[e] : e
+ return x1 > 0 ? y1 / x1 : 1e3
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/index.ts b/skills/imported/benjitaylor/stageshow/kit/index.ts
new file mode 100644
index 0000000..c5bad77
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/index.ts
@@ -0,0 +1,45 @@
+// Components import their own CSS, so no separate style import is required.
+// Which unused styles are removed depends on the host bundler.
+// kit/stageshow.css explicitly loads every kit style up front.
+
+// The stage: the canvas, the script, the cursor, measuring and playback
+export { Stage } from './stage'
+export { useLook } from './looks'
+export { useReducedMotion } from './reduced-motion'
+export { inset, box, between, contains } from './measure'
+export type { StageProps, Play, LookTokens } from './stage'
+export type { Act, Script } from './act'
+export type { Point, Rect, Target } from './measure'
+export type { CursorKind, CursorStyle } from './cursor'
+export type { Theme, Palette, Fidelity, Layout } from './looks'
+export type { KeysAt } from './keys'
+export { clock, installVirtualClock } from './clock'
+export type { ClockControl } from './clock'
+
+// Frames: windows, devices and system surfaces, from real geometry
+export { Window, Bar, Text, Prompt, Caret } from './window'
+export type { WindowProps, BarProps, TextProps, PromptProps } from './window'
+export { Phone, LockScreen, Notification, WALLPAPER } from './phone'
+export type { PhoneProps, PhoneFrame, LockScreenProps, NotificationProps } from './phone'
+export { Keyboard } from './ios'
+export type { KeyboardProps } from './ios'
+export { Thread, Bubble, Stamp, Receipt } from './messages'
+export type { ThreadProps, BubbleProps } from './messages'
+
+// Motion: behaviors that have to hold up frame by frame (motion/, one file per part)
+export { Connector, Count, Roll, Swap, Collapse, Fly, Follow, cellOf, States, Stream, Popover, Camera, Scroll, Expand } from './motion'
+export type { ConnectorProps, CountProps, FollowProps, FlyProps, PopoverOrigin } from './motion'
+export { EASE, bezier, css as easeCss } from './ease'
+export type { EaseName, Bezier } from './ease'
+
+// Small parts: starting points with quiet defaults; restyle them as the product does (parts/, controls/)
+export { Avatar, Check, Icon, Spinner, Toast, Tooltip, Meter, Menu, Facepile, Chip, BrandMark, Pin, Row } from './parts'
+export type { AvatarProps, IconName, BrandMarkProps, PinProps, RowProps } from './parts'
+export { brandMarks } from './brands'
+export type { BrandName, BrandAsset } from './brands'
+export { Field, Segmented, Toggle, Tabs, Badge, Slot } from './controls'
+export type { FieldProps } from './controls'
+export { Code } from './code'
+export type { CodeLine } from './code'
+export { Chart, chartPoints } from './chart'
+export type { ChartProps } from './chart'
diff --git a/skills/imported/benjitaylor/stageshow/kit/ios.css b/skills/imported/benjitaylor/stageshow/kit/ios.css
new file mode 100644
index 0000000..124915f
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/ios.css
@@ -0,0 +1,177 @@
+/* The iOS keyboard (ios.tsx). */
+
+@layer stageshow {
+ /* ---- iOS keyboard (ios.tsx) ----
+ iOS 27, at true points inside a Phone. Measured from iOS 27 screenshots:
+ keys 31.8 × 41.6 with 5.8 gaps on a 52.6 row pitch, special keys the
+ letters' color, a 123 / space / return row of 88 / 182 / 88. */
+ .ss-kb {
+ --ss-kb-sheet: #e3e4e9;
+ --ss-kb-key: #fff;
+ --ss-kb-down: #c4c6cd;
+ --ss-kb-ink: #000;
+ --ss-kb-faint: #3c3c43;
+ /* Nearly the screen's full width: 3pt in from each edge */
+ position: relative;
+ flex: none;
+ margin: 0 3px 4px;
+ padding: 0 3px 0;
+ border-radius: 28px 28px 52px 52px;
+ background: color-mix(in srgb, var(--ss-kb-sheet) 94%, transparent);
+ box-shadow: inset 0 0.5px 0 rgba(255, 255, 255, 0.7), 0 -0.5px 0 rgba(0, 0, 0, 0.06);
+ color: var(--ss-kb-ink);
+ font-family: -apple-system, 'SF Pro Text', system-ui, sans-serif;
+ user-select: none;
+ }
+
+ .ss-kb[data-dark] {
+ --ss-kb-sheet: #2a2a2c;
+ --ss-kb-key: #4a4a4c;
+ --ss-kb-down: #6b6b6e;
+ --ss-kb-ink: #fff;
+ --ss-kb-faint: #ebebf5;
+ box-shadow: inset 0 0.5px 0 rgba(255, 255, 255, 0.1);
+ }
+
+ .ss-kb__suggest {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ align-items: center;
+ height: 50px;
+ font-size: 17px;
+ letter-spacing: -0.01em;
+ text-align: center;
+ }
+
+ .ss-kb__suggest span + span {
+ border-left: 1px solid color-mix(in srgb, var(--ss-kb-faint) 22%, transparent);
+ }
+
+ .ss-kb__row {
+ /* One key: ten keys and nine gaps fill the row, on every row */
+ --k: calc((100% - 54px) / 10);
+ display: flex;
+ justify-content: center;
+ gap: 6px;
+ height: 42.5px;
+ margin-bottom: 11px;
+ }
+
+ .ss-kb__key {
+ position: relative;
+ display: grid;
+ flex: none;
+ place-items: center;
+ width: var(--k);
+ border-radius: 8.5px;
+ background: var(--ss-kb-key);
+ font-size: 23px;
+ line-height: 1;
+ transition: background-color 0.12s ease;
+ }
+
+ .ss-kb[data-dark] .ss-kb__key {
+ font-weight: 400;
+ }
+
+ .ss-kb__key[data-down] {
+ background: var(--ss-kb-down);
+ transition: none;
+ }
+
+ /* Letters show their callout instead of darkening */
+ .ss-kb__row:not(.ss-kb__row--bottom) .ss-kb__key:not(.ss-kb__key--wide)[data-down] {
+ background: var(--ss-kb-key);
+ }
+
+ .ss-kb__key--wide {
+ width: calc(var(--k) * 1.36);
+ }
+
+ .ss-kb__row[data-row="2"] > .ss-kb__key--wide:first-child {
+ margin-right: 7.2px;
+ }
+
+ .ss-kb__row[data-row="2"] > .ss-kb__key--wide:last-child {
+ margin-left: 7.2px;
+ }
+
+ .ss-kb__key--mode {
+ width: calc(var(--k) * 2.78);
+ font-size: 17px;
+ }
+
+ .ss-kb__key--space {
+ flex: 1;
+ width: auto;
+ }
+
+ /* The magnified letter that rises out of a pressed key */
+ .ss-kb__callout {
+ position: absolute;
+ bottom: 30px;
+ left: 50%;
+ display: grid;
+ place-items: center;
+ width: 145%;
+ height: 54px;
+ padding-bottom: 6px;
+ border-radius: 12px;
+ background: var(--ss-kb-key);
+ box-shadow: 0 1px 6px rgba(0, 0, 0, 0.16);
+ font-size: 34px;
+ transform: translateX(-50%);
+ pointer-events: none;
+ z-index: 2;
+ }
+
+ /* Joins the balloon to its key, over the balloon's shadow */
+ .ss-kb__callout::after {
+ content: '';
+ position: absolute;
+ top: 100%;
+ left: 50%;
+ /* The key's own width (the balloon is 145% of it) */
+ width: 69%;
+ height: 14px;
+ margin-top: -4px;
+ background: var(--ss-kb-key);
+ transform: translateX(-50%);
+ }
+
+ .ss-kb__row:first-of-type .ss-kb__key:first-child .ss-kb__callout,
+ .ss-kb__row[data-row="0"] .ss-kb__key:first-child .ss-kb__callout {
+ left: 0;
+ transform: none;
+ }
+
+ .ss-kb__row[data-row="0"] .ss-kb__key:first-child .ss-kb__callout::after {
+ left: 0;
+ transform: none;
+ }
+
+ .ss-kb__row[data-row="0"] .ss-kb__key:last-child .ss-kb__callout {
+ left: auto;
+ right: 0;
+ transform: none;
+ }
+
+ .ss-kb__row[data-row="0"] .ss-kb__key:last-child .ss-kb__callout::after {
+ left: auto;
+ right: 0;
+ transform: none;
+ }
+
+ .ss-kb__row--bottom {
+ margin-bottom: 0;
+ }
+
+ .ss-kb__below {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ height: 72px;
+ padding: 0 22px 6px;
+ color: var(--ss-kb-faint);
+ }
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/ios.tsx b/skills/imported/benjitaylor/stageshow/kit/ios.tsx
new file mode 100644
index 0000000..22a4ed8
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/ios.tsx
@@ -0,0 +1,137 @@
+import { useEffect, useRef, useState, type CSSProperties } from 'react'
+import './ios.css'
+
+// The iOS 27 keyboard, at true points inside a Phone: the Liquid Glass sheet
+// with its predictive bar, softly rounded keys with wide gaps, special keys
+// the same color as the letters, a ↵ return key, and the emoji and dictation
+// buttons below the keys. Measured from iOS 27 screenshots.
+
+const ROWS = ['qwertyuiop', 'asdfghjkl', 'zxcvbnm']
+
+export interface KeyboardProps {
+ /**
+ * The text typed so far. Each new character pops its key, and shift follows
+ * iOS's auto-capitalisation (on at the start and after a sentence ends).
+ */
+ typed?: string
+ /** Force shift on or off instead of following the text. */
+ shift?: boolean
+ /** The three words in the predictive bar. */
+ suggestions?: [string, string, string]
+ /** A key held down: a letter, or 'shift', 'delete', 'return', 'space', '123'. */
+ pressed?: string | null
+ dark?: boolean
+ className?: string
+ style?: CSSProperties
+}
+
+function Glyph({ name }: { name: 'shift' | 'shift-on' | 'delete' | 'return' | 'emoji' | 'mic' }) {
+ const common = { fill: 'none', stroke: 'currentColor', strokeWidth: 1.6, strokeLinejoin: 'round' as const, strokeLinecap: 'round' as const }
+ switch (name) {
+ case 'shift':
+ case 'shift-on':
+ return (
+
+ )
+ case 'delete':
+ return (
+
+ )
+ case 'return':
+ return (
+
+ )
+ case 'emoji':
+ return (
+
+ )
+ case 'mic':
+ return (
+
+ )
+ }
+}
+
+/**
+ * The iOS keyboard. Put it at the bottom of a Phone's screen, under the
+ * composer; pass `typed` and it pops each key as the text grows.
+ */
+export function Keyboard({ typed = '', shift, suggestions = ['I', 'The', 'I’m'], pressed, dark, className, style }: KeyboardProps) {
+ // The key that just landed, shown in its callout for a moment
+ const [pop, setPop] = useState<{ ch: string; n: number } | null>(null)
+ const last = useRef(typed)
+ useEffect(() => {
+ const was = last.current
+ last.current = typed
+ if (typed.length <= was.length || !typed.startsWith(was)) return
+ const ch = typed[typed.length - 1].toLowerCase()
+ setPop((p) => ({ ch, n: (p?.n ?? 0) + 1 }))
+ const id = setTimeout(() => setPop(null), 140)
+ return () => clearTimeout(id)
+ }, [typed])
+ const auto = typed === '' || /[.!?]\s$/.test(typed)
+ const upper = shift ?? auto
+ const down = (k: string) => pressed === k || (k.length === 1 && pop?.ch === k)
+ return (
+
+ )
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/keys.css b/skills/imported/benjitaylor/stageshow/kit/keys.css
new file mode 100644
index 0000000..a6b39e9
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/keys.css
@@ -0,0 +1,60 @@
+/* Keystrokes (act.key): the keycap rows. */
+
+@layer stageshow {
+ .ss-keys {
+ position: absolute;
+ bottom: 16px;
+ left: 50%;
+ z-index: 99;
+ display: flex;
+ gap: 4px;
+ opacity: 0;
+ transform: translate(-50%, 6px) scale(0.96);
+ transition: opacity 0.16s ease, transform 0.2s cubic-bezier(0.22, 1, 0.36, 1);
+ pointer-events: none;
+ }
+
+ .ss-keys[data-show="true"] {
+ opacity: 1;
+ transform: translate(-50%, 0) scale(1);
+ }
+
+ .ss-keys[data-at="top"] {
+ top: 16px;
+ bottom: auto;
+ }
+
+ .ss-keys[data-at="bottom-left"],
+ .ss-keys[data-at="bottom-right"] {
+ left: auto;
+ transform: translateY(6px) scale(0.96);
+ }
+
+ .ss-keys[data-at="bottom-left"] { left: 16px; }
+
+ .ss-keys[data-at="bottom-right"] { right: 16px; }
+
+ .ss-keys[data-at="bottom-left"][data-show="true"],
+ .ss-keys[data-at="bottom-right"][data-show="true"] {
+ transform: none;
+ }
+
+ .ss-key {
+ display: grid;
+ place-items: center;
+ min-width: 28px;
+ height: 28px;
+ padding: 0 8px;
+ border-radius: 7px;
+ background: rgba(24, 24, 27, 0.88);
+ box-shadow: inset 0 -2px 0 rgba(0, 0, 0, 0.4), 0 4px 12px rgba(0, 0, 0, 0.18);
+ color: #fff;
+ font: 500 13px/1 var(--ss-font);
+ transition: transform 0.08s ease-out, box-shadow 0.08s ease-out;
+ }
+
+ .ss-key[data-down="true"] {
+ transform: translateY(1px) scale(0.94);
+ box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.4), 0 2px 6px rgba(0, 0, 0, 0.18);
+ }
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/keys.tsx b/skills/imported/benjitaylor/stageshow/kit/keys.tsx
new file mode 100644
index 0000000..3d1d28a
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/keys.tsx
@@ -0,0 +1,36 @@
+import './keys.css'
+
+// Keystrokes shown as keycaps (act.key): the caps for a combo, and the rows
+// the stage draws them in.
+
+/** Where keycaps show. Give `{ wide, compact }` to place them per layout. */
+export type KeysAt = 'bottom' | 'top' | 'bottom-left' | 'bottom-right'
+
+// Each keystroke is its own row of caps, so one still fading out never
+// changes or recenters when the next arrives
+export type Keys = { id: number; caps: string[]; show: boolean; down: boolean }
+
+const KEYCAPS: Record = { cmd: '⌘', shift: '⇧', alt: '⌥', option: '⌥', ctrl: '⌃', enter: '↵', return: '↵', esc: 'esc', tab: '⇥', up: '↑', down: '↓', left: '←', right: '→', backspace: '⌫' }
+
+/** 'cmd+k' as caps: ['⌘', 'K']. */
+export const capsOf = (combo: string) => combo.split('+').map((k) => KEYCAPS[k.trim().toLowerCase()] ?? k.trim().toUpperCase())
+
+/** The last row (fading out) and the current one. */
+export function Keycaps({ rows, at }: { rows: (Keys | null)[]; at: KeysAt }) {
+ return (
+ <>
+ {rows.map(
+ (row) =>
+ row && (
+
+ {row.caps.map((c, i) => (
+
+ {c}
+
+ ))}
+
+ ),
+ )}
+ >
+ )
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/looks.ts b/skills/imported/benjitaylor/stageshow/kit/looks.ts
new file mode 100644
index 0000000..b637c7d
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/looks.ts
@@ -0,0 +1,23 @@
+import { createContext, useContext } from 'react'
+
+// The look a stage sets for everything inside it: theme, palette, fidelity,
+// and which of its designs (wide or compact) is showing. provides it;
+// parts read it with useLook().
+
+/** glass: frosted, translucent surfaces over a soft color field. */
+export type Theme = 'light' | 'dark' | 'blueprint' | 'glass'
+export type Palette = 'accent' | 'mono'
+/** minimal: no text, only shapes. standard: real text where it carries meaning. realistic: real text, logos and avatars everywhere. */
+export type Fidelity = 'minimal' | 'standard' | 'realistic'
+/** wide: the design size. compact: the smaller design used below `compact.below`. */
+export type Layout = 'wide' | 'compact'
+
+export type Look = { theme: Theme; palette: Palette; fidelity: Fidelity; layout: Layout }
+
+export const LookContext = createContext({
+ theme: 'light',
+ palette: 'accent',
+ fidelity: 'standard',
+ layout: 'wide',
+})
+export const useLook = () => useContext(LookContext)
diff --git a/skills/imported/benjitaylor/stageshow/kit/measure.ts b/skills/imported/benjitaylor/stageshow/kit/measure.ts
new file mode 100644
index 0000000..27880ce
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/measure.ts
@@ -0,0 +1,122 @@
+// Measuring: where things are, in stage pixels. Targets and rects for the
+// script API, the drawn scale that turns screen pixels into stage pixels,
+// and the rect helpers demos use to place overlays from measured targets.
+
+export type Point = { x: number; y: number }
+export type Rect = { x: number; y: number; width: number; height: number }
+/**
+ * A target is the name in an element's data-target (aimed at its center), a
+ * named element with x and y as fractions of its box ({ at: 'apply', x: 0.8, y: 0.7 }),
+ * or a point in stage pixels.
+ */
+export type Target = string | Point | { at: string; x: number; y: number } | { wide: Target; compact: Target }
+
+export const EMPTY: Rect = { x: 0, y: 0, width: 0, height: 0 }
+
+export const nameOf = (t: Target) => (typeof t === 'string' ? t : 'at' in t ? t.at : null)
+
+/** Rect helpers for positioning overlays from measured targets. */
+export const inset = (r: Rect, by: number): Rect => ({ x: r.x - by, y: r.y - by, width: r.width + by * 2, height: r.height + by * 2 })
+export const box = (r: Rect) => ({ left: r.x, top: r.y, width: r.width, height: r.height })
+export const between = (a: Point, b: Point): Rect => ({
+ x: Math.min(a.x, b.x),
+ y: Math.min(a.y, b.y),
+ width: Math.abs(b.x - a.x),
+ height: Math.abs(b.y - a.y),
+})
+export const contains = (outer: Rect, inner: Rect) =>
+ inner.x >= outer.x && inner.y >= outer.y && inner.x + inner.width <= outer.x + outer.width && inner.y + inner.height <= outer.y + outer.height
+
+// Stage pixels from screen pixels. The scale is measured as the stage is drawn
+// now (its rendered width over its layout width), not the fit's own scale, so
+// a stage inside anything else that scales it (a camera, a zoomed card) still
+// aims true
+export const drawnScale = (root: HTMLElement | null, fallback: number) => (root?.offsetWidth && root.getBoundingClientRect().width / root.offsetWidth) || fallback || 1
+
+// Reuse one stage read while measuring a collection of targets. A fresh
+// snapshot on each pass still includes parent Camera/Phone transforms.
+export function stageSpace(root: HTMLElement, fallback: number) {
+ const bounds = root.getBoundingClientRect()
+ return { left: bounds.left, top: bounds.top, scale: (root.offsetWidth && bounds.width / root.offsetWidth) || fallback || 1, borderX: root.clientLeft, borderY: root.clientTop }
+}
+
+export function rectOf(root: HTMLElement, el: HTMLElement, fallback: number, space = stageSpace(root, fallback)): Rect {
+ const b = el.getBoundingClientRect()
+ const { scale } = space
+ // From the inside of the stage's border, where its absolute children are placed
+ return { x: (b.left - space.left) / scale - space.borderX, y: (b.top - space.top) / scale - space.borderY, width: b.width / scale, height: b.height / scale }
+}
+
+// A target's box as rect() hands it to the scene: where it is drawn, but
+// without a scale of its own (a button's press, a hover lift). Anything
+// placed from it (a popover under a button) would otherwise shift by a
+// fraction of a pixel for every press and settle back after
+export function targetRectOf(root: HTMLElement, el: HTMLElement, fallback: number, space = stageSpace(root, fallback)): Rect {
+ const r = rectOf(root, el, fallback, space)
+ const style = getComputedStyle(el)
+ const t = style.transform
+ const m = t && t !== 'none' ? t.match(/^matrix\(([^)]+)\)$/) : null
+ if (!m) return r
+ const [a, b, c, d, e, f] = m[1].split(',').map(Number)
+ if (b || c || e || f || !a || !d) return r
+ // Unscaled from the drawn box, not offsetWidth, which rounds to whole pixels
+ const w = r.width / a
+ const h = r.height / d
+ const [ox, oy] = style.transformOrigin.split(' ').map(parseFloat)
+ return { x: r.x - ox * (1 - a), y: r.y - oy * (1 - d), width: w, height: h }
+}
+
+// The untransformed layout box: offsets up to the stage, ignoring any scale
+// or slide an element is part way through. When nothing between the element
+// and the stage is transformed, the drawn box is the layout box, measured
+// exactly; offsets round to whole pixels. Otherwise it walks the offsets. An
+// offset is measured from the inside of its parent's border and before its
+// scroll, so each parent's border is added and its scroll taken off on the way
+// up: without that, an element in a bordered box measured a pixel up and left,
+// and whatever landed on it (a flyer) jumped that pixel on the handoff.
+const transformed = (n: Element) => {
+ const c = getComputedStyle(n)
+ return c.transform !== 'none' || (c.translate && c.translate !== 'none') || (c.scale && c.scale !== 'none') || (c.rotate && c.rotate !== 'none')
+}
+export function layoutRectOf(root: HTMLElement, el: HTMLElement): Rect {
+ let still = true
+ for (let a: Element | null = el; a && a !== root; a = a.parentElement) if (transformed(a)) { still = false; break }
+ if (still) return rectOf(root, el, 1)
+ let x = 0
+ let y = 0
+ let n: HTMLElement | null = el
+ while (n && n !== root) {
+ x += n.offsetLeft
+ y += n.offsetTop
+ const parent: Element | null = n.offsetParent
+ if (!parent || !root.contains(parent)) break
+ n = parent as HTMLElement
+ if (n !== root) {
+ x += n.clientLeft - n.scrollLeft
+ y += n.clientTop - n.scrollTop
+ }
+ }
+ return { x, y, width: el.offsetWidth, height: el.offsetHeight }
+}
+
+export function sameRects(a: Record, b: Record) {
+ const ka = Object.keys(a)
+ if (ka.length !== Object.keys(b).length) return false
+ return ka.every((k) => {
+ const x = a[k]
+ const y = b[k]
+ return y && Math.abs(x.x - y.x) < 0.5 && Math.abs(x.y - y.y) < 0.5 && Math.abs(x.width - y.width) < 0.5 && Math.abs(x.height - y.height) < 0.5
+ })
+}
+
+/** Places a Scroll's bar thumb for the element's current scroll position. */
+export function thumb(el: HTMLElement) {
+ const wrap = el.parentElement
+ if (!wrap?.classList.contains('ss-scroller')) return
+ const h = el.clientHeight
+ const total = el.scrollHeight
+ const size = total > h ? Math.max(24, (h / total) * h) : 0
+ const y = total > h ? (el.scrollTop / (total - h)) * (h - size) : 0
+ wrap.style.setProperty('--ss-thumb-h', `${size}px`)
+ wrap.style.setProperty('--ss-thumb-y', `${y}px`)
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/messages.css b/skills/imported/benjitaylor/stageshow/kit/messages.css
new file mode 100644
index 0000000..c540195
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/messages.css
@@ -0,0 +1,462 @@
+/* Messages (messages.tsx): Thread, Bubble, Stamp, Receipt and the chat look. */
+
+@layer stageshow {
+ .ss-thread {
+ --ss-thread-size: 15px;
+ --ss-me: #0a84ff;
+ --ss-me-text: #fff;
+ --ss-them: #e9e9eb;
+ --ss-them-text: #000;
+ /* One curve for everything that scrolls, so the thread moves as one */
+ --ss-scroll: 0.55s cubic-bezier(0.25, 1, 0.5, 1);
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-end;
+ gap: 3px;
+ /* Never grows past the room it's given: a full conversation overflows its
+ top (under the header) instead of pushing the composer and keyboard down */
+ min-height: 0;
+ /* Clips only what scrolls off the top: a bubble forming out of the composer
+ below, or a tail or badge at the sides, stays visible */
+ clip-path: inset(0 -100vw -100vh -100vw);
+ font-size: var(--ss-thread-size);
+ line-height: 1.3;
+ letter-spacing: -0.01em;
+ }
+
+ /* Inside a phone, messages are at true iOS size */
+ .ss-phone__content .ss-thread {
+ --ss-thread-size: 17px;
+ }
+
+ /* Inside a phone the stamps and receipts scale with the bubbles: a phone
+ keeps them readable (iOS draws them at 11pt, which under a 300px phone
+ falls below 10px on the page) */
+ .ss-phone__content .ss-stamp,
+ .ss-phone__content .ss-receipt {
+ font-size: 14px;
+ }
+
+ .ss-thread[data-app="sms"] {
+ --ss-me: #34c759;
+ }
+
+ .ss-stage[data-theme="dark"] .ss-thread,
+ .ss-phone[data-dark] .ss-thread {
+ --ss-them: #26252a;
+ --ss-them-text: #fff;
+ }
+
+ /* Rows glide on top, each on a layer it keeps, so a bubble and its words
+ move as one picture (alone, the words step whole pixels inside the
+ gliding bubble). top is set (not auto) because auto can't be animated */
+ .ss-bubble-row {
+ position: relative;
+ top: 0;
+ will-change: transform;
+ display: flex;
+ flex: none;
+ transition: top var(--ss-scroll);
+ }
+
+ .ss-bubble-row[data-from="me"] {
+ justify-content: flex-end;
+ }
+
+ /* A gap between runs, a hairline within one */
+ .ss-bubble-row[data-from="me"] + .ss-bubble-row[data-from="them"],
+ .ss-bubble-row[data-from="them"] + .ss-bubble-row[data-from="me"] {
+ margin-top: 8px;
+ }
+
+ .ss-bubble {
+ position: relative;
+ max-width: 75%;
+ padding: 0.42em 0.76em;
+ top: 0;
+ left: 0;
+ border-radius: 1.06em;
+ isolation: isolate;
+ color: var(--t);
+ overflow-wrap: break-word;
+ transition: width 0.35s cubic-bezier(0.22, 1, 0.36, 1), height 0.35s cubic-bezier(0.22, 1, 0.36, 1);
+ }
+
+ /* The fill is its own layer, so it can grow from the tail corner while the
+ text only rises and fades: text that ends a scale hops as it lands */
+ .ss-bubble::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ z-index: -1;
+ border-radius: inherit;
+ background: var(--b);
+ transform-origin: inherit;
+ transition: opacity 0.3s ease 0.06s;
+ }
+
+ /* A received bubble in a group chat carries the speaker's avatar beside
+ it, sat on the bubble's bottom edge as Messages draws it */
+ .ss-bubble-row[data-avatar] {
+ align-items: flex-end;
+ }
+
+ .ss-bubble__avatar {
+ display: inline-flex;
+ flex: none;
+ margin-right: 8px;
+ transition: margin-bottom 0.45s cubic-bezier(0.22, 1, 0.36, 1);
+ }
+
+ /* Beside a reacted bubble the avatar stays on the bubble's bottom edge,
+ not on the room the reaction opened under it */
+ .ss-bubble-row:has(.ss-bubble[data-tapback]) .ss-bubble__avatar {
+ margin-bottom: 21px;
+ }
+
+ /* A reaction: the emoji in a small white pill on the bubble's bottom
+ corner, on the tail's side, overlapping the edge the way every chat app
+ draws one. The bubble makes room below it. It pops in from nothing. */
+ .ss-bubble[data-tapback] {
+ margin-bottom: 21px;
+ }
+
+ /* The room under the bubble opens on the same clock as the reaction
+ growing out of its edge, so the thread moves with it, not after it; it
+ closes only once the reaction has gone */
+ .ss-bubble {
+ transition: width 0.35s cubic-bezier(0.22, 1, 0.36, 1), height 0.35s cubic-bezier(0.22, 1, 0.36, 1), margin-bottom 0.3s cubic-bezier(0.22, 1, 0.36, 1) 0.12s;
+ }
+
+ .ss-bubble[data-tapback] {
+ transition: width 0.35s cubic-bezier(0.22, 1, 0.36, 1), height 0.35s cubic-bezier(0.22, 1, 0.36, 1), margin-bottom 0.45s cubic-bezier(0.22, 1, 0.36, 1);
+ }
+
+ /* A true circle, overlapping only the bubble's bottom padding (6px), never
+ its last line of words; the emoji is centered in it */
+ .ss-tapback {
+ position: absolute;
+ bottom: -17px;
+ left: 8px;
+ z-index: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ padding: 0;
+ border-radius: 50%;
+ background: var(--ss-thread-ground, #fff);
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08), 0 0 0 0.5px rgba(0, 0, 0, 0.05);
+ font-size: 13px;
+ line-height: 1;
+ pointer-events: none;
+ transform-origin: 50% 50%;
+ transform: scale(0);
+ transition: transform 0.45s cubic-bezier(0.22, 1.4, 0.36, 1), opacity 0.1s ease;
+ }
+
+ .ss-bubble-row[data-from="me"] .ss-tapback {
+ left: auto;
+ right: 8px;
+ }
+
+ .ss-stage[data-theme="dark"] .ss-tapback {
+ background: var(--ss-thread-ground, #2c2c2e);
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
+ }
+
+ .ss-tapback[data-show] {
+ transform: scale(1);
+ }
+
+ .ss-tapback:not([data-show]) {
+ opacity: 0;
+ transform: scale(0.5);
+ transition: transform 0.16s ease, opacity 0.14s ease;
+ }
+
+ .ss-tapback__emoji {
+ display: block;
+ width: 1em;
+ height: 1em;
+ line-height: 1;
+ text-align: center;
+ }
+
+ /* Only arrivals rise in from their tail corner */
+ .ss-bubble[data-arrived] {
+ animation: ss-bubble-in 0.45s cubic-bezier(0.22, 1, 0.36, 1) both;
+ }
+
+ .ss-bubble[data-arrived]::before {
+ animation: ss-bubble-fill 0.45s cubic-bezier(0.22, 1, 0.36, 1) both;
+ }
+
+ /* A bubble with an origin forms out of the composer instead (in messages.tsx) */
+ .ss-bubble[data-arrived][data-source],
+ .ss-bubble[data-arrived][data-source]::before {
+ animation: none;
+ }
+
+ /* In flight from the composer: words only, the fill comes in as it travels */
+ .ss-bubble[data-flying]::before {
+ opacity: 0;
+ transition: none;
+ }
+
+ /* Its tail grows in once it has landed */
+ .ss-bubble[data-arrived][data-source][data-tail] > .ss-bubble__tail {
+ animation-delay: 0.32s;
+ }
+
+ [data-from="me"] > .ss-bubble {
+ --b: var(--ss-me);
+ --t: var(--ss-me-text);
+ transform-origin: 100% 100%;
+ }
+
+ [data-from="them"] > .ss-bubble {
+ --b: var(--ss-them);
+ --t: var(--ss-them-text);
+ transform-origin: 0 100%;
+ }
+
+ @keyframes ss-bubble-in {
+ from {
+ opacity: 0;
+ top: 6px;
+ }
+ }
+
+ @keyframes ss-bubble-fill {
+ from {
+ transform: scale(0.8);
+ }
+ }
+
+ /* The morph clips its own text while the box grows, but only on the sides
+ that grow: the tail, just outside the tail corner, stays where it is */
+ [data-from="them"] > .ss-bubble[style*="width"] {
+ clip-path: inset(0 0 -1em -1em);
+ }
+
+ [data-from="me"] > .ss-bubble[style*="width"] {
+ clip-path: inset(0 -1em -1em 0);
+ }
+
+ .ss-bubble__text {
+ display: block;
+ }
+
+ /* An arriving bubble brings its words with it, from the first frame. Words
+ growing out of the typing dots fade in while the box grows, never after it,
+ so there is no empty pill */
+ .ss-bubble[data-grew] > .ss-bubble__text {
+ animation: ss-fade-in 0.28s ease both;
+ }
+
+ .ss-bubble__tail {
+ position: absolute;
+ bottom: 0;
+ width: 1.06em;
+ height: 0.94em;
+ fill: var(--b);
+ pointer-events: none;
+ /* Retracted into the bubble's corner unless this bubble closes its run */
+ opacity: 0;
+ transform: scale(0.2);
+ transition: transform 0.3s cubic-bezier(0.25, 1, 0.5, 1), opacity 0.18s ease;
+ }
+
+ .ss-bubble[data-tail] > .ss-bubble__tail {
+ opacity: 1;
+ transform: none;
+ }
+
+ [data-from="me"] > .ss-bubble > .ss-bubble__tail {
+ right: -0.36em;
+ transform-origin: 15% 85%;
+ }
+
+ [data-from="them"] > .ss-bubble > .ss-bubble__tail {
+ left: -0.36em;
+ transform-origin: 85% 85%;
+ }
+
+ /* An arriving bubble's tail grows out once the bubble has nearly landed.
+ Backwards fill only, so the transition above still runs when it retracts */
+ .ss-bubble[data-arrived][data-tail] > .ss-bubble__tail {
+ animation: ss-tail-in 0.32s cubic-bezier(0.25, 1, 0.5, 1) 0.2s backwards;
+ }
+
+ @keyframes ss-tail-in {
+ from { opacity: 0; transform: scale(0.2); }
+ }
+
+ .ss-bubble__bar {
+ display: inline-block;
+ height: 0.4em;
+ border-radius: 0.2em;
+ background: currentColor;
+ opacity: 0.35;
+ vertical-align: middle;
+ }
+
+ /* Exactly one line tall (0.84em of padding + 1.3em of line), so a one-line
+ reply grows only sideways and nothing above it moves */
+ .ss-bubble[data-typing] {
+ padding: 0.84em 0.8em;
+ }
+
+ .ss-bubble__dots {
+ display: flex;
+ gap: 0.24em;
+ }
+
+ .ss-bubble__dots i {
+ width: 0.46em;
+ height: 0.46em;
+ border-radius: 50%;
+ background: #8e8e93;
+ animation: ss-typing 1.2s ease-in-out infinite;
+ }
+
+ .ss-bubble__dots i:nth-child(2) {
+ animation-delay: 0.16s;
+ }
+
+ .ss-bubble__dots i:nth-child(3) {
+ animation-delay: 0.32s;
+ }
+
+ @keyframes ss-typing {
+ 0%, 60%, 100% {
+ opacity: 0.4;
+ transform: none;
+ }
+ 30% {
+ opacity: 1;
+ transform: translateY(-0.12em);
+ }
+ }
+
+ /* Stamps glide with the thread like the bubbles do (the receipt's own
+ transition below includes top too) */
+ .ss-stamp {
+ position: relative;
+ top: 0;
+ will-change: transform;
+ transition: top var(--ss-scroll);
+ }
+
+ .ss-stamp {
+ flex: none;
+ margin: 10px 0 6px;
+ color: #8e8e93;
+ font-size: 0.7em;
+ font-weight: 500;
+ text-align: center;
+ }
+
+ .ss-receipt {
+ display: grid;
+ flex: none;
+ grid-template-rows: 0fr;
+ justify-items: end;
+ color: #8e8e93;
+ font-size: 0.66em;
+ font-weight: 500;
+ position: relative;
+ top: 0;
+ will-change: transform;
+ transition: top var(--ss-scroll), grid-template-rows var(--ss-scroll), opacity 0.25s ease;
+ opacity: 0;
+ }
+
+ /* Delivered to Read: the old word lifts out as the new one rises in, the two
+ overlapping so there is never an empty moment, pinned to the right edge */
+ .ss-receipt .ss-roll[data-variant='fade'] .ss-roll__item[data-out] {
+ animation: ss-receipt-out 0.3s ease both;
+ }
+
+ .ss-receipt .ss-roll[data-variant='fade'] .ss-roll__char {
+ animation: ss-receipt-in 0.4s cubic-bezier(0.25, 1, 0.5, 1) 0.08s both;
+ }
+
+ .ss-receipt .ss-roll[data-variant='fade'] .ss-roll__mid {
+ transition-duration: 0.45s;
+ }
+
+ @keyframes ss-receipt-out {
+ to { opacity: 0; filter: blur(1.5px); transform: translateY(-0.5em); }
+ }
+
+ @keyframes ss-receipt-in {
+ from { opacity: 0; filter: blur(1.5px); transform: translateY(0.5em); }
+ }
+
+ .ss-receipt > span {
+ min-height: 0;
+ overflow: hidden;
+ padding-right: 0.3em;
+ }
+
+ .ss-receipt[data-show] {
+ grid-template-rows: 1fr;
+ opacity: 1;
+ }
+
+ /* Chat: the AI-app look. The person's turn is a quiet bubble on the right; the
+ reply is plain text across the column, with no bubble and no tail */
+ .ss-thread[data-app="chat"] {
+ --ss-me: var(--ss-ink-1);
+ --ss-me-text: var(--ss-text-strong);
+ gap: 18px;
+ line-height: 1.55;
+ }
+
+ .ss-thread[data-app="chat"] .ss-bubble {
+ border-radius: 1.2em;
+ padding: 0.5em 0.9em;
+ }
+
+ .ss-thread[data-app="chat"] .ss-bubble__tail {
+ display: none;
+ }
+
+ /* The fill is the bubble's own layer (::before), so the reply clears that,
+ not just its background */
+ .ss-thread[data-app="chat"] [data-from="them"] > .ss-bubble {
+ --b: transparent;
+ /* Plain text has no box to ease: it takes its height as words stream in,
+ or the thread chases a lagging box and the text spills past it */
+ transition: none;
+ max-width: 100%;
+ padding: 0;
+ border-radius: 0;
+ background: none;
+ color: var(--ss-text);
+ }
+
+ .ss-thread[data-app="chat"] .ss-bubble-row + .ss-bubble-row {
+ margin-top: 0;
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ .ss-bubble,
+ .ss-bubble__text {
+ animation-duration: 0.01s;
+ }
+
+ .ss-bubble__dots i {
+ animation: none;
+ opacity: 0.6;
+ }
+
+ .ss-bubble-row,
+ .ss-bubble {
+ transition-duration: 0.01s;
+ }
+ }
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/messages.tsx b/skills/imported/benjitaylor/stageshow/kit/messages.tsx
new file mode 100644
index 0000000..4441ce8
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/messages.tsx
@@ -0,0 +1,259 @@
+import { Children, cloneElement, createContext, isValidElement, useContext, useEffect, useLayoutEffect, useRef, type CSSProperties, type ReactElement, type ReactNode } from 'react'
+import { useLook } from './looks'
+import { Roll } from './motion/Roll'
+import './messages.css'
+
+// Message threads the way messaging apps draw them: runs of bubbles where only
+// the last in a run has a tail, a typing indicator that grows into the reply,
+// and older messages easing up as new ones arrive. One part for iMessage-style
+// threads and for AI chat, where the reply is plain text with no bubble.
+
+// Whether the thread has finished its first render: bubbles there from the
+// start are the opening picture and don't rise in; only arrivals do
+const Ready = createContext<{ current: boolean }>({ current: true })
+
+// offsetTop rounds to whole pixels. Keep fractional positions through the
+// FLIP, including inside a scaled Phone, or a row steps before its glide starts.
+function threadTops(root: HTMLElement) {
+ const style = getComputedStyle(root)
+ const extra = style.boxSizing === 'border-box' ? 0 :
+ [style.paddingLeft, style.paddingRight, style.borderLeftWidth, style.borderRightWidth].reduce((sum, value) => sum + (parseFloat(value) || 0), 0)
+ const width = parseFloat(style.width) + extra
+ const scale = (width && root.getBoundingClientRect().width / width) || 1
+ const parents = new Map()
+ return new Map(Array.from(root.children, (el) => {
+ const parent = (el as HTMLElement).offsetParent
+ if (parent && !parents.has(parent)) parents.set(parent, parent.getBoundingClientRect().top)
+ return [el, (el.getBoundingClientRect().top - (parent ? parents.get(parent)! : 0)) / scale]
+ }))
+}
+
+export interface ThreadProps {
+ /**
+ * imessage: blue and gray bubbles with tails (sms for green). chat: the
+ * AI-app look, a quiet gray bubble for the person and plain text for the reply.
+ */
+ app?: 'imessage' | 'sms' | 'chat'
+ className?: string
+ style?: CSSProperties
+ children?: ReactNode
+}
+
+/**
+ * A column of messages pinned to its bottom, as a conversation is. Messages
+ * already on screen glide up when one arrives instead of jumping; the thread
+ * clips what scrolls off its top, so put it under a header or let the stage
+ * crop it. Tails go on the last bubble of each run automatically.
+ */
+export function Thread({ app = 'imessage', className, style, children }: ThreadProps) {
+ const ref = useRef(null)
+ const ready = useRef(false)
+ // Where each message is right before this update. Read fresh every time,
+ // not remembered from the last update: CSS motion in between (a receipt
+ // opening, a bubble growing) moves messages without a render, and a stale
+ // position would glide them from somewhere they no longer are
+ const tops = ref.current ? threadTops(ref.current) : new Map()
+ useEffect(() => {
+ ready.current = true
+ }, [])
+ // Glide: every message that moved since the last render starts where it
+ // was and eases to where it is (FLIP), so an arrival pushes the rest up
+ useLayoutEffect(() => {
+ const root = ref.current
+ if (!root) return
+ const next = threadTops(root)
+ for (const [node, top] of Array.from(next)) {
+ const el = node as HTMLElement
+ const was = tops.get(el)
+ if (was === undefined || Math.abs(was - top) < 0.001) continue
+ // Start from where it is on screen: a glide still under way continues
+ // from its current offset instead of jumping back
+ // (by top, on the layer each row keeps, so a bubble's words neither hop
+ // as they land nor step inside the bubble on the way)
+ const now = parseFloat(getComputedStyle(el).top) || 0
+ el.style.transition = 'none'
+ el.style.top = `${was + now - top}px`
+ void el.offsetWidth
+ el.style.transition = ''
+ el.style.top = ''
+ }
+ })
+ // A tail closes each run: the last bubble before the sender changes
+ const list = Children.toArray(children).filter(isValidElement) as ReactElement[]
+ const fromOf = (c: ReactElement) => (c.type === Bubble ? c.props.from : null)
+ const out = list.map((c, i) => {
+ if (c.type !== Bubble || c.props.tail !== undefined) return c
+ const next = list.slice(i + 1).find((n) => n.type === Bubble)
+ return cloneElement(c, { tail: !next || fromOf(next) !== c.props.from })
+ })
+ return (
+
+
+ {out}
+
+
+ )
+}
+
+export interface BubbleProps {
+ /** me sits on the right in the accent bubble; them on the left. */
+ from: 'me' | 'them'
+ /** Three bouncing dots in place of the text. Turning it off grows the bubble into the message. */
+ typing?: boolean
+ /** Set by Thread: true on the last bubble of a run. */
+ tail?: boolean
+ /**
+ * Where the message comes from, as iMessage sends: a target name (or a
+ * CSS selector) for the composer's text. The bubble forms out of it, the words
+ * lifting from the field into place while the bubble fills in around them,
+ * instead of rising in. Clear the field in the same update that adds the bubble.
+ */
+ source?: string
+ /**
+ * A tapback on this bubble: the emoji, and `show` to pop it in and out. It
+ * sits in a white circle on the bubble's bottom corner, and the bubble makes
+ * room below so the emoji never covers the words.
+ */
+ tapback?: { emoji: ReactNode; show?: boolean }
+ /** A small `Avatar` beside a received bubble, as a group chat shows who is speaking. */
+ avatar?: ReactNode
+ className?: string
+ style?: CSSProperties
+ children?: ReactNode
+ /** data-target and other data attributes land on the bubble itself. */
+ [data: `data-${string}`]: unknown
+}
+
+/**
+ * One message. Bubbles in the thread on its first render are the opening
+ * picture and don't animate; only later arrivals rise in. In minimal
+ * fidelity a string message becomes a bar.
+ */
+export function Bubble({ avatar, from, typing = false, tail, source, tapback, className, style, children, ...data }: BubbleProps) {
+ const { fidelity } = useLook()
+ const arrived = useRef(useContext(Ready).current).current
+ const ref = useRef(null)
+ const size = useRef<{ w: number; h: number } | null>(null)
+ // Whether this bubble grew out of the typing dots (its words fade in as it grows)
+ const wasTyping = useRef(typing)
+ const grew = useRef(false)
+ if (wasTyping.current && !typing) grew.current = true
+ wasTyping.current = typing
+ // Morph: measure the bubble before and after the content changes, then ease
+ // its box from the old size to the new
+ useLayoutEffect(() => {
+ const el = ref.current
+ if (!el) return
+ const was = size.current
+ el.style.width = ''
+ el.style.height = ''
+ // Resolved CSS dimensions retain the fractions offsetWidth/Height round
+ // away, so releasing the measured box doesn't resize it at the endpoint.
+ const measured = getComputedStyle(el)
+ const now = { w: parseFloat(measured.width), h: parseFloat(measured.height) }
+ size.current = now
+ if (!was || (was.w === now.w && was.h === now.h)) return
+ // The words keep their final line breaks while the box grows around them
+ const text = el.querySelector('.ss-bubble__text')
+ if (text) text.style.width = getComputedStyle(text).width
+ el.style.transition = 'none'
+ el.style.width = `${was.w}px`
+ el.style.height = `${was.h}px`
+ void el.offsetWidth
+ el.style.transition = ''
+ el.style.width = `${now.w}px`
+ el.style.height = `${now.h}px`
+ const end = () => {
+ el.style.width = ''
+ el.style.height = ''
+ if (text) text.style.width = ''
+ }
+ el.addEventListener('transitionend', end, { once: true })
+ return () => el.removeEventListener('transitionend', end)
+ }, [typing, children])
+ // Form out of the composer: start where the field's words were, in the
+ // field's color with no fill, and travel into place as the fill comes in
+ useLayoutEffect(() => {
+ const el = ref.current
+ if (!el || !arrived || !source) return
+ const stage = el.closest('.ss-stage') ?? document
+ const src = stage.querySelector(`[data-target="${source}"]`) ?? stage.querySelector(source)
+ const text = el.querySelector('.ss-bubble__text')
+ if (!src || !text) return
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches || el.closest('[data-still]')) return
+ // Measure where it rests, not where an earlier run already moved it
+ el.style.transition = 'none'
+ el.style.left = el.style.top = '0px'
+ const k = el.getBoundingClientRect().width / el.offsetWidth || 1
+ const o = src.getBoundingClientRect()
+ const t = text.getBoundingClientRect()
+ const dx = (o.left - t.left) / k
+ const dy = (o.top + o.height / 2 - (t.top + t.height / 2)) / k
+ const ease = 'cubic-bezier(0.22, 1, 0.36, 1)'
+ // Travels by left and top, so the words land without a sub-pixel hop
+ el.style.transition = 'none'
+ el.style.left = `${dx}px`
+ el.style.top = `${dy}px`
+ el.setAttribute('data-flying', '')
+ el.style.color = getComputedStyle(src).color
+ void el.offsetWidth
+ el.style.transition = `left 0.5s ${ease}, top 0.5s ${ease}, color 0.3s ease 0.06s`
+ el.style.left = el.style.top = ''
+ el.removeAttribute('data-flying')
+ el.style.color = ''
+ const end = (e: TransitionEvent) => {
+ if (e.target !== el || e.propertyName !== 'top') return
+ el.style.transition = ''
+ el.removeEventListener('transitionend', end)
+ }
+ el.addEventListener('transitionend', end)
+ return () => el.removeEventListener('transitionend', end)
+ }, []) // eslint-disable-line react-hooks/exhaustive-deps
+ let body = children
+ if (fidelity === 'minimal' && typeof children === 'string') body =
+ return (
+
+ {avatar && from !== 'me' && {avatar}}
+
+ {tapback && (
+
+ {tapback.emoji}
+
+ )}
+ {typing ? (
+
+
+
+
+
+ ) : (
+ {body}
+ )}
+ {/* Always drawn, so a tail grows out of the corner when its bubble
+ becomes the last of a run, and retracts when it stops being */}
+
+
+
+ )
+}
+
+/** A centered timestamp between runs ("Today 9:41"). */
+export function Stamp({ children, className }: { children: ReactNode; className?: string }) {
+ return
{children}
+}
+
+/**
+ * The small status under your last message. `children` rolls when it changes
+ * (Delivered to Read); `show` false folds it away.
+ */
+export function Receipt({ show = true, children, className }: { show?: boolean; children: string; className?: string }) {
+ return (
+
+
+ {children}
+
+
+ )
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/Camera.css b/skills/imported/benjitaylor/stageshow/kit/motion/Camera.css
new file mode 100644
index 0000000..7b66727
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/motion/Camera.css
@@ -0,0 +1,27 @@
+/* Camera */
+
+@property --ss-feather {
+ syntax: '';
+ inherits: false;
+ initial-value: 0px;
+}
+
+@layer stageshow {
+ /* The camera's frame: clips the zoomed scene and feathers its edges while
+ zoomed (the feather eases in and out with the move) */
+ .ss-camera-view {
+ position: absolute;
+ inset: 0;
+ overflow: clip;
+ mask-image:
+ linear-gradient(to right, transparent, #000 var(--ss-feather), #000 calc(100% - var(--ss-feather)), transparent),
+ linear-gradient(to bottom, transparent, #000 var(--ss-feather), #000 calc(100% - var(--ss-feather)), transparent);
+ mask-composite: intersect;
+ }
+
+ .ss-camera {
+ position: absolute;
+ inset: 0;
+ transform-origin: 0 0;
+ }
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/Camera.tsx b/skills/imported/benjitaylor/stageshow/kit/motion/Camera.tsx
new file mode 100644
index 0000000..8eeff56
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/motion/Camera.tsx
@@ -0,0 +1,51 @@
+import { useLayoutEffect, useRef, useState, type ReactNode } from 'react'
+import { useReducedMotion } from '../reduced-motion'
+import type { Rect } from '../measure'
+import { css, type Bezier, type EaseName } from '../ease'
+import './Camera.css'
+
+/**
+ * Moves the view in on part of the scene and back out, pushing in on a
+ * detail. Wrap the scene in it and set `focus` to a rect in
+ * stage pixels, measured with `a.rect(name, { layout: true })` (the layout
+ * rect, so the zoom itself never feeds back in); null shows everything. The
+ * view never leaves the scene's edges. Keep the cursor still while the camera
+ * moves, or put it where it is going only once it has settled. The view is
+ * its own frame: zoomed in, its edges feather so the scene passes under a
+ * soft edge instead of running hard into the stage's (`feather`, in px).
+ * Everything inside moves with the zoom, and `a.rect()` of a target inside
+ * reports where it is on screen now; give the cursor's rest point in stage
+ * pixels, outside the Camera, or it lands somewhere else once zoomed.
+ * `ease` takes a named curve from kit/ease.ts ('house' for a video-paced move).
+ */
+export function Camera({ focus, pad = 32, max = 2, duration = 1100, feather = 28, ease: curve = 'camera', children, className }: { focus: Rect | null; pad?: number; max?: number; duration?: number; feather?: number; ease?: EaseName | Bezier; children: ReactNode; className?: string }) {
+ const ref = useRef(null)
+ const [size, setSize] = useState({ w: 0, h: 0 })
+ const reduced = useReducedMotion()
+ // Followed, not read once: a Stage switching to its compact design (or any
+ // resize) changes the frame the zoom is worked out in
+ useLayoutEffect(() => {
+ const p = ref.current?.parentElement?.parentElement
+ if (!p) return
+ const read = () => setSize((was) => (was.w === p.offsetWidth && was.h === p.offsetHeight ? was : { w: p.offsetWidth, h: p.offsetHeight }))
+ read()
+ const ro = new ResizeObserver(read)
+ ro.observe(p)
+ return () => ro.disconnect()
+ }, [])
+ let transform = 'none'
+ if (focus && size.w) {
+ const s = Math.max(1, Math.min(max, size.w / (focus.width + pad * 2), size.h / (focus.height + pad * 2)))
+ const tx = Math.min(0, Math.max(size.w - size.w * s, size.w / 2 - (focus.x + focus.width / 2) * s))
+ const ty = Math.min(0, Math.max(size.h - size.h * s, size.h / 2 - (focus.y + focus.height / 2) * s))
+ transform = `translate(${tx}px, ${ty}px) scale(${s})`
+ }
+ const ease = `${reduced ? 0 : duration}ms ${css(curve)}`
+ return (
+
+
+ {children}
+
+
+ )
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/Collapse.css b/skills/imported/benjitaylor/stageshow/kit/motion/Collapse.css
new file mode 100644
index 0000000..41fc77a
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/motion/Collapse.css
@@ -0,0 +1,97 @@
+/* Collapse */
+
+@layer stageshow {
+ /* The space and the content move together, so there's never an empty band
+ where a row was or is about to be. Closing, the content dims fast (to a
+ third in the first 100ms, before the closing edge reaches it) and then
+ dissolves over the rest of the height's travel, so it is faint while
+ the edge passes and still there while the space closes: the fade and
+ the height overlap to the end and no empty box is shown. A fade that
+ finished before the height had moved left a blank band for about 100ms.
+ Opening, the content comes in as the edge passes it. No mask, so no
+ gradient wash. */
+ .ss-collapse {
+ display: grid;
+ grid-template-rows: 0fr;
+ opacity: 0;
+ transition: grid-template-rows 0.42s cubic-bezier(0.4, 0, 0.2, 1) 0.05s, opacity 0.42s cubic-bezier(0, 0.8, 0.5, 1);
+ }
+
+ .ss-collapse[data-open] {
+ grid-template-rows: 1fr;
+ opacity: 1;
+ transition: grid-template-rows 0.46s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.24s ease-out 0.15s;
+ }
+
+ /* solid: a surface unlike the ground opens and closes without fading
+ (a dark panel would pass through gray); only the height moves */
+ .ss-collapse[data-solid],
+ .ss-collapse[data-solid][data-open] {
+ opacity: 1;
+ }
+
+ .ss-collapse__inner {
+ min-height: 0;
+ overflow: clip;
+ overflow-clip-margin: 3px;
+ }
+
+ /* A clip reveal unfolds from the content's middle line: the space clips it
+ equally above and below, so a row's text (in its middle) is whole by about
+ half the height and can be seen early without ever looking sliced. Closing
+ folds back into that line. */
+ .ss-collapse[data-reveal='clip'] > .ss-collapse__inner {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ /* Thin rows side by side: a margin would let their windows overlap */
+ overflow-clip-margin: 0;
+ }
+
+ /* Taller than a row (a comment, a card): it reveals from the top, and its
+ content comes in as the space finishes opening, so no line is seen cut.
+ Cards and messages read better with reveal="drop". */
+ .ss-collapse[data-reveal='clip'][data-tall] > .ss-collapse__inner {
+ justify-content: flex-start;
+ }
+
+ .ss-collapse[data-reveal='clip'][data-tall][data-open] {
+ transition: grid-template-rows 0.46s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.24s ease-out 0.3s;
+ }
+
+ /* Settled open, nothing clips: a toolbar or popover can hang off a row */
+ .ss-collapse[data-open]:not([data-moving]) > .ss-collapse__inner {
+ overflow: visible;
+ }
+
+ /* drop: nothing clips; the content arrives whole into the opening space */
+ .ss-collapse[data-reveal='drop'] > .ss-collapse__inner {
+ overflow: visible;
+ }
+
+ /* The glide is a transform on a layer the content keeps for good. Moved by
+ layout (top), text steps whole pixels while its box glides, so a count in
+ a pill hops inside it; and text that ends a transform without a layer of
+ its own is redrawn snapped and hops as it lands. On a lasting layer it
+ moves as one picture and rests drawn exactly as it moved. */
+ .ss-collapse[data-reveal='drop'] > .ss-collapse__inner > * {
+ transform: translateY(-10px);
+ will-change: transform;
+ transition: transform 0.3s ease;
+ }
+
+ .ss-collapse[data-reveal='drop'][data-from='below'] > .ss-collapse__inner > * {
+ transform: translateY(10px);
+ }
+
+ .ss-collapse[data-reveal='drop'][data-open] > .ss-collapse__inner > * {
+ transform: none;
+ transition: transform 0.6s cubic-bezier(0.22, 1, 0.36, 1) 0.2s;
+ }
+
+ /* Drop content is whole and unclipped, so it waits for most of its space:
+ shown early, it would hang over whatever sits below the opening gap */
+ .ss-collapse[data-reveal='drop'][data-open] {
+ transition: grid-template-rows 0.5s cubic-bezier(0.45, 0, 0.2, 1), opacity 0.3s ease 0.24s;
+ }
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/Collapse.tsx b/skills/imported/benjitaylor/stageshow/kit/motion/Collapse.tsx
new file mode 100644
index 0000000..18306f3
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/motion/Collapse.tsx
@@ -0,0 +1,69 @@
+import { useLayoutEffect, useRef, useState, type HTMLAttributes, type ReactNode } from 'react'
+import './Collapse.css'
+
+/**
+ * Opens and closes its content's height, so what comes after moves with it:
+ * a row leaving a list, a log panel opening under a status. Closing dims the
+ * content fast and dissolves it over the whole of the height's travel, so a
+ * row is faint by the time the edge reaches it and the space is never seen
+ * empty; opening brings the content with its height. Children can have any height or
+ * padding. The inner box is a deliberate crop (film.mjs skips it) and clips
+ * 3px wide, so rings and shadows on the content aren't shaved.
+ */
+export function Collapse({ open, reveal = 'clip', from = 'above', solid = false, children, className, ...rest }: HTMLAttributes & {
+ open: boolean
+ /**
+ * clip: the opening space uncovers the content, for rows and text. The
+ * content is faint while the edge passes it, so nothing is ever seen sliced.
+ * drop: the space opens and the content arrives whole, gliding into place.
+ * For cards and messages: a notification, a toast, a chat message.
+ */
+ reveal?: 'clip' | 'drop'
+ /** Where drop content comes from: 'above' (a notification), or 'below' (a chat message landing at the bottom of a feed). */
+ from?: 'above' | 'below'
+ /**
+ * Opaque throughout: the height opens and closes over the content without
+ * fading it. For a surface unlike the ground (a dark log panel on a light
+ * window), which would otherwise pass through gray. Its content is there
+ * from the first frame: open it with what it shows, never empty.
+ */
+ solid?: boolean
+ children: ReactNode
+}) {
+ // Marked moving from the frame open changes until the height settles
+ const [moving, setMoving] = useState(false)
+ // Taller than a row: unfolding from the middle would cut its first line
+ // while it's already visible, so it reveals from the top and fades in late
+ const [tall, setTall] = useState(false)
+ const inner = useRef(null)
+ const first = useRef(true)
+ useLayoutEffect(() => {
+ const h = inner.current?.scrollHeight ?? 0
+ if (h) setTall(h > 44)
+ if (first.current) return void (first.current = false)
+ setMoving(true)
+ // No transitionend without a transition (reduced motion): never leave it moving
+ const t = setTimeout(() => setMoving(false), 900)
+ return () => clearTimeout(t)
+ }, [open])
+ return (
+
+ )
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/Connector.css b/skills/imported/benjitaylor/stageshow/kit/motion/Connector.css
new file mode 100644
index 0000000..a0f78f9
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/motion/Connector.css
@@ -0,0 +1,128 @@
+/* Connector */
+
+@layer stageshow {
+ .ss-connector {
+ position: absolute;
+ inset: 0;
+ z-index: 20;
+ width: 100%;
+ height: 100%;
+ overflow: visible;
+ pointer-events: none;
+ }
+
+ .ss-connector__line {
+ fill: none;
+ stroke: var(--ss-accent);
+ stroke-width: 1.5;
+ stroke-dasharray: 1;
+ stroke-dashoffset: 1;
+ transition: stroke-dashoffset 1s cubic-bezier(0.65, 0, 0.35, 1), opacity 0.25s ease;
+ }
+
+ .ss-connector[data-dashed] .ss-connector__line {
+ stroke-dasharray: 4 4;
+ stroke-dashoffset: 0;
+ }
+
+ .ss-connector__reveal {
+ stroke-dasharray: 1;
+ stroke-dashoffset: 1;
+ transition: stroke-dashoffset 1s cubic-bezier(0.65, 0, 0.35, 1);
+ }
+
+ .ss-connector[data-show] .ss-connector__reveal { stroke-dashoffset: 0; }
+ .ss-connector:not([data-show]) .ss-connector__reveal {
+ transition: stroke-dashoffset 0s 0.25s;
+ }
+
+ .ss-connector[data-show] .ss-connector__line {
+ stroke-dashoffset: 0;
+ }
+
+ .ss-connector[data-show][data-dashed] .ss-connector__line {
+ opacity: 1;
+ }
+
+ .ss-connector:not([data-show]) .ss-connector__line {
+ opacity: 0;
+ transition: opacity 0.25s ease, stroke-dashoffset 0s 0.25s;
+ }
+
+ .ss-connector__dot {
+ fill: var(--ss-accent);
+ stroke: #fff;
+ stroke-width: 2;
+ filter: drop-shadow(0 1px 1px rgb(20 35 55 / .16));
+ opacity: 0;
+ transform-box: fill-box;
+ transform-origin: center;
+ transform: scale(0.4);
+ transition: opacity 0.2s ease, transform 0.3s cubic-bezier(0.22, 1, 0.36, 1);
+ }
+
+ .ss-connector[data-arrived] .ss-connector__dot {
+ opacity: 1;
+ transform: scale(1);
+ }
+
+ .ss-connector[data-show] .ss-connector__dot--start {
+ opacity: 1;
+ transform: scale(1);
+ }
+
+ .ss-connector__arrow {
+ fill: none;
+ stroke: var(--ss-accent);
+ stroke-width: 1.5;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ opacity: 0;
+ transition: opacity 0.2s ease;
+ }
+
+ /* The head arrives as the line reaches it */
+ .ss-connector[data-arrived] .ss-connector__arrow {
+ opacity: 1;
+ }
+
+ /* The head is always solid: it must not pick up the line's draw-in dash */
+ .ss-connector__arrow {
+ stroke-dasharray: none;
+ stroke-dashoffset: 0;
+ transition: opacity 0.2s ease;
+ }
+
+ .ss-connector__label {
+ position: absolute;
+ z-index: 21;
+ padding: 2px 7px;
+ border-radius: 999px;
+ background: var(--ss-raised);
+ box-shadow: 0 0 0 1px var(--ss-line), var(--ss-shadow-card);
+ font-size: 10.5px;
+ font-weight: 500;
+ line-height: 14px;
+ color: var(--ss-text-strong);
+ white-space: nowrap;
+ opacity: 0;
+ transform: translate(-50%, -50%);
+ pointer-events: none;
+ transition: opacity 0.2s ease;
+ }
+
+ /* A step number: a small circle on the line */
+ .ss-connector__label[data-step] {
+ display: grid;
+ place-items: center;
+ width: 18px;
+ height: 18px;
+ padding: 0;
+ font-variant-numeric: tabular-nums;
+ }
+
+ .ss-connector__label[data-arrived] {
+ opacity: 1;
+ transition: opacity 0.2s ease;
+ }
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/Connector.tsx b/skills/imported/benjitaylor/stageshow/kit/motion/Connector.tsx
new file mode 100644
index 0000000..07643a9
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/motion/Connector.tsx
@@ -0,0 +1,182 @@
+import { useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
+import type { Point } from '../measure'
+import './Connector.css'
+
+type Route = 'h' | 'v' | 'straight' | 'step' | 'curve'
+
+export interface ConnectorProps {
+ from: Point
+ to: Point
+ /** h: across, then down or up. v: down or up, then across. straight: a direct line.
+ * step: across, down or up halfway, then across again, so it enters `to`
+ * level. Use step when several lines meet one target: they merge into one
+ * shared run before it, instead of knotting at its edge. curve: the same
+ * leave-level, arrive-level path as step, as one soft S, for a looser
+ * diagram (a flow between nodes, a hand-off between two cards). */
+ route?: Route
+ /** Draws on when it turns true, fades off (intact) when it turns false. */
+ show: boolean
+ /** Corner radius at the elbow. */
+ radius?: number
+ /** End dots. Defaults to 'end', or 'none' with an arrow. */
+ dots?: 'end' | 'both' | 'none'
+ /** An arrowhead at `to`, for direction: a request going out, a response coming back. */
+ arrow?: boolean
+ /** A label on the line: a short word ("POST", "200 OK") in a pill, or a number for a step marker. Arrives after the line draws. */
+ label?: ReactNode
+ /** Where along the line the label sits, 0 (from) to 1 (to). */
+ labelAt?: number
+ dashed?: boolean
+ className?: string
+}
+
+/**
+ * A line that ties one thing to another: an invoice to its plan, a row to its
+ * detail card, a value to its annotation, a request to its endpoint. Points
+ * are in stage pixels (from rect() or center()). Put it last in the scene so
+ * it draws over both ends.
+ */
+export function Connector({ from, to, route = 'h', show, radius = 8, dots, arrow, label, labelAt = 0.5, dashed, className }: ConnectorProps) {
+ // A curve with an arrow ends a little short and runs straight in, so the
+ // head sits on a straight run. Over a short gap the curve only levels out in
+ // its last pixels, and a head laid on the bend kinks against the line
+ const tip = arrow && route === 'curve' ? { x: to.x - Math.sign(to.x - from.x) * Math.min(8, Math.abs(to.x - from.x) / 3), y: to.y } : to
+ const d = tip === to ? elbow(from, to, route, radius) : `${elbow(from, tip, route, radius)}L${to.x} ${to.y}`
+ const ends = dots ?? (arrow ? 'none' : 'end')
+ // A dashed line ends on a dash, never a gap short of its target: stretch
+ // the 4/4 pattern a hair so a whole number of dashes fits
+ const line = useRef(null)
+ const reveal = useRef(null)
+ const revealId = `ss-connector-${useId().replace(/:/g, '')}`
+ const [arrived, setArrived] = useState(false)
+ useLayoutEffect(() => {
+ const el = line.current
+ if (!el) return
+ if (!dashed) {
+ el.style.removeProperty('stroke-dasharray')
+ return
+ }
+ const len = el.getTotalLength()
+ const n = Math.max(1, Math.round((len + 4) / 8))
+ const dash = (len / (n * 2 - 1)).toFixed(3)
+ el.style.strokeDasharray = `${dash} ${dash}`
+ }, [d, dashed])
+ useLayoutEffect(() => {
+ const el = dashed ? reveal.current : line.current
+ setArrived(false)
+ if (!show || !el) return
+ // The endpoint belongs to the visible line, not a guessed delay. This
+ // also works when reduced motion removes the transition, or a host
+ // advances its CSS transition one frame at a time.
+ const property = 'stroke-dashoffset'
+ const check = () => {
+ const style = getComputedStyle(el)
+ const complete = Math.abs(parseFloat(style.strokeDashoffset)) < .0001
+ if (complete) setArrived(true)
+ }
+ const settle = (event: TransitionEvent) => {
+ if (event.target === el && event.propertyName === property) check()
+ }
+ el.addEventListener('transitionend', settle)
+ el.addEventListener('transitioncancel', settle)
+ check()
+ return () => {
+ el.removeEventListener('transitionend', settle)
+ el.removeEventListener('transitioncancel', settle)
+ }
+ }, [d, dashed, show])
+ const pts = tip === to ? corners(from, to, route) : [...corners(from, tip, route), to]
+ const q = pts[pts.length - 1]
+ // A straight horizontal/vertical elbow can end with a zero-length leg.
+ // Its arrow follows the last real segment, rather than collapsing to a dot.
+ const p = pts.slice(0, -1).reverse().find(p => Math.hypot(q.x - p.x, q.y - p.y) > .001) ?? q
+ const directionLength = Math.hypot(q.x - p.x, q.y - p.y)
+ const len = directionLength || 1
+ const ux = (q.x - p.x) / len
+ const uy = (q.y - p.y) / len
+ const head = `M${q.x - ux * 6 - uy * 4} ${q.y - uy * 6 + ux * 4}L${q.x} ${q.y}L${q.x - ux * 6 + uy * 4} ${q.y - uy * 6 - ux * 4}`
+ const at = along(pts, labelAt)
+ return (
+ <>
+
+ {label !== undefined && (
+
+ {label}
+
+ )}
+ >
+ )
+}
+
+// The S-curve's controls: level out of `a` and level into `b`, half way across
+function bend(a: Point, b: Point): [Point, Point] {
+ const m = (a.x + b.x) / 2
+ return [{ x: m, y: a.y }, { x: m, y: b.y }]
+}
+
+function corners(a: Point, b: Point, route: Route): Point[] {
+ if (route === 'straight') return [a, b]
+ if (route === 'curve') {
+ // Sampled, so a label and the arrow follow the curve itself
+ const [c1, c2] = bend(a, b)
+ return Array.from({ length: 25 }, (_, i) => {
+ const t = i / 24
+ const u = 1 - t
+ return {
+ x: u * u * u * a.x + 3 * u * u * t * c1.x + 3 * u * t * t * c2.x + t * t * t * b.x,
+ y: u * u * u * a.y + 3 * u * u * t * c1.y + 3 * u * t * t * c2.y + t * t * t * b.y,
+ }
+ })
+ }
+ if (route === 'step') {
+ const m = (a.x + b.x) / 2
+ return [a, { x: m, y: a.y }, { x: m, y: b.y }, b]
+ }
+ return route === 'h' ? [a, { x: b.x, y: a.y }, b] : [a, { x: a.x, y: b.y }, b]
+}
+
+function along(pts: Point[], f: number): Point {
+ const segs = pts.slice(1).map((p, i) => Math.hypot(p.x - pts[i].x, p.y - pts[i].y))
+ let left = segs.reduce((n, s) => n + s, 0) * Math.max(0, Math.min(1, f))
+ for (let i = 0; i < segs.length; i++) {
+ if (left <= segs[i] || i === segs.length - 1) {
+ const t = segs[i] ? Math.min(1, left / segs[i]) : 0
+ return { x: pts[i].x + (pts[i + 1].x - pts[i].x) * t, y: pts[i].y + (pts[i + 1].y - pts[i].y) * t }
+ }
+ left -= segs[i]
+ }
+ return pts[0]
+}
+
+function elbow(a: Point, b: Point, route: Route, r: number) {
+ if (route === 'straight') return `M${a.x} ${a.y}L${b.x} ${b.y}`
+ if (route === 'curve') {
+ const [c1, c2] = bend(a, b)
+ return `M${a.x} ${a.y}C${c1.x} ${c1.y} ${c2.x} ${c2.y} ${b.x} ${b.y}`
+ }
+ const dx = b.x - a.x
+ const dy = b.y - a.y
+ if (route === 'step') {
+ const m = (a.x + b.x) / 2
+ const k = Math.min(r, Math.abs(dx) / 2, Math.abs(dy) / 2)
+ const sx = Math.sign(dx) || 1
+ const sy = Math.sign(dy) || 1
+ if (!k) return `M${a.x} ${a.y}H${m}V${b.y}H${b.x}`
+ return `M${a.x} ${a.y}H${m - sx * k}Q${m} ${a.y} ${m} ${a.y + sy * k}V${b.y - sy * k}Q${m} ${b.y} ${m + sx * k} ${b.y}H${b.x}`
+ }
+ const k = Math.min(r, Math.abs(dx), Math.abs(dy))
+ const sx = Math.sign(dx) || 1
+ const sy = Math.sign(dy) || 1
+ if (route === 'h') {
+ return `M${a.x} ${a.y}H${b.x - sx * k}Q${b.x} ${a.y} ${b.x} ${a.y + sy * k}V${b.y}`
+ }
+ return `M${a.x} ${a.y}V${b.y - sy * k}Q${a.x} ${b.y} ${a.x + sx * k} ${b.y}H${b.x}`
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/Count.css b/skills/imported/benjitaylor/stageshow/kit/motion/Count.css
new file mode 100644
index 0000000..57c74da
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/motion/Count.css
@@ -0,0 +1,68 @@
+/* Count */
+
+@layer stageshow {
+ .ss-count {
+ display: inline-flex;
+ align-items: baseline;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+ }
+
+ .ss-count__char {
+ display: inline-block;
+ }
+
+ /* A unit after the figure keeps a box of its own, a hair from the last
+ digit (a word space when the format writes one), so "21°" and "240ms"
+ read as one figure without the unit sitting on the digit. Restyle it
+ here (a smaller, lighter degree sign); widen the hair with the token */
+ .ss-count__unit {
+ display: inline-block;
+ margin-left: var(--ss-count-unit-gap, 0.08em);
+ }
+
+ .ss-count__unit[data-space] {
+ margin-left: 0.28em;
+ }
+
+ /* One digit: a window one line tall over a strip of 0 to 9. The strip
+ slides to the figure it shows; a change rolls it through the figures
+ in between, so 4 to 9 passes 5, 6, 7 and 8 */
+ .ss-count__digit {
+ display: inline-block;
+ /* Sized by its figures, so the number's letter-spacing counts as it does
+ in plain text; a fixed 1ch box set tracked figures wider than the text */
+ height: 1em;
+ overflow: hidden;
+ line-height: 1;
+ vertical-align: baseline;
+ /* The window's edges soften a touch, so a figure passing them is never
+ sliced; the figure at rest sits inside the clear middle */
+ mask-image: linear-gradient(to bottom, transparent, #000 8%, #000 92%, transparent);
+ }
+
+ .ss-count__strip {
+ display: flex;
+ flex-direction: column;
+ will-change: transform;
+ transition-property: transform;
+ transition-timing-function: cubic-bezier(0.3, 0.75, 0.1, 1);
+ }
+
+ .ss-count__strip > span {
+ display: block;
+ height: 1em;
+ line-height: 1;
+ text-align: left; /* a digit sits on its cell's left like set type; centered, a leading 1 drifts off the column */
+ }
+
+ .ss-count[data-snap] .ss-count__strip {
+ transition: none;
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ .ss-count__strip {
+ transition: none;
+ }
+ }
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/Count.tsx b/skills/imported/benjitaylor/stageshow/kit/motion/Count.tsx
new file mode 100644
index 0000000..42d3ee6
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/motion/Count.tsx
@@ -0,0 +1,141 @@
+import { useLayoutEffect, useEffect, useRef, useState, type HTMLAttributes } from 'react'
+import { useReducedMotion } from '../reduced-motion'
+import './Count.css'
+
+export interface CountProps extends HTMLAttributes {
+ value: number
+ /** How a number is written, e.g. n => `$${n.toFixed(2)}` */
+ format?: (n: number) => string
+ /** ms for a digit to roll from the old figure to the new one. */
+ duration?: number
+ /** ms between one digit starting and the next, from the right. */
+ stagger?: number
+}
+
+// Three decades of figures, so a digit can roll on past 9 into 0 (or back
+// past 0 into 9) without ever running off the end of the strip
+const STRIP = '012345678901234567890123456789'.split('')
+const MIDDLE = 10
+
+// A unit after the figure ("21°", "240ms", "40%", "12 min"): the run of
+// letters or signs that ends the text, after its last digit. It gets a box of
+// its own, a hair from the digits (a word space when the format writes one),
+// so the pair reads as one figure without the unit sitting on the last digit
+const unitOf = (text: string) => {
+ const m = /^(.*\d)(\s*)([^\d\s.,]+)$/.exec(text)
+ return m ? { head: m[1], space: m[2].length > 0, unit: m[3] } : { head: text, space: false, unit: '' }
+}
+
+const digitsFromRight = (text: string) => {
+ const out: number[] = []
+ for (let i = text.length - 1; i >= 0; i--) {
+ const c = text[i]
+ if (c >= '0' && c <= '9') out.push(Number(c))
+ }
+ return out
+}
+
+/**
+ * A number whose digits roll to each new value, the way a counter does:
+ * each changed digit turns from the old figure to the new one, the last digit
+ * first, so a total settles from the right. A rise rolls the figures up, a
+ * fall rolls them down, and a digit passing 9 carries on into 0 rather than
+ * spinning back. Unchanged digits and the punctuation hold still. Tabular
+ * figures keep every column the same width. A unit the format writes after
+ * the figure ("21°", "240ms", "40%") sits in a box of its own
+ * (`.ss-count__unit`), a hair from the last digit, or a word space when the
+ * format has one ("12 min"); restyle it there for a smaller or lighter unit,
+ * and set `--ss-count-unit-gap` to widen the hair. Inside an `act.snap` or an
+ * offscreen reset it jumps to the value, so unseen resets stay unseen.
+ */
+export function Count({ value, format = (n) => Math.round(n).toLocaleString('en-US'), duration = 700, stagger = 45, className, style, ...rest }: CountProps) {
+ const reduced = useReducedMotion()
+ const el = useRef(null)
+ const text = format(value)
+ const { head, space, unit } = unitOf(text)
+ const chars = head.split('')
+ const digits = digitsFromRight(text)
+ // Where each column's strip sits, by column from the right. Starts in the
+ // middle decade; a change moves it by the shortest turn in the value's
+ // direction, and it is put back in the middle once the roll has settled
+ const [idx, setIdx] = useState(() => digits.map((d) => MIDDLE + d))
+ const shown = useRef({ text, value, idx })
+ shown.current.idx = idx
+ const homing = useRef(false)
+
+ useLayoutEffect(() => {
+ const node = el.current
+ if (!node) return
+ const prev = shown.current
+ if (prev.text === text) return
+ const instant = reduced || duration <= 0 || !!node.closest('[data-instant], [data-offscreen]')
+ const before = digitsFromRight(prev.text)
+ const up = value >= prev.value
+ const next = digits.map((d, c) => {
+ const was = before[c]
+ const at = prev.idx[c]
+ if (was === undefined || at === undefined || instant) return MIDDLE + d
+ if (was === d) return at
+ return up ? at + ((d - was + 10) % 10) : at - ((was - d + 10) % 10)
+ })
+ node.toggleAttribute('data-snap', instant)
+ if (!instant) {
+ // Settle the old transforms in the browser first, so the new ones transition
+ for (const strip of Array.from(node.querySelectorAll('.ss-count__strip'))) void getComputedStyle(strip).transform
+ node.setAttribute('data-rolling', '')
+ }
+ shown.current = { text, value, idx: next }
+ setIdx(next)
+ }, [text, value, reduced, duration]) // eslint-disable-line react-hooks/exhaustive-deps
+
+ // Once every digit has landed, put the strips back in the middle decade
+ // without a transition, and drop the rolling mark a still waits on
+ useEffect(() => {
+ const node = el.current
+ if (!node || !node.hasAttribute('data-rolling')) return
+ const id = window.setTimeout(() => {
+ node.setAttribute('data-snap', '')
+ homing.current = true
+ const home = digits.map((d) => MIDDLE + d)
+ shown.current.idx = home
+ setIdx(home)
+ node.removeAttribute('data-rolling')
+ }, duration + stagger * digits.length + 60)
+ return () => window.clearTimeout(id)
+ }, [idx]) // eslint-disable-line react-hooks/exhaustive-deps
+
+ // The snap comes off only after the home transforms are in the DOM (a
+ // frame callback could run before React commits them, and the strips
+ // would then roll a whole decade back to the middle)
+ useLayoutEffect(() => {
+ const node = el.current
+ if (!node || !homing.current) return
+ homing.current = false
+ for (const strip of Array.from(node.querySelectorAll('.ss-count__strip'))) void getComputedStyle(strip).transform
+ node.removeAttribute('data-snap')
+ }, [idx])
+
+ // Columns are keyed from the right, so a total that gains a digit keeps the
+ // digits it already had rolling in place and only the new leading one appears
+ let fromRight = digits.length
+ return (
+
+ {chars.map((c, i) => {
+ if (c < '0' || c > '9') return {c}
+ const column = --fromRight
+ const at = idx[column] ?? MIDDLE + Number(c)
+ return (
+
+
+ {STRIP.map((n, k) => {n})}
+
+
+ )
+ })}
+ {unit && {unit}}
+
+ )
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/Expand.css b/skills/imported/benjitaylor/stageshow/kit/motion/Expand.css
new file mode 100644
index 0000000..963ee09
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/motion/Expand.css
@@ -0,0 +1,80 @@
+/* Expand */
+
+@layer stageshow {
+ .ss-expand {
+ position: absolute;
+ inset: 0;
+ overflow: hidden;
+ }
+
+ .ss-expand__content {
+ position: absolute;
+ inset: 0;
+ }
+
+ /* Launching an app, as iOS zooms one out of a notification or an icon: the
+ whole app, laid out full size, is scaled down onto the closed thing and
+ grows to fill the screen. It stays on its own layer for good, so its text
+ never re-snaps when the zoom ends. The window takes the app's surface as it
+ leaves the face, and gives it back as it lands on it closing */
+ .ss-expand[data-launch] {
+ transform-origin: 0 0;
+ will-change: transform;
+ background-color: transparent;
+ transition:
+ transform var(--ss-expand-ms) var(--ss-expand-ease),
+ clip-path var(--ss-expand-ms) var(--ss-expand-ease),
+ background-color calc(var(--ss-expand-ms) * 0.25) ease calc(var(--ss-expand-ms) * 0.7);
+ }
+
+ /* Opening, the app's surface rises from the first frame as the face fades,
+ one crossfade, so the window is never a see-through frame of content */
+ .ss-expand[data-launch][data-open] {
+ background-color: var(--ss-expand-bg, var(--ss-surface));
+ transition:
+ transform var(--ss-expand-ms) var(--ss-expand-ease),
+ clip-path var(--ss-expand-ms) var(--ss-expand-ease),
+ background-color calc(var(--ss-expand-ms) * 0.4) ease;
+ }
+
+ /* The app's content waits for its window: it fades in once the window has
+ grown a third of the way, never floating over the wallpaper while the
+ window is still the size of the face. Closing, it holds until the window
+ is nearly the face again */
+ .ss-expand[data-launch] > .ss-expand__content {
+ opacity: 0;
+ transition: opacity calc(var(--ss-expand-ms) * 0.25) ease calc(var(--ss-expand-ms) * 0.6);
+ }
+
+ .ss-expand[data-launch][data-open] > .ss-expand__content {
+ opacity: 1;
+ transition: opacity calc(var(--ss-expand-ms) * 0.4) ease calc(var(--ss-expand-ms) * 0.3);
+ }
+
+ /* The closed thing's own face, drawn at its own size and scaled with the
+ window: it blows up and fades as the app grows out of it, and comes back as
+ the window lands on it */
+ .ss-expand__face {
+ position: absolute;
+ top: 0;
+ left: 0;
+ transform-origin: 0 0;
+ pointer-events: none;
+ transition: opacity calc(var(--ss-expand-ms) * 0.3) ease calc(var(--ss-expand-ms) * 0.65);
+ }
+
+ /* It is placed by the closed thing's measured box, so that box is all it
+ takes: margins it has where it lives (a notification's side margins) would
+ shift it off the original the moment it takes over */
+ .ss-expand__face > * {
+ box-sizing: border-box;
+ width: 100%;
+ height: 100%;
+ margin: 0;
+ }
+
+ .ss-expand[data-open] > .ss-expand__face {
+ opacity: 0;
+ transition: opacity calc(var(--ss-expand-ms) * 0.4) ease;
+ }
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/Expand.tsx b/skills/imported/benjitaylor/stageshow/kit/motion/Expand.tsx
new file mode 100644
index 0000000..97a76b1
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/motion/Expand.tsx
@@ -0,0 +1,109 @@
+import { useLayoutEffect, useRef, useState, type HTMLAttributes, type ReactNode } from 'react'
+import { useReducedMotion } from '../reduced-motion'
+import type { Rect } from '../measure'
+import './Expand.css'
+
+/**
+ * A card that opens into a full screen: one element, never a swap. Closed, it
+ * is clipped to `from` (the card's rect from a.rect, in stage pixels; it is
+ * converted for you, also inside a Phone, whose screen is scaled again) with
+ * its corners; open, the clip releases to the whole parent.
+ *
+ * Without `face`, as iOS opens an App Store card: lay the children out as the
+ * open screen, top-aligned so the card's header is the screen's header; the
+ * content rides with the card and settles at the top.
+ *
+ * With `face`, as iOS opens an app out of a notification or an icon: `face` is
+ * the closed thing's own look (render the notification again). The whole app,
+ * laid out full size, is scaled down onto it and zooms up to fill the screen,
+ * its top showing first; the face blows up with it and fades, so the app grows
+ * out of the banner. Give the app's background as `--ss-expand-bg`.
+ * `radius` is the card's corner. `duration` defaults to 520ms for a card and
+ * 640ms for a launch.
+ */
+export function Expand({ open, from, radius = 16, duration, face, children, className, style, ...rest }: HTMLAttributes & { open: boolean; from: Rect; radius?: number; duration?: number; face?: ReactNode }) {
+ const ref = useRef(null)
+ // Where the parent sits on the stage, and how much it is scaled on screen
+ // (the stage's scale, times a phone screen's own)
+ const [box, setBox] = useState({ w: 0, h: 0, x: 0, y: 0, k: 1, r: 0 })
+ useLayoutEffect(() => {
+ const p = ref.current?.parentElement
+ const stage = p?.closest('.ss-stage')
+ if (!p || !stage) return
+ const sr = stage.getBoundingClientRect()
+ const pr = p.getBoundingClientRect()
+ const s = sr.width / stage.offsetWidth || 1
+ const k = pr.width / p.offsetWidth || 1
+ // Inside a Phone, open is the display's own corner, not square: the app
+ // keeps its rounded corners all the way in and lands under the bezel's
+ const r = parseFloat(getComputedStyle(p).getPropertyValue('--ss-screen-r')) || 0
+ setBox({ w: p.clientWidth, h: p.clientHeight, x: (pr.left - sr.left) / s + (p.clientLeft * k) / s, y: (pr.top - sr.top) / s + (p.clientTop * k) / s, k: k / s, r })
+ }, [from.x, from.y, from.width, from.height])
+ const reduced = useReducedMotion()
+ // Only opening and closing animate. A new `from` while the state holds (the
+ // first measure, a re-measure) is applied at once: animating it would sweep
+ // the clip in from wherever the unmeasured rect put it, the stage's top left.
+ // A card not measured yet (no size) isn't shown at all
+ const key = `${open}|${from.x}|${from.y}|${from.width}|${from.height}|${box.w}|${box.h}|${box.x}|${box.y}`
+ const last = useRef({ open, key })
+ // Open, the target doesn't depend on `from`, so a re-measure mid-open (the
+ // tapped notification easing back from its press) must not cut it short
+ const moving = last.current.key === key || last.current.open !== open || open
+ useLayoutEffect(() => {
+ last.current = { open, key }
+ })
+ const unmeasured = !open && (!from.width || !from.height || !box.w)
+ // `from` in the parent's own pixels
+ const x = (from.x - box.x) / box.k
+ const y = (from.y - box.y) / box.k
+ const w = from.width / box.k
+ const h = from.height / box.k
+ const r = Math.max(0, box.w - x - w)
+ const b = Math.max(0, box.h - y - h)
+ // A card: a quick start that settles long, like a UIKit spring with no
+ // overshoot. A launch is gentler and longer: with the card's curve the app
+ // is nearly full screen within a tenth of a second, which reads as a jump
+ const curve = face ? 'cubic-bezier(0.25, 0.6, 0.2, 1)' : 'cubic-bezier(0.3, 0.75, 0.1, 1)'
+ const ms = reduced ? 0 : (duration ?? (face ? 640 : 520))
+ const ease = `${ms}ms ${curve}`
+ if (face) {
+ // The app at full size, scaled by the card's width onto the card, its
+ // height clipped to the card's (in the window's own pixels)
+ const s = box.w ? w / box.w : 1
+ const clipH = Math.max(0, box.h - h / s)
+ const shut = { transform: `translate(${x}px, ${y}px) scale(${s})`, clipPath: `inset(0px 0px ${clipH}px 0px round ${radius / s}px)` }
+ const wide = { transform: 'translate(0px, 0px) scale(1)', clipPath: `inset(0px 0px 0px 0px round ${box.r}px)` }
+ return (
+
+
{children}
+
+ {face}
+
+
+ )
+ }
+ const clip = open ? `inset(0px 0px 0px 0px round ${box.r}px)` : `inset(${y}px ${r}px ${b}px ${x}px round ${radius}px)`
+ // Retain the same composited content layer at both ends, so the text does
+ // not re-rasterise when the card finishes opening or closing.
+ const ride = { transform: `translate3d(0, ${open ? 0 : y}px, 0)`, transition: moving ? `transform ${ease}` : 'none' }
+ return (
+
+
+ {children}
+
+
+ )
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/Fly.css b/skills/imported/benjitaylor/stageshow/kit/motion/Fly.css
new file mode 100644
index 0000000..ef88ccc
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/motion/Fly.css
@@ -0,0 +1,51 @@
+/* Fly */
+
+@layer stageshow {
+ .ss-fly {
+ position: absolute;
+ /* Above popovers and menus: what flies usually leaves one */
+ z-index: 30;
+ pointer-events: none;
+ }
+
+ /* The flyer fills its box exactly: no line box under an inline child (which
+ sat it a few px low, then the real element snapped up on landing), and
+ children sized 100% get the full height */
+ .ss-fly > div,
+ .ss-fly__lift {
+ width: 100%;
+ height: 100%;
+ }
+
+ .ss-fly__lift {
+ display: flex;
+ }
+
+ .ss-fly__lift > * {
+ flex: 1 1 auto;
+ max-width: 100%;
+ max-height: 100%;
+ }
+
+ /* held: it buds off what it leaves, fading in where it sits (a value
+ chip's words lie exactly over the value's), never popping in whole */
+ .ss-fly[data-held]:not([data-go]) .ss-fly__lift {
+ animation: ss-fly-bud 0.2s ease-out both;
+ }
+
+ @keyframes ss-fly-bud {
+ from { opacity: 0; }
+ }
+
+ /* A small lift mid-flight that is gone before it lands */
+ .ss-fly[data-go] .ss-fly__lift {
+ animation-name: ss-fly-lift;
+ animation-timing-function: cubic-bezier(0.33, 1, 0.68, 1);
+ animation-fill-mode: both;
+ }
+
+ @keyframes ss-fly-lift {
+ 30% { transform: scale(1.12); filter: drop-shadow(0 6px 10px rgba(0, 0, 0, 0.18)); }
+ 85%, 100% { transform: none; filter: none; }
+ }
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/Fly.tsx b/skills/imported/benjitaylor/stageshow/kit/motion/Fly.tsx
new file mode 100644
index 0000000..d8f1075
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/motion/Fly.tsx
@@ -0,0 +1,91 @@
+import { useEffect, useRef, type ReactNode } from 'react'
+import { useReducedMotion } from '../reduced-motion'
+import type { Rect } from '../measure'
+import { warn } from '../util'
+import './Fly.css'
+
+export interface FlyProps {
+ /** Where it starts and where it lands, in stage pixels (from `rect()`): the source element's own rect and the landing element's own rect, never a slot or wrapper around them. */
+ from: Rect
+ to: Rect
+ /** False waits at `from`, hidden; true shows it there and flies to `to`. Hide the source in the same set that turns it true. */
+ go: boolean
+ /** Shown while it waits at `from`: the thing being carried (under the cursor, before it flies), or a chip budding off a value, which grows out of it (it scales up from the source as it fades in) rather than popping in whole. Off by default, so it never doubles the element it leaves. */
+ held?: boolean
+ /** ms for the flight. Default 750. */
+ duration?: number
+ children: ReactNode
+ className?: string
+}
+
+/**
+ * Carries one element from one place to another along a soft arc: an avatar
+ * into an assignee slot, a file into a folder. It lifts a little on the way
+ * and is back to its final size before it lands, decelerating into place with
+ * no bounce. It is hidden until `go` (unless `held`): hide the source in the
+ * set that turns `go` on. Render the real element at `to` only once it has
+ * landed, in the same set that hides this one (unmount it, or snap `go` back
+ * to false, which hides it), so it reads as one object.
+ *
+ * The flyer is scaled from the `from` box to the `to` box, and its child is
+ * stretched to fill `from`, so draw the child at the start size. A child of a
+ * different shape, or a `to` taken from a slot rather than the element that
+ * lands, changes size in flight and snaps on the handoff (film.mjs: `fly size`).
+ */
+export function Fly({ from: liveFrom, to: liveTo, go, held: shownAtRest = false, duration = 750, children, className }: FlyProps) {
+ const reduced = useReducedMotion()
+ // Both ends are fixed at take-off. Measured live, they move under the flight
+ // (a closing menu scales the start away) and every change restarts the
+ // transition: a creep near the end, then a jump when the real one appears.
+ const held = useRef<{ from: Rect; to: Rect } | null>(null)
+ if (!go) held.current = null
+ else if (!held.current) held.current = { from: liveFrom, to: liveTo }
+ const { from: start, to } = held.current ?? { from: liveFrom, to: liveTo }
+ // An empty start (nothing measured yet) takes the landing size, so the
+ // flyer never grows from a point
+ const from = start.width ? start : { ...start, width: to.width || 1, height: to.height || 1 }
+ const at = go ? to : from
+ const ms = reduced ? 0 : duration
+ const sx = at.width / (from.width || 1)
+ // A child that doesn't fill the start box takes off or lands at the wrong size
+ const body = useRef(null)
+ useEffect(() => {
+ const el = body.current?.firstElementChild as HTMLElement | null
+ if (!el || !start.width) return
+ if (Math.abs(el.offsetWidth - from.width) > 1 || Math.abs(el.offsetHeight - from.height) > 1)
+ warn(`Fly: the child is ${el.offsetWidth}×${el.offsetHeight} but takes off from ${Math.round(from.width)}×${Math.round(from.height)}. Draw it at the start size, and pass the landing element's own rect as \`to\`.`)
+ }, [from.width, from.height, start.width])
+ return (
+ // Horizontal and vertical travel on slightly different curves make a gentle
+ // arc. Far apart curves (a fast-out x) read as a skid sideways, then a drop.
+
+
+
+ {children}
+
+
+
+ )
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/Follow.css b/skills/imported/benjitaylor/stageshow/kit/motion/Follow.css
new file mode 100644
index 0000000..037aaa4
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/motion/Follow.css
@@ -0,0 +1,17 @@
+/* Follow */
+
+@layer stageshow {
+ .ss-follow {
+ position: absolute;
+ top: 0;
+ left: 0;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.18s ease;
+ will-change: transform;
+ }
+
+ .ss-follow[data-show] {
+ opacity: 1;
+ }
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/Follow.tsx b/skills/imported/benjitaylor/stageshow/kit/motion/Follow.tsx
new file mode 100644
index 0000000..5465317
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/motion/Follow.tsx
@@ -0,0 +1,139 @@
+import { useLayoutEffect, useRef, type HTMLAttributes, type ReactNode } from 'react'
+import type { Rect } from '../measure'
+import { useReducedMotion } from '../reduced-motion'
+import './Follow.css'
+
+export interface FollowProps extends HTMLAttributes {
+ /** The rect to sit on, in stage pixels. null hides it. */
+ to: Rect | null
+ /** Higher is snappier. 0.25 is a quick, settled follow. */
+ stiffness?: number
+ children?: ReactNode
+}
+
+/**
+ * An overlay that springs onto a rect and keeps its momentum when the rect
+ * changes: drop targets, selection rings, hover highlights. A CSS transition
+ * restarts from rest on every change, so a target updated cell by cell on a
+ * freeform drag stutters and shakes; this retargets smoothly instead.
+ */
+export function Follow({ to, stiffness = 0.25, className, style, children, ...rest }: FollowProps) {
+ const reduced = useReducedMotion()
+ const el = useRef(null)
+ const cur = useRef(null)
+ const vel = useRef({ x: 0, y: 0, width: 0, height: 0 })
+ const target = useRef(to)
+ target.current = to
+ // The loop sleeps once it has settled; a new target wakes it
+ const wake = useRef<() => void>(() => {})
+ useLayoutEffect(() => {
+ let id = 0
+ let running = false
+ const k = stiffness
+ const damp = 2 * Math.sqrt(k) * 0.95
+ const frame = 1000 / 60
+ const keys = ['x', 'y', 'width', 'height'] as const
+ let previous = performance.now()
+ let remainder = 0
+ let simulation = cur.current ? { ...cur.current } : null
+ let velocity = { ...vel.current }
+ let priorTarget: Rect | null = null
+ const integrate = (c: Rect, v: typeof velocity, t: Rect) => {
+ for (const key of keys) {
+ v[key] += (t[key] - c[key]) * k - v[key] * damp
+ c[key] += v[key]
+ }
+ }
+ const step = (time = performance.now()) => {
+ const elapsed = Math.min(100, Math.max(0, time - previous))
+ previous = time
+ const t = target.current
+ const node = el.current
+ // Hidden: forget where it was, so it next appears on its target rather than sliding in from the last one
+ if (!t || reduced) {
+ cur.current = t ? { ...t } : null
+ vel.current = { x: 0, y: 0, width: 0, height: 0 }
+ simulation = cur.current ? { ...cur.current } : null
+ velocity = { ...vel.current }
+ remainder = 0
+ }
+ if (node && t) {
+ if (!cur.current) cur.current = { ...t }
+ if (!simulation || !priorTarget || keys.some(key => priorTarget![key] !== t[key])) {
+ // Retarget from the last rendered position and velocity, including
+ // a partial simulation step, so an interruption cannot rewind.
+ simulation = { ...cur.current }
+ velocity = { ...vel.current }
+ remainder = 0
+ }
+ priorTarget = { ...t }
+ const c = cur.current
+ const v = vel.current
+ // Preserve the existing 60Hz spring trajectory. Interpolate its next
+ // step at higher refresh rates instead of integrating faster per RAF.
+ remainder += elapsed
+ while (remainder >= frame - 1e-6) {
+ integrate(simulation, velocity, t)
+ remainder = Math.max(0, remainder - frame)
+ }
+ const next = { ...simulation }, nextVelocity = { ...velocity }
+ integrate(next, nextVelocity, t)
+ const part = remainder / frame
+ for (const key of keys) {
+ c[key] = simulation[key] + (next[key] - simulation[key]) * part
+ v[key] = velocity[key] + (nextVelocity[key] - velocity[key]) * part
+ if (Math.abs(t[key] - c[key]) < 0.05 && Math.abs(v[key]) < 0.05) {
+ c[key] = t[key]
+ v[key] = 0
+ }
+ }
+ node.style.transform = `translate(${c.x}px, ${c.y}px)`
+ node.style.width = `${c.width}px`
+ node.style.height = `${c.height}px`
+ }
+ const c = cur.current
+ const v = vel.current
+ const settled = !t || !node || (c !== null && v.x === 0 && v.y === 0 && v.width === 0 && v.height === 0 && c.x === t.x && c.y === t.y && c.width === t.width && c.height === t.height)
+ running = !settled
+ if (running) id = requestAnimationFrame(step)
+ }
+ wake.current = () => {
+ if (reduced || !target.current || !cur.current) {
+ // Initial placement and reappearance are layout, not spring motion.
+ // A paused host must not need Step just to reveal the first target.
+ cancelAnimationFrame(id)
+ running = false
+ step()
+ return
+ }
+ if (running || keys.every(key => cur.current![key] === target.current![key] && vel.current[key] === 0)) return
+ running = true
+ previous = performance.now()
+ id = requestAnimationFrame(step)
+ }
+ wake.current()
+ return () => {
+ running = false
+ cancelAnimationFrame(id)
+ wake.current = () => {}
+ }
+ }, [stiffness, reduced])
+ useLayoutEffect(() => wake.current(), [to?.x, to?.y, to?.width, to?.height, !to])
+ return (
+
+ {children}
+
+ )
+}
+
+/**
+ * Which cell a value falls in, sticky at the borders: it only changes once
+ * the value is `hysteresis` of a cell past the edge, so a drag along a border
+ * doesn't flicker between two cells.
+ */
+export function cellOf(value: number, size: number, prev: number | null, hysteresis = 0.2) {
+ const raw = Math.floor(value / size)
+ if (prev === null || raw === prev) return raw
+ const edge = raw > prev ? (prev + 1) * size : prev * size
+ return Math.abs(value - edge) > size * hysteresis ? raw : prev
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/Popover.css b/skills/imported/benjitaylor/stageshow/kit/motion/Popover.css
new file mode 100644
index 0000000..35027f3
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/motion/Popover.css
@@ -0,0 +1,65 @@
+/* Popover */
+
+@layer stageshow {
+ .ss-popover {
+ position: absolute;
+ /* Above connectors and flights: an open menu is the topmost surface */
+ z-index: 25;
+ isolation: isolate;
+ /* The surface and its shadow are drawn on ::before, so clipping the popover
+ cuts its shadow off. A sliding submenu clips an inner track instead. */
+ overflow: visible !important;
+ padding: 4px;
+ border-radius: var(--ss-radius-lg);
+ opacity: 0;
+ pointer-events: none;
+ /* Long enough to read as a fade at a demo's pace: at 0.15s it falls
+ between frames and the menu seems to vanish */
+ transition: opacity 0.3s ease;
+ }
+
+ /* The surface scales a touch from the origin; the text only fades. Text that
+ ends a scale is redrawn snapped to the pixel grid and wobbles as it lands,
+ so it never rides the transform. Restyle it with --ss-popover-bg and
+ --ss-popover-shadow rather than background and box-shadow. */
+ .ss-popover::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ z-index: -1;
+ border-radius: inherit;
+ background: var(--ss-popover-bg, var(--ss-raised));
+ box-shadow: var(--ss-popover-shadow, var(--ss-shadow-float));
+ transform: scale(0.96);
+ transform-origin: var(--ss-popover-origin, top left);
+ transition: transform 0.3s ease;
+ pointer-events: none;
+ }
+
+ .ss-popover[data-show] {
+ opacity: 1;
+ transition: opacity 0.18s ease;
+ }
+
+ .ss-popover[data-show]::before {
+ transform: none;
+ transition: transform 0.24s cubic-bezier(0.22, 1, 0.36, 1);
+ }
+
+ /* Closing, it leaves as one object: the whole popover, contents and all,
+ shrinks a touch toward its origin as it fades. A surface that shrank
+ alone would slip out from under its contents and leave them floating.
+ (Text landing at the end of a scale wobbles; on the way out it ends
+ invisible, so that doesn't apply.) Once it has faded (data-instant),
+ the surface takes its opening scale again, out of sight */
+ .ss-popover:not([data-show]):not([data-instant]) {
+ transform: scale(0.96);
+ transform-origin: var(--ss-popover-origin, top left);
+ transition: opacity 0.24s ease, transform 0.24s cubic-bezier(0.4, 0, 1, 1);
+ }
+
+ .ss-popover:not([data-show]):not([data-instant])::before {
+ transform: none;
+ transition: none;
+ }
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/Popover.tsx b/skills/imported/benjitaylor/stageshow/kit/motion/Popover.tsx
new file mode 100644
index 0000000..f0ffbb4
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/motion/Popover.tsx
@@ -0,0 +1,38 @@
+import { useEffect, useRef, useState, type CSSProperties, type HTMLAttributes, type ReactNode } from 'react'
+import './Popover.css'
+
+/** CSS transform-origin keywords: a side, or a vertical and horizontal pair. */
+export type PopoverOrigin = 'top' | 'bottom' | 'left' | 'right' | 'center' | `${'top' | 'bottom' | 'center'} ${'left' | 'right' | 'center'}` | `${'left' | 'right'} center`
+
+/**
+ * A floating surface anchored to something: a menu, a snooze picker, a prompt
+ * bar under a selection. It fades and scales a touch from `origin`, the side
+ * nearest its trigger; the box itself never resizes while it opens. Position
+ * it from rect() of the trigger.
+ */
+export function Popover({ show, origin = 'top left', className, style, children, ...rest }: HTMLAttributes & { show: boolean; origin?: PopoverOrigin }) {
+ // Closing, it keeps the place and contents it had open: a scene that resets
+ // the anchor or the items as it closes would otherwise make the fading
+ // popover jump or change under the eye. Once it has faded, it lets go and
+ // takes the live contents instantly, out of sight, so a change made while
+ // it was hidden (a list reset for the next loop) doesn't play when it reopens.
+ const held = useRef<{ style?: CSSProperties; children: ReactNode; origin: PopoverOrigin } | null>(null)
+ const [released, setReleased] = useState(false)
+ useEffect(() => {
+ if (show) return setReleased(false)
+ const t = setTimeout(() => setReleased(true), 320)
+ return () => clearTimeout(t)
+ }, [show])
+ if (show) held.current = { style, children, origin }
+ const shown = show || released || !held.current ? { style, children, origin } : held.current
+ return (
+
+ {shown.children}
+
+ )
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/Roll.css b/skills/imported/benjitaylor/stageshow/kit/motion/Roll.css
new file mode 100644
index 0000000..ee8d807
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/motion/Roll.css
@@ -0,0 +1,77 @@
+/* Roll */
+
+@layer stageshow {
+ .ss-roll {
+ white-space: pre;
+ }
+
+ /* Only the changed part rolls; it clips its own motion and eases to the new width */
+ .ss-roll__mid {
+ display: inline-grid;
+ /* Clip the vertical roll only; the width never cuts text, because the box
+ holds the wider of the two lines while they swap */
+ clip-path: inset(0 -100vw);
+ vertical-align: bottom;
+ transition: width 0.5s cubic-bezier(0.22, 1, 0.36, 1);
+ }
+
+ .ss-roll__item {
+ grid-area: 1 / 1;
+ justify-self: start;
+ width: max-content;
+ }
+
+ /* Pinned at its right edge: the words change against that edge, so a narrower
+ word doesn't ride the box's left edge as it closes */
+ .ss-roll[data-align='end'] .ss-roll__mid {
+ /* The column is the box itself, not the widest word, so the right edge
+ holds while the box eases and the wider word spills left, unclipped */
+ grid-template-columns: 100%;
+ }
+
+ .ss-roll[data-align='end'] .ss-roll__item {
+ justify-self: unsafe end;
+ }
+
+ .ss-roll__char {
+ display: inline-block;
+ white-space: pre;
+ animation: ss-roll-in 0.55s cubic-bezier(0.22, 1, 0.36, 1) both;
+ }
+
+ .ss-roll__item[data-out] {
+ /* The old text is mostly gone before the new rises (it starts 110ms later) */
+ animation: ss-roll-out 0.28s cubic-bezier(0.55, 0, 1, 0.45) both;
+ }
+
+ /* A full line of travel each way, so the old and new text never overlap */
+ @keyframes ss-roll-in {
+ from { opacity: 0; filter: blur(2px); transform: translateY(100%); }
+ }
+
+ @keyframes ss-roll-out {
+ to { opacity: 0; filter: blur(2px); transform: translateY(-100%); }
+ }
+
+ /* Labels on controls crossfade in place instead of rolling a full line */
+ .ss-roll[data-variant='fade'] .ss-roll__char {
+ animation-name: ss-fade-in;
+ animation-duration: 0.35s;
+ animation-delay: 0.12s;
+ }
+
+ .ss-roll[data-variant='fade'] .ss-roll__item[data-out] {
+ animation-name: ss-fade-out;
+ animation-duration: 0.16s;
+ }
+
+ /* The width eases in step with the crossfade, and nothing paints outside it */
+ .ss-roll[data-variant='fade'] .ss-roll__mid {
+ clip-path: inset(0);
+ transition-duration: 0.4s;
+ }
+
+ @keyframes ss-fade-out {
+ to { opacity: 0; filter: blur(2px); transform: translateY(-3px); }
+ }
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/Roll.tsx b/skills/imported/benjitaylor/stageshow/kit/motion/Roll.tsx
new file mode 100644
index 0000000..f4b8ff3
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/motion/Roll.tsx
@@ -0,0 +1,191 @@
+import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from 'react'
+import { useReducedMotion } from '../reduced-motion'
+import './Roll.css'
+
+/**
+ * Text that rolls when it changes. Only the part that differs moves: in
+ * "$0.01 per 1,000" to "$0.0098 per 1,000", "$0.0" and " per 1,000" hold still
+ * while the old digits slide up and out and the new ones rise in one by one.
+ * Words move whole: "Checking calendars" to "Everyone can make it" rolls word
+ * by word, and a change never splits a word.
+ * The changing part eases to its new width, so the text after it moves with it.
+ * For prices, counts in words, statuses and labels.
+ */
+export function Roll({ delay = 0, children, ...rest }: Parameters[0] & {
+ /** Hold the old text this many ms before rolling: a total that changes
+ * after the row that caused it has landed, not on the same frame. */
+ delay?: number
+}) {
+ const reduced = useReducedMotion()
+ const [held, setHeld] = useState(children)
+ useEffect(() => {
+ if (!delay || reduced) return setHeld(children)
+ const id = setTimeout(() => setHeld(children), delay)
+ return () => clearTimeout(id)
+ }, [children, delay, reduced])
+ return {delay && !reduced ? held : children}
+}
+
+function RollNow({
+ children,
+ stagger = 45,
+ variant = 'roll',
+ align,
+ className,
+ style,
+}: {
+ children: string
+ stagger?: number
+ /** The side the text is pinned to. Found from the layout when left out: a
+ * right-aligned value (text-align right, or last in a space-between or
+ * flex-end row) changes against its right edge, so it never drifts. */
+ align?: 'start' | 'end'
+ /** 'roll' slides text up through the line, for numbers and inline text. 'fade'
+ * crossfades in place with a small lift, for labels on buttons and other
+ * controls, where a full roll shows two lines at once. */
+ variant?: 'roll' | 'fade'
+ className?: string
+ style?: CSSProperties
+}) {
+ const reduced = useReducedMotion()
+ const [view, setView] = useState({ text: children, head: 0, tail: 0, old: '', key: 0, numeric: true })
+ const mid = useRef(null)
+ const root = useRef(null)
+ const fromWidth = useRef(null)
+ // Inline text has no computed width. Use the line box's scale to convert
+ // its painted width back to local CSS pixels, including inside a Phone or
+ // Camera. offsetWidth rounds away the fractions that keep a suffix still.
+ const rootWidth = () => {
+ if (!root.current || !mid.current) return 0
+ const height = parseFloat(getComputedStyle(mid.current).height)
+ const scale = height > 0 ? mid.current.getBoundingClientRect().height / height : 1
+ return root.current.getBoundingClientRect().width / (scale || 1)
+ }
+ const [side, setSide] = useState<'start' | 'end'>(align ?? 'start')
+ useLayoutEffect(() => {
+ if (align) return setSide(align)
+ const el = root.current
+ if (!el?.parentElement) return
+ // Look through wrappers that only hold the roll (a , a value span):
+ // they size to it, so where the wrapper sits is where the text pins
+ let box: HTMLElement = el
+ while (box.parentElement && box.parentElement.childElementCount === 1 && Math.abs(box.parentElement.getBoundingClientRect().width - box.getBoundingClientRect().width) < 1) box = box.parentElement
+ const parent = box.parentElement
+ if (!parent) return
+ const p = getComputedStyle(parent)
+ const flex = /flex/.test(p.display) && p.flexDirection.startsWith('row')
+ // Pushed to the right edge by an auto margin (or anything else) reads the
+ // same from the layout: flush with the row's end, slack before it
+ const pushed = () => {
+ const prev = box.previousElementSibling
+ const r = box.getBoundingClientRect()
+ const pr = parent.getBoundingClientRect()
+ const flush = Math.abs(pr.right - parseFloat(p.paddingRight) - parseFloat(p.borderRightWidth) - r.right) < 1
+ const slack = prev ? r.left - prev.getBoundingClientRect().right - (parseFloat(p.columnGap) || 0) : 0
+ return flush && slack > 1
+ }
+ const end =
+ /right|end/.test(getComputedStyle(el).textAlign) ||
+ (flex && box === parent.lastElementChild && (/space-between|flex-end|^end/.test(p.justifyContent) || pushed()))
+ setSide(end ? 'end' : 'start')
+ }, [align])
+ useEffect(() => {
+ if (reduced) {
+ setView({ text: children, head: 0, tail: 0, old: '', key: 0, numeric: true })
+ return
+ }
+ if (view.text === children) return
+ const a = view.text
+ const b = children
+ let head = 0
+ while (head < a.length && head < b.length && a[head] === b[head]) head++
+ let tail = 0
+ while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail++
+ // Digits can change one at a time; words can't. Snap a wordy change out to
+ // whole words.
+ const numeric = /^[\d.,$€£%+\-−\s]*$/.test(a.slice(head, a.length - tail) + b.slice(head, b.length - tail))
+ if (!numeric) {
+ const space = (c: string | undefined) => c === undefined || /\s/.test(c)
+ while (head > 0 && !space(b[head - 1]) && !space(b[head])) head--
+ while (tail > 0 && !space(b[b.length - tail - 1]) && !space(b[b.length - tail])) tail--
+ }
+ const key = view.key + 1
+ // Capture the rendered width before repartitioning head/middle/tail.
+ // During interruption this is between endpoints, not the old target.
+ fromWidth.current = rootWidth()
+ setView({ text: b, head, tail, old: a.slice(head, a.length - tail), key, numeric })
+ const id = setTimeout(() => setView((v) => (v.key === key ? { ...v, old: '' } : v)), 700)
+ return () => clearTimeout(id)
+ }, [children, reduced]) // eslint-disable-line react-hooks/exhaustive-deps
+ // While the old and new text swap, the box starts at the old text's width,
+ // opens to the wider of the two so neither runs into what follows, then
+ // settles to the new width. Set on the element directly so the starting
+ // width never animates from a stale value.
+ useLayoutEffect(() => {
+ const el = mid.current
+ const live = el?.querySelector('[data-live]')
+ const out = el?.querySelector('[data-out]')
+ if (!el || !live) return
+ const width = (node: HTMLElement) => parseFloat(getComputedStyle(node).width) || 0
+ const next = width(live)
+ if (reduced || view.key === 0) {
+ el.style.transition = 'none'
+ el.style.width = `${next}px`
+ fromWidth.current = null
+ return
+ }
+ // Repartitioning moves unchanged text out of the grid. Subtract its new
+ // width from the old rendered total so that move itself changes no room.
+ const affixes = rootWidth() - width(el)
+ const previous = fromWidth.current
+ fromWidth.current = null
+ const start = previous === null ? (out ? width(out) : 0) : Math.max(0, previous - affixes)
+ el.style.transition = 'none'
+ el.style.width = `${start}px`
+ void el.offsetWidth
+ el.style.transition = ''
+ // A control's label: the box and the crossfade are one move, straight to
+ // the new width, and the box clips the old label as it goes
+ if (variant === 'fade') {
+ el.style.width = `${next}px`
+ return
+ }
+ const widest = Math.max(next, start, out ? width(out) : 0)
+ el.style.width = `${widest}px`
+ if (widest === next) return
+ const id = setTimeout(() => (el.style.width = `${next}px`), 360)
+ return () => clearTimeout(id)
+ }, [view.key, reduced, reduced ? children : undefined])
+ // The settled render must not wait for an effect: a Stage poster can
+ // arrive in the same commit that disables its CSS animations.
+ const { text, head, tail, old, key, numeric } = reduced
+ ? { text: children, head: 0, tail: 0, old: '', key: 0, numeric: true }
+ : view
+ const now = text.slice(head, text.length - tail)
+ // Characters for numbers, whole words (with their spaces) for text
+ const units = numeric ? now.split('') : now.split(/(?<=\s)/)
+ return (
+
+ {text.slice(0, head)}
+
+ {old && (
+
+ {old}
+
+ )}
+
+ {key > 0 && variant === 'fade'
+ ? {now}
+ : key > 0
+ ? units.map((c, i) => (
+
+ {c}
+
+ ))
+ : now}
+
+
+ {text.slice(text.length - tail)}
+
+ )
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/Scroll.css b/skills/imported/benjitaylor/stageshow/kit/motion/Scroll.css
new file mode 100644
index 0000000..cb163ea
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/motion/Scroll.css
@@ -0,0 +1,49 @@
+/* Scroll */
+
+@layer stageshow {
+ .ss-scroll {
+ overflow: hidden;
+ }
+
+ /* Only once it has scrolled: both edges soften over the rows passing them */
+ .ss-scroll[data-scrolled] {
+ mask-image: linear-gradient(to bottom, transparent, #000 18px, #000 calc(100% - 18px), transparent);
+ }
+
+ /* Scroll: the overlay bar shows while scrolling and fades once it rests */
+ .ss-scroller {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ }
+
+ .ss-scroller > .ss-scroll {
+ flex: 1;
+ min-height: 0;
+ }
+
+ .ss-scroller__thumb {
+ position: absolute;
+ top: 0;
+ right: 2px;
+ width: 6px;
+ height: var(--ss-thumb-h, 0);
+ border-radius: 3px;
+ background: var(--ss-ink-5);
+ transform: translateY(var(--ss-thumb-y, 0));
+ opacity: 0;
+ transition: opacity 0.5s ease 0.6s;
+ pointer-events: none;
+ }
+
+ .ss-scroller[data-scrolling] .ss-scroller__thumb,
+ .ss-scroller[data-bar="always"] .ss-scroller__thumb {
+ opacity: 1;
+ transition: opacity 0.15s ease;
+ }
+
+ .ss-stage[data-theme="dark"] .ss-scroller__thumb {
+ background: rgba(255, 255, 255, 0.28);
+ }
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/Scroll.tsx b/skills/imported/benjitaylor/stageshow/kit/motion/Scroll.tsx
new file mode 100644
index 0000000..461aa9b
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/motion/Scroll.tsx
@@ -0,0 +1,24 @@
+import { useLayoutEffect, useRef, type CSSProperties, type ReactNode } from 'react'
+import { thumb } from '../measure'
+import './Scroll.css'
+
+/**
+ * A scrolling area with a scrollbar, driven by `a.scroll(name, to)`. The bar
+ * is macOS's overlay bar: hidden at rest, shown while the content moves, fading
+ * once it stops. `bar="always"` keeps it visible (Windows, or a still that
+ * needs to say "this scrolls"). Rows cut at an edge soften once it has scrolled.
+ */
+export function Scroll({ name, bar = 'overlay', className, style, children, ...rest }: { name: string; bar?: 'overlay' | 'always' | 'none'; className?: string; style?: CSSProperties; children: ReactNode } & Record<`data-${string}`, unknown>) {
+ const ref = useRef(null)
+ useLayoutEffect(() => {
+ if (ref.current) thumb(ref.current)
+ })
+ return (
+
+
+ {children}
+
+ {bar !== 'none' && }
+
+ )
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/States.css b/skills/imported/benjitaylor/stageshow/kit/motion/States.css
new file mode 100644
index 0000000..dbd709d
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/motion/States.css
@@ -0,0 +1,49 @@
+/* States */
+
+@layer stageshow {
+ .ss-states {
+ display: inline-grid;
+ align-items: center;
+ justify-items: center;
+ }
+
+ /* The old state hands off to the new one: it's mostly gone as the new one
+ starts, so they never read as two labels, but the control is never empty */
+ .ss-states__item {
+ display: inline-flex;
+ grid-area: 1 / 1;
+ align-items: center;
+ gap: 6px;
+ /* The item keeps the line box the plain label would have had: its text
+ inherits the line-height of the spot it sits in, so a Roll or any
+ other part that clips to its line (Roll's clip-path does) has a line
+ to show in. A zero line box here once sized the item to its glyphs,
+ which also clipped a Roll inside it to nothing and left a text-only
+ States no height at all. A small line under a name sets its own
+ line-height, as Row's subtitle does */
+ opacity: 0;
+ filter: blur(2px);
+ /* Transform travel leaves the grid's baseline in place. Moving margins
+ let the hidden first item shift the incoming label and adjacent text. */
+ will-change: transform;
+ transform: translate3d(0, 4px, 0);
+ transition: opacity 0.15s ease, filter 0.15s ease, transform 0s 0.15s;
+ }
+
+ .ss-states__item[data-on] {
+ opacity: 1;
+ filter: none;
+ transform: translate3d(0, 0, 0);
+ transition: opacity 0.25s ease-out 0.07s, filter 0.25s ease-out 0.07s, transform 0.3s cubic-bezier(0.22, 1, 0.36, 1) 0.07s;
+ }
+
+ .ss-states[data-align="start"] { justify-items: start; }
+
+ .ss-states[data-align="end"] { justify-items: end; }
+
+ .ss-states[data-reduced] > .ss-states__item {
+ transform: translate3d(0, 0, 0);
+ filter: none;
+ transition: opacity 0.12s ease;
+ }
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/States.tsx b/skills/imported/benjitaylor/stageshow/kit/motion/States.tsx
new file mode 100644
index 0000000..1894b3c
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/motion/States.tsx
@@ -0,0 +1,29 @@
+import type { CSSProperties, ReactNode } from 'react'
+import { useReducedMotion } from '../reduced-motion'
+import './States.css'
+
+/**
+ * The states of one control stacked in one place and crossfaded: a button's
+ * "Deploy", spinner and "Deployed"; a status's "Building" and "Ready". The
+ * outgoing state is gone before the incoming one arrives, so they never
+ * overlap, and the box holds the largest state, so nothing around it shifts.
+ * Each state keeps the line box a plain label would have in that spot, so a
+ * `Roll` or `Count` inside a state shows whole; give a small line under a
+ * name its own line-height, as a subtitle would.
+ *
+ * Not for a label centered with an icon in a box that keeps its width (a
+ * button's "Sending" then "Sent"): the held box leaves the shorter word off
+ * center. Use `Swap` there, so the group re-centers as the label changes.
+ */
+export function States({ value, states, align = 'center', className, style, ...rest }: { value: K; states: Record; /** Where a smaller state sits in the box: start for text in a row, center for a button's label, end for a right-aligned value. */ align?: 'start' | 'center' | 'end'; className?: string; style?: CSSProperties } & Record<`data-${string}`, unknown>) {
+ const reduced = useReducedMotion()
+ return (
+
+ {(Object.keys(states) as K[]).map((k) => (
+
+ {states[k]}
+
+ ))}
+
+ )
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/Stream.css b/skills/imported/benjitaylor/stageshow/kit/motion/Stream.css
new file mode 100644
index 0000000..605699d
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/motion/Stream.css
@@ -0,0 +1,32 @@
+/* Stream */
+
+@layer stageshow {
+ .ss-stream__w {
+ opacity: 0;
+ filter: blur(3px);
+ border-radius: 3px;
+ box-decoration-break: clone;
+ -webkit-box-decoration-break: clone;
+ transition: opacity 0.35s ease, filter 0.35s ease, background-color 0.3s ease;
+ }
+
+ .ss-stream__w[data-on] {
+ opacity: 1;
+ filter: none;
+ }
+
+ /* Changed words, joined into runs */
+ .ss-stream__w[data-mark] {
+ background-color: var(--ss-success-soft);
+ }
+
+ .ss-stream__w[data-join] {
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+ }
+
+ .ss-stream__w[data-joined] {
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+ }
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/Stream.tsx b/skills/imported/benjitaylor/stageshow/kit/motion/Stream.tsx
new file mode 100644
index 0000000..c036578
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/motion/Stream.tsx
@@ -0,0 +1,30 @@
+import type { CSSProperties } from 'react'
+import './Stream.css'
+
+/**
+ * Text written in word by word, as a model streams it. Every word is laid out
+ * from the start in its final place, hidden, so nothing reflows as it
+ * arrives. `count` is how many words show; drive it from the script. `mark`
+ * highlights words (what changed in a rewrite), and neighboring marked words
+ * join into one run; return false for all to fade the marks away.
+ */
+export function Stream({ text, count, mark, className, style, ...rest }: { text: string; count: number; mark?: (word: string, i: number) => boolean; className?: string; style?: CSSProperties } & Record<`data-${string}`, unknown>) {
+ const words = text.split(' ')
+ const marked = words.map((w, i) => !!mark?.(w, i))
+ return (
+
+ {words.map((w, i) => {
+ const join = marked[i] && marked[i + 1]
+ return (
+
+
+ {w}
+ {join ? ' ' : ''}
+
+ {!join && i < words.length - 1 ? ' ' : ''}
+
+ )
+ })}
+
+ )
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/Swap.css b/skills/imported/benjitaylor/stageshow/kit/motion/Swap.css
new file mode 100644
index 0000000..9492b1f
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/motion/Swap.css
@@ -0,0 +1,48 @@
+/* Swap */
+
+@layer stageshow {
+ .ss-swap {
+ position: relative;
+ display: inline-block;
+ vertical-align: top;
+ /* Sideways only: a word can't spill while the box narrows, but a label
+ trimmed to cap height keeps its overshoot above and below */
+ overflow-x: clip;
+ overflow-y: visible;
+ }
+
+ /* While the box resizes, its edge is soft: a word passing it fades out
+ rather than being sliced */
+ .ss-swap[data-swapping] {
+ mask-image: linear-gradient(to right, #000 calc(100% - 8px), transparent);
+ }
+
+ .ss-swap__in,
+ .ss-swap__out {
+ display: block;
+ width: max-content;
+ max-width: 100%;
+ /* A label trimmed to its cap height (text-box on the Swap) trims both
+ copies alike: the fading one is its own box and would sit lower */
+ text-box: inherit;
+ }
+
+ .ss-swap__out {
+ position: absolute;
+ top: 0;
+ left: 0;
+ animation: ss-swap-out ease forwards;
+ }
+
+ .ss-swap__in {
+ animation: ss-swap-in cubic-bezier(0.25, 0.8, 0.25, 1) both;
+ }
+
+ @keyframes ss-swap-out { to { opacity: 0; filter: blur(2px); } }
+
+ @keyframes ss-swap-in { 0%, 30% { opacity: 0; filter: blur(2px); } 100% { opacity: 1; filter: none; } }
+
+ @media (prefers-reduced-motion: reduce) {
+ .ss-swap__in, .ss-swap__out { animation-duration: 1ms !important; }
+ }
+}
diff --git a/skills/imported/benjitaylor/stageshow/kit/motion/Swap.tsx b/skills/imported/benjitaylor/stageshow/kit/motion/Swap.tsx
new file mode 100644
index 0000000..7f0b32d
--- /dev/null
+++ b/skills/imported/benjitaylor/stageshow/kit/motion/Swap.tsx
@@ -0,0 +1,103 @@
+import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'
+import { useReducedMotion } from '../reduced-motion'
+import './Swap.css'
+
+/**
+ * Text (or anything) replaced in place: the old fades out, the new fades in,
+ * and the box eases between their sizes so the lines around it move with it,
+ * never snap. Key it on what changed: `{text}`.
+ */
+export function Swap({ id, children, duration = 450, className, style, ...rest }: { id: string | number; children: ReactNode; duration?: number; className?: string; style?: CSSProperties } & Record<`data-${string}`, unknown>) {
+ const reduced = useReducedMotion()
+ const box = useRef(null)
+ const inner = useRef(null)
+ const [prev, setPrev] = useState<{ id: string | number; node: ReactNode; w?: number } | null>(null)
+ // The id on screen, kept in state so a render React runs twice (Strict
+ // Mode) still hands the old content to the fading copy
+ const [shownId, setShownId] = useState(id)
+ const last = useRef(children)
+ const [size, setSize] = useState<{ w: number; h: number } | null>(null)
+ // True while a swap's size transition runs: measuring then would cancel it
+ const swapping = useRef(false)
+ swapping.current = !reduced && prev !== null
+ if (shownId !== id) {
+ // The old words keep the width they had, so they never rewrap as the box
+ // narrows under them
+ setPrev(reduced ? null : { id: shownId, node: last.current, w: size?.w })
+ setShownId(id)
+ }
+ // The content last committed: written after commit, never during a render
+ // React may repeat, so the fading copy always holds the old words
+ useLayoutEffect(() => {
+ last.current = children
+ })
+ // Measured on every change of id, and kept current after: a font or the
+ // demo's own CSS arriving late changes the size without a swap
+ useLayoutEffect(() => {
+ const el = inner.current
+ if (!el) return
+ // Measured with the box at its natural size for one synchronous read: the
+ // box still holds the old size, and longer text measured inside it would
+ // be squeezed to the old width. Restored before the browser paints. The
+ // observer skips while a swap runs: switching the transition off for the
+ // read would cancel the height animation and snap the box to its end.
+ const measure = (observed?: boolean) => {
+ const b = box.current
+ if (!b || (observed && swapping.current)) return
+ const { width, height, transition } = b.style
+ const own = { width: el.style.width, maxWidth: el.style.maxWidth }
+ b.style.transition = 'none'
+ b.style.width = ''
+ b.style.height = ''
+ el.style.width = ''
+ el.style.maxWidth = ''
+ // The exact used size, not offsetWidth: that rounds down, and a label
+ // a fraction of a pixel wider than its box wraps onto two lines.
+ // Computed style also ignores the stage's own scale
+ const cs = getComputedStyle(el)
+ const w = parseFloat(cs.width) || 0
+ const h = parseFloat(cs.height) || 0
+ el.style.width = own.width
+ el.style.maxWidth = own.maxWidth
+ b.style.width = width
+ b.style.height = height
+ void b.offsetWidth
+ b.style.transition = transition
+ setSize((s) => (s && s.w === w && s.h === h ? s : { w, h }))
+ }
+ measure()
+ const ro = new ResizeObserver(() => measure(true))
+ ro.observe(el)
+ return () => ro.disconnect()
+ }, [id, reduced])
+ useEffect(() => {
+ if (!prev) return
+ if (reduced) {
+ setPrev(null)
+ return
+ }
+ const t = setTimeout(() => setPrev(null), duration)
+ return () => clearTimeout(t)
+ }, [prev, duration, reduced])
+ // Narrowing, the box waits while the old words fade, so they are never cut
+ // by its edge mid-word; widening, it opens at once for the new ones
+ const shrink = !!(prev && size && prev.w !== undefined && size.w < prev.w)
+ // The soft edge is for words passing a moving edge: an image swapped for
+ // one the same size keeps its own edges
+ const resizing = !!(prev && size && prev.w !== undefined && size.w !== prev.w)
+ return (
+
+ {!reduced && prev && (
+