Merge remote-tracking branch 'gh/main' into wave/h-shared-frontend

This commit is contained in:
Edward Langley
2026-09-20 11:48:28 -07:00
6 changed files with 273 additions and 35 deletions
+17 -10
View File
@@ -75,14 +75,15 @@ nix profile install .
### From crates.io
```sh
cargo install improvise
```
Not yet published — `improvise` is not on crates.io, so `cargo install
improvise` does not work today. It is tracked as improvise-l36.
### Prebuilt binaries
See the [GitHub releases page](https://github.com/fiddlerwoaroof/improvise/releases)
for prebuilt binaries (Linux x86_64, macOS Intel and Apple Silicon).
The [GitHub releases page](https://github.com/fiddlerwoaroof/improvise/releases)
carries prebuilt binaries for Linux (x86_64 and aarch64) and macOS (Intel and
Apple Silicon), built by `cargo dist`. The current release is the `v0.1.0-rc2`
prerelease; there is no `v0.1.0` tag yet (improvise-3tj).
## The data model
@@ -178,10 +179,16 @@ improvise script setup.txt -f model.improv
## In the browser
The browser page is at **<https://fiddlerwoaroof.github.io/improvise/>**, built
and published from `main` by the `pages-build` / `pages-deploy` jobs in
`.github/workflows/ci.yml`. A deploy only happens after `cargo test` and the
wasm32 check have passed.
The browser page is at **<https://fiddlerwoaroof.github.io/improvise/app/>**,
built and published from `main` by the `pages-build` / `pages-deploy` jobs in
`.github/workflows/ci.yml`. A deploy only happens after `cargo test`, the
wasm32 check and the Playwright browser harness have passed.
The site **root** is the landing page — `docs/index.html`, with the recorded
terminal casts — and it links to the app. `web/assemble.sh` puts the two
together, which is why the app sits under `app/` rather than at the root: every
path in `app.js` and `worker.js` is relative, so the bundles move with it and
the generated wasm-pack glue needs no editing.
**It needs no server.** The page is still a thin client — it holds no model,
and every question about a cell, its text and whether it may be typed into, is
@@ -368,7 +375,7 @@ browser can satisfy is checked against `web/app.js`, which opens
`new WebSocket(url)` and can therefore send no header. Verify the deny actually
denies before trusting it with a document.
Then, from <https://fiddlerwoaroof.github.io/improvise/>, reload with
Then, from <https://fiddlerwoaroof.github.io/improvise/app/>, reload with
`?ws=improvise.example.com`. Caddy preserves the request path, so the
multi-document form works too: serve several files and name one with
`?ws=improvise.example.com/bank-info`.
+7 -3
View File
@@ -175,7 +175,7 @@ Generate the GIF by running `nix develop --command vhs docs/demo.tape`. Iterate
Asciinema produces `.cast` files that the asciinema-player JS widget can replay in a browser, with selectable text and pixel-perfect terminal rendering. These will be embedded in the GitHub Pages landing page in Phase 4.
Before recording, set the terminal to exactly 100 columns by 30 rows: `stty cols 100 rows 30` (or resize the terminal window manually if `stty` doesn't take). This matters because the asciinema player renders at the recorded dimensions and a wrong size will look broken on the landing page.
Before recording, set the terminal to match what `docs/index.html` declares. That is **120 columns by 37 rows** today, not the 100x30 this plan originally said: the casts that were actually recorded are 120x37, and the landing page follows the recordings rather than the other way round, because a 30-row player clips the bottom seven rows of a 37-row cast — which is where the tile bar and the status line are. `stty cols 120 rows 37` (or resize the window manually if `stty` doesn't take). Whichever geometry is used, the two must agree: the player renders at the size it is told and a mismatch looks broken. If a re-recording changes it, change `cols`/`rows` in `docs/index.html` in the same commit (improvise-2oxt).
Record four casts under `docs/casts/`:
@@ -235,11 +235,15 @@ Vanilla HTML, single file, under 200 lines. No framework, no build step. Dark ba
Section structure: title and one-line tagline → "Reassign axes on the fly" with the pivot cast → "Drill into aggregated cells" with the drill cast → "Formulas over dimensions" with the formulas cast → "Import a CSV" with the import cast → "Get it" with the install commands and a GitHub link.
Each cast should be embedded with `AsciinemaPlayer.create()` configured with `rows: 30, cols: 100, theme: 'monokai', autoPlay: false, loop: true`.
Each cast should be embedded with `AsciinemaPlayer.create()` configured with `theme: 'monokai', autoPlay: false, loop: true`, and `cols`/`rows` set to the casts' own geometry — 120x37 as recorded, not the 100x30 this plan first specified. See 2.4.
### 4.2 Enable GitHub Pages
In the GitHub repo settings, enable Pages with source set to the `main` branch and folder set to `/docs`. Verify the site is live at `https://<user>.github.io/improvise/` within a few minutes of pushing.
**Not branch-and-folder.** Pages is served from the GitHub Actions artifact that `pages-build` / `pages-deploy` publish in `.github/workflows/ci.yml` (Settings → Pages → Source: GitHub Actions), and the tree is assembled by `web/assemble.sh`: `docs/index.html` and `docs/casts/` at the root, the browser app under `app/`.
Setting the source to branch `main` / folder `/docs` instead would serve `docs/` raw. That used to fail loudly — there was no `docs/index.html`, so it was a 404 — and since improvise-e61 it fails **quietly**: the landing page renders perfectly and its "Run improvise in your browser" link 404s, because `app/` exists only in the assembled tree. A page that looks right with a dead front door is worse than a page that is missing.
Verify the site is live at `https://<user>.github.io/improvise/` within a few minutes of pushing, and click through to the app rather than only loading the root.
---
+213
View File
@@ -0,0 +1,213 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>improvise — terminal pivot-table modeling</title>
<meta
name="description"
content="Terminal pivot-table modeling in the spirit of Lotus Improv: multidimensional cells, formulas over dimensions instead of cell addresses, vim-style keybindings."
/>
<!-- improvise-e61: the site root, linking to the app under ./app/.
Why cols/rows are 120x37 and not the 100x30 the tickets name: see the
close reasons on improvise-e61 and improvise-kh8. -->
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/asciinema-player@3.10.0/dist/bundle/asciinema-player.min.css"
/>
<style>
:root {
--bg: #14161a;
--panel: #1b1e24;
--fg: #d7dae0;
--dim: #8b93a1;
--accent: #8ec07c;
--rule: #2b2f37;
--mono: ui-monospace, "SFMono-Regular", "Menlo", "Consolas", monospace;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font: 16px/1.6 system-ui, -apple-system, "Segoe UI", sans-serif;
}
main {
max-width: 1000px;
margin: 0 auto;
padding: 3rem 1rem 5rem;
}
h1,
h2 {
font-family: var(--mono);
font-weight: 600;
letter-spacing: -0.01em;
}
h1 {
font-size: 2.4rem;
margin: 0 0 0.4rem;
color: var(--accent);
}
h2 {
font-size: 1.15rem;
margin: 3rem 0 0.3rem;
padding-top: 1.5rem;
border-top: 1px solid var(--rule);
}
.tagline {
color: var(--dim);
font-size: 1.1rem;
max-width: 62ch;
margin: 0 0 1.5rem;
}
p.note {
color: var(--dim);
margin: 0 0 1rem;
max-width: 72ch;
}
/* Same size as the body text and set off, not a grey footnote: a
visitor who reads the section reads this (improvise-2oxt). */
p.dated {
margin: 0 0 1rem;
padding: 0.6rem 0.9rem;
max-width: 72ch;
border-left: 3px solid var(--accent);
background: var(--panel);
}
a {
color: var(--accent);
}
.cta {
display: inline-block;
margin: 0 0.6rem 0.6rem 0;
padding: 0.55rem 1.1rem;
border: 1px solid var(--rule);
border-radius: 6px;
background: var(--panel);
font-family: var(--mono);
text-decoration: none;
}
.cta:hover {
border-color: var(--accent);
}
.cta.primary {
border-color: var(--accent);
color: var(--bg);
background: var(--accent);
font-weight: 600;
}
.player {
background: var(--panel);
border: 1px solid var(--rule);
border-radius: 6px;
overflow: hidden;
}
pre {
background: var(--panel);
border: 1px solid var(--rule);
border-radius: 6px;
padding: 1rem;
overflow-x: auto;
font-family: var(--mono);
font-size: 0.9rem;
}
footer {
margin-top: 3rem;
padding-top: 1.5rem;
border-top: 1px solid var(--rule);
color: var(--dim);
font-size: 0.9rem;
}
</style>
</head>
<body>
<main>
<h1>improvise</h1>
<p class="tagline">
Terminal pivot-table modeling in the spirit of Lotus Improv —
multidimensional cells, formulas over dimensions instead of cell
addresses, and vim-style keybindings for reassigning axes on the fly.
</p>
<p>
<a class="cta primary" href="./app/"
>▶ Run improvise in your browser</a
>
<a class="cta" href="https://github.com/fiddlerwoaroof/improvise"
>Source on GitHub</a
>
</p>
<p class="note">
No install, no server, no account: the whole application runs in a Web
Worker on this machine and the document lives in your browser's private
storage.
</p>
<h2>Reassign an axis</h2>
<p class="note">
A category lives on exactly one axis. Moving it rewrites the grid;
nothing in the data or the formulas changes.
</p>
<div class="player" id="cast-pivot"></div>
<h2>Drill into an aggregate</h2>
<p class="note">
An aggregated cell opens onto the records behind it, editable in place.
</p>
<p class="dated">
Recorded 2026-04-09. The records grid has changed since: it now has a
column per measure plus columns for adding a category and a measure,
where this recording shows a single <code>Value</code> column.
</p>
<div class="player" id="cast-drill"></div>
<h2>Formulas over dimensions</h2>
<p class="note">
<code>Profit = Revenue - Cost</code> names categories and items, not
<code>B3</code>, so it survives every rearrangement of the view.
</p>
<div class="player" id="cast-formulas"></div>
<h2>Import a CSV</h2>
<p class="note">
The wizard classifies each column as a category, a measure or a time
field, and builds the model from what you confirm.
</p>
<div class="player" id="cast-import"></div>
<h2>Install</h2>
<pre>
# Nix
nix profile install github:fiddlerwoaroof/improvise
# Prebuilt binaries (Linux x86_64/aarch64, macOS Intel and Apple Silicon)
# https://github.com/fiddlerwoaroof/improvise/releases</pre
>
<p class="note">
Not yet on crates.io; see the releases page for the current prerelease.
</p>
<footer>
Apache-2.0 ·
<a href="https://github.com/fiddlerwoaroof/improvise">GitHub</a> ·
<a href="./app/">Live browser demo</a>
</footer>
</main>
<script src="https://cdn.jsdelivr.net/npm/asciinema-player@3.10.0/dist/bundle/asciinema-player.min.js"></script>
<script>
// One loop rather than four copies of the same call: the config is the
// same for every cast and a second spelling of it is a second answer.
for (const name of ["pivot", "drill", "formulas", "import"]) {
AsciinemaPlayer.create(
`./casts/${name}.cast`,
document.getElementById(`cast-${name}`),
// No idleTimeLimit: every cast header already carries 2.0, and a
// second spelling of it here is a second answer to one question.
{ cols: 120, rows: 37, theme: "monokai", autoPlay: false, loop: true },
);
}
</script>
</body>
</html>
+3 -1
View File
@@ -31,7 +31,9 @@ export default defineConfig({
],
webServer: {
command: "python3 -m http.server 8099 --bind 127.0.0.1 --directory serve",
url: "http://127.0.0.1:8099/index.html",
// The APP, not the site root — since improvise-kh8 the root is the landing
// page, which would answer 200 with no bundle assembled at all.
url: "http://127.0.0.1:8099/app/index.html",
reuseExistingServer: !process.env.CI,
timeout: 30_000,
},
+6 -6
View File
@@ -31,7 +31,7 @@ test.describe("the shell reports what its peer is doing", () => {
await page.routeWebSocket("ws://127.0.0.1:8787/", (ws) => {
ws.onMessage(() => ws.send("{\"NotAFrame\":{}}"));
});
await page.goto("/index.html?ws=127.0.0.1:8787");
await page.goto("/app/index.html?ws=127.0.0.1:8787");
await expect(status(page)).toContainText("bad frame", { timeout: 15_000 });
});
@@ -41,7 +41,7 @@ test.describe("the shell reports what its peer is doing", () => {
page,
}) => {
await page.addInitScript(logStatus);
await page.goto(`/index.html?ws=${NO_WEBSOCKET_HERE}`);
await page.goto(`/app/index.html?ws=${NO_WEBSOCKET_HERE}`);
await expect(status(page)).not.toHaveText("", { timeout: 15_000 });
await page.waitForTimeout(HANDSHAKE_TIMEOUT_MS + 1_000);
@@ -65,7 +65,7 @@ test.describe("the shell reports what its peer is doing", () => {
await page.routeWebSocket("ws://127.0.0.1:8790/", (ws) => {
ws.onMessage(() => ws.close());
});
await page.goto("/index.html?ws=127.0.0.1:8790");
await page.goto("/app/index.html?ws=127.0.0.1:8790");
await expect(status(page)).toContainText("connected", { timeout: 15_000 });
await expect(status(page)).toContainText("disconnected", { timeout: 15_000 });
@@ -88,7 +88,7 @@ test.describe("the shell reports what its peer is doing", () => {
page,
}) => {
await page.routeWebSocket("ws://127.0.0.1:8788/", () => {});
await page.goto("/index.html?ws=127.0.0.1:8788");
await page.goto("/app/index.html?ws=127.0.0.1:8788");
await expect(status(page)).toContainText("sent no document", { timeout: 20_000 });
});
});
@@ -97,7 +97,7 @@ test.describe("the standalone deployment", () => {
// No `?ws=`, so the page spawns the in-page worker over OPFS. If this draws
// a grid, worker.js, bindings.rs, OpfsFs and the seed fetch all ran.
test("the page draws the demo document out of the worker", async ({ page }) => {
await page.goto("/index.html");
await page.goto("/app/index.html");
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
await expect(status(page)).toContainText("in-page worker");
// web/demo.improv: Product on row, _Measure on column (gotcha 46).
@@ -110,7 +110,7 @@ test.describe("the standalone deployment", () => {
test("committing an edit sends one frame and shows the host's value", async ({
page,
}) => {
await page.goto("/index.html?doc=commit-test.improv");
await page.goto("/app/index.html?doc=commit-test.improv");
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
const cell = page.locator("#grid td.editable").first();
+27 -15
View File
@@ -1,5 +1,6 @@
#!/bin/sh
# Assemble the static site from `web/` into `site/`.
# Assemble the static site into `site/`: the landing page from `docs/` at the
# root, and the browser app from `web/` under `app/`.
#
# `web/` is the *source* directory: it also holds build.sh, assemble.sh and
# .gitignore, which have no business being served. This copies what ships.
@@ -33,26 +34,37 @@ test -d "$root/web/worker-pkg" || { echo "web/worker-pkg missing — run web/bui
rm -rf "$out"
mkdir -p "$out"
cp -f "$root/web/index.html" "$root/web/app.js" "$root/web/style.css" "$root/web/worker.js" "$out/"
# The landing page is the front door and the app is what it sends you to
# (user ruling, improvise-kh8). So `docs/` lands at the ROOT and the app moves
# under `app/` whole — every path in app.js and worker.js is relative, so the
# bundles travel with it and the generated glue needs no editing.
cp -f "$root/docs/index.html" "$out/index.html"
cp -rf "$root/docs/casts" "$out/casts"
app="$out/app"
mkdir -p "$app"
cp -f "$root/web/index.html" "$root/web/app.js" "$root/web/style.css" "$root/web/worker.js" "$app/"
# The document the page opens on a browser that has never seen one.
# web/worker.js fetches it by the ?seed= URL and hands the bytes to Rust,
# which writes them through the VFS — so leaving it out is an empty grid, not
# a 404 the page could report.
cp -f "$root/web/demo.improv" "$out/"
cp -rf "$root/web/pkg" "$out/pkg"
cp -rf "$root/web/worker-pkg" "$out/worker-pkg"
rm -f "$out/pkg/.gitignore" "$out/pkg/package.json"
rm -f "$out/worker-pkg/.gitignore" "$out/worker-pkg/package.json"
cp -f "$root/web/demo.improv" "$app/"
cp -rf "$root/web/pkg" "$app/pkg"
cp -rf "$root/web/worker-pkg" "$app/worker-pkg"
rm -f "$app/pkg/.gitignore" "$app/pkg/package.json"
rm -f "$app/worker-pkg/.gitignore" "$app/worker-pkg/package.json"
# BSD sed (macOS) needs an argument to -i; GNU sed must not be given one.
sedi() { sed "$1" "$2" > "$2.tmp" && mv -f "$2.tmp" "$2"; }
sedi "s|\./pkg/improvise_wasm_client\.js|&?v=${token}|" "$out/app.js"
sedi "s|\./worker-pkg/improvise_worker_server\.js|&?v=${token}|" "$out/worker.js"
sedi "s|\./pkg/improvise_wasm_client\.js|&?v=${token}|" "$app/app.js"
sedi "s|\./worker-pkg/improvise_worker_server\.js|&?v=${token}|" "$app/worker.js"
sedi "s|'improvise_wasm_client_bg\.wasm'|'improvise_wasm_client_bg.wasm?v=${token}'|" \
"$out/pkg/improvise_wasm_client.js"
"$app/pkg/improvise_wasm_client.js"
sedi "s|'improvise_worker_server_bg\.wasm'|'improvise_worker_server_bg.wasm?v=${token}'|" \
"$out/worker-pkg/improvise_worker_server.js"
"$app/worker-pkg/improvise_worker_server.js"
# One `grep` over all four files succeeds when ANY one of them matches, so
# three failed substitutions could still produce a "successful" assembly under
@@ -66,7 +78,7 @@ stamped() {
}
grep -n "$2" "$1"
}
stamped "$out/app.js" "pkg/improvise_wasm_client.js?v=${token}"
stamped "$out/worker.js" "worker-pkg/improvise_worker_server.js?v=${token}"
stamped "$out/pkg/improvise_wasm_client.js" "improvise_wasm_client_bg.wasm?v=${token}"
stamped "$out/worker-pkg/improvise_worker_server.js" "improvise_worker_server_bg.wasm?v=${token}"
stamped "$app/app.js" "pkg/improvise_wasm_client.js?v=${token}"
stamped "$app/worker.js" "worker-pkg/improvise_worker_server.js?v=${token}"
stamped "$app/pkg/improvise_wasm_client.js" "improvise_wasm_client_bg.wasm?v=${token}"
stamped "$app/worker-pkg/improvise_worker_server.js" "improvise_worker_server_bg.wasm?v=${token}"