Skip to content

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

Merged
JonnyTran merged 43 commits into
developfrom
feat/v2-ui-extraction-grid
Jul 26, 2026
Merged

feat(v2-ui): /extractions Perspective grid, review-page retirement (ENG-25)#234
JonnyTran merged 43 commits into
developfrom
feat/v2-ui-extraction-grid

Conversation

@JonnyTran

@JonnyTran JonnyTran commented Jul 22, 2026

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.

extractions-demo.mp4

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 26, 2026 9:39pm

…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.

@JonnyTran JonnyTran changed the title feat(v2-ui): /extractions Perspective grid, review-page retirement feat(v2-ui): /extractions Perspective grid, review-page retirement (ENG-25) Jul 25, 2026
Introduced a new document detailing acceptance criteria for the extraction projection viewer, outlining specific tests for grid behavior, data handling, and performance under real data conditions. Updated the existing extraction table design document to reference these criteria, ensuring comprehensive coverage of the testing strategy.
Introduced a new navigation tab for extractions in the index.vue file. Updated the tab change handler to utilize a centralized route mapping for better maintainability. This change enhances the user interface by providing direct access to the extractions section.
Updated the CHANGELOG to correct links and removed the license header and Dockerfile for the frontend, as they are no longer needed. Adjusted workspace references in the dataset API mock and test files to reflect the new naming convention. This cleanup enhances project maintainability.
Medium findings from roborev jobs 249, 250, 251, 255, 259 (256's medium was
already moot after the dad64f9 revert).

- perspective-bootstrap: `fetch` only rejects on a network-level failure, so an
  HTTP error status resolved normally and `init_server` stashed the error
  `Response` — the boot memoized as *resolved* and the failure re-emerged as an
  unhandled rejection from `worker()`. Both fetches now assert `response.ok`.
- useExtractionsViewModel: the in-flight dedupe matched on workspace id alone,
  so a deselect-then-reselect of the same workspace adopted the superseded
  promise, issued no request, and left the page on the empty state for a
  workspace with data. The guard now requires token currency.
- demo harness: page errors could not fail the gate (a green "N/N passed" video
  on an uncaught app exception), and `rm -rf "$OUT"` was unguarded on a
  documented env knob. Page errors are now fatal (DEMO_ALLOW_PAGE_ERRORS=1 to
  override) and deletion requires a marker file the script itself creates.
- grid-adapter: record why the cell-click chain is retained ahead of its
  consumer (ENG-32) so the next cleanup pass doesn't remove it a second time.
- cellMetaAt: correct a comment that misdescribed row-header metadata as
  carrying `column_header`.

Adds specs for the three gaps these expose: a resolved-but-!ok WASM fetch, the
reselect race, and — via a fake `regular_table` in a real shadow root — the
banding/linkable classes landing on the first draw. Each was mutation-verified
to fail without its fix.

915 unit tests pass, lint clean, `nuxi typecheck` exit 0.
Four parallel audits against HEAD classified doc content as discarded
(abandoned/reversed and now contradicted by code), outdated (names code that
does not exist), or vacuous (cannot fail). Removals only — no rewrites. Every
finding was verified against `file:line` evidence before deletion.

Discarded — the reference-review vertical (deleted by 293466a, superseded by
the server-enriched projection + /extractions grid):
- design spec §7 "Decision 5 — Reference-agnostic ProjectionReviewForm"; its
  ledger item shipped as GET /v2/projection. The 3 contract gotchas it recorded
  survive in 2026-07-20 §2, the other 2 in the plan (line 22) and apiErrors.ts.
- plan Task 15 (/references page), the ReviewRecordCard + ProjectionReviewForm
  test blocks, e2e scenarios 2-4 and their seam-C preamble, the 9 dead review.*
  i18n keys, and the file-structure entries for all of the above.

Discarded — the /extractions nav decision, reversed by b8651b0:
- both spots forbidding nav wiring (plan Tasks 10/13) and the §5 ledger entry
  listing it as unbuilt; extractions-nav.spec.ts now gates the opposite.

Discarded — the perspective.worker() calling convention, replaced by the
memoized initPerspectiveClient (146f5ff) and prohibited in-code: the two
prose references plus the __mocks__ and vi.mock blocks encoding it.

Vacuous:
- AC4's "reference repeats rather than being merged" — guaranteed three times
  over (required Pydantic field, unconditional adapter line, and Perspective
  having no cell-merging at all), so nothing could violate it.
- AC3's "no assertion is made about cross-schema row correspondence" — a
  constraint on the test author, not the system; it also contradicted the
  closing section, which claimed the criterion asserts it.
- the e2e scenario-1 "CORS preflight" claim — Nitro's devProxy makes every
  /api/v2 call same-origin, so no preflight is ever issued.
- plan Task 12's e2e listing, whose assertions 9ab8d52 already replaced for
  being unfalsifiable, and Task 9's chunk-size check that Task 13 never runs.
- useExtractionsViewModel's "does not navigate (guard off)" test: its only
  assertion duplicated grid-adapter.test.ts, and its actual claim was
  unasserted — flipping the flag or deleting the guard both left it green.

914 unit tests pass, lint clean, `nuxi typecheck` exit 0.
The component shipped as `ExtractionsGrid.client.vue` (the `.client` suffix is
load-bearing — it is what makes Nuxt treat it as client-only, which the
Perspective WASM boot requires), but the plan still names the pre-rename
`ExtractionsGrid.vue`/`.test.ts` throughout. Two `npx vitest run` commands and a
`git add` were broken as written.

Every path in a runnable command in this plan now resolves at HEAD.
The prune in 50d4d2a over-reached: `notApplicable` was deleted along with the
genuinely-dead `review.*` keys, but it is live — `translation/en.js:147`,
consumed by `ReviewCellInput.vue:9` via the `ReviewCell.notApplicable` flag.
The closing `},` went with it, leaving the code fence on an unterminated object
literal.

The snippet is now byte-identical to the `review: {…}` block in en.js.
Addresses the remaining open findings from roborev 214 (a review of the
extraction-table plan doc, re-checked against the shipped code).

- latest_responses ordered only by updated_at, which TimestampMixin defaults
  to datetime.utcnow, so two users submitting back-to-back could tie and the
  winning envelope was whatever order Postgres returned. Load response_id
  into DuckDB and append it to the ORDER BY, mirroring what effective_records
  already does with record_id.
- Add test_table_fanout_through_the_response_path: every fan-out test seeded
  a suggestion, leaving the double-wrapped {q: {"value": [...]}} response
  envelope unwrap uncovered.
- Document on build_reference_view that its user-scoping intentionally
  differs from build_workspace_view's any-user coalescing.
roborev 268 (review of 0cb1ad7):
- Pin explicit distinct `updated_at` on
  test_latest_submitted_response_any_user_beats_suggestion: it relied on the
  TimestampMixin default, so both rows tied and the assertion rode the new id
  tiebreaker rather than the ordering rule it names.
- Add test_updated_at_dominates_the_response_id_tiebreaker, where the lower id
  carries the later timestamp, so the two keys' precedence is pinned. Both keys
  are now independently mutation-checked.
- Document that the tiebreaker buys stability, not latest-ness: on a tie the
  winner is the greatest response_id, deterministic but arbitrary.

roborev 266: the onCellClick spec asserted only the URL, which holds on both
branches of the ANNOTATION_CELL_LINKS_ENABLED guard. Stub the window.location
href setter and assert it is never written, so flipping the guard fails it.

roborev 262:
- Fold consoleErrors into the demo's fatal condition behind
  DEMO_ALLOW_CONSOLE_ERRORS; ExtractionsGrid reports its worst failure via
  console.error, so collecting these without gating on them read as a signal
  they weren't.
- Clear document.body after each ExtractionsGrid spec; fakeRegularTable leaked
  shadow hosts into the shared happy-dom document.
- Flush the scheduler explicitly after each saveSelectedWorkspace in the
  reselect spec instead of relying on an incidental microtask drain.
- Document that assertOk covers the status, not the bytes: reading the body
  would close that hole but give up streaming WASM compilation on every boot.

roborev 260:
- Give each tab its own optional `route` and drop NAVIGATION_TAB_ROUTES: two
  independent sources of truth let a route exist with no tab, or a tab route
  nowhere and render a blank panel.
- Add pages/index.test.ts covering push-vs-panel without mounting the
  DI-heavy page; the mapping was pinned only by the separate v2 e2e project.
- Scope the e2e heading assertion to the page's own h1 — the breadcrumb trail
  also carries an "Extractions" crumb.
- Drop the untranslated `extractions` blocks from de/es/ja so the `en`
  fallback renders identically and the gap is visible to translation owners.
roborev 269:
- Add the `delete window.location` fallback to the onCellClick spec's teardown
  and assert the real location is back afterwards. happy-dom 20 exposes
  `location` as an own configurable accessor, so the existing restore did run
  (see the review comment) — but the assertion makes a leaked stub fail loudly
  instead of staying inert until some later spec reads it.
- Derive the panel tab ids in pages/index.test.ts from index.vue's own
  `activeTab.id === '...'` branches instead of hand-copying them. The copy was
  asymmetric: adding a branch failed the test, but removing or renaming one
  left it green while that tab rendered an empty panel.
- Restore the text assertion the e2e heading check lost when it was scoped to
  the BEM class, so a broken `extractions.title` lookup fails again.
- Gate the demo only on console errors it owns (ExtractionsGrid/grid-adapter).
  The harness drives `npm run dev`, so devtools and HMR share the `[error]`
  channel; every one being fatal meant unrelated noise failed the pipeline and
  the only escape switched off the signal the gate exists for.
The reference-review page that consumed these was removed by 293466a,
leaving the whole widget layer with zero consumers. A repo-wide grep for
the three component names found exactly one hit outside the subtree: a
comment. Deleting the components alone would have left four domain
modules orphaned, so they go together.

Components: ReviewCellInput.vue, ReviewProvenance.vue, V2TableEditor.vue.
Domain: widget-adapters.ts, widget-mapping.ts, SuggestionHint.ts,
ReviewCell.ts. Each with its spec.

Kept: response-values.ts (live via AnnotationRepository) and the three
review use-cases still registered in DI.

V2TableEditor was a fourth independent Tabulator boot alongside
RenderTable.vue and BaseSimpleTable.vue, re-imported globally-loaded
tabulator.min.css, and shipped none of BaseSimpleTable's theme overrides
- so it would have rendered off-brand the moment anything used it. v2
has since standardised on Perspective.

Also drops the five review.* i18n keys that lose their last consumer
(en.js only; de/es/ja never carried them), and amends the keep-list in
the extraction-table plan. ENG-32 is updated to say the widgets are a
rebuild, not an adaptation - its description previously told the
implementer to build on files this commit deletes.

Tests: 919 -> 883, zero failures.
Three defects, all shipped together:

1. The color map named --fg-status-active and --fg-status-danger. Neither
   exists in assets/css/themes.css - the defined tokens are
   --fg-status-{pending,draft,discarded,submitted}. Every entry fell
   through to its var() fallback, so every status rendered the same grey.
   `published` and `completed` have no token of their own and now share
   --fg-status-submitted: both are the terminal "done" state and they
   never appear in the same table (schemas renders draft|published,
   records renders pending|completed|discarded).

2. The badge rendered the raw server status string. It now translates via
   v2Status.*, a new key family covering both vocabularies the badge
   spans. Deliberately not v1's recordStatus.*, which is disjoint.

3. V2RecordsTable declared a required workspaceId prop that nothing
   referenced, left over from the reference-review page deleted by
   293466a. Its call site passed `schema?.workspaceId ?? ''` - and
   Schema has no workspaceId field, so it was always ''.

The old spec could not see defect 1: it compared the two *declaration
strings*, which differ, while the rendered colors were identical. The
rewrite asserts the resolved token per status AND that every token named
is defined in all three of themes.css's blocks - the second check is what
makes a nonexistent token visible. Verified by reintroducing the bug:
both assertions fail.

Tests 883 -> 899, lint and typecheck clean.
One component tree instead of two. After the dead review/table subtree
went, components/v2/ held only common/, extractions/ and schemas/ - all
three move, so the tree is gone entirely.

  common/V2Empty.vue                  -> features/global/
  common/V2StatusBadge.vue (+test)    -> features/global/
  extractions/ExtractionsGrid.client.vue (+test)  -> features/extractions/
  extractions/perspective-bootstrap.ts (+test)    -> features/extractions/
  schemas/V2RecordsTable.vue          -> features/schemas/

global/ is the documented home for components used across pages
(docs/structure.md) and V2Empty has 8 usages over 4 pages; extractions/
and schemas/ are new feature roots.

All git mv, so history stays followable. No template usage changed:
nuxt.config.ts registers components with pathPrefix: false, so every
<V2Empty>/<ExtractionsGrid> still resolves by bare name. Three places
hard-coded the old path and did need editing - the vitest alias for
perspective-bootstrap, the vi.mock on the same specifier, and
ExtractionsGrid's own ~/-absolute import. The .client suffix is preserved:
it is what keeps Nuxt from SSR-evaluating the WASM boot.

Note on the vitest alias: it is currently redundant with
ExtractionsGrid.client.test.ts's own vi.mock - no other spec mounts the
grid (useExtractionsViewModel.test.ts only mentions it in a comment). It
still had to be updated, because a stale alias key is silently inert
rather than an error, which is exactly how this would bite later.

Verified: 899 tests pass, lint clean, nuxi typecheck clean, npm run build
succeeds, playwright --project=v2 --workers=1 is 4/4, and the duplicate
basename check stays empty. Browser-checked /schemas, /schemas/[id] and
/extractions in light and dark: badges keep their per-status colors and
the Perspective grid still boots and paints.
The prefix was only ever a flat-namespace collision guard while a
parallel components/v2/ tree existed (see the vertical-slice plan). Inside
features/ it reads as leftover scaffolding, and all three target names
were free.

  V2Empty        -> Empty        (BEM root v2-empty -> empty-state)
  V2StatusBadge  -> StatusBadge
  V2RecordsTable -> RecordsTable (BEM root v2-records-table -> records-table)

Each rename covers the file, the `name:` option, the BEM root class, every
template usage, and the stub keys in pages/schemas/index.test.ts - the
last of those being the easy one to miss, since Vue does not error on a
stub key that matches nothing. Confirmed they are load-bearing here:
renaming the Empty stub key back fails the empty-state test rather than
silently passing.

Kept as its own commit so a rename never has to be untangled from the move.

Verified: 899 tests, lint, nuxi typecheck, npm run build, and
playwright --project=v2 --workers=1 at 4/4; badge colors byte-identical to
the pre-rename run across light/dark/high-contrast.
…eep-list

Jobs 271, 272, 274.

The one that mattered (272, Medium): the badge looks its label up
dynamically, so no literal "v2Status.draft" exists for a grep and the
spec's $t stub never consults a catalog - deleting the whole v2Status
block from en.js left all 899 tests green while every badge rendered a
raw key. This branch already lost the review.* keys that way twice. The
spec now asserts en.v2Status's keys against STATUS_TOKENS.

Also in that spec: the themes.css check counted total `--token:`
occurrences against a hardcoded 3, so three copies inside :root would
have passed, and its "restructure guard" matched block headers by regex -
ambiguous, because themes.css carries a second [data-theme="dark"] and
[data-theme="high-contrast"] block that only set color-scheme. Replaced
with a parser that splits the file into selector/token blocks, keeps
those defining --fg-status-*, pins the selector list and asserts each
token per block by name. The distinctness test compared `var(--token)`
strings - the same gap the previous commit claimed to have closed - and
now compares resolved values per block. __dirname (the only use in any
spec) is now fileURLToPath(import.meta.url).

Every new guard verified by mutation: removing the v2Status block,
dropping --fg-status-draft from the high-contrast block alone, and making
draft equal submitted in :root each fail exactly one intended test.

274: Empty -> EmptyState. `Empty` is single-word, which Vue reserves and
vue/multi-word-component-names would reject - that rule is off only for
Nuxt's single-word pages/layouts, not as a policy for components. It also
matches the empty-state BEM root already introduced. This departs from
the name in the approved plan; flagged rather than silent.

274 also asked for stub-effectiveness assertions. Note that the suggested
findComponent({ name }).exists() does not work: shallowMount auto-stubs
unmatched children while keeping their names, so it passes either way
(verified). The assertions check content only the keyed stubs render, and
both fail when their keys are staled.

271: three documentation findings, addressed in the extraction-table
plan's keep-list rather than as code comments - it now states why the
review use-cases were kept while the widgets were deleted (server
contract vs. a UI to be redesigned), and records that ColumnMeta.review
plus the components/base/inputs/ leaves are deliberate ENG-32 groundwork
rather than dead weight.

902 tests, lint clean, typecheck clean, build clean, v2 e2e 4/4.
@JonnyTran
JonnyTran merged commit 7b740e1 into develop Jul 26, 2026
4 checks passed
JonnyTran added a commit that referenced this pull request Jul 28, 2026
Columns now come from GET /datasets/{id}/fields, search from v1's
Elasticsearch-backed records/search (authoritative total). Deletes
AnnotationRepository and the three review use-cases, orphaned since #234,
and the rebuild-index button (reindex is a CLI). Also drops the now-dead
gen:api codegen scripts and the two CI gates that diffed against the
deleted v2 OpenAPI snapshot/generated client.
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