Merge pull request #47 from fiddlerwoaroof/wave/p-milestones
CI / Test and lint (host) (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Browser bundle + static site (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled
CI / Test and lint (host) (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Browser bundle + static site (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled
Wave 2 WU-P: the landing page, and the milestone epics verified criterion by criterion
This commit is contained in:
@@ -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
@@ -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
@@ -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>
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user