628f43121d
improvise-5jna, improvise-0x9u, improvise-seik — three browser-page defects with one shape: a thing that looks done from every check except running it. improvise-5jna. `doc_name`/`doc_dirty`/`doc_stored_as` were projected by z754 and painted by nothing. `render_surface.test.js` could not see it — it is a SUBSTRING SEARCH over the page's source, so a field named by a function nothing calls reads as painted, which is also how `model.minibuffer` shipped dead. So the guard is a harness assertion that loads the page and reads `#doc-name`, proved by writing it first (`element(s) not found`). The joining is `shell.js::documentTitle`, the TUI's own `name (stored_as) [+]` minus the chrome; the `[+]` is a node test, not a harness one, because this deployment persists on every accepted batch and clears the flag in the same round trip, so the marker is transient. UNPAINTED is now empty. improvise-0x9u. From an EDITING state a cell click did nothing at all: the mousedown moved focus, the input's blur ran `close`, `close` repainted, `replaceChildren` destroyed the `<td>` the mousedown landed on, and no `click` was ever dispatched. The old spec pressed Escape between the two clicks, so it only ever clicked from a non-editing page — this repo's characteristic defect. Worth recording: moving to `mousedown` is NECESSARY AND NOT SUFFICIENT, and the first attempt failed the same assertion with the same symptom. Removing a focused `<input>` fires `blur` SYNCHRONOUSLY, so `close` rebuilt the table from inside itself and detached the `<td>` again. `close` therefore no longer repaints, the `blur` arm checks `closed` first, and the handler `preventDefault()`s so no focus change happens at all — otherwise the default action focuses `#grid` after `input.focus()` and blurs the new editor. `close` restores the DRAWN text, since nothing repaints behind it any more. improvise-seik. `Client::viewport` was a wasm-bindgen door with no caller, kept for a reason `web/app.js` falsified in the same wave. It is one letter from `set_viewport` and fails SILENTLY — identical frame, forgotten window, every cursor key an upstream black hole. Deleted, along with the exported `init()` free function that duplicated `new Client()`. The guard is not the deletion: `wasm_surface.test.js` now reads the generated `.d.ts` and requires every declared method to be one `app.js` calls or to be named in `UNCALLED` with its reason. Proved by injecting a spare `static viewport(...)` into the built `.d.ts` and watching it fail by name. Tests: 1405 Rust (unchanged), 27 node + 4 bundle (was 26 + 3), 24 chromium harness (was 23). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
161 lines
6.0 KiB
JavaScript
161 lines
6.0 KiB
JavaScript
// Everything `web/app.js` decides that is not painting: no DOM, no browser
|
|
// API, one `node --test` away. What belongs here: context/code/clients.md.
|
|
|
|
/** The websocket URL for `?ws=`, defaulting to TLS. Why, and when an explicit
|
|
* `ws://` is upgraded rather than honoured: context/code/clients.md. */
|
|
export function socketUrl(peer, pageProtocol) {
|
|
const secure = pageProtocol === "https:";
|
|
if (/^wss:\/\//i.test(peer)) return peer;
|
|
if (/^ws:\/\//i.test(peer)) return secure ? peer.replace(/^ws:/i, "wss:") : peer;
|
|
return `${secure ? "wss" : "ws"}://${peer}`;
|
|
}
|
|
|
|
/** `#status` carries two independent facts, composed rather than overwritten:
|
|
* a repaint must not quietly erase a connection error. */
|
|
export function composeStatus(shell, model) {
|
|
return [shell, model].filter(Boolean).join(" · ");
|
|
}
|
|
|
|
/** The title bar's one line, in the order and spelling `src/draw.rs::draw_title`
|
|
* uses. The three facts are the host's; only the joining is here (clients.md). */
|
|
export function documentTitle(name, dirty, storedAs) {
|
|
return `${name}${storedAs ? ` (${storedAs})` : ""}${dirty ? " [+]" : ""}`;
|
|
}
|
|
|
|
/** The collapse glyph. `collapsed` is the fact, decided by the view and
|
|
* carried on the frame; only this spelling is the page's (gotcha 26). */
|
|
export function indicator(collapsed) {
|
|
return collapsed ? "▶" : "▼";
|
|
}
|
|
|
|
// 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.
|
|
export const ROW_PX = 26;
|
|
export const COL_PX = 110;
|
|
|
|
/** How long a peer has to answer the handshake. Exported because the harness
|
|
* must jump *past* it: a spec with its own copy can understate it silently
|
|
* and every watchdog assertion then passes vacuously (clients.md). */
|
|
export const HANDSHAKE_TIMEOUT_MS = 8000;
|
|
|
|
/** The extent a box of this size can show, in whole DATA cells: the frame is
|
|
* charged for first, and at least one cell is always asked for. */
|
|
export function extentOf(box, frameCost, metrics = { rowPx: ROW_PX, colPx: COL_PX }) {
|
|
return {
|
|
rows: Math.max(1, Math.floor(box.height / metrics.rowPx) - frameCost.rows),
|
|
cols: Math.max(1, Math.floor(box.width / metrics.colPx) - frameCost.cols),
|
|
};
|
|
}
|
|
|
|
/** What the grid's own frame costs, in whole cells — measured off the painted
|
|
* model, never predicted. Inline group ROWS are deliberately not counted. */
|
|
export function frameCostOf(model) {
|
|
return {
|
|
rows: model.col_levels.length + (model.col_groups.length > 0 ? 1 : 0),
|
|
cols: model.row_levels,
|
|
};
|
|
}
|
|
|
|
/** The grid, as a description: every decision made, nothing created. The page
|
|
* decides nothing here and neither does this — which labels repeat, how many
|
|
* header levels there are and which columns share a group all arrive
|
|
* answered, by the same `AxisFrame` the TUI widget reads (improvise-anr9). */
|
|
export function gridPlan(model) {
|
|
const cornerSpan = model.row_levels;
|
|
const groups = model.col_groups.map((group) => ({
|
|
span: group.span,
|
|
// `label` is null for a run in no group; an empty STRING is a legal group
|
|
// name, so this is a null check and not a truthiness test.
|
|
text: group.label === null ? "" : `${indicator(group.collapsed)} ${group.label}`,
|
|
}));
|
|
|
|
// The cell coordinates a click sends are DATA row indices and a group header
|
|
// is not a data row, so this counts rather than trusting the row's position.
|
|
let dataRow = -1;
|
|
const body = model.rows.map((row) => {
|
|
if (row.kind === "group") {
|
|
return {
|
|
kind: "group",
|
|
colSpan: model.row_levels + model.col_levels[0].length,
|
|
text: `${indicator(row.collapsed)} ${row.label}`,
|
|
};
|
|
}
|
|
dataRow += 1;
|
|
return {
|
|
kind: "data",
|
|
row: dataRow,
|
|
labels: row.labels,
|
|
cells: row.cells.map((cell, col) => {
|
|
// A keyboard edit is open when `model.editing` is a string, and it
|
|
// holds what has been typed. Both facts are decided in Rust.
|
|
const typing = cell.selected && typeof model.editing === "string";
|
|
return {
|
|
row: dataRow,
|
|
col,
|
|
text: typing ? `${model.editing}▏` : cell.text,
|
|
editText: cell.edit_text,
|
|
editable: cell.editable,
|
|
classes: {
|
|
numeric: cell.numeric && !typing,
|
|
error: cell.error,
|
|
selected: cell.selected,
|
|
typing,
|
|
// `editable` is the server's answer relayed through Rust: a stored
|
|
// cell. Drill is not on the wire, so an aggregate is not offered.
|
|
editable: cell.editable,
|
|
},
|
|
};
|
|
}),
|
|
};
|
|
});
|
|
|
|
return { cornerSpan, groups, levels: model.col_levels, body };
|
|
}
|
|
|
|
/** The page's two status facts and the handshake watchdog, which share a home
|
|
* because the one rule between them is that a *failure disarms the timer*. */
|
|
export function createShell({ onStatus, timeoutMs, setTimer = setTimeout, clearTimer = clearTimeout }) {
|
|
let shell = "";
|
|
let model = "";
|
|
let watchdog = null;
|
|
let arms = 0;
|
|
const paint = () => onStatus(composeStatus(shell, model));
|
|
|
|
return {
|
|
setShell(text) {
|
|
shell = text;
|
|
paint();
|
|
},
|
|
setModel(text) {
|
|
model = text;
|
|
paint();
|
|
},
|
|
/** A peer that never answers the handshake. Deliberately about the *first
|
|
* frame*, not either peer's own failure modes — clients.md. */
|
|
arm(message) {
|
|
arms += 1;
|
|
clearTimer(watchdog);
|
|
watchdog = setTimer(() => {
|
|
watchdog = null;
|
|
this.setShell(message);
|
|
}, timeoutMs);
|
|
},
|
|
/** A frame arrived, so the peer answered: the watchdog has no question
|
|
* left to ask. */
|
|
sawFrame() {
|
|
clearTimer(watchdog);
|
|
watchdog = null;
|
|
},
|
|
/** A peer that failed *loudly* has already said something more precise, so
|
|
* the watchdog must be DISARMED and not merely pre-empted (PR #13). */
|
|
fail(text) {
|
|
this.sawFrame();
|
|
this.setShell(text);
|
|
},
|
|
armed: () => watchdog !== null,
|
|
/** How many times the watchdog has been armed. A test denying its message
|
|
* needs this to be nonzero, or it is denying an unreachable event. */
|
|
timesArmed: () => arms,
|
|
};
|
|
}
|