# 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/themes.py` | the theme picker: `C-a t`, the list, two previews *worn* rather than swatched, a typed filter, `C-s` writing the one `theme_name` line, and Escape putting back what was worn |
| `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 `
` 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.
- A demo that changes the theme must keep to dark ones. Cells slosh does not
paint keep the *terminal's* own background, which the cast header fixes
once, so `paper` on a black page would look broken in the player and honest
in a terminal that is light. Same reason a config demo's colour bars are
drawn by the program rather than assumed from the palette.
- Anything a demo writes on screen is published: `themes.py` copies
`contrib/themes` next to its temp config instead of naming `theme_dir`, so
the `cat config.kdl` frame carries a theme *name* and not the render
machine's path.
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.