From e2bc8dfb861d5f628f0dd493d8d85728a20d5d68 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sun, 11 Oct 2026 10:36:44 +0200 Subject: [PATCH 1/3] playground: build on go-ndarray v0.11.0 v0.11.0 compiles against the playground unchanged: vet (native and js/wasm), the -race suite, 100% statement coverage, the engine suite as WebAssembly and the browser proof all pass with no source change. The wasm grows from 13,323,797 to 13,914,229 bytes (3,467,883 to 3,590,670 gzipped). Co-Authored-By: Claude Opus 5.5 --- playground/go.mod | 2 +- playground/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/playground/go.mod b/playground/go.mod index fdfcb70..20a37a9 100644 --- a/playground/go.mod +++ b/playground/go.mod @@ -3,7 +3,7 @@ module github.com/go-ndarray/go-ndarray.github.io/playground go 1.27.2 require ( - github.com/go-ndarray/ndarray v0.9.1 + github.com/go-ndarray/ndarray v0.11.0 github.com/go-widgets/mvvm v0.13.0 github.com/go-widgets/mvvmtk v0.14.1 github.com/go-widgets/painter v0.15.0 diff --git a/playground/go.sum b/playground/go.sum index 89efcf2..e0b1364 100644 --- a/playground/go.sum +++ b/playground/go.sum @@ -22,8 +22,8 @@ github.com/go-images/jpeg2000 v0.1.0 h1:SiFAZ6NGvGLU+VmxKji74RiSQDeK6TyjUZr1wjlx github.com/go-images/jpeg2000 v0.1.0/go.mod h1:MwSmrbiP6RK7eLphoNyzY60BAXWOD+8LVdBYDPIBrN4= github.com/go-images/png v0.1.0 h1:OI4yHotS0lA75sLnG2FTL2Vooh4PWpVWEidre1msSe0= github.com/go-images/png v0.1.0/go.mod h1:V9vaEbr2nrZGorUWn4xv9F24Yuu1/qLVtmailM9GBVo= -github.com/go-ndarray/ndarray v0.9.1 h1:1115kGKWYoqmZArPVXGgsUK9T8NvLoWMhQfjIwNUEvc= -github.com/go-ndarray/ndarray v0.9.1/go.mod h1:URXtNNKKw05MdWCqSCOmiw4zE38HQl95PztFSwgeVdk= +github.com/go-ndarray/ndarray v0.11.0 h1:HMW7rAI25wSNNgVxOFTYCz48vW/wIBRT8IN17DNZHQ8= +github.com/go-ndarray/ndarray v0.11.0/go.mod h1:ZcgNCBVLXt1FxeZjQWI5Z+k0SSWAT0/wR8slfat/gRA= github.com/go-opentype/fonts v0.10.0 h1:9f7ik795SlBEJjz6iqBE/ZV2PaTauAj537MMWw9t4OQ= github.com/go-opentype/fonts v0.10.0/go.mod h1:b+o8v1zDZ6B3GixA398jGILAh09+WwfyoYz52zKe7DA= github.com/go-opentype/opentype v0.13.0 h1:q9K7IGDfwMP/ik51ZkwiYf6jbaqPfocvAIuojetd0UQ= From c923bd8e806ea9dc493c9baee75ee848cbf65b53 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sun, 11 Oct 2026 10:36:44 +0200 Subject: [PATCH 2/3] playground: browser proof at dark@2x and on a phone viewport Two cases join light@1x, dark@1x and light@2x, each running every existing assertion: dark@2x, and a phone set with page.setViewport (390x844 CSS px, deviceScaleFactor 3, isMobile, hasTouch), since headless --window-size cannot go that narrow. Every case now also checks, before anything else, that the canvas fits the width asked for (measured against that width: a mobile viewport widens innerWidth to follow overflow), that the page does not scroll horizontally, that the backing store matches the device pixels and that the app painted something besides the background. A capture is taken as soon as the page is ready, so a failure still leaves a picture. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/playground.yml | 6 +++-- playground/browser_test.go | 3 ++- playground/browsertest/driver.cjs | 42 +++++++++++++++++++++++++++---- 3 files changed, 43 insertions(+), 8 deletions(-) diff --git a/.github/workflows/playground.yml b/.github/workflows/playground.yml index 0a16854..6e9f2b2 100644 --- a/.github/workflows/playground.yml +++ b/.github/workflows/playground.yml @@ -9,8 +9,10 @@ # ui-guards mvvmlint (no direct widget-state writes) and bricolint (no # hand-drawn UI), each with a negative control proving it bites. # browser-proof the BUILT page (Hugo + the wasm) in headless Chrome, light -# and dark forced, 1x and 2x: real clicks and keys, asserted on -# the app's state AND on canvas pixels. A missing browser is a +# and dark forced, 1x and 2x, plus a phone (390x844 at 3x, +# mobile, touch): real clicks and keys, asserted on the app's +# state AND on canvas pixels, and the canvas must fit the +# viewport width. A missing browser is a # failure here, never a skip. name: playground diff --git a/playground/browser_test.go b/playground/browser_test.go index 414487c..19256c3 100644 --- a/playground/browser_test.go +++ b/playground/browser_test.go @@ -21,7 +21,8 @@ import ( // TestBrowserProof builds the page exactly as the deploy does — the wasm from // this module, wasm_exec.js from the same toolchain, then Hugo — serves it, // and runs browsertest/driver.cjs against it in headless Chrome, light and -// dark with the scheme forced, at 1x and 2x. See the driver for what it +// dark with the scheme forced, at 1x and 2x (both schemes), and on a phone +// viewport (390x844 CSS px at 3x, mobile, touch). See the driver for what it // checks. It skips when Chrome, node, puppeteer-core or Hugo is missing, // unless PLAYGROUND_REQUIRE_BROWSER=1 (CI), where a skip would be a pass that // proved nothing. diff --git a/playground/browsertest/driver.cjs b/playground/browsertest/driver.cjs index a1d03e5..4739879 100644 --- a/playground/browsertest/driver.cjs +++ b/playground/browsertest/driver.cjs @@ -26,7 +26,13 @@ function check(name, ok, detail) { const BG = { light: [0xff, 0xff, 0xff], dark: [0x0b, 0x0e, 0x14] }; const VIRIDIS_ENDS = [[0x44, 0x01, 0x54], [0xfd, 0xe7, 0x25]]; -async function run(scheme, dpr) { +// The cases: the forced scheme, the device pixel ratio and the viewport. A +// phone is set with page.setViewport (isMobile, hasTouch), because headless +// Chrome's --window-size cannot go that narrow. +const DESKTOP = { width: 1400, height: 900 }; +const PHONE = { width: 390, height: 844, isMobile: true, hasTouch: true, name: "phone" }; + +async function run(scheme, dpr, vp = DESKTOP) { const browser = await puppeteer.launch({ executablePath: chrome, headless: true, args: ["--no-sandbox", "--disable-gpu", "--blink-settings=preferredColorScheme=" + (scheme === "dark" ? 0 : 1)], @@ -36,13 +42,14 @@ async function run(scheme, dpr) { const errors = []; page.on("pageerror", (e) => errors.push(String(e))); page.on("console", (m) => { if (m.type() === "error") errors.push(m.text()); }); - await page.setViewport({ width: 1400, height: 900, deviceScaleFactor: dpr }); + await page.setViewport({ width: vp.width, height: vp.height, deviceScaleFactor: dpr, isMobile: !!vp.isMobile, hasTouch: !!vp.hasTouch }); await page.emulateMediaFeatures([{ name: "prefers-color-scheme", value: scheme }]); await page.evaluateOnNewDocument(() => { try { localStorage.removeItem("theme"); } catch (e) {} }); await page.goto(url, { waitUntil: "load" }); await page.waitForFunction(() => globalThis.ndarrayPlaygroundReady === true && document.querySelector(".stage").classList.contains("ready"), { timeout: 60000 }); - const tag = scheme + "@" + dpr + "x: "; + const tag = scheme + "@" + dpr + "x" + (vp.name ? " " + vp.name : "") + ": "; + const shot = (what) => path.join(captureDir, what + "-" + scheme + "-" + dpr + "x" + (vp.name ? "-" + vp.name : "") + ".png"); const debug = async () => JSON.parse(await page.evaluate(() => ndarrayDebug())); const canvasBox = await page.evaluate(() => { const r = document.getElementById("nd-canvas").getBoundingClientRect(); return { x: r.left, y: r.top, w: r.width, h: r.height }; }); @@ -90,6 +97,29 @@ async function run(scheme, dpr) { await page.keyboard.type(text); }; + // 0. A capture of the opened page first, so a failure below still leaves + // the picture of what was on screen. + await page.screenshot({ path: shot("loaded") }); + // 0b. The viewport is the one asked for, and the canvas fits it: no + // horizontal overflow, and something other than the background is + // painted across it. + const fit = await page.evaluate(() => { + const c = document.getElementById("nd-canvas"); + const r = c.getBoundingClientRect(); + return { iw: innerWidth, dpr: devicePixelRatio, left: r.left, right: r.right, cw: c.width, + scroll: document.documentElement.scrollWidth, client: document.documentElement.clientWidth }; + }); + // Measured against the width ASKED for: on a mobile viewport Chrome widens + // the layout viewport to fit overflowing content, so innerWidth alone + // would follow the overflow it is meant to catch. + check(tag + "the canvas fits the viewport width", fit.left >= 0 && fit.right <= vp.width + 0.5, fit.left + ".." + fit.right + " of " + vp.width); + check(tag + "the page does not scroll horizontally", fit.scroll <= vp.width && fit.client <= vp.width, "scrollWidth " + fit.scroll + ", clientWidth " + fit.client + " of " + vp.width); + check(tag + "the viewport is " + vp.width + " CSS px at " + dpr + "x", fit.iw === vp.width && fit.dpr === dpr, fit.iw + " @ " + fit.dpr); + check(tag + "the canvas backing store matches the device pixels", Math.abs(fit.cw - Math.round((fit.right - fit.left) * dpr)) <= dpr, fit.cw); + const all = await pixels({ x: 0, y: 0, w: canvasBox.w, h: Math.min(canvasBox.h, 400) }); + const painted = all.length / 4 - count(all, BG[scheme], 6); + check(tag + "the app rendered something (non-background pixels)", painted > 1000, painted + " px"); + // 1. The page opened on the README example, in the forced theme. let d = await debug(); check(tag + "opens on the README example (7 steps)", d.steps.length === 7, d.steps.length); @@ -103,7 +133,7 @@ async function run(scheme, dpr) { const heat = count(tp, VIRIDIS_ENDS[0], 12) + count(tp, VIRIDIS_ENDS[1], 12); check(tag + "the table is painted as a heatmap (viridis end colours on screen)", heat > 200, heat + " px"); check(tag + "the memory line names the layout", /strides \(3, 1\) elements/.test(d.memory), d.memory); - await page.screenshot({ path: path.join(captureDir, "open-" + scheme + "-" + dpr + "x.png") }); + await page.screenshot({ path: shot("open") }); // 2. Click a step of the pipeline: b, the reshaped view. const st = (await rects()).steps; @@ -141,7 +171,7 @@ async function run(scheme, dpr) { const ep = await pixels((await rects()).error); const red = scheme === "dark" ? [0xf7, 0x8b, 0x8b] : [0xb4, 0x23, 0x18]; check(tag + "the error is on screen in red", count(ep, red, 40) > 20, count(ep, red, 40) + " px"); - await page.screenshot({ path: path.join(captureDir, "error-" + scheme + "-" + dpr + "x.png") }); + await page.screenshot({ path: shot("error") }); // 5. The code panel: Go program, then the NumPy equivalent. check(tag + "the Go program is shown", d.code.startsWith("package main") && d.code.includes("ndarray.Arange(0, 6, 1)"), d.code.slice(0, 40)); @@ -203,6 +233,8 @@ async function run(scheme, dpr) { await run("light", 1); await run("dark", 1); await run("light", 2); + await run("dark", 2); + await run("light", 3, PHONE); console.log("RESULT " + JSON.stringify({ ok: true, results })); } catch (e) { console.log("RESULT " + JSON.stringify({ ok: false, error: String(e.message || e), results })); From 18f830a9203382fe7f1e5c9bde1a810ad13fef38 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sun, 11 Oct 2026 10:54:47 +0200 Subject: [PATCH 3/3] playground: usable on a phone -- a minimum height and a scrolling stage The phone case of the browser proof (390x844 at 3x) found the stacked layout broken: the page cannot scroll, so the canvas was the 779 px under the nav bar, less than the stacked layout needs. The table, the only part that grows, got 0 px; the code panel ran under the status line; beside the presets and reset the title got 10 px ("gc"). - State.MinHeight(w): the least height the scene needs at width w, 0 side by side. Measured, not computed: the layout is laid out once at a tall height and the table's share replaced by six rows, so it cannot drift from the layout. - The wasm host makes the canvas at least that tall, and the stage scrolls vertically (overflow-y:auto); pointer coordinates already come from getBoundingClientRect. - Narrow, the title has a row of its own above presets and reset. - The driver scrolls a widget into view before clicking it, as a visitor does, and re-reads where the canvas is. TestMinHeightOnAPhone pins it, and fails with the old top bar (title 30 px of 1170). Locally: 100% coverage; browser proof 215/215, phone heatmap 28829 px (was 0). Co-Authored-By: Claude Opus 5.5 --- layouts/_default/playground.html | 5 ++- playground/app.go | 59 ++++++++++++++++++++++---- playground/app_test.go | 37 ++++++++++++++++ playground/browsertest/driver.cjs | 18 +++++++- playground/cmd/playground-wasm/main.go | 15 +++++++ 5 files changed, 123 insertions(+), 11 deletions(-) diff --git a/layouts/_default/playground.html b/layouts/_default/playground.html index de242c3..16b95d3 100644 --- a/layouts/_default/playground.html +++ b/layouts/_default/playground.html @@ -14,7 +14,10 @@ html,body{height:100%} body.pg{display:flex;flex-direction:column;overflow:hidden} .pg .nav{position:relative} - .stage{position:relative;flex:1;min-height:0;background:var(--bg)} + /* The stage scrolls when the app needs more height than the window has: + stacked on a phone, the wasm host makes the canvas as tall as + State.MinHeight says. */ + .stage{position:relative;flex:1;min-height:0;background:var(--bg);overflow-x:hidden;overflow-y:auto} #nd-canvas{display:block;width:100%;height:100%;opacity:0;transition:opacity .2s} .stage.ready #nd-canvas{opacity:1} #pg-load{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;color:var(--slate);font-size:14px} diff --git a/playground/app.go b/playground/app.go index 5c79b9b..24629ce 100644 --- a/playground/app.go +++ b/playground/app.go @@ -89,9 +89,10 @@ type State struct { // The box tree. The boxes are the toolkit's own types, not wrappers: the // focus system walks them to find the widget that receives the keyboard. - root, left, right *toolkit.VBox - body toolkit.Widget // an HBox, or a VBox when narrow - formBtns, codeBar, top *toolkit.HBox + root, left, right *toolkit.VBox + body toolkit.Widget // an HBox, or a VBox when narrow + formBtns, codeBar *toolkit.HBox + top toolkit.Widget // an HBox, or a VBox (title above the presets) when narrow dropdowns []*toolkit.DropDown @@ -207,10 +208,24 @@ func (s *State) layout() { sc := toolkit.Scaled narrow := s.w < sc(narrowW) - s.top = hbox(sc(gap)) - s.top.AddFlex(s.title, 1) - s.top.AddFixed(s.presets, sc(260)) - s.top.AddFixed(s.reset, sc(80)) + // On a narrow screen the presets and reset take the whole width, so the + // title gets a row of its own: beside them a 390 px phone left it 10 px. + topH := sc(barH) + if narrow { + pick := hbox(sc(gap)) + pick.AddFlex(s.presets, 1) + pick.AddFixed(s.reset, sc(80)) + top := vbox(sc(gap)) + top.AddFixed(s.title, sc(barH)) + top.AddFixed(pick, sc(barH)) + s.top, topH = top, sc(2*barH+gap) + } else { + top := hbox(sc(gap)) + top.AddFlex(s.title, 1) + top.AddFixed(s.presets, sc(260)) + top.AddFixed(s.reset, sc(80)) + s.top = top + } s.formBtns = hbox(sc(gap)) s.formBtns.AddFlex(s.apply, 1) @@ -256,7 +271,7 @@ func (s *State) layout() { s.body = b } s.root = vbox(sc(gap)) - s.root.AddFixed(s.top, sc(barH)) + s.root.AddFixed(s.top, topH) s.root.AddFlex(s.body, 1) s.root.AddFixed(s.status, sc(labelH)) m := sc(margin) @@ -270,6 +285,34 @@ func (s *State) Resize(w, h int) { s.layout() } +// minTableH is the least height, in logical pixels, the stacked layout leaves +// the table: a header and a few rows. +const minTableH = 6 * rowH + +// MinHeight is the least height, in device pixels, the scene needs at width +// w. Side by side it is 0: every widget fits whatever the window gives. Stacked, +// the form, the summary and the code panel have fixed heights and the table +// takes what is left, so a short window left it nothing: 0 px on a 390×844 +// phone. The host makes the canvas at least this tall and lets the page +// scroll. +// +// It is measured, not computed: in the stacked layout the table is the only +// part that grows, so the layout is laid out once at a tall height and the +// table's share is replaced by minTableH. Formula and layout cannot drift. +func (s *State) MinHeight(w int) int { + if w >= toolkit.Scaled(narrowW) { + return 0 + } + ow, oh := s.w, s.h + const tall = 1 << 15 + s.w, s.h = max(w, 1), tall + s.layout() + need := tall - s.table.Bounds().H + toolkit.Scaled(minTableH) + s.w, s.h = ow, oh + s.layout() + return need +} + // Size is the scene size in device pixels. func (s *State) Size() (int, int) { return s.w, s.h } diff --git a/playground/app_test.go b/playground/app_test.go index 7ef6da5..df8ac2b 100644 --- a/playground/app_test.go +++ b/playground/app_test.go @@ -187,6 +187,43 @@ func TestResizeStacksWhenNarrow(t *testing.T) { } } +// TestMinHeightOnAPhone is the layout the browser proof's phone case found +// broken: 390×844 CSS pixels at 3× gave the table 0 px, ran the code panel +// under the status line and clipped the title to "gc". +func TestMinHeightOnAPhone(t *testing.T) { + s := newTestState(t, false) + if got := s.MinHeight(testW); got != 0 { + t.Errorf("side by side, MinHeight = %d, want 0", got) + } + SetupText(3) + defer SetupText(1) + w, h := 390*3, 779*3 // the canvas under the nav bar, in device pixels + s.Resize(w, h) + before := s.Rects() + need := s.MinHeight(w) + if need <= h { + t.Fatalf("MinHeight(%d) = %d: the 779 px a phone leaves were enough, yet they gave the table %d px", + w, need, before["table"].H) + } + if gw, gh := s.Size(); gw != w || gh != h || s.table.Bounds() != before["table"] { + t.Error("MinHeight changed the scene it measured") + } + s.Resize(w, need) + if got, want := s.table.Bounds().H, toolkit.Scaled(minTableH); got != want { + t.Errorf("at MinHeight the table is %d px, want %d", got, want) + } + code, status := s.code.Bounds(), s.status.Bounds() + if code.Y+code.H > status.Y { + t.Errorf("the code panel (to %d) runs under the status line (from %d)", code.Y+code.H, status.Y) + } + if status.Y+status.H > need { + t.Errorf("the status line (to %d) runs off the canvas (%d)", status.Y+status.H, need) + } + if tw, pw := s.title.Bounds().W, s.presets.Bounds().W; tw < w/2 || pw < w/2 { + t.Errorf("narrow top bar: title %d px, presets %d px, of %d", tw, pw, w) + } +} + func TestSetTheme(t *testing.T) { s := newTestState(t, false) s.SetTheme(true) diff --git a/playground/browsertest/driver.cjs b/playground/browsertest/driver.cjs index 4739879..f3cea65 100644 --- a/playground/browsertest/driver.cjs +++ b/playground/browsertest/driver.cjs @@ -52,11 +52,22 @@ async function run(scheme, dpr, vp = DESKTOP) { const shot = (what) => path.join(captureDir, what + "-" + scheme + "-" + dpr + "x" + (vp.name ? "-" + vp.name : "") + ".png"); const debug = async () => JSON.parse(await page.evaluate(() => ndarrayDebug())); - const canvasBox = await page.evaluate(() => { const r = document.getElementById("nd-canvas").getBoundingClientRect(); return { x: r.left, y: r.top, w: r.width, h: r.height }; }); + const boxOf = async () => page.evaluate(() => { const r = document.getElementById("nd-canvas").getBoundingClientRect(); return { x: r.left, y: r.top, w: r.width, h: r.height }; }); + let canvasBox = await boxOf(); + // On a phone the canvas is taller than the window and the stage scrolls + // (State.MinHeight): scroll a widget into view before clicking it, as a + // visitor would, then re-read where the canvas now is. + const reveal = async (r) => { + await page.evaluate((y, h) => { + const s = document.querySelector(".stage"); + if (y < s.scrollTop || y + h > s.scrollTop + s.clientHeight) s.scrollTop = Math.max(0, y + h / 2 - s.clientHeight / 2); + }, r.y, r.h); + canvasBox = await boxOf(); + }; const rects = async () => JSON.parse(await page.evaluate(() => ndarrayRects())); const center = (r) => [canvasBox.x + r.x + r.w / 2, canvasBox.y + r.y + r.h / 2]; const clickAt = async (x, y) => { await page.mouse.click(x, y); await new Promise((r) => setTimeout(r, 60)); }; - const click = async (name) => { const r = (await rects())[name]; const [x, y] = center(r); await clickAt(x, y); }; + const click = async (name) => { const r = (await rects())[name]; await reveal(r); const [x, y] = center(r); await clickAt(x, y); }; // Pixels of the canvas inside a CSS rectangle (relative to the canvas). const pixels = async (r) => page.evaluate((r, dpr) => { const c = document.getElementById("nd-canvas"); @@ -74,6 +85,7 @@ async function run(scheme, dpr, vp = DESKTOP) { check(tag + "option " + option + " exists in " + dd, idx >= 0, list.join(",")); await click(dd); const pop = (await rects())[dd + "Popover"]; + await reveal(pop); const visible = Math.min(list.length, 12); const rowH = pop.h / visible; // The popover keeps its scroll between openings: wheel it back to the @@ -137,6 +149,7 @@ async function run(scheme, dpr, vp = DESKTOP) { // 2. Click a step of the pipeline: b, the reshaped view. const st = (await rects()).steps; + await reveal(st); // Probe down the list until the click lands on row b (index 1): the test // does not assume the list's row height. for (let y = st.y + 4; y < st.y + st.h; y += 4) { @@ -176,6 +189,7 @@ async function run(scheme, dpr, vp = DESKTOP) { // 5. The code panel: Go program, then the NumPy equivalent. check(tag + "the Go program is shown", d.code.startsWith("package main") && d.code.includes("ndarray.Arange(0, 6, 1)"), d.code.slice(0, 40)); const lang = (await rects()).lang; + await reveal(lang); await clickAt(canvasBox.x + lang.x + lang.w * 0.75, canvasBox.y + lang.y + lang.h / 2); d = await debug(); check(tag + "the NumPy tab shows NumPy", d.code.startsWith("import numpy as np") && d.code.includes("b.T"), d.code.slice(0, 40)); diff --git a/playground/cmd/playground-wasm/main.go b/playground/cmd/playground-wasm/main.go index 6912d30..854bdf9 100644 --- a/playground/cmd/playground-wasm/main.go +++ b/playground/cmd/playground-wasm/main.go @@ -145,7 +145,21 @@ func main() { return changed }) + // grow makes the canvas at least as tall as the scene needs at its width + // (State.MinHeight, non-zero only when the columns stack) and lets the + // stage scroll; otherwise the canvas fills the stage, as before. + stage := canvas.Get("parentElement") + grow := func() { + d := dpr() + need := int(float64(state.MinHeight(int(canvas.Get("clientWidth").Float()*d+0.5)))/d + 0.999) + if need > stage.Get("clientHeight").Int() { + canvas.Get("style").Set("height", strconv.Itoa(need)+"px") + } else { + canvas.Get("style").Set("height", "") + } + } refit := func() { + grow() nw, nh, nd := deviceSize() ow, oh := state.Size() if nw == ow && nh == oh && nd == curDPR { @@ -186,6 +200,7 @@ func main() { return string(b) })) + refit() // a narrow window may need a taller canvas than the stage render() js.Global().Set("ndarrayPlaygroundReady", true) select {}