8ad5c0806d
Three things the user reported after looking at the deployed page, in the order they cost a visitor something. The deployment notice was ten dense lines of configuration reference — ?doc=, ?seed=, ?ws=, wss:// and reverse proxies — above the tile bar, the category tree and the grid on every visit: about 230px of an 820px window. It is now a <details>, so the collapsing is the BROWSER's (keyboard operable, and correct with app.js absent or still loading) and the only thing the page adds is remembering the answer. It starts COLLAPSED, and collapsing withholds nothing: the one fact a first visitor needs — the document lives in this tab, is saved to this browser's private storage and never leaves the machine — is the <summary>, which never collapses. What a stored value means is a decision and lives in shell.js; touching localStorage is a browser API and stays in app.js, wrapped, with every value that is not one of the two spellings read as "unanswered". The minibuffer was a 14px line in the page's bottom-left corner, up to 800px from the cell being typed into. It is now a raised bar over the grid. The CONTENT is untouched — RenderMinibuffer is built from `state.view.mode.minibuffer()`, the same semantic value the TUI draws — so this is position, weight and contrast, and there is no second derivation of what a prompt says. MinibufColor still resolves here exactly as draw.rs::minibuf_fg resolves it for the terminal. style.css stops being "legibility only": layered surfaces, one accent, a filled badge for the mode, chips for the two axis strips, one selection treatment, tabular numerals in the grid, and a focus ring on the one keyboard surface the page has. The palette is the landing page's, so the two read as one product. Asserted (harness/tests/contract.spec.js, Chromium): the notice starts collapsed and remembers, in BOTH directions across a reload; it opens from the keyboard, which the page's own keydown handler must not claim; the minibuffer is inside the viewport, OVERLAPS #grid — the assertion a static bottom bar cannot satisfy — and is larger than body text. Each was proved discriminating by injecting the fault and watching it fail. NOT asserted, and named as such in context/code/clients.md: colour, contrast, spacing, weight, the grouped and multi-level header DOM, either theme, and the localStorage-throws path. Those are argued and read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
291 lines
12 KiB
JavaScript
291 lines
12 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 ? "▶" : "▼";
|
|
}
|
|
|
|
/** 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,
|
|
};
|
|
}
|