66693602c3
Self-review against the code-review skill, and it is gotcha 48's own third lesson turning up in the change that cites it: "when a fact is stated in more than one place, changing it is a grep, not an edit." `context/code/clients.md` said `#status` composes two facts in three further places -- the shell.js inventory, the transports section and the two-numbers section -- all still describing the `.` join this branch deletes. A reader arriving at any of them would have been told the opposite of what the code does. The minibuffer section also predated `#bottom-dock` and said nothing about what happens when a toast and the prompt coincide. Also drops `createToasts().showing()`, which nothing calls. It was speculative API on a single-use object (CLAUDE.md section 2), and an unexercised accessor on a timer is one more thing that can be wrong. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
382 lines
14 KiB
JavaScript
382 lines
14 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}`;
|
|
}
|
|
|
|
/** How long a transient message stays on screen. Long enough to read a
|
|
* sentence, short enough that it is gone before it is furniture. */
|
|
export const TOAST_MS = 6000;
|
|
|
|
/** `ViewState::status_msg` is STATE, not an event: a repaint re-offers the
|
|
* string that is already on screen, so dismissal keys off message IDENTITY
|
|
* and never off a timer alone. What that costs: context/code/clients.md. */
|
|
export function createToasts({
|
|
onShow,
|
|
onHide,
|
|
ttlMs = TOAST_MS,
|
|
setTimer = setTimeout,
|
|
clearTimer = clearTimeout,
|
|
}) {
|
|
// What was last ACCEPTED, kept after the toast hides. That is the whole
|
|
// mechanism: a repaint offering it again is recognised and dropped.
|
|
let offered = "";
|
|
let timer = null;
|
|
|
|
return {
|
|
offer(text) {
|
|
const next = text ?? "";
|
|
if (next === offered) return;
|
|
offered = next;
|
|
clearTimer(timer);
|
|
timer = null;
|
|
if (next === "") {
|
|
onHide();
|
|
return;
|
|
}
|
|
onShow(next);
|
|
timer = setTimer(() => {
|
|
timer = null;
|
|
onHide();
|
|
}, ttlMs);
|
|
},
|
|
};
|
|
}
|
|
|
|
/** 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, index) => ({
|
|
index,
|
|
// 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 items a menu offers, RELAYED from `cycle` — the one home of the
|
|
// Page/Filter split, so a Filter axis offers none (improvise-rw6l).
|
|
items: axis.cycle
|
|
? axis.cycle.items.map((label, item) => ({
|
|
item,
|
|
label,
|
|
selected: item === axis.cycle.at,
|
|
}))
|
|
: [],
|
|
}));
|
|
}
|
|
|
|
/** The down-arrow. Present exactly where there is a menu to open, which is
|
|
* `navigable` and never "is this on the page bar" (gotcha 27). */
|
|
export const MENU_ARROW = "▾";
|
|
|
|
/** What a delegated click on the page bar or its menu MEANS. `null` where the
|
|
* node names neither, which is every gap between the chips. */
|
|
export function pageItemAction(dataset) {
|
|
const axis = whole(dataset.axis);
|
|
if (axis === null) return null;
|
|
const item = whole(dataset.item);
|
|
// A chip carries an axis and no item: it OPENS the menu. A menu row carries
|
|
// both: it picks. One attribute apart, so nothing infers from structure.
|
|
return item === null ? { kind: "open", axis } : { kind: "pick", axis, item };
|
|
}
|
|
|
|
/** What a key does while the page menu is open. The menu owns the keyboard
|
|
* exactly as the inline editor does — `context/code/clients.md`. */
|
|
export function menuKey(key, { count, active }) {
|
|
if (count === 0) return { kind: "close" };
|
|
switch (key) {
|
|
case "Escape":
|
|
case "Tab":
|
|
return { kind: "close" };
|
|
case "Enter":
|
|
case " ":
|
|
return { kind: "pick", item: active };
|
|
case "ArrowDown":
|
|
case "j":
|
|
return { kind: "move", item: (active + 1) % count };
|
|
case "ArrowUp":
|
|
case "k":
|
|
return { kind: "move", item: (active - 1 + count) % count };
|
|
case "Home":
|
|
return { kind: "move", item: 0 };
|
|
case "End":
|
|
return { kind: "move", item: count - 1 };
|
|
// Everything else is SWALLOWED rather than passed on: a menu that let
|
|
// `hjkl` reach the grid would scroll the thing it is anchored to.
|
|
default:
|
|
return { kind: "ignore" };
|
|
}
|
|
}
|
|
|
|
/** 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*.
|
|
*
|
|
* The two facts go to two SINKS and are never joined: a repaint reaches
|
|
* `onModel` and cannot address `onShell` at all. That is the structural form
|
|
* of the rule the `·` join used to keep by convention — context/code/clients.md. */
|
|
export function createShell({
|
|
onShell,
|
|
onModel,
|
|
timeoutMs,
|
|
setTimer = setTimeout,
|
|
clearTimer = clearTimeout,
|
|
}) {
|
|
let watchdog = null;
|
|
let arms = 0;
|
|
|
|
return {
|
|
setShell(text) {
|
|
onShell(text);
|
|
},
|
|
setModel(text) {
|
|
onModel(text);
|
|
},
|
|
/** 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,
|
|
};
|
|
}
|