From 1097e306ffb57aeac5c8a2d5c3c3492768a24b04 Mon Sep 17 00:00:00 2001 From: Jian Gong Date: Sun, 27 Sep 2026 22:10:31 +0800 Subject: [PATCH 1/3] fix(gui): contain usage table captions within their scrollport Absolutely positioned screen-reader captions escaped the capped table's overflow and enlarged the outer document, allowing scrolling into blank space after the Usage report ended. Establish the table scrollport as their containing block and cover long and short tables at desktop and mobile widths. --- gui/src/styles-usage-workspace.css | 2 + gui/tests/usage-scroll-browser.ts | 90 ++++++++++++++++++++++++++++++ structure/dashboard-and-usage.md | 2 +- 3 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 gui/tests/usage-scroll-browser.ts diff --git a/gui/src/styles-usage-workspace.css b/gui/src/styles-usage-workspace.css index 91485e1a21..0acad7cf26 100644 --- a/gui/src/styles-usage-workspace.css +++ b/gui/src/styles-usage-workspace.css @@ -147,6 +147,8 @@ } .usw-section .tbl-wrap { + /* Keep absolute screen-reader captions inside the table's scrollable overflow. */ + position: relative; min-width: 0; /* Inset table from border+surface so cells aren't flush to the frame. */ padding: var(--space-3); diff --git a/gui/tests/usage-scroll-browser.ts b/gui/tests/usage-scroll-browser.ts new file mode 100644 index 0000000000..52452c5fe5 --- /dev/null +++ b/gui/tests/usage-scroll-browser.ts @@ -0,0 +1,90 @@ +/** Rendered regression for Usage's nested table scroller and screen-reader captions. + * Run with an active browser-skill Agent Window: BSK_SESSION= bun tests/usage-scroll-browser.ts + * Uses the real source stylesheets and synthetic rows; never connects to a user's proxy. + * Unlike happy-dom, the browser measures overflow from absolutely positioned descendants. + */ +import { resolve, sep } from "node:path"; + +const session = process.env.BSK_SESSION; +if (!session) throw new Error("Set BSK_SESSION to an active browser-skill Agent Window session."); +const src = resolve(import.meta.dir, "../src"); +const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + const url = new URL(request.url); + if (url.pathname === "/") return new Response("Usage scroll regression", { + headers: { "content-type": "text/html" }, + }); + if (url.pathname === "/fixture") { + const count = url.searchParams.get("rows") === "long" ? 40 : 2; + const rows = Array.from({ length: count }, (_, i) => ` + model-${i}ProviderShare + ${"12345".repeat(8)} + 94%Cache detail coverage for model ${i} + `).join(""); + return new Response(` +
+
+
+

Models

+ ${"".repeat(12)}${rows}
Column
+

Coverage

+
`, { headers: { "content-type": "text/html" } }); + } + const filePath = resolve(src, `.${url.pathname}`); + if (filePath.startsWith(`${src}${sep}`) && filePath.endsWith(".css")) { + const file = Bun.file(filePath); + if (await file.exists()) return new Response(file); + } + return new Response("Not found", { status: 404 }); + }, +}); + +async function browser(command: string, ...args: string[]) { + const process = Bun.spawn(["bsk", command, ...args, "--session", session!], { stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(process.stdout).text(), new Response(process.stderr).text(), process.exited, + ]); + if (exitCode !== 0) throw new Error(stderr || stdout); + return stdout; +} + +try { + await browser("navigate", server.url.href); + const results = JSON.parse(await browser("evaluate", `(async () => { + const results = []; + for (const width of [1280, 390]) for (const rows of ['short', 'long']) { + const frame = document.createElement('iframe'); + frame.style.cssText = 'width:' + width + 'px;height:750px;border:0'; + frame.src = '/fixture?rows=' + rows; + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Fixture load timed out')), 5000); + frame.onload = () => { clearTimeout(timeout); resolve(); }; + document.body.append(frame); + }); + const d = frame.contentDocument, w = frame.contentWindow; + const main = d.querySelector('main'), wrap = d.querySelector('.tbl-wrap'); + // A short report may naturally end above the viewport bottom; only scrolling + // beyond that initial end (or the viewport for long reports) is blank overflow. + const expectedBottom = Math.min(w.innerHeight, main.getBoundingClientRect().bottom); + d.body.scrollTop = d.body.scrollHeight; + d.documentElement.scrollTop = d.documentElement.scrollHeight; + const blankBeyondMain = Math.max(0, expectedBottom - main.getBoundingClientRect().bottom); + wrap.scrollTop = wrap.scrollHeight; + const lastRow = d.querySelector('tbody tr:last-child').getBoundingClientRect(); + const bounds = wrap.getBoundingClientRect(); + const lastRowReachable = lastRow.bottom <= bounds.bottom + 1 && lastRow.top >= bounds.top; + const captionsPresent = d.querySelectorAll('.sr-only').length === (rows === 'long' ? 40 : 2); + const tableScrolls = rows !== 'long' || wrap.scrollTop > 0; + results.push({ width, rows, blankBeyondMain, lastRowReachable, captionsPresent, tableScrolls, + pass: blankBeyondMain <= 1 && lastRowReachable && captionsPresent && tableScrolls }); + frame.remove(); + } + return { results, pass: results.every(result => result.pass) }; + })()`)); + console.log(JSON.stringify(results, null, 2)); + if (results.pass !== true) process.exitCode = 1; +} finally { + server.stop(true); +} diff --git a/structure/dashboard-and-usage.md b/structure/dashboard-and-usage.md index 9926f81960..985f82d50f 100644 --- a/structure/dashboard-and-usage.md +++ b/structure/dashboard-and-usage.md @@ -292,7 +292,7 @@ surface filtering. `managementUsageMaxReadBytes` remains a recognized compatibil bounded legacy readers, but it is not an accuracy limit or tuning knob for `GET /api/usage`. A Codex-surface response includes an `accounts` breakdown keyed by stable non-PII `accountLogLabel`; cards join it to the management account DTO for 30-day tokens, API-equivalent cost and coverage. New main-pool rows use `main`; legacy bare `openai` rows remain ambiguous. A missing `usage.jsonl` returns a zeroed summary with 200 because a fresh install has no usage. Unmeasured requests remain distinct from measured zero through `measured / reported / unreported / unsupported / estimated` counts and their coverage totals. -The Usage tab renders that shape and the main Dashboard shows its 30-day summary. The 200-entry in-memory `requestLog` is not the aggregation source; the JSONL ledger is. +The Usage tab renders that shape and the main Dashboard shows its 30-day summary. The 200-entry in-memory `requestLog` is not the aggregation source; the JSONL ledger is. Usage table scrollports in `gui/src/styles-usage-workspace.css` contain absolute screen-reader captions so long tables do not extend the outer document beyond the report; `gui/tests/usage-scroll-browser.ts` measures that boundary and last-row reachability at desktop and mobile widths. Ledger read failures instead return `500 { error: "read_failed" }`. Shared GUI usage admission reads that body before classifying HTTP failure and also rejects the legacy HTTP-200 envelope, so every shared cache retains its last valid report rather than fabricating zero totals. > Decision record: [ADR-0106](decisions/ADR-0106-usage-read-failure-contract.md) From de94b23adb960713b8fdf0c11eca74416594c237 Mon Sep 17 00:00:00 2001 From: Jian Gong Date: Sun, 27 Sep 2026 22:15:24 +0800 Subject: [PATCH 2/3] test(gui): enforce usage scroll containment in CI --- gui/tests/usage-scroll-containment.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 gui/tests/usage-scroll-containment.test.ts diff --git a/gui/tests/usage-scroll-containment.test.ts b/gui/tests/usage-scroll-containment.test.ts new file mode 100644 index 0000000000..155783d984 --- /dev/null +++ b/gui/tests/usage-scroll-containment.test.ts @@ -0,0 +1,10 @@ +import { expect, test } from "bun:test"; +import { effectiveDeclaration, withoutComments } from "./helpers/css-declarations"; + +test("Usage table scrollports contain absolutely positioned accessible captions", async () => { + const css = withoutComments(await Bun.file(new URL("../src/styles-usage-workspace.css", import.meta.url)).text()); + // Source contract only: happy-dom cannot measure overflow. The rendered + // regression in usage-scroll-browser.ts proves the blank-scroll failure. + expect(effectiveDeclaration(css, ".usw-section .tbl-wrap", "position")).toBe("relative"); + expect(effectiveDeclaration(css, ".usw-section .tbl-wrap", "overflow-y")).toBe("auto"); +}); From bb40ef837d1ad106f78c19eea554262582321f09 Mon Sep 17 00:00:00 2001 From: Jian Gong Date: Sun, 27 Sep 2026 22:48:57 +0800 Subject: [PATCH 3/3] test(gui): restore usage test globals without leaking readonly properties --- gui/tests/usage-custom-range.test.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/gui/tests/usage-custom-range.test.tsx b/gui/tests/usage-custom-range.test.tsx index 8a69858618..4b60483cbd 100644 --- a/gui/tests/usage-custom-range.test.tsx +++ b/gui/tests/usage-custom-range.test.tsx @@ -10,7 +10,7 @@ import Usage from "../src/pages/Usage"; const globals = ["document", "window", "navigator", "localStorage", "sessionStorage", "ResizeObserver", "IS_REACT_ACT_ENVIRONMENT"] as const; const originalFetch = globalThis.fetch; -let previousGlobals: Record<(typeof globals)[number], unknown>; +let previousGlobals: Record<(typeof globals)[number], PropertyDescriptor | undefined>; let testWindow: Window; let root: Root | undefined; let container: HTMLElement; @@ -20,7 +20,7 @@ type RequestGate = { url: string; resolve: (response: Response) => void }; let requests: RequestGate[]; beforeEach(() => { - previousGlobals = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + previousGlobals = Object.fromEntries(globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)])) as typeof previousGlobals; clearClientResourceStoresForTests(); testWindow = new Window({ url: "http://localhost/" }); testWindow.localStorage.setItem("ocx-lang", "en"); @@ -47,7 +47,11 @@ afterEach(async () => { globalThis.fetch = originalFetch; clearClientResourceStoresForTests(); testWindow.close(); - for (const key of globals) Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + for (const key of globals) { + const descriptor = previousGlobals[key]; + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } }); async function mount(connected = false) {