diff --git a/app/src/sim/ui/framelog.ts b/app/src/sim/ui/framelog.ts index a941dff..ed6ea49 100644 --- a/app/src/sim/ui/framelog.ts +++ b/app/src/sim/ui/framelog.ts @@ -1,10 +1,11 @@ /// The frame inspector under the canvas. It merges the frame logs of every live -/// connection into one time-ordered list under a labelled column header — a row -/// per frame: connection (when there is more than one), direction, kind, -/// function, round-trip time (on the reply, from its own request by id), byte -/// length — expanding to the decoded envelope, the framing name, and the raw -/// bytes as hex or text. A gap in *virtual* time over `IDLE_MS` inserts an -/// `idle` separator. Every row kind and every connection is filterable from the +/// connection into one time-ordered `` with a sticky header — a row per +/// frame: connection (when there is more than one), direction, kind, function, +/// round-trip time (on the reply, from its own request by id), byte length. Each +/// frame is its own ``: a summary row that expands (click / Enter) to a +/// detail row with the decoded envelope, the framing name, and the raw bytes as +/// hex or text. A gap in *virtual* time over `IDLE_MS` inserts an `idle` +/// separator row. Every row kind and every connection is filterable from the /// header. A refused connection gets its own row. The pane's height is /// drag-resizable by its top grip. /// @@ -169,28 +170,44 @@ export function frameLog(): FrameLog { clear.textContent = "clear"; head.append(title, kindFilters, connFilters, clockBar, clear); - // column header — same grid as each row's `` (see the CSS) - const cols = document.createElement("div"); - cols.className = "sim-frames-cols"; - for (const [cls, label, hint] of [ - ["frame-conn", "conn", "which connection this frame is on"], - ["frame-seq", "#", "frame number, in the order they crossed the wire"], - ["frame-dir", "direction", "sender → receiver (instance names)"], - ["frame-kind", "kind", "handshake, request, or response"], - ["frame-fn", "function", "the protocol function this frame belongs to"], - ["frame-delta", "rtt", "round-trip time — on the response, measured from its own request"], - ["frame-len", "bytes", "encoded size of the frame on the wire"], - ] as const) { - const s = document.createElement("span"); - s.className = cls; - s.textContent = label; - s.title = hint; - cols.append(s); + // ── the table ─────────────────────────────────────────────────────── + // one real
: a sticky , then a per frame. Fixed + // layout + explicit widths on the narrow columns keep it aligned; the + // `direction` and `function` columns share the slack. + const COLS = [ + ["frame-conn", "conn", "which connection this frame is on", "8rem"], + ["frame-seq", "#", "frame number, in the order they crossed the wire", "3rem"], + ["frame-dir", "direction", "sender → receiver (instance names)", ""], + ["frame-kind", "kind", "handshake, request, or response", "6rem"], + ["frame-fn", "function", "the protocol function this frame belongs to", ""], + ["frame-delta", "rtt", "round-trip time — on the response, from its own request", "5rem"], + ["frame-len", "bytes", "encoded size of the frame on the wire", "4rem"], + ] as const; + + const table = document.createElement("table"); + table.className = "sim-frames-table"; + const thead = document.createElement("thead"); + table.append(thead); + + function renderHead() { + const cols = multi ? COLS : COLS.slice(1); + const tr = document.createElement("tr"); + for (const [cls, label, hint, width] of cols) { + const th = document.createElement("th"); + th.className = cls; + th.textContent = label; + th.title = hint; + if (width) th.style.width = width; + tr.append(th); + } + thead.replaceChildren(tr); } + const colCount = () => (multi ? COLS.length : COLS.length - 1); const list = document.createElement("div"); list.className = "sim-frames-list"; - el.append(grip, head, cols, list); + list.append(table); + el.append(grip, head, list); let sources: LogSource[] = []; let api: FrameSource = { frames: () => [], detail: () => null }; @@ -201,7 +218,9 @@ export function frameLog(): FrameLog { let multi = false; function applyFilter() { - for (const r of list.querySelectorAll(".frame-row, .frame-idle")) { + for (const r of table.querySelectorAll( + "tbody.frame-row, tbody.frame-idle, tbody.frame-refused", + )) { const byKind = hidden.has(r.dataset.kind ?? ""); const byConn = r.dataset.conn ? hiddenConns.has(r.dataset.conn) : false; r.hidden = byKind || byConn; @@ -228,12 +247,14 @@ export function frameLog(): FrameLog { const detail = api.detail(connId, f.seq) ?? { kind: f.kind, framing: "?" }; if (lastAt !== null && f.at - lastAt > IDLE_MS) { - const sep = document.createElement("div"); + const sep = document.createElement("tbody"); sep.className = "frame-idle"; sep.dataset.kind = "idle"; sep.hidden = hidden.has("idle"); - sep.textContent = `⋯ ${humanGap(f.at - lastAt)} idle`; - list.append(sep); + const line = td("frame-idle-line", `⋯ ${humanGap(f.at - lastAt)} idle`); + line.colSpan = colCount(); + sep.append(tr(line)); + table.append(sep); } lastAt = f.at; @@ -248,30 +269,55 @@ export function frameLog(): FrameLog { } const dropped = /dropped/.test(f.fault ?? ""); - const row = document.createElement("details"); - row.className = `frame-row frame-${detail.kind}${multi ? " has-conn" : ""}${ - f.fault ? " frame-faulted" : "" - }${dropped ? " frame-dropped" : ""}`; + const row = document.createElement("tbody"); + row.className = `frame-row frame-${detail.kind}${f.fault ? " frame-faulted" : ""}${ + dropped ? " frame-dropped" : "" + }`; row.dataset.kind = detail.kind; row.dataset.conn = connId; row.hidden = hidden.has(detail.kind) || hiddenConns.has(connId); - const summary = document.createElement("summary"); - const rtt = cell("frame-delta", rttText); + const summary = document.createElement("tr"); + summary.className = "frame-summary"; + summary.tabIndex = 0; + summary.setAttribute("role", "button"); + summary.setAttribute("aria-expanded", "false"); + const rtt = td("frame-delta", rttText); if (rttText) rtt.title = "round-trip time"; const kindText = f.fault ? `${detail.kind} · ${f.fault}` : detail.kind; const cells = [ - cell("frame-seq", String(f.seq).padStart(3, "0")), - cell("frame-dir", `${f.from} → ${f.to}`), - cell("frame-kind", kindText), - cell("frame-fn", detail.fn ?? (detail.err ? `err ${detail.err.ordinal}` : "")), + td("frame-seq", String(f.seq).padStart(3, "0")), + td("frame-dir", `${f.from} → ${f.to}`), + td("frame-kind", kindText), + td("frame-fn", detail.fn ?? (detail.err ? `err ${detail.err.ordinal}` : "")), rtt, - cell("frame-len", `${f.bytes.length} B`), + td("frame-len", `${f.bytes.length} B`), ]; - if (multi) cells.unshift(cell("frame-conn", labelByConn.get(connId) ?? connId)); + if (multi) cells.unshift(td("frame-conn", labelByConn.get(connId) ?? connId)); summary.append(...cells); row.append(summary); + const detailTd = document.createElement("td"); + detailTd.colSpan = colCount(); + const detailRow = tr(detailTd); + detailRow.className = "frame-detail"; + detailRow.hidden = true; + row.append(detailRow); + + const toggle = () => { + const open = detailRow.hidden; + detailRow.hidden = !open; + summary.setAttribute("aria-expanded", String(open)); + row.classList.toggle("open", open); + }; + summary.addEventListener("click", toggle); + summary.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + toggle(); + } + }); + const body = document.createElement("div"); body.className = "frame-body mono"; body.append(kv("framing", detail.framing)); @@ -312,17 +358,22 @@ export function frameLog(): FrameLog { } body.append(rawBtns, ...rawPanes); - row.append(body); - list.append(row); + detailTd.append(body); + table.append(row); list.scrollTop = list.scrollHeight; } function refusedRow(connId: string, reason: string) { - const r = document.createElement("div"); - r.className = "frame-row frame-refused"; - r.dataset.conn = connId; - r.textContent = `${multi ? (labelByConn.get(connId) ?? connId) + " · " : ""}connection refused · ${reason}`; - list.append(r); + const tb = document.createElement("tbody"); + tb.className = "frame-row frame-refused"; + tb.dataset.conn = connId; + const c = td( + "", + `${multi ? (labelByConn.get(connId) ?? connId) + " · " : ""}connection refused · ${reason}`, + ); + c.colSpan = colCount(); + tb.append(tr(c)); + table.append(tb); } // `keepCursor`: wipe the rows on screen but leave `shownByConn` where it is, @@ -331,7 +382,8 @@ export function frameLog(): FrameLog { function resetList(keepCursor = false) { sentAt.clear(); lastAt = null; - list.replaceChildren(); + for (const tb of table.querySelectorAll("tbody")) tb.remove(); + list.querySelector(".frames-empty")?.remove(); if (keepCursor) { for (const s of sources) shownByConn.set(s.connId, api.frames(s.connId).length); } else { @@ -348,14 +400,14 @@ export function frameLog(): FrameLog { sources = next; api = next_api; multi = next.length > 1; - cols.classList.toggle("has-conn", multi); + renderHead(); labelByConn.clear(); for (const s of next) labelByConn.set(s.connId, s.label); renderConnFilters(); if (next.length === 0) { const p = document.createElement("p"); - p.className = "muted pad"; + p.className = "muted pad frames-empty"; p.textContent = "no connection"; list.append(p); return; @@ -489,6 +541,17 @@ function cell(cls: string, text: string): HTMLSpanElement { s.textContent = text; return s; } +function td(cls: string, text: string): HTMLTableCellElement { + const d = document.createElement("td"); + if (cls) d.className = cls; + d.textContent = text; + return d; +} +function tr(...cells: HTMLElement[]): HTMLTableRowElement { + const r = document.createElement("tr"); + r.append(...cells); + return r; +} function kv(k: string, v: string): HTMLElement { const d = document.createElement("div"); d.className = "frame-kv"; diff --git a/app/src/sim/view.test.ts b/app/src/sim/view.test.ts index 20cbdaf..217e0ca 100644 --- a/app/src/sim/view.test.ts +++ b/app/src/sim/view.test.ts @@ -385,6 +385,46 @@ test("polish — a long gap in virtual time inserts a filterable idle separator" sim.destroy(); }); +test("polish — the frame log is a real
whose rows expand in place", async () => { + const sim = createSim(); + document.body.append(sim.el); + sim.setShape(shape()); + const { server, client } = place(sim); + fire(server, "click"); + const cfg = sim.el.querySelector(".behavior-config") as HTMLTextAreaElement; + cfg.value = JSON.stringify({ value: { body: "HI", seq: 1 } }); + fire(cfg, "change"); + fire(client, "click"); + pick(sim.el.querySelector(".connect-sel") as HTMLSelectElement, server.dataset.id!); + await tick(); + fireSend(sim.el, "send"); + await tick(); + + const table = sim.el.querySelector(".sim-frames-list table.sim-frames-table") as HTMLTableElement; + assert.ok(table, "the log renders as a
"); + assert.deepEqual( + [...table.querySelectorAll("thead th")].map((th) => th.textContent), + ["#", "direction", "kind", "function", "rtt", "bytes"], + "a sticky header row, one "); + const summary = row.querySelector("tr.frame-summary") as HTMLElement; + const detail = row.querySelector("tr.frame-detail") as HTMLElement; + assert.equal(summary.querySelectorAll("td").length, 6, "6 cells, aligned to the header"); + assert.ok(detail.hidden, "the detail row starts collapsed"); + assert.equal(summary.getAttribute("aria-expanded"), "false"); + + fire(summary, "click"); + assert.ok(!detail.hidden, "clicking the summary row expands the detail"); + assert.equal(summary.getAttribute("aria-expanded"), "true"); + assert.match(detail.querySelector(".frame-body")!.textContent!, /framing/); + fire(summary, "click"); + assert.ok(detail.hidden, "clicking again collapses it"); + sim.destroy(); +}); + test("2a — fan-out: one server, two clients, two connections; drop one, the other stays live", async () => { const sim = createSim(); document.body.append(sim.el); diff --git a/app/src/style.css b/app/src/style.css index 83fef92..4df67e7 100644 --- a/app/src/style.css +++ b/app/src/style.css @@ -1048,64 +1048,54 @@ label.clock-btn { stroke: var(--err); stroke-dasharray: 3 3; } -/* One grid template shared by the column header and every row's summary. Every - track is rem / fr — no `ch`, `auto` or `max-content`, which would size to each - grid's own content and drift (the header text ≠ the row text, and the header - font is smaller). */ -.sim-frames { - /* tracks include each cell's 1rem of horizontal padding (border-box) */ - --frame-grid: 3rem 10rem 5.5rem 1fr 5rem 4rem; - --frame-grid-conn: 8rem 3rem 10rem 5.5rem 1fr 5rem 4rem; -} - -.sim-frames-cols, -details.frame-row > summary { - display: grid; - grid-template-columns: var(--frame-grid); - align-items: baseline; -} -.sim-frames-cols.has-conn, -details.frame-row.has-conn > summary { - grid-template-columns: var(--frame-grid-conn); -} -.sim-frames-cols:not(.has-conn) .frame-conn { - display: none; +/* the frame log is a real
per column (no conn column for a single wire)", + ); + + const row = table.querySelector("tbody.frame-row") as HTMLElement; + assert.equal(row.tagName, "TBODY", "each frame is its own
: fixed layout keeps the columns aligned, + the sticks while the rows scroll. */ +.sim-frames-table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; + font-size: 0.72rem; } -/* column rules — a border on every cell but the last makes it read as a table */ -.sim-frames-cols > span, -details.frame-row > summary > span { - min-width: 0; +.sim-frames-table th, +.sim-frames-table td { + padding: 0.22rem 0.5rem; + text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - padding: 0.22rem 0.5rem; border-right: 1px solid var(--border); } -.sim-frames-cols > span:last-child, -details.frame-row > summary > span:last-child { +.sim-frames-table th:last-child, +.sim-frames-table td:last-child { border-right: none; } - -.sim-frames-cols { +.sim-frames-table thead th { + position: sticky; + top: 0; + z-index: 1; + font-weight: 400; font-size: 0.6rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); background: var(--bg-2); - border-bottom: 1px solid var(--border); + /* a real border-bottom doesn't stick with border-collapse — paint it */ + box-shadow: inset 0 -1px 0 var(--border); } -.frame-row { +tbody.frame-row { border-bottom: 1px solid var(--border); - font-size: 0.72rem; } -details.frame-row > summary { +tbody.frame-row > tr.frame-summary { cursor: pointer; - list-style: none; } -details.frame-row > summary:hover { +tbody.frame-row > tr.frame-summary:hover { background: #ffffff08; } +tbody.frame-row > tr.frame-detail > td { + padding: 0; +} + .frame-conn { color: var(--accent); opacity: 0.85; @@ -1114,9 +1104,6 @@ details.frame-row > summary:hover { border-left: 1px solid var(--border); padding-left: 0.4rem; } -details.frame-row > summary::-webkit-details-marker { - display: none; -} .frame-row .frame-seq { color: var(--muted); } @@ -1138,7 +1125,7 @@ details.frame-row > summary::-webkit-details-marker { .frame-unknown .frame-kind { color: var(--muted); } -.frame-refused { +.frame-refused td { padding: 0.3rem 0.4rem; color: var(--err); } @@ -1173,7 +1160,10 @@ details.frame-row > summary::-webkit-details-marker { color: var(--muted); font-size: 0.72rem; } -.frame-idle { +tbody.frame-idle > tr > td { + padding: 0; +} +.frame-idle-line { display: flex; align-items: center; gap: 0.6rem; @@ -1182,8 +1172,8 @@ details.frame-row > summary::-webkit-details-marker { color: var(--muted); opacity: 0.75; } -.frame-idle::before, -.frame-idle::after { +.frame-idle-line::before, +.frame-idle-line::after { content: ""; flex: 1; border-top: 1px dashed var(--border);