feat(web): the page spawns the worker, opens a demo, and reads a key

Three pieces of the standalone deployment, all in web/.

improvise-djm — app.js picks its peer instead of assuming a socket. The
wasm bundle is the same artifact either way (improvise-xks6), so the
difference is confined to one factory: `?ws=` selects the websocket, its
absence spawns `new Worker('./worker.js?doc=&seed=', {type:'module'})`.
The websocket path is KEPT, because improvise-ws-server is how a
TUI-hosted document is shared and nothing else can reach it; the presence
of the parameter is the whole selection mechanism, since a second knob
naming the same fact is a second thing to get wrong.

improvise-bck — web/demo.improv, built against an assertion rather than
by flipping an axis. examples/demo.improv leaves Customer/Date on None,
so every cell is an Aggregate and nothing is editable. The new demo puts
exactly one category on each visible axis (Product on row, _Measure on
column, Quarter on page) and carries Profit = Revenue - Cost, so a
visitor sees the formula move beside the cell they typed in, with no
keystroke and no scrolling. Two tests hold it: tests/web_demo_model.rs
projects the whole grid through grid_of and asserts every CellId is
Record (ADR 002 — that is what `editable` means), and post_message.rs
drives the shipped bytes through the real seed -> VFS -> open -> Snapshot
chain. The first was proved discriminating by pointing it at
examples/demo.improv, where it names the aggregate at (0,0).

One category per axis is asserted too: GridCache slash-joins a
multi-level tuple (gotcha 44), so a second category on an axis puts that
known gap on the front page.

improvise-hwpi — all four. A keydown listener on window feeds
Client::on_key_event, so the classifier is reachable at last; a frame
that will not fold now writes to #status instead of only the console; the
socket has an error listener, so "not running" no longer reads like
"still connecting"; and close() has a re-entry guard. Plus one the ticket
could not have predicted: a worker whose start() rejects is caught inside
worker.js and fires no `error` on the main thread, so the same silence
was about to be rebuilt in the new path — a first-frame watchdog covers
both deployments, because "the peer never answered" is the one condition
that means the same thing on each.

Verified: cargo test --workspace 1159 passing (1155 + 4), clippy clean,
fmt clean, actionlint clean, ./web/build.sh end to end (345 KB / 123 KB
gzipped client, 1.33 MB / 498 KB gzipped worker), every call present in
the generated .d.ts with the right arity, every relative reference in the
assembled site resolving. NOT verified: there is no browser here. No line
of JavaScript in this commit has ever run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
This commit is contained in:
Edward Langley
2026-09-19 19:28:21 -07:00
parent 4286bcc4fc
commit a550201fae
8 changed files with 448 additions and 58 deletions
+5
View File
@@ -130,6 +130,11 @@ jobs:
set -eu set -eu
mkdir -p site mkdir -p site
cp web/index.html web/app.js web/style.css web/worker.js site/ cp web/index.html web/app.js web/style.css web/worker.js site/
# 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 web/demo.improv site/
cp -rf web/pkg site/pkg cp -rf web/pkg site/pkg
cp -rf web/worker-pkg site/worker-pkg cp -rf web/worker-pkg site/worker-pkg
rm -f site/pkg/.gitignore site/pkg/package.json rm -f site/pkg/.gitignore site/pkg/package.json
+3
View File
@@ -13,6 +13,9 @@ bench/*.txt
*.patch *.patch
*.improv *.improv
!examples/*.improv !examples/*.improv
# The document the static site ships and seeds a fresh browser from
# (improvise-bck). A shipped asset, not somebody's working file.
!web/*.improv
# Beads / Dolt files (added by bd init) # Beads / Dolt files (added by bd init)
.dolt/ .dolt/
@@ -71,7 +71,11 @@ struct MainThread {
impl MainThread { impl MainThread {
fn boot(storage: MemoryFs, path: &Path) -> Self { fn boot(storage: MemoryFs, path: &Path) -> Self {
let workbook = vfs::open(&storage, &Opening::named(path).with_seed(&demo_bytes())) Self::boot_seeded(storage, path, &demo_bytes())
}
fn boot_seeded(storage: MemoryFs, path: &Path, seed: &[u8]) -> Self {
let workbook = vfs::open(&storage, &Opening::named(path).with_seed(seed))
.expect("the demo seeds into an empty origin"); .expect("the demo seeds into an empty origin");
let mut app = App::with_storage(workbook, Some(path.to_path_buf()), Box::new(storage)); let mut app = App::with_storage(workbook, Some(path.to_path_buf()), Box::new(storage));
app.rebuild_layout(); app.rebuild_layout();
@@ -320,3 +324,52 @@ fn the_vfs_offers_every_document_the_origin_holds() {
"a picker must see both the seeded document and the created one" "a picker must see both the seeded document and the created one"
); );
} }
/// The document the static site actually ships, driven the way the page
/// drives it (improvise-bck, improvise-djm).
///
/// `tests/web_demo_model.rs` in the root crate asks the same question of
/// `grid_of` directly; this asks it of the **seed → VFS → open → connect →
/// Snapshot** chain, which is the one the browser depends on and the one
/// nothing else exercises. `web/worker.js` fetches these exact bytes and
/// hands them to `start(doc, seed)`, so the fixture is the file rather than a
/// copy of it — a demo that stops being editable fails here, not in a browser
/// nobody in this repository has.
///
/// What is still unverified either way: OPFS (`MemoryFs` stands in) and every
/// line of JavaScript.
#[test]
fn the_shipped_demo_seeds_a_worker_whose_every_cell_is_settable() {
let seed = std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../web/demo.improv"))
.expect("web/demo.improv is where the site build expects it");
let (storage, _) = origin();
let mut main = MainThread::boot_seeded(storage, Path::new("demo.improv"), &seed);
main.post(&ClientFrame::Viewport { rows: 40, cols: 20 });
assert_eq!(
main.grid.row_labels,
vec!["Espresso", "Latte", "Cold Brew", "Pastry"]
);
assert_eq!(main.grid.col_labels, vec!["Revenue", "Cost", "Profit"]);
assert_eq!(main.text_at(0, 2), "12,200", "Profit is live on arrival");
// `cell` panics on an aggregate, which is exactly the failure a visitor
// meets as a cell that does not answer a click (ADR 002, gotcha 42).
for row in 0..main.grid.row_labels.len() {
for col in 0..main.grid.col_labels.len() {
main.cell(row, col);
}
}
// And the payoff the page exists to show: an edit to an input moves the
// formula beside it, through the projection stream rather than by
// assumption.
let revenue = main.cell(0, 0);
main.post(&main.caught_up(ModelCommand::SetCell {
cell: revenue,
value: CellValue::Number(20000.0),
}));
assert_eq!(main.text_at(0, 0), "20,000");
assert_eq!(main.text_at(0, 2), "13,800", "20,000 - 6,200");
}
+109
View File
@@ -0,0 +1,109 @@
//! The demo document the standalone page opens must be one a visitor can
//! *type into* (improvise-bck).
//!
//! The bug this guards against is not hypothetical: `examples/demo.improv`
//! leaves `Customer` and `Date` on the None axis, so every cell in it is an
//! aggregate over records the grid does not name, and an aggregate is not
//! settable — correct behaviour, and a demo where the first thing a visitor
//! clicks does nothing.
//!
//! The question is asked exactly the way the browser asks it. ADR 002 says a
//! cell is settable iff its `CellId` is `Record`, `project::cell_id` is the
//! one place that decides, and `render.rs::editable` in the wasm client is
//! that variant and nothing else (gotcha 42). So this test projects the
//! demo's whole grid through `grid_of` — the same function the server calls
//! per frame — and asserts on the surrogate, not on the axes. Asserting the
//! axes instead would re-derive the rule and could agree with itself while
//! disagreeing with the client.
use improvise::protocol::CellId;
use improvise_command::ui::app::App;
use improvise_command::ui::project::{Viewport, grid_of};
fn demo_app() -> App {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("web/demo.improv");
let workbook = improvise::persistence::load(&path).expect("web/demo.improv loads");
App::new(workbook, Some(path))
}
/// Every cell the page paints names a stored record, so every cell is one the
/// visitor may click and change.
#[test]
fn every_visible_cell_in_the_web_demo_is_settable() {
let app = demo_app();
let (rows, cols) = (app.layout.row_count(), app.layout.col_count());
assert!(rows > 0 && cols > 0, "the demo grid must not be empty");
let grid = grid_of(
&app,
Viewport {
row_offset: 0,
col_offset: 0,
rows,
cols,
},
);
for r in 0..rows {
for c in 0..cols {
let cell = grid.cell(r, c).expect("a projected cell at every position");
match &cell.id {
Some(CellId::Record(_)) => {}
other => panic!(
"cell ({r},{c}) — row {:?}, column {:?} — is {other:?}, not a Record, so the \
page will show it as un-editable",
grid.row_labels.get(r),
grid.col_labels.get(c),
),
}
}
}
}
/// The demo is legible only if its headers are single-level. `GridCache`
/// slash-joins a multi-level tuple into one label (gotcha 44), so a second
/// category on an axis reads as `Espresso/North` in the browser. That is a
/// known gap in the shared layer; the demo must not stand on it.
#[test]
fn the_web_demo_has_one_category_per_visible_axis() {
let app = demo_app();
let (rows, cols) = (app.layout.row_count(), app.layout.col_count());
let grid = grid_of(
&app,
Viewport {
row_offset: 0,
col_offset: 0,
rows,
cols,
},
);
for label in grid.row_labels.iter().chain(grid.col_labels.iter()) {
assert!(
!label.contains('/'),
"{label:?} is a joined multi-level label; the browser cannot draw header levels yet"
);
}
}
/// The payoff a visitor should see without pressing a key: a formula column
/// beside the two columns that feed it, so an edit visibly moves it.
#[test]
fn the_web_demo_shows_a_formula_beside_its_inputs() {
let app = demo_app();
let grid = grid_of(
&app,
Viewport {
row_offset: 0,
col_offset: 0,
rows: app.layout.row_count(),
cols: app.layout.col_count(),
},
);
assert_eq!(grid.col_labels, vec!["Revenue", "Cost", "Profit"]);
assert_eq!(
grid.row_labels,
vec!["Espresso", "Latte", "Cold Brew", "Pastry"]
);
let profit = grid.cell(0, 2).expect("the Profit cell");
assert_eq!(profit.text, "12,200", "18,400 - 6,200, recomputed on load");
}
+178 -21
View File
@@ -1,24 +1,49 @@
// The browser half of improvise-cr3, kept as thin as it can be made. // The browser half of improvise-cr3, kept as thin as it can be made, now
// talking to either of two peers (improvise-djm).
// //
// The boundary is JSON strings in and out, so this file holds no protocol // The boundary is JSON strings in and out, so this file holds no protocol
// knowledge at all: it never inspects a Command, never builds a CellId, never // knowledge at all: it never inspects a Command, never builds a CellId, never
// decides whether a cell is editable or whether a typed string is a number. // decides whether a cell is editable or whether a typed string is a number.
// Four calls are the whole harness — // Five calls are the whole harness —
// //
// Client.viewport(rows, cols) -> the handshake frame, and every resize // Client.viewport(rows, cols) -> the handshake frame, and every resize
// client.on_frame(json) -> fold one socket frame, in arrival order // client.on_frame(json) -> fold one frame, in arrival order
// client.render_model() -> everything to paint, already decided // client.render_model() -> everything to paint, already decided
// client.typed_cell(r, c, s) -> the frame for "the user typed s here" // client.typed_cell(r, c, s) -> the frame for "the user typed s here"
// client.on_key_event(k,c,a,s,m) -> the frame for a keypress, folded locally
// first where this client may answer it
// //
// — and the two rules this file must not break are ordering and framing: // — and the two rules this file must not break are ordering and framing:
// frames are folded one at a time in the order they arrive (a cell update // frames are folded one at a time in the order they arrive (a cell update
// that overtakes its labels is dropped, not replayed), and the *first* frame // that overtakes its labels is dropped, not replayed), and the *first* frame
// out must be the viewport, because the server builds the snapshot at it. // out must be the viewport, because the server builds the snapshot at it.
//
// # Two transports, one client
//
// The wasm bundle is the SAME artifact either way (improvise-xks6: one
// envelope pair, two deployments). Only where the strings come from differs,
// so the difference is confined to `openTransport` and nothing below it knows
// which peer it has:
//
// default an in-page Web Worker hosting the whole App over OPFS
// (improvise-9x6). No server process; this is what makes a
// GitHub Pages copy of this directory a working application.
// ?ws=URL a websocket to `improvise-ws-server`. That process is how a
// document opened in the TUI is shared, so the path is kept; the
// presence of the parameter is the whole selection mechanism,
// because a second knob naming the same fact is a second thing to
// get wrong.
//
// Neither is reachable silently. Every transport writes what it is doing to
// `#status` — "starting", "connected", "cannot reach …", "disconnected" — so
// a peer that never arrives reads differently from one that went away.
import init, { Client } from "./pkg/improvise_wasm_client.js"; import init, { Client } from "./pkg/improvise_wasm_client.js";
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
const WS_URL = params.get("ws") ?? `ws://${location.hostname || "127.0.0.1"}:8787`; const WS_URL = params.get("ws"); // present => socket, absent => in-page worker
const DOC = params.get("doc") ?? "demo.improv";
const SEED = params.get("seed") ?? "demo.improv";
// Rough character metrics, used only to turn the window size into an extent. // Rough character metrics, used only to turn the window size into an extent.
// Being wrong here costs a row of over- or under-fetch, nothing more. // Being wrong here costs a row of over- or under-fetch, nothing more.
@@ -35,7 +60,19 @@ const el = {
await init(); await init();
const client = new Client(); const client = new Client();
const socket = new WebSocket(WS_URL);
// `#status` carries two independent facts — what the transport is doing, and
// what the document last said — so it composes them rather than letting a
// repaint quietly erase a connection error.
let shellStatus = "";
let modelStatus = "";
function paintStatus() {
el.status.textContent = [shellStatus, modelStatus].filter(Boolean).join(" · ");
}
function setShellStatus(text) {
shellStatus = text;
paintStatus();
}
/** The extent this window can show, in whole cells. */ /** The extent this window can show, in whole cells. */
function extent() { function extent() {
@@ -47,21 +84,110 @@ function extent() {
} }
function sendViewport() { function sendViewport() {
if (socket.readyState !== WebSocket.OPEN) return;
const { rows, cols } = extent(); const { rows, cols } = extent();
socket.send(Client.viewport(rows, cols)); transport.send(Client.viewport(rows, cols));
} }
// A peer that never answers the handshake. The socket has an `error` event
// for this; the worker has none that fires, because worker.js catches its own
// `start()` rejection — so a browser with no OPFS, or a worker bundle that
// failed to load, would leave the page saying "in-page worker" over an empty
// grid forever. The watchdog is deliberately about the *first frame* rather
// than about either peer's own failure modes: it is the one condition that
// means the same thing in both deployments.
const HANDSHAKE_TIMEOUT_MS = 8000;
let watchdog = null;
function armWatchdog(message) {
clearTimeout(watchdog);
watchdog = setTimeout(() => setShellStatus(message), HANDSHAKE_TIMEOUT_MS);
}
/** One frame, one fold, in arrival order. Never batch or reorder these. */
function onFrame(text) {
clearTimeout(watchdog);
try {
client.on_frame(text);
} catch (err) {
// `on_frame` returns a Result, which wasm-bindgen throws. Thrown in a bare
// listener it reached the devtools console and nothing else, and the grid
// simply stopped updating with nothing on screen saying why (improvise-hwpi).
setShellStatus("bad frame — the grid has stopped updating (see console)");
console.error("improvise: frame rejected", text, err);
return;
}
repaint();
}
// ── transports ───────────────────────────────────────────────────────────
/** The in-page worker: the standalone deployment, no server process. */
function workerTransport() {
const query = new URLSearchParams({ doc: DOC, seed: SEED });
// `type: "module"` is not optional: worker.js is an ES module (it `import`s
// the worker bundle), and a classic worker would fail to parse it.
const worker = new Worker(`./worker.js?${query}`, { type: "module" });
worker.addEventListener("message", (event) => onFrame(event.data));
worker.addEventListener("messageerror", () =>
setShellStatus("the worker sent a frame this page could not read"),
);
worker.addEventListener("error", (event) => {
setShellStatus(`worker failed to start: ${event.message ?? "see console"}`);
console.error("improvise: worker error", event);
});
setShellStatus(`in-page worker · ${DOC}`);
// Frames posted before the worker has finished `start()` are held by
// worker.js and replayed in order, so the handshake can go out immediately.
return { send: (text) => worker.postMessage(text), ready: Promise.resolve() };
}
/** A websocket to `improvise-ws-server`: how a TUI-hosted document is shared. */
function socketTransport() {
const socket = new WebSocket(WS_URL);
const ready = new Promise((resolve) => {
socket.addEventListener("open", () => {
setShellStatus(`connected · ${WS_URL}`);
resolve();
});
});
socket.addEventListener("message", (event) => onFrame(event.data));
// Without this, a connection that never opens left #status blank and
// "server not running" read exactly like "still connecting" (improvise-hwpi).
socket.addEventListener("error", () =>
setShellStatus(`cannot reach ${WS_URL} — is improvise-ws-server running?`),
);
socket.addEventListener("close", () => setShellStatus("disconnected"));
setShellStatus(`connecting to ${WS_URL}…`);
return {
send: (text) => {
if (socket.readyState === WebSocket.OPEN) socket.send(text);
},
ready,
};
}
const transport = WS_URL ? socketTransport() : workerTransport();
// ── painting ─────────────────────────────────────────────────────────────
let painted = -1; let painted = -1;
let editing = null; // {row, col} while an input is open, so a repaint cannot steal it let editing = null; // {row, col} while an input is open, so a repaint cannot steal it
function repaint() { function repaint() {
const model = JSON.parse(client.render_model()); if (editing) return;
if (model.revision === painted || editing) return; let model;
try {
model = JSON.parse(client.render_model());
} catch (err) {
setShellStatus("cannot render the current state (see console)");
console.error("improvise: render failed", err);
return;
}
if (model.revision === painted) return;
painted = model.revision; painted = model.revision;
el.mode.textContent = model.mode; el.mode.textContent = model.mode;
el.status.textContent = model.status; modelStatus = model.status;
paintStatus();
el.viewName.textContent = model.view_name; el.viewName.textContent = model.view_name;
el.grid.replaceChildren(gridTable(model)); el.grid.replaceChildren(gridTable(model));
el.categories.replaceChildren(categoryList(model)); el.categories.replaceChildren(categoryList(model));
@@ -114,13 +240,18 @@ function edit(td, row, col) {
input.focus(); input.focus();
input.select(); input.select();
// Enter removes the focused <input>, which fires `blur`, which calls
// `close` again. Harmless before, but only by accident (improvise-hwpi).
let closed = false;
const close = (commit) => { const close = (commit) => {
if (closed) return;
closed = true;
editing = null; editing = null;
if (commit) { if (commit) {
const frame = client.typed_cell(row, col, input.value); const frame = client.typed_cell(row, col, input.value);
// null only if the grid moved under the editor; the repaint that // null only if the grid moved under the editor; the repaint that
// follows restores whatever the server now says is there. // follows restores whatever the server now says is there.
if (frame) socket.send(frame); if (frame) transport.send(frame);
} }
td.replaceChildren(original); td.replaceChildren(original);
painted = -1; painted = -1;
@@ -130,6 +261,8 @@ function edit(td, row, col) {
input.addEventListener("keydown", (e) => { input.addEventListener("keydown", (e) => {
if (e.key === "Enter") close(true); if (e.key === "Enter") close(true);
else if (e.key === "Escape") close(false); else if (e.key === "Escape") close(false);
// The editor owns the keyboard while it is open; the page-level handler
// below must not also see these.
e.stopPropagation(); e.stopPropagation();
}); });
input.addEventListener("blur", () => close(false)); input.addEventListener("blur", () => close(false));
@@ -149,19 +282,43 @@ function categoryList(model) {
return list; return list;
} }
socket.addEventListener("open", () => { // ── the keyboard ─────────────────────────────────────────────────────────
sendViewport(); // the handshake: the snapshot is built at this extent //
el.status.textContent = "connected"; // `Client::on_key_event` classifies the event and folds it locally where this
}); // client may answer it (text entry, and nothing else — see keys.rs); anything
// else becomes a `ClientFrame::Key` for the peer to resolve against the real
// App. Until improvise-bpq2 lands, `Host::handle` answers that frame with an
// empty vec, so an upstream key is sent and correctly does nothing yet. That
// is a gap in the peer, not in this listener.
socket.addEventListener("message", (event) => { window.addEventListener("keydown", (e) => {
// One frame, one fold, in arrival order. Never batch or reorder these. // The inline editor owns the keyboard while it is open. It stops
client.on_frame(event.data); // propagation, so this is belt and braces for a focus we did not predict.
if (editing) return;
// BrowserKey drops anything held with the platform meta key precisely so
// Cmd-R does not arrive as a bare `r`; do not claim it here either.
if (e.metaKey) return;
const frame = client.on_key_event(e.key, e.ctrlKey, e.altKey, e.shiftKey, e.metaKey);
if (frame) {
// A frame means the page claimed the key, so the browser must not also
// act on it — an arrow that both moves the cursor and scrolls the page is
// the worse of the two errors. Combinations under Ctrl or Alt are left
// alone regardless: those are the browser's own shortcuts, and this
// client resolves nothing under them anyway.
if (!e.ctrlKey && !e.altKey) e.preventDefault();
transport.send(frame);
}
repaint(); repaint();
}); });
socket.addEventListener("close", () => { // ── start ────────────────────────────────────────────────────────────────
el.status.textContent = "disconnected";
});
await transport.ready;
sendViewport(); // the handshake: the snapshot is built at this extent
armWatchdog(
WS_URL
? `${WS_URL} accepted the connection but sent no document`
: "the in-page worker did not answer — this browser may not allow it (see console)",
);
new ResizeObserver(sendViewport).observe(el.grid); new ResizeObserver(sendViewport).observe(el.grid);
+9 -6
View File
@@ -22,14 +22,17 @@ wasm-pack build "$root/crates/improvise-worker-server" \
--target web --out-dir "$root/web/worker-pkg" --target web --out-dir "$root/web/worker-pkg"
cat <<'MSG' cat <<'MSG'
Built. To run the thin client against a real server: Built. Serve the directory and open it — that is the whole application:
improvise-ws-server your-model.improv --port 8787
python3 -m http.server 8080 --directory web python3 -m http.server 8080 --directory web
then open http://127.0.0.1:8080/ (override the socket with ?ws=ws://host:port). then open http://127.0.0.1:8080/. The document lives in an in-page Web
Worker and persists to this browser's private storage (OPFS). It is seeded
on first load from web/demo.improv; ?doc= and ?seed= name other documents.
worker-pkg/ is built and web/worker.js will load it, but nothing instantiates To point the same page at a separate server process instead:
that worker yet: the main-thread bootstrap that spawns it and wires the
MessageChannel is improvise-djm. Until then the worker bundle ships unused. improvise-ws-server your-model.improv --port 8787
then reload with ?ws=ws://127.0.0.1:8787.
MSG MSG
+57
View File
@@ -0,0 +1,57 @@
v2025-04-09
# Corner Coffee — 2025
Initial View: Plan
## View: Plan
Product: row
_Measure: column
Quarter: page, Q1
_Index: none
_Dim: none
format: ,.0
## Formulas
- Profit = Revenue - Cost
## Category: _Measure
- Revenue, Cost
## Category: Product
- Espresso, Latte, |Cold Brew|, Pastry
## Category: Quarter
- Q1, Q2, Q3, Q4
## Data
Product=Espresso, Quarter=Q1, _Measure=Revenue = 18400
Product=Espresso, Quarter=Q1, _Measure=Cost = 6200
Product=Espresso, Quarter=Q2, _Measure=Revenue = 17100
Product=Espresso, Quarter=Q2, _Measure=Cost = 5900
Product=Espresso, Quarter=Q3, _Measure=Revenue = 15800
Product=Espresso, Quarter=Q3, _Measure=Cost = 5400
Product=Espresso, Quarter=Q4, _Measure=Revenue = 20300
Product=Espresso, Quarter=Q4, _Measure=Cost = 6900
Product=Latte, Quarter=Q1, _Measure=Revenue = 24600
Product=Latte, Quarter=Q1, _Measure=Cost = 9100
Product=Latte, Quarter=Q2, _Measure=Revenue = 23800
Product=Latte, Quarter=Q2, _Measure=Cost = 8800
Product=Latte, Quarter=Q3, _Measure=Revenue = 22400
Product=Latte, Quarter=Q3, _Measure=Cost = 8300
Product=Latte, Quarter=Q4, _Measure=Revenue = 27900
Product=Latte, Quarter=Q4, _Measure=Cost = 10200
Product=|Cold Brew|, Quarter=Q1, _Measure=Revenue = 9200
Product=|Cold Brew|, Quarter=Q1, _Measure=Cost = 3400
Product=|Cold Brew|, Quarter=Q2, _Measure=Revenue = 14600
Product=|Cold Brew|, Quarter=Q2, _Measure=Cost = 5300
Product=|Cold Brew|, Quarter=Q3, _Measure=Revenue = 17300
Product=|Cold Brew|, Quarter=Q3, _Measure=Cost = 6200
Product=|Cold Brew|, Quarter=Q4, _Measure=Revenue = 8100
Product=|Cold Brew|, Quarter=Q4, _Measure=Cost = 3000
Product=Pastry, Quarter=Q1, _Measure=Revenue = 11500
Product=Pastry, Quarter=Q1, _Measure=Cost = 5200
Product=Pastry, Quarter=Q2, _Measure=Revenue = 10900
Product=Pastry, Quarter=Q2, _Measure=Cost = 4900
Product=Pastry, Quarter=Q3, _Measure=Revenue = 10200
Product=Pastry, Quarter=Q3, _Measure=Cost = 4600
Product=Pastry, Quarter=Q4, _Measure=Revenue = 13400
Product=Pastry, Quarter=Q4, _Measure=Cost = 6000
+30 -27
View File
@@ -8,24 +8,28 @@
</head> </head>
<body> <body>
<!-- <!--
The browser shell for improvise-ws-server (improvise-cr3). The browser shell (improvise-cr3, improvise-djm).
Deliberately three elements and no framework. Everything that could be Deliberately four elements and no framework. Everything that could be
*wrong* — what a cell is, whether it may be typed into, where the *wrong* — what a cell is, whether it may be typed into, where the
selection sits, how the category tree flattens — is decided in Rust and selection sits, how the category tree flattens — is decided in Rust and
arrives already decided (see `render.rs`). This page paints it. arrives already decided (see `render.rs`). This page paints it.
Build the wasm module into ./pkg first: Build both wasm bundles first:
wasm-pack build crates/improvise-wasm-client --target web --out-dir ../../web/pkg ./web/build.sh
then serve this directory over http (a module script will not load from then serve this directory over http (a module script will not load from
file://) and run the server beside it: file://):
improvise-ws-server bank-info.improv --port 8787
python3 -m http.server 8080 --directory web python3 -m http.server 8080 --directory web
The socket address can be overridden with ?ws=ws://host:port. That is the whole standalone deployment: the document lives in an
in-page Web Worker and persists to this browser's private storage.
?doc= and ?seed= name the document and the file it is first seeded from.
To talk to a separate `improvise-ws-server` process instead — which is
how a document open in the TUI is shared — pass ?ws=ws://host:port.
--> -->
<header> <header>
<h1>improvise</h1> <h1>improvise</h1>
@@ -35,29 +39,28 @@
</header> </header>
<!-- <!--
This page is the *client* half and nothing else. It holds no model: what This page is the *client* half and nothing else: it holds no model. What
a cell is, what it says and whether it may be typed into is decided by a it now has is a peer it can always reach — a Web Worker in this same tab
server and arrives already decided. So a copy served from a static host hosting the whole App over OPFS (improvise-9x6, improvise-djm) — so a
— GitHub Pages included — paints an empty grid and says "disconnected" copy served from a static host, GitHub Pages included, is a working
until it is pointed at one. application rather than a client with nowhere to connect.
Two servers are possible. `improvise-ws-server`, which exists, is a The notice below stays static HTML rather than something app.js decides,
native process you run yourself; pass its address as ?ws=ws://host:port. for the same reason as before: it is true on every load in either mode,
The in-page worker (improvise-9x6), which does not exist yet, is what and a page that cannot reach its peer cannot be trusted to explain why.
makes this page standalone. Until it lands the honest description of a What the peer is actually *doing* — starting, connected, unreachable,
Pages deployment is "the delivery pipeline, carrying a client with gone — is live, and app.js writes it to #status.
nowhere to connect" — which is why this notice is static HTML rather
than something app.js decides: it is true on every load, and a page that
cannot reach a server cannot be trusted to explain why.
--> -->
<p id="backend-notice"> <p id="backend-notice">
improvise in the browser is a thin client: the document lives in a Everything here runs in this tab: the document lives in a Web Worker
server. Run <code>improvise-ws-server model.improv --port 8787</code> and is saved to this browser's private storage, so edits survive a
and reload with <code>?ws=ws://127.0.0.1:8787</code>. Served over reload and never leave the machine. Load a different document with
https, a <code>ws://</code> address is blocked as mixed content — use <code>?doc=name.improv</code>, seeded from
<code>wss://</code> or a local http server. Running with no separate <code>?seed=url</code>. To open a document hosted by a separate
process at all is the in-page worker (improvise-9x6), and it is not process instead, run
built yet. <code>improvise-ws-server model.improv --port 8787</code> and reload
with <code>?ws=ws://127.0.0.1:8787</code> — note that from an https
origin a <code>ws://</code> address is blocked as mixed content.
</p> </p>
<main> <main>