Skip to content

Replace the website with a daily cross-environment test dashboard built around promotion drift - #120

Merged
gaurav merged 61 commits into
mainfrom
redesign-dashboard
Sep 1, 2026
Merged

Replace the website with a daily cross-environment test dashboard built around promotion drift#120
gaurav merged 61 commits into
mainfrom
redesign-dashboard

Conversation

@gaurav

@gaurav gaurav commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

The GitHub Pages site was an earlier, weaker version of what is now Babel Explorer. This replaces it with a dashboard that runs the validation suite against every environment daily and publishes the result, organised around the question the data says actually matters: where in the promotion pipeline did this appear?

Closes #113. Supersedes #118 (left as a draft until this merges) and #117, which is closed: its four code files were byte-identical to these and its prose is a subset of what is here.

What's here

The old site is gone. The Prefix Comparator moved into Babel (NCATSTranslator/Babel#889) and Autocomplete into Babel Explorer (TranslatorSRI/babel-explorer#7), so both pages are deleted, along with website-vue3-vite/ — a never-deployed app that duplicated the Google Sheet test logic in the browser (44 files, ~10k lines).

The suite emits machine-readable results. pytest --report-jsonl (a pytest_runtest_logreport hook in tests/conftest.py) writes one JSON line per test phase — raw pytest facts only. src/babel_validation/tools/generate_report.py turns those into report.json and appends one summary line to history.jsonl; all classification into passed/failed/xfailed/xpassed/skipped/error happens there, where it is unit-testable, rather than in the fixture that produced it. A raw file is treated as damageable: one unparseable or malformed record costs that record and nothing else. A result whose node ID names no environment is counted as unattributed and published as a count, never as a table row, because the site has no column to render it in.

A daily workflow runs it at 06:30 UTC: the suite against every environment, then the report, the site build and a deploy to gh-pages. Three things about that loop are worth knowing, because each was a bug first:

  • The list of environments comes from read_targets(), not from a second copy in the YAML. When those disagreed, the extra target reached the site as a permanently unreachable column, sorted after prod, and as an always-empty position in every ?sig= signature — which silently invalidates previously shared links.
  • A failing test run and a broken one are told apart. Failing tests are the artifact this workflow exists to publish, so exit 0 or 1 passes silently — but only when that target actually wrote results, because the command is uv run pytest and uv exits 1 for its own failures too, before pytest starts. timeout firing (124) is annotated as a warning, because the environment hung and the report already says so; anything else — a collection error, a usage error, nothing collected — is an error annotation. Every broken target fails the job in a final step after the deploy, so the targets that did work still publish. generate_report already refused to publish when every target was unreachable; this is the partial case, which is the one that looks fine.
  • Only a manual dispatch cancels a run in progress. A blanket cancel-in-progress would let a push, or the next day's cron, kill a scheduled run mid-flight — and that run is what appends the day's line to history.jsonl.

Three pages, not one. Dashboard (run banner, per-environment pipeline cards, promotion-drift panel, environment detail matrix), Results (a sticky filter bar over the test matrix, filterable by category, source and environment, with shareable URL state), and History (what changed since the previous run, then a row per run).

The design follows the data. Measured against the 2026-09-01 report: of 4,584 results, 528 are interesting, and 519 of those differ across environments — only 9 fail everywhere. Failures per environment in promotion order run exp 21 → dev 79 → ci 81 → ci-es 222 → test 80 → prod 327, and the top outcome patterns are pass ×5 FAIL (148), pass pass pass FAIL pass FAIL (100) and xfail ×5 XPASS (67). Drift dominates the file, and it used to be hidden behind a checkbox labelled "interesting only". The redesign promotes it to structure.

Bootstrap 5.3 with a small theme layer, not PrimeVue. A component library only reaches inside the Vue islands, the matrix is a pivot we would hand-write regardless, and the default view is ~530 rows, so virtual scroll buys nothing.

The report is untrusted input, and is treated as such. No v-html anywhere; report values render only through {{ }}; links are built from generator-validated parts; blocklist rows withhold their detail in the expanded-row markup as well as the label; no Google Sheet ID or link appears anywhere in the output. A facet is as public as a cell, so the filter dropdowns exclude blocklist rows exactly as the table does. The URL parameters are treated the same way: they are read before the report loads, so there is nothing to check them against, and every one of them goes through Object.hasOwn rather than indexing a JSON-parsed object directly — a rule now in CLAUDE.md, because it has been two bugs in the same file.

The Google Sheet IDs are out of the repository, and have been rotated. They live in BABEL_VALIDATION_SHEET_ID and BABEL_VALIDATION_BLOCKLIST_SHEET_ID (.env locally, repository secrets in Actions), resolved through resolve_sheet_id(). This matters more than it looks: the sheets are shared as "anyone with the link" because the CSV export is unauthenticated, so the ID is the credential — and the old one is checked into main and sits in this repository's public history, where git rm cannot reach it. Both sheets have been restricted, both old IDs now return 401 credential-free on the gviz CSV export and on /pub?output=csv, and both secrets hold new IDs as of 2026-09-01 (#126). A new env.default documents every variable and what each one turns off when missing, so cp env.default .env is the whole setup, and CLAUDE.md now tells coding agents not to read .env back — everything an agent reads lands in a transcript that is stored, replayed and pasted into issues.

What it produces

A run takes 19–36 minutes, almost all of it the six sequential per-target pytest invocations; the report, build and deploy together are about 30 seconds. The most recent run published 4,152 Google Sheet results, 313 blocklist results and 96 GitHub issue results across six environments.

Tests: 46 vitest specs across results, history, driftPanel, statusMatrix, overview and reportData, run in CI beside the Python unit tests, which are now 192.

What it deliberately does not do

  • No NameRes ES in Test. Test matches CI — NodeNorm ES against the Solr-backed NameRes — because NameRes ES is being validated in CI ([ci-es]) first. The Redis-backed nodenorm.test.transltr.io was switched off on 2026-08-31, so there is no Redis NodeNorm left in Test to compare against; dev, exp and prod still run one.
  • No sparklines on History until there are enough runs to plot (~14); marked with a ponytail: note in History.vue.
  • The six per-target runs stay sequential. A matrix job would cut the wall clock from ~26 minutes to roughly 7, but it restructures the job graph and the gh-pages deploy, so it is Run the dashboard's per-environment tests as a matrix job (26 min -> ~7) #122 rather than another commit here.
  • DEPLOYMENT_ORDER stays hardcoded. Promotion order is a semantic fact about the pipeline that no config file states, so unlike the workflow's target list it cannot be derived from targets.ini. What it no longer does is fail quietly: an unrecognised environment used to sort last, which is indistinguishable from being the end of the pipeline, so the Dashboard now names it instead.

Before merging

Nothing outstanding. All four items that were blocking are done: the temporary push: trigger is removed, #117 is resolved, the sheet IDs and repository secrets are rotated, and the dashboard has been run once against the new secrets — run 33469165720, whose published report carries 4,152 sheet rows and 313 blocklist rows, which is the evidence that both new IDs resolved. A green tick alone would not have shown that: a nonzero pytest exit is normal here.

One thing to watch on the first run after merge: workflow_dispatch only starts working once dashboard.yaml is on the default branch, so the exit-code triage above has been verified by running the step's script under bash -e against stubbed exits, but has never executed on a runner. That applies to the empty-results check as well.

Follow-on work

Filed, on the Babel Validation v1.0 milestone:

History — the stages this was built in, two review rounds, and the sheet-ID rotation. Kept for anyone tracing why a particular line looks the way it does; every durable conclusion is already in a code comment, in CLAUDE.md or in website/README.md above.

Built in six stages on top of #118: 0 chrome (nav bar, cards, container, footer, dark mode, favicon), 1 extract reportData.js (pure moves), 2 split the pages (Overview.vue + StatusMatrix.vue + Results.vue + FilterBar.vue, /results/ added), 3 sticky filter bar, empty/loading/error states, category/source/environment filters, 4 promotion-drift panel with ?sig= links into Results, 5 History (/trends//history/, plus a since-last-run diff).

Two things the screenshots changed after the fact: odd-one-out shading now applies only to the status rows environments should agree on — record counts and latencies differ by nature, and shading them made every cell amber — and the environment cards became a six-column grid rather than a flex row, which wrapped four-and-two at 1440px and broke the left-to-right reading of the pipeline.

A code review then found seven issues, one commit each:

  • history.replaceState was passed a relative ?query, which resolves against <base href="/babel-validation/"> rather than the current path, so the first filter change on /results/ silently moved the address bar to the Dashboard — which ignores every one of those parameters, so copied and reloaded links came back empty. No test can reproduce it, because vitest mounts at / with no <base>; that is now written down in website/README.md.
  • ?page=9999 was clamped for rendering but not for the URL, so Copy link shared a page that did not exist.
  • fetchHistory mapped JSON.parse over every line at once, so one truncated line took the whole History page down. The existing test fed valid JSON that wasn't an object, which the filter after the map already handled — the guard with a test was the one that wasn't needed.
  • The odd-one-out shading picked a winner on a tie by Map insertion order, shading half a row amber to say nothing.
  • scroll-padding-top sat on body, where it has no effect; the viewport takes it from the root element.
  • test-redis — a target read_targets() returned but the workflow never ran. Removed when NodeNorm Redis in Test was switched off, and the underlying divergence fixed by deriving the loop from read_targets().
  • The temporary push trigger, now removed.

The page-clamp regression test initially passed against the unfixed code, because editing a filter resets the page to 1 and hid the bug; it now expands a row instead, which rewrites the URL without touching the page. All four regression tests were re-checked against the unfixed code afterwards.

main was merged in after #116 landed, bringing the per-target OpenAPI paths, the /status backend check and .python-version. That merge is what surfaced test-redis.

A later pass added env.default and the .env rule, the unknown-environment alert, the dispatch-only cancellation, read_targets tests, and the exit-code triage; it also found that test_milestones_page.py on #112 had six tests that had never run in CI for want of a unit marker, which is now a rule in CLAUDE.md. The sheet-ID rotation went through #126: the old IDs appeared to still work after the sheets were restricted, which turned out to be owner-session access rather than caching — a credential-free curl returned 401 immediately.

The verification run was started by an empty commit, because workflow_dispatch does not work until the workflow file is on the default branch, and the trigger that would otherwise have started one was about to be removed.

A third review round found six more, again one commit each, all of them cases where something wrong looked like something working:

  • The exit-code triage added in the previous round accepted exit 1 as "tests failed" without checking that pytest had run at all — and uv run pytest exits 1 for uv's own failures too, which would have gone green with the target rendered as merely unreachable.
  • read_raw_records promised that one corrupt line costs one record, but a record with a non-string msg reached str.startswith and "\n---\n".join, taking the whole report down.
  • Unattributed results were written into the results table, where they have no column: a row with a label and six blank cells, invisible only because the one test in that bucket currently passes.
  • ?env= was indexed straight into the outcomes object, so a prototype key made the environment filter match every row rather than none — the same bug already fixed for ?test=, in the same file.
  • The pinned ?test= row was read as the start of its kind's block, printing that kind's heading twice on one page.
  • The History delta badges were coloured by sign alone, which is backwards for the passed count: a run where 200 fewer tests passed showed a green -200.

A Copilot review then raised five threads and one suppressed comment, all six fixed, again one commit each. Two of them were the same omission twice: error was missing from History's since-last-run diff and from the environment cards, so a target with passing tests and one setup error read as "0 failed" on the Dashboard and as "Nothing changed" on History. The others: _trimmed_float accepted "NaN" and "Infinity" from a /status response, which Python's json writes as bare tokens that JSON.parse rejects — one bad latency would have blanked every page rather than one cell; the pagination arrows carried Bootstrap's .disabled class but no disabled attribute, so activating Previous on page 1 set the page to 0 and blanked the table; and the since-last-run diff skipped any target missing from either run, so adding or removing an environment reported "Nothing changed".

Each fix's regression test was checked against the unfixed code first. Three findings from that round were left alone as facts about the target layout rather than about this diff: [ci] and [ci-es] share a NodeNormURL, so the odd-one-out tally counts that instance twice; _report_file is never closed, which is harmless only because every line is flushed; and the ?page= clamp still only runs on a user edit, so ?page=9999 can be copied out of the address bar untouched, which is narrower than the case the clamp was added for.

🤖 Generated with Claude Code

gaurav and others added 26 commits August 26, 2026 18:57
pytest --report-jsonl PATH appends one JSON line per test (nodeid, phase,
outcome, wasxfail, duration, user_properties, concise crash message) from a
pytest_runtest_logreport hook. Only the xdist controller writes: workers
forward their TestReports, so the guard on PYTEST_XDIST_WORKER prevents
duplicate lines without any file locking.

The two Google Sheet test functions now record category, source, source_url,
query_id and query_label via record_property, so the dashboard gets that
metadata structurally instead of regexing failure messages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
src.babel_validation.tools.generate_report aggregates the --report-jsonl
records per test (worst outcome wins across phases and subtests), classifies
them into passed/failed/xfailed/xpassed/skipped/error (strict XPASS included),
splits the target out of parametrize ids (either end, longest name first so
ci-es beats ci), and merges in each target's NodeNorm/NameRes /status.

report.json is published on a public website and everything feeding it is
untrusted, so this module is the choke point: text is repr-escaped and
truncated, issue ids and source URLs only become links when they match the
targets.ini Repositories allowlist, /status responses pass a key whitelist,
and blocklist test details are withheld entirely.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Prefix Comparator has moved into Babel (NCATSTranslator/Babel#889) and
Autocomplete is moving into Babel Explorer (TranslatorSRI/babel-explorer#7),
so both pages are gone, along with the never-deployed website-vue3-vite app
that duplicated the Google Sheet test logic in the browser.

The Astro site now renders data/report.json and data/history.jsonl (produced
by generate_report): per-environment status cards (Babel/NameRes versions,
database sizes, NameRes p95 latency, outcome counts), a tests-by-environment
matrix defaulting to 'interesting only' rows (failing, unexpectedly passing,
or differing across environments) with links out to the Google Sheet row, the
GitHub issue, direct NodeNorm/NameRes queries and Babel Explorer, and a
trends page listing one summary row per daily run.

All report text is rendered via {{ }} interpolation only, and links are
constructed from generator-validated parts, since the report is derived from
untrusted input. Dependencies used only by the deleted tools are dropped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The new dashboard.yaml workflow (daily cron plus workflow_dispatch) runs the
full test suite against every target in targets.ini except localhost — one
sequential pytest run per target, capped at 45 minutes each so a hung
environment cannot sink the job, with a nonzero exit treated as normal since
the report is the artifact — then generates report.json/history.jsonl and
deploys the built site to gh-pages. The previous run's history is fetched
from the live site, because the deploy action force-pushes gh-pages as a
single commit.

The release-triggered deploy workflow is gone: it would publish a site with
stale or missing data, and workflow_dispatch covers manual redeploys.

README.md and CLAUDE.md now describe the dashboard, its generation flow, and
the untrusted-input rules its generator and Vue components must follow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ecking it in

The sheet is shared as 'anyone with the link' because the CSV export is
fetched unauthenticated, so the ID is the capability that grants access.
GoogleSheetTestCases() now resolves it from the BABEL_VALIDATION_SHEET_ID
environment variable (loaded from .env locally; a repository secret of the
same name in the dashboard workflow), fails loudly when it is missing, and
rejects values that do not look like a sheet ID before they reach a URL.
Its __str__ no longer embeds the ID, since that string is interpolated into
assertion messages the dashboard publishes.

Once the sheet is re-shared under a new ID, the old ID in the public Git
history stops working. The leak-guard unit test now plants a fake ID in the
environment and checks the generator cannot surface it in report.json.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The blocklist sheet logs Red Team offensive terms, so its ID is even more of
a secret than the test-case sheet's. Both sheets now resolve their IDs
through a shared resolve_sheet_id() helper: the environment variable
(BABEL_VALIDATION_SHEET_ID / BABEL_VALIDATION_BLOCKLIST_SHEET_ID, loaded
from .env locally, repository secrets in the dashboard workflow), failing
loudly on a missing or implausible value.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…l, pagination, shareable links

The per-environment status cards become one transposed table with
environments as columns in deployment order (exp, dev, ci, ci-es, test,
prod) and one row per status value, so a version working its way towards
prod reads left to right and the odd environment out is highlighted
(table-warning on cells that differ from the majority). Test-outcome counts
follow as rows in the same table, and adding a status row is now one entry
in STATUS_ROWS. The results matrix and the trends page use the same column
order.

The always-visible 'interesting only' checkbox — one accidental click away
from rendering thousands of rows — moves into a collapsible Filters panel
alongside text search, test-source and has-outcome filters, and the matrix
is paginated (100 rows per page by default, selectable) instead of rendered
in full.

Filter state, page, and the expanded test live in the URL query string, so
the address bar always reproduces the current view; a 'Copy link to this
view' button copies it, and a shared link to a test hidden by the current
filters pins that test at the top rather than showing nothing. Query
parameters are untrusted input: they feed only string filters and key
lookups, never markup or fetch targets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
workflow_dispatch only works once the file is on the default branch; this
push trigger is for pre-merge test runs and comes out before merge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pytest rejects '=' inside a -k expression, so the documented -k "row=42"
never worked; select the full node ID instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…allows

The Python-template 'lib/' pattern in the root .gitignore matched
website/src/lib/, so deploymentOrder.js was never committed and the CI
build failed with 'Could not resolve' while local builds — with the file
present on disk — passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Without a path argument, pytest-xdist workers do not load tests/conftest.py
early enough to register --target/--report-jsonl, and every worker dies at
argparse ('unrecognized arguments') — which is exactly what happened on the
first dashboard run: six 'successful' pytest invocations, zero results.

The generator now refuses to succeed when every target is unreachable, so a
broken run stops the workflow before the deploy step instead of publishing
an empty dashboard. The report files are still written for debugging.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…st tests'

Node IDs are rootdir-relative: 'pytest tests --target dev' (the workflow's
invocation) yields 'tests/github_issues/...' where 'pytest tests/nodenorm/...'
yields 'nodenorm/...'. The generator's kind checks and the Dashboard's
nodenorm//nameres/ prefix checks assumed the bare form, so the first real
workflow run reported github_issues_ran=false despite 96 issues having run,
and every service link would have been missing. Keys now have the tests/
prefix stripped, verified by regenerating the report from that run's actual
raw artifact.

Also: only github_issues/test_github_issues.py counts towards
github_issues_ran (the github_issues/unit/ parser tests run without a
token), and the workflow deselects '-m unit' — the per-target runs were
repeating the unit suite six times into the report's unattributed bucket.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both cost a broken CI run this session: pytest -n without an explicit tests
path leaves workers ignorant of the conftest options, and the root
.gitignore's Python-template 'lib/' pattern silently swallows
website/src/lib/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The trends page sits under `<base href="/babel-validation/">`, so its
`../` back-link resolved to the site root rather than the dashboard.

Dashboard.vue's watchers are registered before created(), so they saw
readUrl()'s assignments as edits: the filters handler reset page to 1 and
rewrote the URL, and a link with ?page=3 always landed on page 1. Ignore
watcher fire-ups until the initial read has flushed.

Also index the pinned-row lookup with Object.hasOwn: ?test=constructor
otherwise pinned an Object.prototype member and blanked the table. And
drop non-object history lines in Trends.vue, which the template indexes
as run.targets[target].

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fetch_status() called .rstrip() on a URL that targets.ini need not
define, read_raw_records() passed a non-string id to parse_nodeid, and
append_history() only checked that prior lines were valid JSON, not that
they were objects with targets — each aborted the whole report rather
than the one value.

_annotate_result() also ran only for the first target seen, so a row
whose first target failed during setup — before record_property runs —
lost its category and service links even though the other targets had
recorded them. Merge props across every target.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The README's own example (--report-jsonl raw/local.jsonl, no mkdir) died
in pytest_configure before collecting anything. Appending was also wrong
for a local re-run: build_results takes the worst outcome per test, so
stale records kept a fixed test red. CI writes one fresh file per target,
so it never noticed either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It was only there so the job could be exercised before the file reached
the default branch. Left in, every push to the branch would start a
~4.5h run that force-pushes gh-pages, racing the daily cron.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Frontend work needs a report.json and a history.jsonl, and generating
them locally means running the suite against at least one environment
first. The live site already publishes both, so download them into
website/public/data/ instead — the directory the dev server reads, and
one the root .gitignore's data/ rule already covers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The shakedown run died before pytest reported anything, leaving a row of
real /status values and all-zero counts at the top of the live
history.jsonl. Every run copies the prior file forward verbatim, so that
row would have sat in the trends table forever.

A run where no target recorded a single test is a broken run rather than
a data point, so drop it on the way in and decline to write one on the
way out. The live file heals itself on the next daily run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The three frontend bugs the last review round found — a shared link
losing its page, a ?test= key reaching Object.prototype, a history line
that is not an object blanking the trends table — were all invisible to
the Python suite, which never loads a component.

`npm test` mounts Dashboard and Trends against a stub report and asserts
what a shared link restores. Four of the six tests fail against the
components as they stood before that review. The Tests workflow runs
them alongside the Python unit tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The site had no chrome at all — a bare container-fluid, no navigation, no
way to reach the run history except a sentence of prose, and fourteen
lines of CSS in the whole project. Everything rendered as one flat column
of tables across the full width of the monitor.

Content now sits in cards on the body background over a tinted page,
inside a 1320px container, under a dark nav bar that links the sibling
projects and the repository. src/styles/theme.css holds the whole custom
layer, written in terms of Bootstrap's theme variables so that the
data-bs-theme dark mode the nav bar's toggle sets works throughout; the
toggle cycles light, dark and follow-the-system, and the choice is
applied in <head> so the page never flashes the wrong theme.

Also fixes three things the shell was hiding: the favicon was the stock
Astro logo, referenced root-absolutely so it 404ed under the /babel-
validation/ base; the viewport meta was missing initial-scale; and the
failure-message background was a hardcoded rgba(0,0,0,.05) that is
invisible on a dark surface.

The Dashboard and Trends components are untouched, and their tests still
pass unchanged. Bootstrap 5.3.2 -> 5.3.8 rides along here, alone, so any
fallout from the upgrade is isolated to this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dashboard.vue was 604 lines because it held everything: the status-row
spec, the row labels, the interestingness predicate, the link builders
and the fetch, alongside the markup. Splitting the page in two means two
components need all of that, so it moves out first, on its own, with no
behaviour change — the six tests pass untouched.

The link builders move byte for byte. Their regexes and their
encodeURIComponent calls are the security boundary between untrusted
report text and a URL, so the file says so where someone might otherwise
tidy them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One page held everything: provenance, warnings, the environment table, the
filters and 4,583 rows of results. Nothing led, so nothing was findable.

The landing page now answers "is anything broken, and where" — the six
environments as cards in promotion order, each linking to its own
failures, over the status table (which gains the six NodeNorm databases
the report has always carried and never showed). The matrix moves to
/results/ behind a filter bar that sticks to the top of the window with
the table header directly beneath it, and gains category, source and
per-environment outcome filters. Choosing an environment narrows the
outcome filter to it, so "failing in dev" is now a link rather than a
reading exercise.

Outcome badges are subtle everywhere except failures and errors, which
stay solid: a screen of results should read as "mostly fine, these two
are not" rather than as a wall of colour. It is also a dark-mode fix —
the bg-*-subtle pairs flip with data-bs-theme and the text-bg-* helpers
do not. Rows are keyboard-focusable now, and the empty state carries the
reset button rather than hiding it in a panel that can be closed.

Blocklist rows are withheld twice over: the expanded detail is now gated
on the kind, not just on the label, and the category and source dropdowns
skip them — a dropdown is as public as a cell, which a new test caught.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Of the 526 tests worth looking at in the current report, 518 behave
differently in different environments — and they do it in a handful of
recurring ways. 147 tests pass everywhere but prod; 100 fail in ci-es and
prod alone; 68 are expected failures that prod passes. Those are three
findings about three Babel versions, not 315 unrelated failures, but the
matrix showed them as 315 rows.

The dashboard now groups the interesting tests by their outcome across
every environment, in promotion order, and lists the most common patterns
with a link into the results page. The pattern is a short string, one
character per environment — "pppppF" is "passes everywhere except prod" —
which makes it small enough for a URL and, because the alphabet is
closed, checkable on the way back in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The trends page listed runs and left the reader to diff two columns by
eye. It now says it outright: which environments moved to a new Babel or
NameRes version, and which count went up or down, computed from the last
two lines of the history file.

Still no chart — four runs exist, and a table beats a sparkline at that
length. There is a ponytail: note for when there are enough to plot.

Renamed from /trends/ to /history/ to match the nav; nothing outside the
site linked to the old path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Screenshots of the redesign against the live report showed the fix this
needed: NodeNorm record counts, Solr index sizes and p95 latencies differ
between environments by nature, so highlighting the minority value turned
almost every cell amber and taught the reader to ignore the colour.

Only the rows that mean something when they disagree — the Babel, Biolink
and NameRes versions, and the two service statuses — are compared now.

The environment cards also move from a flex row to a six-column grid.
Flex sized them by content, so at 1440px they wrapped four and two, and
the pipeline stopped reading left to right halfway through.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@gaurav gaurav added this to the Babel Validation v1.0 milestone Aug 27, 2026
workflow_dispatch only works once the file is on the default branch, so a
pre-merge run has to trigger on push. #118 carried that trigger and is
now kept only for reference, so it moves here — the redesign is what
needs deploying for review.

Only one branch should have it at a time: each run takes about four hours
and force-pushes gh-pages. Remove it before merging, as #118 did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI installs with `npm ci`, which refuses a lockfile that does not match
package.json — and the lock left by the incremental installs that added
vitest and upgraded Bootstrap was missing esbuild's platform packages for
every OS but this one, so the new website-tests job could not start.

Regenerated from scratch (72 @esbuild/* entries now present) and verified
with npm ci, npm test and npm run build locally rather than on the
runner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
gaurav and others added 3 commits August 31, 2026 16:13
read_targets had no tests. It decides which environments the report contains,
and as of the dashboard workflow taking its loop from it, which environments are
run at all — so the localhost exclusion and the section order are load-bearing
in a way they were not when the workflow carried its own list.

Four tests: every section but localhost, order preserved as the file gives it,
the repository allowlist lowercased and blank-stripped, and the checked-in
targets.ini yielding a non-empty list without localhost in it. Dropping the
localhost exclusion fails them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DEPLOYMENT_ORDER is the last hardcoded list of environments on the site: the
workflow and generate_report both read targets.ini now, but promotion order is a
semantic fact about the pipeline that no config file states, so it cannot be
derived from one and has to stay written down.

What it should not do is fail quietly. An unrecognised target sorted last, which
is indistinguishable from "this is the end of the pipeline" — and the sentence
above the cards then read "reaches exp first and <unknown> last", asserting
something nobody had said. The Dashboard now names the environments the order
does not know, and builds that sentence only from the ones it does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`concurrency: dashboard-deploy` serialised runs but never cancelled, so pressing
Run workflow waited out whatever was already going — up to 26 minutes to see the
effect of a change.

A blanket `cancel-in-progress: true` is the wrong fix: a push, or the next day's
cron, could then kill a scheduled run mid-flight, and that run is what appends
the day's line to history.jsonl. Cancelling is gated on the event instead, so
only a manual dispatch supersedes. It is safe there because the run being
cancelled would have produced the same two files as the one replacing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d .env

The sheet IDs are the credentials — both sheets are shared as "anyone with the
link" because the CSV export is unauthenticated — so setting up a checkout meant
knowing which four variables exist, which two are secret, and what each one
turns off when missing. env.default now says all of that, with the ID's format
and where it sits in a sheet URL, so `cp env.default .env` is the whole setup.

The CLAUDE.md rule is the other half. Everything a coding agent reads goes into
a transcript that is stored, replayed and pasted into issues, so reading .env
back — cat, grep, echo $BABEL_VALIDATION_SHEET_ID, printing a CSV export URL —
turns a secret into a logged one, and an exposed ID means re-sharing the sheet
and rotating the secret. Writing .env is fine; reading it is not, and with
env.default there is no reason to. Also gives the presence check that prints a
boolean instead of the value.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
gaurav and others added 4 commits September 1, 2026 00:15
An empty commit, deliberately: the repository secrets were replaced on
2026-09-01 and nothing has exercised them, but workflow_dispatch is unavailable
until dashboard.yaml is on the default branch, so a push to this branch is the
only way to start a run before merge. A mistyped secret fails at collection with
"No Google Sheet ID", so one green run settles it.

The temporary push trigger is removed once that run is green — after which this
is the last thing that could have started one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The note said to split the loop if runtime ever approached the 6h job limit.
It is nowhere near that, and never will be — the reason to do it is that ~25 of
the run's ~26 minutes are six pytest invocations waiting on each other, which is
the feedback loop, not the ceiling. That is now filed, so the note points at it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It existed because workflow_dispatch does not work until the workflow file is on
the default branch, so a push to this branch was the only way to run the
dashboard before merge. Left in, every push to a merged branch would start a
~26-minute run that force-pushes gh-pages, racing the daily cron.

Once this merges, the file is on main and dispatch works: `gh workflow run
dashboard.yaml`. A dispatch cancels a run already in progress, which the cron
never does — see the concurrency block.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`|| echo "target $t exited $?"` treated every nonzero exit the same. Exit 1 —
tests failed — is the artifact this workflow publishes, so it must not fail the
job. But 2 (a collection error), 3, 4 and 5 mean the run did not do what its
green tick suggests, and they rendered identically: the target ends up with no
records, the report marks it unreachable, and the dashboard reads that as
someone else's deployment being down.

Exits are now sorted into three: 0 and 1 pass silently; 124 is annotated as a
warning, because `timeout` firing means the environment hung and the report
already says so — going red every day an environment is down is how people stop
reading the red; anything else is an error annotation and is recorded in
BROKEN_TARGETS. A final step fails the job on that, deliberately after the
deploy, so the targets that did work still publish.

generate_report's all_targets_unreachable already covered total failure. This is
the partial case, which is the one that looks fine.

The loop also now uses `|| status=$?`: steps run under `bash -e`, so reading
`$?` after an unguarded call would have aborted at the first target with a
failing test — which is most of them.

Verified by extracting the step from the YAML and running it under `bash -e`
against stub exits: all-1 is clean, 124 warns without failing, 2 and 4 mark the
target broken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Replaces the retired website tools with a daily, cross-environment validation dashboard focused on promotion drift.

Changes:

  • Adds pytest JSONL reporting, secure report generation, and daily GitHub Pages deployment.
  • Adds Dashboard, Results, and History views with filtering, sharing, and frontend tests.
  • Removes the obsolete Vue/Vite application and protects Google Sheet IDs through environment configuration.

Reviewed changes

Copilot reviewed 88 out of 96 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
.github/workflows/dashboard.yaml Adds daily testing, report generation, and deployment.
.github/workflows/deploy-website-to-gh-pages.yaml Removes the superseded release deployment.
.github/workflows/tests.yaml Adds website unit tests to CI.
CLAUDE.md Documents dashboard workflows and security rules.
README.md Documents dashboard usage and report generation.
env.default Adds environment-variable setup guidance.
src/babel_validation/sources/google_sheets/__init__.py Adds validated sheet-ID resolution.
src/babel_validation/sources/google_sheets/blocklist.py Loads the blocklist sheet ID securely.
src/babel_validation/sources/google_sheets/google_sheet_test_cases.py Removes the embedded validation sheet ID.
src/babel_validation/tools/__init__.py Adds the report-tools package.
src/babel_validation/tools/generate_report.py Generates sanitized dashboard and history data.
tests/conftest.py Adds pytest JSONL outcome reporting.
tests/nameres/test_nameres_from_gsheet.py Records structured NameRes metadata.
tests/nodenorm/test_nodenorm_from_gsheet.py Records structured NodeNorm metadata.
tests/targets.ini Removes the retired Test Redis target.
tests/unit/__init__.py Adds the unit-test package marker.
tests/unit/test_generate_report.py Tests report generation and sanitization.
tests/unit/test_google_sheet_id.py Tests sheet-ID validation.
website/README.md Documents dashboard development.
website/package-lock.json Locks updated frontend dependencies.
website/package.json Adds dashboard testing and data scripts.
website/public/favicon.svg Replaces the site favicon.
website/public/prefix_reports/prefix_report-2024oct24.json Removes retired comparator data.
website/public/prefix_reports/prefix_report-dev-2025jan23.json Removes retired comparator data.
website/src/components/Autocomplete.vue Removes the retired autocomplete tool.
website/src/components/Card.astro Removes the obsolete card component.
website/src/components/DriftPanel.vue Adds promotion-pattern grouping.
website/src/components/FilterBar.vue Adds result filtering and link copying.
website/src/components/History.vue Adds run history and change summaries.
website/src/components/NavBar.astro Adds navigation and theme controls.
website/src/components/Overview.vue Adds the dashboard overview.
website/src/components/PrefixComparator.vue Removes the retired comparator.
website/src/components/Results.vue Adds the filterable results matrix.
website/src/components/StatusMatrix.vue Adds cross-environment status comparison.
website/src/components/bootstrap/BCard.vue Removes an unused Bootstrap wrapper.
website/src/deploymentOrder.js Defines semantic promotion order.
website/src/env.d.ts Adds Vue component declarations.
website/src/layouts/Layout.astro Adds the shared themed page shell.
website/src/pages/autocomplete/index.astro Removes the autocomplete route.
website/src/pages/history/index.astro Adds the History page.
website/src/pages/index.astro Replaces the landing page with the dashboard.
website/src/pages/prefix-comparator/index.astro Removes the comparator route.
website/src/pages/results/index.astro Adds the Results page.
website/src/reportData.js Centralizes report logic and safe link construction.
website/src/styles/theme.css Adds dashboard styling and dark-mode support.
website/test/driftPanel.test.js Tests promotion-pattern grouping.
website/test/history.test.js Tests history parsing and comparisons.
website/test/overview.test.js Tests overview ordering and links.
website/test/reportData.test.js Tests report classification and links.
website/test/results.test.js Tests filtering, pagination, and URL state.
website/test/statusMatrix.test.js Tests disagreement highlighting.
website/vitest.config.js Configures frontend unit tests.
website-vue3-vite/.eslintrc.cjs Removes legacy lint configuration.
website-vue3-vite/.gitignore Removes legacy ignore rules.
website-vue3-vite/.prettierrc.json Removes legacy formatting configuration.
website-vue3-vite/.vscode/extensions.json Removes legacy editor recommendations.
website-vue3-vite/README.md Removes legacy app documentation.
website-vue3-vite/cypress.config.js Removes legacy Cypress configuration.
website-vue3-vite/cypress/e2e/example.cy.js Removes scaffolded Cypress test.
website-vue3-vite/cypress/e2e/jsconfig.json Removes Cypress JavaScript configuration.
website-vue3-vite/cypress/fixtures/example.json Removes scaffolded fixture data.
website-vue3-vite/cypress/support/commands.js Removes scaffolded Cypress commands.
website-vue3-vite/cypress/support/e2e.js Removes Cypress support setup.
website-vue3-vite/index.html Removes the legacy app entry page.
website-vue3-vite/package-lock.json Removes the legacy dependency lock.
website-vue3-vite/package.json Removes the legacy package definition.
website-vue3-vite/public/favicon.ico Removes the legacy favicon.
website-vue3-vite/src/App.vue Removes the legacy root component.
website-vue3-vite/src/assets/base.css Removes legacy base styling.
website-vue3-vite/src/assets/logo.svg Removes the legacy logo.
website-vue3-vite/src/assets/main.css Removes legacy application styling.
website-vue3-vite/src/components/HelloWorld.vue Removes scaffolded content.
website-vue3-vite/src/components/TestResult.vue Removes browser-side test rendering.
website-vue3-vite/src/components/TextWithURLs.vue Removes legacy URL rendering.
website-vue3-vite/src/components/TheWelcome.vue Removes scaffolded welcome content.
website-vue3-vite/src/components/WelcomeItem.vue Removes scaffolded welcome items.
website-vue3-vite/src/components/__tests__/HelloWorld.spec.js Removes scaffolded unit tests.
website-vue3-vite/src/components/icons/IconCommunity.vue Removes scaffolded icon.
website-vue3-vite/src/components/icons/IconDocumentation.vue Removes scaffolded icon.
website-vue3-vite/src/components/icons/IconEcosystem.vue Removes scaffolded icon.
website-vue3-vite/src/components/icons/IconSupport.vue Removes scaffolded icon.
website-vue3-vite/src/components/icons/IconTooling.vue Removes scaffolded icon.
website-vue3-vite/src/main.js Removes the legacy application bootstrap.
website-vue3-vite/src/models/NameResTest.js Removes browser-side NameRes tests.
website-vue3-vite/src/models/NodeNormTest.js Removes browser-side NodeNorm tests.
website-vue3-vite/src/models/Test.js Removes the legacy test model.
website-vue3-vite/src/models/TestResult.js Removes the legacy result model.
website-vue3-vite/src/models/helpers.js Removes legacy CURIE helpers.
website-vue3-vite/src/router/index.js Removes legacy client routing.
website-vue3-vite/src/views/AboutView.vue Removes scaffolded About view.
website-vue3-vite/src/views/Autocomplete.vue Removes legacy autocomplete view.
website-vue3-vite/src/views/AutocompleteBulkValidator.vue Removes legacy bulk validator.
website-vue3-vite/src/views/HomeView.vue Removes the legacy home view.
website-vue3-vite/src/views/NameResValidator.vue Removes the legacy NameRes validator.
website-vue3-vite/src/views/NodeNormValidator.vue Removes the legacy NodeNorm validator.
website-vue3-vite/vite.config.js Removes legacy Vite configuration.
Suppressed comments (1)

website/src/components/History.vue:51

  • A target added to or removed from targets.ini is silently skipped here. If that is the only difference, History reports “Nothing changed”; add a change entry for the missing side before continuing so environment-list changes remain visible.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/babel_validation/tools/generate_report.py
Comment thread website/src/components/History.vue
Comment thread website/src/components/History.vue Outdated
Comment thread website/src/components/Results.vue
Comment thread website/src/components/Overview.vue
gaurav and others added 12 commits September 1, 2026 01:55
The exit-code triage added in 5f90b3b reads 0 and 1 as "the run worked" — 1
being failing tests, which is the artifact this workflow publishes. But the
command is `timeout 45m uv run pytest`, and uv exits 1 for its own failures too:
a failed dependency sync, a resolution error, an unreadable pyproject. Those
happen before pytest starts, so the target ends up with no records at all, the
report renders it as unreachable during the run, BROKEN_TARGETS stays empty and
the job goes green — the exact silent partial failure the triage was added to
catch, arriving through the one branch that does not check anything.

pytest_configure opens raw/$t.jsonl with mode 'w' before collection, so the file
exists and is non-empty for any run that got as far as reporting a test. An
empty or absent file on exit 0 or 1 therefore means pytest never ran, and that
target is now recorded as broken like any other unusable run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
read_raw_records is deliberately forgiving — "one corrupt line must not sink the
whole report" — but it only checks that a record is a dict whose id is a string.
Everything else is passed through as parsed, and both consumers of msg assume a
string: classify_record calls msg.startswith to spot the [XPASS(strict)] prefix,
and build_results joins the messages of a test's records with "\n---\n". A
record carrying {"msg": 123} therefore raises AttributeError or TypeError out of
build_results and takes every other result with it, which is the opposite of the
contract read_raw_records advertises.

Both sites now coerce with str(), the same thing sanitize already does one line
later. A hand-edited, truncated or partially-written raw file costs the record
it damaged and nothing more.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parse_nodeid puts a test whose node ID names no known environment into the "?"
bucket, and build_results wrote it into results like any other row. The site has
no column for it: Results.vue renders one column per entry of report["targets"],
which read_targets never puts "?" into. Such a row reaches /results/ as a label
above an entirely blank set of cells, with nothing on that page to explain the
blanks — the Dashboard's "could not be attributed" note is not rendered there.

Today the only test in that bucket is test_env.py::test_google_sheet_has_test_cases,
which is not parametrized by target. It passes, so isInteresting filters it out
and nobody has seen this. The day it fails it becomes an interesting, entirely
blank row.

The cell is now skipped when building results. It is still counted, and still
reported: counts["?"] is what the report publishes as unattributed_counts, and
the Dashboard already renders that. Only the row is dropped, not the whole
result — a key with both attributed and unattributed records keeps the columns
it does have. github_issues_ran moved above the skip so it still sees every
record.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
report.results[key].outcomes is a plain object parsed from JSON, so it inherits
Object.prototype. matchesOutcome indexed it directly with the environment name
from the URL, and ?env=constructor — or toString, or valueOf — found the
inherited function: truthy, so the `if (!cell) return false` guard passed it
through, but with cell.o undefined. With no outcome filter set the next line
returns true unconditionally, so the environment filter matched everything
rather than nothing, and the page looked unfiltered for no visible reason.

Object.hasOwn now gates it, which is what ?test= has done since the same bug was
found in the pinned-row lookup. Both parameters are read straight from the URL
before the report has loaded, so neither can be checked against an allowlist of
real values; hasOwn is the check that does not need one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A shared ?test= link whose row the current filters or page would hide is pinned
at index 0 of rows so the link shows something. kindHeading derives the group
headings purely from adjacent rows, so it read that pinned row as the first of
its kind's block: the heading printed above it, and then again a few rows later
where the sorted block for that kind actually began. "Babel Validation Google
Sheet" twice on one page reads as a rendering fault.

The pinned row is now flagged, gets its own "Linked test" heading, and never
suppresses the heading of the block below it — its kind is whatever the link
happened to point at, which says nothing about where the sorted rows start. That
also makes the extra row legible: the page renders pageSize + 1 rows by design,
because the pinned row is outside the page window rather than part of it, and
until now nothing on screen said so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The since-last-run panel coloured every delta badge by its sign alone: positive
red, negative green. That is right for failed and xpassed and exactly backwards
for passed, which is the third entry of COUNTS. A run where 200 fewer tests
passed — an environment going down, collection breaking, the sheet failing to
load — rendered a green -200, and 200 more tests passing rendered red.

The polarity now travels with the change: each one carries `worse`, computed per
count when the change is built, and the badge class reads that instead of the
sign.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This has been two bugs in Results.vue now, six weeks apart and in the same file:
?test= pinned an Object.prototype member as a table row, and ?env= made the
environment filter match every row rather than none. Both times the value was
read from the URL and used as a key on a plain object parsed from report.json,
where the inherited members are truthy and carry none of the expected fields.

A third occurrence is likely — every filter on that page is a URL parameter, and
readUrl runs before the report loads, so there is no set of valid values to
check against. Object.hasOwn is the guard that does not need one, and it is now
written down next to the rest of the report's untrusted-input rules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
_trimmed_float coerced with float(), which accepts "NaN", "Infinity" and
"-Infinity" — and a /status response is service output we do not control, so
those are reachable. Python's json then writes them as the bare tokens NaN and
Infinity: valid Python, and not JSON. JSON.parse rejects the whole file, so one
service reporting a NaN latency would blank every page of the dashboard rather
than one cell of one card.

They are now dropped like any other unusable value, which is what the rest of
trim_status does with input it cannot use. _trimmed_int gains OverflowError for
the same reason: int(float("inf")) raises it, and that was not caught either.

json.dump also gets allow_nan=False. It should now be unreachable, but writing a
report no browser can parse is worse than failing the run that produced it, and
the workflow already knows how to report a target whose run broke.

Copilot: src/babel_validation/tools/generate_report.py — non-finite floats
survive the conversion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
COUNTS held failed, xpassed and passed. error is the fourth outcome the
dashboard treats as actionable — a setup or teardown failure, which is a defect
in this suite rather than a finding about a deployment — and it was in neither
the since-last-run diff nor the per-run badges. A run that gained three errors
and moved nothing else reported "Nothing changed", which is the one sentence on
that page that has to be true.

history.jsonl already carries the full counts dict, so the existing published
file needs no regeneration for this to work.

Copilot: website/src/components/History.vue — error counts are omitted from the
previous-run comparison.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The since-last-run diff compared each target field by field, and skipped any
target missing from either side. An environment arriving or leaving is the
largest change a run can carry, and it was the one change the diff could not
express: with nothing else moving, the page said "Nothing changed" on the run
that added or dropped a whole environment.

That is not hypothetical on this branch — test-redis was removed from
targets.ini during it, and #123 is the same shape of bug in the ?sig= links.

Copilot (suppressed): website/src/components/History.vue — a target added to or
removed from targets.ini is silently skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bootstrap's `.disabled` on the `<li>` is styling. The `<button>` inside it stays
in the tab order, exposes no disabled state to assistive technology, and fires
its handler on Enter or Space — so a keyboard user on page 1 could activate
"Previous", which set page to 0. currentPage was capped but not floored, so it
followed to 0, the row slice ran from a negative index to zero, and the table
went blank with the pagination still reading "Page 0 of 22".

Both buttons now carry a real :disabled binding, and currentPage is floored at 1
so the state is unreachable rather than merely unreached.

Copilot: website/src/components/Results.vue — the Bootstrap disabled class only
styles the surrounding <li>.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The cards led with "N failed" and then listed passed, xfailed and skipped.
error appeared nowhere, so a target with 500 passing tests and one setup error
read as "0 failed" and looked healthy — while the results table, which counts
error among the interesting outcomes, showed the row. generate_report's
`unreachable` flag catches only the all-error case, which is the one nobody
would miss anyway.

The line is rendered only when the count is nonzero: an error is rare, and "0
errored" repeated across six cards is noise that would train people to skip the
line that matters.

Copilot: website/src/components/Overview.vue — these cards omit counts.error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@gaurav
gaurav merged commit 1568843 into main Sep 1, 2026
2 checks passed
@gaurav
gaurav deleted the redesign-dashboard branch September 1, 2026 06:41
gaurav added a commit that referenced this pull request Sep 2, 2026
pytest_configure opens it and nothing ever closed it; it was left to interpreter
shutdown. Nothing is lost today, because every record is flush()ed as it is
written — which is exactly what makes this worth fixing rather than shrugging
at. The flush is per test phase on a file that gets thousands of them, so it is
the obvious thing for someone to remove when this run feels slow, and the day
that happens the last records of a run vanish with no error and the dashboard
quietly under-reports.

Noticed reviewing #120; not caused by it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
gaurav added a commit that referenced this pull request Sep 2, 2026
beforeUnmount disconnected the ResizeObserver but left the custom property it
had been writing. That property lives on documentElement, which outlives the
component: Results.vue unmounts the bar on the load-error path, and the stale
height then holds the table header's sticky offset down a page that no longer
has a bar above it.

Narrow — the load-error retry is the only way to reach it — but the fix is one
line, and a component that writes to a shared global should be the thing that
cleans it up.

Noticed reviewing #120; not caused by it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Replace Babel Validation website

2 participants