webvas 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/site/app.js ADDED
@@ -0,0 +1,466 @@
1
+ const editor = document.querySelector("#source");
2
+ const stage = document.querySelector("#stage");
3
+ const status = document.querySelector("#status");
4
+ const consoleBox = document.querySelector(".console");
5
+ const loading = document.querySelector("#loading");
6
+ const loadingLabel = document.querySelector("#loading-label");
7
+ const dirty = document.querySelector("#dirty");
8
+ let worker;
9
+ let pendingSource;
10
+ let screen;
11
+ let gpu;
12
+ let generation = 0;
13
+ let workerBlobUrl;
14
+ const PLAYGROUND_URL = "https://rbgfx.github.io/webvas/";
15
+
16
+ function shaderExample(label, name, fragment) {
17
+ return {
18
+ name: label,
19
+ source: [
20
+ 'require "webvas"',
21
+ 'require "rlsl"',
22
+ "",
23
+ `builder = RLSL::ShaderBuilder.new(:${name})`,
24
+ "builder.uniforms { float :time }",
25
+ "builder.fragment_source <<~SHADER",
26
+ ...fragment.trim().split("\n").map(line => " " + line),
27
+ "SHADER",
28
+ "layout = RLSL::WGSL::UniformLayout.build({ resolution: :vec2, time: :float })",
29
+ 'shader = Webvas::Shader.new(builder.build_wgsl_shader, canvas: "#gpu", layout: layout)',
30
+ "Webvas.run_shader(shader) { |time| { time: time } }",
31
+ ""
32
+ ].join("\n")
33
+ };
34
+ }
35
+
36
+ const examples = [
37
+ {
38
+ name: "Gesso · animated orbit",
39
+ source: [
40
+ 'require "gesso"',
41
+ "",
42
+ 'Gesso.run(width: 480, height: 320, runner: :web) do',
43
+ ' background "#171a18"',
44
+ " draw do",
45
+ ' background "#171a18"',
46
+ " no_stroke",
47
+ ' fill "#d38a62"',
48
+ " circle 240 + Math.cos(millis / 850.0) * 92, 160, 18",
49
+ ' fill "#718b74"',
50
+ " circle 240 + Math.cos(millis / 850.0 + Math::PI) * 92, 160, 11",
51
+ ' fill "#d9ddd5"',
52
+ " circle 240, 160, 4",
53
+ " end",
54
+ "end",
55
+ ""
56
+ ].join("\n")
57
+ },
58
+ {
59
+ name: "Gesso · orbiting bubbles",
60
+ source: [
61
+ 'require "gesso"',
62
+ "",
63
+ 'Gesso.run(width: 480, height: 320, runner: :web) do',
64
+ ' background "#101820"',
65
+ " draw do",
66
+ ' background "#101820"',
67
+ " no_stroke",
68
+ " 12.times do |index|",
69
+ " angle = millis / 1100.0 + index * Math::PI * 2 / 12",
70
+ ' fill index.even? ? "#79b4a8" : "#e6b566"',
71
+ " circle 240 + Math.cos(angle) * 110, 160 + Math.sin(angle * 1.3) * 72, 7 + index % 4 * 2",
72
+ " end",
73
+ " end",
74
+ "end",
75
+ ""
76
+ ].join("\n")
77
+ },
78
+ {
79
+ name: "Gesso · pointer light",
80
+ source: [
81
+ 'require "gesso"',
82
+ "",
83
+ 'Gesso.run(width: 480, height: 320, runner: :web) do',
84
+ ' background "#111417"',
85
+ " draw do",
86
+ ' background "#111417"',
87
+ " no_stroke",
88
+ ' fill mouse_pressed? ? "#e5b36a" : "#84a99d"',
89
+ " circle mouse_x, mouse_y, mouse_pressed? ? 32 : 18",
90
+ ' fill "#e5e6df"',
91
+ " circle mouse_x, mouse_y, 3",
92
+ " end",
93
+ "end",
94
+ ""
95
+ ].join("\n")
96
+ },
97
+ {
98
+ name: "RBGL · RGB triangle",
99
+ source: [
100
+ 'require "webvas"',
101
+ 'require "rbgl"',
102
+ "include Larb",
103
+ "include RBGL::Engine",
104
+ "",
105
+ "backend = Webvas::Backend.new(width: 480, height: 320)",
106
+ "window = RBGL::GUI::Window.new(width: 480, height: 320, backend: backend)",
107
+ "pipeline = Pipeline.create do",
108
+ " vertex do |input, _uniforms, output|",
109
+ " output.position = input.position.to_vec4",
110
+ " output.color = input.color",
111
+ " end",
112
+ " fragment do |input, _uniforms, output|",
113
+ " output.color = input.color",
114
+ " end",
115
+ "end",
116
+ "pipeline.cull_mode = :none",
117
+ "vertices = VertexBuffer.from_array(VertexLayout.position_color, [",
118
+ " { position: Vec3[0, 0.7, 0], color: Color.red },",
119
+ " { position: Vec3[-0.7, -0.7, 0], color: Color.green },",
120
+ " { position: Vec3[0.7, -0.7, 0], color: Color.blue }",
121
+ "]) ",
122
+ "Webvas.run(window) do |context, _delta|",
123
+ ' context.clear(color: Color.from_hex("#101827"))',
124
+ " context.bind_pipeline(pipeline)",
125
+ " context.bind_vertex_buffer(vertices)",
126
+ " context.draw_arrays(:triangles, 0, 3)",
127
+ "end",
128
+ ""
129
+ ].join("\n")
130
+ },
131
+ {
132
+ name: "RBGL · clear color",
133
+ source: [
134
+ 'require "webvas"',
135
+ 'require "rbgl"',
136
+ "include Larb",
137
+ "",
138
+ "backend = Webvas::Backend.new(width: 480, height: 320)",
139
+ "window = RBGL::GUI::Window.new(width: 480, height: 320, backend: backend)",
140
+ "Webvas.run(window) do |context, _delta|",
141
+ ' context.clear(color: Color.from_hex("#293b4a"))',
142
+ "end",
143
+ ""
144
+ ].join("\n")
145
+ },
146
+ {
147
+ name: "RBGL · pixel gradient",
148
+ source: [
149
+ 'require "webvas"',
150
+ 'require "rbgl"',
151
+ "width, height = 480, 320",
152
+ 'pixels = String.new(capacity: width * height * 4, encoding: Encoding::BINARY)',
153
+ "height.times do |y|",
154
+ ' width.times { |x| pixels << [x * 255 / width, y * 255 / height, 120, 255].pack("C4") }',
155
+ "end",
156
+ "backend = Webvas::Backend.new(width: width, height: height)",
157
+ "window = RBGL::GUI::Window.new(width: width, height: height, backend: backend)",
158
+ "Webvas.run(window) { window.set_pixels(pixels) }",
159
+ ""
160
+ ].join("\n")
161
+ },
162
+ shaderExample("RLSL · animated plasma", "webvas_plasma", `
163
+ uv = frag_coord / resolution.y
164
+ wave = sin(uv.x * 8.0 + u.time) * cos(uv.y * 7.0 - u.time)
165
+ vec3(0.5 + wave * 0.25, 0.25 + uv.y * 0.5, 0.5 - wave * 0.3)
166
+ `),
167
+ shaderExample("RLSL · orbiting rings", "webvas_rings", `
168
+ uv = (frag_coord - resolution * 0.5) / resolution.y
169
+ radius = sqrt(uv.x * uv.x + uv.y * uv.y)
170
+ wave = 0.5 + 0.5 * cos(radius * 42.0 - u.time * 2.0)
171
+ vec3(wave * 0.8, 0.25 + uv.y * 0.3, 1.0 - wave * 0.6)
172
+ `),
173
+ shaderExample("RLSL · shifting gradient", "webvas_gradient", `
174
+ uv = frag_coord / resolution
175
+ vec3(0.15 + uv.x * 0.6, 0.2 + uv.y * 0.6, 0.45 + 0.2 * sin(u.time))
176
+ `)
177
+ ];
178
+
179
+ function setStatus(message, error = false) {
180
+ status.textContent = message;
181
+ consoleBox.classList.toggle("error", error);
182
+ }
183
+
184
+ function setSource(value, markDirty = true) {
185
+ editor.value = value;
186
+ dirty.classList.toggle("visible", markDirty);
187
+ }
188
+
189
+ function selectErrorLine(backtrace) {
190
+ const line = Number(backtrace.match(/\beval:(\d+):/)?.[1]);
191
+ if (!line) return;
192
+ let start = 0;
193
+ for (let index = 1; index < line; index += 1) {
194
+ const newline = editor.value.indexOf("\n", start);
195
+ if (newline < 0) return;
196
+ start = newline + 1;
197
+ }
198
+ const newline = editor.value.indexOf("\n", start);
199
+ editor.focus({ preventScroll: true });
200
+ editor.setSelectionRange(start, newline < 0 ? editor.value.length : newline);
201
+ }
202
+
203
+ function mountCanvases() {
204
+ screen = document.createElement("canvas");
205
+ gpu = document.createElement("canvas");
206
+ screen.id = "screen";
207
+ gpu.id = "gpu";
208
+ screen.width = gpu.width = 480;
209
+ screen.height = gpu.height = 320;
210
+ screen.tabIndex = gpu.tabIndex = 0;
211
+ screen.setAttribute("aria-label", "Ruby 2D sketch canvas");
212
+ gpu.setAttribute("aria-label", "Ruby WebGPU shader canvas");
213
+ gpu.hidden = true;
214
+ stage.replaceChildren(screen, gpu);
215
+ }
216
+
217
+ async function start(source) {
218
+ const currentGeneration = ++generation;
219
+ pendingSource = source;
220
+ worker?.terminate();
221
+ if (workerBlobUrl) URL.revokeObjectURL(workerBlobUrl);
222
+ worker = null;
223
+ mountCanvases();
224
+ loading.classList.remove("hidden");
225
+ loadingLabel.textContent = "Starting an isolated Ruby runtime…";
226
+ document.querySelector("#runtime-state").textContent = "Starting worker";
227
+ document.querySelector("#performance-state").textContent = "Frame timing warming up";
228
+ setStatus("Starting Ruby runtime…");
229
+ try {
230
+ const workerUrl = new URL("./worker.js", import.meta.url);
231
+ const response = await fetch(workerUrl, { credentials: "omit" });
232
+ if (!response.ok) throw new Error("Worker download failed: " + response.status);
233
+ if (generation !== currentGeneration) return;
234
+ workerBlobUrl = URL.createObjectURL(new Blob([await response.text()], { type: "text/javascript" }));
235
+ worker = new Worker(workerBlobUrl, { type: "module", name: "webvas" });
236
+ } catch (error) {
237
+ if (generation === currentGeneration) {
238
+ loading.classList.add("hidden");
239
+ setStatus(error.message || "Worker could not start", true);
240
+ }
241
+ return;
242
+ }
243
+ const active = worker;
244
+ worker.addEventListener("message", event => {
245
+ if (generation !== currentGeneration || worker !== active) return;
246
+ const message = event.data || {};
247
+ if (message.type === "webvas:loading") {
248
+ loadingLabel.textContent = message.message;
249
+ document.querySelector("#runtime-state").textContent = "Loading runtime";
250
+ } else if (message.type === "webvas:ready") {
251
+ if (workerBlobUrl) URL.revokeObjectURL(workerBlobUrl);
252
+ workerBlobUrl = null;
253
+ loading.classList.add("hidden");
254
+ document.querySelector("#runtime-state").textContent = "Ruby ready";
255
+ active.postMessage({ type: "webvas:run", source: pendingSource });
256
+ pendingSource = null;
257
+ } else if (message.type === "webvas:started") {
258
+ dirty.classList.remove("visible");
259
+ setStatus("Sketch is running");
260
+ } else if (message.type === "webvas:mode") {
261
+ screen.hidden = message.canvas === "#gpu";
262
+ gpu.hidden = message.canvas !== "#gpu";
263
+ } else if (message.type === "webvas:pixelated") {
264
+ const canvas = message.selector === "#gpu" ? gpu : screen;
265
+ canvas.style.imageRendering = message.enabled ? "pixelated" : "auto";
266
+ } else if (message.type === "webvas:size") {
267
+ document.querySelector("#canvas-size").textContent = message.width + " × " + message.height;
268
+ } else if (message.type === "webvas:metrics") {
269
+ document.querySelector("#performance-state").textContent =
270
+ `${message.fps.toFixed(1)} fps · ${message.callbackMs.toFixed(2)} ms / frame`;
271
+ } else if (message.type === "webvas:error") {
272
+ loading.classList.add("hidden");
273
+ selectErrorLine(message.backtrace || "");
274
+ setStatus([message.message, message.backtrace].filter(Boolean).join("\n"), true);
275
+ }
276
+ });
277
+ worker.addEventListener("error", event => {
278
+ if (generation !== currentGeneration || worker !== active) return;
279
+ loading.classList.add("hidden");
280
+ const reason = event.error?.stack || event.error?.message || event.message;
281
+ setStatus(reason || `Worker failed${event.filename ? `: ${event.filename}:${event.lineno}` : ""}`, true);
282
+ });
283
+ const screenCanvas = screen.transferControlToOffscreen();
284
+ const gpuCanvas = gpu.transferControlToOffscreen();
285
+ worker.postMessage({
286
+ type: "webvas:init",
287
+ screen: screenCanvas,
288
+ gpu: gpuCanvas,
289
+ bridgeUrl: new URL("./bridge.js", import.meta.url).href,
290
+ shaderUrl: new URL("./wgsl-runner.js", import.meta.url).href,
291
+ wasmUrl: new URL(document.querySelector('meta[name="webvas-runtime"]').content, document.baseURI).href
292
+ }, [screenCanvas, gpuCanvas]);
293
+ }
294
+
295
+ function sendInput(event) {
296
+ if (!worker || !["pointerdown", "pointerup", "pointermove", "pointercancel", "keydown", "keyup", "wheel"].includes(event.type)) return;
297
+ const rect = event.target.getBoundingClientRect();
298
+ if (event.type === "pointerdown") {
299
+ event.target.focus({ preventScroll: true });
300
+ event.target.setPointerCapture(event.pointerId);
301
+ }
302
+ if (event.type === "wheel") event.preventDefault();
303
+ if (["keydown", "keyup"].includes(event.type) && ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", " "].includes(event.key)) event.preventDefault();
304
+ worker.postMessage({
305
+ type: "webvas:input",
306
+ event: {
307
+ type: event.type === "pointercancel" ? "pointerup" : event.type,
308
+ clientX: event.clientX || 0,
309
+ clientY: event.clientY || 0,
310
+ left: rect.left,
311
+ top: rect.top,
312
+ rectWidth: rect.width,
313
+ rectHeight: rect.height,
314
+ button: event.button,
315
+ deltaX: event.deltaX,
316
+ deltaY: event.deltaY,
317
+ code: event.code,
318
+ key: event.key,
319
+ shiftKey: event.shiftKey,
320
+ ctrlKey: event.ctrlKey,
321
+ altKey: event.altKey,
322
+ metaKey: event.metaKey
323
+ }
324
+ });
325
+ }
326
+
327
+ async function share() {
328
+ try {
329
+ if (!("CompressionStream" in window) || !navigator.clipboard) {
330
+ throw new Error("Share links require a modern browser and clipboard permission.");
331
+ }
332
+ const source = new TextEncoder().encode(editor.value);
333
+ if (source.length > 32768) throw new Error("Share links are limited to 32 KB of source.");
334
+ const stream = new Blob([source]).stream().pipeThrough(new CompressionStream("deflate"));
335
+ const compressed = new Uint8Array(await new Response(stream).arrayBuffer());
336
+ let binary = "";
337
+ compressed.forEach(byte => { binary += String.fromCharCode(byte); });
338
+ const token = btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
339
+ const url = new URL(location.href);
340
+ url.searchParams.delete("source");
341
+ url.hash = "code=" + token;
342
+ await navigator.clipboard.writeText(url.href);
343
+ setStatus("Share link copied");
344
+ } catch (error) {
345
+ setStatus(error.message || "Could not create a share link", true);
346
+ }
347
+ }
348
+
349
+ function download() {
350
+ const url = URL.createObjectURL(new Blob([editor.value], { type: "text/x-ruby;charset=utf-8" }));
351
+ const link = Object.assign(document.createElement("a"), { href: url, download: "sketch.rb" });
352
+ link.click();
353
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
354
+ }
355
+
356
+ function downloadHtml() {
357
+ if (new TextEncoder().encode(editor.value).length > 32768) {
358
+ setStatus("HTML downloads are limited to 32 KB of source.", true);
359
+ return;
360
+ }
361
+ const page = document.documentElement.cloneNode(true);
362
+ page.querySelector("#source").textContent = editor.value;
363
+ const stylesheet = page.querySelector('link[rel="stylesheet"]');
364
+ stylesheet.href = new URL(stylesheet.getAttribute("href"), PLAYGROUND_URL).href;
365
+ const entry = page.querySelector('script[type="module"]');
366
+ entry.src = new URL(entry.getAttribute("src"), PLAYGROUND_URL).href;
367
+ page.querySelector('meta[name="webvas-runtime"]').content = new URL("./assets/webvas.wasm", PLAYGROUND_URL).href;
368
+ page.querySelector('meta[http-equiv="Content-Security-Policy"]').content =
369
+ "default-src 'none'; script-src https://rbgfx.github.io https://cdn.jsdelivr.net 'wasm-unsafe-eval' 'unsafe-eval'; worker-src blob:; connect-src https://rbgfx.github.io data:; style-src https://rbgfx.github.io; img-src https://rbgfx.github.io data: blob:; font-src https://rbgfx.github.io; object-src 'none'; base-uri 'none'";
370
+ const url = URL.createObjectURL(new Blob(["<!doctype html>\n", page.outerHTML], { type: "text/html" }));
371
+ const link = Object.assign(document.createElement("a"), { href: url, download: "webvas-sketch.html" });
372
+ link.click();
373
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
374
+ }
375
+
376
+ async function loadSharedSource() {
377
+ const token = new URLSearchParams(location.hash.slice(1)).get("code");
378
+ if (!token) return;
379
+ try {
380
+ if (token.length > 65536) throw new Error("Shared link is too large.");
381
+ const normalized = token.replaceAll("-", "+").replaceAll("_", "/");
382
+ const binary = atob(normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "="));
383
+ const bytes = Uint8Array.from(binary, char => char.charCodeAt(0));
384
+ const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("deflate"));
385
+ const source = await readLimited(stream, 32768);
386
+ setSource(source, false);
387
+ history.replaceState(null, "", location.pathname + location.search);
388
+ } catch (error) {
389
+ setStatus(error.message || "Could not decode the shared sketch", true);
390
+ }
391
+ }
392
+
393
+ async function readLimited(stream, limit) {
394
+ const reader = stream.getReader();
395
+ const chunks = [];
396
+ let size = 0;
397
+ while (true) {
398
+ const { done, value } = await reader.read();
399
+ if (done) break;
400
+ size += value.byteLength;
401
+ if (size > limit) {
402
+ await reader.cancel();
403
+ throw new Error("Source exceeds 32 KB.");
404
+ }
405
+ chunks.push(value);
406
+ }
407
+ const bytes = new Uint8Array(size);
408
+ let offset = 0;
409
+ for (const chunk of chunks) {
410
+ bytes.set(chunk, offset);
411
+ offset += chunk.byteLength;
412
+ }
413
+ return new TextDecoder().decode(bytes);
414
+ }
415
+
416
+ async function loadProjectSource() {
417
+ const path = new URLSearchParams(location.search).get("source");
418
+ if (!path) return;
419
+ try {
420
+ const base = new URL(".", location.href);
421
+ const url = new URL(path, base);
422
+ if (url.origin !== base.origin || !url.pathname.startsWith(base.pathname)) {
423
+ throw new Error("Project source must be on this site.");
424
+ }
425
+ const response = await fetch(url, { credentials: "omit" });
426
+ if (!response.ok) throw new Error("Project source could not be loaded.");
427
+ const source = await readLimited(response.body, 32768);
428
+ setSource(source, false);
429
+ } catch (error) {
430
+ setStatus(error.message || "Could not load project source", true);
431
+ }
432
+ }
433
+
434
+ for (const [index, example] of examples.entries()) {
435
+ const option = document.createElement("option");
436
+ option.value = String(index);
437
+ option.textContent = example.name;
438
+ document.querySelector("#examples").append(option);
439
+ }
440
+ document.querySelector("#examples").addEventListener("change", event => {
441
+ const example = examples[Number(event.target.value)];
442
+ if (example) setSource(example.source);
443
+ event.target.value = "";
444
+ });
445
+ document.querySelector("#run").addEventListener("click", () => start(editor.value));
446
+ document.querySelector("#share").addEventListener("click", share);
447
+ document.querySelector("#download").addEventListener("click", download);
448
+ document.querySelector("#download-html").addEventListener("click", downloadHtml);
449
+ editor.addEventListener("input", () => dirty.classList.add("visible"));
450
+ editor.addEventListener("keydown", event => {
451
+ if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
452
+ event.preventDefault();
453
+ start(editor.value);
454
+ }
455
+ });
456
+ stage.addEventListener("pointerdown", sendInput);
457
+ stage.addEventListener("pointerup", sendInput);
458
+ stage.addEventListener("pointermove", sendInput);
459
+ stage.addEventListener("pointercancel", sendInput);
460
+ stage.addEventListener("keydown", sendInput);
461
+ stage.addEventListener("keyup", sendInput);
462
+ stage.addEventListener("wheel", sendInput, { passive: false });
463
+ setSource(editor.value || examples[0].source, false);
464
+ await loadProjectSource();
465
+ await loadSharedSource();
466
+ start(editor.value);
data/site/index.html ADDED
@@ -0,0 +1,56 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="color-scheme" content="dark">
7
+ <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' https://rbgfx.github.io https://cdn.jsdelivr.net 'wasm-unsafe-eval' 'unsafe-eval'; worker-src blob:; connect-src 'self' https://rbgfx.github.io data:; style-src 'self' https://rbgfx.github.io; img-src 'self' https://rbgfx.github.io data: blob:; font-src 'self' https://rbgfx.github.io; object-src 'none'; base-uri 'self'">
8
+ <meta name="webvas-runtime" content="./assets/webvas.wasm">
9
+ <meta name="description" content="Run Ruby sketches in your browser with RBGL, Gesso, and ruby.wasm.">
10
+ <title>Webvas — Ruby, in motion</title>
11
+ <link rel="stylesheet" href="./style.css">
12
+ </head>
13
+ <body>
14
+ <header class="topbar">
15
+ <a class="wordmark" href="./" aria-label="Webvas home"><span class="mark">W</span> webvas</a>
16
+ <div class="top-meta"><span class="live-dot"></span><span>Ruby graphics, in the browser</span><a href="https://github.com/rbgfx/webvas">GitHub ↗</a></div>
17
+ </header>
18
+ <main>
19
+ <section class="intro">
20
+ <h1>Write Ruby.<br><span>Watch it move.</span></h1>
21
+ <p class="lede">A quiet workspace for sketches, pixels, and shaders.<br>Runs in a dedicated browser worker.</p>
22
+ </section>
23
+ <section class="workbench" aria-label="Ruby sketch editor">
24
+ <div class="toolbar">
25
+ <div class="file-label"><span class="file-icon">◈</span><span>sketch.rb</span><span class="dirty" id="dirty" aria-hidden="true">●</span></div>
26
+ <label class="example-select"><span class="sr-only">Load an example</span>
27
+ <select id="examples"><option value="">Examples</option></select>
28
+ </label>
29
+ <div class="toolbar-actions">
30
+ <button class="quiet-button" id="share" type="button">Share</button>
31
+ <button class="quiet-button" id="download" type="button">Download .rb</button>
32
+ <button class="quiet-button" id="download-html" type="button">Download HTML</button>
33
+ <button class="run-button" id="run" type="button"><span aria-hidden="true">▶</span> Run <kbd>⌘ ↵</kbd></button>
34
+ </div>
35
+ </div>
36
+ <div class="panes">
37
+ <div class="editor-pane">
38
+ <div class="pane-heading"><span>EDITOR</span><span>Ruby 4.0 · UTF-8</span></div>
39
+ <label class="sr-only" for="source">Ruby source code</label>
40
+ <textarea id="source" spellcheck="false" autocapitalize="off" autocomplete="off" autocorrect="off"></textarea>
41
+ </div>
42
+ <div class="stage-pane">
43
+ <div class="pane-heading"><span>CANVAS</span><span id="canvas-size">480 × 320</span></div>
44
+ <div class="stage-wrap" id="stage">
45
+ <div class="loading" id="loading"><span class="spinner" aria-hidden="true"></span><span id="loading-label">Starting Ruby runtime</span></div>
46
+ </div>
47
+ <div class="console" aria-live="polite" aria-atomic="true"><span class="console-dot"></span><span id="status">Preparing the drawing machine…</span></div>
48
+ </div>
49
+ </div>
50
+ <footer class="workbench-foot"><span>Runs in a dedicated worker</span><span>Ctrl / ⌘ + Enter to run</span><span id="performance-state">Frame timing warming up</span><span id="runtime-state">WASM loading</span></footer>
51
+ </section>
52
+ <p class="note"><span>✳</span> Source is sent only to the local worker. Share links contain compressed source text.</p>
53
+ </main>
54
+ <script type="module" src="./app.js"></script>
55
+ </body>
56
+ </html>
@@ -0,0 +1,26 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' https://cdn.jsdelivr.net 'wasm-unsafe-eval' 'unsafe-eval'; worker-src 'self' blob:; connect-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'self'">
7
+ <title>Webvas loader example</title>
8
+ <style>
9
+ body { margin: 2rem; background: #111417; color: #e5e6df; font: 1rem system-ui, sans-serif; }
10
+ canvas { display: block; width: min(80vw, 640px); height: auto; aspect-ratio: 4 / 3; background: #111417; }
11
+ output { display: block; margin-top: .75rem; white-space: pre-wrap; }
12
+ </style>
13
+ </head>
14
+ <body>
15
+ <canvas id="screen" width="320" height="240" aria-label="Ruby sketch"></canvas>
16
+ <script type="text/ruby" data-webvas>
17
+ require "gesso"
18
+ Gesso.run(width: 320, height: 240, runner: :web) do
19
+ draw do
20
+ background mouse_pressed? ? "#e5b36a" : "#123456"
21
+ end
22
+ end
23
+ </script>
24
+ <script src="./loader.js"></script>
25
+ </body>
26
+ </html>
data/site/style.css ADDED
@@ -0,0 +1,89 @@
1
+ :root {
2
+ color-scheme: dark;
3
+ --paper: #111211;
4
+ --panel: #181a18;
5
+ --panel-raised: #202220;
6
+ --line: #303330;
7
+ --muted: #828780;
8
+ --ink: #e8eae5;
9
+ --copper: #d38a62;
10
+ --green: #99b493;
11
+ --mono: ui-monospace, SFMono-Regular, Menlo, monospace;
12
+ --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
13
+ }
14
+ * { box-sizing: border-box; }
15
+ body { margin: 0; min-width: 320px; background: var(--paper); color: var(--ink); font-family: var(--sans); }
16
+ button, select, textarea { font: inherit; }
17
+ button, select { color: inherit; }
18
+ .topbar { height: 64px; display: flex; align-items: center; justify-content: space-between; padding: 0 clamp(18px, 5vw, 76px); border-bottom: 1px solid #252725; }
19
+ .wordmark { display: flex; gap: 10px; align-items: center; color: var(--ink); text-decoration: none; font-weight: 800; letter-spacing: -.04em; }
20
+ .mark { display: grid; place-items: center; width: 27px; height: 27px; border-radius: 7px 7px 7px 2px; background: var(--copper); color: #211812; font: 700 15px var(--mono); }
21
+ .top-meta { display: flex; align-items: center; gap: 11px; color: var(--muted); font: 11px var(--mono); }
22
+ .top-meta a { color: #c8cbc5; text-decoration: none; margin-left: 18px; }
23
+ .live-dot, .console-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); }
24
+ main { width: min(1180px, calc(100% - 40px)); margin: 0 auto; }
25
+ .intro { display: grid; grid-template-columns: 1fr 1fr; column-gap: 40px; align-items: end; padding: 58px 0 34px; }
26
+ h1 { margin: 0; font-size: clamp(38px, 5vw, 58px); line-height: .98; font-weight: 700; text-wrap: balance; }
27
+ h1 span { color: #858b84; }
28
+ .lede { justify-self: end; margin: 0 3px 4px 0; color: #a3a79f; font-size: 14px; line-height: 1.8; }
29
+ .workbench { overflow: hidden; border: 1px solid #393c38; border-radius: 11px; background: var(--panel); box-shadow: 0 16px 50px #0004; }
30
+ .toolbar { min-height: 54px; display: flex; align-items: center; gap: 20px; padding: 8px 12px 8px 17px; border-bottom: 1px solid var(--line); }
31
+ .file-label { display: flex; align-items: center; gap: 9px; min-width: 160px; font: 11px var(--mono); color: #d8dbd4; }
32
+ .file-icon { color: var(--copper); font-size: 15px; }
33
+ .dirty { visibility: hidden; color: var(--copper); font-size: 9px; }
34
+ .dirty.visible { visibility: visible; }
35
+ .example-select select { max-width: 190px; padding: 7px 28px 7px 10px; border: 1px solid var(--line); border-radius: 5px; background: #20221f; font: 11px var(--mono); }
36
+ .toolbar-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; }
37
+ .quiet-button, .run-button { height: 34px; padding: 0 12px; border: 1px solid transparent; border-radius: 5px; background: transparent; cursor: pointer; font-size: 11px; }
38
+ .quiet-button { color: #b8bcb4; }
39
+ .quiet-button:hover { background: #272a27; }
40
+ .run-button { display: flex; align-items: center; gap: 8px; background: var(--copper); color: #231a15; font-weight: 800; }
41
+ .run-button:hover { background: #e09a72; }
42
+ .run-button kbd { margin-left: 5px; padding: 2px 4px; border: 1px solid #624735; border-radius: 3px; background: #cc8057; font: 9px var(--mono); }
43
+ button:focus-visible, select:focus-visible, textarea:focus-visible, a:focus-visible { outline: 2px solid var(--copper); outline-offset: 3px; }
44
+ .panes { display: grid; grid-template-columns: 1fr 1fr; min-height: 438px; }
45
+ .editor-pane, .stage-pane { min-width: 0; display: flex; flex-direction: column; }
46
+ .editor-pane { border-right: 1px solid var(--line); }
47
+ .pane-heading { height: 38px; display: flex; align-items: center; justify-content: space-between; padding: 0 16px; border-bottom: 1px solid #292c29; color: #9da29a; font: 9px var(--mono); }
48
+ .pane-heading span + span { color: #717770; }
49
+ textarea { flex: 1; width: 100%; min-height: 399px; resize: vertical; padding: 20px; border: 0; outline-offset: -3px; background: #151715; color: #d9ddd5; font: 12px/1.75 var(--mono); tab-size: 2; }
50
+ textarea::selection { background: #79533d; color: #fff4e8; }
51
+ .stage-wrap { position: relative; flex: 1; min-height: 399px; padding: 20px; background: #131513; }
52
+ .stage-wrap canvas { display: block; width: 100%; height: 100%; min-height: 359px; border: 1px solid #3a3e39; background: #1c1f1d; object-fit: contain; outline: none; }
53
+ .stage-wrap canvas:focus-visible { outline: 2px solid var(--copper); outline-offset: 2px; }
54
+ .stage-wrap canvas[hidden] { display: none; }
55
+ .loading { position: absolute; inset: 20px; display: flex; align-items: center; justify-content: center; gap: 10px; background: #171917; color: #9ca198; font: 11px var(--mono); }
56
+ .loading.hidden { display: none; }
57
+ .spinner { width: 13px; height: 13px; border: 1px solid #555d53; border-top-color: var(--copper); border-radius: 50%; }
58
+ .console { min-height: 39px; display: flex; align-items: center; gap: 9px; padding: 0 16px; border-top: 1px solid var(--line); color: #a3a79f; font: 10px var(--mono); }
59
+ .console-dot { flex: 0 0 auto; width: 6px; height: 6px; }
60
+ .console.error { color: #e1a08a; }
61
+ .console.error .console-dot { background: #d27861; }
62
+ .workbench-foot { min-height: 34px; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 14px; padding: 8px 15px; border-top: 1px solid #252825; color: #72776f; font: 9px var(--mono); }
63
+ #performance-state { font-variant-numeric: tabular-nums; }
64
+ .note { margin: 15px 0 48px; color: #747a72; font-size: 11px; text-wrap: pretty; }
65
+ .note span { color: var(--copper); margin-right: 5px; }
66
+ .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
67
+ @media (max-width: 720px) {
68
+ .top-meta > span:not(.live-dot) { display: none; }
69
+ .top-meta a { margin-left: 0; }
70
+ .intro { grid-template-columns: 1fr; row-gap: 18px; padding-top: 42px; }
71
+ .lede { justify-self: start; font-size: 12px; }
72
+ .toolbar { flex-wrap: wrap; gap: 7px; }
73
+ .file-label { min-width: 0; margin-right: auto; }
74
+ .example-select { order: 3; }
75
+ .toolbar-actions { width: 100%; flex-wrap: wrap; justify-content: space-between; margin-left: 0; gap: 2px; }
76
+ .quiet-button { padding: 0 7px; }
77
+ .run-button { padding: 0 9px; }
78
+ .run-button kbd { display: none; }
79
+ .panes { grid-template-columns: 1fr; }
80
+ .editor-pane { border-right: 0; border-bottom: 1px solid var(--line); }
81
+ textarea { min-height: 280px; }
82
+ .stage-wrap { min-height: 300px; padding: 12px; }
83
+ .stage-wrap canvas { min-height: 276px; }
84
+ .loading { inset: 12px; }
85
+ .workbench-foot span:nth-child(2) { display: none; }
86
+ }
87
+ @media (prefers-reduced-motion: reduce) {
88
+ *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; transition-duration: .01ms !important; }
89
+ }