From 3cc137981f8146afa4c3e65e23122d93beb35d2f 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 91485e1a217..0acad7cf267 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 00000000000..52452c5fe50 --- /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 4a3c0667c74..3c0651f9f7a 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 5557a3aa58892ea2e5288aec2b382d41cbb98deb 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 00000000000..155783d984f --- /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 16cb602b98072acca523ef324ec5c2b9c6d62f48 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 8a698586188..4b60483cbda 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) {