diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e42dcc2..e04ed1b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,6 +88,8 @@ jobs: - run: python scripts/build_dataset.py --output dist/casebook - run: python scripts/swe_dataset.py --output dist/independent-swe-dataset - run: npm ci + - name: The committed report script matches its TypeScript source + run: npm run check:frontend - run: npx playwright install --with-deps chromium - run: npm run test:site - run: node scripts/check_model_upgrade.cjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ca7628..691c00e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,11 @@ verdict on the first screen, skip link, keyboard sorting, filtering, 320 px reflow, no-JavaScript, print, and browsing from the viewer index. `@axe-core/playwright` 4.13.0 is a pinned dev dependency. +- The report front end is now TypeScript (`frontend/report_enhance.ts`, strict + mode), compiled to the packaged `report_enhance.js`. The compiled file is + committed, so installing EvalArc still needs no Node; CI fails when it is + stale. TypeScript 7.0.2 is a pinned dev dependency. The Python core is + unchanged. ## 0.17.3 — 2026-10-01 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6800c56..410f170 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,7 +3,9 @@ Install with `python -m pip install -e ".[dev]"`, then run `pytest -q`, `ruff check .`, and `ruff format --check .`. Install Node.js to exercise the independent JavaScript support policy; that test is skipped if Node is absent. -CI explicitly provisions Node. See [task authoring](docs/task-authoring.md) for +CI explicitly provisions Node. Report browser code is TypeScript in `frontend/`: +after editing it, run `npm ci`, `npm run build:frontend` and `npm run test:reports`, +and commit the regenerated `src/evalarc/assets/report_enhance.js`. See [task authoring](docs/task-authoring.md) for the current built-in registry and evidence contract. The [workflow guide](docs/workflow.md) covers readiness checks and comparisons. diff --git a/MANIFEST.in b/MANIFEST.in index ba72437..ecf3383 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -16,3 +16,4 @@ recursive-include site *.html *.css *.js *.svg recursive-include scripts *.py *.cjs *.html *.md recursive-include huggingface *.md include package.json package-lock.json +recursive-include frontend *.ts *.json diff --git a/docs/viewer.md b/docs/viewer.md index 41e6535..714a8f7 100644 --- a/docs/viewer.md +++ b/docs/viewer.md @@ -37,6 +37,16 @@ machine, forward the port over SSH instead of exposing it: ssh -L 7576:127.0.0.1:7576 user@remote # then run evalarc view there ``` +## Architecture + +The core (parsing, gates, verification, the viewer server) is Python with no +runtime dependencies, like Inspect AI. The browser code is TypeScript in +`frontend/`, type-checked in strict mode and compiled to +`src/evalarc/assets/report_enhance.js`. The compiled file is committed so +`pip install evalarc` never needs Node; `npm run check:frontend` fails in CI if +it differs from a fresh compile. After editing `frontend/*.ts`, run +`npm run build:frontend` and commit both files. + ## Report pages Reports open with the decision (verdict, reason, next step) and work in light diff --git a/docs/viewer.zh-CN.md b/docs/viewer.zh-CN.md index d6e537e..98b702a 100644 --- a/docs/viewer.zh-CN.md +++ b/docs/viewer.zh-CN.md @@ -20,6 +20,11 @@ evalarc view runs --write-index site/index.html # 为 CI 产物或 Pages 生 的 Host(防 DNS 重绑定)、路径穿越、指向目录外的符号链接和目录列表;只读、无 API。远程机器请用 `ssh -L 7576:127.0.0.1:7576 user@remote` 转发端口,不要直接暴露。 +**架构:** 核心(解析、门禁、复核、查看器服务)是零运行时依赖的 Python,与 Inspect AI 相同;浏览器端 +代码是 `frontend/` 下的 TypeScript,严格模式类型检查后编译为 `src/evalarc/assets/report_enhance.js`。 +编译产物随仓库提交,因此 `pip install evalarc` 不需要 Node;若它与重新编译的结果不一致,CI 中的 +`npm run check:frontend` 会失败。修改 `frontend/*.ts` 后请运行 `npm run build:frontend` 并提交两者。 + **报告页面:** 先给结论(结果、原因、下一步),支持深浅色、手机、400% 缩放、打印和禁用 JavaScript。 启用 JavaScript 时,表头变为排序按钮(WAI-ARIA APG 可排序表格模式),8 行以上的表格带筛选框。 脚本内联并由页面 CSP 中的 SHA-256 哈希放行,不允许其他脚本、网络请求或远程资源。 diff --git a/frontend/report_enhance.ts b/frontend/report_enhance.ts new file mode 100644 index 0000000..50455d9 --- /dev/null +++ b/frontend/report_enhance.ts @@ -0,0 +1,111 @@ +// Progressive enhancement for EvalArc offline reports, compiled to +// src/evalarc/assets/report_enhance.js (committed, so `pip install` needs no Node). +// The Python core inlines the compiled file and allows it by CSP hash; without +// JavaScript every table still renders in full. Sortable columns follow the WAI-ARIA +// APG sortable-table pattern (a button inside each header, aria-sort on the sorted +// column only). Tables with 8+ rows get a labelled row filter with a live count. +// Tables marked data-static are left alone. + +(() => { + type Direction = "ascending" | "descending"; + + const SORTABLE_MIN_ROWS = 3; + const FILTER_MIN_ROWS = 8; + + function leadingNumber(text: string): number { + const match = text.replace(/,/g, "").match(/[-+]?\d*\.?\d+/); + return match ? parseFloat(match[0]) : NaN; + } + + function cellText(row: HTMLTableRowElement, column: number): string { + const cell = row.cells[column]; + return cell ? (cell.textContent ?? "").trim() : ""; + } + + function compare(a: string, b: string): number { + const na = leadingNumber(a); + const nb = leadingNumber(b); + if (!Number.isNaN(na) && !Number.isNaN(nb)) return na - nb; + return a.localeCompare(b, undefined, { numeric: true }); + } + + function makeSortable(body: HTMLTableSectionElement, headers: HTMLTableCellElement[]): void { + headers.forEach((th, column) => { + const label = (th.textContent ?? "").trim(); + if (!label) return; + const button = document.createElement("button"); + button.type = "button"; + const icon = document.createElement("span"); + icon.setAttribute("aria-hidden", "true"); + icon.textContent = "↕"; + button.append(document.createTextNode(`${label} `), icon); + th.textContent = ""; + th.append(button); + button.addEventListener("click", () => { + const direction: Direction = + th.getAttribute("aria-sort") === "ascending" ? "descending" : "ascending"; + for (const other of headers) { + other.removeAttribute("aria-sort"); + const mark = other.querySelector("button span"); + if (mark) mark.textContent = "↕"; + } + th.setAttribute("aria-sort", direction); + icon.textContent = direction === "ascending" ? "▲" : "▼"; + const sorted = Array.from(body.rows).sort((x, y) => { + const order = compare(cellText(x, column), cellText(y, column)); + return direction === "ascending" ? order : -order; + }); + for (const row of sorted) body.append(row); + }); + }); + } + + function addFilter(table: HTMLTableElement, rows: HTMLTableRowElement[], index: number): void { + const id = `filter-${index}`; + const tools = document.createElement("div"); + tools.className = "tools"; + const label = document.createElement("label"); + label.htmlFor = id; + label.textContent = "Filter rows"; + const input = document.createElement("input"); + input.type = "search"; + input.id = id; + input.autocomplete = "off"; + const count = document.createElement("span"); + count.className = "count"; + count.setAttribute("aria-live", "polite"); + const update = (): void => { + const query = input.value.trim().toLowerCase(); + let shown = 0; + for (const row of rows) { + const hit = !query || (row.textContent ?? "").toLowerCase().includes(query); + row.hidden = !hit; + if (hit) shown += 1; + } + count.textContent = `${shown} of ${rows.length} rows`; + }; + input.addEventListener("input", update); + tools.append(label, input, count); + (table.closest(".scroll") ?? table).before(tools); + update(); + } + + function enhance(): void { + let filters = 0; + document.querySelectorAll("main table").forEach((table) => { + const body = table.tBodies[0]; + const head = table.tHead; + if (!body || !head || !head.rows[0]) return; + if (body.rows.length < SORTABLE_MIN_ROWS || table.dataset.static !== undefined) return; + const rows = Array.from(body.rows); + makeSortable(body, Array.from(head.rows[0].cells)); + if (rows.length >= FILTER_MIN_ROWS) { + filters += 1; + addFilter(table, rows, filters); + } + }); + } + + + enhance(); +})(); diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..5e06857 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "preserve", + "moduleDetection": "legacy", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "exactOptionalPropertyTypes": true, + "removeComments": false, + "newLine": "lf", + "outDir": "../src/evalarc/assets", + "rootDir": "." + }, + "files": ["report_enhance.ts"] +} diff --git a/package-lock.json b/package-lock.json index a9a4794..f4e0b16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "0.17.3", "devDependencies": { "@axe-core/playwright": "4.13.0", - "playwright": "1.63.0" + "playwright": "1.63.0", + "typescript": "7.0.2" } }, "node_modules/@axe-core/playwright": { @@ -25,6 +26,346 @@ "playwright-core": ">= 1.0.0" } }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/axe-core": { "version": "4.13.0", "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz", @@ -63,6 +404,41 @@ "engines": { "node": ">=20" } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } } } } diff --git a/package.json b/package.json index 56081d2..d515072 100644 --- a/package.json +++ b/package.json @@ -2,12 +2,16 @@ "name": "evalarc-evidence-site", "version": "0.17.3", "private": true, - "description": "Browser checks for the static EvalArc evidence explorer", + "description": "Browser checks and the TypeScript report front end for EvalArc", "scripts": { - "test:site": "node scripts/check_site.cjs" + "build:frontend": "tsc -p frontend/tsconfig.json", + "check:frontend": "node scripts/check_frontend_build.cjs", + "test:site": "node scripts/check_site.cjs", + "test:reports": "node scripts/check_reports_browser.cjs" }, "devDependencies": { "@axe-core/playwright": "4.13.0", - "playwright": "1.63.0" + "playwright": "1.63.0", + "typescript": "7.0.2" } } diff --git a/scripts/check_frontend_build.cjs b/scripts/check_frontend_build.cjs new file mode 100644 index 0000000..f0b84ff --- /dev/null +++ b/scripts/check_frontend_build.cjs @@ -0,0 +1,24 @@ +// The report enhancement script is written in TypeScript (frontend/) and compiled to +// src/evalarc/assets/report_enhance.js, which is committed so `pip install evalarc` +// never needs Node. Fail when the committed file differs from a fresh compile. +const assert = require("node:assert/strict"); +const { execFileSync } = require("node:child_process"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const root = path.resolve(__dirname, ".."); +const committed = path.join(root, "src/evalarc/assets/report_enhance.js"); +const out = fs.mkdtempSync(path.join(os.tmpdir(), "evalarc-frontend-")); +try { + const tsc = path.join(root, "node_modules/.bin", process.platform === "win32" ? "tsc.cmd" : "tsc"); + execFileSync(tsc, ["-p", path.join(root, "frontend/tsconfig.json"), "--outDir", out], + { stdio: "inherit" }); + const fresh = fs.readFileSync(path.join(out, "report_enhance.js"), "utf8"); + assert.equal(fs.readFileSync(committed, "utf8"), fresh, + "src/evalarc/assets/report_enhance.js is stale; run `npm run build:frontend` and commit it"); + assert(!/^\s*(import|export)\s/m.test(fresh), "the compiled script must stay a classic script"); + console.log("Compiled frontend matches the committed asset."); +} finally { + fs.rmSync(out, { recursive: true, force: true }); +} diff --git a/src/evalarc/assets/report_enhance.js b/src/evalarc/assets/report_enhance.js index 3d1b119..ab2eb6b 100644 --- a/src/evalarc/assets/report_enhance.js +++ b/src/evalarc/assets/report_enhance.js @@ -1,78 +1,107 @@ -// Progressive enhancement for EvalArc offline reports. Inlined into every page and -// allowed by a CSP hash; without JavaScript every table still renders in full. -// Sortable columns follow the WAI-ARIA APG sortable table pattern (a button inside -// each header, aria-sort on the sorted column only). Tables with 8+ rows get a -// labelled row filter with a live count. Tables marked data-static are left alone. +"use strict"; +// Progressive enhancement for EvalArc offline reports, compiled to +// src/evalarc/assets/report_enhance.js (committed, so `pip install` needs no Node). +// The Python core inlines the compiled file and allows it by CSP hash; without +// JavaScript every table still renders in full. Sortable columns follow the WAI-ARIA +// APG sortable-table pattern (a button inside each header, aria-sort on the sorted +// column only). Tables with 8+ rows get a labelled row filter with a live count. +// Tables marked data-static are left alone. (() => { - const number = text => { - const match = text.replace(/,/g, "").match(/[-+]?\d*\.?\d+/); - return match ? parseFloat(match[0]) : NaN; - }; - let filters = 0; - document.querySelectorAll("main table").forEach(table => { - const body = table.tBodies[0]; - const head = table.tHead; - if (!body || !head || body.rows.length < 3 || table.dataset.static !== undefined) return; - const rows = [...body.rows]; - const headers = [...head.rows[0].cells]; - headers.forEach((th, column) => { - const label = th.textContent.trim(); - if (!label) return; - const button = document.createElement("button"); - button.type = "button"; - const icon = document.createElement("span"); - icon.setAttribute("aria-hidden", "true"); - icon.textContent = "↕"; - button.append(document.createTextNode(label + " "), icon); - th.textContent = ""; - th.append(button); - button.addEventListener("click", () => { - const direction = th.getAttribute("aria-sort") === "ascending" ? "descending" : "ascending"; - headers.forEach(other => { - other.removeAttribute("aria-sort"); - const mark = other.querySelector("button span"); - if (mark) mark.textContent = "↕"; + const SORTABLE_MIN_ROWS = 3; + const FILTER_MIN_ROWS = 8; + function leadingNumber(text) { + const match = text.replace(/,/g, "").match(/[-+]?\d*\.?\d+/); + return match ? parseFloat(match[0]) : NaN; + } + function cellText(row, column) { + const cell = row.cells[column]; + return cell ? (cell.textContent ?? "").trim() : ""; + } + function compare(a, b) { + const na = leadingNumber(a); + const nb = leadingNumber(b); + if (!Number.isNaN(na) && !Number.isNaN(nb)) + return na - nb; + return a.localeCompare(b, undefined, { numeric: true }); + } + function makeSortable(body, headers) { + headers.forEach((th, column) => { + const label = (th.textContent ?? "").trim(); + if (!label) + return; + const button = document.createElement("button"); + button.type = "button"; + const icon = document.createElement("span"); + icon.setAttribute("aria-hidden", "true"); + icon.textContent = "↕"; + button.append(document.createTextNode(`${label} `), icon); + th.textContent = ""; + th.append(button); + button.addEventListener("click", () => { + const direction = th.getAttribute("aria-sort") === "ascending" ? "descending" : "ascending"; + for (const other of headers) { + other.removeAttribute("aria-sort"); + const mark = other.querySelector("button span"); + if (mark) + mark.textContent = "↕"; + } + th.setAttribute("aria-sort", direction); + icon.textContent = direction === "ascending" ? "▲" : "▼"; + const sorted = Array.from(body.rows).sort((x, y) => { + const order = compare(cellText(x, column), cellText(y, column)); + return direction === "ascending" ? order : -order; + }); + for (const row of sorted) + body.append(row); + }); }); - th.setAttribute("aria-sort", direction); - icon.textContent = direction === "ascending" ? "▲" : "▼"; - const key = row => (row.cells[column] ? row.cells[column].textContent.trim() : ""); - const sorted = [...body.rows].sort((x, y) => { - const a = key(x), b = key(y), na = number(a), nb = number(b); - const order = !isNaN(na) && !isNaN(nb) ? na - nb - : a.localeCompare(b, undefined, { numeric: true }); - return direction === "ascending" ? order : -order; + } + function addFilter(table, rows, index) { + const id = `filter-${index}`; + const tools = document.createElement("div"); + tools.className = "tools"; + const label = document.createElement("label"); + label.htmlFor = id; + label.textContent = "Filter rows"; + const input = document.createElement("input"); + input.type = "search"; + input.id = id; + input.autocomplete = "off"; + const count = document.createElement("span"); + count.className = "count"; + count.setAttribute("aria-live", "polite"); + const update = () => { + const query = input.value.trim().toLowerCase(); + let shown = 0; + for (const row of rows) { + const hit = !query || (row.textContent ?? "").toLowerCase().includes(query); + row.hidden = !hit; + if (hit) + shown += 1; + } + count.textContent = `${shown} of ${rows.length} rows`; + }; + input.addEventListener("input", update); + tools.append(label, input, count); + (table.closest(".scroll") ?? table).before(tools); + update(); + } + function enhance() { + let filters = 0; + document.querySelectorAll("main table").forEach((table) => { + const body = table.tBodies[0]; + const head = table.tHead; + if (!body || !head || !head.rows[0]) + return; + if (body.rows.length < SORTABLE_MIN_ROWS || table.dataset.static !== undefined) + return; + const rows = Array.from(body.rows); + makeSortable(body, Array.from(head.rows[0].cells)); + if (rows.length >= FILTER_MIN_ROWS) { + filters += 1; + addFilter(table, rows, filters); + } }); - sorted.forEach(row => body.append(row)); - }); - }); - if (rows.length < 8) return; - filters += 1; - const id = "filter-" + filters; - const tools = document.createElement("div"); - tools.className = "tools"; - const label = document.createElement("label"); - label.htmlFor = id; - label.textContent = "Filter rows"; - const input = document.createElement("input"); - input.type = "search"; - input.id = id; - input.autocomplete = "off"; - const count = document.createElement("span"); - count.className = "count"; - count.setAttribute("aria-live", "polite"); - const update = () => { - const query = input.value.trim().toLowerCase(); - let shown = 0; - rows.forEach(row => { - const hit = !query || row.textContent.toLowerCase().includes(query); - row.hidden = !hit; - shown += hit; - }); - count.textContent = `${shown} of ${rows.length} rows`; - }; - input.addEventListener("input", update); - tools.append(label, input, count); - (table.closest(".scroll") || table).before(tools); - update(); - }); + } + enhance(); })();