aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-30 12:49:24 +0200
committeruser <user@clank>2026-08-30 12:49:24 +0200
commita3c5c69d089efd3d778e8b4ef859a6432cd3d07f (patch)
treeb048c2c08709d671fe92907c952df80359893394
parentgraphics: a frame the client never got must be said again (diff)
downloadslosh-a3c5c69d089efd3d778e8b4ef859a6432cd3d07f.tar.gz
slosh-a3c5c69d089efd3d778e8b4ef859a6432cd3d07f.tar.xz
slosh-a3c5c69d089efd3d778e8b4ef859a6432cd3d07f.zip
web: sloshcast
-rw-r--r--.gitignore3
-rw-r--r--web/README.md7
-rwxr-xr-xweb/build-site27
-rw-r--r--web/index.html347
-rw-r--r--web/recordings/Makefile31
-rw-r--r--web/recordings/PLAN.md140
-rw-r--r--web/recordings/castgen.py356
-rw-r--r--web/recordings/demos/config.py72
-rw-r--r--web/recordings/demos/panes.py83
-rw-r--r--web/recordings/demos/reflow.py48
-rw-r--r--web/recordings/demos/scripting.py101
-rw-r--r--web/recordings/demos/sessions.py127
-rw-r--r--web/recordings/demos/shaders.py95
-rw-r--r--web/recordings/player.css124
-rw-r--r--web/recordings/player.js465
-rw-r--r--web/recordings/seek.html13
-rw-r--r--web/recordings/test.html26
-rw-r--r--web/site.css165
18 files changed, 2081 insertions, 149 deletions
diff --git a/.gitignore b/.gitignore
index 6a3998e..f384d2c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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 &amp;&amp; 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 &amp;&amp; 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&nbsp;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 &amp;&amp; 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
+
+ // -- 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; }