// 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 ? "▶" : "▼"; } /** Where the deployment notice's open/closed state is remembered. Per-viewer * and per-origin by construction: it never reaches Rust and never travels. */ export const NOTICE_KEY = "improvise.notice.open"; /** Whether a visitor who has never answered sees the notice OPEN. It is a * product call and the argument for `false` is in context/code/clients.md. */ export const NOTICE_OPEN_BY_DEFAULT = false; /** What a stored notice state MEANS. Everything that is not one of the two * spellings — absent, empty, a value from an older page, a string a private * window handed back — is "unanswered", which is the default and not an * error: storage is allowed to forget, and a page that treated forgetting as * a third state would flicker between them. */ export function noticeOpen(stored) { if (stored === "open") return true; if (stored === "closed") return false; return NOTICE_OPEN_BY_DEFAULT; } /** …and the inverse, so the two spellings have exactly one home. */ export function noticeState(open) { return open ? "open" : "closed"; } // 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 { // The totals strip is chrome on the same terms as a header level: always // there when there are totals, and never a data row (improvise-p7lh). rows: model.col_levels.length + (model.col_groups.length > 0 ? 1 : 0) + (model.totals.length > 0 ? 1 : 0), cols: model.row_levels, }; } /** The page bar: one entry per category fixing which records are visible. The * Page/Filter split is `cycle`, decided in Rust — gotcha 27, clients.md. */ export function pageEntries(model) { return model.page_coords.map((axis) => ({ // The TUI's own spelling (`src/ui/grid.rs`); only the spelling is local, // exactly as `indicator`'s is. text: `${axis.cat} = ${axis.selected}`, // `cycle` is null for a Filter axis, which constrains without being // navigable: there is no position to show and no key that would move it. position: axis.cycle ? `${axis.cycle.at + 1}/${axis.cycle.count}` : "", navigable: Boolean(axis.cycle), })); } /** 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, }, }; }), }; }); // An EMPTY strip means this grid has no totals — not a row of blanks, and // not "not caught up yet" (gotcha 33). So `null`, not an empty row. const totals = model.totals.length === 0 ? null : { span: cornerSpan, // The SECOND spelling of this word — `src/ui/grid.rs` has the other. // Named as debt rather than shared: context/code/clients.md. label: "Total", cells: model.totals.map((cell) => ({ text: cell.text, classes: { numeric: cell.numeric, error: cell.error }, })), }; return { cornerSpan, groups, levels: model.col_levels, body, totals }; } /** What a data cell carries on its node, so one delegated handler can read * back the answers `gridPlan` already made (improvise-b8ot, clients.md). */ export function cellAttrs(cell) { return { row: String(cell.row), col: String(cell.col), edit: cell.editText }; } /** …and the inverse: what a delegated mousedown MEANS. `null` where the event * did not land on a data cell, which is every header and the corner. */ export function cellAction(dataset, editable) { const row = whole(dataset.row); const col = whole(dataset.col); // A node that carries no coordinates is not a cell: every header and the // corner reach here, and none of them means anything. if (row === null || col === null) return null; return { row, col, editable, editText: dataset.edit ?? "" }; } /** The same, for the surfaces whose rows are named by one index: the panels, * the tile bar and the category tree. `null` where there is no row. */ export function indexAction(dataset) { return whole(dataset.index); } /** A non-negative whole number off an attribute, or `null`. An ABSENT * attribute and the string `""` are both "no index", and `Number` says 0. */ function whole(text) { if (typeof text !== "string" || text.trim() === "") return null; const value = Number(text); return Number.isInteger(value) && value >= 0 ? value : null; } /** What the worker just posted. A protocol frame is a STRING and a shell * notice is an OBJECT; nothing else crosses that port (clients.md). */ export function classifyWorkerMessage(data) { if (typeof data === "string") return { kind: "frame", text: data }; if (data && typeof data === "object" && typeof data.kind === "string") { return { kind: "notice", notice: data }; } return { kind: "unknown", data }; } /** What a boot or save notice READS AS. The kinds are decided in Rust * (`improvise_worker_server::boot`); only the wording is the page's. */ export function noticeText(notice) { const where = notice.document ? ` — ${notice.document}` : ""; const why = notice.detail ? ` (${notice.detail})` : ""; switch (notice.kind) { case "ReadOnly": return `read-only${where}: another tab or window has it open. Close it and reload to edit.`; case "BootFailed": return `the in-page worker could not start${where}${why}`; case "SeedMissing": return `started an EMPTY document${where}: its starting copy could not be fetched${why}`; case "SaveFailed": return `NOT SAVED${where}${why}`; // A kind this page does not know is still a failure, and saying so beats // saying nothing — the reason it carries is the point (improvise-1wft). default: return `the in-page worker reported ${notice.kind}${where}${why}`; } } /** Whether a notice means the session is unusable, as opposed to degraded. * Only `BootFailed` is fatal: read-only, empty and unsaved all still paint. */ export function isFatal(notice) { return notice.kind === "BootFailed"; } /** 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, /** The window this shell was built with. Exposed so a spec can compare the * RUNNING page's value against the one it imported — improvise-669b. */ timeoutMs: () => timeoutMs, /** 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, }; }