a73x

7a57ef8c

feat: web shell — canvas wall, 1x1 passive tiles, zoomed input/IME/scrollback

a73x   2026-08-13 10:40

Commit message
feat: web shell — canvas wall, 1x1 passive tiles, zoomed input/IME/scrollback

M-web Task 8. One wasm instance per tile (one replica each, memories
isolated); the pump routes frames — snapshot/delta through
mux_apply_frame with dirty-row-only repaints, RESYNC answered with a
fresh (0,0) attach, exit_status discriminated by the CLI's own
state-since-attach rule into 'session full' vs 'exited N',
scrollback_chunk into the scratch terminal. Wall tiles attach 1x1 (the
passivity contract stated at the send site); only the zoomed tile takes
keys, sends resize-if-differs, and pages history by wheel — any other
key exits scroll mode swallowed, the CLI rule. IME/dead keys via a
hidden focused input whose compositions arrive paste-shaped; pastes
chunk at 32 KiB under the hub's 64 KiB bound. Every wasm memory access
re-reads memory.buffer — the detach discipline stated in the header.

Rider: the hub gains /tiles (labels as JSON, escaped) — the runtime
half the embedded page cannot know. Smoke: /tiles serves, page+wasm
serve, node verify 62/62.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

src/webhub.zig
Old New
@@ -249,11 +249,36 @@ fn dialLoop(
249 /// One accepted connection, start to finish (M-web Task 7). Static 249 /// One accepted connection, start to finish (M-web Task 7). Static
250 /// requests loop for keep-alive; a WebSocket upgrade consumes the 250 /// requests loop for keep-alive; a WebSocket upgrade consumes the
251 /// connection into a tile pump and never returns to HTTP. 251 /// connection into a tile pump and never returns to HTTP.
252 /// `/tiles`: the runtime half the embedded page cannot know — the tile
253 /// labels, in index order, as a JSON array. Escaping covers the two
254 /// bytes JSON cannot carry raw in a string plus control chars; labels
255 /// are argv (hosts, paths), not hostile input, but a path with a quote
256 /// in it must not break the page.
257 pub fn tilesJson(alloc: std.mem.Allocator, labels: []const []const u8) ![]u8 {
258 var out: std.ArrayList(u8) = .empty;
259 errdefer out.deinit(alloc);
260 try out.append(alloc, '[');
261 for (labels, 0..) |label, i| {
262 if (i > 0) try out.append(alloc, ',');
263 try out.append(alloc, '"');
264 for (label) |c| switch (c) {
265 '"' => try out.appendSlice(alloc, "\\\""),
266 '\\' => try out.appendSlice(alloc, "\\\\"),
267 0x00...0x1f => try out.print(alloc, "\\u{x:0>4}", .{c}),
268 else => try out.append(alloc, c),
269 };
270 try out.append(alloc, '"');
271 }
272 try out.append(alloc, ']');
273 return out.toOwnedSlice(alloc);
274 }
275
252 pub fn serveConn( 276 pub fn serveConn(
253 alloc: std.mem.Allocator, 277 alloc: std.mem.Allocator,
254 stream: std.net.Stream, 278 stream: std.net.Stream,
255 port: u16, 279 port: u16,
256 targets: []const client.Target, 280 targets: []const client.Target,
281 labels: []const []const u8,
257 assets: Assets, 282 assets: Assets,
258 ) void { 283 ) void {
259 defer stream.close(); 284 defer stream.close();
@@ -297,6 +322,18 @@ pub fn serveConn(
297 return; 322 return;
298 } 323 }
299 324
325 if (std.mem.eql(u8, path, "/tiles")) {
326 const json = tilesJson(alloc, labels) catch return;
327 defer alloc.free(json);
328 req.respond(json, .{
329 .extra_headers = &.{
330 .{ .name = "content-type", .value = "application/json" },
331 .{ .name = "cache-control", .value = "no-cache" },
332 },
333 }) catch return;
334 continue;
335 }
336
300 if (route(assets, path)) |asset| { 337 if (route(assets, path)) |asset| {
301 req.respond(asset.body, .{ 338 req.respond(asset.body, .{
302 .extra_headers = &.{ 339 .extra_headers = &.{
src/webhub_main.zig
Old New
@@ -225,7 +225,7 @@ pub fn main() !u8 {
225 while (true) { 225 while (true) {
226 const conn = listener.accept() catch continue; 226 const conn = listener.accept() catch continue;
227 const th = std.Thread.spawn(.{}, webhub.serveConn, .{ 227 const th = std.Thread.spawn(.{}, webhub.serveConn, .{
228 alloc, conn.stream, parsed.port, targets.items, assets, 228 alloc, conn.stream, parsed.port, targets.items, labels.items, assets,
229 }) catch { 229 }) catch {
230 conn.stream.close(); 230 conn.stream.close();
231 continue; 231 continue;
web/index.html
Old New
@@ -1,17 +1,59 @@
1 <!doctype html> 1 <!doctype html>
2 <!-- muxweb: the wall of devices. Skeleton (M-web Task 5); the shell 2 <!-- muxweb: the wall of devices (M-web Task 8). Served from memory by
3 proper lands in Task 8. --> 3 muxweb; everything dynamic lives in mux.js; the replica itself is
4 mux_core.wasm — the same ghostty-vt the daemon runs. -->
4 <html lang="en"> 5 <html lang="en">
5 <head> 6 <head>
6 <meta charset="utf-8"> 7 <meta charset="utf-8">
7 <title>mux</title> 8 <title>mux</title>
8 <style> 9 <style>
9 body { margin: 0; background: #111; color: #ccc; font: 14px monospace; } 10 :root { --bg: #0d0f12; --chrome: #1a1d23; --fg: #c8ccd4; --dim: #6b7280; }
10 #wall { display: grid; gap: 8px; padding: 8px; } 11 * { box-sizing: border-box; }
12 body {
13 margin: 0; background: var(--bg); color: var(--fg);
14 font: 13px/1.4 ui-monospace, monospace;
15 }
16 #wall {
17 display: grid; gap: 10px; padding: 10px;
18 grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));
19 }
20 .tile {
21 background: var(--chrome); border: 1px solid #262a33; border-radius: 4px;
22 overflow: hidden; cursor: pointer; display: flex; flex-direction: column;
23 }
24 .tile header {
25 display: flex; justify-content: space-between; align-items: center;
26 padding: 4px 8px; font-size: 12px; color: var(--dim);
27 border-bottom: 1px solid #262a33; user-select: none;
28 }
29 .tile header .label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
30 .badge { padding: 0 6px; border-radius: 3px; font-size: 11px; }
31 .badge.connecting, .badge.reconnecting { background: #4a3b12; color: #e8c35a; }
32 .badge.up { background: #16351f; color: #6fce8a; }
33 .badge.gone, .badge.exited, .badge.full { background: #3d1a1a; color: #e07a7a; }
34 .badge.scroll { background: #1a2c3d; color: #6ab0e0; }
35 .tile canvas { display: block; width: 100%; height: auto; background: #000; }
36 /* The zoomed tile: same element, promoted. 1:1 pixels, keys go here. */
37 .tile.zoomed {
38 position: fixed; inset: 0; z-index: 10; border-radius: 0; cursor: default;
39 border: none;
40 }
41 .tile.zoomed canvas { width: auto; height: auto; margin: 0 auto; }
42 #shade {
43 display: none; position: fixed; inset: 0; z-index: 5; background: #000a;
44 }
45 #shade.on { display: block; }
46 /* IME target: focusable, invisible, never display:none (that kills IME). */
47 #ime {
48 position: fixed; left: -9999px; top: 0; width: 1px; height: 1px;
49 opacity: 0; border: 0; padding: 0;
50 }
11 </style> 51 </style>
12 </head> 52 </head>
13 <body> 53 <body>
54 <div id="shade"></div>
14 <div id="wall"></div> 55 <div id="wall"></div>
56 <input id="ime" autocomplete="off" autocapitalize="off" spellcheck="false">
15 <script src="/mux.js"></script> 57 <script src="/mux.js"></script>
16 </body> 58 </body>
17 </html> 59 </html>
web/mux.js
Old New
@@ -1,7 +1,404 @@
1 // muxweb glue skeleton (M-web Task 5). The renderer, input, and tile 1 // muxweb shell (M-web Task 8). Thin by construction: every decision that
2 // logic land in Task 8. Discipline stated once, here, for everything 2 // can live in Zig lives in mux_core.wasm; this file is glue — WebSocket
3 // that follows: NEVER cache a view of wasm memory — every call can grow 3 // pump, canvas painter, event translation.
4 // linear memory and growth detaches every ArrayBuffer view. Re-read 4 //
5 // exports.memory.buffer after each call. 5 // THE ONE DISCIPLINE: never cache a view of wasm memory. Any call can
6 // grow linear memory, and growth detaches every ArrayBuffer view. Every
7 // access below re-reads exports.memory.buffer. (Spike-measured: caching
8 // gives silent garbage or a TypeError, and not immediately.)
6 'use strict'; 9 'use strict';
7 console.log('muxweb: shell skeleton'); 10
11 // Wire bytes (protocol.zig MsgType / webhub.zig envelope).
12 const MSG = {
13 attach: 0x01, input: 0x02, resize: 0x03, detach: 0x04, fetch_scrollback: 0x05,
14 snapshot: 0x81, exit_status: 0x82, scrollback_chunk: 0x85, delta: 0x87,
15 pty_mode: 0x88,
16 };
17 const ENV_FRAME = 0x00, ENV_CONTROL = 0x01;
18
19 // keymap.Key by @intFromEnum (wasm_core.zig pins the table).
20 const KEY = {
21 char: 0, Enter: 1, Tab: 2, Backspace: 3, Escape: 4,
22 ArrowUp: 5, ArrowDown: 6, ArrowLeft: 7, ArrowRight: 8,
23 Home: 9, End: 10, Insert: 11, Delete: 12, PageUp: 13, PageDown: 14,
24 F1: 15, F2: 16, F3: 17, F4: 18, F5: 19, F6: 20, F7: 21, F8: 22,
25 F9: 23, F10: 24, F11: 25, F12: 26,
26 };
27
28 // Pastes chunk at 32 KiB so no browser→hub message approaches the hub's
29 // 64 KiB inbound bound (webhub.zig ws_buffer_len).
30 const PASTE_CHUNK = 32 * 1024;
31
32 const FONT = '14px ui-monospace, monospace';
33 const DEFAULT_FG = '#c8ccd4', DEFAULT_BG = '#000000';
34
35 // The xterm 256 palette, computed once.
36 const PALETTE = (() => {
37 const base = [
38 '#000000', '#cd0000', '#00cd00', '#cdcd00', '#0000ee', '#cd00cd', '#00cdcd', '#e5e5e5',
39 '#7f7f7f', '#ff0000', '#00ff00', '#ffff00', '#5c5cff', '#ff00ff', '#00ffff', '#ffffff',
40 ];
41 const p = base.slice();
42 const lv = [0, 95, 135, 175, 215, 255];
43 for (let r = 0; r < 6; r++) for (let g = 0; g < 6; g++) for (let b = 0; b < 6; b++)
44 p.push(`rgb(${lv[r]},${lv[g]},${lv[b]})`);
45 for (let i = 0; i < 24; i++) { const v = 8 + i * 10; p.push(`rgb(${v},${v},${v})`); }
46 return p;
47 })();
48
49 function colorOf(packed, dflt) {
50 const kind = packed >>> 24;
51 if (kind === 1) return PALETTE[packed & 0xff];
52 if (kind === 2) return `rgb(${(packed >> 16) & 0xff},${(packed >> 8) & 0xff},${packed & 0xff})`;
53 return dflt;
54 }
55
56 // One cell's font metrics, measured once against the real font.
57 const METRICS = (() => {
58 const c = document.createElement('canvas').getContext('2d');
59 c.font = FONT;
60 const m = c.measureText('M');
61 const w = Math.ceil(m.width);
62 const h = Math.ceil((m.fontBoundingBoxAscent || 11) + (m.fontBoundingBoxDescent || 3));
63 return { w, h, ascent: Math.ceil(m.fontBoundingBoxAscent || 11) };
64 })();
65
66 let compiledCore = null; // one compile, one instance per tile
67
68 class Tile {
69 constructor(idx, label, wallEl) {
70 this.idx = idx;
71 this.label = label;
72 this.zoomed = false;
73 this.scrollPages = 0;
74 this.gotState = false; // JS mirror of the CLI's state_since_attach
75
76 this.el = document.createElement('div');
77 this.el.className = 'tile';
78 this.el.innerHTML =
79 `<header><span class="label"></span><span class="badge connecting">connecting</span></header>`;
80 this.el.querySelector('.label').textContent = `${idx}: ${label}`;
81 this.canvas = document.createElement('canvas');
82 this.el.appendChild(this.canvas);
83 this.ctx = this.canvas.getContext('2d');
84 wallEl.appendChild(this.el);
85
86 this.el.addEventListener('click', (ev) => {
87 if (!this.zoomed) { ev.stopPropagation(); zoom(this); }
88 });
89 this.el.addEventListener('wheel', (ev) => {
90 if (this.zoomed) { ev.preventDefault(); this.onWheel(ev); }
91 // Wall tiles don't scroll (spec): the event falls through to the page.
92 }, { passive: false });
93 }
94
95 async start() {
96 const { instance } = await WebAssembly.instantiate(compiledCore, {});
97 this.core = instance.exports;
98 if (this.core.mux_init(80, 24) !== 0) { this.setBadge('gone', 'init failed'); return; }
99 this.sizeCanvas();
100
101 this.ws = new WebSocket(`ws://${location.host}/ws/${this.idx}`);
102 this.ws.binaryType = 'arraybuffer';
103 this.ws.onmessage = (ev) => this.onMessage(new Uint8Array(ev.data));
104 this.ws.onclose = () => this.setBadge('gone', 'gone');
105 this.ws.onerror = () => this.setBadge('gone', 'gone');
106 }
107
108 // --- wasm memory access, always through fresh views ---
109 mem() { return new Uint8Array(this.core.memory.buffer); }
110 stage(bytes) {
111 if (bytes.length > this.core.mux_input_cap()) return false;
112 this.mem().set(bytes, this.core.mux_input_ptr());
113 return true;
114 }
115 outBytes() {
116 const ptr = this.core.mux_output_ptr(), len = this.core.mux_output_len();
117 return this.mem().slice(ptr, ptr + len);
118 }
119
120 // --- wire out ---
121 sendFrame(type, payload) {
122 if (this.ws.readyState !== WebSocket.OPEN) return;
123 const msg = new Uint8Array(6 + payload.length);
124 msg[0] = ENV_FRAME;
125 msg[1] = type;
126 new DataView(msg.buffer).setUint32(2, payload.length, true);
127 msg.set(payload, 6);
128 this.ws.send(msg);
129 }
130 sendAttach(fresh) {
131 // THE PASSIVITY CONTRACT (spec amendment 1): a wall tile attaches at
132 // 1x1. The daemon refuses the degenerate size, answers a unicast
133 // snapshot carrying the true grid, and the slot stays 0x0 forever —
134 // this tile can never move the shared session. Only the zoomed tile
135 // claims its real size.
136 const cols = this.zoomed ? this.zoomCols() : 1;
137 const rows = this.zoomed ? this.zoomRows() : 1;
138 const n = this.core.mux_attach_payload(cols, rows, fresh ? 1 : 0);
139 if (n > 0) { this.gotState = false; this.sendFrame(MSG.attach, this.outBytes()); }
140 }
141 sendKey(keyId, cp, mods) {
142 const n = this.core.mux_key_encode(keyId, cp, mods);
143 if (n > 0) this.sendFrame(MSG.input, this.outBytes());
144 }
145 sendPaste(text) {
146 const bytes = new TextEncoder().encode(text);
147 for (let off = 0; off < bytes.length; off += PASTE_CHUNK) {
148 const chunk = bytes.subarray(off, off + PASTE_CHUNK);
149 if (!this.stage(chunk)) return;
150 if (this.core.mux_paste_encode(chunk.length) > 0)
151 this.sendFrame(MSG.input, this.outBytes());
152 }
153 }
154 sendResizeIfDiffers() {
155 // Only the zoomed tile, and only when its computed size actually
156 // differs from the session's grid (spec: passive wall, latest-wins
157 // respected).
158 if (!this.zoomed) return;
159 const cols = this.zoomCols(), rows = this.zoomRows();
160 if (cols === this.core.mux_cols() && rows === this.core.mux_rows()) return;
161 const p = new Uint8Array(4);
162 const dv = new DataView(p.buffer);
163 dv.setUint16(0, cols, true);
164 dv.setUint16(2, rows, true);
165 this.sendFrame(MSG.resize, p);
166 }
167 zoomCols() { return Math.max(2, Math.floor(window.innerWidth / METRICS.w)); }
168 zoomRows() { return Math.max(2, Math.floor((window.innerHeight - 26) / METRICS.h)); }
169
170 // --- wire in ---
171 onMessage(bytes) {
172 if (bytes.length < 1) return;
173 if (bytes[0] === ENV_CONTROL) {
174 const { state } = JSON.parse(new TextDecoder().decode(bytes.subarray(1)));
175 this.setBadge(state, state);
176 if (state === 'up') this.sendAttach(false); // browser owns re-attach
177 return;
178 }
179 if (bytes[0] !== ENV_FRAME || bytes.length < 6) return;
180 const type = bytes[1];
181 const len = new DataView(bytes.buffer, bytes.byteOffset).getUint32(2, true);
182 if (6 + len !== bytes.length) return;
183 const payload = bytes.subarray(6);
184
185 switch (type) {
186 case MSG.snapshot:
187 case MSG.delta: {
188 if (!this.stage(payload)) return;
189 const r = this.core.mux_apply_frame(type, payload.length);
190 if (r === 0) {
191 this.gotState = true;
192 if (this.badgeIs('full')) this.setBadge('up', 'up');
193 if (this.scrollPages === 0) this.paintLive();
194 return;
195 }
196 if (r === 1) this.sendAttach(true); // RESYNC: quote (0,0)
197 return;
198 }
199 case MSG.exit_status: {
200 // Before any state this is the daemon refusing the attach
201 // (session full) — the CLI's own discriminator, mirrored.
202 if (!this.gotState) this.setBadge('full', 'session full');
203 else this.setBadge('exited', `exited ${payload[0] ?? 0}`);
204 return;
205 }
206 case MSG.scrollback_chunk: {
207 if (this.scrollPages === 0 || payload.length < 6) return;
208 const rows = payload.subarray(6); // echoed start+count stripped
209 if (!this.stage(rows)) return;
210 if (this.core.mux_scroll_feed(rows.length) === 0) this.paintScroll();
211 return;
212 }
213 case MSG.pty_mode: // no prediction in the web client (spec non-goal)
214 default:
215 return;
216 }
217 }
218
219 // --- painting ---
220 sizeCanvas() {
221 const cols = this.core.mux_cols(), rows = this.core.mux_rows();
222 const w = cols * METRICS.w, h = rows * METRICS.h;
223 if (this.canvas.width !== w || this.canvas.height !== h) {
224 this.canvas.width = w;
225 this.canvas.height = h;
226 this.core.mux_mark_all_dirty();
227 }
228 }
229 paintLive() {
230 this.sizeCanvas();
231 const n = this.core.mux_read_viewport();
232 for (let i = 0; i < n; i++) this.paintRow(this.core.mux_dirty_row(i));
233 this.paintCursor();
234 }
235 paintScroll() {
236 this.sizeCanvas();
237 this.core.mux_read_scroll_viewport();
238 const rows = this.core.mux_rows();
239 for (let y = 0; y < rows; y++) this.paintRow(y);
240 }
241 paintRow(y) {
242 const cols = this.core.mux_cols();
243 const base = this.core.mux_viewport_ptr() + y * cols * 16;
244 // Fresh view per row paint; see the header discipline.
245 const cells = new DataView(this.core.memory.buffer, base, cols * 16);
246 const ctx = this.ctx;
247 ctx.font = FONT;
248 ctx.textBaseline = 'alphabetic';
249 for (let x = 0; x < cols; x++) {
250 const cp = cells.getUint32(x * 16, true);
251 const fg = cells.getUint32(x * 16 + 4, true);
252 const bg = cells.getUint32(x * 16 + 8, true);
253 const flags = cells.getUint32(x * 16 + 12, true);
254 if (flags & (1 << 17)) continue; // spacer: the wide cell painted it
255 const wide = (flags & (1 << 16)) !== 0;
256 const inverse = (flags & (1 << 4)) !== 0;
257 let fgC = colorOf(fg, DEFAULT_FG), bgC = colorOf(bg, DEFAULT_BG);
258 if (inverse) { const t = fgC; fgC = bgC; bgC = t; }
259 const px = x * METRICS.w, py = y * METRICS.h, cw = METRICS.w * (wide ? 2 : 1);
260 ctx.fillStyle = bgC;
261 ctx.fillRect(px, py, cw, METRICS.h);
262 if (cp !== 0 && cp !== 32) {
263 const bold = (flags & 1) !== 0, faint = (flags & 4) !== 0, italic = (flags & 2) !== 0;
264 ctx.fillStyle = fgC;
265 ctx.globalAlpha = faint ? 0.6 : 1;
266 ctx.font = `${italic ? 'italic ' : ''}${bold ? 'bold ' : ''}${FONT}`;
267 ctx.fillText(String.fromCodePoint(cp), px, py + METRICS.ascent);
268 ctx.globalAlpha = 1;
269 ctx.font = FONT;
270 }
271 const ustyle = (flags >> 8) & 7;
272 if (ustyle !== 0) {
273 ctx.fillStyle = fgC;
274 ctx.fillRect(px, py + METRICS.h - 2, cw, ustyle === 2 ? 2 : 1); // double→thick
275 }
276 if (flags & (1 << 6)) { // strikethrough
277 ctx.fillStyle = fgC;
278 ctx.fillRect(px, py + (METRICS.h >> 1), cw, 1);
279 }
280 }
281 }
282 paintCursor() {
283 if (this.scrollPages !== 0) return;
284 const x = this.core.mux_cursor_x(), y = this.core.mux_cursor_y();
285 this.ctx.fillStyle = '#c8ccd488';
286 this.ctx.fillRect(x * METRICS.w, y * METRICS.h, METRICS.w, METRICS.h);
287 }
288
289 // --- scrollback ---
290 onWheel(ev) {
291 const rows = this.core.mux_rows();
292 const hist = this.core.mux_history_rows();
293 if (ev.deltaY < 0) {
294 const maxPages = Math.ceil(hist / rows);
295 if (this.scrollPages < maxPages) this.scrollPages++;
296 else return;
297 } else {
298 if (this.scrollPages === 0) return;
299 this.scrollPages--;
300 }
301 if (this.scrollPages === 0) {
302 this.setBadge('up', 'up');
303 this.core.mux_mark_all_dirty();
304 this.paintLive();
305 return;
306 }
307 this.setBadge('scroll', `history -${this.scrollPages}`);
308 const start = this.core.mux_scroll_start(this.scrollPages, rows);
309 const p = new Uint8Array(6);
310 const dv = new DataView(p.buffer);
311 dv.setUint32(0, start, true);
312 dv.setUint16(4, rows, true);
313 this.sendFrame(MSG.fetch_scrollback, p);
314 }
315
316 exitScroll() {
317 if (this.scrollPages === 0) return;
318 this.scrollPages = 0;
319 this.setBadge('up', 'up');
320 this.core.mux_mark_all_dirty();
321 this.paintLive();
322 }
323
324 // --- chrome ---
325 setBadge(cls, text) {
326 const b = this.el.querySelector('.badge');
327 b.className = `badge ${cls}`;
328 b.textContent = text;
329 }
330 badgeIs(cls) { return this.el.querySelector('.badge').classList.contains(cls); }
331 }
332
333 // --- zoom / focus ---
334 let zoomedTile = null;
335 const shade = document.getElementById('shade');
336 const ime = document.getElementById('ime');
337
338 function zoom(tile) {
339 if (zoomedTile) unzoom();
340 zoomedTile = tile;
341 tile.zoomed = true;
342 tile.el.classList.add('zoomed');
343 shade.classList.add('on');
344 ime.focus();
345 tile.sendResizeIfDiffers();
346 }
347 function unzoom() {
348 if (!zoomedTile) return;
349 zoomedTile.exitScroll();
350 zoomedTile.zoomed = false;
351 zoomedTile.el.classList.remove('zoomed');
352 zoomedTile = null;
353 shade.classList.remove('on');
354 ime.blur();
355 // Unzoom sends NOTHING: the session stays attached, the grid stays
356 // where it is (spec). The tile keeps painting as a wall tile.
357 }
358 shade.addEventListener('click', unzoom);
359
360 // Keys go ONLY to the zoomed tile — no zoom, no bytes (spec).
361 document.addEventListener('keydown', (ev) => {
362 const t = zoomedTile;
363 if (!t) return;
364 if (ev.isComposing) return; // IME owns it; compositionend delivers
365 const mods = (ev.shiftKey ? 1 : 0) | (ev.altKey ? 2 : 0) | (ev.ctrlKey ? 4 : 0);
366 // Leave genuine browser chords alone (copy/paste arrive as events).
367 if (ev.ctrlKey && ev.shiftKey && (ev.key.toLowerCase() === 'c' || ev.key.toLowerCase() === 'v')) return;
368 if (t.scrollPages > 0 && ev.key !== 'PageUp' && ev.key !== 'PageDown') {
369 t.exitScroll(); // any other key leaves scroll mode, swallowed (CLI rule)
370 ev.preventDefault();
371 return;
372 }
373 if (KEY[ev.key] !== undefined && ev.key !== 'char') {
374 ev.preventDefault();
375 t.sendKey(KEY[ev.key], 0, mods);
376 return;
377 }
378 if ([...ev.key].length === 1) {
379 ev.preventDefault();
380 t.sendKey(KEY.char, ev.key.codePointAt(0), mods);
381 }
382 });
383 document.addEventListener('paste', (ev) => {
384 if (!zoomedTile) return;
385 ev.preventDefault();
386 const text = ev.clipboardData?.getData('text');
387 if (text) zoomedTile.sendPaste(text);
388 });
389 ime.addEventListener('compositionend', (ev) => {
390 if (zoomedTile && ev.data) zoomedTile.sendPaste(ev.data);
391 ime.value = '';
392 });
393 window.addEventListener('resize', () => zoomedTile?.sendResizeIfDiffers());
394
395 // --- boot ---
396 (async function boot() {
397 compiledCore = await WebAssembly.compileStreaming(fetch('/mux_core.wasm'));
398 const labels = await (await fetch('/tiles')).json();
399 const wall = document.getElementById('wall');
400 for (let i = 0; i < labels.length; i++) {
401 const tile = new Tile(i, labels[i], wall);
402 tile.start();
403 }
404 })();