Skip to content

feat(v2-ui): /extractions Perspective grid, review-page retirement#234

Open
JonnyTran wants to merge 24 commits into
developfrom
feat/v2-ui-extraction-grid
Open

feat(v2-ui): /extractions Perspective grid, review-page retirement#234
JonnyTran wants to merge 24 commits into
developfrom
feat/v2-ui-extraction-grid

Conversation

@JonnyTran

Copy link
Copy Markdown
Member

Phase 2 of the extraction-table plan (docs/superpowers/plans/2026-07-20-extraction-table.md, Tasks 8–13), stacked on Phase 1 / PR #233 which is already merged to develop. This is the integration-risk half: the Perspective 4.5.2 WASM wiring, the grid component, the /extractions page, the review-page deletion, and the replacement e2e gate.

Implements spec §3.3 and §3.5 of docs/superpowers/specs/2026-07-20-extraction-table-design.md.

What's here

Perspective 4.5.2 integration (@perspective-dev/*, all pinned exactly 4.5.2; no @finos/*)

  • components/v2/extractions/perspective-bootstrap.ts — module-level memo that initializes the server + viewer WASM engines exactly once and shares one Client/Worker for the app's lifetime.
  • nuxt.config.tsvue.compilerOptions.isCustomElement for perspective-* tags, build.target: esnext, and optimizeDeps.exclude for the WASM ESM packages.
  • vitest.config.ts + __mocks__/perspective-bootstrap.js — unit tests never touch WASM or custom elements.

ExtractionsGrid.client.vue — the <perspective-viewer> wrapper. The .client.vue suffix keeps Nuxt from ever SSR-evaluating browser-only Web Component / Worker / WASM code. Loads the flat projection rows into a Perspective table, restores a static Datagrid (settings: false, no sort/filter), bands rows by reference via the datagrid's addStyleListener, and emits cell-click with { cell, reference, schemaId, columnName }.

/extractions pagepages/extractions/{index.vue,useExtractionsViewModel.ts}, breadcrumbs, i18n. Standalone route, deliberately not wired into nav or index.vue. ?workspace_id= overrides the selected workspace (deep-load / e2e determinism).

Reference-review page retired (spec §3.5) — Provenance and ReviewCell were first extracted verbatim into v2/domain/entities/review/ReviewCell.ts and the kept importers repointed, then the page, its view-model, ProjectionReviewForm, ReviewRecordCard, ReferenceReview.ts, GetReferenceReviewUseCase, ReferenceReviewsStorage and three e2e specs were deleted. The §3.5 keep-list survives intact with its DI registrations.

Verification

  • Server suite: 138 passed.
  • Frontend: 903 tests passed, nuxi typecheck, npm run lint, npm run build all clean.
  • Playwright e2e 3/3 passing serially against a live stack, run twice — once after Task 12 and again after the final fix commits. Perspective genuinely boots and renders real data in a real browser: both schemas' columns appear (including the record-less coverage-map schema), and coalesce is proven (120 from a suggestion, control beating a competing intervention suggestion). All WASM fetches 200, no console errors.
  • A throwaway remount diagnostic (/extractions/schemas/{id}/extractions) confirmed the shared-client memo survives unmount/remount — the specific risk introduced by the Worker fix below.
  • Code-splitting holds: Perspective WASM (~3.4 MB) + JS glue live only in the /extractions route chunk (211 KB); the entry chunk has zero Perspective references.
  • Scope audit clean: no nav wiring, no sort/filter UI, no Arrow IPC, no annotation-mode changes, ANNOTATION_CELL_LINKS_ENABLED === false, V2TableEditor.vue untouched vs base.

Defects found and fixed during review

Worth calling out because unit tests could not have caught most of them — the Perspective lifecycle is unreachable under happy-dom:

  • Non-scalar cell values (multi_label_selection, ranking, span are arrays/objects) were fed raw into Perspective, which infers scalar column types only — [object Object] at best, a client.table() rejection at worst. That rejection escaped the guarded path, leaving a blank viewer with loadFailed === false and no message. Now coerced in toPerspectiveData (scalars and null pass through unchanged) with a load-error path into the page's existing failure state.
  • One Web Worker + full server WASM instance leaked per page visitperspective.worker() ran on every mount, and only Client.terminate() terminates the Worker. Client hoisted into the shared bootstrap memo.
  • A transient WASM fetch failure bricked /extractions for the whole SPA session — the bootstrap memoized the rejected promise. Now resets on rejection.
  • A dynamic-$t i18n regression: the deletion pass removed review.response/review.suggestion as "unreferenced", but ReviewProvenance.vue — a keep-list file — resolves them via $t(`review.${source}`). Restored, with a regression test that mounts a real createI18n from the actual en.js catalog (the repo's $t stub echoes keys, which would have made the test hollow).
  • Perspective lifecycle correctness: concurrent mount-vs-watch loads racing into two live tables; a superseded table leaking on the torn-down-mid-flight path; viewer.eject() before a re-load() instead of an undocumented load-then-delete assumption; the click guard accepting <th> as well as <td>.
  • View-model load races: request-token last-write-wins plus in-flight dedup keyed on entry identity, covered by three deterministic race tests.

Notes for reviewers

Three items are flagged rather than decided, because the plan mandates them and I did not want to override it unilaterally:

  1. i18n placement. The plan's File Structure mandates translation/{en,de,es,ja}.js, so the extractions: block was added to all four with English placeholders in de/es/ja. But no existing v2 block (schemas, review, document, import, errors) appears in de/es/ja at all — this repo's convention is en-only plus fallbackLocale: "en". Review noted the placeholders will silently pass any future "is it translated?" check whereas the fallback leaves the gap visible. Happy to drop the three non-English blocks if you agree.
  2. @types/react devDependency — works around an upstream packaging bug in @perspective-dev/viewer@4.5.2, whose dist .d.ts points into its own src/ts tree, which imports react types. skipLibCheck cannot help (it skips .d.ts, not the real .ts file TS falls back to). A narrower ambient declare module "react" stub plus a tsconfig paths remap is the tighter alternative.
  3. gen:api drift, pre-existing and environmental. Regenerating now emits "Unprocessable Content" where the committed snapshot says "Unprocessable Entity" — Python 3.13 renamed HTTPStatus(422).phrase. The same mismatch exists at the base commit with no dependency bump on this branch, so the regenerated files were deliberately reverted, not committed, leaving the snapshot matching what other environments produce. Needs a maintainer call.

Known coverage gaps, recorded rather than papered over: cell-click plumbing is asserted by nothing until ANNOTATION_CELL_LINKS_ENABLED is flipped on; multi-page projections are unit-tested but the e2e seed has a single reference; pages/extractions/index.vue has no component test. Also pre-existing and not introduced here: save-review-draft-use-case.ts and discard-review-use-case.ts have no co-located tests despite the keep-list implying coverage.

JonnyTran added 17 commits July 21, 2026 23:45
Its only two callers (review-loop.spec.ts, draft-lifecycle.spec.ts) were
deleted earlier in this branch, leaving it an unused export. Confirmed no
remaining callers before removal.
…le load failures

toPerspectiveData() previously fed multi_label_selection/ranking/span cell values
(arrays/objects) straight into Perspective, which only infers scalar column types -
client.table() could render [object Object] or reject outright. Serialize non-scalar
values to a stable JSON string while passing null and genuine scalars through
unchanged.

That table-construction call also sat outside performLoad's try/catch, so a rejection
escaped as an unhandled rejection: loadFailed stayed false while the viewer rendered
empty with no explanation. Guard the call and emit a new `load-error` event that the
extractions page wires into its existing loadError state cascade.
initPerspective() memoized `ready` unconditionally (`ready ??= ...`), including a
REJECTED promise: one failed fetch of the server/viewer WASM (offline blip, a 404
right after a redeploy) permanently bricked every later mount of the extractions
grid until a hard page reload. Reset the memo to null when the boot rejects so the
next call starts a fresh attempt, while still sharing one in-flight promise across
concurrent callers and never re-running after a successful boot.
…d mounts

ExtractionsGrid called perspective.worker() directly on every mount. worker()
constructs a brand-new Web Worker + WASM server instance each call (confirmed in
@perspective-dev/client's worker()/pe() helpers); only Client.terminate() runs the
close callback that tears one down, and onBeforeUnmount never called it. Ten visits
to /extractions left ten live workers, each with a full WASM heap.

Hoist the client into a module-level memo (initPerspectiveClient) alongside the
existing WASM-boot memo, mirroring its retry-on-rejection behavior. Exactly one
client/worker now lives for the app's session; ExtractionsGrid only drops its local
reference on unmount and intentionally never terminates the shared client.
@JonnyTran
JonnyTran requested a review from a team as a code owner July 22, 2026 10:39
@vercel

vercel Bot commented Jul 22, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
extralit-frontend Ready Ready Preview, Comment Jul 23, 2026 4:56pm

…wer load failures

The datagrid plugin picks `renderTarget = CSS.supports("selector(:host-context(foo))")
? "shadow" : "light"` and, in the shadow case, renders the regular-table and every <td>
inside an attachShadow({mode:"open"}) root. Chromium takes that branch, so the Vue
scoped `:deep()` rules -- which compile to a document-level stylesheet -- could not
reach the cells: banding and the pointer cursor were toggled on every draw and styled
nothing. Inject an id-guarded <style> into the regular-table's own root node instead,
leaving the scoped rules to serve the light-DOM target.

`addStyleListener` only pushes onto regular-table's listener array; it never invokes the
callback nor forces a redraw, and `load()` had already drawn the first frame by the time
we registered. So even once the CSS reached the cells, nothing applied the classes until
a scroll or resize. Apply them once explicitly after registering.

Also: the eject/load/restore/getPlugin catch swallowed every error with no binding, no
log and no emit -- leaving `loadFailed` false with an empty viewer, exactly the silent
failure the load-error contract exists to prevent. Mirror the table-build handler,
including its staleness guard. And release the superseded table on the `!viewer?.load`
early return, the one unwind path still leaking it, now that `table = newTable` has
dropped the last reference to it.

Verified in a real browser: computed cursor `pointer` and the band rule computing to
rgba(0, 0, 0, 0.035) on first paint with zero interaction; style element present once.
`perspective.init_server` returns void, not a promise -- it only stashes what it is
handed (@perspective-dev/client/dist/esm/perspective.browser.d.ts:7). So
`Promise.all([init_server(fetch(SERVER_WASM)), init_client(fetch(CLIENT_WASM))])`
settled on the viewer half alone: a rejected server-WASM fetch never reached the
attempt, never triggered the retry-on-rejection reset, surfaced later as an unhandled
rejection when worker() awaited the stashed promise, and left `ready` memoized as
RESOLVED -- so initPerspective() reported success for a boot that had failed. The
retry's headline scenario, a transient fetch blip, was the one case it did not cover.

Await both fetches here so either can reject into the attempt, then hand the resolved
Responses to init_server/init_client.

The specs missed this because their mocks gave init_server a promise-returning
signature the real module does not have, so every failure case exercised a path that
cannot occur. Type the factories against the real modules and drive the failure specs
by rejecting fetch, as production does. Also pin the untested half of the contract:
a resolved boot is never re-run.
…ions load

`load()`'s null-id early return did not bump `requestToken`, so deselecting the
workspace mid-load could not supersede the request already in flight: it still passed
its token check and committed the old workspace's projection after the selection was
gone, leaving the grid showing a workspace the user was no longer in with no way to
clear it. Bump the token and clear the projection.

Also add `hasLoaded` so the page can tell "not loaded yet" from "genuinely empty" --
`isLoading` starts false and `load()` only runs after `ensureWorkspaces()` resolves, so
every visit briefly rendered the empty state before the spinner -- and drop the
`extractions.loading` key, which no caller ever referenced (the page renders
BaseLoading, which takes no message).
JSON.stringify does not unconditionally return a string: it returns undefined for a
function or symbol, and throws on a BigInt or a circular structure. Either outcome
breaks the "every manifest column present on every row, null when absent" contract that
toPerspectiveData's doc comment says downstream schema inference depends on -- undefined
would leak into a key the contract promises is present, and the throw would escape as a
spurious load-error. Server JSON cannot produce these today, but this helper is the one
place meant to make that guarantee unconditional.
The seed wrote record fields identical to the annotation values it was meant to prove
were coalescing -- fields size="120"/label="control" against a "120" suggestion and a
"control" response. A regression that resolved cells from raw record fields instead of
coalescing suggestion/response would have passed every positive assertion; only the
"intervention" absence check had any power, and it only proved "not the suggestion",
never "the response won". Seed distinct field values (999/unset) and assert they are
absent, so a field-fallback regression fails loudly. Verified live: 999/unset count 0
while the coalesced 120/control render.

Scope the spec's text assertions to the grid's testid rather than unscoped substring
matches over the whole document, and validate loadSeed's parsed object so a stale
seed-output.json names its own fix instead of timing out on "undefined.notes".

The seed's label divergence broke search-roundtrip, which was unmodified: v2 FTS indexes
only raw record.fields, never responses. Restore searchability via the unprojected
country field rather than weakening the grid's divergence check.

Also drop apiToken/apiUrl and the APIRequestContext import, dead since
createIsolatedRecord's removal; harden ReviewProvenance's guard to assert against the
catalog's structure rather than regexing the whole en.js source (an identically valued
pair under any other namespace satisfied it, and en.js already contains a near-miss) and
resolve that catalog relative to the spec rather than the runner's cwd.
@JonnyTran

Copy link
Copy Markdown
Member Author

Pushed 5 commits addressing the automated per-commit reviews on this branch (16 jobs, all now closed). Most of their findings were already superseded by later commits here; these are the ones still live at HEAD.

The one that matters — row banding and the pointer cursor rendered nothing. @perspective-dev/viewer-datagrid picks renderTarget = CSS.supports("selector(:host-context(foo))") ? "shadow" : "light" and, in the shadow case, renders the regular-table and every <td> inside attachShadow({mode:"open"}). Chromium takes that branch, so the Vue scoped :deep() rules — which compile to a document-level stylesheet — could never reach the cells. applyCellStyles faithfully toggled both classes on every draw and styled nothing.

Compounding it: addStyleListener only pushes onto regular-table's listener array — it never invokes the callback nor forces a redraw — and load() had already drawn the first frame before we registered. So even once the CSS reached the cells, nothing applied the classes until a scroll or resize, i.e. never for a user who just reads the grid.

Both fixed, and verified in a real browser rather than by unit test (neither is reachable under happy-dom): on first paint with zero interaction, linkable cells compute cursor: pointer, the band rule computes to rgba(0, 0, 0, 0.035), and the injected <style> is present exactly once. 3/3 e2e serial throughout.

Also fixed:

  • The WASM retry never covered the server half. init_server returns void (perspective.browser.d.ts:7), so Promise.all([init_server(fetch(...)), init_client(fetch(...))]) settled on the viewer alone — a rejected server-WASM fetch became an unhandled rejection while ready stayed memoized as resolved, meaning initPerspective() reported success for a boot that failed. The specs missed it because their mocks gave init_server a promise return the real module doesn't have.
  • The eject/load/restore/getPlugin catch swallowed everything — no binding, no log, no emit — leaving loadFailed false with an empty viewer, exactly the silent failure the load-error contract exists to prevent.
  • The e2e gate couldn't fail on a coalesce regression. The seed wrote record fields identical to the annotation values it was proving coalesced (size="120", label="control"), so a field-fallback regression would have passed every positive assertion. Fields are now distinct and asserted absent; verified live that 999/unset count 0 while 120/control render. Note this rippled into search-roundtrip — v2 FTS indexes only raw record.fields, never responses.
  • Superseded-table leak on the !viewer?.load unwind path; dead previousTableDeleted assignments and their incorrect comment; a deselected workspace unable to supersede an in-flight load; the empty state flashing before the spinner; toScalarCell returning undefined/throwing on values JSON.stringify can't handle; order-coupled unit specs with no beforeEach reset; a ReviewProvenance guard that regexed all of en.js rather than the review namespace; and dead apiToken/apiUrl e2e exports.

Deliberately not changed, flagged instead: the de/es/ja English placeholders (plan-mandated — the open question in the PR description above), the spec §3.5 keep-list flagged as "orphaned" (it's intentional scaffolding for the review drawer), the esnext browser-baseline question, and an HMR-only worker-accumulation guard.

`InternalPage` gave all three of its slots literal string fallbacks ("here is the
header", "here is the page header", "here is the page content"). They were dev
scaffolding, but a slot fallback renders whenever the slot is unfilled -- so every
page that fills only some of the three showed the placeholder as real page copy.

/extractions fills `header` and `page-content` but not `page-header`, and so
rendered "here is the page header" directly above the page title. Found while
recording the extraction-grid demo against the live stack.

Render nothing for an unfilled slot instead. The two pages that do fill
`page-header` (user-settings, dataset settings) are unaffected.
Records the workspace-wide extraction grid against a live backend in headless
chromium and composes the recording into an annotated 1080p video with Remotion.

It is a demo and a gate: every scene asserts the behaviour it is showing, and a
failed assertion exits non-zero and stops the pipeline, so a broken UI cannot be
dressed up as a finished video. The composition renders the run's real pass/fail
counts, which makes the video a report on the run rather than a claim about it.
This is what surfaced the InternalPage slot-placeholder bug fixed in 3f25ea4.

The seed is deliberately richer than e2e/v2's minimal fixture -- a table question
that fans one reference out to several stacked rows (so banding is visible), a
schema with questions but zero records (the coverage map), a human response
competing with an agent suggestion, and deliberate holes so absent extractions
render blank rather than fabricated.

video/ is a separate npm package on purpose: Remotion pulls React 19 and its own
toolchain, none of which belong in the Nuxt app's dependency graph. It is excluded
from the app's ESLint config for the same reason. Recordings, renders and the
generated data module stay untracked -- the composition is a report on one
specific run, and committing its data would let stale captions render against a
fresh recording.
@JonnyTran

Copy link
Copy Markdown
Member Author

Demo video: /extractions against a live stack

Recorded the grid end-to-end in headless Chromium against a real backend (extralit-server :6900, Nuxt dev :3000) — no network mocks — and composed the recording into an annotated 1080p video with Remotion. Harness added in 119cff8 under extralit-frontend/demo/; run it with ./demo/run-demo.sh.

Result: 27/27 assertions passed, 0 page errors, 0 console errors.

It's a gate, not just a screencast

Every scene asserts the behaviour it's showing. A failed assertion exits non-zero and stops the pipeline, so a broken UI can't be dressed up as a finished video — and the composition renders the run's real pass/fail counts, so the video is a report on the run rather than a claim about it.

scene what it proves
01 sign-in real bearer token through the actual UI
02 grid 8 references × 3 schemas denormalized into one flat grid; GET /api/v2/projection → 200
03 coalesce agent said cohort, a reviewer submitted cluster-RCT — grid shows the human answer, and cohort appears nowhere
04 gaps risk_of_bias has questions but zero records: its columns still render, empty. Missing extractions render blank, not fabricated
05 banding a table question fans one reference out to several stacked rows; banding survives the virtualized redraw after scrolling
06 click cells resolve back to reference + schema (annotation deep-link still flagged off)
07 swap switching workspace refetches the projection in place — new columns in, old columns gone
08 empty an un-extracted workspace gets an explicit empty state, never a blank grid

The seed (demo/seed_demo_workspace.py) is deliberately richer than e2e/v2's minimal fixture — a realistic malaria systematic review shaped so each of the above has something real to show, including deliberate holes (a missing country, a missing sample size, a reference with no outcomes record).

It caught a real bug

layouts/InternalPage.vue gave all three of its slots literal string fallbacks ("here is the page header", …) left over from dev scaffolding. A slot fallback renders whenever the slot is unfilled, so /extractions — which fills header and page-content but not page-header — rendered "here is the page header" as real copy directly above the page title. Fixed in 3f25ea4, with a regression assertion in the demo driver.

Notes for anyone re-running it

  • Perspective's Datagrid renders into a shadow root — a plain document.querySelectorAll("td") finds nothing. The driver walks every open shadow root instead.
  • Perspective infers numeric columns, so a digit-only 4812 renders as 4,812; assertions compare against a separator-stripped copy.
  • demo/video/ is a separate npm package on purpose (Remotion pulls React 19 + its own toolchain, which don't belong in the Nuxt dependency graph) and is excluded from the app's ESLint config.
  • Recordings, renders and the generated data.ts stay untracked — the composition is a report on one specific run.

📎 Video attached in the next comment.

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.

1 participant