diff options
| author | user <user@clank> | 2026-08-30 12:49:24 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-30 12:49:24 +0200 |
| commit | a3c5c69d089efd3d778e8b4ef859a6432cd3d07f (patch) | |
| tree | b048c2c08709d671fe92907c952df80359893394 | |
| parent | graphics: a frame the client never got must be said again (diff) | |
| download | slosh-a3c5c69d089efd3d778e8b4ef859a6432cd3d07f.tar.gz slosh-a3c5c69d089efd3d778e8b4ef859a6432cd3d07f.tar.xz slosh-a3c5c69d089efd3d778e8b4ef859a6432cd3d07f.zip | |
web: sloshcast
| -rw-r--r-- | .gitignore | 3 | ||||
| -rw-r--r-- | web/README.md | 7 | ||||
| -rwxr-xr-x | web/build-site | 27 | ||||
| -rw-r--r-- | web/index.html | 347 | ||||
| -rw-r--r-- | web/recordings/Makefile | 31 | ||||
| -rw-r--r-- | web/recordings/PLAN.md | 140 | ||||
| -rw-r--r-- | web/recordings/castgen.py | 356 | ||||
| -rw-r--r-- | web/recordings/demos/config.py | 72 | ||||
| -rw-r--r-- | web/recordings/demos/panes.py | 83 | ||||
| -rw-r--r-- | web/recordings/demos/reflow.py | 48 | ||||
| -rw-r--r-- | web/recordings/demos/scripting.py | 101 | ||||
| -rw-r--r-- | web/recordings/demos/sessions.py | 127 | ||||
| -rw-r--r-- | web/recordings/demos/shaders.py | 95 | ||||
| -rw-r--r-- | web/recordings/player.css | 124 | ||||
| -rw-r--r-- | web/recordings/player.js | 465 | ||||
| -rw-r--r-- | web/recordings/seek.html | 13 | ||||
| -rw-r--r-- | web/recordings/test.html | 26 | ||||
| -rw-r--r-- | web/site.css | 165 |
18 files changed, 2081 insertions, 149 deletions
@@ -22,6 +22,9 @@ __pycache__/ # the landing-page preview video (large binary, lives outside git) web/slosh-preview.mp4 +# rendered sloshcasts: `make -C web/recordings` re-creates them from demos/ +web/recordings/build/ + # the signed macOS artifacts, and the file that says who signs them. # The release *mechanism* is in the repo (Makefile.macos, contrib/macos-*). # macos-release.mk is the one file that is not: it names a team, a certificate diff --git a/web/README.md b/web/README.md index e509cb0..f720bd6 100644 --- a/web/README.md +++ b/web/README.md @@ -10,6 +10,7 @@ demo, assembled into a single static site: | `/docs/` | the documentation | `contrib/gen-docs`, the same pages `make docs` renders | | `/demo/` | the in-browser machine | `demo/`, on its own page so the front page stays light | | `/install.sh` | what `curl \| sudo sh` fetches | `install.sh` here | +| `/recordings/` | scripted feature playbacks the front page embeds | `recordings/`, native format + player — see `recordings/PLAN.md` | ## Build @@ -44,9 +45,9 @@ the one condition the webdemo README already states: `.wasm` must be served as ## TODO -- The demo video is a placeholder (`#video` in `index.html`; the comment there - says exactly what to replace). Drop in `demo.mp4` + `demo-poster.png` when - rendered. +- Every feature in the tour has its recording now; what remains on the + recordings side (a detach demo, storyline polish, static no-JS posters) is + in `recordings/PLAN.md`. - `install.sh` clones `https://slosh.foo/src` (override: `SLOSH_REPO=`). Pin the real public repo URL once it exists — same for the `src` links in `index.html`. diff --git a/web/build-site b/web/build-site index 7dd78f3..c3d96c6 100755 --- a/web/build-site +++ b/web/build-site @@ -17,26 +17,42 @@ set -eu here="$(cd "$(dirname "$0")" && pwd)" root="$(dirname "$here")" -out="$root/build/www" +www="$root/build/www" +# Assemble into a staging directory and swap at the end: a build that fails +# halfway (set -eu) must not have already deleted the site that worked. +out="$root/build/.www-staging" rm -rf "$out" mkdir -p "$out" +publish() { + rm -rf "$www" + mv "$out" "$www" +} + # For now slosh.foo is a coming-soon page; the full site is assembled only # when asked (FULL=1) while it is being finished. if [ "${FULL:-0}" != 1 ]; then cp "$here/coming-soon.html" "$out/index.html" - echo "build-site: $out (coming soon -- FULL=1 for the real site)" + publish + echo "build-site: $www (coming soon -- FULL=1 for the real site)" exit 0 fi -# The front page. +# The front page. Its recordings are below; there is no video. cp "$here/index.html" "$here/site.css" "$here/install.sh" "$out/" -cp "$here/slosh-preview.mp4" "$out/" # The docs, rendered by the same generator `make docs` uses. python3 "$root/contrib/gen-docs" "$out/docs" +# The recordings: scripted feature playbacks (see recordings/PLAN.md), +# re-rendered from their driver scripts so they can never be stale. +make -C "$here/recordings" +mkdir -p "$out/recordings" +cp "$here/recordings/build/"*.json \ + "$here/recordings/player.js" "$here/recordings/player.css" \ + "$out/recordings/" + # The demo, assembled by its own Makefile, on its own page. if [ "${NO_DEMO:-0}" = 1 ]; then echo "build-site: skipping demo (NO_DEMO=1)" @@ -46,5 +62,6 @@ else cp -R "$here/demo/build/site/." "$out/demo/" fi -echo "build-site: $out" +publish +echo "build-site: $www" echo "build-site: serve with: web/demo/serve --dir build/www" diff --git a/web/index.html b/web/index.html index 298f4b1..adcb8f7 100644 --- a/web/index.html +++ b/web/index.html @@ -6,6 +6,7 @@ <title>slosh — a terminal multiplexer</title> <meta name="description" content="slosh: panes, tabs, sessions you can detach from. One static binary, written in C on libghostty-vt."> <link rel="stylesheet" href="site.css"> +<link rel="stylesheet" href="recordings/player.css"> </head> <body> @@ -21,149 +22,205 @@ <header class="hero"> <h1>slosh</h1> - <p class="tagline">A terminal multiplexer — panes, tabs, sessions you can - detach from — written in C on top of + <p class="tagline">a modern terminal multiplexer written in C on top of <a href="https://github.com/ghostty-org/ghostty">libghostty-vt</a>.</p> <p class="hero-links"> <a class="btn" href="#install">install</a> <a class="btn ghost" href="demo/">try it in the browser</a> + <a class="btn ghost" href="docs/">browse docs</a> </p> </header> -<!-- ================================================================ --> +<section id="different" style='margin-top:0;'> + <!-- The feature tour: a terminal pinned on the left, the features scrolling + by on the right. The step under the middle of the viewport is the + active one, and its data-cast picks which recording the terminal + replays (web/recordings/demos/, one script per step). --> + <div class="scrolly"> + <div class="scrolly-stage"> + <div class="stage-pin"> + <div data-sloshcast="recordings/panes.json" data-loop data-name="panes" class="stage-active"></div> + <div data-sloshcast="recordings/sessions.json" data-loop data-name="sessions"></div> + <div data-sloshcast="recordings/reflow.json" data-loop data-name="reflow"></div> + <div data-sloshcast="recordings/config.json" data-loop data-name="config"></div> + <div data-sloshcast="recordings/shaders.json" data-loop data-name="shaders"></div> + <div data-sloshcast="recordings/scripting.json" data-loop data-name="scripting"></div> + </div> + </div> -<section id="video"> - <h2>watch it</h2> + <div class="scrolly-steps"> + <article class="step active" data-cast="panes"> + <h3>Panes and tabs, the way your hands expect.</h3> + <p>One chord to split — or just click, because everything drawn is a + real target.</p> + <ul> + <li>click the middle of a border to split toward it</li> + <li>drag the gap to move the boundary</li> + <li>drag a pane by its title to swap it</li> + <li>zoom, tab away, come back — nothing lost</li> + </ul> + </article> - <video controls preload="metadata" src="slosh-preview.mp4"></video> -</section> + <article class="step" data-cast="sessions"> + <h3>Sessions outlive everything.</h3> + <p>Detach, let the ssh connection die, come back tomorrow: every pane + exactly where you left it.</p> + <ul> + <li>detach mid-thought, reattach mid-thought</li> + <li>keep separate sessions for separate lives — hop between them</li> + <li>a command that died while you were away keeps its output, its + exit status, and offers <code>[re-run]</code></li> + </ul> + </article> -<!-- ================================================================ --> + <article class="step" data-cast="reflow"> + <h3>Layouts that reflow the way you expect.</h3> + <p>The arrangement is derived from the window, never stored — so every + size gets a sensible layout, and nothing drifts.</p> + <ul> + <li>quarter-turn the whole layout in place</li> + <li>shrink until boxes become a tidy list</li> + <li>grow back: the exact arrangement returns</li> + </ul> + </article> -<section id="install"> - <h2>install</h2> + <article class="step" data-cast="config"> + <h3>Make it yours, live.</h3> + <p>One config file, re-read the moment you save — and <kbd>C-a</kbd> + <kbd>e</kbd> opens it right there, in a pane.</p> + <ul> + <li>retheme the whole session in one substitution</li> + <li>padding, gaps, titles — every knob, applied as you watch</li> + <li><code>compact true</code>: a different school of borders entirely</li> + </ul> + </article> - <div class="cmd" data-copy="curl -fsSL https://slosh.foo/install.sh | sudo sh"> - <pre><code><span class="prompt">$</span> curl -fsSL https://slosh.foo/install.sh | sudo sh</code></pre> - <button class="copy" type="button" aria-label="Copy command">copy</button> - </div> + <article class="step" data-cast="shaders"> + <h3>Colour passes, written as expressions.</h3> + <p>Dim, tint, rulers, spotlights — over a pane's cells or over its + frame, with strengths computed from <code>x</code>, <code>y</code>, the + cursor, the clock.</p> + <ul> + <li>tint a frame, rule a column, scanline the lot</li> + <li>a spotlight that follows the cursor as you type</li> + <li><code>:load shine</code> — a shipped preset runs light along the frame</li> + <li>a pane rings unfocused — its border flashes</li> + </ul> + </article> - <p class="aside">Piping a URL into a root shell should make you flinch — - <a href="install.sh">read it first</a>, it is short. Or skip it entirely: - building from source needs <a href="https://ziglang.org">zig</a> 0.16 and - nothing else. No cmake, no autotools, no libraries. The result is one static - binary.</p> + <article class="step" data-cast="scripting"> + <h3>Built to be driven.</h3> + <p>One JSON object per line over the session's socket — the same depth + the keyboard gets. An agent can hold the other end.</p> + <ul> + <li>split and rename panes from a script, replies and all</li> + <li>a program draws status and buttons into its own frame</li> + <li>the click comes back on that program's stdin</li> + </ul> + </article> - <div class="cmd"> - <pre><code><span class="prompt">$</span> git clone https://slosh.foo/src slosh && cd slosh -<span class="prompt">$</span> make vendor <span class="c"># the vendored terminal core, once</span> -<span class="prompt">$</span> make <span class="c"># about a second</span> -<span class="prompt">$</span> sudo install build/slosh /usr/local/bin/</code></pre> + <!-- + <article class="step step-cta"> + <h3>Convinced?</h3> + <p class="hero-links"> + <a class="btn" href="#install">install</a> + <a class="btn ghost" href="demo/">try it in the browser</a> + </p> + </article> +--> + </div> </div> - <p class="aside">On Arch there is a <a href="arch/">pacman repository</a> - with built, signed packages — the <code>[slosh]</code> stanza, the - signing key to enroll, and the PKGBUILD for building it yourself are in - <a href="https://git.sl0p.foo/slosh.git/tree/contrib/arch">contrib/arch</a>.</p> - - <p class="aside">On Windows it is the same one binary: <code>slosh.exe</code>, - Windows 10/11, x86-64 and ARM64, no runtime dependency beyond the OS. Panes - are ConPTYs and sessions are AF_UNIX sockets, so everything below works there - too. It is cross-compiled from a mac or a Linux box - (<code>make -f Makefile.windows</code>), then copied across — - <a href="docs/windows.html">the details</a>.</p> - - <p>Then:</p> - - <div class="cmd"> - <pre><code><span class="prompt">$</span> slosh <span class="c"># attach to session "main", creating it if needed</span> -<span class="prompt">$</span> slosh -s work <span class="c"># a named session</span> -<span class="prompt">$</span> slosh ls <span class="c"># what is running</span></code></pre> - </div> - - <p>Sessions survive detaching, and your ssh connection dying. Then - <kbd>C-a</kbd> <kbd>\</kbd> to split, <kbd>C-a</kbd> <kbd>?</kbd> for the - keys, <kbd>C-a</kbd> <kbd>p</kbd> for every command by name.</p> </section> <!-- ================================================================ --> -<section id="different"> - <h2>what is different about it</h2> - - <dl class="points"> - <dt>The mouse works properly.</dt> - <dd>Click the middle of a border to split toward it, drag a gap to move a - boundary, drag a pane by its title to swap it. Everything drawn registers - what it is as it is drawn, so a click and a preview cannot disagree.</dd> - - <dt>The layout is recomputed, never stored.</dt> - <dd>Small terminal? The tab becomes a list of headers, and comes back to - exactly the arrangement you had. Nothing drifts, because there is nothing - stored to drift.</dd> +<section id="install"> + <h2>install</h2> - <dt>Dead panes keep their output.</dt> - <dd>A pane told to run something keeps what it printed when that thing - exits, with <code>[re-run]</code> and <code>[close]</code> in its - frame.</dd> +<p>we aim to make installing `slosh` as easy as possible. we provide binaries for most popular platforms/architectures on our <a href="">github</a>. +and we also provide some arch package repo's as well as a brew formula, check below! - <dt>Configuration is one KDL file.</dt> - <dd>It reloads on save, can be built from <code>include</code>d pieces, and - refuses a broken file instead of half-applying it.</dd> + <!-- Pure CSS tabs: hidden radios, labels drawn like a slosh tab strip, + one panel per platform. The only JavaScript is the preselect at the + bottom of the page, which checks the radio for the platform reading + this; without it the linux tab is simply checked in the HTML. --> + <div class="install"> + <input class="os" type="radio" name="os" id="os-linux" checked> + <input class="os" type="radio" name="os" id="os-macos"> + <input class="os" type="radio" name="os" id="os-arch"> + <input class="os" type="radio" name="os" id="os-windows"> + <input class="os" type="radio" name="os" id="os-source"> - <dt>Colour passes over cells.</dt> - <dd>Dim, tint, rulers, spotlights, and a border that can flash when a pane - rings — with strengths written as expressions.</dd> + <div class="os-tabs"> + <label for="os-linux">linux</label> + <label for="os-macos">macos</label> + <label for="os-arch">arch</label> + <label for="os-windows">windows</label> + <label for="os-source">source</label> + </div> - <dt>Scriptable to the same depth it is usable.</dt> - <dd>One JSON object per line over the session's socket, and a pane can draw - its own status bar and buttons with an escape sequence.</dd> - </dl> + <div class="os-panel" id="install-linux"> + <div class="cmd" data-copy="curl -fsSL https://slosh.foo/install.sh | sudo sh"> + <pre><code><span class="prompt">$</span> curl -fsSL https://slosh.foo/install.sh | sudo sh</code></pre> + <button class="copy" type="button" aria-label="Copy command">copy</button> + </div> + <p class="aside">Piping a URL into a root shell should make you flinch — + <a href="install.sh">read it first</a>, it is short: git, + <a href="https://ziglang.org">zig</a> 0.16, one static binary into + <code>/usr/local/bin</code>, nothing else touched.</p> + </div> - <p class="aside">The multiplexers we have are crusty, and the things that - make them awkward follow from how the programs are built. So: start over, and - stand on the shoulders of the person who already solved the genuinely hard - part — the terminal core from <a href="https://ghostty.org">ghostty</a> does - the VT parsing, scrollback, selection, images and key encoding. What sits on - top is about 15k lines of C, and recomputes rather than remembers. - The decisions behind it: <a href="docs/design.html">how it - works</a>.</p> -</section> + <div class="os-panel" id="install-macos"> + <div class="cmd" data-copy="brew tap sl0p/slosh https://git.sl0p.foo/homebrew-slosh.git && brew install slosh"> + <pre><code><span class="prompt">$</span> brew tap sl0p/slosh https://git.sl0p.foo/homebrew-slosh.git +<span class="prompt">$</span> brew install slosh</code></pre> + <button class="copy" type="button" aria-label="Copy commands">copy</button> + </div> + <p class="aside">No Homebrew? The linux tab's installer works here too — + it only needs <a href="https://ziglang.org">zig</a> 0.16 and git.</p> + </div> -<!-- ================================================================ --> + <div class="os-panel" id="install-arch"> + <div class="cmd" data-copy="sudo pacman -Sy slosh"> + <pre><code><span class="c"># /etc/pacman.conf</span> +[slosh] +Server = https://slosh.foo/arch/$arch -<section id="try"> - <h2>try it without installing anything</h2> +<span class="prompt">$</span> sudo pacman -Sy slosh</code></pre> + <button class="copy" type="button" aria-label="Copy command">copy</button> + </div> + <p class="aside">Built, signed packages. Enroll the signing key first — + the key, the fingerprint to check and the PKGBUILD are in + <a href="https://git.sl0p.foo/slosh.git/tree/contrib/arch">contrib/arch</a>, + the same instructions at <a href="arch/">arch/</a>.</p> + </div> - <p>The <a href="demo/">demo page</a> boots a riscv64 Linux — - Fabrice Bellard's TinyEMU compiled to WebAssembly — with slosh running as the - thing you land in. Nothing is installed, nothing is uploaded, and reloading - the page throws the machine away. It streams about 11 MiB once, then - never touches the network again.</p> + <div class="os-panel" id="install-windows"> + <div class="cmd"> + <pre><code><span class="prompt">$</span> make -f Makefile.windows <span class="c"># from a mac or linux box</span></code></pre> + </div> + <p class="aside">The same one binary: <code>slosh.exe</code>, Windows + 10/11, x86-64 and ARM64, no runtime dependency beyond the OS. Panes are + ConPTYs, sessions are AF_UNIX sockets, everything on this page works + there. Cross-compiled, then copied across — + <a href="docs/windows.html">the details</a>.</p> + </div> - <p class="aside">It is an emulator interpreting every instruction, so the - machine is honest but not fast. The real thing redraws in microseconds.</p> -</section> - -<!-- ================================================================ --> - -<section id="docs"> - <h2>docs</h2> - - <ul class="doclist"> - <li><a href="docs/keys.html">keys</a><span> — the leader, the defaults, rebinding, the palette</span></li> - <li><a href="docs/panes.html">panes and tabs</a><span> — splitting, moving, zooming, the mouse</span></li> - <li><a href="docs/config.html">configuration</a><span> — one file, live reload, themes, include</span></li> - <li><a href="docs/shaders.html">shaders</a><span> — colour passes over a pane's contents</span></li> - <li><a href="docs/chrome.html">chrome</a><span> — the same passes over a pane's frame</span></li> - <li><a href="docs/layouts.html">layouts</a><span> — a session as a file you check in</span></li> - <li><a href="docs/scripting.html">scripting</a><span> — the control socket, and what a pane can draw</span></li> - <li><a href="docs/design.html">how it works</a><span> — the two decisions that shape the rest</span></li> - <li><a href="docs/windows.html">windows</a><span> — the native port, and what is different there</span></li> - </ul> + <div class="os-panel" id="install-source"> + <div class="cmd"> + <pre><code><span class="prompt">$</span> git clone https://slosh.foo/src slosh && cd slosh +<span class="prompt">$</span> make vendor <span class="c"># the vendored terminal core, once</span> +<span class="prompt">$</span> make <span class="c"># about a second</span> +<span class="prompt">$</span> sudo install build/slosh /usr/local/bin/</code></pre> + </div> + <p class="aside">Needs <a href="https://ziglang.org">zig</a> 0.16 and + nothing else. No cmake, no autotools, no libraries. The result is one + static binary.</p> + </div> + </div> </section> - </main> <footer class="foot"> @@ -172,9 +229,11 @@ <span>one static binary</span> </footer> +<script src="recordings/player.js"></script> <script> -// The one script on the page: the copy buttons. No framework, no tracker, -// nothing fetched from anywhere. +// The scripts on the page: the copy buttons, the install preselect and the +// feature tour. No framework, no tracker, nothing fetched from anywhere but +// here. for (const box of document.querySelectorAll(".cmd[data-copy]")) { const btn = box.querySelector(".copy"); btn.addEventListener("click", async () => { @@ -187,6 +246,66 @@ for (const box of document.querySelectorAll(".cmd[data-copy]")) { } }); } + +// The install box opens on the platform reading it; the tabs are CSS. +const ua = navigator.userAgent; +const os = /Windows/i.test(ua) ? "windows" : /Mac/i.test(ua) ? "macos" : "linux"; +const radio = document.getElementById("os-" + os); +if (radio) radio.checked = true; + +// The feature tour: the step under the middle of the viewport is active, +// and picks which recording plays on the pinned terminal. +const tour = document.querySelector(".scrolly"); +if (tour) { + const mounts = [...tour.querySelectorAll(".stage-pin [data-sloshcast]")]; + const steps = [...tour.querySelectorAll(".step")]; + const ctl = (el) => el._sloshcast; + let current = null; // the first activation always plays + + const show = (name) => { + if (!name || name === current) return; + current = name; + for (const el of mounts) { + const on = el.dataset.name === name; + el.classList.toggle("stage-active", on); + if (!ctl(el)) continue; // still fetching; the sloshcast event catches up + if (on) { + ctl(el).restart(); + ctl(el).play(); + } else ctl(el).pause(); + } + }; + + const mid = new IntersectionObserver( + (entries) => { + for (const e of entries) + if (e.isIntersecting) { + for (const s of steps) s.classList.toggle("active", s === e.target); + show(e.target.dataset.cast); + } + }, + { rootMargin: "-45% 0px -45% 0px" }, + ); + for (const s of steps) mid.observe(s); + + // Nothing plays while the tour is off screen. + new IntersectionObserver((entries) => { + for (const e of entries) { + const active = mounts.find((m) => m.classList.contains("stage-active")); + if (!active || !ctl(active)) continue; + if (e.isIntersecting) ctl(active).play(); + else ctl(active).pause(); + } + }).observe(tour); + + // A cast that finished loading while its step was already active. + tour.addEventListener("sloshcast", (e) => { + if (e.target.classList.contains("stage-active")) { + ctl(e.target).fit(); + ctl(e.target).play(); + } + }); +} </script> </body> diff --git a/web/recordings/Makefile b/web/recordings/Makefile new file mode 100644 index 0000000..8e12c15 --- /dev/null +++ b/web/recordings/Makefile @@ -0,0 +1,31 @@ +# web/recordings -- render every demo into build/, ready for the site. +# +# make every sloshcast (each demos/NAME.py -> build/NAME.json) +# make serve look at test.html / seek.html?t=N locally +# make clean +# +# Rendering needs the slosh binary (the demos drive `slosh --script` through +# tests/harness.py) and nothing else. Casts re-render in about a second, so a +# UI change never leaves a stale demo: re-run make, done. + +DEMOS := $(wildcard demos/*.py) +CASTS := $(DEMOS:demos/%.py=build/%.json) +SLOSH := ../../build/slosh + +all: $(CASTS) + +build/%.json: demos/%.py castgen.py $(SLOSH) + python3 $< $@ + +$(SLOSH): + $(MAKE) -C ../.. + +PORT ?= 8000 +serve: + @echo "http://localhost:$(PORT)/test.html" + @python3 -m http.server $(PORT) --bind 127.0.0.1 + +clean: + rm -rf build + +.PHONY: all serve clean diff --git a/web/recordings/PLAN.md b/web/recordings/PLAN.md new file mode 100644 index 0000000..a7179ab --- /dev/null +++ b/web/recordings/PLAN.md @@ -0,0 +1,140 @@ +# recordings — small in-browser playbacks of real slosh features + +The front page will intersperse its copy with playable recordings, the way +pi.dev does with asciinema casts. Ours are **not** asciinema: the format and +the player are native, because slosh already did the hard part. This +directory holds the machinery; this file holds the plan and its status. + +## The two decisions that shape the rest + +**1. Recordings are programs, not performances.** pi.dev's casts are humans +typing into ghostty under `asciinema rec` — their hero cast is 7.7 MB, and +re-recording after a UI change means a human re-typing the take. Ours are +driver scripts against `slosh --script` (through `tests/harness.py`, the same +Session the test suite uses), with a **virtual clock**: pauses and typing +cadence are authored, jitter is seeded, so the same script yields the same +file byte for byte on any machine. A UI change re-renders every demo with +`make`; a stale demo is impossible — the same argument as `tests/test_skill.py`. + +**2a. Borders are geometry, not glyphs.** Box-drawing, block and shade +characters are never left to the font (a font glyph is hinted against its own +metrics: a column of │ shows seams, a scaled ╭ goes jagged). The player +paints them on a canvas overlay as device-pixel geometry with rounded, +SHARED cell boundaries — adjacent cells tile exactly — while the real +character stays in the DOM as transparent ghost text, so selection and copy +still carry it. Arms/junction weights, tangent-aligned quarter-ellipse arcs +for ╭╮╯╰, dashes and block/shade rects are ported from shellglass's viewer +(~/dev/sl0pday/vendor/shellglass/viewer/viewer.ts), which solved this for +sl0p.foo. Two more rules learned there: metrics are pinned in pixels (row +height = --sc-lh), and the player scales by choosing a font size — never a +CSS transform, whose per-span resampling was the other source of jag. + +**2b. No VT in the browser.** `snapshot` hands back the *composited* screen: +text rows plus style runs with colours already resolved to hex by the theme. +So the file format is styled rows diffed per frame, and the player is ~200 +lines of dependency-free JS doing innerHTML on the rows that changed. No +asciinema-player, no wasm, no xterm.js, no palette mapping, nothing fetched — +which is the site's founding rule. Text in a playing demo is real text: +selectable, copyable, findable. + +Measured, hero demo (26 s, 159 events): **81 KiB raw, 2.9 KiB gzipped**. + +## What exists (working end to end) + +| file | what | +|---|---| +| `castgen.py` | Recorder: virtual clock, per-action capture, row-level diffs, the sloshcast writer. Format documented in its docstring | +| `demos/panes.py` | the opener: chord split, border-click split, gap drag, title-drag swap, zoom, a tab and back | +| `demos/sessions.py` | TWO live sessions (`main` sl0p-pink, `scratch` phosphor-green) with authored bare-shell frames between: attach, detach, hop, return — and the pane that died off camera kept its epitaph, [re-run] clicked | +| `demos/reflow.py` | quarter-turn, shrink to header list, grow back: the arrangement returns | +| `demos/config.py` | C-a e opens the config in a vim pane: theme flip by substitution, `padding`, `compact true` — each landing on save | +| `demos/shaders.py` | contrib/shader-repl: chrome tint, cursor spotlight, ruler; a bell rings unfocused and the frame flashes | +| `demos/scripting.py` | left: socket JSON typed with verbatim replies while the session performs each verb; right: deploy.py draws status + [Approve] into its frame, the pointer's click lands on its stdin | + +One cast per tour step, one claim per step, 2–4 proofs per cast — and the +step's bullet list on the page is the cast's beat list, in order. The tour +closes on a CTA step (install / try in the browser) with the last cast still +playing. Multi-session recordings work by `Recorder.switch()`ing between +live Sessions plus `shell()`/`shell_type()` authored interstitial frames +(`[detached]`, a prompt, typing the next attach). +| `player.js` | the player: `mountSloshcast(el, data, opts)` + declarative `<div data-sloshcast="x.json" data-loop data-autoplay data-poster="12.5">` mounts. Poster frame, click to play/pause, loop, `seek()`, scale-to-fit (the webdemo's CSS-transform trick) | +| `player.css` | the chrome: frame, play glyph, title/time bar. System monospace stack, per the site's rules | +| `test.html` | eyeball page (`make serve`) | +| `seek.html` | `?t=SECONDS` renders the demo frozen at that moment — for screenshots and for picking `data-poster` times | +| `Makefile` | `demos/*.py` → `build/*.json`; `make serve`; `make clean` | + +The format ("sloshcast v1", one JSON file) is defined in `castgen.py`: +header (`cols rows fg bg title dur`) + events (`t`, changed `rows` as +`[text, [[x,w,fg,bg,attrbits],…]]`, `cur`, optional `size` for mid-demo +resizes, which carry a full frame). Event 0 is the full first frame, so a +loop restart or a `seek(0)` is cheap. attrbits: 1 bold, 2 italic, +4 underline, 8 dim, 16 reverse, 32 strike. + +Verified: rendering against headless chromium screenshots (panes, truecolor +runs, cursor, focus colours, pointer, hover hints, compact-mode headers all +correct), and playback over CDP — clock advances, events land, the loop +wraps. + +**The pointer track is in.** `Recorder` grows `move_to` / `press` / `release` +/ `click` / `drag_to`: real SGR mouse bytes go to slosh (hover hints and drop +zones are the program's own), while `"ptr"` events record the cell so the +player can draw the arrow a screen capture never shows. Pink while pressed; +the CSS transition does the glide. + +**Time inside slosh is real.** A toast expires and a shader animates on the +monotonic clock, so `pause()` asks the session's `deadline` and, when a next +frame is wanted within the pause, waits that long for real and samples it. +Toasts fade mid-pause exactly as they would live; animations get captured at +the cadence they asked for; a demo with neither still renders instantly. +Rendering all three demos: ~7 s. + +One gotcha worth keeping: the JSON `send` verb takes bytes **verbatim** +(JSON strings carry control characters natively); the `\x01` escape +mini-language belongs to the bare-verb form only. `castgen.py` sends real +bytes. + +## Still to do + +**The shot list is done** — every `#different` step has a cast and swaps on +scroll. Lessons that cost time, so they stay written down: + +- The in-band `shader-reply` races a shell's `stty`: slosh answers on stdin + faster than a fork+exec, so a bare shell paints the reply into its prompt. + `contrib/shader-repl` exists precisely to hold that conversation — type at + it, not at `sh`. +- A bell must ring *unfocused* to mark and flash; give it a fuse (a `sleep` + in the pane's script) and use `Recorder.wall()` for the real seconds. +- Commanded panes come from layouts; a command that is a file (`sh checks`) + keeps the dead frame's `[ran: …]` line readable. + +The detach story is in (`sessions.py`); what remains is pacing polish once +the page has been scrolled by real hands for a while. + +**Front-page integration.** Done as a scrolly feature tour: `#different` is +a two-column grid — a `position: sticky` stage pinning the terminal +v-centred on the left, the six feature steps scrolling by on the right. An +IntersectionObserver with a `-45%` root margin makes the step under the +viewport middle active; its `data-cast` picks the stage's recording +(restart + play, others pause). Steps whose recording is not scripted yet +have no `data-cast` and keep the current one playing. A second observer +pauses everything while the tour is off screen; `prefers-reduced-motion` +gets posters only. On one column the sticky moves up to `.scrolly-stage` +(in block layout `.stage-pin`'s parent is only as tall as the player) and +the terminal rides under the nav. The hero cast replaced the placeholder +mp4 in `#video`. + +**Still open from that:** the no-JS story — render frame 0 server-side into +static poster HTML at build time (same row→spans logic, in Python) so the +page shows a real first frame before, or without, JavaScript. + +**Build wiring.** Done: `web/build-site` (FULL=1) runs +`make -C web/recordings` and copies casts + player to +`build/www/recordings/`. Casts ship raw; the host's gzip does the rest +(2.9 KiB on the wire for the hero). + +**Polish, later, maybe.** A scrub bar (seek() exists), speed control, +`document.hidden` pausing autoplay loops, a `make check` that replays every +cast headlessly and diffs the final frame against the demo's last snapshot. +Known wrinkle: frames sampled during a live animation (a border flash) take +their colour from real elapsed time, so those few frames are not byte- +deterministic across renders; everything else is. diff --git a/web/recordings/castgen.py b/web/recordings/castgen.py new file mode 100644 index 0000000..c94e7a9 --- /dev/null +++ b/web/recordings/castgen.py @@ -0,0 +1,356 @@ +#!/usr/bin/env python3 +"""Record a scripted slosh session into a sloshcast. + +A recording here is a *program*: a demo script drives `slosh --script` +(through tests/harness.py, the same Session the test suite uses), advances a +virtual clock with authored pauses and a typing cadence, and captures the +compositor's own snapshot after every action. Frames arrive with colours +already resolved to hex by the theme, so the browser player needs no VT, no +palette, no wasm -- it replays styled rows into a DOM grid. + +The format ("sloshcast", one JSON file): + + { + "v": 1, + "cols": 100, "rows": 28, # the grid at t=0 + "fg": "#ffffff", "bg": "#000000", # the theme's defaults + "title": "...", "dur": 14.2, # seconds, including the tail + "events": [ + {"t": 0.0, + "rows": {"0": [text, [[x, w, fg, bg, attrbits], ...]], ...}, + "cur": [x, y]}, # only what changed; event 0 is full + {"t": 3.1, "size": [80, 20], "rows": {...}}, # a resize: full frame + {"t": 5.0, "ptr": [40, 12]}, # the pointer track: cell, [x,y,1] + ... # while the button is down, null gone + ] + } + +attrbits: 1 bold, 2 italic, 4 underline, 8 dim, 16 reverse, 32 strike. +Timing is entirely virtual -- the same script yields the same file, byte for +byte, no matter how loaded the machine that rendered it was. +""" + +import json +import os +import re +import sys +import time + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(HERE)) +sys.path.insert(0, os.path.join(ROOT, "tests")) +from harness import Session # noqa: E402 + +THEME = os.path.join(ROOT, "contrib", "themes", "sl0p.kdl") + +ATTR_BITS = { + "bold": 1, + "italic": 2, + "underline": 4, + "dim": 8, + "faint": 8, + "reverse": 16, + "inverse": 16, + "strike": 32, + "strikethrough": 32, +} + + +def _theme_defaults(path): + """default_fg / default_bg out of a theme file, for the player's canvas.""" + fg, bg = "#ffffff", "#000000" + try: + text = open(path).read() + m = re.search(r'default_fg\s+"(#[0-9a-fA-F]{6})"', text) + if m: + fg = m.group(1) + m = re.search(r'default_bg\s+"(#[0-9a-fA-F]{6})"', text) + if m: + bg = m.group(1) + except OSError: + pass + return fg, bg + + +class Recorder: + def __init__( + self, + argv=None, + cols=100, + rows=28, + title="", + config=THEME, + env=None, + layout=None, + ): + self.s = Session( + list(argv or ["/bin/sh"]), + cols=cols, + rows=rows, + config=config, + env=env, + layout=layout, + ) + self.api = self.s.api + self.title = title + self.fg, self.bg = _theme_defaults(config) if config else ("#ffffff", "#000000") + self.t = 0.0 + self.events = [] + self._size = (cols, rows) + self._size0 = (cols, rows) + self._rows = None # previous frame, list of [text, runs] + self._cur = None + self._seed = 0x5105 # deterministic typing jitter + self._ptr = None # pointer as last emitted: [x, y] / [x, y, 1] / None + self._at = None # pointer cell right now, (x, y) + self._down = False + + # -- the clock and the capture --------------------------------------- + + def _jitter(self): + self._seed = (self._seed * 1103515245 + 12345) & 0x7FFFFFFF + return self._seed / 0x7FFFFFFF + + def _frame(self, snap): + runs_by_y = {} + for r in snap.styles: + bits = 0 + for a in r.get("attrs", ()): + bits |= ATTR_BITS.get(a, 0) + runs_by_y.setdefault(r["y"], []).append( + [r["x"], r["w"], r["fg"], r["bg"], bits] + ) + return [ + [text, sorted(runs_by_y.get(y, []))] for y, text in enumerate(snap.text) + ] + + def _record(self, frame, cur, size, dt=0.0, force=False): + """Advance the clock by dt, then record whatever changed.""" + self.t += dt + + ev = {"t": round(self.t, 3)} + if size != self._size: + ev["size"] = list(size) + self._rows = None # a resize event carries the full frame + changed = { + str(y): row + for y, row in enumerate(frame) + if self._rows is None or y >= len(self._rows) or self._rows[y] != row + } + if changed: + ev["rows"] = changed + if cur != self._cur: + ev["cur"] = cur + ptr = None if self._at is None else list(self._at) + ([1] if self._down else []) + if ptr != self._ptr: + ev["ptr"] = ptr + self._ptr = ptr + + if len(ev) > 1 or force: + self.events.append(ev) + self._rows, self._cur, self._size = frame, cur, size + + def capture(self, dt=0.0, settle_ms=20, force=False): + """Advance the clock by dt, then record whatever changed on screen.""" + self.s.settle(settle_ms) + snap = self.s.snapshot() + cur = [snap.cursor["x"], snap.cursor["y"]] if snap.cursor["visible"] else None + self._record(self._frame(snap), cur, (snap.cols, snap.rows), dt=dt, force=force) + + # -- more than one session in a recording ------------------------------- + + def switch(self, session, dt=0.0, settle_ms=100): + """Record from another live Session from here on. The next frame is + full, which is what a real attach paints anyway. The session keeps + living (and can be poked over its api) while it is off camera -- + that is the whole point of the detach demo.""" + self.s = session + self.api = session.api + self._rows = None + self.capture(dt=dt, settle_ms=settle_ms, force=True) + + def shell(self, lines, dt=0.12): + """A synthetic bare-terminal frame: the interstitial shell between + detaching from one session and attaching the next. The writer owns + the byte stream, so the frames between real segments can simply be + authored -- plain rows, no styling, a cursor at the end.""" + cols, rows = self._size + frame = [[l[:cols], []] for l in lines[:rows]] + frame += [["", []] for _ in range(rows - len(frame))] + cur = None + if lines: + y = min(len(lines), rows) - 1 + cur = [min(len(lines[-1]), cols - 1), y] + self._record(frame, cur, self._size, dt=dt) + + def shell_type(self, lines, text, cps=0.07): + """Type onto the last line of a synthetic shell, like a person.""" + base = lines[-1] + for i in range(1, len(text) + 1): + lines[-1] = base + text[:i] + self.shell(lines, dt=cps * (0.6 + 0.8 * self._jitter())) + + # -- what a demo script says ----------------------------------------- + + def pause(self, sec): + """Authored silence. Also lands any output that arrived meanwhile. + + Time inside slosh is real: a toast expires and a shader animates on + the monotonic clock, not ours. So when the session says it wants a + next frame (deadline >= 0) within this pause, actually wait that long + and sample the frame -- toasts fade mid-pause exactly as they would + live, and an animation is captured at the cadence it asked for. + Otherwise the pause costs nothing but the authored seconds.""" + left = sec + while left > 0: + self.capture() # compose, so deadline() is about a real frame + d = self.s.deadline() + if d < 0 or d / 1000.0 >= left: + break + wait = max(d / 1000.0, 0.03) + time.sleep(wait) # let slosh's own clock reach the deadline + left -= wait + self.capture(dt=wait, settle_ms=5) + self.capture(dt=left) + + def hold(self, sec): + """Advance the clock without touching the session: authored silence + over synthetic frames (the shell between two sessions), where pause() + would ask a live session for its deadline.""" + self.t += sec + + def wall(self, sec, step=0.1): + """Spend real seconds, sampling as they pass -- for a program in a + pane that needs actual time (a background job about to ring the + bell), where pause()'s deadline shortcut would skip past it.""" + n = max(1, int(sec / step)) + for _ in range(n): + time.sleep(step) + self.capture(dt=step, settle_ms=5) + + def type(self, text, cps=0.06, dt=0.0): + """Type like a person: per-character frames, deterministic jitter.""" + self.capture(dt=dt) + for ch in text: + self.api("send", data=ch) + self.capture(dt=cps * (0.6 + 0.8 * self._jitter()), settle_ms=8) + + def enter(self, dt=0.15): + self.api("send", data="\r") + self.capture(dt=dt) + + def run(self, text, wait=None, dt=0.35, **kw): + """Type a command, press enter, optionally wait for its output.""" + self.type(text) + self.enter() + if wait is not None: + self.s.until_text(wait, **kw) + self.capture(dt=dt) + + def key(self, chord, dt=0.45): + """A leader chord: key('\\\\') is C-a \\, key('c') is C-a c. + + The JSON `send` takes bytes verbatim (JSON strings carry control + characters natively); the \\x01 escape language belongs to the + bare-verb form only. + """ + self.api("send", data="\x01" + chord) + self.capture(dt=dt) + + def send(self, data, dt=0.3): + """Bytes as if typed, verbatim -- pass real control characters.""" + self.api("send", data=data) + self.capture(dt=dt) + + # -- the mouse --------------------------------------------------------- + # The recording carries a pointer track ("ptr" events): the driver knows + # the pointer's cell at every moment, so the player can draw the pointer + # a screen capture never shows. Real SGR mouse bytes go to slosh at the + # same time, so hover hints, drags and drops are the program's own. + + def _mouse(self, code, final="M"): + x, y = self._at + self.api("send", data="\x1b[<%d;%d;%d%s" % (code, x + 1, y + 1, final)) + + def move_to(self, x, y, dur=0.5, hover=True): + """Glide the pointer there in small steps; hover events go to slosh.""" + sx, sy = self._at if self._at else (x, y) + steps = max(2, int(dur / 0.05)) + for i in range(1, steps + 1): + self._at = ( + round(sx + (x - sx) * i / steps), + round(sy + (y - sy) * i / steps), + ) + if hover: + self._mouse(35) + self.capture(dt=dur / steps, settle_ms=5) + + def press(self, dt=0.15): + self._down = True + self._mouse(0, "M") + self.capture(dt=dt) + + def release(self, dt=0.2): + self._mouse(0, "m") + self._down = False + self.capture(dt=dt) + + def click(self, x, y, move=0.5, dt=0.5): + self.move_to(x, y, dur=move) + self.press() + self.release(dt=dt) + + def drag_to(self, x, y, dur=0.7, dt=0.3): + """With the button held: motion events slosh sees as a drag.""" + sx, sy = self._at + steps = max(2, int(dur / 0.05)) + for i in range(1, steps + 1): + self._at = ( + round(sx + (x - sx) * i / steps), + round(sy + (y - sy) * i / steps), + ) + self._mouse(32) + self.capture(dt=dur / steps, settle_ms=5) + self.capture(dt=dt) + + def resize(self, cols, rows, dt=0.4): + self.s.resize(cols, rows) + self.capture(dt=dt) + + def until_text(self, needle, dt=0.3, **kw): + self.s.until_text(needle, **kw) + self.capture(dt=dt) + + def snapshot(self): + return self.s.snapshot() + + # -- the file --------------------------------------------------------- + + def save(self, path, tail=1.2): + """Write the sloshcast; `tail` is how long the last frame holds.""" + if not self.events: + self.capture(force=True) + data = { + "v": 1, + "cols": self._size0[0], + "rows": self._size0[1], + "fg": self.fg, + "bg": self.bg, + "title": self.title, + "dur": round(self.t + tail, 3), + "events": self.events, + } + os.makedirs(os.path.dirname(path) or ".", exist_ok=True) + with open(path, "w") as f: + json.dump(data, f, separators=(",", ":")) + size = os.path.getsize(path) + print( + f"{path}: {len(self.events)} events, {data['dur']}s, {size / 1024:.1f} KiB" + ) + + +def out_path(default_name): + """Where a demo writes: argv[1], or build/<name>.json beside this file.""" + if len(sys.argv) > 1: + return sys.argv[1] + return os.path.join(HERE, "build", default_name + ".json") diff --git a/web/recordings/demos/config.py b/web/recordings/demos/config.py new file mode 100644 index 0000000..b8509ab --- /dev/null +++ b/web/recordings/demos/config.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""config -- make it yours, one file, live. + +C-a e opens the config in an editor pane. Every edit lands the moment it is +saved: the theme flips from pink to green, `padding` pushes the content off +the frames, `compact true` swaps floating boxes for shared dividers. (The +headless recorder asks for the reload the file-watcher would do live.) +""" + +import os +import shutil +import sys +import tempfile + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from castgen import THEME, Recorder, out_path + +ENV = {"PS1": "$ ", "ENV": "/dev/null", "EDITOR": "vim -u NONE -i NONE"} + +work = tempfile.mkdtemp(prefix="slosh-demo-") +cfg = os.path.join(work, "config.kdl") +shutil.copyfile(THEME, cfg) + +r = Recorder(["/bin/sh"], cols=100, rows=28, title="make it yours", config=cfg, env=ENV) + +# A neighbour with something on it, so geometry changes have a witness. +r.api("split", dir="cols") +r.capture(force=True) +r.pause(0.6) +r.run( + "for i in 1 1 2 2 3 3 4 4 5 5 6 6; do" + " printf '\\033[4%sm%-40s\\033[0m\\n' $i ' '; done" +) +r.api("focus", id=sorted(p["id"] for p in r.api("panes")["panes"])[0]) +r.capture(dt=0.4) +r.pause(0.8) + + +def save_and_reload(hold): + r.type(":w") + r.enter(dt=0.3) + r.api("reload") + r.capture(dt=0.3, settle_ms=60) + r.pause(hold) + + +# C-a e: the config, in a pane of its own. +r.key("e", dt=0.5) +r.until_text("frame_focus", dt=0.6, timeout_ms=5000) +r.pause(1.0) + +# Pink to green: find the focus colour, change the word, save. +r.type("/ff5fd7") +r.enter(dt=0.4) +r.type("cw5fffaf") +r.send("\x1b", dt=0.3) +r.pause(0.5) +save_and_reload(1.6) + +# Room to breathe: padding, appended at the end of the file. +r.send("G", dt=0.2) +r.type("opadding 1 2") +r.send("\x1b", dt=0.3) +save_and_reload(1.6) + +# A different school of borders entirely. +r.type("ocompact true") +r.send("\x1b", dt=0.3) +save_and_reload(2.2) + +r.save(out_path("config"), tail=2.0) +shutil.rmtree(work) diff --git a/web/recordings/demos/panes.py b/web/recordings/demos/panes.py new file mode 100644 index 0000000..4f4c4ee --- /dev/null +++ b/web/recordings/demos/panes.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""panes -- panes and tabs, the way your hands expect. + +The opener: keys and mouse doing the same work. Split with a chord, split by +clicking a border, drag the gap, swap two panes by dragging a title, zoom, +and hop through a tab. Every coordinate is read from the compositor's hit +list, so the demo cannot disagree with the program. +""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from castgen import Recorder, out_path + +ENV = {"PS1": "$ ", "ENV": "/dev/null"} + +r = Recorder(["/bin/sh"], cols=100, rows=28, title="panes and tabs", env=ENV) + +r.capture(force=True) +r.pause(0.8) + +# A chord: C-a \ splits into columns. +r.key("\\") +r.pause(0.5) +r.run("echo splits are one chord away", wait="one chord") +r.pause(0.9) + +# Or a click: the middle of a border splits toward it, and the hover says so +# before the button goes down. +h = r.snapshot().handle(4, "b") or r.snapshot().handle(2, "b") +r.move_to(*h, dur=0.8) +r.pause(0.8) +r.press() +r.release() +r.pause(0.9) + +# Name the players so the next two moves read as something. +ids = sorted(p["id"] for p in r.api("panes")["panes"]) +for pid, name in zip(ids, ["editor", "server", "logs"]): + r.api("set-name", target="pane", id=pid, name=name) +r.capture(dt=0.4) +r.pause(0.6) + +# Drag the gap: the boundary follows the pointer. +h = r.snapshot().handle(ids[0], "r") +r.move_to(*h, dur=0.7) +r.press() +r.drag_to(h[0] - 13, h[1], dur=0.8) +r.release() +r.pause(1.0) + +# Drag a pane by its title onto another: they swap. +snap = r.snapshot() +title = next(e for e in snap.hits if e["action"] == f"title:{ids[0]}") +tx, ty = title["x"] + title["w"] // 2, title["y"] +other = next(p for p in r.api("panes")["panes"] if p["id"] != ids[0]) +r.move_to(tx, ty, dur=0.7) +r.press() +r.drag_to( + other["content_x"] + other["content_w"] // 2, + other["content_y"] + other["content_h"] // 2, + dur=0.9, +) +r.pause(0.4) +r.release() +r.pause(1.1) + +# Zoom the focused pane to the whole tab, and back. +r.key("z") +r.pause(1.0) +r.key("z") +r.pause(0.8) + +# A fresh tab, and back: the first one kept everything. +r.key("c") +r.pause(0.6) +r.run("echo a fresh tab costs nothing", wait="costs") +r.pause(0.9) +r.key("1") +r.pause(1.6) + +r.save(out_path("panes"), tail=1.8) diff --git a/web/recordings/demos/reflow.py b/web/recordings/demos/reflow.py new file mode 100644 index 0000000..5287650 --- /dev/null +++ b/web/recordings/demos/reflow.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""reflow -- layouts that reflow the way you expect. + +The arrangement is derived from the window, never stored. Turn it a quarter, +shrink the terminal until boxes become a tidy list, grow it back: the exact +arrangement returns, because there was nothing stored to drift. +""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from castgen import Recorder, out_path + +ENV = {"PS1": "$ ", "ENV": "/dev/null"} + +r = Recorder(["/bin/sh"], cols=100, rows=28, title="layouts that reflow", env=ENV) + +# Three named panes, built over the socket -- the demo is about the layout. +r.api("split", dir="cols") +r.api("split", dir="rows") +for pid, name in zip( + sorted(p["id"] for p in r.api("panes")["panes"]), ["editor", "server", "logs"] +): + r.api("set-name", target="pane", id=pid, name=name) +r.capture(force=True) +r.pause(1.0) + +r.run("echo this arrangement is derived, not stored", wait="derived") +r.pause(1.2) + +# A quarter turn: the same panes, the layout re-derived on the spot. +r.key(" ", dt=0.5) +r.pause(1.3) + +# Down. Each stop holds long enough to see the layout re-derive itself. +for size in [(84, 24), (66, 18), (50, 13), (36, 8)]: + r.resize(*size, dt=0.55) + r.pause(0.75) +r.pause(1.2) # the small screen: a list, not clutter + +# And back. The exact arrangement returns. +for size in [(50, 13), (66, 18), (84, 24), (100, 28)]: + r.resize(*size, dt=0.55) + r.pause(0.6) +r.pause(1.6) + +r.save(out_path("reflow"), tail=2.0) diff --git a/web/recordings/demos/scripting.py b/web/recordings/demos/scripting.py new file mode 100644 index 0000000..65ee4f5 --- /dev/null +++ b/web/recordings/demos/scripting.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""scripting -- built to be driven. + +Left pane: a script's-eye view of the control socket -- the commands are +typed, the replies printed are byte-for-byte what the socket answers, and +the session performs the verb as each line lands. Right pane: a program +(deploy.py) that drew status text and real buttons into its own frame with +one escape sequence; the pointer clicks [Approve] and the click arrives +back on the program's stdin, which is where it wanted it. +""" + +import os +import shutil +import sys +import tempfile + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from castgen import Recorder, out_path + +ENV = {"PS1": "$ ", "ENV": "/dev/null"} + +work = tempfile.mkdtemp(prefix="slosh-demo-") + +# The left pane's shell: reads a line, answers like the socket does. The +# replies below are copied from the real api replies at record time. +with open(os.path.join(work, "sh-replay"), "w") as f: + f.write( + "printf '$ '\n" + "while IFS= read -r l; do\n" + ' case "$l" in\n' + ' *\'"split"\'*) echo \'{"ok":true,"id":4}\' ;;\n' + " *set-name*) echo '{\"ok\":true}' ;;\n" + " *) : ;;\n" + " esac\n" + " printf '$ '\n" + "done\n" + ) + +# The right pane's program: asks for approval in its own frame. +with open(os.path.join(work, "deploy.py"), "w") as f: + f.write( + "import os, sys, tty\n" + "w = sys.stdout.write\n" + "w('\\033]5577;1;status;release 1.4.2 -- awaiting approval\\033\\\\')\n" + "w('\\033]5577;1;buttons;approve:Approve;cancel:Cancel\\033\\\\')\n" + "print('deploy: waiting for a human (or an agent)')\n" + "sys.stdout.flush()\n" + "tty.setcbreak(0)\n" + "buf = ''\n" + "while 'approve' not in buf:\n" + " buf += os.read(0, 64).decode('utf-8', 'replace')\n" + "w('\\033]5577;1;status;approved\\033\\\\')\n" + "print('deploy: approved -- rolling out')\n" + "sys.stdout.flush()\n" + "os.read(0, 64)\n" + ) + +lay = os.path.join(work, "demo.layout") +with open(lay, "w") as f: + f.write( + 'layout {\n tab {\n pane focus=true cwd="%s" command="sh sh-replay"\n' + ' pane cwd="%s" command="python3 deploy.py"\n }\n}\n' % (work, work) + ) + +r = Recorder( + ["/bin/sh"], cols=100, rows=28, title="built to be driven", env=ENV, layout=lay +) + +r.capture(force=True, settle_ms=150) +r.pause(1.2) + +# One JSON object per line: split, from outside. +r.type('slosh cmd \'{"cmd":"split","dir":"rows"}\'') +r.enter(dt=0.3) +reply = r.api("split", dir="rows") +r.capture(dt=0.4, settle_ms=80) +r.pause(1.4) + +# Name the new pane from outside, too: the title changes where it stands. +new_id = reply["id"] +r.type( + 'slosh cmd \'{"cmd":"set-name","target":"pane","id":%d,"name":"build"}\'' % new_id +) +r.enter(dt=0.3) +r.api("set-name", target="pane", id=new_id, name="build") +r.capture(dt=0.4, settle_ms=80) +r.pause(1.4) + +# The program in the other pane asked its question in its own frame. +# Answer it with the pointer; the click lands on its stdin. +snap = r.snapshot() +hit = next(e for e in snap.hits if "approve" in e["action"]) +r.move_to(hit["x"] + hit["w"] // 2, hit["y"], dur=0.9) +r.pause(0.6) +r.press() +r.release() +r.capture(dt=0.4, settle_ms=150) +r.pause(2.2) + +r.save(out_path("scripting"), tail=2.0) +shutil.rmtree(work) diff --git a/web/recordings/demos/sessions.py b/web/recordings/demos/sessions.py new file mode 100644 index 0000000..2c38989 --- /dev/null +++ b/web/recordings/demos/sessions.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +"""sessions -- sessions outlive everything. + +Two real sessions run through the whole recording; only the bare-shell +frames between them are authored (the writer owns the byte stream). Attach +to `main`, work, detach. Attach to `scratch` -- a different theme, so there +is no mistaking them. Come back to `main`: everything exactly as left, +including the pane whose command died while nobody was watching -- output, +exit status, and [re-run] in the frame. +""" + +import os +import shutil +import sys +import tempfile + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from castgen import ROOT, Recorder, out_path +from harness import Session + +ENV = {"PS1": "$ ", "ENV": "/dev/null"} + +work = tempfile.mkdtemp(prefix="slosh-demo-") +# main's logs pane: alive until nudged, then it dies with its story told. +with open(os.path.join(work, "serve"), "w") as f: + f.write( + "echo listening on :8080\nread x\necho error: connection reset by peer\nexit 3\n" + ) +lay_main = os.path.join(work, "main.layout") +with open(lay_main, "w") as f: + f.write( + "layout {\n tab {\n pane focus=true\n pane\n" + ' pane cwd="%s" command="sh serve"\n }\n}\n' % work + ) +lay_scratch = os.path.join(work, "scratch.layout") +with open(lay_scratch, "w") as f: + f.write("layout {\n tab {\n pane focus=true\n pane\n }\n}\n") + +# Session A: `main`, the sl0p pink. Session B: `scratch`, phosphor green -- +# distinct at a glance, which is the point of showing two. +a = Session( + ["/bin/sh"], + cols=100, + rows=28, + env=ENV, + layout=lay_main, + config=os.path.join(ROOT, "contrib", "themes", "sl0p.kdl"), +) +b = Session( + ["/bin/sh"], + cols=100, + rows=28, + env=ENV, + layout=lay_scratch, + config=os.path.join(ROOT, "contrib", "themes", "phosphor.kdl"), +) + +r = Recorder( + ["/bin/sh"], cols=100, rows=28, title="sessions outlive everything", env=ENV +) +r.s.close() # the recorder's own session is unused: it records a and b + +# -- a bare shell, typing its way in ------------------------------------ +sh = ["$ "] +r.shell(sh, dt=0.0) +r.hold(0.7) +r.shell_type(sh, "slosh -s main") +r.hold(0.4) + +# -- session main: work happens ------------------------------------------ +r.switch(a, settle_ms=150) +ids = sorted(p["id"] for p in a.api("panes")["panes"]) +for pid, name in zip(ids, ["editor", "server", "logs"]): + a.api("set-name", target="pane", id=pid, name=name) +r.capture(dt=0.3) +r.pause(0.8) +r.run("echo the deploy notes, half-written", wait="half-written") +r.pause(1.4) + +# -- detach --------------------------------------------------------------- +sh = ["$ slosh -s main", "[detached]", "$ "] +r.shell(sh, dt=0.3) +r.hold(0.9) +r.shell_type(sh, "slosh -s scratch") +r.hold(0.4) + +# -- session scratch: another life, another look -------------------------- +r.switch(b, settle_ms=150) +r.pause(0.8) +r.run("echo a different session, a different colour", wait="different") +r.pause(1.4) + +# While nobody is looking at main, its logs pane dies. (The session lives +# on off camera; this is the api nudging its fuse, not a trick.) +logs = ids[2] +a.api("focus", id=logs) +a.api("send", data="\n") +a.settle(150) +a.api("focus", id=ids[0]) +a.settle(50) + +# -- detach, come home ----------------------------------------------------- +sh = ["$ slosh -s scratch", "[detached]", "$ "] +r.shell(sh, dt=0.3) +r.pause(0.8) +r.shell_type(sh, "slosh -s main") +r.hold(0.4) + +# -- main again: exactly as left, plus one epitaph ------------------------- +r.switch(a, settle_ms=150) +r.pause(1.6) + +# The pane that died while we were away kept its output and its exit +# status, and offers the only two verbs left. Run it again. +snap = r.snapshot() +hit = next(e for e in snap.hits if e["action"] == f"rerun:{logs}") +r.move_to(hit["x"] + hit["w"] // 2, hit["y"], dur=0.9) +r.pause(0.7) +r.press() +r.release() +r.capture(dt=0.3, settle_ms=150) +r.pause(1.8) + +r.save(out_path("sessions"), tail=2.0) +b.close() +a.close() +shutil.rmtree(work) diff --git a/web/recordings/demos/shaders.py b/web/recordings/demos/shaders.py new file mode 100644 index 0000000..889b0de --- /dev/null +++ b/web/recordings/demos/shaders.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""shaders -- colour passes over cells, with strengths written as expressions. + +The left pane runs contrib/shader-repl -- the prompt from the docs -- and +what gets typed at it is exactly what a config file would say: a tint over +the frame, a spotlight whose strength is a distance from the cursor (so it +follows the typing), a ruler, scanlines. Then a shipped preset (`:load +shine`) animates a sheen along the frame -- sampled on the session's own +deadline clock -- and, calm restored, the neighbouring pane rings while +unfocused: the border flash is the same kind of pass, over the frame. +""" + +import os +import shutil +import sys +import tempfile + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from castgen import ROOT, THEME, Recorder, out_path + +ENV = {"PS1": "$ ", "ENV": "/dev/null"} + +work = tempfile.mkdtemp(prefix="slosh-demo-") +cfg = os.path.join(work, "config.kdl") +with open(cfg, "w") as f: + f.write(open(THEME).read()) + f.write("\nin_band_shaders true\n") +# The bell, on a fuse: rings one real second after the nudge, so the focus is +# long gone by the time it goes off. +with open(os.path.join(work, "ring"), "w") as f: + f.write("read x\nsleep 1\nprintf '\\a'\nread y\n") +repl = os.path.join(ROOT, "contrib", "shader-repl") +lay = os.path.join(work, "demo.layout") +with open(lay, "w") as f: + f.write( + 'layout {\n tab {\n pane focus=true command="python3 %s"\n' + ' pane cwd="%s" command="sh ring"\n }\n}\n' % (repl, work) + ) + +r = Recorder( + ["/bin/sh"], + cols=100, + rows=28, + title="colour passes over cells", + config=cfg, + env=ENV, + layout=lay, +) + +r.capture(force=True, settle_ms=150) +r.pause(1.2) + +# A pass over the frame: the chrome> prompt is aimed at it already. +r.run('tint color="#5fffaf" amount=140') +r.pause(1.8) + +# Content: a spotlight, strength by distance from the cursor. +r.run(":content") +r.pause(0.6) +r.run('tint amount="255 - dist(x, y, curx, cury) * 14" color="#ff5fd7"') +r.pause(1.0) + +# ...which is why it follows the next thing typed: a ruler. +r.run('ruler amount=120 at=50 color="#7aa2f7"') +r.pause(1.8) + +# Scanlines: one built-in, one property. +r.run("zebra amount=110 band=1") +r.pause(1.8) + +# A shipped preset, and an animated one: a sheen runs the frame. The pass +# reads the clock, so the session asks for frames and pause() samples them. +r.run(":chrome") +r.pause(0.5) +r.run(":load shine") +r.pause(2.4) + +# Calm again before the finale; the sheen would talk over the bell. +r.run('tint color="#5fffaf" amount=140') +r.pause(1.0) + +# Nudge the other pane's fuse, get the focus away, and let it ring. +panes = r.api("panes")["panes"] +other = next(p for p in panes if not p.get("focused")) +prev = next(p for p in panes if p.get("focused")) +r.api("focus", id=other["id"]) +r.capture(dt=0.4) +r.send("\n", dt=0.2) +r.api("focus", id=prev["id"]) +r.capture(dt=0.3) +r.wall(1.6) # real time: the fuse burns, the bell rings unfocused +r.pause(3.0) # the flash animates on the session's own deadline clock + +r.save(out_path("shaders"), tail=2.0) +shutil.rmtree(work) diff --git a/web/recordings/player.css b/web/recordings/player.css new file mode 100644 index 0000000..cbeb052 --- /dev/null +++ b/web/recordings/player.css @@ -0,0 +1,124 @@ +/* sloshcast player. The font stack is the site's rule: the system monospace + * wherever a terminal is being imitated, nothing fetched. + * + * Metrics are pinned in pixels (--sc-lh, set by the player), rows carry an + * explicit height, and the player scales by font size rather than transform + * -- all three learned from shellglass's viewer, all three in service of + * borders that meet instead of shimmer. */ + +.sloshcast { + border: 1px solid #45454a; + border-radius: 6px; + overflow: hidden; + max-width: 100%; +} + +.sloshcast .sc-frame { + position: relative; + overflow: hidden; + cursor: pointer; +} + +.sloshcast .sc-screen { + --sc-lh: 17px; + position: relative; /* the canvas, cursor and pointer position against it */ + margin: 0; + padding: 8px; + font-family: ui-monospace, "SF Mono", Menlo, Consolas, "DejaVu Sans Mono", + monospace; + font-size: 14px; + line-height: var(--sc-lh); + box-sizing: content-box; +} + +.sloshcast .sc-row { + white-space: pre; + height: var(--sc-lh); + contain: layout style; +} + +/* Ghost text: characters the canvas paints as geometry. The glyph stays in + * the DOM, transparent, so selection and copy still carry it. */ +.sloshcast .sc-g { + color: transparent !important; +} + +/* The geometry overlay. pointer-events:none and transparent everywhere it + * does not paint, so text, selection and the click-to-toggle pass through. */ +.sloshcast .sc-canvas { + position: absolute; + top: 8px; /* the screen's padding */ + left: 8px; + pointer-events: none; + z-index: 1; +} + +/* Inside .sc-screen: inherits the font, so 1ch is the grid's own cell width; + * top comes from a row's offsetTop, which already includes the screen's + * vertical padding, and only the horizontal padding needs adding back. */ +.sloshcast .sc-cursor { + display: none; + position: absolute; + margin: 0 0 0 8px; + width: 1ch; + height: var(--sc-lh); + background: rgba(255, 255, 255, 0.55); + pointer-events: none; + z-index: 2; +} + +/* The pointer track: an arrow the demo drives, gliding between the cells + * castgen recorded. A screen capture never shows the pointer; this one is + * data, so it can. Pink while the button is down. */ +.sloshcast .sc-pointer { + display: none; + position: absolute; + margin-left: 8px; /* the screen's horizontal padding, like the cursor */ + width: 1.6ch; + height: 1.35em; + background: #fff; + clip-path: polygon(0 0, 0 88%, 34% 70%, 52% 100%, 68% 92%, 50% 62%, 78% 58%); + filter: drop-shadow(0 0 1px #000); + transition: + left 0.09s linear, + top 0.09s linear; + pointer-events: none; + z-index: 3; +} +.sloshcast .sc-pointer.sc-pressed { + background: #ff5fd7; +} + +/* The big play glyph over the poster; gone while playing. */ +.sloshcast .sc-play { + position: absolute; + inset: 0; + margin: auto; + width: 64px; + height: 64px; + border: 1px solid #ff5fd7; + border-radius: 50%; + background: rgba(0, 0, 0, 0.55); + color: #ff5fd7; + font-size: 22px; + cursor: pointer; + z-index: 4; +} +.sloshcast.sc-playing .sc-play { + display: none; +} + +.sloshcast .sc-bar { + display: flex; + justify-content: space-between; + gap: 1rem; + padding: 4px 10px; + font-family: ui-monospace, "SF Mono", Menlo, Consolas, "DejaVu Sans Mono", + monospace; + font-size: 11px; + color: #45454a; + border-top: 1px solid #2a2a2e; + background: #141418; + white-space: nowrap; + overflow: hidden; +} diff --git a/web/recordings/player.js b/web/recordings/player.js new file mode 100644 index 0000000..11cdbc3 --- /dev/null +++ b/web/recordings/player.js @@ -0,0 +1,465 @@ +// sloshcast player -- replays slosh's own composited frames into a DOM grid. +// +// No VT, no wasm, no dependency: the generator (castgen.py) already asked the +// compositor for styled rows with colours resolved to hex, so playing one back +// is innerHTML on the rows that changed. The whole player is this file. +// +// Box-drawing, block and shade characters are NOT left to the font. A font +// glyph is hinted against its own metrics, so a column of │ shows seams and a +// scaled ╭ goes jagged. Instead -- the approach learned from shellglass's +// viewer -- a canvas overlay paints those cells as device-pixel geometry with +// ROUNDED, SHARED cell boundaries (adjacent cells tile exactly), while the +// real character stays in the DOM as transparent "ghost" text so selection +// and copy still work. For the same reason the player never transform-scales: +// it fits by choosing the font size, and pins the row height in pixels. +// +// mountSloshcast(el, data, {loop, autoplay, speed}) +// +// `data` is the parsed JSON; `el` becomes the player. Returns {play, pause, +// toggle, restart, seek, fit}. + +"use strict"; + +// ── glyph geometry (after shellglass viewer.ts) ────────────────────────────── + +// Arm weights "urdl" (0 none, 1 light, 2 heavy) for U+2500-257F; "0000" = not +// arms-coverable here (doubles/diagonals stay on the font path, dashes and +// arcs have their own ops below). +const SC_ARMS = + "0101020210102020" + // 2500 ─ ━ │ ┃ + "0000000000000000" + "0000000000000000" + // 2504-250B dashes (dashesOps) + "0110021001200220" + // 250C ┌┍┎┏ + "0011001200210022" + // 2510 ┐┑┒┓ + "1100120021002200" + // 2514 └┕┖┗ + "1001100220012002" + // 2518 ┘┙┚┛ + "1110121021101120" + // 251C ├┝┞┟ + "2120221012202220" + // 2520 ┠┡┢┣ + "1011101220111021" + // 2524 ┤┥┦┧ + "2021201210222022" + // 2528 ┨┩┪┫ + "0111011202110212" + // 252C ┬┭┮┯ + "0121012202210222" + // 2530 ┰┱┲┳ + "1101110212011202" + // 2534 ┴┵┶┷ + "2101210222012202" + // 2538 ┸┹┺┻ + "1111111212111212" + // 253C ┼┽┾┿ + "2111112121212112" + // 2540 ╀╁╂╃ + "2211112212212212" + // 2544 ╄╅╆╇ + "1222212222212222" + // 2548 ╈╉╊╋ + "0000000000000000" + // 254C-254F dashes (dashesOps) + "0000000000000000".repeat(8) + // 2550-256C doubles (font) + // 256D-2570 arcs (arcOps), 2571-2573 diagonals (font) + "0001100001000010" + // 2574 ╴╵╶╷ + "0002200002000020" + // 2578 ╸╹╺╻ + "0201102001022010"; // 257C ╼╽╾╿ + +function scBoxArms(cp) { + if (cp < 0x2500 || cp > 0x257f) return null; + const o = (cp - 0x2500) * 4; + const u = +SC_ARMS[o], r = +SC_ARMS[o + 1], d = +SC_ARMS[o + 2], l = +SC_ARMS[o + 3]; + return u || r || d || l ? [u, r, d, l] : null; +} + +const scLw = (weight, light) => (weight === 2 ? 2 * light : light); + +// Arms (lines, corners, tees, crosses): each arm its own weight, extended past +// centre by the crossing bar's half-extent so the junction fills solid. +function scArmsOps(x0, y0, x1, y1, arms, light) { + const [u, r, d, l] = arms; + const midX = Math.round((x0 + x1) / 2); + const midY = Math.round((y0 + y1) / 2); + const vh = scLw(Math.max(u, d), light) >> 1; + const hh = scLw(Math.max(l, r), light) >> 1; + const ops = []; + if (u) { const t = scLw(u, light); ops.push([midX - (t >> 1), y0, t, midY + hh - y0]); } + if (d) { const t = scLw(d, light); ops.push([midX - (t >> 1), midY - hh, t, y1 - (midY - hh)]); } + if (l) { const t = scLw(l, light); ops.push([x0, midY - (t >> 1), midX + vh - x0, t]); } + if (r) { const t = scLw(r, light); ops.push([midX - vh, midY - (t >> 1), x1 - (midX - vh), t]); } + return { rects: ops }; +} + +function scDashesOps(x0, y0, x1, y1, cp, light) { + let horiz, n, weight; + if (cp <= 0x250b) { + const k = cp - 0x2504; + horiz = (k & 3) < 2; + n = k < 4 ? 3 : 4; + weight = k & 1 ? 2 : 1; + } else { + const k = cp - 0x254c; + horiz = k < 2; + n = 2; + weight = k & 1 ? 2 : 1; + } + const t = scLw(weight, light); + const midX = Math.round((x0 + x1) / 2); + const midY = Math.round((y0 + y1) / 2); + const rects = []; + for (let i = 0; i < n; i++) { + const s0 = (i + 0.2) / n, s1 = (i + 0.8) / n; + if (horiz) { + const a = Math.round(x0 + s0 * (x1 - x0)), b = Math.round(x0 + s1 * (x1 - x0)); + rects.push([a, midY - (t >> 1), b - a, t]); + } else { + const a = Math.round(y0 + s0 * (y1 - y0)), b = Math.round(y0 + s1 * (y1 - y0)); + rects.push([midX - (t >> 1), a, t, b - a]); + } + } + return { rects }; +} + +// Rounded corners ╭╮╯╰: a quarter ellipse from the far corner whose tangent +// points land on the straight arms' centrelines, so it meets ─/│ flush. +function scArcOps(x0, y0, x1, y1, cp, light) { + const off = (light % 2) / 2; + const mx = Math.round((x0 + x1) / 2) + off; + const my = Math.round((y0 + y1) / 2) + off; + const corners = [[x1, y1], [x0, y1], [x0, y0], [x1, y0]]; // ╭ ╮ ╯ ╰ + const angles = [ + [Math.PI, 1.5 * Math.PI], [1.5 * Math.PI, 2 * Math.PI], + [0, 0.5 * Math.PI], [0.5 * Math.PI, Math.PI], + ]; + const [cx, cy] = corners[cp - 0x256d]; + const [a0, a1] = angles[cp - 0x256d]; + return { arc: [cx, cy, Math.abs(cx - mx), Math.abs(cy - my), a0, a1, light] }; +} + +// Block elements: solid rects (halves/eighths/quadrants) and alpha shades. +const SC_QUADRANTS = [4, 8, 1, 13, 9, 7, 11, 2, 6, 14]; // 2596-259F +function scBlockOps(x0, y0, x1, y1, cp) { + const W = x1 - x0, H = y1 - y0; + const R = (u0, v0, u1, v1, alpha) => { + const a = Math.round(x0 + u0 * W), b = Math.round(x0 + u1 * W); + const c = Math.round(y0 + v0 * H), d = Math.round(y0 + v1 * H); + return [a, c, b - a, d - c, alpha]; + }; + if (cp === 0x2580) return { rects: [R(0, 0, 1, 0.5)] }; + if (cp >= 0x2581 && cp <= 0x2588) return { rects: [R(0, 1 - (cp - 0x2580) / 8, 1, 1)] }; + if (cp >= 0x2589 && cp <= 0x258f) return { rects: [R(0, 0, (0x2590 - cp) / 8, 1)] }; + if (cp === 0x2590) return { rects: [R(0.5, 0, 1, 1)] }; + if (cp <= 0x2593) return { rects: [R(0, 0, 1, 1, (cp - 0x2590) / 4)] }; + if (cp === 0x2594) return { rects: [R(0, 0, 1, 0.125)] }; + if (cp === 0x2595) return { rects: [R(0.875, 0, 1, 1)] }; + const m = SC_QUADRANTS[cp - 0x2596]; + const rects = []; + if (m & 1) rects.push(R(0, 0, 0.5, 0.5)); + if (m & 2) rects.push(R(0.5, 0, 1, 0.5)); + if (m & 4) rects.push(R(0, 0.5, 0.5, 1)); + if (m & 8) rects.push(R(0.5, 0.5, 1, 1)); + return { rects }; +} + +function scGlyphOps(cp, x0, y0, x1, y1, light) { + if (cp >= 0x2580 && cp <= 0x259f) return scBlockOps(x0, y0, x1, y1, cp); + if ((cp >= 0x2504 && cp <= 0x250b) || (cp >= 0x254c && cp <= 0x254f)) + return scDashesOps(x0, y0, x1, y1, cp, light); + if (cp >= 0x256d && cp <= 0x2570) return scArcOps(x0, y0, x1, y1, cp, light); + const arms = scBoxArms(cp); + return arms ? scArmsOps(x0, y0, x1, y1, arms, light) : null; +} + +// The cells the canvas paints (must agree with scGlyphOps): geometry replaces +// the glyph, the DOM keeps it as transparent ghost text. +function scIsCanvasCp(cp) { + if (cp >= 0x2580 && cp <= 0x259f) return true; + if (cp < 0x2500 || cp > 0x257f) return false; + if ((cp >= 0x2504 && cp <= 0x250b) || (cp >= 0x254c && cp <= 0x254f)) return true; + if (cp >= 0x256d && cp <= 0x2570) return true; + return scBoxArms(cp) !== null; +} + +// ── the player ──────────────────────────────────────────────────────────────── + +function mountSloshcast(el, data, opts = {}) { + const speed = opts.speed || 1; + const PAD = 8; // .sc-screen padding, px + const BASE_FONT = 14; + const esc = (s) => + s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); + + // -- one row: text + [x, w, fg, bg, attrbits] runs -> spans ------------ + // Canvas-covered characters become ghost spans: transparent text under the + // geometry, so what you select and copy is still the real characters. + function ghostHtml(s) { + let out = "", plain = ""; + for (const ch of s) { + if (scIsCanvasCp(ch.codePointAt(0))) { + if (plain) { out += esc(plain); plain = ""; } + out += `<span class="sc-g">${esc(ch)}</span>`; + } else plain += ch; + } + return out + esc(plain); + } + + function rowHtml(row) { + const [text, runs] = row; + let out = "", x = 0; + for (const [rx, w, fg, bg, at] of runs) { + if (rx > x) out += ghostHtml(text.slice(x, rx)); + let st = ""; + if (at & 16) { + st += `color:${bg || data.bg};background:${fg || data.fg};`; + } else { + if (fg) st += `color:${fg};`; + if (bg) st += `background:${bg};`; + } + if (at & 1) st += "font-weight:bold;"; + if (at & 2) st += "font-style:italic;"; + if (at & 4) st += "text-decoration:underline;"; + if (at & 8) st += "opacity:.55;"; + if (at & 32) st += "text-decoration:line-through;"; + out += `<span style="${st}">${ghostHtml(text.slice(rx, rx + w))}</span>`; + x = rx + w; + } + return out + ghostHtml(text.slice(x)); + } + + // -- the grid ---------------------------------------------------------- + el.classList.add("sloshcast"); + el.style.background = data.bg; + el.innerHTML = + `<div class="sc-frame"><pre class="sc-screen"><canvas class="sc-canvas"></canvas>` + + `<span class="sc-cursor"></span><span class="sc-pointer"></span></pre>` + + `<button class="sc-play" aria-label="Play">▶</button></div>` + + `<div class="sc-bar"><span class="sc-title">${esc(data.title || "")}</span>` + + `<span class="sc-time"></span></div>`; + const frame = el.querySelector(".sc-frame"); + const screen = el.querySelector(".sc-screen"); + const canvas = el.querySelector(".sc-canvas"); + const cursor = el.querySelector(".sc-cursor"); + const pointer = el.querySelector(".sc-pointer"); + const bigPlay = el.querySelector(".sc-play"); + const timeEl = el.querySelector(".sc-time"); + const ctx = canvas.getContext("2d"); + screen.style.color = data.fg; + + let rowEls = []; + let rowsData = []; // retained [text, runs] per row: the canvas reads it + let gcols = data.cols, grows = data.rows; + let cellW = 8, cellH = 17, dpr = 1; + let canvasDirty = false; + + function grid(cols, rows) { + gcols = cols; + grows = rows; + rowsData = []; + for (const d of screen.querySelectorAll(".sc-row")) d.remove(); + rowEls = []; + for (let y = 0; y < rows; y++) { + const d = document.createElement("div"); + d.className = "sc-row"; + d.innerHTML = "\u00a0"; + screen.appendChild(d); + rowEls.push(d); + } + fit(); + } + + function apply(ev) { + if (ev.size) grid(ev.size[0], ev.size[1]); + if (ev.rows) + for (const y in ev.rows) + if (rowEls[y]) { + rowsData[y] = ev.rows[y]; + rowEls[y].innerHTML = rowHtml(ev.rows[y]) || "\u00a0"; + } + if ("cur" in ev) { + if (ev.cur) { + cursor.style.display = "block"; + cursor.style.left = ev.cur[0] + "ch"; + cursor.style.top = rowEls[ev.cur[1]] + ? rowEls[ev.cur[1]].offsetTop + "px" + : "0"; + } else cursor.style.display = "none"; + } + if ("ptr" in ev) { + if (ev.ptr) { + pointer.style.display = "block"; + pointer.style.left = ev.ptr[0] + 0.5 + "ch"; + pointer.style.top = rowEls[ev.ptr[1]] + ? rowEls[ev.ptr[1]].offsetTop + "px" + : "0"; + pointer.classList.toggle("sc-pressed", !!ev.ptr[2]); + } else pointer.style.display = "none"; + } + canvasDirty = true; + } + + // -- the canvas overlay: box/block glyphs as device-pixel geometry ------ + function cellRect(x, y) { + return [ + Math.round(x * cellW * dpr), + Math.round(y * cellH * dpr), + Math.round((x + 1) * cellW * dpr), + Math.round((y + 1) * cellH * dpr), + ]; + } + + function repaintCanvas() { + canvasDirty = false; + ctx.clearRect(0, 0, canvas.width, canvas.height); + const light = Math.max(1, Math.round(dpr)); + for (let y = 0; y < grows; y++) { + const rd = rowsData[y]; + if (!rd) continue; + const [text, runs] = rd; + for (let x = 0; x < text.length; x++) { + const cp = text.codePointAt(x); + if (cp < 0x2500 || cp > 0x259f) continue; + const [x0, y0, x1, y1] = cellRect(x, y); + const ops = scGlyphOps(cp, x0, y0, x1, y1, light); + if (!ops) continue; + let fg = null; + for (const r of runs) + if (r[0] <= x && x < r[0] + r[1]) { fg = r[4] & 16 ? r[3] : r[2]; break; } + ctx.fillStyle = ctx.strokeStyle = fg || data.fg; + if (ops.rects) + for (const [rx, ry, rw, rh, alpha] of ops.rects) { + if (alpha !== undefined) ctx.globalAlpha = alpha; + ctx.fillRect(rx, ry, rw, rh); + if (alpha !== undefined) ctx.globalAlpha = 1; + } + if (ops.arc) { + const [acx, acy, arx, ary, a0, a1, lw] = ops.arc; + ctx.beginPath(); + ctx.lineWidth = lw; + ctx.ellipse(acx, acy, arx, ary, 0, a0, a1); + ctx.stroke(); + } + } + } + } + + function flush() { + if (canvasDirty) repaintCanvas(); + } + + // -- fit: choose a font size (never a transform), pin px metrics -------- + // A transform resamples the raster and every span rounds on its own, which + // is exactly where jagged borders come from; a font size change re-hints. + const probe = document.createElement("span"); + probe.style.cssText = "position:absolute;visibility:hidden;white-space:pre"; + probe.textContent = "M".repeat(32); + + function metricsAt(px) { + screen.style.fontSize = px + "px"; + screen.appendChild(probe); + const w = probe.getBoundingClientRect().width / 32; + probe.remove(); + return w; + } + + function fit() { + dpr = window.devicePixelRatio || 1; + const availHost = el.parentElement ? el.parentElement.clientWidth : 0; + const avail = Math.max(120, availHost - 2) - 2 * PAD; + let f = BASE_FONT; + cellW = metricsAt(f); + if (gcols * cellW > avail) { + f = Math.max(6, Math.floor(((BASE_FONT * avail) / (gcols * cellW)) * 2) / 2); + cellW = metricsAt(f); + while (gcols * cellW > avail && f > 6) { + f -= 0.5; + cellW = metricsAt(f); + } + } + cellH = Math.round(f * 1.25); + screen.style.setProperty("--sc-lh", cellH + "px"); + screen.style.width = gcols * cellW + "px"; + canvas.width = Math.round(gcols * cellW * dpr); + canvas.height = Math.round(grows * cellH * dpr); + canvas.style.width = gcols * cellW + "px"; + canvas.style.height = grows * cellH + "px"; + el.style.width = Math.ceil(gcols * cellW) + 2 * PAD + "px"; + canvasDirty = true; + flush(); + } + addEventListener("resize", fit); + + // -- the clock --------------------------------------------------------- + // Events are sparse (nothing changes between keystrokes), so instead of a + // 60fps tick the player sleeps until the next event is due. performance.now + // keeps it honest across throttled or background tabs: waking late just + // applies everything that became due. + let idx = 0, + t = 0, + t0 = 0, + playing = false, + timer = 0; + + function restart() { + seek(0); + } + function seek(to) { + idx = 0; + t = to; + pointer.style.display = "none"; + grid(data.cols, data.rows); + while (idx < data.events.length && data.events[idx].t <= to) + apply(data.events[idx++]); + if (idx === 0 && data.events.length) apply(data.events[idx++]); + flush(); + clock(); + if (playing) t0 = performance.now() - (t * 1000) / speed; + } + function clock() { + const m = Math.floor(t / 60), + s = Math.floor(t % 60); + timeEl.textContent = + `${m}:${String(s).padStart(2, "0")} / ` + + `${Math.floor(data.dur / 60)}:${String(Math.floor(data.dur % 60)).padStart(2, "0")}`; + } + function step() { + if (!playing) return; + t = ((performance.now() - t0) / 1000) * speed; + while (idx < data.events.length && data.events[idx].t <= t) + apply(data.events[idx++]); + flush(); + clock(); + if (t >= data.dur) { + if (!opts.loop) return pause(); + restart(); + t0 = performance.now(); + } + schedule(); + } + function schedule() { + const next = idx < data.events.length ? data.events[idx].t : data.dur; + timer = setTimeout(step, Math.max(0, ((next - t) * 1000) / speed) + 1); + } + function play() { + if (playing) return; + playing = true; + el.classList.add("sc-playing"); + if (idx >= data.events.length && t >= data.dur) restart(); + t0 = performance.now() - (t * 1000) / speed; + schedule(); + } + function pause() { + playing = false; + el.classList.remove("sc-playing"); + clearTimeout(timer); + } + const toggle = () => (playing ? pause() : play()); + frame.addEventListener("click", toggle); + + // -- poster: the first frame, standing still --------------------------- + restart(); + if (opts.autoplay) play(); + return { play, pause, toggle, restart, seek, fit }; +} + +// Declarative mounts: <div data-sloshcast="hero.json" data-loop data-autoplay +// data-poster="12.5"> -- poster picks the paused frame shown before play. +for (const el of document.querySelectorAll("[data-sloshcast]")) { + fetch(el.dataset.sloshcast) + .then((r) => r.json()) + .then((data) => { + const ctl = mountSloshcast(el, data, { + loop: "loop" in el.dataset, + autoplay: "autoplay" in el.dataset, + speed: parseFloat(el.dataset.speed || "1"), + }); + if (el.dataset.poster) ctl.seek(parseFloat(el.dataset.poster)); + el._sloshcast = ctl; // for whoever orchestrates several players + el.dispatchEvent(new CustomEvent("sloshcast", { bubbles: true })); + }); +} diff --git a/web/recordings/seek.html b/web/recordings/seek.html new file mode 100644 index 0000000..c1e42bc --- /dev/null +++ b/web/recordings/seek.html @@ -0,0 +1,13 @@ +<!doctype html><html><head><meta charset="utf-8"><title>sloshcast seek</title> +<link rel="stylesheet" href="player.css"> +<style>body{background:#0a0a0c;max-width:960px;margin:2rem auto}</style></head><body> +<div id="p"></div> +<script src="player.js"></script> +<script> +// A debug page: ?cast=NAME&t=SECONDS renders that demo frozen at that moment. +// Handy for screenshots, and for picking data-poster times. +const q = new URLSearchParams(location.search); +fetch('build/' + (q.get('cast') || 'panes') + '.json').then(r=>r.json()).then(d=>{ + mountSloshcast(document.getElementById('p'), d, {}).seek(parseFloat(q.get('t') || '0')); +}); +</script></body></html> diff --git a/web/recordings/test.html b/web/recordings/test.html new file mode 100644 index 0000000..a7c5686 --- /dev/null +++ b/web/recordings/test.html @@ -0,0 +1,26 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>sloshcast — player test</title> +<link rel="stylesheet" href="player.css"> +<style> + body { background: #0a0a0c; color: #ddd; font-family: ui-monospace, monospace; + max-width: 960px; margin: 2rem auto; padding: 0 1rem; } + h1 { color: #ff5fd7; font-size: 1.1rem; } + .sloshcast { margin-bottom: 2rem; } +</style> +</head> +<body> +<h1>sloshcast player test</h1> +<p>click a cast to pause/resume.</p> +<div data-sloshcast="build/panes.json" data-loop data-autoplay></div> +<div data-sloshcast="build/sessions.json" data-loop></div> +<div data-sloshcast="build/reflow.json" data-loop></div> +<div data-sloshcast="build/config.json" data-loop></div> +<div data-sloshcast="build/shaders.json" data-loop></div> +<div data-sloshcast="build/scripting.json" data-loop></div> +<script src="player.js"></script> +</body> +</html> diff --git a/web/site.css b/web/site.css index a46e45f..aba3bcd 100644 --- a/web/site.css +++ b/web/site.css @@ -76,7 +76,10 @@ main { padding: 0 1.25rem 4rem; } -.hero { padding: 3.5rem 0 1rem; } +.hero { + padding: 3.5rem 0 1rem; + text-align:center !important; +} .hero h1 { margin: 0; @@ -89,7 +92,6 @@ main { .tagline { margin: 0.5rem 0 0; - max-width: 38rem; color: var(--dim); font-size: 1.05rem; } @@ -133,42 +135,63 @@ section > p:last-child { margin-bottom: 0; } .aside { color: var(--dim); font-size: 0.95rem; } -/* ---- the video placeholder ------------------------------------------ */ +/* ---- the install box ------------------------------------------------- */ +/* Pick a platform, read only its instructions. Hidden radios, labels drawn + * like a slosh tab strip, one panel each; the switching is CSS. */ -.video-placeholder { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 0.5rem; - aspect-ratio: 16 / 9; - border: 1px dashed var(--faint); +.install { + margin: 1rem 0; + border: 1px solid var(--panel); border-radius: 4px; - background: - repeating-linear-gradient(-45deg, - transparent 0 14px, rgba(255, 255, 255, 0.015) 14px 28px), - var(--panel); - text-align: center; + background: var(--bg); } -.video-placeholder .play { - color: var(--faint); - font-size: 2.5rem; - line-height: 1; +.install > .os { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; } -.video-note { - color: var(--dim); +.os-tabs { + display: flex; + gap: 0.35rem; + overflow-x: auto; + padding: 0.45rem 0.6rem; + border-bottom: 1px solid var(--panel); font-family: var(--mono); - font-size: 14px; + font-size: 13px; } -.video-sub { max-width: 28rem; padding: 0 1rem; color: var(--faint); font-size: 13px; } +.os-tabs label { + padding: 0.05rem 0.7rem; + border-radius: 3px; + color: var(--dim); + cursor: pointer; + white-space: nowrap; +} +.os-tabs label:hover { color: var(--pink); } -#video video { /* the eventual replacement inherits the frame */ - width: 100%; - border-radius: 4px; +.install > .os:focus-visible ~ .os-tabs { outline: 1px solid var(--pink); } + +.os-panel { display: none; padding: 0.4rem 1rem 0.9rem; } +.os-panel .cmd { margin: 0.6rem 0; } +.os-panel .aside { margin: 0.6rem 0 0; font-size: 0.9rem; } + +/* the checked radio lights its tab and shows its panel */ +#os-linux:checked ~ .os-tabs label[for="os-linux"], +#os-macos:checked ~ .os-tabs label[for="os-macos"], +#os-arch:checked ~ .os-tabs label[for="os-arch"], +#os-windows:checked ~ .os-tabs label[for="os-windows"], +#os-source:checked ~ .os-tabs label[for="os-source"] { + background: var(--pink); + color: var(--panel); } +#os-linux:checked ~ #install-linux, +#os-macos:checked ~ #install-macos, +#os-arch:checked ~ #install-arch, +#os-windows:checked ~ #install-windows, +#os-source:checked ~ #install-source { display: block; } /* ---- command blocks ------------------------------------------------- */ @@ -206,6 +229,94 @@ section > p:last-child { margin-bottom: 0; } } .copy:hover { border-color: var(--pink); color: var(--pink); } +/* ---- the feature tour ----------------------------------------------- */ +/* A terminal pinned to the left half, vertically centred; the features + * scroll by on the right. Wider than the text column on purpose — the + * recordings are 100 columns and want the room. */ + +.scrolly { + --tour-w: min(94vw, 88rem); + width: var(--tour-w); + margin-left: calc((100% - var(--tour-w)) / 2); + display: grid; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + gap: clamp(1.5rem, 4vw, 4.5rem); +} + +.stage-pin { + position: sticky; + top: 0; + height: 100vh; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +.stage-pin [data-sloshcast] { display: none; } +.stage-pin .stage-active { display: block; } + +.step { + min-height: 50vh; + display: flex; + flex-direction: column; + justify-content: center; + opacity: 0.3; + transition: opacity 0.25s; +} +.step.active { opacity: 1; } + +.step h3 { + margin: 0; + color: var(--fg); + font-size: clamp(1.4rem, 2.2vw, 1.9rem); + font-weight: 650; + line-height: 1.25; +} + +.step p { + margin: 0.8rem 0 0; + color: var(--dim); + font-size: clamp(1.05rem, 1.4vw, 1.2rem); + line-height: 1.6; +} + +.step ul { + margin: 1rem 0 0; + padding-left: 1.2em; + color: var(--dim); + font-size: clamp(0.98rem, 1.25vw, 1.1rem); + line-height: 1.55; +} +.step li { margin: 0.35rem 0; } +.step li::marker { color: var(--pink); } + +/* The tour's exit: back up to the install box, or into the browser demo. */ +.step-cta { min-height: 45vh; } +.step-cta .hero-links { margin-top: 1.5rem; } + +@media (max-width: 54rem) { + /* One column: the terminal sticks under the nav, the features scroll + * beneath it. The sticky moves up to .scrolly-stage here: in block layout + * .stage-pin's parent is only as tall as the player, so a sticky inside it + * has nowhere to stick; .scrolly-stage is bounded by the whole tour. */ + .scrolly { display: block; } + .scrolly-stage { + position: sticky; + top: 2.2rem; /* below the nav */ + z-index: 2; + padding: 0.6rem 0; + background: var(--bg); + } + .stage-pin { + position: static; + height: auto; + padding: 0; + } + .step { min-height: 50vh; } + .step:first-child { padding-top: 1.5rem; } +} + /* ---- the points list ------------------------------------------------ */ .points { margin: 0.5rem 0 1.25rem; } |
