From a550201fae39b120d66a7304878b2134ecfe075f Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Sat, 19 Sep 2026 19:28:21 -0700 Subject: [PATCH] feat(web): the page spawns the worker, opens a demo, and reads a key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH --- .github/workflows/ci.yml | 5 + .gitignore | 3 + .../tests/post_message.rs | 55 ++++- tests/web_demo_model.rs | 109 ++++++++++ web/app.js | 205 ++++++++++++++++-- web/build.sh | 15 +- web/demo.improv | 57 +++++ web/index.html | 57 ++--- 8 files changed, 448 insertions(+), 58 deletions(-) create mode 100644 tests/web_demo_model.rs create mode 100644 web/demo.improv diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 27c2e378..652f12ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -130,6 +130,11 @@ jobs: set -eu mkdir -p 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/worker-pkg site/worker-pkg rm -f site/pkg/.gitignore site/pkg/package.json diff --git a/.gitignore b/.gitignore index 8f972db3..3c21aba4 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,9 @@ bench/*.txt *.patch *.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) .dolt/ diff --git a/crates/improvise-worker-server/tests/post_message.rs b/crates/improvise-worker-server/tests/post_message.rs index e2943d00..eb2a81a0 100644 --- a/crates/improvise-worker-server/tests/post_message.rs +++ b/crates/improvise-worker-server/tests/post_message.rs @@ -71,7 +71,11 @@ struct MainThread { impl MainThread { 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"); let mut app = App::with_storage(workbook, Some(path.to_path_buf()), Box::new(storage)); 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" ); } + +/// 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"); +} diff --git a/tests/web_demo_model.rs b/tests/web_demo_model.rs new file mode 100644 index 00000000..2f2f0cc9 --- /dev/null +++ b/tests/web_demo_model.rs @@ -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"); +} diff --git a/web/app.js b/web/app.js index a228f759..16c997e7 100644 --- a/web/app.js +++ b/web/app.js @@ -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 // 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. -// Four calls are the whole harness — +// Five calls are the whole harness — // -// Client.viewport(rows, cols) -> the handshake frame, and every resize -// client.on_frame(json) -> fold one socket frame, in arrival order -// client.render_model() -> everything to paint, already decided -// client.typed_cell(r, c, s) -> the frame for "the user typed s here" +// Client.viewport(rows, cols) -> the handshake frame, and every resize +// client.on_frame(json) -> fold one frame, in arrival order +// client.render_model() -> everything to paint, already decided +// 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: // 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 // 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"; 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. // Being wrong here costs a row of over- or under-fetch, nothing more. @@ -35,7 +60,19 @@ const el = { await init(); 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. */ function extent() { @@ -47,21 +84,110 @@ function extent() { } function sendViewport() { - if (socket.readyState !== WebSocket.OPEN) return; 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 editing = null; // {row, col} while an input is open, so a repaint cannot steal it function repaint() { - const model = JSON.parse(client.render_model()); - if (model.revision === painted || editing) return; + if (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; el.mode.textContent = model.mode; - el.status.textContent = model.status; + modelStatus = model.status; + paintStatus(); el.viewName.textContent = model.view_name; el.grid.replaceChildren(gridTable(model)); el.categories.replaceChildren(categoryList(model)); @@ -114,13 +240,18 @@ function edit(td, row, col) { input.focus(); input.select(); + // Enter removes the focused , which fires `blur`, which calls + // `close` again. Harmless before, but only by accident (improvise-hwpi). + let closed = false; const close = (commit) => { + if (closed) return; + closed = true; editing = null; if (commit) { const frame = client.typed_cell(row, col, input.value); // null only if the grid moved under the editor; the repaint that // follows restores whatever the server now says is there. - if (frame) socket.send(frame); + if (frame) transport.send(frame); } td.replaceChildren(original); painted = -1; @@ -130,6 +261,8 @@ function edit(td, row, col) { input.addEventListener("keydown", (e) => { if (e.key === "Enter") close(true); 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(); }); input.addEventListener("blur", () => close(false)); @@ -149,19 +282,43 @@ function categoryList(model) { return list; } -socket.addEventListener("open", () => { - sendViewport(); // the handshake: the snapshot is built at this extent - el.status.textContent = "connected"; -}); +// ── the keyboard ───────────────────────────────────────────────────────── +// +// `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) => { - // One frame, one fold, in arrival order. Never batch or reorder these. - client.on_frame(event.data); +window.addEventListener("keydown", (e) => { + // The inline editor owns the keyboard while it is open. It stops + // 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(); }); -socket.addEventListener("close", () => { - el.status.textContent = "disconnected"; -}); +// ── start ──────────────────────────────────────────────────────────────── +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); diff --git a/web/build.sh b/web/build.sh index b4c0904a..6861fb15 100755 --- a/web/build.sh +++ b/web/build.sh @@ -22,14 +22,17 @@ wasm-pack build "$root/crates/improvise-worker-server" \ --target web --out-dir "$root/web/worker-pkg" 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 -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 -that worker yet: the main-thread bootstrap that spawns it and wires the -MessageChannel is improvise-djm. Until then the worker bundle ships unused. +To point the same page at a separate server process instead: + + improvise-ws-server your-model.improv --port 8787 + +then reload with ?ws=ws://127.0.0.1:8787. MSG diff --git a/web/demo.improv b/web/demo.improv new file mode 100644 index 00000000..b2eb845b --- /dev/null +++ b/web/demo.improv @@ -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 diff --git a/web/index.html b/web/index.html index b43c3eb5..7fe0d7a0 100644 --- a/web/index.html +++ b/web/index.html @@ -8,24 +8,28 @@

improvise

@@ -35,29 +39,28 @@

- improvise in the browser is a thin client: the document lives in a - server. Run improvise-ws-server model.improv --port 8787 - and reload with ?ws=ws://127.0.0.1:8787. Served over - https, a ws:// address is blocked as mixed content — use - wss:// or a local http server. Running with no separate - process at all is the in-page worker (improvise-9x6), and it is not - built yet. + Everything here runs in this tab: the document lives in a Web Worker + and is saved to this browser's private storage, so edits survive a + reload and never leave the machine. Load a different document with + ?doc=name.improv, seeded from + ?seed=url. To open a document hosted by a separate + process instead, run + improvise-ws-server model.improv --port 8787 and reload + with ?ws=ws://127.0.0.1:8787 — note that from an https + origin a ws:// address is blocked as mixed content.