diff --git a/.claude/commands/refractor.md b/.claude/commands/refractor.md deleted file mode 100644 index 6b8496272..000000000 --- a/.claude/commands/refractor.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -description: Refactor code following best practices and design patterns ---- - -## Your task - -Refactor the following code: @$ARGUMENTS - -Guidelines: -1. **Maintain functionality**: Ensure no breaking changes -2. **Improve readability**: Make code more self-documenting -3. **Extract common patterns**: Identify and extract reusable components -4. **Performance optimization**: Improve efficiency where possible -5. **Modern conventions**: Use current best practices -6. **Type safety**: Add or improve type annotations if applicable - -Explain each change and why it's beneficial. \ No newline at end of file diff --git a/.claude/commands/update-claudemd.md b/.claude/commands/update-claudemd.md deleted file mode 100644 index f38b9f223..000000000 --- a/.claude/commands/update-claudemd.md +++ /dev/null @@ -1,144 +0,0 @@ ---- -allowed-tools: Bash(git diff:*), Bash(git log:*), Bash(git status:*), Bash(find:*), Bash(grep:*), Bash(wc:*), Bash(ls:*) -description: Automatically update CLAUDE.md file based on recent code changes ---- - -# Update Claude.md File - -## Current Claude.md State -@CLAUDE.md - -## Git Analysis - -### Current Repository Status -!`git status --porcelain` - -### Recent Changes (Last 10 commits) -!`git log --oneline -10` - -### Detailed Recent Changes -!`git log --since="1 week ago" --pretty=format:"%h - %an, %ar : %s" --stat` - -### Recent Diff Analysis -!`git diff HEAD~5 --name-only | head -20` - -### Detailed Diff of Key Changes -!`git diff HEAD~5 -- "*.js" "*.ts" "*.jsx" "*.tsx" "*.py" "*.md" "*.json" | head -200` - -### New Files Added -!`git diff --name-status HEAD~10 | grep "^A" | head -15` - -### Deleted Files -!`git diff --name-status HEAD~10 | grep "^D" | head -10` - -### Modified Core Files -!`git diff --name-status HEAD~10 | grep "^M" | grep -E "(package\.json|README|config|main|index|app)" | head -10` - -## Project Structure Changes -!`find . -name "*.md" -not -path "./node_modules/*" -not -path "./.git/*" | head -10` - -## Configuration Changes -!`git diff HEAD~10 -- package.json tsconfig.json webpack.config.js next.config.js .env* docker* | head -100` - -## API/Route Changes -!`git diff HEAD~10 -- "**/routes/**" "**/api/**" "**/controllers/**" | head -150` - -## Database/Model Changes -!`git diff HEAD~10 -- "**/models/**" "**/schemas/**" "**/migrations/**" | head -100` - -## Your Task - -Based on the current CLAUDE.md content and all the git analysis above, create an updated CLAUDE.md file that: - -## 1. Preserves Important Existing Content -- Keep the core project description and architecture -- Maintain important setup instructions -- Preserve key architectural decisions and patterns -- Keep essential development workflow information - -## 2. Integrates Recent Changes -Analyze the git diff and logs to identify: -- **New Features**: What new functionality was added? -- **API Changes**: New endpoints, modified routes, updated parameters -- **Configuration Updates**: Changes to build tools, dependencies, environment variables -- **File Structure Changes**: New directories, moved files, deleted components -- **Database Changes**: New models, schema updates, migrations -- **Bug Fixes**: Important fixes that affect how the system works -- **Refactoring**: Significant code reorganization or architectural changes - -## 3. Updates Key Sections -Intelligently update these CLAUDE.md sections: - -### Project Overview -- Update description if scope changed -- Note new technologies or frameworks added -- Update version information - -### Architecture -- Document new architectural patterns -- Note significant structural changes -- Update component relationships - -### Setup Instructions -- Add new environment variables -- Update installation steps if dependencies changed -- Note new configuration requirements - -### API Documentation -- Add new endpoints discovered in routes -- Update existing endpoint documentation -- Note authentication or parameter changes - -### Development Workflow -- Update based on new scripts in package.json -- Note new development tools or processes -- Update testing procedures if changed - -### Recent Changes Section -Add a "Recent Updates" section with: -- Summary of major changes from git analysis -- New features and their impact -- Important bug fixes -- Breaking changes developers should know about - -### File Structure -- Update directory explanations for new folders -- Note relocated or reorganized files -- Document new important files - -## 4. Smart Content Management -- **Don't duplicate**: Avoid repeating information already well-documented -- **Prioritize relevance**: Focus on changes that affect how developers work with the code -- **Keep it concise**: Summarize rather than listing every small change -- **Maintain structure**: Follow existing CLAUDE.md organization -- **Add timestamps**: Note when major updates were made - -## 5. Output Format -Provide the complete updated CLAUDE.md content, organized as: - -```markdown -# Project Name - -## Overview -[Updated project description] - -## Architecture -[Updated architecture information] - -## Setup & Installation -[Updated setup instructions] - -## Development Workflow -[Updated development processes] - -## API Documentation -[Updated API information] - -## File Structure -[Updated directory explanations] - -## Recent Updates (Updated: YYYY-MM-DD) -[Summary of recent changes] - -## Important Notes -[Key information for developers] \ No newline at end of file diff --git a/docs/superpowers/plans/2026-07-10-v2-frontend-vertical-slice.md b/docs/superpowers/plans/2026-07-10-v2-frontend-vertical-slice.md index 8c23bc7b2..3c306401a 100644 --- a/docs/superpowers/plans/2026-07-10-v2-frontend-vertical-slice.md +++ b/docs/superpowers/plans/2026-07-10-v2-frontend-vertical-slice.md @@ -17,7 +17,7 @@ - **Reuse-don't-fork:** leaves are extracted with `git mv` and v1 re-pointed; nothing is copy-pasted into `v2/`. - **Name-keyed global registries:** ts-injecty DI and `useStoreFor` Pinia stores are keyed by **class name**; Nuxt component auto-import is **flat** (`pathPrefix: false`). Every v2 use-case/repository/storage-state class name and every new component name must be globally unique vs v1 (hence `V2RecordRepository`, `V2TableEditor`, etc.). - **UI copy says "Schema"** — all new copy through i18n key families `schemas.*` and `review.*` in `translation/en.js` (fallbackLocale is `en`; other locales fall back). Never reuse `dataset`-keyed copy. -- **Routes:** `/schemas`, `/schemas/[id]`, `/schemas/[id]/settings`, `/references/[...reference]?workspace_id=`. No `/v2/` prefix. Always `encodeURIComponent(reference)` when building API URLs. +- **Routes:** `/schemas`, `/schemas/[id]`, `/schemas/[id]/settings`. No `/v2/` prefix. Always `encodeURIComponent(reference)` when building API URLs. - **axios baseURL is `/api`** → repository paths start with `/v2/...`. The shared instance injects `Authorization: Bearer` and runs `AxiosErrorHandler` + `AxiosCache` interceptors — do not create a parallel HTTP client. - **Server contract gotchas** (from spec §7/§10, verified against merged code): projection cells & `response.values` keyed by question **name**, suggestions by question **id**; `response.values` is double-wrapped `{question_name: {"value": …}}` on PUT and GET; `GET /records/{id}/responses` returns `null` with 200; two 422 body shapes (`detail: string` and `detail: [{loc,msg,type}]`); `total` is approximate; unknown filter columns → 5xx (UI must offer only known columns); `span` questions are rejected by the server (excluded from the slice). - **Question value shapes the server validates** (v1 validator union): `text`→string, `label_selection`→string, `multi_label_selection`→string[], `rating`→number, `ranking`→`[{value, rank}]`, `table`→object with keys ⊆ bound columns. @@ -30,9 +30,7 @@ 2. **App home:** nav sibling — a third "Schemas" tab on the home page that navigates to `/schemas` (Task 6). Home swap deferred to Phase 6. 3. **i18n:** new `schemas.*` / `review.*` families in `translation/en.js` only (fallback covers de/es/ja). 4. **Rebuild-index affordance:** yes — button on `/schemas/[id]/settings` calling `POST /v2/schemas/{id}:rebuild-index`, showing the returned `{indexed: n}` (Task 8). -5. **Orphaned response values:** `ReferenceReview` assembly collects `orphanedValues` (response keys no question owns); the form surfaces them read-only and excludes them from emit payloads (Tasks 11, 14). -**Spec deviation (recorded):** spec §7 sketches `` with a singular `draft` prop, but a reference spans multiple records each with its own draft. The plan puts each record's draft inside `ReviewRecord.draft` on the assembled entity; the form takes only `:review`. Emits keep the spec'd `(recordId, values)` signatures. ## File structure @@ -51,13 +49,12 @@ extralit-frontend/ record/V2Record.ts, record/RecordsPage.ts # Task 5 search/SearchCriteria.ts (+ .test.ts) # Task 5 review/response-values.ts (+ .test.ts) # Task 10 - review/ReferenceReview.ts, review/SuggestionHint.ts (+ tests) # Task 11 + review/SuggestionHint.ts (+ tests) # Task 11 review/widget-adapters.ts (+ .test.ts) # Task 14 domain/usecases/ get-schemas-use-case.ts, get-schema-settings-use-case.ts # Task 4 get-schema-records-use-case.ts, search-records-use-case.ts, rebuild-schema-index-use-case.ts # Task 5 - get-reference-review-use-case.ts (+ .test.ts) # Task 11 submit-reference-review-use-case.ts, save-review-draft-use-case.ts, discard-review-use-case.ts (+ tests) # Task 12 infrastructure/ @@ -67,7 +64,6 @@ extralit-frontend/ repositories/AnnotationRepository.ts, ProjectionRepository.ts, repositories/apiErrors.ts (+ tests) # Task 10 storage/SchemasStorage.ts # Task 4 - storage/ReferenceReviewsStorage.ts # Task 11 di/di.ts, di/index.ts # Task 4 (grows each task) components/base/inputs/ # Task 9 (moved leaves) label-selection/{LabelSelection.component.vue, useLabelSelectionViewModel.ts} @@ -76,13 +72,11 @@ extralit-frontend/ text-area/ContentEditableFeedbackTask.vue components/v2/ schemas/V2RecordsTable.vue # Task 7 - review/{ProjectionReviewForm.vue, ReviewRecordCard.vue, - ReviewCellInput.vue, ReviewProvenance.vue} (+ tests) # Task 14 + review/{ReviewCellInput.vue, ReviewProvenance.vue} (+ tests) # Task 14 table/V2TableEditor.vue (+ .test.ts) # Task 13 pages/schemas/index.vue + useSchemasViewModel.ts (+ test) # Task 6 pages/schemas/[id]/index.vue + useSchemaRecordsViewModel.ts (+ test) # Task 7 pages/schemas/[id]/settings.vue + useSchemaSettingsViewModel.ts (+ test)# Task 8 - pages/references/[...reference].vue + useReferenceReviewViewModel.ts # Task 15 e2e/v2/ # Tasks 16–17 fixtures.ts, seed/seed_v2_e2e.py, *.spec.ts translation/en.js # Tasks 6, 8, 14 (add keys) @@ -3378,20 +3372,15 @@ git commit -m "feat(v2-ui): lean V2TableEditor tabulator wrapper for table quest **Files:** - Create: `extralit-frontend/v2/domain/entities/review/widget-adapters.ts` -- Create: `extralit-frontend/components/v2/review/ProjectionReviewForm.vue` -- Create: `extralit-frontend/components/v2/review/ReviewRecordCard.vue` - Create: `extralit-frontend/components/v2/review/ReviewCellInput.vue` - Create: `extralit-frontend/components/v2/review/ReviewProvenance.vue` - Modify: `extralit-frontend/translation/en.js` (add `review.*` keys) - Test: `extralit-frontend/v2/domain/entities/review/widget-adapters.test.ts` -- Test: `extralit-frontend/components/v2/review/ProjectionReviewForm.test.ts` **Interfaces:** - Consumes: `ReferenceReview` / `ReviewRecord` / `ReviewCell` / `SuggestionHint` (Task 11), extracted leaves (Task 9), `V2TableEditor` (Task 13), `Question` (Task 3). - Produces: - Widget adapters (pure, tested): `buildLabelOptions(question, selected: unknown): {id,text,value,description,isSelected}[]`; `selectedFromLabelOptions(options, multiple): string | string[] | null`; `buildRatingOptions(question, selected: unknown): {id,value,isSelected}[]`; `selectedFromRatingOptions(options): number | null`; `buildRankingValues(question, ranked: unknown): {id,text,value,description,rank}[]`; `rankingAnswerFromValues(values): {value,rank}[]`; `suggestionHintFor(cell: ReviewCell): SuggestionHint | null`. - - `` — **pure**: no route reads, no fetches, no queue knowledge (spec §7). `values` are PLAIN (unwrapped); the page's use-cases wrap them. - - `` also accepts `:submit-errors="Record"` (recordId → messages) so the page can surface normalized 422s inline. - [ ] **Step 1: Write the failing adapter tests** @@ -3585,121 +3574,14 @@ In `extralit-frontend/translation/en.js`, add: ```js review: { - title: "Review", - submit: "Submit", - saveDraft: "Save draft", - discard: "Discard", suggestion: "Suggestion", response: "Response", agent: "Agent", score: "Score", - context: "Context", notApplicable: "Not applicable in this schema version", - orphanedValues: "Values from removed questions (read-only, not resubmitted)", - loadError: "Could not load review for this reference.", - submitted: "Response submitted", - draftSaved: "Draft saved", - discarded: "Response discarded", }, ``` -- [ ] **Step 4: Write the failing form component test** - -Create `extralit-frontend/components/v2/review/ProjectionReviewForm.test.ts`: - -```ts -import { describe, expect, it } from "vitest"; -import { mount } from "@vue/test-utils"; -import ProjectionReviewForm from "./ProjectionReviewForm.vue"; -import { ReferenceReview, ReviewCell, ReviewRecord } from "~/v2/domain/entities/review/ReferenceReview"; -import { Question } from "~/v2/domain/entities/question/Question"; - -const textQuestion = new Question("q-size", "s-1", "size", "Sample size", null, "text", ["size"], {}, true); -const labelQuestion = new Question("q-label", "s-1", "label", "Label", null, "label_selection", ["label"], { - type: "label_selection", - options: [{ value: "a", text: "A", description: null }], -}, false); - -const makeReview = (cells: ReviewCell[], draft = null, orphaned: { name: string; value: unknown }[] = []) => - new ReferenceReview("10.1000/j.x", [new ReviewRecord("r-1", "s-1", "sample_size", cells, [], orphaned, draft)], 1); - -const stubs = { - // Leaves are exercised in their own suites; here we assert dispatch + emit shaping. - ContentEditableFeedbackTask: { template: "
", props: ["value"] }, - LabelSelectionComponent: { template: "
", props: ["modelValue"] }, - RatingMonoSelectionComponent: true, - DndSelectionComponent: true, - V2TableEditor: true, -}; - -describe("ProjectionReviewForm", () => { - it("renders a widget per question type and suggestion provenance", () => { - const review = makeReview([ - new ReviewCell(textQuestion, "12", "suggestion", { agent: "gpt", score: 0.9, suggestedValue: "12" }, false), - new ReviewCell(labelQuestion, null, null, null, false), - ]); - - const wrapper = mount(ProjectionReviewForm, { props: { review }, global: { stubs } }); - - expect(wrapper.find(".stub-text").exists()).toBe(true); - expect(wrapper.find(".stub-label").exists()).toBe(true); - expect(wrapper.text()).toContain("review.suggestion"); - expect(wrapper.text()).toContain("gpt"); - }); - - it("emits submit with (recordId, plain values) — page wraps them", async () => { - const review = makeReview([ - new ReviewCell(textQuestion, "12", "suggestion", { agent: "gpt", score: 0.9, suggestedValue: "12" }, false), - ]); - const wrapper = mount(ProjectionReviewForm, { props: { review }, global: { stubs } }); - - await wrapper.get("[data-test='submit-r-1']").trigger("click"); - - expect(wrapper.emitted("submit")).toEqual([["r-1", { size: "12" }]]); - }); - - it("marks not-applicable cells and excludes them from emitted values", async () => { - const review = makeReview([ - new ReviewCell(textQuestion, "12", "suggestion", null, false), - new ReviewCell(labelQuestion, "a", "suggestion", null, true), - ]); - const wrapper = mount(ProjectionReviewForm, { props: { review }, global: { stubs } }); - - expect(wrapper.text()).toContain("review.notApplicable"); - await wrapper.get("[data-test='save-draft-r-1']").trigger("click"); - expect(wrapper.emitted("save-draft")).toEqual([["r-1", { size: "12" }]]); - }); - - it("surfaces orphaned values read-only and never includes them in emits", async () => { - const review = makeReview( - [new ReviewCell(textQuestion, "12", null, null, false)], - null, - [{ name: "ghost", value: "zzz" }] - ); - const wrapper = mount(ProjectionReviewForm, { props: { review }, global: { stubs } }); - - expect(wrapper.text()).toContain("review.orphanedValues"); - expect(wrapper.text()).toContain("ghost"); - await wrapper.get("[data-test='submit-r-1']").trigger("click"); - expect(wrapper.emitted("submit")![0][1]).not.toHaveProperty("ghost"); - }); - - it("emits discard with the record id and renders submit errors passed back by the page", async () => { - const review = makeReview([new ReviewCell(textQuestion, null, null, null, false)]); - const wrapper = mount(ProjectionReviewForm, { - props: { review, submitErrors: { "r-1": ["missing value for required question: size"] } }, - global: { stubs }, - }); - - expect(wrapper.text()).toContain("missing value for required question: size"); - await wrapper.get("[data-test='discard-r-1']").trigger("click"); - expect(wrapper.emitted("discard")).toEqual([["r-1"]]); - }); -}); -``` - -Run: `npx vitest run components/v2/review --reporter=verbose` — expected FAIL (component missing). - - [ ] **Step 5: Write the components** Create `extralit-frontend/components/v2/review/ReviewProvenance.vue`: @@ -3904,174 +3786,6 @@ export default defineComponent({ ``` -Create `extralit-frontend/components/v2/review/ReviewRecordCard.vue`: - -```vue - - - - - -``` - -Note: `ReviewRecordCard` gets the pinned version's `columnsCache` (for table sub-column editors) from `record.columnsCache`, which Task 11's `ReviewRecord` already carries (last constructor param, defaulted to `[]` so this task's test fixtures can omit it). - -Create `extralit-frontend/components/v2/review/ProjectionReviewForm.vue`: - -```vue - - - -``` - [ ] **Step 6: Run tests to verify they pass** @@ -4087,232 +3801,6 @@ git commit -m "feat(v2-ui): pure ProjectionReviewForm with per-type widgets, pro --- -### Task 15: `/references/[...reference]` page + view-model - -**Files:** -- Create: `extralit-frontend/pages/references/[...reference].vue` -- Create: `extralit-frontend/pages/references/useReferenceReviewViewModel.ts` -- Test: `extralit-frontend/pages/references/useReferenceReviewViewModel.test.ts` - -**Interfaces:** -- Consumes: `GetReferenceReviewUseCase` (Task 11); `SubmitReferenceReviewUseCase` / `SaveReviewDraftUseCase` / `DiscardReviewUseCase` / `ReviewSubmitError` (Task 12); `useReferenceReviews` (Task 11); `ProjectionReviewForm` (Task 14); `useNotifications` (v1 services, allowed). -- Produces: route `/references/?workspace_id=`; `useReferenceReviewViewModel(reference: string, workspaceId: string): { review, isLoading, loadFailed, submitErrors, onSubmit, onSaveDraft, onDiscard }`. The page is deliberately the *first thin wrapper* around the form (route param in, composable + form, nothing else — spec §7). - -- [ ] **Step 1: Write the failing view-model test** - -Create `extralit-frontend/pages/references/useReferenceReviewViewModel.test.ts`: - -```ts -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { createPinia, setActivePinia } from "pinia"; -import { useResolveMock } from "~/v1/di/__mocks__/useResolveMock"; -import { GetReferenceReviewUseCase } from "~/v2/domain/usecases/get-reference-review-use-case"; -import { SubmitReferenceReviewUseCase, ReviewSubmitError } from "~/v2/domain/usecases/submit-reference-review-use-case"; -import { SaveReviewDraftUseCase } from "~/v2/domain/usecases/save-review-draft-use-case"; -import { DiscardReviewUseCase } from "~/v2/domain/usecases/discard-review-use-case"; -import { ReferenceReview } from "~/v2/domain/entities/review/ReferenceReview"; -import { useReferenceReviewViewModel } from "./useReferenceReviewViewModel"; - -vi.mock("~/v1/infrastructure/services/useNotifications", () => ({ - useNotifications: () => ({ notify: vi.fn() }), -})); -vi.mock("~/v1/infrastructure/services/useTranslate", () => ({ - useTranslate: () => ({ t: (key: string) => key, tc: (key: string) => key }), -})); - -const REVIEW = new ReferenceReview("10.1000/j.x", [], 0); - -describe("useReferenceReviewViewModel", () => { - beforeEach(() => setActivePinia(createPinia())); - - it("loads the review on mount-equivalent call and exposes it", async () => { - const execute = vi.fn(async () => REVIEW); - useResolveMock(GetReferenceReviewUseCase, { execute }); - useResolveMock(SubmitReferenceReviewUseCase, { execute: vi.fn() }); - useResolveMock(SaveReviewDraftUseCase, { execute: vi.fn() }); - useResolveMock(DiscardReviewUseCase, { execute: vi.fn() }); - - const vm = useReferenceReviewViewModel("10.1000/j.x", "w-1"); - await vm.load(); - - expect(execute).toHaveBeenCalledWith("10.1000/j.x", "w-1"); - expect(vm.review.value).toBe(REVIEW); - }); - - it("collects normalized 422 messages per record on submit failure, then clears on success", async () => { - useResolveMock(GetReferenceReviewUseCase, { execute: vi.fn(async () => REVIEW) }); - const submit = vi - .fn() - .mockRejectedValueOnce(new ReviewSubmitError(["missing value for required question: size"], 422)) - .mockResolvedValueOnce({ id: "resp" }); - useResolveMock(SubmitReferenceReviewUseCase, { execute: submit }); - useResolveMock(SaveReviewDraftUseCase, { execute: vi.fn() }); - useResolveMock(DiscardReviewUseCase, { execute: vi.fn() }); - - const vm = useReferenceReviewViewModel("10.1000/j.x", "w-1"); - await vm.onSubmit("r-1", {}); - expect(vm.submitErrors.value["r-1"]).toEqual(["missing value for required question: size"]); - - await vm.onSubmit("r-1", { size: "12" }); - expect(vm.submitErrors.value["r-1"]).toBeUndefined(); - }); - - it("reloads the review after a successful submit so the projection flips to response", async () => { - const load = vi.fn(async () => REVIEW); - useResolveMock(GetReferenceReviewUseCase, { execute: load }); - useResolveMock(SubmitReferenceReviewUseCase, { execute: vi.fn(async () => ({ id: "resp" })) }); - useResolveMock(SaveReviewDraftUseCase, { execute: vi.fn() }); - useResolveMock(DiscardReviewUseCase, { execute: vi.fn() }); - - const vm = useReferenceReviewViewModel("10.1000/j.x", "w-1"); - await vm.load(); - await vm.onSubmit("r-1", { size: "12" }); - - expect(load).toHaveBeenCalledTimes(2); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd extralit-frontend && npx vitest run pages/references --reporter=verbose` -Expected: FAIL. - -- [ ] **Step 3: Write the view-model and page** - -Create `extralit-frontend/pages/references/useReferenceReviewViewModel.ts`: - -```ts -import { ref } from "vue"; -import { useResolve } from "ts-injecty"; -import { GetReferenceReviewUseCase } from "~/v2/domain/usecases/get-reference-review-use-case"; -import { SubmitReferenceReviewUseCase, ReviewSubmitError } from "~/v2/domain/usecases/submit-reference-review-use-case"; -import { SaveReviewDraftUseCase } from "~/v2/domain/usecases/save-review-draft-use-case"; -import { DiscardReviewUseCase } from "~/v2/domain/usecases/discard-review-use-case"; -import { ReferenceReview } from "~/v2/domain/entities/review/ReferenceReview"; -import { useNotifications } from "~/v1/infrastructure/services/useNotifications"; -import { useTranslate } from "~/v1/infrastructure/services/useTranslate"; - -export const useReferenceReviewViewModel = (reference: string, workspaceId: string) => { - const getReviewUseCase = useResolve(GetReferenceReviewUseCase); - const submitUseCase = useResolve(SubmitReferenceReviewUseCase); - const saveDraftUseCase = useResolve(SaveReviewDraftUseCase); - const discardUseCase = useResolve(DiscardReviewUseCase); - const notifications = useNotifications(); - const { t } = useTranslate(); - - const review = ref(null); - const isLoading = ref(false); - const loadFailed = ref(false); - const submitErrors = ref>({}); - - const load = async () => { - isLoading.value = true; - loadFailed.value = false; - try { - review.value = await getReviewUseCase.execute(reference, workspaceId); - } catch { - loadFailed.value = true; - } finally { - isLoading.value = false; - } - }; - - const runAction = async (recordId: string, action: () => Promise, successKey: string) => { - try { - await action(); - const { [recordId]: _cleared, ...rest } = submitErrors.value; - submitErrors.value = rest; - notifications.notify({ message: t(successKey), type: "success" }); - await load(); // re-read: projection source flips response/suggestion server-side - } catch (error) { - if (error instanceof ReviewSubmitError) { - submitErrors.value = { ...submitErrors.value, [recordId]: error.messages }; - } else { - throw error; - } - } - }; - - const onSubmit = (recordId: string, values: Record) => - runAction(recordId, () => submitUseCase.execute(recordId, values), "review.submitted"); - const onSaveDraft = (recordId: string, values: Record) => - runAction(recordId, () => saveDraftUseCase.execute(recordId, values), "review.draftSaved"); - const onDiscard = (recordId: string) => - runAction(recordId, () => discardUseCase.execute(recordId), "review.discarded"); - - return { review, isLoading, loadFailed, submitErrors, load, onSubmit, onSaveDraft, onDiscard }; -}; -``` - -Create `extralit-frontend/pages/references/[...reference].vue`: - -```vue - - - - - -``` - -- [ ] **Step 4: Run tests and full suite** - -Run: `cd extralit-frontend && npx vitest run pages/references --reporter=verbose && npm run test` -Expected: green. - -- [ ] **Step 5: Live smoke against the local stack (optional but recommended)** - -With the server stack up (`docker-compose up -d` at repo root; `cd extralit-server && uv run python -m extralit_server server-dev`), run `npm run dev`, seed a schema/records via the SDK or the Task 16 seed script, and open `/references/?workspace_id=`. Verify the form renders and a submit round-trips. - -- [ ] **Step 6: Commit** - -```bash -git add pages/references -git commit -m "feat(v2-ui): reference review page wrapping ProjectionReviewForm via composable" -``` - --- ### Task 16: e2e infrastructure (remote-chromium project, seed script) + scenarios 1–2 @@ -4322,7 +3810,6 @@ git commit -m "feat(v2-ui): reference review page wrapping ProjectionReviewForm - Create: `extralit-frontend/e2e/v2/fixtures.ts` - Create: `extralit-frontend/e2e/v2/seed/seed_v2_e2e.py` - Create: `extralit-frontend/e2e/v2/auth-smoke.spec.ts` (scenario 1) -- Create: `extralit-frontend/e2e/v2/slashed-reference.spec.ts` (scenario 2) - Create: `extralit-frontend/e2e/v2/README.md` (how to run on this host) **Interfaces:** @@ -4572,35 +4059,6 @@ test("signs in with a bearer token, lists schemas, opens records", async ({ page Note: the schema list loads for the *selected workspace* — if the seeded workspace isn't the first, switch to it via the workspace selector before asserting (check the home header UI for the selector; add the interaction here once visible). -- [ ] **Step 5: Scenario 2 — slashed-DOI reference** - -Create `extralit-frontend/e2e/v2/slashed-reference.spec.ts`: - -```ts -import { expect, loadSeed, signIn, test } from "./fixtures"; - -// Seam B (spec §10.1-B): %2F-encoded DOI through Nuxt devProxy + uvicorn, untested server-side -// for the projection route. Assert both v2 reference endpoints round-trip. -test("opens a reference containing a slash via the encoded URL", async ({ page }) => { - const seed = loadSeed(); - await signIn(page); - - const projectionRequest = page.waitForResponse( - (r) => r.url().includes("/api/v2/projection/references/") && r.request().method() === "GET" - ); - const recordsRequest = page.waitForResponse( - (r) => r.url().includes(`/api/v2/schemas/${seed.schemaId}/records`) && r.request().method() === "GET" - ); - - await page.goto(`/references/${encodeURIComponent(seed.reference)}?workspace_id=${seed.workspaceId}`); - - expect((await projectionRequest).status()).toBe(200); - expect((await recordsRequest).status()).toBe(200); - - await expect(page.getByText(seed.reference)).toBeVisible(); - await expect(page.locator("[data-question='size']")).toBeVisible(); -}); -``` - [ ] **Step 6: README + run** @@ -4638,102 +4096,13 @@ git commit -m "test(v2-ui): e2e infra (CDP remote chromium, API seeding) + auth ### Task 17: e2e scenarios 3–5 (review loop, draft lifecycle, search round-trip) **Files:** -- Create: `extralit-frontend/e2e/v2/review-loop.spec.ts` (scenario 3) -- Create: `extralit-frontend/e2e/v2/draft-lifecycle.spec.ts` (scenario 4) - Create: `extralit-frontend/e2e/v2/search-roundtrip.spec.ts` (scenario 5) **Interfaces:** - Consumes: Task 16 fixtures/seed (`loadSeed`, `signIn`, seeded suggestion on question `size` with agent `e2e-seeder`), Task 14 form DOM contract (`[data-question]`, `[data-test='submit-']`, provenance badges rendering `review.suggestion`/`review.response` copy — i.e. "Suggestion"/"Response" in English). - Produces: the remaining slice-gating scenarios (spec §10.2 items 3–5). Scenarios 6–8 are follow-ups, out of this plan. -**IMPORTANT — verify seam C server behavior FIRST (spec §10.1-C):** the draft/discard response lifecycle has zero server tests. Before writing scenario 4's assertions, run the three `curl`s below against the live stack and confirm: (a) a `draft` response does NOT flip the projection cell (`source` stays `suggestion`), (b) `discarded` reverts a previously submitted cell to the suggestion. If either fails, STOP and report upstream — file it in the spec ledger; do not code the frontend around broken semantics. - -```bash -TOKEN=$(curl -s -X POST http://localhost:6900/api/v2/token -d "username=extralit&password=12345678" | jq -r .access_token) -RID=$(jq -r .recordId e2e/v2/seed/seed-output.json) -curl -s -X PUT "http://localhost:6900/api/v2/records/$RID/responses" -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" -d '{"values": {"size": {"value": "999"}}, "status": "draft"}' -curl -s "http://localhost:6900/api/v2/projection/references/10.1000%2Fj.e2e-v2?workspace_id=$(jq -r .workspaceId e2e/v2/seed/seed-output.json)" \ - -H "Authorization: Bearer $TOKEN" | jq '.records[0].cells[] | select(.question_name=="size")' -# expect: value "120", source "suggestion" (draft must NOT project) -``` - -- [ ] **Step 1: Scenario 3 — suggestion→response conversion loop** - -Create `extralit-frontend/e2e/v2/review-loop.spec.ts`: - -```ts -import { expect, loadSeed, signIn, test } from "./fixtures"; - -// The core product loop (spec §10.2-3): suggestion shown with provenance → edit → submit → -// projection re-read flips source to response. Never chained over HTTP in the server suites. -test("converts a suggestion into a submitted response", async ({ page }) => { - const seed = loadSeed(); - await signIn(page); - await page.goto(`/references/${encodeURIComponent(seed.reference)}?workspace_id=${seed.workspaceId}`); - - const sizeCell = page.locator("[data-question='size']"); - await expect(sizeCell).toBeVisible(); - await expect(sizeCell.getByText("Suggestion")).toBeVisible(); - await expect(sizeCell.getByText("e2e-seeder")).toBeVisible(); - - // Edit the text answer (ContentEditableFeedbackTask renders a contenteditable paragraph). - const editor = sizeCell.locator("[contenteditable]"); - await editor.click(); - await editor.fill("135"); - - const putResponse = page.waitForResponse( - (r) => r.url().includes(`/api/v2/records/${seed.recordId}/responses`) && r.request().method() === "PUT" - ); - await page.locator(`[data-test='submit-${seed.recordId}']`).click(); - expect((await putResponse).status()).toBe(200); - // Reload: the projection must now resolve from the submitted response. - await page.reload(); - await expect(sizeCell.getByText("Response")).toBeVisible(); - await expect(sizeCell.getByText("Suggestion")).not.toBeVisible(); -}); -``` - -(If `contenteditable.fill` is flaky over CDP, use `editor.click()` + `page.keyboard` select-all/type. Reset state for re-runs by re-running the seed script — it recreates the schema.) - -- [ ] **Step 2: Scenario 4 — draft lifecycle** - -Create `extralit-frontend/e2e/v2/draft-lifecycle.spec.ts`: - -```ts -import { expect, loadSeed, signIn, test } from "./fixtures"; - -// Seam C (spec §10.1-C): drafts have ZERO server-side tests. This spec is the gate: -// a draft restores into the form on reload while the projection still shows the suggestion; -// submitting then flips the projection to response. -test("draft persists in the form without touching the projection, then submits", async ({ page }) => { - const seed = loadSeed(); - await signIn(page); - await page.goto(`/references/${encodeURIComponent(seed.reference)}?workspace_id=${seed.workspaceId}`); - - const sizeCell = page.locator("[data-question='size']"); - const editor = sizeCell.locator("[contenteditable]"); - await editor.click(); - await editor.fill("777"); - - const draftPut = page.waitForResponse( - (r) => r.url().includes(`/records/${seed.recordId}/responses`) && r.request().method() === "PUT" - ); - await page.locator(`[data-test='save-draft-${seed.recordId}']`).click(); - expect((await draftPut).status()).toBe(200); - - await page.reload(); - // Form restores the draft value... - await expect(sizeCell.locator("[contenteditable]")).toHaveText("777"); - // ...but the projection still resolves the suggestion (draft must not project). - await expect(sizeCell.getByText("Suggestion")).toBeVisible(); - - await page.locator(`[data-test='submit-${seed.recordId}']`).click(); - await page.reload(); - await expect(sizeCell.getByText("Response")).toBeVisible(); -}); -``` - [ ] **Step 3: Scenario 5 — search round-trip** @@ -4793,13 +4162,11 @@ git commit -m "test(v2-ui): e2e review loop, draft lifecycle and search round-tr - [ ] `cd extralit-server && uv run pytest tests/unit --disable-warnings` — green. - [ ] `cd extralit-frontend && npm run gen:api && git diff --exit-code -- v2/infrastructure/api` — no drift. - [ ] `cd extralit-frontend && npm run test && npm run lint && npx nuxi typecheck` — green (typecheck may surface pre-existing v1 issues; only new `v2/`/`components/v2` errors block). -- [ ] Full e2e: seed + scenarios 1–5 green against the live stack. - [ ] Boundary audit: `grep -rn "from \"[~@]/v1" extralit-frontend/v2/ | grep -v "store/create\|infrastructure/services"` → empty; `grep -rn "v2/" extralit-frontend/v1/` → empty. - [ ] Use superpowers:finishing-a-development-branch — PR against `develop`. ## Deferred / ledger (do not build now) - Scenarios 6–8 (multi-annotator isolation, old-version rendering e2e, required-422 rendering e2e) — follow the slice (spec §10.2). -- Server-side projection payload enrichment if the 5-endpoint `ReferenceReview` assembly proves chatty (spec §7 ledger). - Column filters on the schema detail page derived from `columns_cache` (only `status` ships now). - Markdown/table sub-modes of the text widget; span questions; schema authoring UI; Queue UI (Phase 5); v1 retirements (Phase 6). diff --git a/docs/superpowers/plans/2026-07-20-extraction-table.md b/docs/superpowers/plans/2026-07-20-extraction-table.md index 829982130..593a51392 100644 --- a/docs/superpowers/plans/2026-07-20-extraction-table.md +++ b/docs/superpowers/plans/2026-07-20-extraction-table.md @@ -8,7 +8,7 @@ - **Phase 1 (Tasks 1–7, PR 1):** everything additive and low-risk — server contract (validator extension, enriched cells, workspace endpoint) plus the frontend data layer (gen:api, domain types, repository, grid adapter, use-case, storage, DI). No UI, no deletions, no new frontend dependencies (the server gains `duckdb`). Mergeable to `develop` on its own; SDK #231 and any other consumer can build against the endpoint immediately. - **Phase 2 (Tasks 8–13, PR 2, stacked on Phase 1):** the integration-risk half — Perspective deps/WASM/Vite wiring, the custom-element grid wrapper, the `/extractions` page, deletion of the reference-review page, and the e2e gate. If Perspective 4.5.2 fights back (WASM boot, init API drift, custom-element quirks), Phase 1 is already merged and unaffected. -**Architecture:** The server performs the full denormalization in `contexts/v2/projection.py`: batched Postgres queries (via the existing `AsyncSession`) fetch raw slices, and an **in-memory DuckDB** connection runs ONE SQL statement that does everything semantic — effective-record dedup (window functions), `submitted response ?? suggestion` coalesce, table fan-out (`json_each`), the independent-stacking row spine, and scalar repetition. Python only registers inputs and regroups the long-format result into Pydantic models, so the transform stays declarative over arbitrary schema-defined JSON and pre-builds the spec §5 Arrow path (`.fetchall()` → `.arrow()` later streams Arrow IPC straight into Perspective). The client only pages, aggregates, and renders. Frontend follows the existing v2 DDD chain: `ProjectionRepository` → `GetWorkspaceProjectionUseCase` → Pinia storage → `useExtractionsViewModel` → `pages/extractions/index.vue`, with a Perspective web-component grid wrapped in `ExtractionsGrid.vue`. +**Architecture:** The server performs the full denormalization in `contexts/v2/projection.py`: batched Postgres queries (via the existing `AsyncSession`) fetch raw slices, and an **in-memory DuckDB** connection runs ONE SQL statement that does everything semantic — effective-record dedup (window functions), `submitted response ?? suggestion` coalesce, table fan-out (`json_each`), the independent-stacking row spine, and scalar repetition. Python only registers inputs and regroups the long-format result into Pydantic models, so the transform stays declarative over arbitrary schema-defined JSON and pre-builds the spec §5 Arrow path (`.fetchall()` → `.arrow()` later streams Arrow IPC straight into Perspective). The client only pages, aggregates, and renders. Frontend follows the existing v2 DDD chain: `ProjectionRepository` → `GetWorkspaceProjectionUseCase` → Pinia storage → `useExtractionsViewModel` → `pages/extractions/index.vue`, with a Perspective web-component grid wrapped in `ExtractionsGrid.client.vue`. **Tech Stack:** FastAPI + SQLAlchemy async + DuckDB (in-process denormalization engine) on extralit-server, Vue 3 / Nuxt 4 / Pinia / ts-injecty (extralit-frontend), Perspective `@perspective-dev/*` 4.5.2 (WASM), openapi-typescript contract gate, pytest / vitest / Playwright. @@ -19,7 +19,11 @@ - Branching: Phase 1 lands on `feat/v2-ui-extraction-table` (based on `develop` @ `52eab556f`) and PRs to `develop`. Phase 2 starts on a stacked branch `feat/v2-ui-extraction-grid` created from the Phase 1 branch (rebase onto `develop` once PR 1 merges; PR 2 targets `develop`). Commit per task, conventional-commit style (`feat(server): …`, `feat(v2-ui): …`, `test(…): …`). - Python: **uv only** (`uv run pytest`, `uv add`); never pip/poetry. All server commands run from `extralit-server/`. All frontend commands run from `extralit-frontend/`. - Perspective packages: `@perspective-dev/client`, `@perspective-dev/server`, `@perspective-dev/viewer`, `@perspective-dev/viewer-datagrid` — all pinned **4.5.2**. `@finos/*` is forbidden (frozen at 3.8.0). -- **Reuse-don't-fork** (spec §2): reuse `ProjectionRepository`, `useStoreFor` (v1 store factory), `useResolve`/ts-injecty DI, `InternalPage`/`AppHeader` layout, `e2e/v2/fixtures.ts`. v0's Tabulator `RenderTable` lineage is NOT reused. Kept for the future review drawer (do not delete): `widget-adapters.ts`, `widget-mapping.ts`, `SuggestionHint.ts`, `response-values.ts`, `ReviewCellInput.vue`, `ReviewProvenance.vue`, `submit-reference-review-use-case.ts`, `save-review-draft-use-case.ts`, `discard-review-use-case.ts` (each with tests). +- **Reuse-don't-fork** (spec §2): reuse `ProjectionRepository`, `useStoreFor` (v1 store factory), `useResolve`/ts-injecty DI, `InternalPage`/`AppHeader` layout, `e2e/v2/fixtures.ts`. v0's Tabulator `RenderTable` lineage is NOT reused. Kept for the future review drawer (do not delete): `response-values.ts`, `submit-reference-review-use-case.ts`, `save-review-draft-use-case.ts`, `discard-review-use-case.ts` (each with tests). **Superseded 2026-07-26** (ENG-32): the review *widgets* — `ReviewCellInput.vue`, `ReviewProvenance.vue`, `V2TableEditor.vue`, `widget-adapters.ts`, `widget-mapping.ts`, `SuggestionHint.ts`, `ReviewCell.ts` — were deleted when `components/v2/` was folded into `components/features/`. Their consumer chain (the reference-review page) had already been removed by `293466ae8`, leaving them unreachable. ENG-32 rebuilds the cell/record correction loop from scratch; `V2TableEditor` in particular should not come back as a fourth independent Tabulator boot now that v2 has standardised on Perspective. + + **Why the use-cases are kept while the widgets went, given both are equally unreachable:** the three use-cases encode a *server contract* — the double-wrapped `{q: {value}}` PUT body and its two distinct 422 shapes — which ENG-32 will re-consume unchanged, so deleting them would throw away knowledge that is expensive to re-derive and easy to get subtly wrong. The widgets encoded a *UI* that ENG-32 will redesign anyway, so keeping them would only preserve a shape nobody intends to rebuild. Same rationale keeps `response-values.ts`, which is additionally still live via `AnnotationRepository`. + + **Also retained for ENG-32, currently single-consumer:** `ColumnMeta.review` and the `review_widgets` overlay `SchemaRepository` maps into it (spec §13) are now written and never read — `widget-mapping.ts` was their only consumer and encoded the §6.2 dtype→editor precedence. Likewise the leaves extracted from v1 under `components/base/inputs/` (`ContentEditableFeedbackTask`, `LabelSelectionComponent`, `RatingMonoSelectionComponent`, `DndSelectionComponent`) were pulled out so the v2 widgets could reuse them (Task 9) and are back to having only v1 `components/features/annotation/**` consumers. Both look like dead weight to a future "single caller, fold it back" pass; they are deliberate ENG-32 groundwork, not leftovers. - **gen:api drift gate:** any server contract change requires `npm run gen:api` from `extralit-frontend/` and committing BOTH `v2/infrastructure/api/openapi.json` and `v2/infrastructure/api/generated/v2-api.ts`. Repositories must type against `components["schemas"][…]` from the generated file. - Frontend TS: `isolatedModules` — type-only imports MUST use the inline form `import { type X } from "…"`. - Pinia store key = the state class's constructor name (`useStoreFor(Ctor)` → `defineStore(Ctor.name, …)`); the class name must be unique across ALL v1+v2 stores. @@ -41,7 +45,7 @@ - `v2/domain/usecases/get-workspace-projection-use-case.ts` (+`.test.ts`) — pages through endpoint, saves to storage - `v2/infrastructure/storage/ExtractionsStorage.ts` — `useExtractions` Pinia store - `components/v2/extractions/perspective-bootstrap.ts` — WASM init singleton (`__mocks__/perspective-bootstrap.js` stub) -- `components/v2/extractions/ExtractionsGrid.vue` (+`.test.ts`) — Perspective viewer wrapper: load, banding, click plumbing +- `components/v2/extractions/ExtractionsGrid.client.vue` (+`.test.ts`) — Perspective viewer wrapper: load, banding, click plumbing - `pages/extractions/index.vue`, `pages/extractions/useExtractionsViewModel.ts` (+`.test.ts`) — the `/extractions` route - `e2e/v2/extractions-grid.spec.ts` — replacement e2e gate @@ -1211,7 +1215,7 @@ Tasks 8–13, on `feat/v2-ui-extraction-grid`. This phase carries the integratio - Create: `extralit-frontend/__mocks__/perspective-bootstrap.js` **Interfaces:** -- Produces: `initPerspective(): Promise` — module-level singleton; resolves once WASM is initialized and returns the `@perspective-dev/client` default export (so callers do `const perspective = await initPerspective(); const client = await perspective.worker();`). Task 9 imports it via the alias-stable specifier `~/components/v2/extractions/perspective-bootstrap`. +- Produces: `initPerspective(): Promise` — module-level singleton; resolves once WASM is initialized and returns the `@perspective-dev/client` default export. Task 9 imports it via the alias-stable specifier `~/components/v2/extractions/perspective-bootstrap`. - The vitest stub replaces the whole bootstrap module, so unit tests never touch WASM/custom elements (mirrors the `tabulator-tables` mock precedent). - [ ] **Step 1: Install pinned packages** @@ -1299,21 +1303,6 @@ If `init_server`/`init_client` names differ in 4.5.2, check `node_modules/@persp - [ ] **Step 5: Create the vitest stub and alias it** -Create `__mocks__/perspective-bootstrap.js`: - -```js -// Perspective touches WASM + custom elements at import time; specs use this stub -// (same rationale as __mocks__/tabulator-tables.js). -export const initPerspective = async () => ({ - worker: async () => ({ - table: async (data) => ({ - __data: data, - size: async () => data.length, - delete: async () => undefined, - }), - }), -}); -``` In `vitest.config.ts`, add to the `resolve.alias` object ABOVE the `"~~"` entry (longer keys must win before `"~"` matches): @@ -1337,75 +1326,31 @@ git commit -m "feat(v2-ui): Perspective 4.5.2 deps, WASM bootstrap, Vite/vitest --- -### Task 9: `ExtractionsGrid.vue` — viewer wrapper with banding + click plumbing (frontend) +### Task 9: `ExtractionsGrid.client.vue` — viewer wrapper with banding + click plumbing (frontend) **Files:** -- Create: `extralit-frontend/components/v2/extractions/ExtractionsGrid.vue` -- Test: `extralit-frontend/components/v2/extractions/ExtractionsGrid.test.ts` +- Create: `extralit-frontend/components/v2/extractions/ExtractionsGrid.client.vue` +- Test: `extralit-frontend/components/v2/extractions/ExtractionsGrid.client.test.ts` **Interfaces:** - Consumes: `initPerspective` (Task 8), `toPerspectiveData`/`cellAt`/`bandParity` (Task 6), `WorkspaceProjection` (Task 5). - Produces: `` (auto-imported by name — `components` config uses `pathPrefix: false`). Emits `cell-click` with `{ cell: ProjectionGridCell; reference: string; schemaId: string; columnName: string }`. Task 10's page listens to this. -- Lazy chunking (spec §3.3): Nuxt code-splits per page and auto-imported components bundle into the chunks that use them — since only `pages/extractions/index.vue` uses this component, the Perspective JS/WASM cost is paid only on `/extractions`. Verify in Task 13's `npm run build` output: the perspective modules must NOT appear in the entry chunk. +- Lazy chunking (spec §3.3): Nuxt code-splits per page and auto-imported components bundle into the chunks that use them — since only `pages/extractions/index.vue` uses this component, the Perspective JS/WASM cost is paid only on `/extractions`. - Note: the custom-element/regular-table interactions cannot be exercised under happy-dom; the unit test covers mount → bootstrap → table creation → viewer load. The real rendering gate is Task 12's e2e spec. - [ ] **Step 1: Write the failing component test** -Create `components/v2/extractions/ExtractionsGrid.test.ts`: - -```ts -import { flushPromises, mount } from "@vue/test-utils"; -import { describe, expect, it, vi } from "vitest"; -import ExtractionsGrid from "./ExtractionsGrid.vue"; -import { WorkspaceProjection } from "~/v2/domain/entities/projection/WorkspaceProjection"; - -const tableSpy = vi.fn(async (data: unknown) => ({ __data: data, delete: async () => undefined })); -const initSpy = vi.fn(async () => ({ worker: async () => ({ table: tableSpy }) })); - -vi.mock("~/components/v2/extractions/perspective-bootstrap", () => ({ - initPerspective: (...args: unknown[]) => initSpy(...args), -})); - -const PROJECTION = new WorkspaceProjection( - [{ name: "Design.type", schemaId: "s-1", schemaName: "Design", questionName: "type", subColumn: null, dtype: "text" }], - [ - { - reference: "10.1/a", - rowIndex: 0, - cells: { - "Design.type": { value: "RCT", source: "response", recordId: "r-1", agent: null, score: null }, - }, - }, - ], - 1 -); - -describe("ExtractionsGrid", () => { - it("boots perspective once and loads the flat projection rows into a table", async () => { - mount(ExtractionsGrid, { - props: { projection: PROJECTION }, - global: { - config: { compilerOptions: { isCustomElement: (tag: string) => tag.startsWith("perspective-") } }, - }, - }); - await flushPromises(); - - expect(initSpy).toHaveBeenCalledTimes(1); - expect(tableSpy).toHaveBeenCalledWith([{ reference: "10.1/a", "Design.type": "RCT" }]); - }); -}); -``` - [ ] **Step 2: Run to verify failure** -Run: `cd extralit-frontend && npx vitest run components/v2/extractions/ExtractionsGrid.test.ts` +Run: `cd extralit-frontend && npx vitest run components/v2/extractions/ExtractionsGrid.client.test.ts` Expected: FAIL — component file not found. - [ ] **Step 3: Implement the component** -Create `components/v2/extractions/ExtractionsGrid.vue` — ` + + diff --git a/extralit-frontend/components/features/extractions/perspective-bootstrap.test.ts b/extralit-frontend/components/features/extractions/perspective-bootstrap.test.ts new file mode 100644 index 000000000..ffcd70715 --- /dev/null +++ b/extralit-frontend/components/features/extractions/perspective-bootstrap.test.ts @@ -0,0 +1,187 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * `perspective-bootstrap.ts` is aliased to a stub in `vitest.config.ts` (every OTHER spec + * that mounts `ExtractionsGrid` needs Perspective's WASM/custom-element boot kept out of the + * happy-dom environment entirely — see that alias's comment). This spec exercises the REAL + * module instead, by importing it with a path relative to this file (`./perspective-bootstrap`) + * rather than the `~/components/features/extractions/perspective-bootstrap` specifier the alias + * matches on. The `@perspective-dev/*` packages themselves are mocked below so importing the + * real module doesn't touch actual WASM/custom-element registration. + * + * The mock factories are typed against the real modules' `.d.ts` signatures (confirmed against + * the installed package — `@perspective-dev/client/dist/esm/perspective.browser.d.ts`'s + * `init_server(...): void` and `@perspective-dev/viewer/dist/esm/bootstrap.d.ts`'s + * `init_client(...): Promise`) via `satisfies Partial` so a future + * signature change that silently reintroduces this bug (`init_server` starting to look like it + * returns a promise, or vice versa) fails typecheck here rather than only in production. + */ + +const initServerSpy = vi.fn<(...args: unknown[]) => void>(); +const initClientSpy = vi.fn<(...args: unknown[]) => Promise>(); +const workerSpy = vi.fn(); + +vi.mock("@perspective-dev/client", () => { + const mod = { + init_server: (...args: unknown[]) => initServerSpy(...args), + worker: (...args: unknown[]) => workerSpy(...args), + } satisfies Partial; + return { default: mod }; +}); + +vi.mock("@perspective-dev/viewer", () => { + const mod = { + init_client: (...args: unknown[]) => initClientSpy(...args), + } satisfies Partial; + return { default: mod }; +}); + +vi.mock("@perspective-dev/viewer-datagrid", () => ({})); + +vi.mock("@perspective-dev/server/dist/wasm/perspective-server.wasm?url", () => ({ default: "server.wasm" })); +vi.mock("@perspective-dev/viewer/dist/wasm/perspective-viewer.wasm?url", () => ({ default: "viewer.wasm" })); + +const fetchMock = vi.fn(); + +beforeEach(() => { + vi.resetModules(); + initServerSpy.mockReset(); + initClientSpy.mockReset(); + workerSpy.mockReset(); + fetchMock.mockReset(); + // Production's actual retry-on-rejection trigger: a rejected `fetch(SERVER_WASM)` (or + // `fetch(CLIENT_WASM)`), NOT a rejected `init_server`/`init_client` call — `init_server` + // returns `void` in the real module, so it can never itself be the thing that rejects. + fetchMock.mockImplementation(async () => ({ ok: true })); + vi.stubGlobal("fetch", fetchMock); +}); + +describe("initPerspective", () => { + it("resets the memoized boot promise when fetch(SERVER_WASM) rejects, so the next call retries instead of replaying the same failure", async () => { + fetchMock.mockImplementationOnce(async () => { + throw new Error("network blip"); + }); + initServerSpy.mockImplementation(() => undefined); + initClientSpy.mockImplementation(() => Promise.resolve()); + + const { initPerspective } = await import("./perspective-bootstrap"); + + // Without the fix, `Promise.all([init_server(fetch(...)), init_client(fetch(...))])` + // settles on the viewer half alone — a rejected server-WASM fetch never reaches `attempt`, + // so this first call would resolve (falsely reporting a successful boot) instead of + // rejecting, and the rejected `fetch` would surface later as an unhandled rejection. + await expect(initPerspective()).rejects.toThrow("network blip"); + // Without the fix, `ready` stays set forever once non-null, so this second call would + // reject with the SAME "network blip" error (or, pre-fix, spuriously resolve) instead of + // performing a fresh, successful retry. + await expect(initPerspective()).resolves.toBeDefined(); + expect(fetchMock).toHaveBeenCalledTimes(4); // 2 wasm fetches x 2 attempts + expect(initServerSpy).toHaveBeenCalledTimes(1); // only the successful attempt reaches init_server + }); + + it("treats an HTTP error status on the server WASM as a boot failure, even though fetch itself resolves", async () => { + // The failure mode a *throwing* `fetch` mock cannot reach: `fetch` only rejects on a + // network-level failure, so a 404 (the redeploy case the module's own comment names) + // RESOLVES with `ok: false`. Unguarded, `init_server` stashes that 404 `Response`, the + // boot memoizes as *resolved*, and the real failure only surfaces later as an unhandled + // rejection when `worker()` awaits the stashed promise. + fetchMock.mockImplementationOnce(async () => ({ ok: false, status: 404 })); + initServerSpy.mockImplementation(() => undefined); + initClientSpy.mockImplementation(() => Promise.resolve()); + + const { initPerspective } = await import("./perspective-bootstrap"); + + await expect(initPerspective()).rejects.toThrow(/server\.wasm → 404/); + // The rejected attempt must never have handed the error response on to the engine... + expect(initServerSpy).not.toHaveBeenCalled(); + // ...and the memo must have reset, so a redeploy that fixes the asset recovers without + // a hard page reload. + await expect(initPerspective()).resolves.toBeDefined(); + expect(initServerSpy).toHaveBeenCalledTimes(1); + }); + + it("resets the memoized boot promise when init_client rejects, so the next call retries instead of replaying the same failure", async () => { + initServerSpy.mockImplementation(() => undefined); + initClientSpy.mockImplementationOnce(() => Promise.reject(new Error("client init failed"))); + initClientSpy.mockImplementation(() => Promise.resolve()); + + const { initPerspective } = await import("./perspective-bootstrap"); + + await expect(initPerspective()).rejects.toThrow("client init failed"); + await expect(initPerspective()).resolves.toBeDefined(); + expect(initClientSpy).toHaveBeenCalledTimes(2); + }); + + it("propagates the rejection to every caller sharing the failed in-flight attempt", async () => { + fetchMock.mockImplementation(async () => { + throw new Error("network blip"); + }); + initServerSpy.mockImplementation(() => undefined); + initClientSpy.mockImplementation(() => Promise.resolve()); + + const { initPerspective } = await import("./perspective-bootstrap"); + + const [a, b] = await Promise.allSettled([initPerspective(), initPerspective()]); + expect(a.status).toBe("rejected"); + expect(b.status).toBe("rejected"); + // Both callers raced the SAME attempt, so only one underlying fetch pair should have run. + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("initializes exactly once across concurrent and sequential calls on a successful boot, and a later call reuses the memo without re-running the boot", async () => { + initServerSpy.mockImplementation(() => undefined); + initClientSpy.mockImplementation(() => Promise.resolve()); + + const { initPerspective } = await import("./perspective-bootstrap"); + + const [a, b] = await Promise.all([initPerspective(), initPerspective()]); + const c = await initPerspective(); + + expect(a).toBe(b); + expect(b).toBe(c); + expect(initServerSpy).toHaveBeenCalledTimes(1); + expect(initClientSpy).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(2); + + // A resolved boot is never reset: a THIRD call after the successful retry-free run above + // must still reuse the same memoized promise instead of re-running init_server/init_client. + const d = await initPerspective(); + expect(d).toBe(c); + expect(initServerSpy).toHaveBeenCalledTimes(1); + expect(initClientSpy).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); + +describe("initPerspectiveClient", () => { + it("resets the memoized client promise on rejection so the next call retries instead of replaying the same failure", async () => { + initServerSpy.mockImplementation(() => undefined); + initClientSpy.mockImplementation(() => Promise.resolve()); + workerSpy.mockImplementationOnce(() => Promise.reject(new Error("worker boot failed"))); + workerSpy.mockImplementation(() => Promise.resolve({ marker: "client" })); + + const { initPerspectiveClient } = await import("./perspective-bootstrap"); + + await expect(initPerspectiveClient()).rejects.toThrow("worker boot failed"); + await expect(initPerspectiveClient()).resolves.toEqual({ marker: "client" }); + expect(workerSpy).toHaveBeenCalledTimes(2); + }); + + it("shares exactly one client across concurrent and sequential calls, calling perspective.worker() exactly once", async () => { + initServerSpy.mockImplementation(() => undefined); + initClientSpy.mockImplementation(() => Promise.resolve()); + workerSpy.mockImplementation(() => Promise.resolve({ marker: "client" })); + + const { initPerspectiveClient } = await import("./perspective-bootstrap"); + + const [a, b] = await Promise.all([initPerspectiveClient(), initPerspectiveClient()]); + const c = await initPerspectiveClient(); + + expect(a).toBe(b); + expect(b).toBe(c); + // This is the crux of the worker/WASM-heap leak fix: every mount of `ExtractionsGrid` + // must share this ONE client rather than each calling `perspective.worker()` (which + // constructs a brand-new Web Worker + WASM server instance every time) for itself. + expect(workerSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/extralit-frontend/components/features/extractions/perspective-bootstrap.ts b/extralit-frontend/components/features/extractions/perspective-bootstrap.ts new file mode 100644 index 000000000..f4e7a7f0b --- /dev/null +++ b/extralit-frontend/components/features/extractions/perspective-bootstrap.ts @@ -0,0 +1,101 @@ +import perspective from "@perspective-dev/client"; +import perspective_viewer from "@perspective-dev/viewer"; +import "@perspective-dev/viewer-datagrid"; +import SERVER_WASM from "@perspective-dev/server/dist/wasm/perspective-server.wasm?url"; +import CLIENT_WASM from "@perspective-dev/viewer/dist/wasm/perspective-viewer.wasm?url"; + +// SPA (ssr: false): this runs client-side only. Module-level guard so the WASM engines +// initialize exactly once no matter how often the page remounts (spec §3.3). +// +// `ready` memoizes the boot promise so concurrent callers (e.g. two fast remounts) share one +// in-flight WASM init instead of racing multiple `init_server`/`init_client` calls. If that +// boot rejects — either `fetch` itself rejecting (offline, a 404 right after a redeploy) or +// `init_client` rejecting — `ready` is reset back to `null` so the *next* call starts a fresh +// attempt instead of replaying the same rejection forever: without the reset, one failed boot +// would permanently brick every later mount until a hard page reload. A *resolved* boot is +// never reset, so the success path still only ever runs once. The `.catch` below only resets +// the module-level memo — it does not swallow the rejection for the caller, who still receives +// it via the `ready` promise returned below. +// +// Note: `perspective.init_server` (from `@perspective-dev/client`) returns `void`, not a +// promise — it only stashes whatever is passed to it. Awaiting the two `fetch()` calls +// ourselves (rather than handing `init_server`/`init_client` the in-flight fetch promises +// directly, as `Promise.all([init_server(fetch(...)), init_client(fetch(...))])` would) is +// what lets a rejected `fetch(SERVER_WASM)` actually reach this `attempt` and trigger the +// retry-on-rejection reset below, instead of surfacing later as an unhandled rejection when +// `worker()` awaits the stashed promise. +let ready: Promise | null = null; + +// `fetch` only rejects on a network-level failure — an HTTP error status *resolves* with +// `ok: false`. Without this check the headline case named above (a 404 right after a +// redeploy) never reaches `attempt`: both fetches resolve, `init_server` stashes the 404 +// `Response`, and the failure re-emerges later as an unhandled rejection when `worker()` +// awaits the stashed promise, with `ready` already memoized as *resolved*. Throwing here is +// what puts an HTTP failure on the same path as a network failure. +// Known limitation, accepted deliberately: this covers the *status*, not the bytes. A 200 +// carrying truncated or corrupt WASM still passes, and since `init_server` only stashes the +// `Response`, the server half's compile failure resurfaces exactly as an unhandled rejection +// from `worker()` with `ready` already memoized as resolved — the very mode the retry above +// exists to kill. Reading the body here (`init_server(await response.arrayBuffer())`) would +// close it, but both engines pass a `Response` to `WebAssembly.instantiateStreaming` (see +// `@perspective-dev/client/dist/esm/perspective.js`), so materializing an ArrayBuffer first +// gives up streaming compilation on every boot to catch a failure that only a corrupted CDN +// object produces. Status errors are the reachable case and are handled; byte-level +// corruption is knowingly out of reach. +const assertOk = (response: Response, url: string): Response => { + if (!response.ok) { + throw new Error(`Perspective WASM fetch failed: ${url} → ${response.status}`); + } + return response; +}; + +export const initPerspective = (): Promise => { + if (!ready) { + const attempt: Promise = (async () => { + const [serverWasm, clientWasm] = await Promise.all([ + fetch(SERVER_WASM).then((response) => assertOk(response, SERVER_WASM)), + fetch(CLIENT_WASM).then((response) => assertOk(response, CLIENT_WASM)), + ]); + perspective.init_server(serverWasm); + await perspective_viewer.init_client(clientWasm); + return perspective; + })(); + attempt.catch(() => { + // Only clear the memo if nothing has raced in a newer attempt already (can't happen + // in practice — a new attempt is only created when `ready` is falsy — but guards + // against clobbering a fresher attempt if this ever changes). + if (ready === attempt) { + ready = null; + } + }); + ready = attempt; + } + return ready; +}; + +type PerspectiveClient = Awaited>; + +// `perspective.worker()` constructs a brand-new Web Worker (+ its own WASM server +// instance) on *every* call — confirmed in +// `@perspective-dev/client/dist/esm/perspective.js`'s `worker()`/`pe()` helpers, which +// always `new Worker(...)`. Only `Client.terminate()` +// (`@perspective-dev/client/dist/wasm/perspective-js.d.ts`'s `Client.terminate()`) runs the +// close callback that actually calls `Worker.terminate()`. Memoizing the client here — +// instead of every `ExtractionsGrid` mount calling `perspective.worker()` for itself — means +// exactly one worker/WASM heap is ever live for the app's session, no matter how many times +// `/extractions` is mounted and unmounted. Mirrors `ready`'s retry-on-rejection behavior for +// the same reason: a transient worker-boot failure must not brick every later mount. +let clientReady: Promise | null = null; + +export const initPerspectiveClient = (): Promise => { + if (!clientReady) { + const attempt = initPerspective().then((p) => p.worker()); + attempt.catch(() => { + if (clientReady === attempt) { + clientReady = null; + } + }); + clientReady = attempt; + } + return clientReady; +}; diff --git a/extralit-frontend/components/v2/common/V2Empty.vue b/extralit-frontend/components/features/global/EmptyState.vue similarity index 78% rename from extralit-frontend/components/v2/common/V2Empty.vue rename to extralit-frontend/components/features/global/EmptyState.vue index 26b242b3e..656976372 100644 --- a/extralit-frontend/components/v2/common/V2Empty.vue +++ b/extralit-frontend/components/features/global/EmptyState.vue @@ -1,7 +1,7 @@ @@ -10,13 +10,13 @@ import { defineComponent } from "vue"; export default defineComponent({ - name: "V2Empty", + name: "EmptyState", props: { message: { type: String, default: "" } }, }); diff --git a/extralit-frontend/components/v2/review/ReviewProvenance.vue b/extralit-frontend/components/v2/review/ReviewProvenance.vue deleted file mode 100644 index 0903cdc0c..000000000 --- a/extralit-frontend/components/v2/review/ReviewProvenance.vue +++ /dev/null @@ -1,44 +0,0 @@ - - - - - diff --git a/extralit-frontend/components/v2/review/ReviewRecordCard.vue b/extralit-frontend/components/v2/review/ReviewRecordCard.vue deleted file mode 100644 index 1ac6da284..000000000 --- a/extralit-frontend/components/v2/review/ReviewRecordCard.vue +++ /dev/null @@ -1,137 +0,0 @@ - - - - - diff --git a/extralit-frontend/components/v2/table/V2TableEditor.test.ts b/extralit-frontend/components/v2/table/V2TableEditor.test.ts deleted file mode 100644 index 1e7daaeea..000000000 --- a/extralit-frontend/components/v2/table/V2TableEditor.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { beforeEach, describe, expect, it } from "vitest"; -import { defineComponent, h, ref } from "vue"; -import { flushPromises, mount } from "@vue/test-utils"; -import { TabulatorFull } from "tabulator-tables"; -import { ColumnMeta } from "~/v2/domain/entities/schema/ColumnMeta"; -import V2TableEditor, { tabulatorColumns, valueFromRowData } from "./V2TableEditor.vue"; - -// The vitest alias resolves tabulator-tables to __mocks__/tabulator-tables.js, which adds -// these static test hooks; the real @types don't declare them, so view them through a cast. -const MockTabulator = TabulatorFull as unknown as { - constructed: number; - latest: { emit(event: string, ...args: unknown[]): void } | null; -}; - -describe("V2TableEditor column derivation", () => { - it("derives one tabulator column per bound ColumnMeta with dtype-driven editors", () => { - const columns = tabulatorColumns( - [ - new ColumnMeta("name", "str", false, null), - new ColumnMeta("count", "int64", false, null), - new ColumnMeta("done", "bool", false, null), - new ColumnMeta("when", "datetime64[ns]", true, null), - ], - true - ); - - expect(columns.map((c) => [c.field, c.editor])).toEqual([ - ["name", "input"], - ["count", "number"], - ["done", "tickCross"], - ["when", "date"], - ]); - }); - - it("honors the review overlay hint over the dtype default", () => { - const columns = tabulatorColumns([new ColumnMeta("count", "int64", false, { type: "text" })], true); - expect(columns[0].editor).toBe("input"); - }); - - it("disables editors when not editable", () => { - const columns = tabulatorColumns([new ColumnMeta("name", "str", false, null)], false); - expect(columns[0].editor).toBe(false); - }); -}); - -describe("valueFromRowData", () => { - it("keeps only bound-column keys (server validates keys ⊆ bound columns)", () => { - const value = valueFromRowData({ name: "a", stray: "x" }, [new ColumnMeta("name", "str", false, null)]); - expect(value).toEqual({ name: "a" }); - }); - - it("drops undefined cells so absent keys stay absent", () => { - const value = valueFromRowData({ name: undefined }, [new ColumnMeta("name", "str", false, null)]); - expect(value).toEqual({}); - }); -}); - -describe("V2TableEditor rebuild behavior", () => { - beforeEach(() => { - MockTabulator.constructed = 0; - MockTabulator.latest = null; - }); - - // A v-model parent so a committed cell edit echoes back into modelValue, exercising - // the self-emit guard that must NOT rebuild the live tabulator (roborev job 148). - const Parent = defineComponent({ - setup() { - const value = ref>({ name: "a" }); - const columns = [new ColumnMeta("name", "str", false, null)]; - return () => - h(V2TableEditor, { - modelValue: value.value, - columns, - "onUpdate:modelValue": (v: Record) => (value.value = v), - }); - }, - }); - - it("does not rebuild the table when the parent echoes back a committed cell edit", async () => { - const wrapper = mount(Parent); - await flushPromises(); - expect(MockTabulator.constructed).toBe(1); - - // Simulate a committed cell edit: the stored handler emits the new row value, - // the parent writes it back into modelValue. - MockTabulator.latest.emit("cellEdited", { getRow: () => ({ getData: () => ({ name: "b" }) }) }); - await flushPromises(); - - // The value round-tripped, but the editor was not torn down and recreated. - expect(MockTabulator.constructed).toBe(1); - wrapper.unmount(); - }); - - it("rebuilds when modelValue changes externally (draft restore / discard)", async () => { - const value = ref>({ name: "a" }); - const columns = [new ColumnMeta("name", "str", false, null)]; - const wrapper = mount(V2TableEditor, { props: { modelValue: value.value, columns } }); - await flushPromises(); - expect(MockTabulator.constructed).toBe(1); - - await wrapper.setProps({ modelValue: { name: "external" } }); - await flushPromises(); - - expect(MockTabulator.constructed).toBe(2); - wrapper.unmount(); - }); -}); diff --git a/extralit-frontend/components/v2/table/V2TableEditor.vue b/extralit-frontend/components/v2/table/V2TableEditor.vue deleted file mode 100644 index a318522e2..000000000 --- a/extralit-frontend/components/v2/table/V2TableEditor.vue +++ /dev/null @@ -1,95 +0,0 @@ - - - - - diff --git a/extralit-frontend/composables/useV2Breadcrumbs.ts b/extralit-frontend/composables/useV2Breadcrumbs.ts index 1352d55df..3fdd7c6b6 100644 --- a/extralit-frontend/composables/useV2Breadcrumbs.ts +++ b/extralit-frontend/composables/useV2Breadcrumbs.ts @@ -15,5 +15,16 @@ export const useV2Breadcrumbs = () => { return crumbs; }; - return { schemasBreadcrumbs }; + const extractionsBreadcrumbs = (leaf: { name: string; link?: string }[] = []): BreadcrumbItem[] => { + const selected = workspacesStore.get().selectedWorkspace; + const crumbs: BreadcrumbItem[] = [{ name: "Home", link: "/" }]; + if (selected) { + crumbs.push({ name: selected.name, isWorkspace: true, workspaceId: selected.id }); + } + crumbs.push({ name: "Extractions", link: "/extractions" }); + crumbs.push(...leaf); + return crumbs; + }; + + return { schemasBreadcrumbs, extractionsBreadcrumbs }; }; diff --git a/extralit-frontend/demo/.gitignore b/extralit-frontend/demo/.gitignore new file mode 100644 index 000000000..5b8948222 --- /dev/null +++ b/extralit-frontend/demo/.gitignore @@ -0,0 +1,9 @@ +# Generated per run — see README. Committing these would let a stale composition render +# against a fresh recording. +video/src/data.ts +video/public/ +video/out/ +video/node_modules/ + +# Default seed-manifest location when --output is not passed. +demo-seed.json diff --git a/extralit-frontend/demo/README.md b/extralit-frontend/demo/README.md new file mode 100644 index 000000000..8655edb69 --- /dev/null +++ b/extralit-frontend/demo/README.md @@ -0,0 +1,73 @@ +# `/extractions` demo video harness + +Records the workspace-wide extraction grid driving a **live** backend in headless Chromium, +then composes the recording into an annotated 1080p video with [Remotion](https://remotion.dev). + +It is a demo **and** a gate. Every scene asserts the behaviour it is showing; a failed +assertion exits non-zero and stops the pipeline, so a broken UI can't be dressed up as a +finished video. The composition renders the real pass/fail counts, so the video is a report +on the run rather than a hand-authored claim about it. + +## Run it + +Needs the full stack up — server on `:6900`, `npm run dev` on `:3000` — plus `ffmpeg` and +`ffprobe` on `PATH`. See the root `CLAUDE.md` for stack setup. + +```bash +./demo/run-demo.sh +``` + +Output defaults to `$TMPDIR/extralit-extractions-demo/` (**outside the repo** — recordings and +renders are large binaries and must never be committed). Override with `DEMO_OUT=...`. + +Artifacts: + +| file | what | +|---|---| +| `extractions-demo.mp4` | the finished 1080p video | +| `timeline.json` | scene boundaries + every assertion's pass/fail | +| `shots/*.png` | one full-page screenshot per scene | +| `console.log` | browser console + `pageerror` capture | +| `video/*.webm` | the raw headless recording | + +## Pieces + +| file | role | +|---|---| +| `seed_demo_workspace.py` | builds the `malaria-demo` + `empty-demo` workspaces | +| `record-demo.mjs` | drives + asserts + records; writes `timeline.json` | +| `build-timeline.mjs` | webm→CFR-mp4 transcode, generates `video/src/data.ts` | +| `video/` | the Remotion composition (isolated React 19 sub-package) | + +`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 also excluded from +the app's ESLint config. `npm install` there is on demand — `run-demo.sh` does it if needed. + +`video/src/data.ts` is **generated, never committed**. The composition is a report on one +specific run; committing it would let stale captions render against a fresh recording. + +## What the seed is shaped to prove + +`e2e/v2/seed/seed_v2_e2e.py` is a minimal assertion fixture. This one is a realistic +systematic review, shaped so every grid affordance has something to show: + +- **`study_characteristics`** — scalar questions with scored agent suggestions. +- **`outcomes`** — a `table` question (arm / n / incidence) that fans one reference out to + several stacked rows, which is what makes reference-group banding visible. +- **`risk_of_bias`** — questions but *zero* records: its columns still appear, empty. This is + the coverage map, and it's the whole point of a workspace-wide projection. +- One reference where a human submitted `cluster-RCT` over the agent's `cohort`, so + response-beats-suggestion coalescing is observable rather than asserted in the abstract. +- Deliberate holes (a missing country, a missing sample size, a reference with no outcomes + record) so absent extractions render blank instead of being fabricated. + +## Gotchas + +- **Perspective renders into a shadow root.** `document.querySelectorAll("td")` finds nothing; + the driver's `GRID_PROBE` walks every open shadow root instead. +- **Perspective infers numeric columns.** A digit-only value like `4812` renders as `4,812`, + so assertions compare against a separator-stripped copy. +- **npm blocks esbuild's postinstall.** `video/package.json` carries an `allowScripts` entry + for it; without that the Remotion bundler fails at render time. +- **Headless Chromium paints no cursor.** The driver injects one so clicks and scrolls read on + screen, and hides the Nuxt devtools anchor (dev-server furniture, not the feature). diff --git a/extralit-frontend/demo/build-timeline.mjs b/extralit-frontend/demo/build-timeline.mjs new file mode 100644 index 000000000..762c754b5 --- /dev/null +++ b/extralit-frontend/demo/build-timeline.mjs @@ -0,0 +1,85 @@ +/** + * Bridges the Playwright recording into the Remotion project: + * 1. transcodes the raw headless-chromium .webm into a constant-frame-rate h264 mp4 + * (Remotion's