From bcede7ee1130963b53d504420f60c896209930ee Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Sat, 18 Jul 2026 16:22:33 -0700 Subject: [PATCH 01/14] fix(v2-ui): inject the resolved store into schema/review use cases, not the hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GetSchemasUseCase and GetReferenceReviewUseCase were typed as `typeof useXxx` and called `this.storage()`, but ts-injecty resolves a hook dependency by *calling* it and injecting the returned store object (same contract as v1's GetWorkspacesUseCase). At runtime `this.storage()` threw "this.schemasStorage is not a function", which the empty catch swallowed and surfaced as "Could not load schemas." on a 200 response. Use the injected value as an object (drop the `()`) and type it as ReturnType. The unit tests passed the raw hook function — bypassing the DI contract — so they missed this; update them to pass the resolved store (useXxx()) as DI does. Verified live: the seeded e2e_v2_slice schema now renders in the table. Surfaced by the first end-to-end run of the v2 frontend e2e. --- .../domain/usecases/get-reference-review-use-case.test.ts | 4 ++-- .../v2/domain/usecases/get-reference-review-use-case.ts | 6 ++++-- .../v2/domain/usecases/get-schemas-use-case.test.ts | 3 ++- .../v2/domain/usecases/get-schemas-use-case.ts | 6 ++++-- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/extralit-frontend/v2/domain/usecases/get-reference-review-use-case.test.ts b/extralit-frontend/v2/domain/usecases/get-reference-review-use-case.test.ts index 6ad3be613..5a071b2f4 100644 --- a/extralit-frontend/v2/domain/usecases/get-reference-review-use-case.test.ts +++ b/extralit-frontend/v2/domain/usecases/get-reference-review-use-case.test.ts @@ -71,7 +71,7 @@ const makeUseCase = () => schemaRepository as never, recordRepository as never, annotationRepository as never, - useReferenceReviews + useReferenceReviews() ); describe("GetReferenceReviewUseCase", () => { @@ -230,7 +230,7 @@ describe("GetReferenceReviewUseCase", () => { schema as never, records as never, annotation as never, - useReferenceReviews + useReferenceReviews() ).execute(REFERENCE, WORKSPACE); const r1 = review.records.find((r) => r.recordId === "r-1")!; diff --git a/extralit-frontend/v2/domain/usecases/get-reference-review-use-case.ts b/extralit-frontend/v2/domain/usecases/get-reference-review-use-case.ts index e9a3149b8..f686869dd 100644 --- a/extralit-frontend/v2/domain/usecases/get-reference-review-use-case.ts +++ b/extralit-frontend/v2/domain/usecases/get-reference-review-use-case.ts @@ -28,7 +28,9 @@ export class GetReferenceReviewUseCase { private readonly schemaRepository: SchemaRepository, private readonly recordRepository: V2RecordRepository, private readonly annotationRepository: AnnotationRepository, - private readonly reviewsStorage: typeof useReferenceReviews + // ts-injecty resolves the `useReferenceReviews` hook by calling it, so the injected + // value is the store object, not the hook (same contract as v1 GetWorkspacesUseCase). + private readonly reviewsStorage: ReturnType ) {} async execute(reference: string, workspaceId: string): Promise { @@ -66,7 +68,7 @@ export class GetReferenceReviewUseCase { ); const review = new ReferenceReview(reference, reviewRecords, projection.totalRecords); - this.reviewsStorage().saveReview(review); + this.reviewsStorage.saveReview(review); return review; } diff --git a/extralit-frontend/v2/domain/usecases/get-schemas-use-case.test.ts b/extralit-frontend/v2/domain/usecases/get-schemas-use-case.test.ts index 20cf654ce..745c94e75 100644 --- a/extralit-frontend/v2/domain/usecases/get-schemas-use-case.test.ts +++ b/extralit-frontend/v2/domain/usecases/get-schemas-use-case.test.ts @@ -11,7 +11,8 @@ describe("GetSchemasUseCase", () => { it("fetches schemas and saves them to storage", async () => { const repository = { getSchemas: vi.fn(async () => [SCHEMA]) }; - const useCase = new GetSchemasUseCase(repository as never, useSchemas); + // Pass the resolved store object, matching what ts-injecty injects at runtime. + const useCase = new GetSchemasUseCase(repository as never, useSchemas()); const result = await useCase.execute("w-1"); diff --git a/extralit-frontend/v2/domain/usecases/get-schemas-use-case.ts b/extralit-frontend/v2/domain/usecases/get-schemas-use-case.ts index 483def3e9..b89f4d0ba 100644 --- a/extralit-frontend/v2/domain/usecases/get-schemas-use-case.ts +++ b/extralit-frontend/v2/domain/usecases/get-schemas-use-case.ts @@ -5,12 +5,14 @@ import { type useSchemas } from "~/v2/infrastructure/storage/SchemasStorage"; export class GetSchemasUseCase { constructor( private readonly schemaRepository: SchemaRepository, - private readonly schemasStorage: typeof useSchemas + // ts-injecty resolves the `useSchemas` hook by calling it, so the injected value + // is the store object, not the hook (same contract as v1 GetWorkspacesUseCase). + private readonly schemasStorage: ReturnType ) {} async execute(workspaceId: string): Promise { const schemas = await this.schemaRepository.getSchemas(workspaceId); - this.schemasStorage().saveSchemas(schemas); + this.schemasStorage.saveSchemas(schemas); return schemas; } } From 1705df5d332b8cd8d8b53ab6d7e5121c108ed068 Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Sun, 19 Jul 2026 01:03:46 -0700 Subject: [PATCH 02/14] feat(v2-ui): add workspace-hydration and breadcrumb composables for v2 pages --- .../composables/useEnsureWorkspaces.test.ts | 28 +++++++++++++++++++ .../composables/useEnsureWorkspaces.ts | 20 +++++++++++++ .../composables/useV2Breadcrumbs.test.ts | 25 +++++++++++++++++ .../composables/useV2Breadcrumbs.ts | 19 +++++++++++++ 4 files changed, 92 insertions(+) create mode 100644 extralit-frontend/composables/useEnsureWorkspaces.test.ts create mode 100644 extralit-frontend/composables/useEnsureWorkspaces.ts create mode 100644 extralit-frontend/composables/useV2Breadcrumbs.test.ts create mode 100644 extralit-frontend/composables/useV2Breadcrumbs.ts diff --git a/extralit-frontend/composables/useEnsureWorkspaces.test.ts b/extralit-frontend/composables/useEnsureWorkspaces.test.ts new file mode 100644 index 000000000..112dc62a8 --- /dev/null +++ b/extralit-frontend/composables/useEnsureWorkspaces.test.ts @@ -0,0 +1,28 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createPinia, setActivePinia } from "pinia"; + +const execute = vi.fn(async () => [{ id: "w-1", name: "e2e-v2" }]); +vi.mock("ts-injecty", () => ({ useResolve: () => ({ execute }) })); + +import { useEnsureWorkspaces } from "./useEnsureWorkspaces"; +import { useWorkspaces } from "~/v1/infrastructure/storage/WorkspaceStorage"; + +describe("useEnsureWorkspaces", () => { + beforeEach(() => { + setActivePinia(createPinia()); + execute.mockClear(); + }); + + it("fetches workspaces when the store is empty", async () => { + const { ensureWorkspaces } = useEnsureWorkspaces(); + await ensureWorkspaces(); + expect(execute).toHaveBeenCalledOnce(); + }); + + it("does not refetch when workspaces are already loaded", async () => { + useWorkspaces().saveWorkspaces([{ id: "w-1", name: "e2e-v2" } as never]); + const { ensureWorkspaces } = useEnsureWorkspaces(); + await ensureWorkspaces(); + expect(execute).not.toHaveBeenCalled(); + }); +}); diff --git a/extralit-frontend/composables/useEnsureWorkspaces.ts b/extralit-frontend/composables/useEnsureWorkspaces.ts new file mode 100644 index 000000000..39102b5ed --- /dev/null +++ b/extralit-frontend/composables/useEnsureWorkspaces.ts @@ -0,0 +1,20 @@ +import { computed } from "vue"; +import { useResolve } from "ts-injecty"; +import { GetWorkspacesUseCase } from "~/v1/domain/usecases/get-workspaces-use-case"; +import { useWorkspaces } from "~/v1/infrastructure/storage/WorkspaceStorage"; + +// v2 pages are reached by direct URL (deep links from the records table, browser reload). +// Only the home page fetches workspaces, so a hard load lands with an empty store and the +// localStorage-pinned selection has nothing to restore against. Hydrate on mount here. +export const useEnsureWorkspaces = () => { + const getWorkspacesUseCase = useResolve(GetWorkspacesUseCase); + const workspacesStore = useWorkspaces(); + const selectedWorkspace = computed(() => workspacesStore.get().selectedWorkspace); + + const ensureWorkspaces = async () => { + if (workspacesStore.get().workspaces.length > 0) return; + await getWorkspacesUseCase.execute(); // saveWorkspaces + pin-restore happen inside the use-case + }; + + return { ensureWorkspaces, selectedWorkspace }; +}; diff --git a/extralit-frontend/composables/useV2Breadcrumbs.test.ts b/extralit-frontend/composables/useV2Breadcrumbs.test.ts new file mode 100644 index 000000000..6215e9865 --- /dev/null +++ b/extralit-frontend/composables/useV2Breadcrumbs.test.ts @@ -0,0 +1,25 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { createPinia, setActivePinia } from "pinia"; +import { useV2Breadcrumbs } from "./useV2Breadcrumbs"; +import { useWorkspaces } from "~/v1/infrastructure/storage/WorkspaceStorage"; + +describe("useV2Breadcrumbs", () => { + beforeEach(() => setActivePinia(createPinia())); + + it("builds Home › workspace › Schemas with a workspace dropdown item", () => { + const store = useWorkspaces(); + store.saveWorkspaces([{ id: "w-1", name: "e2e-v2" } as never]); + store.saveSelectedWorkspace({ id: "w-1", name: "e2e-v2" } as never); + + const crumbs = useV2Breadcrumbs().schemasBreadcrumbs(); + + expect(crumbs.map((c) => c.name)).toEqual(["Home", "e2e-v2", "Schemas"]); + expect(crumbs[1]).toMatchObject({ isWorkspace: true, workspaceId: "w-1" }); + expect(crumbs[2].link).toBe("/schemas"); + }); + + it("appends leaf crumbs", () => { + const crumbs = useV2Breadcrumbs().schemasBreadcrumbs([{ name: "sample_size", link: "/schemas/s-1" }]); + expect(crumbs.at(-1)).toMatchObject({ name: "sample_size", link: "/schemas/s-1" }); + }); +}); diff --git a/extralit-frontend/composables/useV2Breadcrumbs.ts b/extralit-frontend/composables/useV2Breadcrumbs.ts new file mode 100644 index 000000000..1352d55df --- /dev/null +++ b/extralit-frontend/composables/useV2Breadcrumbs.ts @@ -0,0 +1,19 @@ +import { useWorkspaces } from "~/v1/infrastructure/storage/WorkspaceStorage"; +import { type BreadcrumbItem } from "~/v1/infrastructure/types/breadcrumb"; + +export const useV2Breadcrumbs = () => { + const workspacesStore = useWorkspaces(); + + const schemasBreadcrumbs = (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: "Schemas", link: "/schemas" }); + crumbs.push(...leaf); + return crumbs; + }; + + return { schemasBreadcrumbs }; +}; From 00c8bcdb96d11f4d60a536478c7acb221a2866dd Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Sun, 19 Jul 2026 01:06:58 -0700 Subject: [PATCH 03/14] feat(v2-ui): add V2Empty and V2StatusBadge presentational components --- .../components/v2/common/V2Empty.vue | 30 +++++++++++++++++++ .../v2/common/V2StatusBadge.test.ts | 22 ++++++++++++++ .../components/v2/common/V2StatusBadge.vue | 25 ++++++++++++++++ 3 files changed, 77 insertions(+) create mode 100644 extralit-frontend/components/v2/common/V2Empty.vue create mode 100644 extralit-frontend/components/v2/common/V2StatusBadge.test.ts create mode 100644 extralit-frontend/components/v2/common/V2StatusBadge.vue diff --git a/extralit-frontend/components/v2/common/V2Empty.vue b/extralit-frontend/components/v2/common/V2Empty.vue new file mode 100644 index 000000000..26b242b3e --- /dev/null +++ b/extralit-frontend/components/v2/common/V2Empty.vue @@ -0,0 +1,30 @@ + + + + + diff --git a/extralit-frontend/components/v2/common/V2StatusBadge.test.ts b/extralit-frontend/components/v2/common/V2StatusBadge.test.ts new file mode 100644 index 000000000..18519268c --- /dev/null +++ b/extralit-frontend/components/v2/common/V2StatusBadge.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { mount } from "@vue/test-utils"; +import V2StatusBadge from "./V2StatusBadge.vue"; + +const mountBadge = (status: string) => + mount(V2StatusBadge, { + props: { status }, + global: { + stubs: { BaseBadge: { name: "BaseBadge", template: "{{ text }}", props: ["text", "color"] } }, + }, + }); + +describe("V2StatusBadge", () => { + it("passes the status through as the badge text", () => { + expect(mountBadge("published").text()).toContain("published"); + }); + + it("maps known statuses to distinct token colors", () => { + const color = (s: string) => mountBadge(s).findComponent({ name: "BaseBadge" }).props("color"); + expect(color("completed")).not.toBe(color("discarded")); + }); +}); diff --git a/extralit-frontend/components/v2/common/V2StatusBadge.vue b/extralit-frontend/components/v2/common/V2StatusBadge.vue new file mode 100644 index 000000000..1ace22496 --- /dev/null +++ b/extralit-frontend/components/v2/common/V2StatusBadge.vue @@ -0,0 +1,25 @@ + + + From 1d45e51d02be902ecda0a1fa227fdb7ff4795a82 Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Sun, 19 Jul 2026 01:12:11 -0700 Subject: [PATCH 04/14] feat(v2-ui): integrate schemas list into app shell; hydrate workspaces on direct load --- extralit-frontend/pages/schemas/index.test.ts | 42 +++++++- extralit-frontend/pages/schemas/index.vue | 100 ++++++++++++------ 2 files changed, 106 insertions(+), 36 deletions(-) diff --git a/extralit-frontend/pages/schemas/index.test.ts b/extralit-frontend/pages/schemas/index.test.ts index 564e281db..16be0004b 100644 --- a/extralit-frontend/pages/schemas/index.test.ts +++ b/extralit-frontend/pages/schemas/index.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { flushPromises, shallowMount } from "@vue/test-utils"; +import { ref } from "vue"; import { createPinia, setActivePinia } from "pinia"; import Container from "ts-injecty"; import { useResolveMock } from "~/v1/di/__mocks__/useResolveMock"; @@ -7,10 +8,28 @@ import { GetSchemasUseCase } from "~/v2/domain/usecases/get-schemas-use-case"; import { Schema } from "~/v2/domain/entities/schema/Schema"; import { useWorkspaces } from "~/v1/infrastructure/storage/WorkspaceStorage"; import { Workspace } from "~/v1/domain/entities/workspace/Workspace"; +import * as useEnsureWorkspacesModule from "~/composables/useEnsureWorkspaces"; import SchemasPage from "./index.vue"; +// The page hydrates workspaces via useEnsureWorkspaces (which resolves GetWorkspacesUseCase +// from DI). Mock the composable so tests don't need the workspace use-case registered. +vi.mock("~/composables/useEnsureWorkspaces", () => ({ + useEnsureWorkspaces: vi.fn(() => ({ ensureWorkspaces: vi.fn(async () => {}), selectedWorkspace: ref(null) })), +})); + const SCHEMA = new Schema("s-1", "sample_size", "published", "w-1", "v-1", {}, "2026-01-01", "2026-01-01"); +// InternalPage is a layout wrapper; render its header + page-content slots so the page body +// is visible under shallowMount. Named stubs keep findComponent({ name }) working. +const stubs = { + InternalPage: { template: "
" }, + AppHeader: { name: "AppHeader", template: "
" }, + BaseLoading: true, + NuxtLink: { template: "" }, + V2StatusBadge: { name: "V2StatusBadge", template: "{{ status }}", props: ["status"] }, + V2Empty: { name: "V2Empty", template: "
{{ message }}
", props: ["message"] }, +}; + describe("schemas list page", () => { beforeEach(() => { // Reset the global ts-injecty container so each test's useResolveMock wins @@ -19,13 +38,17 @@ describe("schemas list page", () => { setActivePinia(createPinia()); useWorkspaces().saveWorkspaces([new Workspace("w-1", "ws")]); useWorkspaces().saveSelectedWorkspace(new Workspace("w-1", "ws")); + vi.mocked(useEnsureWorkspacesModule.useEnsureWorkspaces).mockReturnValue({ + ensureWorkspaces: vi.fn(async () => {}), + selectedWorkspace: ref(null), + } as never); }); it("loads schemas for the selected workspace and renders a row per schema", async () => { const execute = vi.fn(async () => [SCHEMA]); useResolveMock(GetSchemasUseCase, { execute }); - const wrapper = shallowMount(SchemasPage, { global: { stubs: { NuxtLink: { template: "" } } } }); + const wrapper = shallowMount(SchemasPage, { global: { stubs } }); await flushPromises(); expect(execute).toHaveBeenCalledWith("w-1"); @@ -35,9 +58,24 @@ describe("schemas list page", () => { it("shows the empty state when the workspace has no schemas", async () => { useResolveMock(GetSchemasUseCase, { execute: vi.fn(async () => []) }); - const wrapper = shallowMount(SchemasPage, { global: { stubs: { NuxtLink: true } } }); + const wrapper = shallowMount(SchemasPage, { global: { stubs } }); await flushPromises(); expect(wrapper.text()).toContain("schemas.empty"); }); + + it("hydrates workspaces and renders the app header on mount", async () => { + const ensureWorkspaces = vi.fn(async () => {}); + vi.mocked(useEnsureWorkspacesModule.useEnsureWorkspaces).mockReturnValue({ + ensureWorkspaces, + selectedWorkspace: ref(null), + } as never); + useResolveMock(GetSchemasUseCase, { execute: vi.fn(async () => []) }); + + const wrapper = shallowMount(SchemasPage, { global: { stubs } }); + await flushPromises(); + + expect(ensureWorkspaces).toHaveBeenCalledOnce(); + expect(wrapper.findComponent({ name: "AppHeader" }).exists()).toBe(true); + }); }); diff --git a/extralit-frontend/pages/schemas/index.vue b/extralit-frontend/pages/schemas/index.vue index b0c2372db..077694f94 100644 --- a/extralit-frontend/pages/schemas/index.vue +++ b/extralit-frontend/pages/schemas/index.vue @@ -1,69 +1,101 @@ From 63920d82a52bf38f9d6f5bea698607d5a4a4177f Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Sun, 19 Jul 2026 01:16:05 -0700 Subject: [PATCH 05/14] feat(v2-ui): shell + design-system controls on schema detail; status badges in records table --- .../components/v2/schemas/V2RecordsTable.vue | 2 +- .../pages/schemas/[id]/index.vue | 138 +++++++++++------- 2 files changed, 89 insertions(+), 51 deletions(-) diff --git a/extralit-frontend/components/v2/schemas/V2RecordsTable.vue b/extralit-frontend/components/v2/schemas/V2RecordsTable.vue index 6027ef085..8e616fb29 100644 --- a/extralit-frontend/components/v2/schemas/V2RecordsTable.vue +++ b/extralit-frontend/components/v2/schemas/V2RecordsTable.vue @@ -18,7 +18,7 @@ {{ formatCell(record.fields[column.name]) }} - {{ record.status }} + diff --git a/extralit-frontend/pages/schemas/[id]/index.vue b/extralit-frontend/pages/schemas/[id]/index.vue index 4aa8fb1f3..bbf9f14de 100644 --- a/extralit-frontend/pages/schemas/[id]/index.vue +++ b/extralit-frontend/pages/schemas/[id]/index.vue @@ -1,99 +1,137 @@ diff --git a/extralit-frontend/pages/references/[...reference].vue b/extralit-frontend/pages/references/[...reference].vue index b023c19f7..eb7c629f0 100644 --- a/extralit-frontend/pages/references/[...reference].vue +++ b/extralit-frontend/pages/references/[...reference].vue @@ -1,26 +1,36 @@ From 3ac16521d1a1dd9312b0d5fab8c50fb25fd461dd Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Sun, 19 Jul 2026 13:41:01 -0700 Subject: [PATCH 08/14] fix(v2-ui): correct BaseButton `to` prop type; avoid reference/breadcrumb strict-mode collision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BaseButton `to` prop was declared `type: String | Object` (bitwise-OR evaluates to 0), a malformed prop type that threw 'Right-hand side of instanceof is not an object' during Vue dev-mode prop validation whenever a :to value was passed. This broke the schema-detail page render (stuck spinner, no records) and was latent in DatasetCard/UserSettingsHeader/ QuestionsForm. Fixed to [String, Object]. - Reference-review breadcrumb rendered the bare reference, colliding with the page

'Review — {reference}' so getByText(reference) matched two nodes (e2e strict-mode violation). Leaf is now the static 'Review' label; the

still carries the reference. Unblocks e2e/v2 auth-smoke + slashed-reference (both green). --- .../components/base/base-button/BaseButton.vue | 4 ++-- extralit-frontend/pages/references/[...reference].vue | 7 ++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/extralit-frontend/components/base/base-button/BaseButton.vue b/extralit-frontend/components/base/base-button/BaseButton.vue index 89e3dd11f..5542d1133 100644 --- a/extralit-frontend/components/base/base-button/BaseButton.vue +++ b/extralit-frontend/components/base/base-button/BaseButton.vue @@ -46,7 +46,6 @@ @@ -121,6 +121,19 @@ export default { } &__search-input { flex: 1; + padding: $base-space * 1.2 $base-space * 1.5; + border: 1px solid var(--bg-opacity-10); + border-radius: $border-radius; + background: var(--bg-accent-grey-1); + color: var(--fg-secondary); + @include font-size(14px); + &::placeholder { + color: var(--fg-tertiary); + } + &:focus { + outline: none; + border-color: var(--fg-cuaternary); + } } &__status { padding: 0 $base-space; From a569545062f1712d5228d6aabf686656fea5c6ab Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Sun, 19 Jul 2026 23:13:21 -0700 Subject: [PATCH 13/14] chore: remove outdated HANDOVER.md file The HANDOVER.md file has been deleted as it contained outdated information regarding PR #214 and its verification results. This cleanup helps maintain the repository's documentation relevance. --- HANDOVER.md | 52 ------------------------------------- extralit-frontend/CLAUDE.md | 27 +++++++++++++++++-- 2 files changed, 25 insertions(+), 54 deletions(-) delete mode 100644 HANDOVER.md diff --git a/HANDOVER.md b/HANDOVER.md deleted file mode 100644 index acb05e57a..000000000 --- a/HANDOVER.md +++ /dev/null @@ -1,52 +0,0 @@ -# Session Handover — PR #214 (OSS audit quick wins) - -**Date:** 2026-06-09 -**Branch:** `chore/quick-wins-203-204-211-212` -**PR:** https://github.com/Extralit/extralit/pull/214 (targets `develop`) -**Outcome:** Quick-win bundle for #203/#204/#211/#212 implemented and **verified**. **All PR CI checks green** on `6c7c48a6e` (frontend build, server package + docker images, SDK matrix 3.9–3.13, Scorecard, Snyk). Also unblocked the frontend CI test step that had been red on `develop` since #200. **Ready to merge.** - -> CI note: `build (3.9)` failed once on a transient Docker-registry timeout pulling the `extralitdev/extralit-hf-space:latest` service container (network flake at "Initialize containers", before any code ran); green on re-run. "Publish Release" shows "skipping" — expected on a PR. - ---- - -## PR #214 status — verification results - -| Item | Scope | Status | -|---|---|---| -| **#203** uv cache path | `extralit-server/docker/server/Dockerfile`: `UV_CACHE_DIR=/home/extralit/.cache/uv` + matching `--mount` target | ✅ Code correct & self-consistent (build-time BuildKit cache; only mount in builder stage). Docker build itself covered by CI "Build extralit-server package". | -| **#204** onboarding docs | README fence fix, SDK README snippets, root `AGENTS.md` → per-component `CLAUDE.md`, `examples/README.md`, `CONTRIBUTING.md` dev-env section | ✅ Committed. | -| **#211** Argilla→Extralit frontend rebrand | `BackendEnvironment.argilla`→`.extralit`, `argillaDatasets`→`extralitDatasets` i18n (4 locales + `DatasetList.vue`), `docs.argilla.io`→`docs.extralit.ai` in `ja.js`, 84 license headers | ✅ Verified: `useRunningEnvironment.test.ts` passes, no `BackendEnvironment.argilla` left in source, `extralitDatasets` consistent across all 4 locales + component, frontend build succeeds. | -| **#212** untrack editor/cache dirs | already in `.gitignore`, not tracked | ✅ No-op (confirmed). | -| **(extra)** empty spec from #200 | `components/features/import/file-upload/useImportFileUploadViewModel.spec.ts` was 0 bytes → Jest "must contain at least one test" → **broke frontend CI test step since #200** | ✅ Added `it.todo` placeholder; suite now green. | - -### Test plan (PR description) — results this session -- **`npm run lint`** — PR's own changed files are clean. The 50 errors `lint` reports are all in files **not touched by this PR** (pre-existing repo-wide debt); CI's lint step is `continue-on-error: true`, so they don't gate the PR. -- **`npm run test`** — 723 passed / 3 skipped; the one prior failure (empty `#200` spec) is fixed. -- **`npm run build`** — Nuxt production build succeeds (exit 0) with the rebranded type/i18n key. -- **Docker build** — Dockerfile change reviewed; defer to CI "Build extralit-server package" (heavy locally on ARM). - ---- - -## Key facts / gotchas for next session - -- **`develop`'s frontend CI has been red since #200** (`2ac8134a7`) because of the empty spec — not a #214 regression. This PR fixes it as a side effect. -- **CI lint vs pre-commit:** the pre-commit hook only lints staged files (so the PR "passed lint" locally), but CI runs full-repo `npm run lint`. Full-repo lint has 50 pre-existing errors incl. a **parse error in `pages/index.vue:157`** — worth a separate cleanup issue, out of scope here. -- **Intentionally retained Argilla references** (do not "fix"): `how-to-configure-argilla-on-huggingface/` doc paths (renamed paths 404), `argilla-dev` localStorage key (breaks installed extensions), `argilla.imglab-cdn.net` CDN, E2E mock fixtures, `CHANGELOG.md [Argilla]` entries, NOTICE attribution. - ---- - -## Remaining / next steps - -1. ✅ **CI green** — frontend build, server package + docker images, SDK matrix 3.9–3.13, Scorecard, Snyk all pass. -2. **Merge** — PR is ready; no blocking checks remain. -3. **Follow-ups (not in this PR):** full-repo frontend lint cleanup (50 errors incl. `pages/index.vue` parse error); the security items flagged but unfiled in the original audit (secret rotation, wildcard CORS in `api/handlers/v1/files.py`, `v-html` sanitization); architecture sequence #206→#207→#208; backend #205; schemas/payloads #209; uv workspaces #210. - ---- - -## Map of files touched by PR #214 -- `extralit-server/docker/server/Dockerfile` — #203 uv cache path -- `extralit/README.md`, root `README.md`, `AGENTS.md`, `CONTRIBUTING.md`, `examples/README.md` — #204 docs -- `extralit-frontend/v1/.../environment/{Environment.ts,EnvironmentRepository.ts}`, `v1/infrastructure/types/environment.ts`, `useRunningEnvironment.test.ts` — #211 env rename -- `extralit-frontend/translation/{en,de,es,ja}.js`, `components/features/home/dataset-list/DatasetList.vue` — #211 i18n -- ~84 frontend source files — #211 license-header convention -- `extralit-frontend/components/features/import/file-upload/useImportFileUploadViewModel.spec.ts` — empty-spec CI fix (this session) diff --git a/extralit-frontend/CLAUDE.md b/extralit-frontend/CLAUDE.md index 555eb0f6e..5e6987246 100644 --- a/extralit-frontend/CLAUDE.md +++ b/extralit-frontend/CLAUDE.md @@ -44,8 +44,31 @@ npm run e2e:report # View test report > `/api/v1/token` + `/api/v1/me` offline, and waits for the home/datasets landing at `/` > (there is no `/datasets` route). This flow is runtime-verified via the CDP browser. The > per-page specs still need fresh Extralit screenshot baselines (`--update-snapshots`); the -> inherited ones are Argilla's. The local Playwright browser can't launch on the Orin dev -> host (missing OS libs, no sudo) — run the headless gate in CI. +> inherited ones are Argilla's. The local Playwright chromium **does** launch on the Orin +> host (verified 2026-07-18: `chromium.launch({headless:true})` → Chrome 149 after a plain +> `npx playwright install chromium`; no `install-deps`/sudo needed), so the headless gate +> can run locally as well as in CI. + +## v2 e2e suite (`e2e/v2/`, real backend — the v2 slice's integration gate) + +Separate Playwright project (`--project=v2`, `testMatch: v2/**/*.spec.ts`); the legacy +Argilla specs above are **not** a v2 gate. No network mocking — it exercises real bearer +auth on `/api/v2`, slashed-DOI encoding, the suggestion→response loop, drafts and search +freshness. Env knobs (see `e2e/v2/fixtures.ts`): `E2E_API_URL` (default `http://localhost:6900`), +`E2E_BASE_URL`/`BASE_URL` (default `http://localhost:3000`), `E2E_USERNAME`/`E2E_PASSWORD` +(default `extralit`/`12345678`), optional `E2E_CDP_URL` to drive a remote chromium. + +```bash +npm run e2e:v2:seed # uv run ../extralit-server python e2e/v2/seed/seed_v2_e2e.py +npm run dev -- --host # dev server reachable from the browser +npm run e2e:v2 # playwright test --project=v2 (local chromium) +``` + +Requires the full stack up with the server on :6900. On the Orin host the backing services +publish to `localhost` (postgres :5432, minio :9000, elasticsearch :9200) but the compose +`redis` is **not** published, and the server `.env` uses docker-network hostnames +(`minio`/`elasticsearch`/`redis`) — running the server on the host needs those overridden +to `localhost` plus a throwaway redis on :6379. ## Code Quality From 42a4b9898470cdaed563331e86a003926c87aa5c Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Mon, 20 Jul 2026 00:00:09 -0700 Subject: [PATCH 14/14] chore: docs --- .../specs/2026-07-19-reference-review.md | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-19-reference-review.md diff --git a/docs/superpowers/specs/2026-07-19-reference-review.md b/docs/superpowers/specs/2026-07-19-reference-review.md new file mode 100644 index 000000000..a2b5a2deb --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-reference-review.md @@ -0,0 +1,133 @@ +# Handoff — ReferenceReview slice: design/correctness interrogation brief + +**Date:** 2026-07-19 +**Context branch:** `polish/v2-ui-shell-integration` (PR #232). The ReferenceReview slice itself is **already on `develop`** (merged via #230). +**Goal of next session:** interrogate the Presentation, Domain/infra, and Backend layers for **user design, API design, correctness, and performance** — then decide whether to redesign-in-place, or replace. + +--- + +## Why this brief exists (the reframing) + +I was asked whether the reference-review files are half-baked sprawl worth deleting, this is a critical review of the ReferenceReview vertical slice across three layers to determine if it is worth redesigning. + +**Where the value actually sits:** the *presentation* (~360 LOC) is the under-designed, replaceable part. The *domain/infra + backend contract* (~1,200 LOC, well-tested) is the asset a redesign reuses — deleting it means rebuilding it. + +--- + +## The full surface (the "any others") + +### Presentation (~360 LOC) — the redesign target +- `components/v2/review/ReviewRecordCard.vue` (137) — one card per schema-record; renders context as raw `
`, **dumps orphaned values as `JSON.stringify`**, per-record submit/save-draft/discard footer. +- `components/v2/review/ProjectionReviewForm.vue` (32) — thin list wrapper over records. +- `components/v2/review/ReviewCellInput.vue` (151) — dispatches to existing annotation widgets by question type. +- `components/v2/review/ReviewProvenance.vue` (44) — agent/score/source line. +- `pages/references/[...reference].vue` (69) — catch-all route (slashed DOIs), shell-wrapped in #232. + +### Domain / infra (~1,100 LOC) — the asset +- `v2/domain/entities/review/ReferenceReview.ts` (65) — aggregate: reference → ReviewRecord[] (+ cells, context, orphaned values, draft). +- `v2/domain/entities/review/widget-adapters.ts` (88, +120 test) — **maps v2 questions ↔ existing annotation widget option models** (label/rating/ranking/table). High reuse value. +- `v2/domain/entities/review/widget-mapping.ts` (25, +43 test), `SuggestionHint.ts` (22, +20 test), `response-values.ts` (9, +24 test). +- `v2/domain/usecases/get-reference-review-use-case.ts` (131, **+245 test**) — assembles projection + suggestions + responses + drafts into the review model. +- `v2/domain/usecases/submit-reference-review-use-case.ts` (25, +38 test), `save-review-draft-use-case.ts` (16), `discard-review-use-case.ts` (17). +- `v2/infrastructure/storage/ReferenceReviewsStorage.ts` (27) — Pinia store keyed by reference. +- `v2/infrastructure/repositories/ProjectionRepository.ts` (49) — GET `/v2/projection/references/{encoded}`. +- `v2/di/di.ts` — registrations (partial file). +- `components/v2/schemas/V2RecordsTable.vue` — the **entry-point link** `/references/${encodeURIComponent(reference)}?workspace_id=…` (partial file). + +### Backend (~124 LOC + 2 integration tests) +- `extralit-server/src/extralit_server/api/v2/projection.py` (31) — `GET /projection/references/{reference:path}` → `ProjectionView`. Note the deliberate `/projection/...` prefix to avoid the greedy `:path` on `GET /references/{reference:path}` shadowing it. +- `extralit-server/src/extralit_server/contexts/v2/projection.py` (70) — cross-schema assembly. +- `extralit-server/src/extralit_server/api/schemas/v2/projection.py` (23) — `ProjectionView` DTO. +- Tests: `tests/integration/api/v2/test_projection.py`, `tests/integration/contexts/v2/test_projection.py`. + +### e2e (depend on the slice) +- `e2e/v2/{review-loop,draft-lifecycle,slashed-reference}.spec.ts` — `goto` the `/references/` URL directly; exercise suggestion→response, drafts, slashed-DOI. (These pass **serially**; parallel flakes on shared-schema `rebuild-index` contention — see the PR #232 HANDOVER.) + +--- + +## Interrogation matrix (start here next session) + +Concrete hotspots I noticed while mapping — not yet verified as defects, but the places to point the review. + +### Presentation — **user design** (the weakest layer) +- Orphaned values rendered via `JSON.stringify` (`ReviewRecordCard`) — placeholder, not a designed affordance. +- Context shown as bare `
`; provenance is a minimal agent/score line — no visual hierarchy for "what am I reviewing / what changed." +- Multi-record references: one card stacked per schema-record with **no overview, no navigation, no bulk/keyboard flow**; submit/save-draft/discard are per-card only. +- Loading/empty/error states are minimal (single `V2Empty`/`BaseLoading`). +- **Question for design:** what *is* the review unit — a reference, a schema, a single projected record? The current model shows all records for a reference flat; is that the intended mental model? + +### Domain/infra — **correctness** +- `ReviewRecordCard.cleanValues()` drops `null`/`undefined`/`""` before emit. Interaction with **required-question enforcement** (server-side) and with legitimately-empty answers needs a hard look — can a user intentionally clear a value, and does that round-trip? +- `get-reference-review-use-case.ts` merges projection + suggestions + responses + drafts. Check merge precedence (draft vs saved response vs suggestion), and staleness (Pinia `ReferenceReviewsStorage` cache vs re-fetch). +- `ProjectionRepository` does `encodeURIComponent(reference)` and the backend uses `{reference:path}` — verify no double-encode / slash-decoding mismatch (the `slashed-reference` spec covers the happy path; probe `%2F` vs `/`, `#`, `?` in DOIs). +- `widget-adapters` rebuild option arrays on every `modelValue` change (watch in `ReviewCellInput`) — verify no state loss on external resets (draft restore/discard). + +### Domain/infra + Presentation — **performance** +- No pagination on the review page — loads **all** records for a reference at once. Fan-out cost if a reference spans many schemas/records. +- `ReviewCellInput` watch rebuilds label/rating/ranking option arrays per keystroke/change — check re-render cost with many cells. +- Repeated single-record hydration vs batch. + +### Backend — **API design + performance** +- `ProjectionView` shape: is one endpoint returning the whole cross-schema projection the right granularity, or should it paginate / stream / split per schema? +- `contexts/v2/projection.py` (70 LOC) assembles across schemas — check query count (N schemas × per-record hydration from Postgres); watch for N+1. +- `workspace_id` is a **required query param** on the projection GET — confirm that's the right scoping contract (vs deriving from auth/reference). +- Suggestion/response/draft are **separate PUT/POST** calls from the client (see `submit`/`save-draft`/`discard` use-cases) — is a batched review-submit endpoint warranted? +- The `/projection/...` vs `/references/{reference:path}` routing split (shadowing workaround) — is the URL design coherent for the SDK (#231) too? + +### Backend — **correctness** +- FTS/index eventual consistency already characterized (see PR #232 HANDOVER): reseed leaves a cold index; `:rebuild-index` is needed to warm it. Relevant if projection reads from the index anywhere. +- The `prefilter`-kwarg 500 (FTS + scalar filter) was fixed in #232 (`4fa777343`) — confirm the projection path doesn't have a sibling issue. + +--- + +## Options on the table (decision pending — this is yours) + +1. **Leave #232 as-is** — reference-review stays shell-integrated; a later redesign replaces only the presentation (~360 LOC), reusing domain + backend. Lowest friction. The BaseButton fix + 3 e2e specs need the page to render regardless. +2. **Hide the entry point in #232** *(my recommendation if you want it visibly parked)* — remove the `V2RecordsTable → /references` link (and optionally un-shell the page) so it's unreachable/unblessed, but the code + backend + domain remain for the redesign. e2e specs still pass (they `goto` directly). +3. **Revert the whole slice** — separate PR removing frontend + backend projection + e2e, reconciled against SDK #231. Only if the product call is "pull this direction entirely." **Not recommended** — throws away ~1,200 LOC of tested, reusable machinery + a working backend contract you'd rebuild. + +**Recommendation:** #2 to park it pending design, #1 if fine leaving it reachable. Avoid #3. + +--- + +## Next steps + +1. **Decide the parking option** (#1/#2/#3 above) for PR #232 before it merges. If #2, the change is small: drop the `/references` link in `components/v2/schemas/V2RecordsTable.vue` (and optionally revert the `pages/references/[...reference].vue` shell wrap). +2. **Run the design/brainstorm session** on the *presentation* using `superpowers:brainstorming` — anchor on the "what is the review unit?" question above. +3. **Do the layer interrogation** using the matrix: consider a focused `/code-review` (or the multi-agent `/code-review ultra`) scoped to the review + projection files for correctness/perf, in parallel with the UX design work. +4. **Backend API review** — validate the `ProjectionView` granularity and the projection query fan-out (N+1) before building new UI on top. +5. Keep the domain/infra + backend contract as the stable base; treat the four `components/v2/review/*.vue` + the page as replaceable. + +--- + +## Map of important files + +**Presentation (redesign target)** +- `extralit-frontend/components/v2/review/ReviewRecordCard.vue` +- `extralit-frontend/components/v2/review/ProjectionReviewForm.vue` +- `extralit-frontend/components/v2/review/ReviewCellInput.vue` +- `extralit-frontend/components/v2/review/ReviewProvenance.vue` +- `extralit-frontend/pages/references/[...reference].vue` +- `extralit-frontend/pages/references/useReferenceReviewViewModel.ts` (+ `.test.ts`) + +**Domain / infra (asset — reuse)** +- `extralit-frontend/v2/domain/entities/review/` — `ReferenceReview.ts`, `widget-adapters.ts`, `widget-mapping.ts`, `SuggestionHint.ts`, `response-values.ts` (each with tests) +- `extralit-frontend/v2/domain/usecases/` — `get-reference-review-use-case.ts`, `submit-reference-review-use-case.ts`, `save-review-draft-use-case.ts`, `discard-review-use-case.ts` +- `extralit-frontend/v2/infrastructure/storage/ReferenceReviewsStorage.ts` +- `extralit-frontend/v2/infrastructure/repositories/ProjectionRepository.ts` +- `extralit-frontend/v2/di/di.ts` (registrations) +- `extralit-frontend/components/v2/schemas/V2RecordsTable.vue` (entry-point link) + +**Backend** +- `extralit-server/src/extralit_server/api/v2/projection.py` +- `extralit-server/src/extralit_server/contexts/v2/projection.py` +- `extralit-server/src/extralit_server/api/schemas/v2/projection.py` +- `extralit-server/tests/integration/api/v2/test_projection.py` +- `extralit-server/tests/integration/contexts/v2/test_projection.py` + +**e2e** +- `extralit-frontend/e2e/v2/{review-loop,draft-lifecycle,slashed-reference}.spec.ts` + +**Cross-references** +- `HANDOVER.md` — PR #232 (v2 UI shell) session handoff, incl. the serial-vs-parallel e2e reliability note. +- #230 (`8343da1f8`) — original merge of this slice. #231 — open SDK slice that shares the v2 API surface.