╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ──────────╮ │src/embed/script.js│ ╰───────────────────╯ ╭───────────────────────────────────────────────────────────────────────────╮ │128 }); │ │129 │ │172 - grid.addEventListener("focusin", (event) => { │ │130 + document.addEventListener("focusin", (event) => { │ │131 const t = event.target; │ │132 if (!t || !t.dataset || !t.dataset.focusId) return; │ │133 wasmInstance.exports._onMouseClick(Number(t.dataset.focusId));│ ╰───────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────╮ │134 wasmInstance.exports._tick(minRows(), maxCols()); │ │177 - }); │ │178 - │ │179 - // submit handled on mousedown rather than click because submit-button│ │180 - // spans are tabbable: a mouse click on one moves focus to it, which │ │181 - // fires focusin, which re-renders via innerHTML, which detaches the │ │182 - // very span the click event was about to target. mousedown fires │ │183 - // before the focus change, so the target is guaranteed to be live. │ │184 - grid.addEventListener("mousedown", (event) => { │ │185 - const span = event.target.closest(".clickable"); │ │186 - if (!span || span.dataset.action !== "submit") return; │ │187 - event.preventDefault(); │ │188 - submitForm(span.dataset.url); │ │135 }); │ │136 │ │137 grid.addEventListener("click", (event) => { │ ╰───────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────╮ │138 const span = event.target.closest(".clickable"); │ │139 if (!span) return; │ │194 - // submit is handled in mousedown above │ │195 - if (span.dataset.action === "submit") { │ │196 - event.preventDefault(); │ │197 - return; │ │198 - } │ │140 const focusId = Number(span.dataset.focusId); │ │141 wasmInstance.exports._onMouseClick(focusId); │ │201 - // synthesize Enter so the focused widget's "activate" handler fires│ │202 - // (e.g. a button click runs its submit handler). widgets that don't│ │203 - // care about Enter just ignore it. │ │204 - wasmInstance.exports._onKeyDown(13); │ │142 wasmInstance.exports._tick(minRows(), maxCols()); │ │143 }); │ │144 │ ╰─────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ────────╮ │src/main_wasm.zig│ ╰─────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────╮ │15 const html = try web.generateHtml(allocator, root_ptr, &session); │ │16 defer allocator.free(html); │ │17 setHtml(html); │ │18 + │ │19 + // emit the overlay (form + inputs + button) on every tick so the wasm │ │20 + // layout drives positions, not just the server's initial render. JS │ │21 + // diffs the result against the previous overlay; an unchanged overlay │ │22 + // leaves the live <form> alone — crucial since wiping it mid-click │ │23 + // would detach the submit button before the browser can dispatch the │ │24 + // form submission. │ │25 + const overlay = try web.generateOverlay(allocator, root_ptr, &session);│ │26 + defer allocator.free(overlay); │ │27 + setOverlay(overlay); │ │28 } │ │29 │ │30 // the snapshot is allocated into this arena via parseFromSliceLeaky. it │ ╰───────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────╮ │ 98 _setHtml(arg.ptr, @intCast(arg.len)); │ │ 99 } │ │100 │ │101 +fn setOverlay(arg: []const u8) void { │ │102 + _setOverlay(arg.ptr, @intCast(arg.len)); │ │103 +} │ │104 + │ │105 extern fn _consoleLog(arg: [*]const u8, len: u32) void; │ │106 extern fn _setHtml(arg: [*]const u8, len: u32) void; │ │107 +extern fn _setOverlay(arg: [*]const u8, len: u32) void; │ │108 │ │109 /// js calls this first to get a wasm pointer it can write the page json into.│ │110 export fn _alloc(len: u32) ?[*]u8 { │ ╰───────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ──╮ │src/web.zig│ ╰───────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────╮ │100 var conn_bw = stream.writer(io, &send_buffer); │ │101 var http_server = std.http.Server.init(&conn_br.interface, &conn_bw.interface); │ │102 │ │103 + // serve multiple requests on a single connection so HTTP/1.1 keep-alive │ │104 + // works — important for browser-native form POST: after the 303 the │ │105 + // browser issues GET / on the same socket, and forcibly closing here │ │106 + // would race the follow-up request into a "site can't be reached". │ │107 while (http_server.reader.state == .ready) { │ │108 var request = http_server.receiveHead() catch |receive_err| switch (receive_err) {│ │109 error.HttpConnectionClosing => break, │ ╰───────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │111 else => |e| return e, │ │112 }; │ │113 │ │110 - handleRequest(io, &http_server, &request, allocator, admin_repo_path) catch |request_err| { │ │114 + handleRequest(io, &request, allocator, admin_repo_path) catch |request_err| { │ │115 try err.print("web ui request failed: {s}\n", .{@errorName(request_err)}); │ │116 try err.flush(); │ │113 - if (http_server.reader.state == .received_head) { │ │114 - http_server.reader.state = .ready; │ │115 - } │ │116 - try writeSimpleResponse(&http_server, 500, "Internal Server Error", "text/plain", @errorName(request_err));│ │117 + // best-effort 500. if handleRequest already started writing a │ │118 + // response before throwing, this respond may fail too, but at │ │119 + // that point the connection is unrecoverable anyway. │ │120 + request.respond(@errorName(request_err), .{ │ │121 + .status = .internal_server_error, │ │122 + .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, │ │123 + }) catch {}; │ │124 }; │ │118 - try http_server.out.flush(); │ │119 - break; │ │125 } │ │126 } │ │127 │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────╮ │128 fn handleRequest( │ │129 io: std.Io, │ │125 - http_server: *std.http.Server, │ │130 request: *std.http.Server.Request,│ │131 allocator: std.mem.Allocator, │ │132 admin_repo_path: []const u8, │ ╰───────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────╮ │136 const path = uri.path.percent_encoded; │ │137 │ │138 if (method == .POST and std.mem.eql(u8, path, "/login")) { │ │135 - return handleLogin(io, http_server, request, allocator, admin_repo_path);│ │139 + return handleLogin(io, request, allocator, admin_repo_path); │ │140 } │ │141 if (method == .POST and std.mem.eql(u8, path, "/logout")) { │ │138 - return handleLogout(http_server, request); │ │142 + return handleLogout(request); │ │143 } │ │144 │ │145 const get_or_head = method == .GET or method == .HEAD; │ ╰──────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │146 if (!get_or_head) { │ │143 - if (http_server.reader.state == .received_head) { │ │144 - http_server.reader.state = .ready; │ │145 - } │ │146 - try writeSimpleResponse(http_server, 405, "Method Not Allowed", "text/plain", "method not allowed");│ │147 + try request.respond("method not allowed", .{ │ │148 + .status = .method_not_allowed, │ │149 + .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, │ │150 + }); │ │151 return; │ │152 } │ │153 │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯