diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 6c6a6b5..9cb3702 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -80,6 +80,11 @@ jobs: # A small upload ceiling so the FILE_TOO_LARGE journey trips the real gate with a ~1 MB file # rather than a genuine 100 MB transfer; compose interpolates it into the backend + worker env. XTALATE_MAX_UPLOAD_BYTES: "1048576" + # The serial suite legitimately bursts past the 120/min default on the geometry-heavy journeys + # (frame-chunked fetches + job polling), which 429s every browser request for the rest of that + # wall-clock minute — including the next journey's upload — a timing flake, not a product one. + # Raise the lane's window far above suite volume; the limiter stays on (unit-tested elsewhere). + XTALATE_RATE_LIMIT_PER_MINUTE: "100000" steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 diff --git a/.gitignore b/.gitignore index 77a2b77..d79832a 100644 --- a/.gitignore +++ b/.gitignore @@ -245,3 +245,6 @@ secrets.* _xtalate_objects/ # Default Tier 0 SQLite database (backend.db default database_url; v0.5 M21) _xtalate.db + +# Visual brainstorm companion (local, ephemeral) +.superpowers/ diff --git a/compose.yaml b/compose.yaml index 11de23d..674be04 100644 --- a/compose.yaml +++ b/compose.yaml @@ -32,6 +32,15 @@ x-backend-env: &backend-env # The M30 e2e lane exports a small value here so the oversized-upload journey trips the real # `413 FILE_TOO_LARGE` gate with a kilobyte-scale file instead of a genuine 100 MB transfer. XTALATE_MAX_UPLOAD_BYTES: ${XTALATE_MAX_UPLOAD_BYTES:-104857600} + # Defaults to the application's own 120/min rate limit, so a plain `docker compose up` is + # unchanged (the limiter's production posture is the code default; see backend/config.py). The + # e2e lane exports a generous value here: the Playwright suite is a single serial caller that + # legitimately bursts past 120 requests in a minute on the geometry-heavy journeys (frame-chunked + # fetches + job polling), and a saturated bucket 429s *every* request from the browser for the + # rest of the wall-clock minute — including the next journey's upload — making the gate flaky + # on timing, not on product behaviour. The limiter stays mechanically on; unit tests + # (`tests/backend/test_limits_auth.py`) still assert the 429 posture itself. + XTALATE_RATE_LIMIT_PER_MINUTE: ${XTALATE_RATE_LIMIT_PER_MINUTE:-120} services: postgres: diff --git a/frontend/app/conversions/[conversion_id]/page.test.tsx b/frontend/app/conversions/[conversion_id]/page.test.tsx index cb0346c..4f355ae 100644 --- a/frontend/app/conversions/[conversion_id]/page.test.tsx +++ b/frontend/app/conversions/[conversion_id]/page.test.tsx @@ -23,7 +23,7 @@ const { urlSearchParams } = vi.hoisted(() => ({ urlSearchParams: new URLSearchPa vi.mock("next/navigation", () => ({ useParams: () => ({ conversion_id: "cnv-under-test" }), useSearchParams: () => urlSearchParams, - useRouter: () => ({ push: vi.fn() }), + useRouter: () => ({ replace: vi.fn(), push: vi.fn() }), })); const apiGet = vi.fn(); @@ -144,14 +144,15 @@ describe( ); }); - it("back returns to the file page when a live file_id was handed forward", async () => { + it("back returns to the file's workspace when a live file_id was handed forward", async () => { urlSearchParams.set("file_id", "file-42"); renderWithRecord(lossyRecord); await screen.findByRole("heading", { level: 1 }); - // Arriving from a live upload, back should return to that file — not drop the file in hand. + // Arriving from a live upload, back should return to that file's workspace — not drop the + // file in hand (UI redesign S2: the legacy route resolves into `/f/[id]`). expect(screen.getByRole("link", { name: "Back to Inspection" })).toHaveAttribute( "href", - "/files/file-42", + "/f/file-42", ); }); @@ -176,7 +177,7 @@ describe( expect(await screen.findByRole("region", { name: /resolve and retry/i })).toBeInTheDocument(); expect(screen.getByRole("link", { name: /upload the file again/i })).toHaveAttribute( "href", - "/convert", + "/", ); }); diff --git a/frontend/app/conversions/[conversion_id]/page.tsx b/frontend/app/conversions/[conversion_id]/page.tsx index 83105a9..899ca3e 100644 --- a/frontend/app/conversions/[conversion_id]/page.tsx +++ b/frontend/app/conversions/[conversion_id]/page.tsx @@ -1,341 +1,64 @@ "use client"; -import Link from "next/link"; -import { useParams, useSearchParams } from "next/navigation"; -import { useState } from "react"; -import { CompareTab } from "@/components/CompareTab"; -import { DownloadPanel } from "@/components/DownloadPanel"; -import { ErrorEnvelope } from "@/components/ErrorEnvelope"; -import { Provenance } from "@/components/Provenance"; -import { ResolveAndRetry } from "@/components/ResolveAndRetry"; -import { StructureTab } from "@/components/StructureTab"; -import { useConversionGeometry } from "@/lib/geometry/useGeometry"; -import { BackLink } from "@/components/shell/BackLink"; -import { ConversionReportPanel } from "@/components/report/ConversionReportPanel"; -import { RefusalPanel } from "@/components/report/RefusalPanel"; -import { SummaryChips } from "@/components/report/SummaryChips"; -import { ValidationReportPanel } from "@/components/report/ValidationReportPanel"; -import { conversionQuery, queryKeys, submitRevalidate } from "@/lib/api/queries"; -import { toErrorEnvelope } from "@/lib/api/useInspection"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import type { - ConversionRecord, - ErrorEnvelope as ErrorEnvelopeModel, -} from "@/lib/report/types"; +import { useEffect } from "react"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; +import { useQuery } from "@tanstack/react-query"; +import { ConversionRecord } from "@/components/workspace/ConversionRecord"; +import { apiClient } from "@/lib/api/client"; +import { queryKeys } from "@/lib/api/queries"; +import type { Schemas } from "@/lib/api/client"; /** - * The conversion record page (MASTER_SPEC Part 6 §4.4, Part 7 §2.5–§2.6; slice M29-S2). + * Legacy route (UI redesign S2, D244): the durable record now lives at + * `/f/[file_id]/report/[cid]` (the workspace's Report tab). The record itself carries no `file_id` + * (Part 6 §4.4), so resolution is two-stage: * - * The consolidated, linkable outcome — the page a collaborator is sent and the page a methods - * section cites. Everything on it comes from one `GET /v1/conversions/{id}`, which is served from - * persisted rows alone, so the URL keeps working after the output bytes have expired. - * - * **The layout law.** The order of this page is load-bearing, not cosmetic: - * - * outcome header → summary chips → download panel → reports → provenance - * - * The download control sits *below* the honest quantitative summary of what the conversion kept and - * lost, so the loss summary is structurally in view before the file can be taken. A reader cannot - * reach the button without passing what it cost. This is the one thing in the slice that is never - * cut; a "download" button in the header would technically work and would quietly undo the entire - * point of the product (P1). - * - * **The header never celebrates a lossy conversion.** Its wording is derived from the report's own - * counts, so "Converted — 3 fields removed" is what a lossy run says. There is no success styling - * that outranks the numbers, and no "Done!" that a reader could take as "nothing happened to my - * data". A *refused* conversion routes here too and renders through the same refusal component the - * job page uses, because a refusal is a considered outcome with a record, not an error. - * - * Re-validation is offered because a stored conversion can be re-thresholded long after its bytes - * are gone (Part 6 §2) — and it **appends** a report rather than replacing one, which the page says - * out loud. The reader chooses the tolerance profile (v0.7 M32-S2 lands the picker v0.6 cut): the - * §4.4 named profiles `default`/`strict`/`loose`, so no bar is ever changed without the user asking. - * - * A **refused** conversion is not a dead-end: its record is immutable history, but the same source - * and target can be re-submitted through the interactive recovery cards (`ResolveAndRetry`, M32-S2). + * - `?file_id=` handed forward (the app always does) → immediate redirect into the workspace. + * - a bare bookmarked URL → look the conversion up in `/v1/history`, whose rows carry `file_id` + * while the source upload is still live (Part 6 §4.4); if found, redirect into the workspace. + * - otherwise (the source bytes are long gone — reports outlive bytes) the record renders + * standalone, exactly as it always did: no 404, behaviour preserved. */ - -function outcomeHeadline(record: ConversionRecord): string { - const report = record.conversion_report; - if (report.status === "refused") return "Refused — no file was written"; - - const removed = report.removed.length; - const assumptions = report.assumptions.length; - const parts: string[] = []; - if (removed > 0) parts.push(`${removed} field${removed === 1 ? "" : "s"} removed`); - if (assumptions > 0) { - parts.push(`${assumptions} assumption${assumptions === 1 ? "" : "s"} recorded`); - } - // Nothing lost and nothing assumed is the only case that may be stated plainly as complete — - // and even then it is a statement about the data, not a congratulation. - if (parts.length === 0) return "Converted — nothing was lost or assumed"; - return `Converted — ${parts.join(", ")}`; -} - -/** The validation line beside the headline: what was *verified*, or plainly that nothing was. */ -function validationHeadline(record: ConversionRecord): string { - const validation = record.validation_report; - if (record.conversion_report.status === "refused") { - return "No validation: nothing was written, so there was nothing to check."; - } - if (validation === null) return "Validation has not been recorded for this conversion."; - const failed = validation.checks.filter((c) => c.status === "fail").length; - const warned = validation.checks.filter((c) => c.status === "warn").length; - const skipped = validation.checks.filter((c) => c.status === "skipped").length; - const detail = [ - failed > 0 ? `${failed} failed` : null, - warned > 0 ? `${warned} warned` : null, - skipped > 0 ? `${skipped} skipped` : null, - ] - .filter(Boolean) - .join(", "); - const total = validation.checks.length; - return detail - ? `Validation ${validation.status}: ${total} checks — ${detail}.` - : `Validation ${validation.status}: all ${total} checks passed.`; -} - -export default function ConversionRecordPage() { +export default function LegacyConversionRecordPage() { const params = useParams<{ conversion_id: string }>(); const conversionId = params.conversion_id; - // The record does not carry a `file_id` (the service reduces source/target to format + filename), - // so "convert again" can only return to the file page when the caller that linked here knew it. - // Absent that, we send the reader to a fresh upload rather than fabricating an id that 404s. const fileId = useSearchParams().get("file_id"); - const queryClient = useQueryClient(); - - // The consistent back affordance goes to the file this record came from when we know it, - // otherwise to the history list (a shared link carries no file_id). Never raw browser-back — - // the same rule as the sibling job page (`app/convert/[job_id]/page.tsx`). - const back = fileId - ? { href: `/files/${fileId}`, label: "Inspection" } - : { href: "/history", label: "History" }; - - const [revalidateError, setRevalidateError] = useState(null); - const [revalidating, setRevalidating] = useState(false); - const [profile, setProfile] = useState("default"); - // The viewer tab switch (M62-S1): one visualization surface at a time — the M60 Structure tab - // (output) is the default, the M62 Compare tab (source + output side by side) is one toggle away. - const [vizTab, setVizTab] = useState<"structure" | "compare">("structure"); - - const query = useQuery(conversionQuery(conversionId)); - - // The conversion's **output** geometry (M60-S1): the Structure tab renders the result — the - // bytes the user downloads — fed straight from `GET /v1/conversions/{id}/geometry?side=output` - // at the default frame (D232). Source/output side-by-side is M62 (Compare), not this tab. - const outputGeometry = useConversionGeometry(conversionId, "output"); - - async function handleRevalidate() { - setRevalidateError(null); - setRevalidating(true); - const result = await submitRevalidate(conversionId, profile); - setRevalidating(false); - if (!result.ok) { - setRevalidateError( - toErrorEnvelope(result.error, "REVALIDATE_FAILED", "Could not re-validate this conversion."), - ); - return; + const router = useRouter(); + + // Only fetch history when the caller didn't already hand a file forward. + const history = useQuery({ + queryKey: queryKeys.history, + queryFn: async () => { + const { data, error } = await apiClient.GET("/v1/history", { + params: { query: { limit: 100 } }, + }); + if (error) throw error; + return data; + }, + enabled: !fileId, + // A bounded default retry: a transient fetch failure (a cold dev proxy, a hiccup) must not + // permanently strand a resolvable bookmark on the standalone path — the lookup is one-shot + // by design (no polling), so a failed attempt gets its bounded retries before giving up. + }); + const resolvedFileId = + fileId ?? + (history.data?.items ?? []).find( + (item: Schemas["HistoryItem"]) => + item.conversion_id === conversionId && Boolean(item.file_id), + )?.file_id ?? + null; + + useEffect(() => { + if (resolvedFileId) { + router.replace(`/f/${resolvedFileId}/report/${conversionId}`); } - // The re-validation runs as a job; re-read the record so the appended report appears when done. - await queryClient.invalidateQueries({ queryKey: queryKeys.conversion(conversionId) }); - } - - if (query.isError) { - return ( -
- - - - Start a new conversion - -
- ); - } - - const record = query.data as ConversionRecord | undefined; - if (!record) { - return ( -
-

- Loading this conversion record… -

-
- ); - } - - const report = record.conversion_report; - const refused = report.status === "refused"; + }, [resolvedFileId, conversionId, router]); return ( -
- - {/* 1. Outcome header — quantitative, never celebratory. */} -
-

- {outcomeHeadline(record)} -

-

- {record.source.filename ?? "source"}{" "} - ({record.source.format_id}) - - ({record.target.format_id}) -

-

{validationHeadline(record)}

-
- - {/* 2. Summary chips — the loss summary, above the download by law (see the docstring). */} - {refused ? null : ( -
- -
- )} - - {/* A refusal is a considered outcome with a record; it renders here, not as an error. */} - {refused ? ( -
- - {/* Not a dead-end: re-enter the cards with the same source and target (M32-S2). */} - -
- ) : null} - - {/* 3. Download — structurally below the summary a reader has just passed. */} - - - {/* 4. The two reports: side by side on a wide screen, stacked on a narrow one. */} -
- - {record.validation_report ? ( - - ) : ( -
-

Validation report

-

- {refused - ? "None — the conversion was refused, so no output was written and nothing was measured." - : "None recorded for this conversion yet."} -

-
- )} -
- - {/* The Structure / Compare viewer tabs (M60-S1 + M62-S1, Part 7 §6). One visualization - surface at a time (a genuine tab control, the M60 "tab" seam now with its sibling), each - rendering the conversion's Canonical Object(s) fed from the M59 geometry endpoint. The - panels and reports above are untouched; only the active tab mounts, so the page carries - exactly one live structure surface at a time. A **refused** conversion has no output - bytes, so no viewer — the RefusalPanel above is the substance. */} - {refused ? null : ( -
-
- - -
- {vizTab === "structure" ? ( -
- -
- ) : ( -
- -
- )} -
- )} - - {/* Re-validate: appends, never replaces (Part 6 §2), and works after the bytes are gone. The - reader chooses the tolerance profile (M32-S2) — the bar is never changed without asking. */} - {record.validation_report ? ( -
-
- - -
-

- Re-validation re-thresholds the measurements already recorded — it does not re-read the - file, and it adds a report rather than replacing this one. -

- {revalidateError ? : null} -
- ) : null} - - {/* 5. Provenance — the citable facts. */} - - - -
+ ); } diff --git a/frontend/app/convert/[job_id]/page.test.tsx b/frontend/app/convert/[job_id]/page.test.tsx index 9bb27c1..87755a7 100644 --- a/frontend/app/convert/[job_id]/page.test.tsx +++ b/frontend/app/convert/[job_id]/page.test.tsx @@ -27,7 +27,9 @@ import batchCompletedJob from "@/components/__fixtures__/job.batch_completed.jso vi.mock("next/navigation", () => ({ useParams: () => ({ job_id: "job-under-test" }), // A shared job link carries no `file_id`; the page must cope with that, so the default is empty. + // (With a `file_id` the page redirects into the workspace — covered by the e2e redirect journey.) useSearchParams: () => new URLSearchParams(), + useRouter: () => ({ replace: vi.fn(), push: vi.fn() }), })); /** @@ -141,12 +143,19 @@ describe("ConversionJobPage batch record (v1.5 M58-S2)", () => { expect(tallies).toHaveTextContent("Failed"); expect(tallies).toHaveTextContent("energy ×0"); - // Per-file links resolve to the ordinary child records (converted + refused in order). + // Per-file links resolve to the ordinary child records (converted + refused in order), each on + // its own file's workspace Convert tab (the child's live `file_id`, UI redesign S2). const [converted, refused] = batchCompletedJob.result.entries; const links = screen.getAllByRole("link", { name: /view this file\u2019s conversion record/i }); expect(links).toHaveLength(2); - expect(links[0]).toHaveAttribute("href", `/convert/${converted.child_job_id}`); - expect(links[1]).toHaveAttribute("href", `/convert/${refused.child_job_id}`); + expect(links[0]).toHaveAttribute( + "href", + `/f/${converted.file_id}/convert?job=${converted.child_job_id}`, + ); + expect(links[1]).toHaveAttribute( + "href", + `/f/${refused.file_id}/convert?job=${refused.child_job_id}`, + ); // The batch itself offers no download — each file's download lives on its own record. expect(screen.queryByRole("button", { name: /download/i })).not.toBeInTheDocument(); }); @@ -160,7 +169,10 @@ describe("ConversionJobPage batch record (v1.5 M58-S2)", () => { const child = batchAwaitingJob.children[0]; expect(child.state).toBe("awaiting_recovery"); const answer = screen.getByRole("link", { name: /answer on this conversion's record/i }); - expect(answer).toHaveAttribute("href", `/convert/${child.job_id}`); + expect(answer).toHaveAttribute( + "href", + `/f/${child.file_id}/convert?job=${child.job_id}`, + ); // The batch parent carries no recovery step of its own — nothing to decide here. expect(screen.queryByTestId("recovery-step")).not.toBeInTheDocument(); }); diff --git a/frontend/app/convert/[job_id]/page.tsx b/frontend/app/convert/[job_id]/page.tsx index 15fc3be..35fec9c 100644 --- a/frontend/app/convert/[job_id]/page.tsx +++ b/frontend/app/convert/[job_id]/page.tsx @@ -1,424 +1,37 @@ "use client"; -import Link from "next/link"; -import { useParams, useSearchParams } from "next/navigation"; -import { useState } from "react"; -import { ErrorEnvelope } from "@/components/ErrorEnvelope"; -import { JobPhase } from "@/components/JobPhase"; -import { BackLink } from "@/components/shell/BackLink"; -import { RecoveryStep } from "@/components/recovery/RecoveryStep"; -import { ConversionReportPanel } from "@/components/report/ConversionReportPanel"; -import { RefusalPanel } from "@/components/report/RefusalPanel"; -import { buttonClasses } from "@/components/ui/Button"; -import { cancelJob, isTerminalJobState, jobQuery, queryKeys } from "@/lib/api/queries"; -import { toErrorEnvelope } from "@/lib/api/useInspection"; -import { useCompletionSignal } from "@/lib/notify/useCompletionSignal"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import type { - AwaitingRecoveryBlock, - BatchConvertResult, - ConversionReport, - ErrorEnvelope as ErrorEnvelopeModel, - JobChildRef, -} from "@/lib/report/types"; +import { useEffect } from "react"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; +import { ConversionJob } from "@/components/workspace/ConversionJob"; /** - * The live conversion job page (MASTER_SPEC Part 6 §3.2, Part 7 §2.4; slice M29-S1). - * - * Everything on this page comes from the long-polled job envelope — there is no client-side model - * of what "should" be happening. That is the whole design: the envelope carries the truth, the UI - * renders it, and every state the state machine can reach has an honest card here rather than a - * spinner that never resolves: - * - * - `queued` / `running` — the phase indicator, with **no invented progress** (`JobPhase`). - * - `awaiting_recovery` — the interactive recovery step (`RecoveryStep`, M31): the decision cards, - * the visible deadline stated as a refusal, and a first-class decline. It replaces v0.6's - * read-only placeholder and still never reads as "a default was picked". - * - `completed` — the Conversion Report, or the refusal panel when the engine **declined**. A - * refusal is a completed job at HTTP 200, not an error (Part 6 §1), and is rendered as the - * considered outcome it is. - * - `failed` — the service's own error envelope, code verbatim. - * - `expired` — the pause's deadline passed: the conversion was **refused for want of a decision**. - * Never worded as if a default were applied (Part 7 §2.4). - * - `cancelled` — a card saying no report exists, because none does. Not an empty report shell. - * - * Cancel is offered in every non-terminal state and is described as best-effort, because it is: the - * server may already have finished, and a cancel that loses that race must not overwrite the - * recorded outcome. So the click re-reads the job rather than assuming it won. + * Legacy route (UI redesign S2, D244): the live job surface now lives at + * `/f/[file_id]/convert?job=…` (the workspace's Convert tab). The job envelope carries no + * `file_id` on the wire (Part 6 §3.2), so this route can only resolve the workspace when the caller + * handed one forward (`?file_id=`, as the app always does) — then it redirects. A bare bookmarked + * job URL renders the job standalone (the same content, with a back affordance) rather than 404ing: + * behaviour preserved, never a broken link. */ - -function Card({ - title, - tone = "neutral", - children, -}: { - title: string; - tone?: "neutral" | "fail"; - children?: React.ReactNode; -}) { - const border = tone === "fail" ? "border-cb-fail bg-cb-fail-bg" : "border-line bg-surface"; - return ( -
-

{title}

- {children} -
- ); -} - -function StartOver() { - return ( - - Convert another file - - ); -} - -export default function ConversionJobPage() { +export default function LegacyConversionJobPage() { const params = useParams<{ job_id: string }>(); const jobId = params.job_id; - // Handed forward by `/files/[file_id]` so the record can offer a re-convert; absent on a shared link. const fileId = useSearchParams().get("file_id"); - const queryClient = useQueryClient(); - - // The consistent back affordance goes to this job's own parent: the file it came from when we know - // it, otherwise the upload step (a shared link carries no file_id). Never raw browser-back. - const back = fileId - ? { href: `/files/${fileId}`, label: "Inspection" } - : { href: "/convert", label: "Upload" }; - - const [cancelError, setCancelError] = useState(null); - const [cancelling, setCancelling] = useState(false); - - const job = useQuery(jobQuery(jobId)); + const router = useRouter(); - // The completion signal (v1.1 M39-S4, C1): chime + browser Notification, fired once when this - // job makes the non-terminal → terminal transition, honoring the persisted mute toggle. It fires - // only for a job the user launched (armed by the Convert submit and consumed here), so a refresh - // of — or a shared link to — an already-finished job stays silent. The audio was armed by the - // Convert click (`unlockAudio`, TargetPicker) so it plays even when the tab is backgrounded. - useCompletionSignal(job.data?.state, jobId); - - async function handleCancel() { - setCancelError(null); - setCancelling(true); - const result = await cancelJob(jobId); - setCancelling(false); - if (!result.ok) { - // A cancel can legitimately fail — `JOB_ALREADY_TERMINAL` when the job finished first. That is - // information, not noise: show the service's code and let the refreshed envelope tell the rest. - setCancelError( - toErrorEnvelope(result.error, "CANCEL_FAILED", "Could not cancel this job."), - ); + useEffect(() => { + if (fileId) { + router.replace(`/f/${fileId}/convert?job=${encodeURIComponent(jobId)}`); } - // Either way, re-read the job — the server's state is the answer, not our optimism. - await queryClient.invalidateQueries({ queryKey: queryKeys.job(jobId) }); - } - - // A recovery resume returns the server's next envelope (re-paused for the rest, or completed). We - // do not trust that shape directly: invalidate the poll so the page re-renders from a fresh GET, - // the same "server state is the answer" rule as cancel. - async function handleResumed() { - await queryClient.invalidateQueries({ queryKey: queryKeys.job(jobId) }); - } - - if (job.isError) { - return ( -
- - - -
- ); - } - - const envelope = job.data; - if (!envelope) { - return ( -
- -

- Loading this conversion… -

-
- ); - } - - const state = envelope.state; - const terminal = isTerminalJobState(state); - // A `batch_convert` parent is an ordinary job whose `result` is the aggregate (Part 6 §3, v1.5 - // M58) and whose `children` projection names each fanned-out child job — both rendered below in - // the batch branch. Everything else on this page is the single-file contract unchanged. - const batch = envelope.kind === "batch_convert"; - const children = (envelope.children ?? []) as JobChildRef[]; - const batchResult = (envelope.result ?? null) as BatchConvertResult | null; - const result = (envelope.result ?? null) as { - conversion_id?: string; - conversion_report?: ConversionReport; - } | null; - const report = result?.conversion_report; + }, [fileId, jobId, router]); return ( -
- -
-

- {batch ? "Batch conversion" : "Conversion"} -

-

job {envelope.job_id}

-
- - {state === "queued" || state === "running" ? : null} - - {state === "awaiting_recovery" && envelope.awaiting_recovery ? ( - - ) : null} - - {/* - The batch parent's pause (v1.5 M58-S2): a `batch_convert` parent carries **no recovery - block of its own** — per-file consent stays per-file, so the batch waits on the children - that still need a decision, each answered on the child's own ordinary record. The - `children` projection (present in every state) is rendered as-is: every child's honest - state, with a link to its record. - */} - {batch && state === "awaiting_recovery" ? ( - -

- This batch made no choice for any file — each decision belongs to the conversion it - concerns. The batch waits on the conversions below that still need a decision, and - completes once every one of them is settled. -

-
    - {children.map((child, i) => ( -
  • - - File {i + 1} ·{" "} - {child.state} - - - {child.state === "awaiting_recovery" - ? "Answer on this conversion's record" - : "View this conversion"} - -
  • - ))} -
-
- ) : null} - - {/* - The completed batch record (v1.5 M58-S2): the parent tallies — the reused library - `BatchTallies`/`LabelPresence`, rendered as the counts they are — above the per-file - links, so the honest summary is structurally in view before any reader follows a child - to its record (the layout law: summary above download, and the downloads live only on the - children's own records). Each entry links to the **ordinary** child conversion record the - existing convert page already renders; nothing here re-computes a report or a tally. - */} - {batch && state === "completed" && batchResult ? ( -
- -

- This batch converted {batchResult.tallies.converted} of{" "} - {batchResult.tallies.total} file{batchResult.tallies.total === 1 ? "" : "s"}; - every file’s own record keeps its full report, and each of the links below - resolves to it. -

-
-
-
Total
-
{batchResult.tallies.total}
-
-
-
Converted
-
{batchResult.tallies.converted}
-
-
-
Refused
-
{batchResult.tallies.refused}
-
-
-
Failed
-
{batchResult.tallies.failed}
-
-
-

- Outputs carrying each label: energy ×{batchResult.tallies.label_presence.energy},{" "} - forces ×{batchResult.tallies.label_presence.forces}, stress × - {batchResult.tallies.label_presence.stress}. -

-
- -
    - {batchResult.entries.map((entry, i) => ( -
  • - - File {i + 1} ·{" "} - {entry.status} - - - View this file’s conversion record - -
  • - ))} -
-
-
- ) : null} - - {state === "completed" && report ? ( -
- {report.status === "refused" ? ( - - ) : ( - - )} - {/* - The job is transient; the record is the durable, linkable outcome — and the only place - the download lives, deliberately below the loss summary (M29-S2). A refusal routes there - too: it is a recorded outcome, not a dead end. `file_id` is handed forward because - neither the envelope nor the record carries it. - */} - {result?.conversion_id ? ( - - View the full record{report.status === "refused" ? "" : " and download the file"} - - ) : null} -
- ) : null} - - {state === "failed" ? ( -
- - -
- ) : null} - - {!batch && state === "expired" ? ( -
- -

- This conversion needed a decision before it could be written, and the window for - supplying one closed. Xtalate refused the conversion rather than - choosing on your behalf: no default was applied, no value was invented, and no output - file was written. -

-

- The refusal itself is recorded — the reference below identifies it — so the outcome is - auditable rather than merely absent. Converting again lets you supply the choices up - front. -

-
- {/* The service's own body: code `RECOVERY_REQUIRED`, and the refused conversion's id. */} - - -
- ) : null} - - {batch && state === "cancelled" ? ( -
- -

- You cancelled this batch, so no aggregate result exists for it — - not an empty one, none at all. Files it had not yet launched were never started; - the conversions already launched are ordinary jobs and keep their own records. -

-
- -
- ) : null} - - {!batch && state === "cancelled" ? ( -
- -

- You cancelled this conversion, so no report exists for it — not an - empty one, none at all. Nothing was written and nothing was measured. -

-
- -
- ) : null} - - {/* A terminal state the UI does not have a card for is still named, never rendered blank. */} - {terminal && state !== "completed" && !["failed", "expired", "cancelled"].includes(state) ? ( - -

- The service reported this job as {state}. -

-
- ) : null} - - {!batch && state === "completed" && !report ? ( - -

- This job completed but carried no conversion report. -

-
- ) : null} - - {/* - The footer cancel serves the states with no other exit (`queued`, `running`). A paused job - has a first-class decline *inside* the recovery step, so it is suppressed here — one decline, - in the decision surface, rather than two identical buttons. - */} - {!terminal && state !== "awaiting_recovery" ? ( -
- -

- {batch - ? "Cancelling stops the batch from launching any remaining files; conversions already launched keep their own records." - : "Cancelling is best-effort: work already underway may finish first, and a conversion that has already produced its result keeps it."} -

- {cancelError ? : null} -
- ) : null} -
+ ); } diff --git a/frontend/app/convert/page.tsx b/frontend/app/convert/page.tsx index 03bc019..ab95d0e 100644 --- a/frontend/app/convert/page.tsx +++ b/frontend/app/convert/page.tsx @@ -1,51 +1,10 @@ -"use client"; - -import { useRouter } from "next/navigation"; -import { useQuery } from "@tanstack/react-query"; -import { limitsQuery } from "@/lib/api/queries"; -import { useUpload } from "@/lib/api/useUpload"; -import { BackLink } from "@/components/shell/BackLink"; -import { UploadDropzone } from "@/components/upload/UploadDropzone"; +import { redirect } from "next/navigation"; /** - * Upload (`/convert`) — the first wizard step (MASTER_SPEC Part 7 §2.2). - * - * A client route: it fetches the instance limits so the drop zone can show the size ceiling *before* - * an upload fails (Part 6 §5), runs the transfer through {@link useUpload} with real progress, and on - * a `201` routes to the file resource at `/files/[file_id]` — where M28-S2 submits `POST /v1/inspect`. - * Failures render in place through the shared error envelope; the user stays on the page and retries. + * Legacy route (UI redesign S2, D244): upload lives on the landing (`/`) — the hero's "Convert a + * file" opens the dropzone there — so this route file stays only so bookmarked `/convert` URLs keep + * resolving. A server redirect, no 404s. */ -export default function ConvertPage() { - const router = useRouter(); - const { data: limits } = useQuery(limitsQuery()); - const { status, progress, error, result, upload } = useUpload(); - - async function onFile(file: File) { - const outcome = await upload(file); - if (outcome.ok) router.push(`/files/${outcome.data.file_id}`); - } - - return ( -
- -
-

Convert a file

-

- Upload a source file to inspect what it contains, then choose a target format. Every - conversion produces a report of exactly what was kept, dropped, or assumed. -

-
- - -
- ); +export default function LegacyConvertPage() { + redirect("/"); } diff --git a/frontend/app/dev/structure/[file_id]/page.tsx b/frontend/app/dev/structure/[file_id]/page.tsx index d9c0611..edc4eb1 100644 --- a/frontend/app/dev/structure/[file_id]/page.tsx +++ b/frontend/app/dev/structure/[file_id]/page.tsx @@ -5,9 +5,16 @@ * against a chosen file's geometry endpoint. This is the minimal-mount evidence that a canonical * object renders from `/v1/files/{file_id}/geometry` with no intermediate format, plus the S3 * scrub harness (the window links below drive client-side navigations so the S3 journey measures - * heap across sequential mounts in one JS context). **Not** the Structure tab (that is M60) and - * not a production surface: in a production build (`NODE_ENV === "production"`, as `next build` - * bakes) the page renders a gate notice instead of the viewer. + * heap across sequential mounts in one JS context). + * + * **Retained under UI redesign S5 (D246-adjacent; Rev 1.91).** The `/f/[file_id]/structure` + * workspace tab (S2) is now the promoted home of the viewer, but this spike stays: the heap + * measurement journeys (`e2e/geometry-spike.spec.ts`, `e2e/trajectory-playback-memory.spec.ts`) + * are the committed benchmark harness, and they depend on this surface's client-side window-link + * scrub mechanism to measure browser heap across sequential mounts *in one JS context* — exactly + * what a scrub of the M61 playback budget needs. It is still **not** a production surface: in a + * production build (`NODE_ENV === "production"`, as `next build` bakes) the page renders a gate + * notice instead of the viewer. */ import Link from "next/link"; import { useParams, useSearchParams } from "next/navigation"; @@ -38,10 +45,10 @@ export default function DevStructurePage() { if (process.env.NODE_ENV === "production") { return (
-

+

Dev-only spike surface

-

+

This route is the M59-S2/S3 render proof and is not available in production builds. The Structure tab ships in M60.

@@ -51,27 +58,27 @@ export default function DevStructurePage() { return (
-

+

Structure render proof{" "} - + (dev-only spike surface — M59-S2/S3)

-

- Renders file {file_id}{" "} +

+ Renders file {file_id}{" "} from its canonical geometry endpoint — no intermediate format, no export. {frames ? ( <> {" "} - frames={frames} + frames={frames} ) : null}

{status === "loading" ? ( -

Loading geometry…

+

Loading geometry…

) : status === "error" ? ( -

+

Could not load geometry: {String(error)}

) : geometry ? ( @@ -92,15 +99,15 @@ export default function DevStructurePage() { {/* The S3 scrub harness: client-side window links so the spike journey measures heap across sequential mounts in one JS context (the M61 scrub story). */}
- scrub windows: + scrub windows: {SCRUB_WINDOWS.map((w) => ( {w} diff --git a/frontend/app/f/[file_id]/analysis/page.tsx b/frontend/app/f/[file_id]/analysis/page.tsx new file mode 100644 index 0000000..ec9a11c --- /dev/null +++ b/frontend/app/f/[file_id]/analysis/page.tsx @@ -0,0 +1,19 @@ +/** + * The workspace's Analysis tab — a reserved seam (UI redesign S2, D244; design spec §7, P6). + * + * Analysis is a named, **empty** affordance in this redesign: it renders a clearly-labelled + * placeholder and does nothing. No engine work, no compute, no edit — the seam exists so the later + * work (v1.8's analysis overlays) attaches without re-architecting the shell. S6 owns the polish + * of the seam copy; this slice just makes the tab honest. + */ +export default function AnalysisTabPage() { + return ( +
+

Analysis

+

+ This tab is reserved for per-atom and trajectory analysis — coming in a later version. Your + file and its reports are untouched; nothing here runs yet. +

+
+ ); +} diff --git a/frontend/app/f/[file_id]/convert/page.tsx b/frontend/app/f/[file_id]/convert/page.tsx new file mode 100644 index 0000000..0b8c062 --- /dev/null +++ b/frontend/app/f/[file_id]/convert/page.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; +import Link from "next/link"; +import { useQuery } from "@tanstack/react-query"; +import { ErrorEnvelope } from "@/components/ErrorEnvelope"; +import { PresetManager } from "@/components/presets/PresetManager"; +import { TargetPicker } from "@/components/TargetPicker"; +import { ConversionJob } from "@/components/workspace/ConversionJob"; +import { apiClient } from "@/lib/api/client"; +import { capabilitiesQuery } from "@/lib/api/queries"; +import { toErrorEnvelope, useInspection } from "@/lib/api/useInspection"; +import { writableTargets, type CapabilitiesMap } from "@/lib/capabilities/types"; +import { armCompletionSignal } from "@/lib/notify/completionSignal"; +import type { ErrorEnvelope as ErrorEnvelopeModel } from "@/lib/report/types"; + +/** + * The workspace's Convert tab (UI redesign S2, D244; design spec §3, D-R1/D-R5). + * + * Two modes on one surface: + * + * - **Idle** (`/f/[file_id]/convert`): today's target-select + pre-flight loss preview + * (`TargetPicker`, moved from the old file page) — the reader picks a target, sees exactly what + * it would drop/recover, and commits on the explicit confirm step (B2). + * - **Active job** (`/f/[file_id]/convert?job=…`): the live conversion (ported job page) — phases, + * the interactive recovery step, and the completed record link. The Convert submit routes here + * with the job id, so the whole flow stays in the workspace; the rail's guided-spine CTA is the + * door in. + */ +export default function ConvertTabPage() { + const params = useParams<{ file_id: string }>(); + const router = useRouter(); + const fileId = params.file_id; + const jobId = useSearchParams().get("job"); + + const [submitError, setSubmitError] = useState(null); + // The picker's live (target, mode) selection — reported upward for "save this as a preset" (S4). + const [selection, setSelection] = useState<{ + target: string; + mode: "permissive" | "strict"; + } | null>(null); + + const inspection = useInspection(fileId); + const capabilities = useQuery(capabilitiesQuery()); + const targets = useMemo( + () => (capabilities.data ? writableTargets(capabilities.data as CapabilitiesMap) : []), + [capabilities.data], + ); + + async function handleConvert(targetFormatId: string, mode: "permissive" | "strict") { + setSubmitError(null); + const { data, error } = await apiClient.POST("/v1/convert", { + body: { + file_id: fileId, + target_format_id: targetFormatId, + // v0.7 has the interactive recovery cards (M31), so the button now submits + // allow_recovery: true: a conversion that needs a decision **pauses** (awaiting_recovery) + // and the tab renders the decision cards, rather than refusing outright as v0.6 did (D95). + // Pausing to ask is the explicit-recovery path (P4); nothing is ever defaulted, and an + // unanswered pause still expires to a refusal. Strict-mode loss likewise refuses. + options: { + mode, + acknowledge_loss: false, + acknowledge_parse_warnings: false, + allow_recovery: true, + tolerance_profile: "default", + }, + }, + }); + if (error || !data) { + setSubmitError(toErrorEnvelope(error, "CONVERT_SUBMIT_FAILED", "Could not start the conversion.")); + return; + } + // Arm this job's completion signal (v1.1 M39-S4, C1): only a job the user just launched may + // chime when it finishes, so a later refresh of — or a shared link to — the finished job stays + // silent. + armCompletionSignal(data.job_id); + router.push(`/f/${fileId}/convert?job=${encodeURIComponent(data.job_id)}`); + } + + if (jobId) { + return ( +
+ +
+ ); + } + + return ( +
+
+

Convert

+

+ Choose a target format. The preview below names exactly what the target cannot express, + what it would drop, and what would need a recovery decision — nothing converts silently. +

+
+ {inspection.status === "loading" ? ( +

+ Inspecting this file… +

+ ) : inspection.status === "error" ? ( +
+ + + Upload a different file + +
+ ) : ( + <> + {submitError ? : null} + {targets.length > 0 ? ( + + ) : null} + {"" /* Saved presets (S4) — remembers a target + posture; re-converting pauses for any + file-specific recovery decisions (P4) instead of silently defaulting them. */} + t.format_id === selection.target)?.format_name ?? null : null + } + onConvert={handleConvert} + /> + + )} +
+ ); +} diff --git a/frontend/app/f/[file_id]/layout.tsx b/frontend/app/f/[file_id]/layout.tsx new file mode 100644 index 0000000..41dd536 --- /dev/null +++ b/frontend/app/f/[file_id]/layout.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { SourceRail } from "@/components/shell/SourceRail"; +import { WorkspaceTabs } from "@/components/shell/WorkspaceTabs"; +import { FutureSeams } from "@/components/shell/FutureSeams"; + +/** + * The file-centric workspace shell (UI redesign S2, D244; design spec §3, D-R1/D-R2). + * + * Every `/f/[file_id]` tab renders inside one layout: a pinned **source rail** (filename, format + + * confidence, counts, the guided-spine Convert CTA) beside the tabbed main column + * (`Inspect · Structure · Convert · Report · Analysis`). The rail collapses to a top summary bar on + * narrow screens — the layout stacks instead of scrolling sideways. Below the active tab's content + * sit the reserved **empty seams** of the shell (S6, D247): the File Repair action and the + * Assistant side-panel slot, each an inert "coming later" seat (see `FutureSeams`) — P6. + */ +export default function WorkspaceLayout({ children }: { children: React.ReactNode }) { + const { file_id } = useParams<{ file_id: string }>(); + return ( +
+ +
+ +
{children}
+ +
+
+ ); +} diff --git a/frontend/app/f/[file_id]/page.tsx b/frontend/app/f/[file_id]/page.tsx new file mode 100644 index 0000000..abf759d --- /dev/null +++ b/frontend/app/f/[file_id]/page.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { useParams } from "next/navigation"; +import Link from "next/link"; +import { ErrorEnvelope } from "@/components/ErrorEnvelope"; +import { Inventory } from "@/components/Inventory"; +import { useInspection } from "@/lib/api/useInspection"; +import { pushRecent } from "@/lib/prefs/recents"; +import type { DiscoveryReport } from "@/lib/report/types"; + +/** + * The workspace's Inspect tab (UI redesign S2, D244) — today's `/files/[file_id]` discovery panels, + * moved, not rewritten (design spec §3, D-R1): the file header (detected format + confidence with + * the \"Not the right format?\" override), the structure summary, and the contents inventory (the + * ✓/○/✗ leaf-path answer with parse warnings banded above it). + * + * The target picker now lives on the Convert tab (where the job + recovery wizard live), and the + * structure renders on the Structure tab — the rail and the tabs replace the old single-page + * layout, so each surface owns one job. The guided-spine CTA in the rail advances Inspect → Convert. + */ +function percent(confidence: number): string { + return `${Math.round(confidence * 100)}%`; +} + +function FileHeader({ + report, + override, + onOverride, +}: { + report: DiscoveryReport; + override: string | undefined; + onOverride: (formatId: string | undefined) => void; +}) { + const detected = report.format.format_id; + // Candidate formats to offer for a manual override — the detected one plus every sniff candidate. + const candidates = useMemo(() => { + const ids = new Set([detected]); + for (const ev of report.format.sniff_evidence ?? []) ids.add(ev.format_id); + return [...ids]; + }, [detected, report.format.sniff_evidence]); + + return ( +
+

{report.file.filename}

+

+ Detected {report.format.format_name}{" "} + {report.format.overridden ? ( + (format set manually) + ) : ( + + ({percent(report.format.confidence)} confidence + {report.format.ambiguous ? ", ambiguous" : ""}) + + )} +

+

sha256 {report.file.sha256.slice(0, 12)}…

+
+ Not the right format? + +
+
+ ); +} + +function StructureSummary({ report }: { report: DiscoveryReport }) { + const { frame_count, atom_count, species } = report.structure; + return ( +
+
+
Frames
+
{frame_count}
+
+
+
Atoms
+
{atom_count}
+
+
+
Species
+
{species.join(", ") || "—"}
+
+
+ ); +} + +export default function InspectTabPage() { + const params = useParams<{ file_id: string }>(); + const fileId = params.file_id; + + const [override, setOverride] = useState(undefined); + const inspection = useInspection(fileId, override); + // A stable "ready" handle so the render's `.report` access is explicitly narrowed to the state + // that actually carries it (the status-union ternary narrows the error/loading branches cleanly; + // this makes the ready branch unambiguous). + const readyReport = inspection.status === "ready" ? inspection : null; + + // Record this file as a recent (UI redesign S4, D246, D-R6): the recents strip + the command + // palette read the same localStorage list, so a file you opened seconds ago is one click away. + // Keyed by fileId so a re-render of the cached inspection never duplicates the entry. + const pushedFor = useRef(null); + useEffect(() => { + if (!readyReport || pushedFor.current === fileId) return; + pushedFor.current = fileId; + const report = readyReport.report; + pushRecent({ + key: fileId, + href: `/f/${fileId}`, + filename: report.file.filename ?? fileId, + format_id: report.format.format_id, + last_seen_at: new Date().toISOString(), + }); + // Depends on the narrowed `readyReport` (null until a successful inspection lands); never on + // `inspection.report` directly, which does not exist in the loading/error states. + }, [readyReport, fileId]); + + return ( +
+ {inspection.status === "loading" ? ( +

+ Inspecting this file… +

+ ) : inspection.status === "error" ? ( +
+ + + Upload a different file + +
+ ) : readyReport ? ( + <> + + + + + ) : null} +
+ ); +} diff --git a/frontend/app/f/[file_id]/report/[conversion_id]/page.tsx b/frontend/app/f/[file_id]/report/[conversion_id]/page.tsx new file mode 100644 index 0000000..6b393c8 --- /dev/null +++ b/frontend/app/f/[file_id]/report/[conversion_id]/page.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { ConversionRecord } from "@/components/workspace/ConversionRecord"; + +/** + * The workspace's Report tab (UI redesign S2, D244; design spec §3, D-R1) — a specific conversion's + * durable record inside the file's workspace (`/f/[file_id]/report/[cid]`). S3 redesigns the report + * panels themselves; S2 moves the surface. The file id rides in the URL (the record carries none of + * its own, Part 6 §4.4), so the rail can pin the source context beside the outcome. + */ +export default function ReportTabPage() { + const params = useParams<{ file_id: string; conversion_id: string }>(); + return ( + + ); +} diff --git a/frontend/app/f/[file_id]/structure/page.tsx b/frontend/app/f/[file_id]/structure/page.tsx new file mode 100644 index 0000000..bcebebf --- /dev/null +++ b/frontend/app/f/[file_id]/structure/page.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { StructureTab } from "@/components/StructureTab"; +import { useFileGeometry } from "@/lib/geometry/useGeometry"; +import { useInspection } from "@/lib/api/useInspection"; + +/** + * The workspace's Structure tab (UI redesign S2, D244) — the M60–M63 `StructureTab` given its own + * tab slot (design spec §3, D-R1): the file's own geometry from `GET /v1/files/{id}/geometry` + * (D232), mounted unchanged with the honest loading/expired/error states it owns. S5 finishes the + * promotion (the dev spike goes away); S2 just moves the surface here. + */ +export default function StructureTabPage() { + const params = useParams<{ file_id: string }>(); + const fileId = params.file_id; + + const fileGeometry = useFileGeometry(fileId); + // The viewer label is the source filename when inspection knows it (one fetch, deduped with the + // rail); the geometry itself never depends on it. + const inspection = useInspection(fileId); + const label = inspection.status === "ready" ? inspection.report.file.filename : undefined; + + return ( +
+ +
+ ); +} diff --git a/frontend/app/files/[file_id]/page.tsx b/frontend/app/files/[file_id]/page.tsx index 82f34b2..53b8a3b 100644 --- a/frontend/app/files/[file_id]/page.tsx +++ b/frontend/app/files/[file_id]/page.tsx @@ -1,205 +1,15 @@ -"use client"; - -import Link from "next/link"; -import { useParams, useRouter } from "next/navigation"; -import { useMemo, useState } from "react"; -import { ErrorEnvelope } from "@/components/ErrorEnvelope"; -import { Inventory } from "@/components/Inventory"; -import { StructureTab } from "@/components/StructureTab"; -import { BackLink } from "@/components/shell/BackLink"; -import { TargetPicker } from "@/components/TargetPicker"; -import { useFileGeometry } from "@/lib/geometry/useGeometry"; -import { apiClient } from "@/lib/api/client"; -import { capabilitiesQuery } from "@/lib/api/queries"; -import { toErrorEnvelope, useInspection } from "@/lib/api/useInspection"; -import { armCompletionSignal } from "@/lib/notify/completionSignal"; -import { useQuery } from "@tanstack/react-query"; -import { writableTargets, type CapabilitiesMap } from "@/lib/capabilities/types"; -import type { DiscoveryReport, ErrorEnvelope as ErrorEnvelopeModel } from "@/lib/report/types"; +import { redirect } from "next/navigation"; /** - * The inspection results page (MASTER_SPEC Part 3 §6, Part 7 §2; slice M28-S2). - * - * Four regions over the Discovery Report the service returns: a file header (sniff format + - * confidence, with a "Not the right format?" override that re-inspects), a structure summary, the - * contents inventory (the ✓/○/✗ leaf-path answer, with any parse warnings banded above it), and the - * target picker whose pre-flight overlay predicts what each target would carry, drop, or recover. - * - * The page is a thin presenter: every load-bearing decision — the presence→loss-kind mapping, the - * capability intersection — lives in unit-tested modules (`components/Inventory`, `lib/preflight`). - * Here we only wire data to them and route the Convert action to the conversion job (M29). + * Legacy route (UI redesign S2, D244): the inspection surface now lives at `/f/[file_id]` (the + * workspace's Inspect tab). This route file stays so every bookmarked `/files/[id]` URL keeps + * resolving — a server redirect, no 404s. */ - -function percent(confidence: number): string { - return `${Math.round(confidence * 100)}%`; -} - -function FileHeader({ - report, - override, - onOverride, +export default async function LegacyFilePage({ + params, }: { - report: DiscoveryReport; - override: string | undefined; - onOverride: (formatId: string | undefined) => void; + params: Promise<{ file_id: string }>; }) { - const detected = report.format.format_id; - // Candidate formats to offer for a manual override — the detected one plus every sniff candidate. - const candidates = useMemo(() => { - const ids = new Set([detected]); - for (const ev of report.format.sniff_evidence ?? []) ids.add(ev.format_id); - return [...ids]; - }, [detected, report.format.sniff_evidence]); - - return ( -
-

{report.file.filename}

-

- Detected {report.format.format_name}{" "} - {report.format.overridden ? ( - (format set manually) - ) : ( - - ({percent(report.format.confidence)} confidence - {report.format.ambiguous ? ", ambiguous" : ""}) - - )} -

-

sha256 {report.file.sha256.slice(0, 12)}…

-
- Not the right format? - -
-
- ); -} - -function StructureSummary({ report }: { report: DiscoveryReport }) { - const { frame_count, atom_count, species } = report.structure; - return ( -
-
-
Frames
-
{frame_count}
-
-
-
Atoms
-
{atom_count}
-
-
-
Species
-
{species.join(", ") || "—"}
-
-
- ); -} - -export default function FilePage() { - const params = useParams<{ file_id: string }>(); - const router = useRouter(); - const fileId = params.file_id; - - const [override, setOverride] = useState(undefined); - const [submitError, setSubmitError] = useState(null); - - const inspection = useInspection(fileId, override); - // The file's own geometry (M60-S1): the Structure tab renders it at the default frame - // (frames=0:1), fed straight from `GET /v1/files/{file_id}/geometry` (D232). - const fileGeometry = useFileGeometry(fileId); - const capabilities = useQuery(capabilitiesQuery()); - const targets = useMemo( - () => (capabilities.data ? writableTargets(capabilities.data as CapabilitiesMap) : []), - [capabilities.data], - ); - - async function handleConvert(targetFormatId: string, mode: "permissive" | "strict") { - setSubmitError(null); - const { data, error } = await apiClient.POST("/v1/convert", { - body: { - file_id: fileId, - target_format_id: targetFormatId, - // v0.7 has the interactive recovery cards (M31), so the button now submits - // allow_recovery: true: a conversion that needs a decision **pauses** (awaiting_recovery) - // and the job page renders the decision cards, rather than refusing outright as v0.6 did - // (D95, now lifted). Pausing to ask is the explicit-recovery path (P4); nothing is ever - // defaulted, and an unanswered pause still expires to a refusal. Strict-mode loss likewise - // refuses — recovery is about missing-required data, a separate axis from loss. - options: { - mode, - acknowledge_loss: false, - acknowledge_parse_warnings: false, - allow_recovery: true, - tolerance_profile: "default", - }, - }, - }); - if (error || !data) { - setSubmitError(toErrorEnvelope(error, "CONVERT_SUBMIT_FAILED", "Could not start the conversion.")); - return; - } - // Arm this job's completion signal (v1.1 M39-S4, C1): only a job the user just launched may - // chime when it finishes, so a later refresh of — or a shared link to — the finished job page - // stays silent. Armed here, on the submit that actually starts the job (after the POST - // succeeded), and consumed by `useCompletionSignal` on the job's first terminal transition. - armCompletionSignal(data.job_id); - // `file_id` rides along in the query so the job — and, after it, the conversion record — can - // offer "convert again with different choices". Neither the job envelope nor the conversion - // record carries the source file id (Part 6 §3.2, §4.4), so the only honest way back to this - // page is to hand it forward; without it those pages link to a fresh upload instead of - // fabricating an id that would 404. - router.push(`/convert/${data.job_id}?file_id=${encodeURIComponent(fileId)}`); - } - - return ( -
- - {inspection.status === "loading" ? ( -

- Inspecting this file… -

- ) : inspection.status === "error" ? ( -
- - - Upload a different file - -
- ) : ( - <> - - - {/* The Structure tab (M60-S1, Part 7 §6): the file's geometry from the M59 endpoint. - Additive — the panels above and below are untouched. A file that could not be - inspected renders no viewer (the page's error branch, above). */} - - - {submitError ? : null} - {targets.length > 0 ? ( - - ) : null} - - )} -
- ); + const { file_id } = await params; + redirect(`/f/${file_id}`); } diff --git a/frontend/app/globals.contrast.test.ts b/frontend/app/globals.contrast.test.ts index 71d3dca..c2a328e 100644 --- a/frontend/app/globals.contrast.test.ts +++ b/frontend/app/globals.contrast.test.ts @@ -131,6 +131,14 @@ function checkTheme(theme: string, block: () => string) { expect(contrast(t("accent-fg"), t("accent"))).toBeGreaterThanOrEqual(AA); }); + // The forward-action accent is also rendered as *text* — links and the active-tab label — on + // the page surface (UI redesign S1, D243). Teal-as-text on the dark surface needs a lighter + // tone than the button-fill teal (which only reaches ~3.3:1 on slate-900), so the two are + // separate tokens: `--accent-text` vs `--accent`. Guard the pair in both themes. + it("accent text clears AA on the page surface (links, active tab)", () => { + expect(contrast(t("accent-text"), t("surface"))).toBeGreaterThanOrEqual(AA); + }); + // The viewer chrome pairs (v1.6 M63-S2, D241): the Structure/Compare tab chrome must meet the // WCAG AA bar of the v0.6 pass — the canvas is not the accessible record, but the chrome is. // The ◆ supplied-violet annotation and the exported-frame marker reuse the already-guarded §4 diff --git a/frontend/app/globals.css b/frontend/app/globals.css index c75c5c1..3c69801 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -48,10 +48,15 @@ --inverse-fg: #ffffff; /* text on the inverse surface */ --inverse-hover: #334155; /* inverse hover (slate-700) */ - /* The forward-action accent (used by the S3 Button primitive; defined here so it flips too). */ - --accent: #2563eb; /* blue-600 */ + /* The forward-action accent (used by the S3 Button primitive; defined here so it flips too). + Precision-instrument deep teal (UI redesign S1, D243): the fill + hover pair paint + buttons/active surfaces; `--accent-text` is the same accent used as *text* (links, the + active-tab label). The two are separate tokens because teal-as-text on the dark surface needs + a lighter tone than the fill can give (the dark block below). */ + --accent: #0e7c86; /* teal fill; white-on-fill ~4.9:1 (was blue-600) */ --accent-fg: #ffffff; - --accent-hover: #1d4ed8; /* blue-700 */ + --accent-hover: #0b646c; /* darker on hover (light-theme direction) */ + --accent-text: #0e7c86; /* accent as text on the white surface ~4.9:1 */ /* Loss foregrounds, one per §4 meaning (on the light surface). */ --cb-preserve: #15803d; /* green — present / preserved / check passed */ @@ -102,12 +107,14 @@ --inverse-fg: #0f172a; /* dark text on it */ --inverse-hover: #cbd5e1; /* slate-300 */ - /* Same blue-600 as light: white text on it clears AA (5.07:1 — blue-500 would be only 3.68:1, - failing white-on-fill), and it still reads as a saturated blue against the dark slate surface. - The hover lifts *brighter* here (the opposite direction from light), the dark-surface way. */ - --accent: #2563eb; /* blue-600 */ + /* Same teal as light: white text on it clears AA (~4.9:1), and it still reads as a saturated + teal against the dark slate surface. The hover lifts *brighter* here (the opposite direction + from light), the dark-surface way. The accent-as-*text* tone lightens further — the fill teal + only reaches ~3.3:1 on slate-900, so text uses its own `--accent-text` (#3fc9d6, ~9:1). */ + --accent: #0e7c86; /* teal fill, kept dark enough for white-on-fill ~4.9:1 */ --accent-fg: #ffffff; - --accent-hover: #3b82f6; /* blue-500 (brighter on hover, for the dark surface) */ + --accent-hover: #14a0ad; /* brighter on hover (dark-theme direction) */ + --accent-text: #3fc9d6; /* lighter teal: accent-as-text >=4.5:1 on slate-900 */ /* Loss foregrounds, lightened so each clears AA on the dark surface and its dark tint. */ --cb-preserve: #4ade80; /* green-400 */ @@ -168,4 +175,24 @@ color: var(--text-strong); background-color: var(--surface); } + + /* + * Motion is restrained and never essential (UI redesign S6, D247; design spec §4 "Motion"). The + * app's transitions are deliberately modest — tab/chip color shifts, an indeterminate progress + * pulse, a scrubber that is a native range — and there is no decorative animation. Honour + * `prefers-reduced-motion`: when a user asks for reduced motion, collapse every transition and + * animation to an instant, one-frame change (and drop smooth scrolling) so nothing on the page + * moves for them. Kept global (not per-component) so a future transition can never forget the + * guard — the same one-defines-it-once posture as the `:focus-visible` baseline above. + */ + @media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } + } } diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 6009d67..f90b6ec 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -1,7 +1,8 @@ -import Link from "next/link"; import { apiClient } from "@/lib/api/client"; +import { RecentsStrip } from "@/components/history/RecentsStrip"; import { LossTag } from "@/components/loss/icons"; import { buttonClasses } from "@/components/ui/Button"; +import { LandingUpload } from "@/components/upload/LandingUpload"; // The live figures (format count, size cap) must describe the *running* instance, never the build // machine: Part 9 §2 fixes only the API origin at build time — limits and capabilities are learned @@ -50,14 +51,15 @@ export default async function LandingPage() { filled in by an explicit recovery choice. Nothing is changed silently.

{/* - The landing hero keeps only the one primary call to action; the secondary destinations - (Formats · History · Docs) now live in the app-shell header, on every page, so they need - not be repeated here (addendum S2). + The landing hero keeps only the one primary call to action — an anchor to the upload + section below (UI redesign S2: with `/convert` redirected to `/`, upload lives on the + landing, so the CTA opens the dropzone instead of a route). The secondary destinations + (Formats · History · Docs) live in the app-shell header, on every page. */} {formatCount !== null ? (

@@ -94,6 +96,24 @@ export default async function LandingPage() { + + {/* Upload — the front door's action (UI redesign S2): the dropzone the hero CTA opens. */} +

+
+

Convert a file

+

+ Upload a source file to inspect what it contains, then choose a target format. Every + conversion produces a report of exactly what was kept, dropped, or assumed. +

+
+ +
+ + {/* One click back to a file you were just working on (UI redesign S4, D246): the recent-files + strip merges this browser's recents with /v1/history; hidden entirely until there is one. */} +
+ +
); } diff --git a/frontend/components/ResolveAndRetry.test.tsx b/frontend/components/ResolveAndRetry.test.tsx index 34f3118..b9eed23 100644 --- a/frontend/components/ResolveAndRetry.test.tsx +++ b/frontend/components/ResolveAndRetry.test.tsx @@ -82,7 +82,7 @@ describe("ResolveAndRetry", () => { expect(armCompletionSignal).toHaveBeenCalledWith("job-new"); // The new job carries the file_id forward so its record can offer this action again. await waitFor(() => - expect(push).toHaveBeenCalledWith("/convert/job-new?file_id=file-123"), + expect(push).toHaveBeenCalledWith("/f/file-123/convert?job=job-new"), ); }); @@ -112,7 +112,7 @@ describe("ResolveAndRetry", () => { // No re-submit is possible without the file, so no button that would 404. expect(screen.queryByRole("button", { name: /resolve and retry/i })).not.toBeInTheDocument(); const link = screen.getByRole("link", { name: /upload the file again/i }); - expect(link).toHaveAttribute("href", "/convert"); + expect(link).toHaveAttribute("href", "/"); expect(submitConvert).not.toHaveBeenCalled(); }); diff --git a/frontend/components/ResolveAndRetry.tsx b/frontend/components/ResolveAndRetry.tsx index 731e524..8bed945 100644 --- a/frontend/components/ResolveAndRetry.tsx +++ b/frontend/components/ResolveAndRetry.tsx @@ -91,9 +91,10 @@ export function ResolveAndRetry({ // resolves, retries, and switches tabs should still hear the finish. Armed before routing, on // the submit that actually started the job, and consumed on the job's first terminal transition. armCompletionSignal(result.envelope.job_id); - // The new job carries the file_id forward, exactly as the file page does, so its own record can - // offer this action again if it too refuses. - router.push(`/convert/${result.envelope.job_id}?file_id=${encodeURIComponent(fileId as string)}`); + // The new job lands on the source file's workspace Convert tab (UI redesign S2), exactly as + // the file's own Convert submit does, so its record can offer this action again if it too + // refuses — the file_id rides the workspace URL, not a query on a legacy route. + router.push(`/f/${fileId as string}/convert?job=${encodeURIComponent(result.envelope.job_id)}`); } return ( @@ -120,7 +121,7 @@ export function ResolveAndRetry({ ) : (

The uploaded source is no longer in hand here, so it must be provided again.{" "} - + Upload the file again to resolve . diff --git a/frontend/components/TargetPicker.tsx b/frontend/components/TargetPicker.tsx index 1b7544c..068a0de 100644 --- a/frontend/components/TargetPicker.tsx +++ b/frontend/components/TargetPicker.tsx @@ -114,12 +114,15 @@ export function TargetPicker({ discovery, targets, onConvert, + onSelection, }: { discovery: DiscoveryReport; /** Write-capable formats (see `writableTargets`). */ targets: FormatCapabilities[]; /** Initiate the conversion — the page POSTs `/v1/convert` and routes to the job (M29). */ onConvert: (targetFormatId: string, mode: "permissive" | "strict") => void | Promise; + /** Report the currently selected (target, mode) — lets the hosting page offer preset-save (S4). */ + onSelection?: (selection: { target: string; mode: "permissive" | "strict" }) => void; }) { const [selectedId, setSelectedId] = useState(null); const [mode, setMode] = useState<"permissive" | "strict">("permissive"); @@ -141,6 +144,11 @@ export function TargetPicker({ [discovery, selectedTarget], ); + // Report the live selection upward so the hosting page can offer "save this as a preset" (S4). + useEffect(() => { + if (onSelection && selectedTarget) onSelection({ target: selectedTarget.format_id, mode }); + }, [onSelection, selectedTarget, mode]); + const confirmingMode = MODES.find((m) => m.value === confirming?.mode) ?? null; const confirmConvertRef = useRef(null); diff --git a/frontend/components/command/CommandPalette.test.tsx b/frontend/components/command/CommandPalette.test.tsx new file mode 100644 index 0000000..94f12c5 --- /dev/null +++ b/frontend/components/command/CommandPalette.test.tsx @@ -0,0 +1,101 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { CommandPaletteTrigger } from "./CommandPaletteTrigger"; + +/** + * The ⌘K command palette (S4, D246) — the crate of the no-dependency palette. The tests pin the + * accessibility contract a real-user journey can't affordably check: it opens, traps focus (Tab + * cannot leave), closes on Escape, and hands focus back to the trigger. The fuzzy ranking itself is + * `lib/command/fuzzy.test.ts`; here the untestable-in-e2e piece — focus behavior — is asserted. + */ +const pushSpy = vi.fn(); + +vi.mock("next/navigation", () => ({ useRouter: () => ({ push: pushSpy }) })); + +function renderTrigger() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { staleTime: Infinity, retry: false } }, + }); + // Provide `/v1/capabilities` up front so the palette's format section has data to fuzzy on + // (the query is disabled while closed; the prefetch is what a live stack would supply). + queryClient.setQueryData(["capabilities"], { + poscar: { + write: { + format_id: "poscar", + format_name: "POSCAR", + direction: "write", + fields: {}, + max_frames: 1, + required_fields: [], + allows_open_boundaries: false, + representable_constraint_kinds: [], + writable_custom_keys: {}, + writable_custom_key_pattern: {}, + native_coordinate_system: "cartesian", + lossy_notes: [], + numeric_precision: {}, + }, + }, + }); + const view = render( + + + , + ); + return view; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("CommandPaletteTrigger + palette (⌘K)", () => { + it("the trigger advertises the dialog and can open it", () => { + renderTrigger(); + const trigger = screen.getByRole("button", { name: /Search/i }); + expect(trigger).toHaveAttribute("aria-haspopup", "dialog"); + expect(trigger).toHaveAttribute("aria-expanded", "false"); + fireEvent.click(trigger); + expect(screen.getByRole("dialog", { name: "Command palette" })).toBeInTheDocument(); + expect(trigger).toHaveAttribute("aria-expanded", "true"); + }); + + it("Meta+K opens the dialog and Escape closes it", () => { + renderTrigger(); + fireEvent.keyDown(window, { key: "k", metaKey: true }); + expect(screen.getByRole("dialog", { name: "Command palette" })).toBeInTheDocument(); + fireEvent.keyDown(window, { key: "Escape" }); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("does not hijack Meta+K while typing in an input", () => { + renderTrigger(); + const input = document.createElement("input"); + document.body.appendChild(input); + input.focus(); + // The keydown bubbles up from the editable to the window listener, which must suppress it. + fireEvent.keyDown(input, { key: "k", metaKey: true, bubbles: true }); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + document.body.removeChild(input); + }); + + it("typing filters fuzzy results and Enter on a result navigates", () => { + renderTrigger(); + fireEvent.click(screen.getByRole("button", { name: /Search/i })); + const input = screen.getByLabelText("Search commands"); + fireEvent.change(input, { target: { value: "Pos" } }); + // "POSCAR" is the exact-substring format candidate; Enter chooses the active (top) result. + const poscar = screen.getAllByRole("option").find((o) => (o.textContent ?? "").includes("POSCAR")); + expect(poscar).toBeTruthy(); + fireEvent.keyDown(input, { key: "Enter" }); + expect(pushSpy).toHaveBeenCalledWith("/formats/poscar"); + }); + + it("Escape from inside the dialog closes it", () => { + renderTrigger(); + fireEvent.click(screen.getByRole("button", { name: /Search/i })); + fireEvent.keyDown(screen.getByLabelText("Search commands"), { key: "Escape" }); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/frontend/components/command/CommandPalette.tsx b/frontend/components/command/CommandPalette.tsx new file mode 100644 index 0000000..0c1efd8 --- /dev/null +++ b/frontend/components/command/CommandPalette.tsx @@ -0,0 +1,269 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useQuery } from "@tanstack/react-query"; +import { capabilitiesQuery } from "@/lib/api/queries"; +import { fuzzySearch, type CommandCandidate } from "@/lib/command/fuzzy"; +import { listRecents } from "@/lib/prefs/recents"; +import type { CapabilitiesMap } from "@/lib/capabilities/types"; + +/** + * The command palette (UI redesign S4, D246; design spec §6 — ⌘K, fuzzy jump, client-side). A + * focus-trapped, ARIA-correct modal dialog that fuzzy-jumps to formats, docs, recent files, and + * actions. It reads only client-side data it already has — `/v1/capabilities` (via react-query) + * and the recents list (localStorage) — so it needs no new backend route. + * + * Accessibility is not cosmetic here: the dialog is `role="dialog"` + `aria-modal` with a labelled + * name, focus is **trapped** (Tab/Shift-Tab cycle inside; it never leaves into the page behind), + * Escape closes, the trigger's `aria-expanded`/`aria-haspopup` describe it, and focus returns to + * the trigger on close (the palette's test + the e2e journey pin this). The palette opens with ⌘K + * (Mac) or Ctrl+K, and the visible trigger button carries the same shortcut label. + * + * The fuzzy matcher is `lib/command/fuzzy.ts` (in-repo, pinned by tests). Choosing a result + * navigates (router.push). Results are grouped: **Formats**, **Docs**, **Recent files**, **Actions**; + * an empty query shows every candidate ("everything" is the fallback, per `fuzzy.test.ts`). + */ + +/** A static action the palette can jump to (navigations only — no hidden behavior). */ +const ACTIONS: CommandCandidate<{ href: string }>[] = [ + { id: "act-convert", label: "Convert a file", search: "Convert upload", payload: { href: "/" } }, + { id: "act-formats", label: "Go to Formats", search: "Formats list formats", payload: { href: "/formats" } }, + { id: "act-history", label: "Go to History", search: "History conversions", payload: { href: "/history" } }, + { id: "act-docs", label: "Go to Docs", search: "Docs documentation", payload: { href: "/docs" } }, +]; + +/** The docs pages reachable from the header — listed as palette destinations. */ +const DOCS: CommandCandidate<{ href: string }>[] = [ + { id: "doc-errors", label: "Docs · Error reference", search: "Error reference docs errors", payload: { href: "/docs/errors" } }, + { id: "doc-guide", label: "Docs · Conversion guide", search: "guide docs", payload: { href: "/docs" } }, +]; + +interface Grouped { + group: string; + items: { candidate: CommandCandidate<{ href: string }>; highlight: readonly number[] }[]; +} + +export function CommandPalette({ + open, + onClose, +}: { + open: boolean; + onClose: () => void; +}) { + const router = useRouter(); + const [query, setQuery] = useState(""); + const inputRef = useRef(null); + const panelRef = useRef(null); + const listRef = useRef(null); + const [cursor, setCursor] = useState(0); + const lastActive = useRef(null); + + // The focus trap, on the **panel** (the ancestor of both the input and the row list — a trap on + // the list alone can never catch a Tab leaving the input, since those are siblings). Tab cycles + // input ↔ rows; it can never land on the page behind the dialog. Shift+Tab reverses. + function trapTab(e: React.KeyboardEvent) { + if (e.key !== "Tab") return; + const panel = panelRef.current; + const input = inputRef.current; + if (!panel) return; + const rows = Array.from(panel.querySelectorAll("[data-result-row]")); + const focusables = input ? [input, ...rows] : rows; + if (focusables.length === 0) { + e.preventDefault(); + return; + } + e.preventDefault(); + const idx = focusables.indexOf(document.activeElement as HTMLElement); + const nextIdx = e.shiftKey + ? idx <= 0 + ? focusables.length - 1 + : idx - 1 + : (idx + 1) % focusables.length; + focusables[nextIdx].focus(); + } + + // "Open report as JSON" action context — none globally; the per-report export already covers it. + + // Fetch `/v1/capabilities` only while open — a closed palette should not poll the network. + const capabilities = useQuery({ ...capabilitiesQuery(), enabled: open }); + const recents = useMemo(() => listRecents(), []); + const sameRecents = useMemo( + () => + recents.map>((r) => ({ + id: `recent-${r.key}`, + label: r.filename, + search: `${r.filename} ${r.format_id}`, + payload: { href: r.href }, + })), + [recents], + ); + + const formatCandidates = useMemo(() => { + const map = (capabilities.data ?? {}) as CapabilitiesMap; + const out: CommandCandidate<{ href: string }>[] = []; + for (const [formatId, dirs] of Object.entries(map)) { + const name = dirs?.write?.format_name ?? dirs?.read?.format_name ?? formatId; + out.push({ id: `fmt-${formatId}`, label: name, search: `${name} ${formatId}`, payload: { href: `/formats/${formatId}` } }); + } + return out; + }, [capabilities.data]); + + // Reset on open/close, and put focus in the input. + useEffect(() => { + if (open) { + setQuery(""); + setCursor(0); + lastActive.current = document.activeElement; + // The dialog's DOM is committed before this effect runs, so the ref is already valid — focus + // synchronously rather than via setTimeout(0), which would hand the palette's focus race + // back to the scheduler (a real source of flakiness under compile/navigation load: the + // dialog was visible but focus had not yet landed when a test or user hit it). + inputRef.current?.focus(); + } + return undefined; + }, [open]); + + const results = useMemo(() => { + const groups: Grouped[] = []; + const push = (group: string, candidates: CommandCandidate<{ href: string }>[]) => { + const rank = fuzzySearch(query, candidates); + if (rank.length > 0) groups.push({ group, items: rank.map((r) => ({ candidate: r.candidate, highlight: r.match.highlight })) }); + }; + push("Formats", formatCandidates); + push("Recent files", sameRecents); + push("Docs", DOCS); + push("Actions", ACTIONS); + return groups; + }, [query, formatCandidates, sameRecents]); + + const flat = results.flatMap((g) => g.items); + const activeItem = flat[cursor]; + + useEffect(() => { + setCursor((c) => Math.min(c, Math.max(flat.length - 1, 0))); + }, [flat.length]); + + function choose(next: { href: string }) { + // Close, then navigate. The dialog unmounts on the next render (focus returns to the trigger); + // the navigation is synchronous so a result never feels laggy. + onClose(); + router.push(next.href); + } + + // A running index across the flat rendering, so `data-active` maps 1:1 to the cursor without + // relying on object identity. + let renderIndex = -1; + + if (!open) return null; + + return ( +

+ {/* Backdrop */} + + ); + })} +
+ )) + )} + + + + ); +} + +/** Render a label with its fuzzy-matched characters highlighted (offset by `` in accent). */ +function highlightedLabel(label: string, highlight: readonly number[]): React.ReactNode { + const set = new Set(highlight); + return ( + <> + {label.split("").map((ch, i) => + set.has(i) ? ( + + {ch} + + ) : ( + {ch} + ), + )} + + ); +} \ No newline at end of file diff --git a/frontend/components/command/CommandPaletteTrigger.tsx b/frontend/components/command/CommandPaletteTrigger.tsx new file mode 100644 index 0000000..6e9e857 --- /dev/null +++ b/frontend/components/command/CommandPaletteTrigger.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { CommandPalette } from "./CommandPalette"; + +/** + * The ⌘K trigger (UI redesign S4, D246) — the client island the app-shell header renders: a + * visible "Search" button plus the global ⌘K / Ctrl+K shortcut to open the command palette. Its + * `aria-expanded`/`aria-haspopup` describe the dialog it toggles, and focus is handed to (and + * returned from) the palette via the dialog itself, so a keyboard user's place in the page + * survives an open-close. + */ +export function CommandPaletteTrigger() { + const [open, setOpen] = useState(false); + const [hydrated, setHydrated] = useState(false); + const openRef = useRef(false); + openRef.current = open; + + // Hydration/probe marker: true only after this component commits client-side, in the same commit + // that attaches the keydown listener below. The ⌘K e2e journey waits on `data-hydrated` before + // pressing the shortcut — the landing heading is SSR'd and visible long before the window + // listener exists, which otherwise hands the open-shortcut a hydration race under full-run load. + useEffect(() => setHydrated(true), []); + + // Global open shortcut — one listener, reads the live `open` from the ref so Escape closes is + // always current. Deliberately suppressed while typing in an input/textarea/editable so ⌘K inside + // a search field never hijacks the keystroke (the same guard `/` uses on the report tab). + useEffect(() => { + function isEditable(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false; + return target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable; + } + function onKeyDown(e: KeyboardEvent) { + const mod = e.metaKey || e.ctrlKey; + if (mod && e.key.toLowerCase() === "k") { + e.preventDefault(); + if (!isEditable(e.target)) setOpen((v) => !v); + } else if (e.key === "Escape" && openRef.current) { + e.preventDefault(); + setOpen(false); + } + } + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, []); + + return ( + <> + + setOpen(false)} /> + + ); +} \ No newline at end of file diff --git a/frontend/components/history/HistoryRow.tsx b/frontend/components/history/HistoryRow.tsx index 7ecb8b9..4b58114 100644 --- a/frontend/components/history/HistoryRow.tsx +++ b/frontend/components/history/HistoryRow.tsx @@ -73,7 +73,7 @@ export function HistoryRow({ Re-convert diff --git a/frontend/components/history/HistoryTableView.test.tsx b/frontend/components/history/HistoryTableView.test.tsx index 38c491f..179a807 100644 --- a/frontend/components/history/HistoryTableView.test.tsx +++ b/frontend/components/history/HistoryTableView.test.tsx @@ -69,11 +69,11 @@ describe("HistoryTableView (Part 7 §2.6, generated from /v1/history)", () => { // upload prompt. expect(within(r).getByRole("link", { name: /open record/i })).toHaveAttribute( "href", - "/conversions/conv-completed-pass?file_id=file-1", + "/f/file-1/report/conv-completed-pass", ); expect(within(r).getByRole("link", { name: /re-?convert/i })).toHaveAttribute( "href", - "/files/file-1", + "/f/file-1/convert", ); expect(within(r).getByRole("button", { name: /delete file/i })).toBeInTheDocument(); }); diff --git a/frontend/components/history/RecentsStrip.test.tsx b/frontend/components/history/RecentsStrip.test.tsx new file mode 100644 index 0000000..7e29a2e --- /dev/null +++ b/frontend/components/history/RecentsStrip.test.tsx @@ -0,0 +1,41 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; +import { pushRecent } from "@/lib/prefs/recents"; +import { RecentsStrip } from "./RecentsStrip"; + +/** + * The recent-files strip (S4, D246) — merges the browser's persisted recents with `/v1/history`. + * This test pins the persisted half (the `merges with history` path is `lib/prefs/recents.ts`, + * tested there); here the history query is stubbed to fail fast so only localStorage recents render, + * and the "no recents → nothing rendered" rule holds. + */ +beforeEach(() => { + window.localStorage.clear(); +}); + +function renderStrip() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { staleTime: Infinity, retry: false } }, + }); + return render( + + + , + ); +} + +describe("RecentsStrip", () => { + it("renders nothing when there are no recents", () => { + renderStrip(); + expect(screen.queryByLabelText("Recent files")).not.toBeInTheDocument(); + }); + + it("links a persisted recent into its workspace", async () => { + pushRecent({ key: "f123", href: "/f/f123", filename: "run.extxyz", format_id: "extxyz", last_seen_at: "2026-08-30T00:00:00Z" }); + renderStrip(); + const link = await screen.findByRole("link", { name: /run\.extxyz/ }); + expect(link).toHaveAttribute("href", "/f/f123"); + expect(screen.getByText("extxyz")).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/frontend/components/history/RecentsStrip.tsx b/frontend/components/history/RecentsStrip.tsx new file mode 100644 index 0000000..9dba40e --- /dev/null +++ b/frontend/components/history/RecentsStrip.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { useMemo } from "react"; +import Link from "next/link"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { historyInfiniteQuery } from "@/lib/api/queries"; +import type { HistoryItem } from "@/lib/history/status"; +import { listRecents, MAX_RECENTS, mergeRecents, type RecentFile } from "@/lib/prefs/recents"; + +/** + * The recent-files strip (UI redesign S4, D246; design spec §6.4) — one click back to a file you + * were just working on. It merges two client-side sources per `lib/prefs/recents.ts`: the + * **localStorage** recents (written when a workspace is visited) and **`/v1/history`** (the durable + * list, so a just-made conversion appears even before this browser touched its workspace). Each chip + * links into the file's workspace (`/f/[file_id]`) while its source upload is live, else to the + * durable record — the reports-outlive-bytes rule, in a strip. + * + * It reads only endpoints and keys the app already uses — no new backend route (D-R6). + */ +function historyToRecent(item: HistoryItem): RecentFile | null { + const source = item.source as { format_id?: unknown; filename?: unknown }; + const formatId = typeof source.format_id === "string" ? source.format_id : ""; + const filename = typeof source.filename === "string" ? source.filename : item.conversion_id; + const fileId = typeof item.file_id === "string" ? item.file_id : null; + // A live source gets a workspace link; a record whose bytes are gone resolves to the durable + // record (the legacy redirect-or-standalone renders the full record either way). + const href = fileId ? `/f/${fileId}` : `/conversions/${item.conversion_id}`; + const key = fileId ?? item.conversion_id; + return { key, href, filename, format_id: formatId, last_seen_at: item.created_at }; +} + +export function RecentsStrip() { + const { data } = useInfiniteQuery(historyInfiniteQuery(MAX_RECENTS)); + const seeded = useMemo( + () => (data?.pages[0]?.items ?? []).map(historyToRecent).filter((r): r is RecentFile => r !== null), + [data], + ); + // The persisted recents are read once — the strip is a snapshot of "recent", not a live counter. + const persisted = useMemo(() => listRecents(), []); + const recents = useMemo(() => mergeRecents(persisted, seeded), [persisted, seeded]); + + if (recents.length === 0) return null; + + return ( +
+
+

Recent files

+ from this browser + history +
+
    + {recents.map((r) => ( +
  • + + {r.filename} + {r.format_id ? ( + + {r.format_id} + + ) : null} + +
  • + ))} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/components/presets/PresetManager.test.tsx b/frontend/components/presets/PresetManager.test.tsx new file mode 100644 index 0000000..711ee36 --- /dev/null +++ b/frontend/components/presets/PresetManager.test.tsx @@ -0,0 +1,72 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { PresetManager } from "./PresetManager"; +import { savePreset } from "@/lib/prefs/presets"; + +/** + * Saved-conversion presets (S4, D246) — the persistence is `lib/prefs/presets.ts` (tested there); + * here the component contract: it saves the picker's current (target, mode) under a name, lists + * saved presets, re-converts from one, and deletes — all replaying the target + posture, and the + * caller's `onConvert` being the only network touch (the app's POST /v1/convert, which pauses for + * any file-specific recovery). + */ +afterEach(() => { + vi.restoreAllMocks(); + window.localStorage.clear(); +}); + +function renderManager(overrides: Partial[0]> = {}) { + const onConvert = vi.fn(); + const defaults = { + currentSelection: { target: "poscar", mode: "strict" } as const, + targetName: "POSCAR", + onConvert, + }; + render(); + return { onConvert }; +} + +describe("PresetManager", () => { + it("renders nothing with no presets and no selection yet", () => { + const { container } = render( + , + ); + expect(container.firstChild).toBeNull(); + }); + + it("saves the current selection under a typed name", () => { + renderManager(); + fireEvent.change(screen.getByLabelText("Preset name"), { target: { value: "Print-ready" } }); + fireEvent.click(screen.getByRole("button", { name: "Save preset" })); + expect(screen.getByRole("status")).toHaveTextContent(/Saved “Print-ready”/); + // The list now shows the preset, with its target + mode posture. + expect(screen.getByTestId("preset-list").textContent).toMatch(/Print-ready/); + expect(screen.getByTestId("preset-list").textContent).toMatch(/POSCAR/); + expect(screen.getByTestId("preset-list").textContent).toMatch(/strict/); + }); + + it("re-converts a saved preset through the caller's onConvert", () => { + savePreset({ + name: "poscar-strict", + target_format_id: "poscar", + target_format_name: "POSCAR", + mode: "strict", + }); + const { onConvert } = renderManager(); + fireEvent.click(screen.getByRole("button", { name: "Re-convert" })); + expect(onConvert).toHaveBeenCalledWith("poscar", "strict"); + }); + + it("deletes a saved preset", () => { + const { presets } = savePreset({ + name: "temp", + target_format_id: "cif", + target_format_name: "CIF", + mode: "permissive", + }); + const { onConvert } = renderManager(); + fireEvent.click(screen.getByRole("button", { name: `Delete preset ${presets[0].name}` })); + expect(onConvert).not.toHaveBeenCalled(); + expect(screen.queryByText("temp")).not.toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/frontend/components/presets/PresetManager.tsx b/frontend/components/presets/PresetManager.tsx new file mode 100644 index 0000000..8cdf5b1 --- /dev/null +++ b/frontend/components/presets/PresetManager.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { useState } from "react"; +import { + deletePreset, + listPresets, + savePreset, + type ConversionPreset, +} from "@/lib/prefs/presets"; + +/** + * Saved-conversion **presets** (UI redesign S4, D246; design spec §6.4) — a named target-format + + * loss-posture combo you can re-apply in one click. It lives on the Convert tab beside the target + * picker: the picker reports its current selection upward, this remembers it under a name, and a + * saved preset re-converts with {@link lib/prefs/presets} semantics — replaying the target and the + * posture, and letting a plenty-file's *recovery* decisions still pause and ask (P4: never + * defaulted). All persistence is localStorage (D-R6). + */ +export function PresetManager({ + currentSelection, + targetName, + onConvert, +}: { + /** The picker's live selection: (target_format_id, mode) — saved when the user names it. */ + currentSelection: { target: string; mode: "permissive" | "strict" } | null; + /** The display name of the currently selected target (for the save button / saved chips). */ + targetName: string | null; + /** Begin a conversion for a target + mode — the Convert tab's `handleConvert`. */ + onConvert: (target: string, mode: "permissive" | "strict") => void | Promise; +}) { + const [presets, setPresets] = useState(() => listPresets()); + const [name, setName] = useState(""); + const [saveMsg, setSaveMsg] = useState(null); + + function handleSave() { + const trimmed = name.trim(); + if (!trimmed || !currentSelection) return; + const { presets: next, saved } = savePreset({ + name: trimmed, + target_format_id: currentSelection.target, + target_format_name: targetName ?? currentSelection.target, + mode: currentSelection.mode, + }); + setPresets(next); + setSaveMsg(saved ? `Saved “${trimmed}”.` : "Could not save (storage unavailable)."); + setName(""); + window.setTimeout(() => setSaveMsg(null), 2000); + } + + function handleDelete(id: string) { + setPresets(deletePreset(id)); + } + + if (presets.length === 0 && !currentSelection) { + return null; + } + + return ( +
+
+

Saved presets

+

+ A preset remembers a target format and the loss posture (permissive / strict), stored in + this browser. If a file still needs a recovery decision, re-converting with a preset pauses + and asks you the same way a fresh convert does. +

+
+ + {/* Save the current selection under a name. */} +
+ setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleSave(); + }} + aria-label="Preset name" + disabled={!currentSelection} + placeholder={currentSelection ? `Save current as…` : "Select a target to save a preset"} + className="rounded-md border border-line px-3 py-1.5 text-sm disabled:opacity-60" + /> + + {saveMsg ? ( + + {saveMsg} + + ) : null} +
+ + {presets.length > 0 ? ( +
    + {presets.map((preset) => ( +
  • +
    + {preset.name} + + → {preset.target_format_name} + + {preset.mode} + + +
    + + +
  • + ))} +
+ ) : ( +

No presets saved yet.

+ )} +
+ ); +} \ No newline at end of file diff --git a/frontend/components/report/ConversionReportPanel.test.tsx b/frontend/components/report/ConversionReportPanel.test.tsx index b01e970..a4526ad 100644 --- a/frontend/components/report/ConversionReportPanel.test.tsx +++ b/frontend/components/report/ConversionReportPanel.test.tsx @@ -1,5 +1,6 @@ -import { render, screen, within } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { REPORT_GROUPING_STORAGE_KEY } from "@/lib/report/grouping"; import type { ConversionReport } from "@/lib/report/types"; import { ConversionReportPanel } from "./ConversionReportPanel"; import completedReport from "./__fixtures__/conversion.completed.json"; @@ -12,6 +13,12 @@ import completedReport from "./__fixtures__/conversion.completed.json"; */ const report = completedReport as unknown as ConversionReport; +afterEach(() => { + // The panel persists its grouping choice (S3) — clear it so a Category toggle in one test can + // never leak into another (the panel initializes its mode from localStorage). + window.localStorage.removeItem(REPORT_GROUPING_STORAGE_KEY); +}); + describe("ConversionReportPanel (Part 4 §2 / Part 7 §4.3)", () => { it("renders one row per entry in every section — counts match the report arrays", () => { render(); @@ -128,6 +135,73 @@ describe("ConversionReportPanel (Part 4 §2 / Part 7 §4.3)", () => { expect(within(groups[0]).getByText("Dynamics")).toBeInTheDocument(); }); + it("the no-loss invariant: default-complete, filter-narrowable, forced-loss visible (S3)", () => { + render(); + + // Default, unfiltered: the rendered row set equals the report model's full row set — nothing + // filtered away by default, including the forced losses (the poscar target *cannot* store + // forces/energy, and the single-structure target *must* drop frames — the fixture is a + // forced-loss conversion, D245's guard case). + expect(screen.getAllByTestId("preserved-row")).toHaveLength(report.preserved.length); + expect(screen.getAllByTestId("removed-row")).toHaveLength(report.removed.length); + expect(screen.getAllByTestId("assumption-row")).toHaveLength(report.assumptions.length); + const forcedLossReason = "Target format stores a single structure (max_frames = 1)."; + expect(screen.getByText(forcedLossReason)).toBeInTheDocument(); + + // Filter narrows the *visible* rows only… + fireEvent.click(screen.getByRole("button", { name: /^Kept/ })); + expect(screen.queryByTestId("removed-row")).not.toBeInTheDocument(); + expect(screen.queryByText(forcedLossReason)).not.toBeInTheDocument(); + expect(screen.getAllByTestId("preserved-row")).toHaveLength(report.preserved.length); + + // …and restoring All brings the full set back — the forced losses are visible again. + fireEvent.click(screen.getByRole("button", { name: /^All/ })); + expect(screen.getAllByTestId("removed-row")).toHaveLength(report.removed.length); + expect(screen.getAllByTestId("preserved-row")).toHaveLength(report.preserved.length); + expect(screen.getByText(forcedLossReason)).toBeInTheDocument(); + }); + + it("category grouping shows the same rows, re-organized (no row dropped by the toggle)", () => { + render(); + const outcomeCounts = { + preserved: screen.getAllByTestId("preserved-row").length, + removed: screen.getAllByTestId("removed-row").length, + assumption: screen.getAllByTestId("assumption-row").length, + }; + + fireEvent.click(screen.getByRole("button", { name: "Category" })); + expect(screen.getAllByTestId("preserved-row")).toHaveLength(outcomeCounts.preserved); + expect(screen.getAllByTestId("removed-row")).toHaveLength(outcomeCounts.removed); + expect(screen.getAllByTestId("assumption-row")).toHaveLength(outcomeCounts.assumption); + // Same rows, bucketed by canonical category — the Categories mix outcomes. + expect(screen.getByTestId("report-section-category-cell")).toBeInTheDocument(); + expect(screen.getByTestId("report-section-category-dynamics")).toBeInTheDocument(); + }); + + it("shows the quantitative loss in mono (a source value is always visually a value)", () => { + render(); + const lost = screen.getByTestId("report-section-lost"); + const frameDrop = within(lost).getByText("9 of 10 frames dropped; frame 9 retained per A1."); + // The S3 row law: the source value renders in the mono DataValue, never plain prose. + expect(frameDrop.className).toContain("font-mono"); + }); + + it("j/k move focus between report rows (S4 row keyboard nav)", () => { + render(); + const rows = screen.getAllByTestId("removed-row"); + expect(rows.length).toBeGreaterThan(1); + + // The rows are script-focusable but not in the Tab order. + expect(rows[0]).toHaveAttribute("tabindex", "-1"); + + // `j` from inside the panel drops focus onto the first row. + rows[1].focus(); + fireEvent.keyDown(rows[1], { key: "k" }); + expect(document.activeElement).toBe(rows[0]); + fireEvent.keyDown(rows[0], { key: "j" }); + expect(document.activeElement).toBe(rows[1]); + }); + it("omits empty loss sections but never the affirmative summary (empty-state)", () => { // A clean conversion: everything preserved, nothing removed, assumed, or warned. The loss // sections are absent (a heading with zero rows would be noise), but the always-present summary diff --git a/frontend/components/report/ConversionReportPanel.tsx b/frontend/components/report/ConversionReportPanel.tsx index 28fe8bc..a1ba353 100644 --- a/frontend/components/report/ConversionReportPanel.tsx +++ b/frontend/components/report/ConversionReportPanel.tsx @@ -1,50 +1,80 @@ -import type { ReactNode } from "react"; +import { useMemo, useRef, useState, type ReactNode } from "react"; import { labelForPath, labelForScenario } from "@/lib/mapping"; -import { groupByKey, shouldCollapse } from "@/lib/report/grouping"; +import { + buildReportRows, + canonicalCategory, + categoryLabel, + countByFilter, + filterRows, + groupByKey, + groupRowsByCategory, + loadGroupingMode, + OUTCOME_ORDER, + OUTCOME_LABELS, + saveGroupingMode, + shouldCollapse, + type GroupingMode, + type ReportFilter, + type ReportRow, +} from "@/lib/report/grouping"; +import { reportToJson, reportToMarkdown } from "@/lib/report/exportReport"; import type { Assumption, ConversionReport, + PreservedEntry, RemovedEntry, ReportWarning, SuppliedEntry, } from "@/lib/report/types"; import { Row } from "./Row"; +import { ReportToolbar } from "./ReportToolbar"; import { SummaryChips } from "./SummaryChips"; /** - * The Conversion Report panel — the five sections of Part 4 §2 rendered in the same order the - * schema names them: **Preserved, Removed, Supplied + Assumptions, Warnings** (MASTER_SPEC - * Part 7 §4.3). This is the design-critical surface of v0.6: the whole product promise ("tells you - * exactly what it kept, what it lost, and why") is this panel being complete and honest. + * The Conversion Report panel — the design-critical surface (MASTER_SPEC Part 7 §4.3, redesigned + * outcome-first by UI redesign S3, D245; design spec §5). * - * Invariants enforced here: - * - **Row completeness.** Each section is `report..map(...)` — one row per entry, no - * filtering, no truncation. A dropped row is a dropped loss, so the tests count rows against the - * fixture arrays. Grouping (below) re-parents rows into disclosures but never removes one. - * - **Reason verbatim.** A Removed row shows its `reason` string exactly as the engine wrote it, - * never a UI paraphrase (Part 7 §2.3). - * - **Supplied + Assumptions are adjacent, in the shared ◆ violet, at prominence equal to - * Removed** — fabricated data is "a third thing", neither preserved nor lost, and it is never - * demoted below the losses it sits beside (Part 7 §4.3). Each Assumption shows its decision - * sentence and lists the canonical fields it authorized. - * - **Plain language, code one step away.** Field paths and scenario codes resolve through - * `lib/mapping.ts`; the raw machine code is never the primary text (Part 7 §3.3). + * **Outcome-first by default (Assumed → Lost → Warned → Kept).** The old section order followed the + * schema (Preserved first); the S3 order leads with what a reader must see — what was fabricated, + * what was lost, what was warned — and ends with what was kept. A toggle switches to canonical + * **category** grouping (Atoms / Cell / Dynamics / … across outcomes), and the choice persists + * (localStorage, D-R6). * - * **Readability at scale (addendum S4).** A summary band elevates the count chips to an at-a-glance - * overview, and the two floodable sections — Warnings, and a lengthy Removed — collapse **same-typed - * entries into expanded-by-default disclosures** (`lib/report/grouping`): Warnings by `code`, Removed - * by canonical category. The disclosures start open, so nothing is ever a click away from being seen - * (the never-buried promise); grouping only kicks in when a key actually repeats, so the ordinary - * single-warning report stays a flat list. This complements the engine-side D108 frame-range collapse. + * **Filter chips** narrow the visible rows only (`All · Kept · Lost · Assumed · Warned`, live + * counts, `aria-pressed`); `/` focuses the filter. **Rows** show the source value in mono + * (never collapsed away) and the outcome tag; assumptions state what was supplied and that it was + * recorded as an assumption, verbatim from the report (P4). **Export** is Copy-as-JSON / + * Copy-as-Markdown (pure serializations of the report model) plus Copy-link to the permalink. * - * An empty section is omitted here — the always-present {@link SummaryChips} carry the affirmative - * zero accounting ("✓ 0 fields removed"), so omission is never a silent blank. + * Invariants that survive the redesign (and are asserted by the no-loss invariant test): + * - **Row completeness.** Every section is a `report..map(...)` (or a view-model row per + * entry) with no filtering, no truncation, no reordering of the report arrays; the S4 + * same-type disclosures re-parent rows but never remove one. + * - **Reason verbatim.** A Removed row shows its `reason` exactly as the engine wrote it. + * - **Supplied + Assumptions adjacent, in the shared ◆ violet** — fabricated data is \"a third + * thing\", never demoted below the losses it sits beside. + * - **Plain language, code one step away** — paths/scenarios resolve through `lib/mapping.ts`. + * - **Empty sections are omitted, but the always-present {@link SummaryChips} carry the + * affirmative zero accounting** (\"✓ 0 fields removed\"), so omission is never a silent blank. + * + * The redesign changes **which sections appear and in what order — never which rows or what they + * say** (design spec §5 invariant). A refusal still renders as a completed, honest report (status + * `refused`), not an error — the refusal content renders through the record page, not here. */ +/** The left accent bar per outcome section, bound to the `--cb-*` loss palette. */ +const OUTCOME_TINT: Record<(typeof OUTCOME_ORDER)[number], string> = { + assumed: "border-cb-assumption", + lost: "border-cb-removed", + warned: "border-cb-warning", + kept: "border-cb-preserve", +}; + function Section({ title, count, tint, + testId, wrapList = true, children, }: { @@ -52,6 +82,8 @@ function Section({ count: number; /** Left accent bar color class, bound to a `--cb-*` token. */ tint: string; + /** Stable hook for the invariant test + the e2e journey (section order, counts). */ + testId?: string; /** When true (default) the section wraps its rows in a divided `
    `; grouped sections manage * their own list/disclosure structure and pass false. */ wrapList?: boolean; @@ -60,7 +92,11 @@ function Section({ if (count === 0) return null; const headingId = `report-section-${title.toLowerCase().replace(/[^a-z]+/g, "-")}`; return ( -
    +

    {title} ({count})

    @@ -94,26 +130,16 @@ function CollapsibleGroup({ ); } -/** The top-level canonical category a path belongs to, e.g. `dynamics.forces` → `dynamics`. */ -function canonicalCategory(path: string): string { - const dot = path.indexOf("."); - return dot === -1 ? path : path.slice(0, dot); -} - -/** A canonical category token as a plain heading, e.g. `user_metadata` → "User metadata". */ -function categoryLabel(category: string): string { - const words = category.split("_"); - const [first, ...rest] = words; - const head = first.charAt(0).toUpperCase() + first.slice(1); - return [head, ...rest].join(" "); -} - -/** A Removed row: field name, then the engine's `reason` verbatim, then any quantitative detail. */ +/** A Removed row: field name, the engine's `reason` verbatim, then the quantitative loss in mono. */ function RemovedRow({ entry }: { entry: RemovedEntry }) { return ( - +

    {entry.reason}

    - {entry.detail ?

    {entry.detail}

    : null}
    ); } @@ -257,8 +283,12 @@ function AssumptionRow({ ); } -export function ConversionReportPanel({ report }: { report: ConversionReport }) { - // Group supplied fields under the assumption that authorized each (Part 4 §2 one-to-many). +/** + * The Assumed section body — every assumption plus the Supplied fields it authorized, with the + * orphan-proof join (a supplied entry whose `from_assumption` matches nothing must still render). + * This is the S3 outcome-first home of what the schema calls "Supplied & assumptions". + */ +function AssumptionsBody({ report }: { report: ConversionReport }) { const suppliedByAssumption = new Map(); for (const entry of report.supplied) { const list = suppliedByAssumption.get(entry.from_assumption) ?? []; @@ -266,17 +296,137 @@ export function ConversionReportPanel({ report }: { report: ConversionReport }) suppliedByAssumption.set(entry.from_assumption, list); } - // Row completeness is a join-proof invariant: a supplied entry whose `from_assumption` matches - // no assumption in this report (an engine bug, a hand-edited fixture) must still render — a - // silently dropped row is a silently dropped loss record. const assumptionIds = new Set(report.assumptions.map((a) => a.id)); const orphanedSupplied = report.supplied.filter((e) => !assumptionIds.has(e.from_assumption)); + return ( + <> + {report.assumptions.map((assumption) => ( + + ))} + {orphanedSupplied.length > 0 ? ( + +
      + {orphanedSupplied.map((entry) => ( +
    • + + + {labelForPath(entry.path).label} + {entry.detail ? — {entry.detail} : null} +
    • + ))} +
    +
    + ) : null} + + ); +} + +/** The Kept section body — one preserved row per entry. */ +function PreservedBody({ preserved }: { preserved: PreservedEntry[] }) { + return ( + <> + {preserved.map((entry) => ( + + ))} + + ); +} + +/** One row in a category section — dispatched by kind to the same renderers the outcome view uses. */ +function CategoryRow({ row, report }: { row: ReportRow; report: ConversionReport }) { + switch (row.kind) { + case "preserved": { + const entry = row.entry as PreservedEntry; + return ( + + ); + } + case "removed": + return ; + case "assumed": { + const entry = row.entry as Assumption; + return ( + s.from_assumption === entry.id)} + /> + ); + } + case "warned": + return ; + } +} + +export function ConversionReportPanel({ + report, + permalink, +}: { + report: ConversionReport; + /** The durable permalink for Copy-link; absent on surfaces without one (the live job view). */ + permalink?: string; +}) { + const [mode, setMode] = useState(() => loadGroupingMode()); + const [filter, setFilter] = useState("all"); + const contentRef = useRef(null); + + // The normalized view model — one row per model entry (the no-loss invariant's home). + const rows = useMemo(() => buildReportRows(report), [report]); + const counts = useMemo(() => countByFilter(rows), [rows]); + + // Report-row keyboard nav (S4, design spec §6): `j` / `k` move focus between rows when focus is + // already inside the panel (the same vocabulary as the / filter shortcut). Rows are reachable but + // not in the Tab order (Row.tsx renders tabIndex -1 + data-report-row). + function onRowNav(e: React.KeyboardEvent) { + if (e.key !== "j" && e.key !== "k") return; + if (!contentRef.current) return; + const rowsEl = Array.from(contentRef.current.querySelectorAll("[data-report-row]")); + if (rowsEl.length === 0) return; + e.preventDefault(); + const current = document.activeElement as HTMLElement | null; + const idx = current && rowsEl.includes(current) ? rowsEl.indexOf(current) : -1; + const next = e.key === "j" ? (idx + 1) % rowsEl.length : (idx - 1 + rowsEl.length) % rowsEl.length; + if (e.key === "j" && idx === -1) { + // No active row yet: j drops onto the first row. + rowsEl[0].focus(); + } else { + rowsEl[next].focus(); + } + } + + function changeMode(next: GroupingMode) { + setMode(next); + saveGroupingMode(next); + } + const source = report.source; const target = report.target; return ( -
    +

    Conversion report

    @@ -298,69 +448,129 @@ export function ConversionReportPanel({ report }: { report: ConversionReport }) >
    + {/* The S3 toolbar: grouping toggle, filter chips, export/share. */} + reportToJson(report)} + onCopyMarkdown={() => reportToMarkdown(report)} + permalink={permalink} + />
    -
    - {report.preserved.map((entry) => ( - - ))} -
    + {mode === "outcome" ? ( + + ) : ( + + )} +
    + ); +} -
    - -
    +/** + * The outcome-first view (S3 default): sections in OUTCOME_ORDER, each rendering its rows through + * the shared bodies (S4 same-type disclosures included). A non-`all` filter shows only the matching + * section — narrowing the visible rows, never touching the model. + */ +function OutcomeSections({ + report, + filter, + counts, +}: { + report: ConversionReport; + filter: ReportFilter; + counts: Record; +}) { + const sectionVisible = (outcome: (typeof OUTCOME_ORDER)[number]) => + filter === "all" || filter === outcome; -
    - {report.assumptions.map((assumption) => ( - - ))} - {orphanedSupplied.length > 0 ? ( - -
      - {orphanedSupplied.map((entry) => ( -
    • - + - {labelForPath(entry.path).label} - {entry.detail ? — {entry.detail} : null} -
    • - ))} -
    -
    - ) : null} -
    + return ( +
    + {sectionVisible("assumed") ? ( +
    + +
    + ) : null} + + {sectionVisible("lost") ? ( +
    + +
    + ) : null} -
    - -
    + {sectionVisible("warned") ? ( +
    + +
    + ) : null} + + {sectionVisible("kept") ? ( +
    + +
    + ) : null} +
    + ); +} + +/** + * The canonical-category view: every row bucketed by its canonical category (Atoms / Cell / + * Dynamics / …), outcomes mixed inside each category — the same rows, re-organized. The filter + * narrows rows first, so a category with nothing matching disappears; rows are never dropped. + */ +function CategorySections({ + report, + rows, + filter, +}: { + report: ConversionReport; + rows: ReportRow[]; + filter: ReportFilter; +}) { + const sections = groupRowsByCategory(filterRows(rows, filter)); + return ( +
    + {sections.map((section) => ( +
    +
      + {section.rows.map((row) => ( + + ))} +
    +
    + ))}
    ); } diff --git a/frontend/components/report/ReportToolbar.tsx b/frontend/components/report/ReportToolbar.tsx new file mode 100644 index 0000000..f5090be --- /dev/null +++ b/frontend/components/report/ReportToolbar.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { copyText } from "@/lib/report/exportReport"; +import { + FILTERS, + GROUPING_MODE_LABELS, + OUTCOME_LABELS, + type GroupingMode, + type ReportFilter, +} from "@/lib/report/grouping"; + +/** + * The Conversion Report toolbar (UI redesign S3, D245; design spec §5) — grouping toggle, filter + * chips with live counts, and the export/share controls. + * + * - **Grouping:** Outcome (default) vs Category; the choice persists via localStorage (D-R6). + * - **Filter chips:** `All · Kept · Lost · Assumed · Warned` with live counts; clicking narrows the + * visible rows only. The chips are real buttons with `aria-pressed`, so the active filter is + * announced, never color-only. Keyboard: `/` focuses the filter from anywhere on the page. + * - **Export:** Copy as JSON, Copy as Markdown (both pure serializations of the report model — see + * `lib/report/exportReport.ts`), and Copy link (the permalink, when the caller knows one). Each + * button confirms with a transient "Copied", never a modal. + */ + +/** The four chip labels — "All" plus the outcomes in the fixed order. */ +const FILTER_LABELS: Record = { + all: "All", + ...OUTCOME_LABELS, +}; + +function isEditableTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false; + return ( + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.tagName === "SELECT" || + target.isContentEditable + ); +} + +export function ReportToolbar({ + mode, + onModeChange, + filter, + onFilterChange, + counts, + onCopyJson, + onCopyMarkdown, + permalink, +}: { + mode: GroupingMode; + onModeChange: (mode: GroupingMode) => void; + filter: ReportFilter; + onFilterChange: (filter: ReportFilter) => void; + /** Live chip counts keyed by filter ("All" carries the full row count). */ + counts: Record; + onCopyJson: () => string; + onCopyMarkdown: () => string; + /** The durable permalink to this record; when absent the Copy-link control is hidden. */ + permalink?: string; +}) { + const [copied, setCopied] = useState<"json" | "markdown" | "link" | null>(null); + const allChipRef = useRef(null); + const copiedTimer = useRef | null>(null); + + // `/` focuses the filter from anywhere on the page — the design-spec §5 keyboard shortcut. + useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + if (event.key === "/" && !isEditableTarget(event.target)) { + event.preventDefault(); + allChipRef.current?.focus(); + } + } + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, []); + + async function handleCopy(kind: "json" | "markdown" | "link") { + const text = kind === "json" ? onCopyJson() : kind === "markdown" ? onCopyMarkdown() : (permalink ?? ""); + if (!text) return; + const ok = await copyText(text); + if (!ok) return; // No clipboard available — stay quiet; the serializers still worked. + if (copiedTimer.current) clearTimeout(copiedTimer.current); + setCopied(kind); + copiedTimer.current = setTimeout(() => setCopied(null), 1500); + } + + const modeButtonClass = (active: boolean) => + `rounded-md border px-2.5 py-1 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent ${ + active + ? "border-accent bg-raised text-accent-text" + : "border-line bg-surface text-body hover:bg-raised" + }`; + + const chipClass = (active: boolean) => + `inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent ${ + active ? "border-accent bg-raised text-accent-text" : "border-line bg-surface text-body hover:bg-raised" + }`; + + const exportButtonClass = + "rounded-md border border-line bg-surface px-2.5 py-1 text-sm text-body transition-colors hover:bg-raised focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"; + + return ( +
    { + // Also handle `/` when the toolbar itself is focused (a stray key never types a slash). + if (event.key === "/" && !isEditableTarget(event.target)) { + event.preventDefault(); + allChipRef.current?.focus(); + } + }} + > + {/* Grouping toggle — Outcome (default) vs Category, persisted. */} +
    + {(Object.keys(GROUPING_MODE_LABELS) as GroupingMode[]).map((m) => ( + + ))} +
    + + {/* Filter chips with live counts — narrowing the visible rows only. */} +
    + {FILTERS.map((f) => { + const active = filter === f; + return ( + + ); + })} +
    + + {/* Export / share. */} +
    + + + {permalink ? ( + + ) : null} +
    +
    + ); +} diff --git a/frontend/components/report/Row.tsx b/frontend/components/report/Row.tsx index 489df9f..cfa5ff9 100644 --- a/frontend/components/report/Row.tsx +++ b/frontend/components/report/Row.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from "react"; import { LossIcon, type LossKind } from "@/components/loss/icons"; +import { DataValue } from "@/components/ui/DataValue"; /** * One presence/outcome row — the shared atom every report section is a list of (MASTER_SPEC @@ -32,15 +33,27 @@ export function Row({ id?: string; }) { return ( + // tabIndex -1 (never in the tab order) but focusable on demand — the S4 report-row keyboard + // nav (j/k) moves focus between rows; it needs them reachable to focus, but they must not join + // the page's Tab sequence (the tooltip/section groupings already own the tab stops).
  • {label}
    - {detail ?
    {detail}
    : null} + {/* The source value, in mono — the S1 precision-instrument rule applied to every report + row (UI redesign S3, D245): a value is always visually a value, and it is never + collapsed away. */} + {detail ? ( +
    + {detail} +
    + ) : null} {children}
  • diff --git a/frontend/components/samples/SamplePicker.tsx b/frontend/components/samples/SamplePicker.tsx new file mode 100644 index 0000000..0eac6c0 --- /dev/null +++ b/frontend/components/samples/SamplePicker.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { useState } from "react"; +import type React from "react"; + +/** + * "Start with a sample" (UI redesign S4, D246; design spec §6.3) — small per-format fixtures + * vendored under `frontend/public/samples/` so a first-time visitor can try a conversion with one + * click instead of hunting for a file. Each is a real, tiny structure; upstream, **the sample goes + * through the normal upload path** (`onPick` hands the caller a `File`, and the caller runs the + * same upload it runs for a dropped/selected file). There is **no special-case backend** — this is + * a client-side `fetch` of a static asset, exactly the rule D-R6 / the slice plan set. + * + * `onPick` may be async (the caller can abort a transfer in flight); the buttons disable and show + * "Loading…" while a fetch + upload is in flight so a double-click cannot start two transfers. + */ +export interface Sample { + /** The vendored filename under `/samples/`. */ + file: string; + /** The display title. */ + label: string; + /** The short format tag shown beside the title. */ + formatTag: string; + /** The content type handed to the `File` (informational — the backend sniffs the real type). */ + mimeType: string; +} + +export const SAMPLES: Sample[] = [ + { file: "water.xyz", label: "A water molecule", formatTag: "XYZ", mimeType: "chemical/x-xyz" }, + { file: "diatomic.extxyz", label: "A celled diatomic", formatTag: "extXYZ", mimeType: "chemical/x-xyz" }, + { file: "nacl.poscar", label: "An NaCl crystal", formatTag: "POSCAR", mimeType: "application/octet-stream" }, +]; + +export function SamplePicker({ onPick }: { onPick: (file: File) => void | Promise }) { + const [busy, setBusy] = useState(null); + + async function pick(sample: Sample) { + if (busy) return; + setBusy(sample.file); + try { + const res = await fetch(`/samples/${sample.file}`); + if (!res.ok) return; + const blob = await res.blob(); + const file = new File([blob], sample.file, { type: sample.mimeType }); + await onPick(file); + } finally { + setBusy(null); + } + } + + return ( +
    +

    + Or start with a sample +

    +
      + {SAMPLES.map((sample) => ( +
    • + +
    • + ))} +
    +
    + ); +} \ No newline at end of file diff --git a/frontend/components/shell/AppHeader.test.tsx b/frontend/components/shell/AppHeader.test.tsx index 3a2f62f..a0e44cf 100644 --- a/frontend/components/shell/AppHeader.test.tsx +++ b/frontend/components/shell/AppHeader.test.tsx @@ -1,9 +1,19 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen, within } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { AppHeader } from "./AppHeader"; import { NotifyPreferenceProvider } from "@/lib/notify/NotifyPreferenceProvider"; import { ThemeProvider } from "@/lib/theme/ThemeProvider"; +// The header mounts the ⌘K palette (S4), which uses the Next router for its navigation — a real +// router context is a browser thing, so the standalone test provides a no-op push. +const pushMock = vi.fn(); +vi.mock("next/navigation", () => ({ useRouter: () => ({ push: pushMock }) })); + +beforeEach(() => { + vi.clearAllMocks(); +}); + /** * The app-shell header (pre-M36 frontend-redesign addendum, Slice S2): a home wordmark on the left, * the primary nav, and the theme + completion-signal mute toggles on the right (the notify toggle @@ -12,12 +22,19 @@ import { ThemeProvider } from "@/lib/theme/ThemeProvider"; * here (as it is in the real tree). */ function renderHeader() { + // QueryClientProvider: the ⌘K palette (S4) reads `/v1/capabilities` via react-query. It stays + // disabled while closed, so the provider here is inert — present for the tree, like the app root. + const queryClient = new QueryClient({ + defaultOptions: { queries: { staleTime: Infinity, retry: false } }, + }); return render( - - - - - , + + + + + + + , ); } @@ -36,7 +53,8 @@ describe("AppHeader", () => { renderHeader(); const nav = screen.getByRole("navigation", { name: "Primary" }); const expected: [string, string][] = [ - ["Convert", "/convert"], + // Upload lives on the landing (UI redesign S2); `/convert` redirects to `/`. + ["Convert", "/"], ["Formats", "/formats"], ["History", "/history"], ["Docs", "/docs"], @@ -46,6 +64,22 @@ describe("AppHeader", () => { } }); + it("uses the accent-text token for interactive nav emphasis, not a hard-coded colour", () => { + renderHeader(); + const convert = screen.getByRole("link", { name: "Convert" }); + // Hover/active emphasis is the themed accent-text token, so it flips correctly in dark mode. + expect(convert.className).toContain("accent-text"); + // No hard-coded slate/blue for the themed role. + expect(convert.className).not.toMatch(/text-blue-|text-slate-/); + }); + + it("mounts the command-palette trigger (⌘K) with the dialog affordance (S4)", () => { + renderHeader(); + const trigger = screen.getByRole("button", { name: /Search/i }); + expect(trigger).toHaveAttribute("aria-haspopup", "dialog"); + expect(trigger).toHaveAttribute("aria-expanded", "false"); + }); + it("mounts the theme toggle", () => { renderHeader(); // Default is light, so the toggle offers to switch to dark. diff --git a/frontend/components/shell/AppHeader.tsx b/frontend/components/shell/AppHeader.tsx index 31ac14f..d4ba597 100644 --- a/frontend/components/shell/AppHeader.tsx +++ b/frontend/components/shell/AppHeader.tsx @@ -1,4 +1,5 @@ import Link from "next/link"; +import { CommandPaletteTrigger } from "@/components/command/CommandPaletteTrigger"; import { NotifyToggle } from "@/lib/notify/NotifyPreferenceProvider"; import { ThemeToggle } from "@/lib/theme/ThemeProvider"; @@ -21,7 +22,9 @@ import { ThemeToggle } from "@/lib/theme/ThemeProvider"; */ const PRIMARY_NAV: { href: string; label: string }[] = [ - { href: "/convert", label: "Convert" }, + // Upload lives on the landing since the UI redesign (S2): `/convert` redirects to `/`, so the + // header points at the real surface (the hero CTA opens the same dropzone). + { href: "/", label: "Convert" }, { href: "/formats", label: "Formats" }, { href: "/history", label: "History" }, { href: "/docs", label: "Docs" }, @@ -45,13 +48,15 @@ export function AppHeader() { {item.label} ))}
    + {/* The ⌘K command palette (UI redesign S4): visible button + global ⌘K/Ctrl-K shortcut. */} +
    diff --git a/frontend/components/shell/BackLink.test.tsx b/frontend/components/shell/BackLink.test.tsx index c8a4713..027d0df 100644 --- a/frontend/components/shell/BackLink.test.tsx +++ b/frontend/components/shell/BackLink.test.tsx @@ -26,4 +26,12 @@ describe("BackLink", () => { expect(svg).not.toBeNull(); expect(svg).toHaveAttribute("aria-hidden", "true"); }); + + it("renders the back action in the accent-text token, never a hard-coded colour", () => { + render(); + const link = screen.getByRole("link"); + // The interactive emphasis is the themed accent-text token (flips correctly in dark mode). + expect(link.className).toContain("text-accent-text"); + expect(link.className).not.toMatch(/text-blue-|text-slate-/); + }); }); diff --git a/frontend/components/shell/BackLink.tsx b/frontend/components/shell/BackLink.tsx index 53a75f1..4b48fde 100644 --- a/frontend/components/shell/BackLink.tsx +++ b/frontend/components/shell/BackLink.tsx @@ -14,7 +14,7 @@ export function BackLink({ href, label }: { href: string; label: string }) {

    Coming in a later version

    +

    + These seats are reserved so later work can attach without re-architecting the workspace. + Nothing here runs yet. +

    +
    + {/* File Repair — reserved as an action affordance at the conversion seam; inert (disabled). */} +
    + + coming later +
    + {/* Assistant — reserved as a side-panel slot; not a control, just a labelled seat. */} +
    + Assistant + coming later +
    +
    +
    + ); +} \ No newline at end of file diff --git a/frontend/components/shell/SourceRail.test.tsx b/frontend/components/shell/SourceRail.test.tsx new file mode 100644 index 0000000..c8326e6 --- /dev/null +++ b/frontend/components/shell/SourceRail.test.tsx @@ -0,0 +1,53 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SourceRail } from "./SourceRail"; +import type { DiscoveryReport } from "@/lib/report/types"; + +/** + * The pinned source rail (UI redesign S2, D244; D-R2): on every workspace tab it keeps the file's + * facts in view — filename, format + confidence, the counts — and its primary CTA is the guided + * spine's next step (the Convert tab). The facts come from the same inspection the Inspect tab + * renders; the rail never makes a second wire call. + */ +const report: DiscoveryReport = { + file: { filename: "relax.traj", size_bytes: 2048, sha256: "ab".repeat(32) }, + format: { format_id: "ase-trajectory", format_name: "ASE Trajectory", confidence: 0.92 }, + structure: { frame_count: 3, atom_count: 64, species: ["Si", "O"] }, + fields: [], + extras: [], + issues: [], + schema_version: "1.0.0", +}; + +const { useInspection } = vi.hoisted(() => ({ useInspection: vi.fn() })); +vi.mock("@/lib/api/useInspection", () => ({ useInspection })); + +describe("SourceRail", () => { + beforeEach(() => { + useInspection.mockReturnValue({ status: "ready", report }); + }); + + it("pins the source facts: filename, format + confidence, and the counts", () => { + render(); + expect(screen.getByText("relax.traj")).toBeInTheDocument(); + expect(screen.getByText(/ASE Trajectory/)).toBeInTheDocument(); + expect(screen.getByText("(92% confidence)")).toBeInTheDocument(); + // The counts render as mono values (the S1 DataValue role). + expect(screen.getByText("3").className).toContain("font-mono"); + expect(screen.getByText("64").className).toContain("font-mono"); + expect(screen.getByText("2.0 KB")).toBeInTheDocument(); + }); + + it("points the guided-spine CTA at the Convert tab", () => { + render(); + const cta = screen.getByRole("link", { name: "Convert →" }); + expect(cta).toHaveAttribute("href", "/f/file-1/convert"); + expect(cta.className).toContain("bg-accent"); // the primary button treatment + }); + + it("renders a loading state while inspection is pending", () => { + useInspection.mockReturnValue({ status: "loading" }); + render(); + expect(screen.getByRole("status")).toHaveTextContent("Loading source…"); + }); +}); diff --git a/frontend/components/shell/SourceRail.tsx b/frontend/components/shell/SourceRail.tsx new file mode 100644 index 0000000..a3c477f --- /dev/null +++ b/frontend/components/shell/SourceRail.tsx @@ -0,0 +1,92 @@ +"use client"; + +import Link from "next/link"; +import { useInspection } from "@/lib/api/useInspection"; +import { buttonClasses } from "@/components/ui/Button"; +import { DataValue } from "@/components/ui/DataValue"; + +/** + * The pinned source rail of the file-centric workspace (UI redesign S2, D244; design spec §3 D-R2). + * + * The file is the noun: on every tab of `/f/[file_id]` this rail keeps \"what did I start with\" in + * view — filename, detected format + confidence, and the key counts — while the main column holds + * the tab's surface. The facts come from the same inspection the Inspect tab renders (one fetch, + * react-query-deduped), never a second wire call. + * + * The primary CTA is the guided spine (D-R5): it always points at the **next sensible step**, so a + * first-timer who only clicks the big button walks Inspect → Convert → (the record) with no wizard. + * The rail collapses to a top summary bar on narrow screens (the layout stacks it above the tabs) — + * no horizontal page scroll. + */ +function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + return `${(n / (1024 * 1024)).toFixed(1)} MB`; +} + +function percent(confidence: number): string { + return `${Math.round(confidence * 100)}%`; +} + +export function SourceRail({ fileId }: { fileId: string }) { + const inspection = useInspection(fileId); + + return ( + + ); +} diff --git a/frontend/components/shell/WorkspaceTabs.test.tsx b/frontend/components/shell/WorkspaceTabs.test.tsx new file mode 100644 index 0000000..00fb31a --- /dev/null +++ b/frontend/components/shell/WorkspaceTabs.test.tsx @@ -0,0 +1,47 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { WorkspaceTabs } from "./WorkspaceTabs"; + +/** + * The workspace tab bar (UI redesign S2, D244; D-R1/D-R5): every surface is a route, so tabs are + * always clickable; the active tab wears the accent-text token and `aria-current="page"`. The + * Report tab needs a conversion id in its URL, so it links only while the workspace is on a report + * route and otherwise renders an inert disabled tab — never a link that would 404. + */ +const { usePathname } = vi.hoisted(() => ({ usePathname: vi.fn(() => "/f/file-1") })); +vi.mock("next/navigation", () => ({ usePathname })); + +describe("WorkspaceTabs", () => { + it("offers the four always-clickable tabs plus the Report slot, pointing at their routes", () => { + render(); + expect(screen.getByRole("link", { name: "Inspect" })).toHaveAttribute("href", "/f/file-1"); + expect(screen.getByRole("link", { name: "Structure" })).toHaveAttribute( + "href", + "/f/file-1/structure", + ); + expect(screen.getByRole("link", { name: "Convert" })).toHaveAttribute("href", "/f/file-1/convert"); + expect(screen.getByRole("link", { name: "Analysis" })).toHaveAttribute( + "href", + "/f/file-1/analysis", + ); + // No report URL exists off a report route — the slot is inert, not a 404 link. + expect(screen.getByText("Report")).toHaveAttribute("aria-disabled", "true"); + }); + + it("marks the active tab with aria-current and the accent-text token", () => { + usePathname.mockReturnValue("/f/file-1/convert"); + render(); + const active = screen.getByRole("link", { name: "Convert" }); + expect(active).toHaveAttribute("aria-current", "page"); + expect(active.className).toContain("text-accent-text"); + expect(screen.getByRole("link", { name: "Inspect" })).not.toHaveAttribute("aria-current"); + }); + + it("links the Report tab while on a report route", () => { + usePathname.mockReturnValue("/f/file-1/report/cnv-42"); + render(); + const report = screen.getByRole("link", { name: "Report" }); + expect(report).toHaveAttribute("aria-current", "page"); + expect(report).toHaveAttribute("href", "/f/file-1/report/cnv-42"); + }); +}); diff --git a/frontend/components/shell/WorkspaceTabs.tsx b/frontend/components/shell/WorkspaceTabs.tsx new file mode 100644 index 0000000..e2f319a --- /dev/null +++ b/frontend/components/shell/WorkspaceTabs.tsx @@ -0,0 +1,74 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +/** + * The workspace tab bar (UI redesign S2, D244; design spec §3, D-R1/D-R5). + * + * `Inspect · Structure · Convert · Report` are the real surfaces (plus Analysis, the S6 empty seam), + * and every tab is a route — so tabs are always clickable and a power user jumps straight to + * Convert. The active tab wears the accent-text token (the S1 `--accent-text` role) with an + * `aria-current="page"` link, never a hard-coded hue. + * + * The Report tab is the one tab that needs a conversion id in its URL (`/f/[id]/report/[cid]`). + * While the workspace is on that route it links to the report in view; from any other tab there is + * no report URL to jump to, so it renders as an inert, disabled tab rather than a link that would + * 404 — the convert flow surfaces the real report link in-content when one exists. + */ +const TABS = [ + { key: "inspect", label: "Inspect" }, + { key: "structure", label: "Structure" }, + { key: "convert", label: "Convert" }, + { key: "analysis", label: "Analysis" }, +] as const; + +export function WorkspaceTabs({ fileId }: { fileId: string }) { + const pathname = usePathname(); + const base = `/f/${fileId}`; + + const hrefFor = (key: (typeof TABS)[number]["key"]): string => + key === "inspect" ? base : `${base}/${key}`; + const activeFor = (key: (typeof TABS)[number]["key"]): boolean => + pathname === hrefFor(key); + + const onReportRoute = pathname.startsWith(`${base}/report/`); + + const linkClass = (active: boolean) => + `-mb-px border-b-2 px-3 py-2 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent ${ + active + ? "border-accent text-accent-text" + : "border-transparent text-muted hover:text-accent-text" + }`; + + return ( + + ); +} diff --git a/frontend/components/ui/Button.test.tsx b/frontend/components/ui/Button.test.tsx index 0ccb294..2433eb4 100644 --- a/frontend/components/ui/Button.test.tsx +++ b/frontend/components/ui/Button.test.tsx @@ -27,6 +27,12 @@ describe("Button", () => { expect(button.className).toContain("text-accent-fg"); }); + it("primary variant fills with the accent token, never a hard-coded colour", () => { + const cls = buttonClasses("primary"); + expect(cls).toContain("bg-accent"); + expect(cls).not.toMatch(/bg-blue-|bg-teal-|#/); + }); + it("defaults to the primary variant", () => { render(); expect(screen.getByRole("button", { name: "Convert" }).className).toContain("bg-accent"); diff --git a/frontend/components/ui/DataValue.test.tsx b/frontend/components/ui/DataValue.test.tsx new file mode 100644 index 0000000..85cabbe --- /dev/null +++ b/frontend/components/ui/DataValue.test.tsx @@ -0,0 +1,24 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { DataValue } from "./DataValue"; + +/** + * The mono wrapper for a rendered scientific value/count/identifier (UI redesign S1). A value is + * always visually a number: `font-mono` from the pinned token, `text-strong` so it reads at full + * weight on the surface. Extra `className` is layout-only and must never drop the base. + */ +describe("DataValue", () => { + it("renders its children in the mono family with the strong text token", () => { + render(10000 × 48 × 3); + const el = screen.getByText("10000 × 48 × 3"); + expect(el.className).toContain("font-mono"); + expect(el.className).toContain("text-strong"); + }); + + it("appends layout-only className without dropping the mono base", () => { + render(42); + const el = screen.getByText("42"); + expect(el.className).toContain("font-mono"); + expect(el.className).toContain("ml-2"); + }); +}); diff --git a/frontend/components/ui/DataValue.tsx b/frontend/components/ui/DataValue.tsx new file mode 100644 index 0000000..d80f527 --- /dev/null +++ b/frontend/components/ui/DataValue.tsx @@ -0,0 +1,18 @@ +import type { ReactNode } from "react"; + +/** + * The mono wrapper for a rendered scientific value, count, or identifier (UI redesign S1, D243). + * Values are shown monospace across the app — positions shapes, frame counts, hashes, file ids — so + * a number is always visually a number. Defining it once keeps that consistent; `className` is for + * layout only (margins, alignment), never to override the mono/colour base. + */ +export function DataValue({ + children, + className, +}: { + children: ReactNode; + className?: string; +}) { + const base = "font-mono text-strong"; + return {children}; +} diff --git a/frontend/components/upload/LandingUpload.tsx b/frontend/components/upload/LandingUpload.tsx new file mode 100644 index 0000000..7d23262 --- /dev/null +++ b/frontend/components/upload/LandingUpload.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useQuery } from "@tanstack/react-query"; +import { limitsQuery } from "@/lib/api/queries"; +import { SamplePicker } from "@/components/samples/SamplePicker"; +import { useUpload } from "@/lib/api/useUpload"; +import { UploadDropzone } from "./UploadDropzone"; + +/** + * The upload affordance on the landing (UI redesign S2, D244; design spec §3): with `/convert` + * redirected to `/`, upload lives here. A client island over the same {@link useUpload} + + * {@link UploadDropzone} pair the old `/convert` page used — moved, not rewritten — routing a + * successful upload into the file's workspace at `/f/[file_id]`. From S4 (D246) it also offers the + * "Start with a sample" affordance: a {@link SamplePicker} whose vendored fixture feeds the **same** + * upload path (`onFile`), so a one-click sample is indistinguishable from a dropped file — no + * special-case backend route. + */ +export function LandingUpload() { + const router = useRouter(); + const { data: limits } = useQuery(limitsQuery()); + const { status, progress, error, result, upload } = useUpload(); + + async function onFile(file: File) { + if (status === "uploading") return; + const outcome = await upload(file); + if (outcome.ok) router.push(`/f/${outcome.data.file_id}`); + } + + return ( +
    + + {/* One-click samples (S4) — same upload path, no special-case backend. */} + +
    + ); +} diff --git a/frontend/components/workspace/ConversionJob.tsx b/frontend/components/workspace/ConversionJob.tsx new file mode 100644 index 0000000..87f6bc7 --- /dev/null +++ b/frontend/components/workspace/ConversionJob.tsx @@ -0,0 +1,379 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; +import { ErrorEnvelope } from "@/components/ErrorEnvelope"; +import { JobPhase } from "@/components/JobPhase"; +import { BackLink } from "@/components/shell/BackLink"; +import { RecoveryStep } from "@/components/recovery/RecoveryStep"; +import { ConversionReportPanel } from "@/components/report/ConversionReportPanel"; +import { RefusalPanel } from "@/components/report/RefusalPanel"; +import { buttonClasses } from "@/components/ui/Button"; +import { cancelJob, isTerminalJobState, jobQuery, queryKeys } from "@/lib/api/queries"; +import { toErrorEnvelope } from "@/lib/api/useInspection"; +import { useCompletionSignal } from "@/lib/notify/useCompletionSignal"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import type { + AwaitingRecoveryBlock, + BatchConvertResult, + ConversionReport, + ErrorEnvelope as ErrorEnvelopeModel, + JobChildRef, +} from "@/lib/report/types"; + +/** + * The live conversion job surface (MASTER_SPEC Part 6 §3.2, Part 7 §2.4; slice M29-S1, moved into + * the workspace by UI redesign S2, D244). + * + * Everything on this page comes from the long-polled job envelope — there is no client-side model + * of what "should" be happening. That is the whole design: the envelope carries the truth, the UI + * renders it, and every state the state machine can reach has an honest card here rather than a + * spinner that never resolves: + * + * - `queued` / `running` — the phase indicator, with **no invented progress** (`JobPhase`). + * - `awaiting_recovery` — the interactive recovery step (`RecoveryStep`, M31): the decision cards, + * the visible deadline stated as a refusal, and a first-class decline. + * - `completed` — the Conversion Report, or the refusal panel when the engine **declined**. + * - `failed` / `expired` / `cancelled` — each a named, honest card. + * + * The workspace Convert tab (`/f/[file_id]/convert?job=…`) renders this in-workspace (no back link + * — the rail + tabs navigate); the legacy `/convert/[job_id]` route renders it standalone for a + * shared job link that carries no file context (the job envelope carries no `file_id` on the wire, + * Part 6 §3.2). + */ + +/** A job link inside the workspace — or the legacy path when no file context is known. */ +export function jobHref(jobId: string, fileId: string | null): string { + return fileId ? `/f/${fileId}/convert?job=${encodeURIComponent(jobId)}` : `/convert/${jobId}`; +} + +/** A conversion record link — the workspace Report tab, or the legacy record path. */ +export function recordHref(conversionId: string, fileId: string | null): string { + return fileId ? `/f/${fileId}/report/${conversionId}` : `/conversions/${conversionId}`; +} + +function Card({ + title, + tone = "neutral", + children, +}: { + title: string; + tone?: "neutral" | "fail"; + children?: React.ReactNode; +}) { + const border = tone === "fail" ? "border-cb-fail bg-cb-fail-bg" : "border-line bg-surface"; + return ( +
    +

    {title}

    + {children} +
    + ); +} + +function StartOver() { + return ( + + Convert another file + + ); +} + +export function ConversionJob({ + jobId, + fileId, + back, +}: { + jobId: string; + /** The file this job belongs to, when known; the job envelope carries no `file_id` (Part 6 §3.2). */ + fileId: string | null; + /** Legacy standalone mode renders a back affordance; the workspace relies on the rail + tabs. */ + back?: { href: string; label: string } | null; +}) { + const queryClient = useQueryClient(); + + const [cancelError, setCancelError] = useState(null); + const [cancelling, setCancelling] = useState(false); + + const job = useQuery(jobQuery(jobId)); + + // The completion signal (v1.1 M39-S4, C1): chime + browser Notification, fired once when this + // job makes the non-terminal → terminal transition, honoring the persisted mute toggle. It fires + // only for a job the user launched (armed by the Convert submit and consumed here), so a refresh + // of — or a shared link to — an already-finished job stays silent. + useCompletionSignal(job.data?.state, jobId); + + async function handleCancel() { + setCancelError(null); + setCancelling(true); + const result = await cancelJob(jobId); + setCancelling(false); + if (!result.ok) { + setCancelError( + toErrorEnvelope(result.error, "CANCEL_FAILED", "Could not cancel this job."), + ); + } + await queryClient.invalidateQueries({ queryKey: queryKeys.job(jobId) }); + } + + async function handleResumed() { + await queryClient.invalidateQueries({ queryKey: queryKeys.job(jobId) }); + } + + if (job.isError) { + return ( +
    + {back ? : null} + + +
    + ); + } + + const envelope = job.data; + if (!envelope) { + return ( +
    + {back ? : null} +

    + Loading this conversion… +

    +
    + ); + } + + const state = envelope.state; + const terminal = isTerminalJobState(state); + const batch = envelope.kind === "batch_convert"; + const children = (envelope.children ?? []) as JobChildRef[]; + const batchResult = (envelope.result ?? null) as BatchConvertResult | null; + const result = (envelope.result ?? null) as { + conversion_id?: string; + conversion_report?: ConversionReport; + } | null; + const report = result?.conversion_report; + + return ( +
    + {back ? : null} +
    +

    + {batch ? "Batch conversion" : "Conversion"} +

    +

    job {envelope.job_id}

    +
    + + {state === "queued" || state === "running" ? : null} + + {state === "awaiting_recovery" && envelope.awaiting_recovery ? ( + + ) : null} + + {batch && state === "awaiting_recovery" ? ( + +

    + This batch made no choice for any file — each decision belongs to the conversion it + concerns. The batch waits on the conversions below that still need a decision, and + completes once every one of them is settled. +

    +
      + {children.map((child, i) => ( +
    • + + File {i + 1} · {child.state} + + + {child.state === "awaiting_recovery" + ? "Answer on this conversion's record" + : "View this conversion"} + +
    • + ))} +
    +
    + ) : null} + + {batch && state === "completed" && batchResult ? ( +
    + +

    + This batch converted {batchResult.tallies.converted} of{" "} + {batchResult.tallies.total} file{batchResult.tallies.total === 1 ? "" : "s"}; + every file’s own record keeps its full report, and each of the links below + resolves to it. +

    +
    +
    +
    Total
    +
    {batchResult.tallies.total}
    +
    +
    +
    Converted
    +
    {batchResult.tallies.converted}
    +
    +
    +
    Refused
    +
    {batchResult.tallies.refused}
    +
    +
    +
    Failed
    +
    {batchResult.tallies.failed}
    +
    +
    +

    + Outputs carrying each label: energy ×{batchResult.tallies.label_presence.energy},{" "} + forces ×{batchResult.tallies.label_presence.forces}, stress × + {batchResult.tallies.label_presence.stress}. +

    +
    + +
      + {batchResult.entries.map((entry, i) => ( +
    • + + File {i + 1} · {entry.status} + + + View this file’s conversion record + +
    • + ))} +
    +
    +
    + ) : null} + + {state === "completed" && report ? ( +
    + {report.status === "refused" ? ( + + ) : ( + + )} + {result?.conversion_id ? ( + + View the full record{report.status === "refused" ? "" : " and download the file"} + + ) : null} +
    + ) : null} + + {state === "failed" ? ( +
    + + +
    + ) : null} + + {!batch && state === "expired" ? ( +
    + +

    + This conversion needed a decision before it could be written, and the window for + supplying one closed. Xtalate refused the conversion rather than + choosing on your behalf: no default was applied, no value was invented, and no output + file was written. +

    +

    + The refusal itself is recorded — the reference below identifies it — so the outcome is + auditable rather than merely absent. Converting again lets you supply the choices up + front. +

    +
    + + +
    + ) : null} + + {batch && state === "cancelled" ? ( +
    + +

    + You cancelled this batch, so no aggregate result exists for it — not + an empty one, none at all. Files it had not yet launched were never started; the + conversions already launched are ordinary jobs and keep their own records. +

    +
    + +
    + ) : null} + + {!batch && state === "cancelled" ? ( +
    + +

    + You cancelled this conversion, so no report exists for it — not an + empty one, none at all. Nothing was written and nothing was measured. +

    +
    + +
    + ) : null} + + {terminal && state !== "completed" && !["failed", "expired", "cancelled"].includes(state) ? ( + +

    + The service reported this job as {state}. +

    +
    + ) : null} + + {!batch && state === "completed" && !report ? ( + +

    This job completed but carried no conversion report.

    +
    + ) : null} + + {!terminal && state !== "awaiting_recovery" ? ( +
    + +

    + {batch + ? "Cancelling stops the batch from launching any remaining files; conversions already launched keep their own records." + : "Cancelling is best-effort: work already underway may finish first, and a conversion that has already produced its result keeps it."} +

    + {cancelError ? : null} +
    + ) : null} +
    + ); +} diff --git a/frontend/components/workspace/ConversionRecord.tsx b/frontend/components/workspace/ConversionRecord.tsx new file mode 100644 index 0000000..313e295 --- /dev/null +++ b/frontend/components/workspace/ConversionRecord.tsx @@ -0,0 +1,333 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; +import { CompareTab } from "@/components/CompareTab"; +import { DownloadPanel } from "@/components/DownloadPanel"; +import { ErrorEnvelope } from "@/components/ErrorEnvelope"; +import { Provenance } from "@/components/Provenance"; +import { ResolveAndRetry } from "@/components/ResolveAndRetry"; +import { StructureTab } from "@/components/StructureTab"; +import { useConversionGeometry } from "@/lib/geometry/useGeometry"; +import { BackLink } from "@/components/shell/BackLink"; +import { ConversionReportPanel } from "@/components/report/ConversionReportPanel"; +import { RefusalPanel } from "@/components/report/RefusalPanel"; +import { SummaryChips } from "@/components/report/SummaryChips"; +import { ValidationReportPanel } from "@/components/report/ValidationReportPanel"; +import { conversionQuery, queryKeys, submitRevalidate } from "@/lib/api/queries"; +import { toErrorEnvelope } from "@/lib/api/useInspection"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import type { + ConversionRecord as ConversionRecordModel, + ErrorEnvelope as ErrorEnvelopeModel, +} from "@/lib/report/types"; + +/** + * The conversion record surface (MASTER_SPEC Part 6 §4.4, Part 7 §2.5–§2.6; slice M29-S2, moved + * into the workspace by UI redesign S2, D244). + * + * The consolidated, linkable outcome — the page a collaborator is sent and the page a methods + * section cites. Everything on it comes from one `GET /v1/conversions/{id}`, which is served from + * persisted rows alone, so the URL keeps working after the output bytes have expired. + * + * **The layout law.** The order is load-bearing, not cosmetic: + * + * outcome header → summary chips → download panel → reports → provenance + * + * The download control sits *below* the honest quantitative summary of what the conversion kept and + * lost, so the loss summary is structurally in view before the file can be taken. **The header + * never celebrates a lossy conversion.** A *refused* conversion renders through the same refusal + * component the job surface uses, because a refusal is a considered outcome with a record, not an + * error. + * + * The workspace Report tab (`/f/[file_id]/report/[cid]`) renders this in-workspace (the rail + + * tabs navigate); the legacy `/conversions/[id]` route renders it standalone for a shared link + * whose source upload is no longer live (the record carries no `file_id`, Part 6 §4.4). + */ + +function outcomeHeadline(record: ConversionRecordModel): string { + const report = record.conversion_report; + if (report.status === "refused") return "Refused — no file was written"; + + const removed = report.removed.length; + const assumptions = report.assumptions.length; + const parts: string[] = []; + if (removed > 0) parts.push(`${removed} field${removed === 1 ? "" : "s"} removed`); + if (assumptions > 0) { + parts.push(`${assumptions} assumption${assumptions === 1 ? "" : "s"} recorded`); + } + // Nothing lost and nothing assumed is the only case that may be stated plainly as complete. + if (parts.length === 0) return "Converted — nothing was lost or assumed"; + return `Converted — ${parts.join(", ")}`; +} + +/** The validation line beside the headline: what was *verified*, or plainly that nothing was. */ +function validationHeadline(record: ConversionRecordModel): string { + const validation = record.validation_report; + if (record.conversion_report.status === "refused") { + return "No validation: nothing was written, so there was nothing to check."; + } + if (validation === null) return "Validation has not been recorded for this conversion."; + const failed = validation.checks.filter((c) => c.status === "fail").length; + const warned = validation.checks.filter((c) => c.status === "warn").length; + const skipped = validation.checks.filter((c) => c.status === "skipped").length; + const detail = [ + failed > 0 ? `${failed} failed` : null, + warned > 0 ? `${warned} warned` : null, + skipped > 0 ? `${skipped} skipped` : null, + ] + .filter(Boolean) + .join(", "); + const total = validation.checks.length; + return detail + ? `Validation ${validation.status}: ${total} checks — ${detail}.` + : `Validation ${validation.status}: all ${total} checks passed.`; +} + +export function ConversionRecord({ + conversionId, + fileId, + inWorkspace, +}: { + conversionId: string; + /** The source file when known; the record itself carries no `file_id` (Part 6 §4.4). */ + fileId: string | null; + /** In the workspace the rail + tabs navigate; the legacy standalone route renders a back link. */ + inWorkspace: boolean; +}) { + const queryClient = useQueryClient(); + + const [revalidateError, setRevalidateError] = useState(null); + const [revalidating, setRevalidating] = useState(false); + const [profile, setProfile] = useState("default"); + // The viewer tab switch (M62-S1): one visualization surface at a time — the M60 Structure tab + // (output) is the default, the M62 Compare tab (source + output side by side) is one toggle away. + const [vizTab, setVizTab] = useState<"structure" | "compare">("structure"); + + const query = useQuery(conversionQuery(conversionId)); + + // The conversion's **output** geometry (M60-S1): the Structure tab renders the result — the + // bytes the user downloads — fed straight from `GET /v1/conversions/{id}/geometry?side=output` + // at the default frame (D232). + const outputGeometry = useConversionGeometry(conversionId, "output"); + + async function handleRevalidate() { + setRevalidateError(null); + setRevalidating(true); + const result = await submitRevalidate(conversionId, profile); + setRevalidating(false); + if (!result.ok) { + setRevalidateError( + toErrorEnvelope(result.error, "REVALIDATE_FAILED", "Could not re-validate this conversion."), + ); + return; + } + await queryClient.invalidateQueries({ queryKey: queryKeys.conversion(conversionId) }); + } + + if (query.isError) { + return ( +
    + {!inWorkspace ? : null} + + + Start a new conversion + +
    + ); + } + + const record = query.data as ConversionRecordModel | undefined; + if (!record) { + return ( +
    +

    + Loading this conversion record… +

    +
    + ); + } + + const report = record.conversion_report; + const refused = report.status === "refused"; + // The consistent back affordance for the legacy standalone route: the file this record came from + // when we know it, otherwise the history list (a shared link carries no file_id). + const back = fileId + ? { href: `/f/${fileId}`, label: "Inspection" } + : { href: "/history", label: "History" }; + + return ( +
    + {!inWorkspace ? : null} + {/* 1. Outcome header — quantitative, never celebratory. */} +
    +

    + {outcomeHeadline(record)} +

    +

    + {record.source.filename ?? "source"}{" "} + ({record.source.format_id}) + + ({record.target.format_id}) +

    +

    {validationHeadline(record)}

    +
    + + {/* 2. Summary chips — the loss summary, above the download by law (see the docstring). */} + {refused ? null : ( +
    + +
    + )} + + {/* A refusal is a considered outcome with a record; it renders here, not as an error. */} + {refused ? ( +
    + + {/* Not a dead-end: re-enter the cards with the same source and target (M32-S2). */} + +
    + ) : null} + + {/* 3. Download — structurally below the summary a reader has just passed. */} + + + {/* 4. The two reports: side by side on a wide screen, stacked on a narrow one. */} +
    + {/* The permalink for Copy-link (UI redesign S3, D245): the workspace URL when the source + file is known, else the durable legacy record URL — both resolve forever. */} + + {record.validation_report ? ( + + ) : ( +
    +

    Validation report

    +

    + {refused + ? "None — the conversion was refused, so no output was written and nothing was measured." + : "None recorded for this conversion yet."} +

    +
    + )} +
    + + {/* The Structure / Compare viewer tabs (M60-S1 + M62-S1, Part 7 §6). A **refused** + conversion has no output bytes, so no viewer — the RefusalPanel above is the substance. */} + {refused ? null : ( +
    +
    + + +
    + {vizTab === "structure" ? ( +
    + +
    + ) : ( +
    + +
    + )} +
    + )} + + {/* Re-validate: appends, never replaces (Part 6 §2), and works after the bytes are gone. */} + {record.validation_report ? ( +
    +
    + + +
    +

    + Re-validation re-thresholds the measurements already recorded — it does not re-read the + file, and it adds a report rather than replacing this one. +

    + {revalidateError ? : null} +
    + ) : null} + + {/* 5. Provenance — the citable facts. */} + + + +
    + ); +} diff --git a/frontend/e2e/accessibility.spec.ts b/frontend/e2e/accessibility.spec.ts index a783243..025e181 100644 --- a/frontend/e2e/accessibility.spec.ts +++ b/frontend/e2e/accessibility.spec.ts @@ -63,7 +63,7 @@ test("the Structure tab's viewer chrome has no serious accessibility violations // frame scrubber (range + play/pause + readout), and the bonds toggle — the canvas itself is // not the accessible record (D241), so the scan judges the chrome around it. const fileId = await uploadFixture(request, FIXTURES.multiFrame); - await page.goto(`/files/${fileId}`); + await page.goto(`/f/${fileId}/structure`); await expect( page.getByRole("heading", { name: "Structure", exact: true }), ).toBeVisible({ timeout: 30_000 }); @@ -73,6 +73,28 @@ test("the Structure tab's viewer chrome has no serious accessibility violations expect(violations, JSON.stringify(violations, null, 2)).toEqual([]); }); +test("the workspace shell has no serious accessibility violations (UIR-S6)", async ({ + page, + request, +}) => { + // The file-centric workspace shell under the new IA: the source rail + tab bar (Inspect/…), a + // tab's real content, and the reserved "coming later" seams — the whole `/f/[id]` layout an axe + // sweep must judge as one surface (the S6 final a11y pass, serious+critical zero, matching the + // M63-S2 posture). A live upload feeds the rail its filename/counts. + const fileId = await uploadFixture(request, FIXTURES.workedExample); + await page.goto(`/f/${fileId}`); + await expect(page.locator('aside[aria-label="Source file"]')).toBeVisible({ timeout: 30_000 }); + // The rail is the shell's readiness signal: once it shows the filename (not "Loading source…") + // and the seams are up, the whole layout under test has hydrated. + await expect(page.locator('aside[aria-label="Source file"]')).not.toContainText("Loading source…", { + timeout: 30_000, + }); + await expect(page.getByTestId("future-seams")).toBeVisible(); + + const violations = await seriousViolations(page); + expect(violations, JSON.stringify(violations, null, 2)).toEqual([]); +}); + test("the Compare tab's viewer chrome has no serious accessibility violations (M63-S2)", async ({ page, request, @@ -105,7 +127,7 @@ test("the Compare tab's viewer chrome has no serious accessibility violations (M const done = await pollJob(request, jobId, ["completed"]); const conversionId = String((done.result as { conversion_id: string }).conversion_id); - await page.goto(`/conversions/${conversionId}`); + await page.goto(`/f/${fileId}/report/${conversionId}`); await expect(page.getByRole("tab", { name: "Compare" })).toBeVisible({ timeout: 30_000 }); await page.getByRole("tab", { name: "Compare" }).click(); const compare = page.locator('section[aria-label="Compare"]'); diff --git a/frontend/e2e/ack-gate.spec.ts b/frontend/e2e/ack-gate.spec.ts index 32fdd8f..41c5bb6 100644 --- a/frontend/e2e/ack-gate.spec.ts +++ b/frontend/e2e/ack-gate.spec.ts @@ -23,10 +23,10 @@ test("a failed-validation output cannot be downloaded without passing the acknow page, request, }) => { - const conversionId = await seedFailedValidationConversion(request); + const { conversionId, fileId } = await seedFailedValidationConversion(request); - // Land on the durable record, exactly as a user following their conversion would. - await page.goto(`/conversions/${conversionId}`); + // Land on the durable record in its workspace, exactly as a user following their conversion would. + await page.goto(`/f/${fileId}/report/${conversionId}`); await expect(page.getByRole("heading", { level: 1 })).toBeVisible({ timeout: 30_000 }); const download = page.getByTestId("download-panel"); diff --git a/frontend/e2e/awaiting-recovery.spec.ts b/frontend/e2e/awaiting-recovery.spec.ts index f026858..5d85f42 100644 --- a/frontend/e2e/awaiting-recovery.spec.ts +++ b/frontend/e2e/awaiting-recovery.spec.ts @@ -25,10 +25,11 @@ test("the awaiting_recovery pause is rendered honestly, never as a silent defaul page, request, }) => { - const jobId = await seedAwaitingRecoveryJob(request); + const { jobId, fileId } = await seedAwaitingRecoveryJob(request); seededJobId = jobId; - await page.goto(`/convert/${jobId}`); + // The workspace's Convert tab hosts the paused job (UI redesign S2). + await page.goto(`/f/${fileId}/convert?job=${jobId}`); // Named, not hidden — the framing sentence with the machine state one glance away. await expect( diff --git a/frontend/e2e/batch-ui.spec.ts b/frontend/e2e/batch-ui.spec.ts index e59b285..3964b3e 100644 --- a/frontend/e2e/batch-ui.spec.ts +++ b/frontend/e2e/batch-ui.spec.ts @@ -18,7 +18,10 @@ interface BatchEnvelope { job_id: string; state: string; children: { job_id: string; file_id: string; state: string }[]; - result?: { tallies?: Record; entries?: { child_job_id: string }[] }; + result?: { + tallies?: Record; + entries?: { child_job_id: string; file_id: string }[]; + }; [key: string]: unknown; } @@ -66,14 +69,18 @@ test("the batch record shows parent tallies and links into each child conversion await expect(tallies).toContainText("1"); await expect(tallies).toContainText("Refused"); - // Per-file links resolve to the ordinary child records, in manifest order. + // Per-file links resolve to the ordinary child records, in manifest order — each on its own + // file's workspace Convert tab (UI redesign S2). const links = page.getByRole("link", { name: /view this file\u2019s conversion record/i }); await expect(links).toHaveCount(2); - await expect(links.first()).toHaveAttribute("href", `/convert/${entries[0].child_job_id}`); + await expect(links.first()).toHaveAttribute( + "href", + `/f/${entries[0].file_id}/convert?job=${entries[0].child_job_id}`, + ); - // Follow the converted child's link: the ordinary convert record renders on its own page. + // Follow the converted child's link: the ordinary conversion record renders in its workspace. await links.first().click(); - await page.waitForURL(`**/convert/${entries[0].child_job_id}`); + await page.waitForURL(new RegExp(`/f/${entries[0].file_id}/convert\\?job=${entries[0].child_job_id}`)); await expect(page.getByRole("heading", { name: "Conversion", exact: true })).toBeVisible(); // The child's durable record — where the download lives — is one link away. await expect( @@ -109,11 +116,11 @@ test("a paused child's awaiting_recovery is visible on its own record, reached f const card = page.getByRole("region", { name: "Waiting on a decision" }); await expect(card).toContainText(/made no choice for any file/i); const answer = page.getByRole("link", { name: /answer on this conversion's record/i }); - await expect(answer).toHaveAttribute("href", `/convert/${child.job_id}`); + await expect(answer).toHaveAttribute("href", `/f/${child.file_id}/convert?job=${child.job_id}`); - // The child's own ordinary record shows the interactive recovery step — the decision lives - // there, never on the batch. + // The child's own record shows the interactive recovery step — the decision lives there, never + // on the batch. await answer.click(); - await page.waitForURL(`**/convert/${child.job_id}`); + await page.waitForURL(new RegExp(`/f/${child.file_id}/convert\\?job=${child.job_id}`)); await expect(page.getByTestId("recovery-step")).toBeVisible(); }); diff --git a/frontend/e2e/cancelled.spec.ts b/frontend/e2e/cancelled.spec.ts index 2656c74..e4502ae 100644 --- a/frontend/e2e/cancelled.spec.ts +++ b/frontend/e2e/cancelled.spec.ts @@ -29,10 +29,10 @@ test("cancelling a paused job shows that no report exists, not an empty one", as page, request, }) => { - const jobId = await seedAwaitingRecoveryJob(request); + const { jobId, fileId } = await seedAwaitingRecoveryJob(request); seededJobId = jobId; - await page.goto(`/convert/${jobId}`); + await page.goto(`/f/${fileId}/convert?job=${jobId}`); const cancelButton = page.getByRole("button", { name: /Cancel conversion/i }); await expect(cancelButton).toBeVisible({ timeout: 30_000 }); diff --git a/frontend/e2e/compare-tab.spec.ts b/frontend/e2e/compare-tab.spec.ts index adc6741..1f63421 100644 --- a/frontend/e2e/compare-tab.spec.ts +++ b/frontend/e2e/compare-tab.spec.ts @@ -44,7 +44,7 @@ test("the Compare tab renders source + output side by side from canonical geomet if (req.url().includes("/v1/download")) exportRequests.push(req.url()); }); - await page.goto(`/conversions/${conversionId}`); + await page.goto(`/f/${fileId}/report/${conversionId}`); // Switch from the default Structure tab to the Compare tab. await expect(page.getByRole("tab", { name: "Compare" })).toBeVisible({ timeout: 30_000 }); await page.getByRole("tab", { name: "Compare" }).click(); @@ -78,7 +78,7 @@ test("the two Compare viewers are camera-locked: a drag on one moves the other t const done = await pollJob(request, jobId, ["completed"]); const conversionId = String((done.result as { conversion_id: string }).conversion_id); - await page.goto(`/conversions/${conversionId}`); + await page.goto(`/f/${fileId}/report/${conversionId}`); await expect(page.getByRole("tab", { name: "Compare" })).toBeVisible({ timeout: 30_000 }); await page.getByRole("tab", { name: "Compare" }).click(); const compare = page.locator('section[aria-label="Compare"]'); @@ -125,7 +125,7 @@ test("a frame_selection conversion's Compare source track carries the report's e const done = await pollJob(request, jobId, ["completed"]); const conversionId = String((done.result as { conversion_id: string }).conversion_id); - await page.goto(`/conversions/${conversionId}`); + await page.goto(`/f/${fileId}/report/${conversionId}`); await expect(page.getByRole("tab", { name: "Compare" })).toBeVisible({ timeout: 30_000 }); await page.getByRole("tab", { name: "Compare" }).click(); const compare = page.locator('section[aria-label="Compare"]'); @@ -256,7 +256,7 @@ test("the Compare flagship: RMSD from the Validation Report, verbatim removed re ); expect(suppliedCell, "the fabricated lattice must be recorded in supplied").toBeDefined(); - await page.goto(`/conversions/${conversionId}`); + await page.goto(`/f/${fileId}/report/${conversionId}`); await expect(page.getByRole("tab", { name: "Compare" })).toBeVisible({ timeout: 30_000 }); await page.getByRole("tab", { name: "Compare" }).click(); const compare = page.locator('section[aria-label="Compare"]'); diff --git a/frontend/e2e/confirm-before-record.spec.ts b/frontend/e2e/confirm-before-record.spec.ts index 2feb73a..f907e86 100644 --- a/frontend/e2e/confirm-before-record.spec.ts +++ b/frontend/e2e/confirm-before-record.spec.ts @@ -28,12 +28,14 @@ test("Cancel leaves no history row; Confirm is what records the conversion", asy await page.goto("/"); await page.getByRole("link", { name: "Convert a file" }).click(); await page.getByLabel("Choose a file to convert").setInputFiles(fixturePath(FIXTURES.workedExample.file)); - await page.waitForURL("**/files/**"); + await page.waitForURL("**/f/**"); await expect(page.getByText(/Detected\s+Extended XYZ/i)).toBeVisible({ timeout: 30_000 }); const before = await historyCount(request); - // 2. Pick plain XYZ and open the confirm step, then CANCEL. The first click must not submit. + // 2. Advance to the Convert tab, pick plain XYZ and open the confirm step, then CANCEL. The first + // click must not submit. + await page.getByRole("link", { name: "Convert →" }).click(); await page.getByRole("button", { name: "Plain XYZ", exact: true }).click(); await page.getByRole("button", { name: /^Convert to Plain XYZ$/ }).click(); await expect(page.getByTestId("convert-confirm")).toBeVisible(); @@ -48,8 +50,8 @@ test("Cancel leaves no history row; Confirm is what records the conversion", asy await expect(page.getByTestId("convert-confirm")).toBeVisible(); await page.getByRole("button", { name: /^Convert$/ }).click(); - // The job completes and the record appears in history. - await page.waitForURL("**/convert/**"); + // The job completes on the Convert tab and the record appears in history. + await page.waitForURL(/\/f\/[^/]+\/convert/); await expect( page.getByRole("link", { name: /View the full record and download the file/i }), ).toBeVisible({ timeout: 30_000 }); diff --git a/frontend/e2e/exported-frame.spec.ts b/frontend/e2e/exported-frame.spec.ts index 5e28b80..962f165 100644 --- a/frontend/e2e/exported-frame.spec.ts +++ b/frontend/e2e/exported-frame.spec.ts @@ -77,7 +77,7 @@ test("a frame_selection conversion names its source frame from the report, one c // 4. The browser: the record's Structure tab carries the report-sourced annotation. A single // frame_selection output is one frame → no output-side scrubber, just the annotation. - await page.goto(`/conversions/${conversionId}`); + await page.goto(`/f/${fileId}/report/${conversionId}`); await expect( page.getByRole("heading", { name: "Structure", exact: true }), ).toBeVisible({ timeout: 30_000 }); @@ -112,7 +112,7 @@ test("the file-page scrubber's frame numbering is the Discovery Report's (report const frameCount = report?.structure?.frame_count; expect(frameCount, "the Discovery Report must report a frame count").toBe(6); - await page.goto(`/files/${fileId}`); + await page.goto(`/f/${fileId}/structure`); await expect( page.getByRole("heading", { name: "Structure", exact: true }), ).toBeVisible({ timeout: 30_000 }); @@ -125,5 +125,5 @@ test("the file-page scrubber's frame numbering is the Discovery Report's (report await slider.fill(String(last)); const mount = page.locator("[data-mounted=true]"); await expect(mount).toHaveAttribute("data-current-frame", String(last), { timeout: 30_000 }); - await expect(page.getByRole("status")).toContainText(`${last} / ${frameCount}`); + await expect(page.getByRole("status").filter({ hasText: "/" })).toContainText(`${last} / ${frameCount}`); }); \ No newline at end of file diff --git a/frontend/e2e/file-too-large.spec.ts b/frontend/e2e/file-too-large.spec.ts index b22fdcb..e82ca22 100644 --- a/frontend/e2e/file-too-large.spec.ts +++ b/frontend/e2e/file-too-large.spec.ts @@ -30,13 +30,17 @@ test("an over-limit upload is refused client-side with the funnel and no network // A file one comfortable step past the ceiling. const oversized = Buffer.alloc(maxUploadBytes + 64 * 1024, 0x41); - await page.goto("/convert"); + await page.goto("/"); // A2's pre-check requires the live ceiling to be *known in the browser*: the drop zone renders // the limits line only once `max_upload_bytes` has been fetched, so waiting for it here makes // the journey deterministically exercise the client-side refusal it asserts — never the // server-413 backstop, which renders the same funnel only after bytes have left the browser - // (a race when the file is chosen before the limits query lands). - await expect(page.getByText(/on this instance/)).toBeVisible(); + // (a race when the file is chosen before the limits query lands). The wait must target the + // drop zone's OWN limits line (with the retention hours only the client limits query renders) — + // the private hero line on `/` is server-rendered in the initial HTML, so `/on this instance/` + // there resolves even while `max_upload_bytes` is still null in the browser and the gate is + // unarmed, which re-admits the exact race this wait exists to rule out. + await expect(page.getByText(/uploads deleted after \d+ hours/)).toBeVisible(); await page.getByLabel("Choose a file to convert").setInputFiles({ name: "too-big.xyz", mimeType: "chemical/x-xyz", diff --git a/frontend/e2e/geometry.spec.ts b/frontend/e2e/geometry.spec.ts index 11af7d7..fca6042 100644 --- a/frontend/e2e/geometry.spec.ts +++ b/frontend/e2e/geometry.spec.ts @@ -64,7 +64,7 @@ test("an expired conversion's geometry 410s while its durable record still rende page, }) => { // A real completed conversion on the running stack (worked example → plain XYZ). - const { conversionId } = await seedCompletedConversion(request); + const { conversionId, fileId } = await seedCompletedConversion(request); // The record page renders fully, interrogating none of the geometry surface — intercept the // geometry route to return the expired envelope (the elapsed-time precondition is not live) and @@ -84,7 +84,7 @@ test("an expired conversion's geometry 410s while its durable record still rende }), ); - await page.goto(`/conversions/${conversionId}`); + await page.goto(`/f/${fileId}/report/${conversionId}`); // The record still renders ("Converted — …"), deliberately below, not blocked by, the geometry 410. await expect(page.getByRole("heading", { name: /^Converted/ })).toBeVisible({ timeout: 30_000 }); }); \ No newline at end of file diff --git a/frontend/e2e/happy-path.spec.ts b/frontend/e2e/happy-path.spec.ts index e148dfa..c00189b 100644 --- a/frontend/e2e/happy-path.spec.ts +++ b/frontend/e2e/happy-path.spec.ts @@ -26,29 +26,34 @@ test("upload → inspect → convert → record → download, extXYZ to XYZ", as await expect(page.getByRole("heading", { name: "Convert a file" })).toBeVisible(); await page.getByLabel("Choose a file to convert").setInputFiles(fixturePath(FIXTURES.workedExample.file)); - // 3. Inspection: the app routes to the file resource and shows what the file actually contains. - await page.waitForURL("**/files/**"); + // 3. Inspection: the app routes into the file's workspace (the Inspect tab) and shows what the + // file actually contains (UI redesign S2: upload lands in the workspace, not a wizard step). + await page.waitForURL("**/f/**"); await expect(page.getByText(/Detected\s+Extended XYZ/i)).toBeVisible({ timeout: 30_000 }); - // 4. Choose plain XYZ as the target, then commit the conversion (permissive is the default). + // 4. Advance the guided spine with the rail's Convert CTA, then choose plain XYZ as the target and + // commit the conversion (permissive is the default). // Since v1.1 M39-S4 (B2) the first click opens an explicit confirm step (the pre-flight // preview, with a final Convert and a Cancel) — the POST /v1/convert fires only on that final // Convert, so an exploratory click never commits a record. + await page.getByRole("link", { name: "Convert →" }).click(); await page.getByRole("button", { name: "Plain XYZ", exact: true }).click(); await page.getByRole("button", { name: /^Convert to Plain XYZ$/ }).click(); await expect(page.getByTestId("convert-confirm")).toBeVisible(); await page.getByRole("button", { name: /^Convert$/ }).click(); - // 5. The live job page. The worker runs the job off the queue, so this polls to completion; when - // it lands, the durable record is one link away (the download deliberately lives only there). - await page.waitForURL("**/convert/**"); + // 5. The live job on the workspace's Convert tab. The worker runs the job off the queue, so this + // polls to completion; when it lands, the durable record is one link away (the download + // deliberately lives only there). + await page.waitForURL(/\/f\/[^/]+\/convert/); const recordLink = page.getByRole("link", { name: /View the full record and download the file/i }); await expect(recordLink).toBeVisible({ timeout: 30_000 }); await recordLink.click(); - // 6. The record page. The outcome header is quantitative and never celebratory: plain XYZ dropped - // the lattice, forces, charge and energy, so the header names removed fields — not "Done!". - await page.waitForURL("**/conversions/**"); + // 6. The record page (the workspace's Report tab). The outcome header is quantitative and never + // celebratory: plain XYZ dropped the lattice, forces, charge and energy, so the header names + // removed fields — not "Done!". + await page.waitForURL(/\/f\/[^/]+\/report\//); await expect(page.getByRole("heading", { name: /^Converted — .*removed/ })).toBeVisible(); // 7. The layout law, asserted against real rendered geometry: the loss summary sits *above* the diff --git a/frontend/e2e/history.spec.ts b/frontend/e2e/history.spec.ts index ba9d3a9..e5c5f23 100644 --- a/frontend/e2e/history.spec.ts +++ b/frontend/e2e/history.spec.ts @@ -31,7 +31,7 @@ test("deletes a source file from history and keeps its report readable", async ( const openRecord = row.getByRole("link", { name: /open record/i }); await expect(openRecord).toHaveAttribute( "href", - `/conversions/${conversionId}?file_id=${encodeURIComponent(fileId)}`, + `/f/${fileId}/report/${conversionId}`, ); await expect(row.getByRole("link", { name: /re-?convert/i })).toBeVisible(); diff --git a/frontend/e2e/keyboard.spec.ts b/frontend/e2e/keyboard.spec.ts index bc4939d..7480478 100644 --- a/frontend/e2e/keyboard.spec.ts +++ b/frontend/e2e/keyboard.spec.ts @@ -23,25 +23,29 @@ test("the primary path is reachable and operable by keyboard from the landing pa await page.keyboard.press("Tab"); await expect(page.locator(":focus")).toHaveText(/Convert a file/); await page.keyboard.press("Enter"); - await page.waitForURL("**/convert"); - // The upload step opens with the consistent back link, then the file chooser — both reachable by - // keyboard, in that order (the back affordance is the first in-content control on every page, S2). - await page.keyboard.press("Tab"); - await expect(page.locator(":focus")).toHaveText(/Home/); + // The CTA opens the landing's upload section (UI redesign S2: upload lives on `/` now, so the CTA + // is an in-page anchor, not a route). The next focusable control is the file chooser — reachable + // by keyboard, no mouse, no navigation. await page.keyboard.press("Tab"); await expect(page.locator(":focus")).toHaveText(/Choose a file/); }); test("the conversion can be chosen and started with the keyboard", async ({ page }) => { // Reach an inspected file (setting the file is the OS picker's job; everything after is keyboard). - await page.goto("/convert"); + await page.goto("/"); await page .getByLabel("Choose a file to convert") .setInputFiles(fixturePath(FIXTURES.workedExample.file)); - await page.waitForURL("**/files/**"); + await page.waitForURL("**/f/**"); await expect(page.getByText(/Detected\s+Extended XYZ/i)).toBeVisible({ timeout: 30_000 }); + // Advance the guided spine with the rail's CTA — focus it and press Enter (keyboard, no mouse). + const convertCta = page.getByRole("link", { name: "Convert →" }); + await convertCta.focus(); + await page.keyboard.press("Enter"); + await page.waitForURL("**/convert"); + // Select the target by focusing it and pressing Enter — the button reflects the choice via // aria-pressed, so a screen-reader user hears the selection, not just sees a color change. const target = page.getByRole("button", { name: "Plain XYZ", exact: true }); @@ -51,14 +55,14 @@ test("the conversion can be chosen and started with the keyboard", async ({ page // Start the conversion from the keyboard. Since v1.1 M39-S4 (B2) the first Enter opens the // inline confirm step; the final Convert (Enter again) commits the POST /v1/convert and the - // wizard advances to the live job page. + // Convert tab advances to the live job. const convert = page.getByRole("button", { name: /^Convert to Plain XYZ$/ }); await convert.focus(); await page.keyboard.press("Enter"); const finalConvert = page.getByRole("button", { name: /^Convert$/ }); await expect(finalConvert).toBeFocused(); // focus lands on the confirm card's primary action await page.keyboard.press("Enter"); - await page.waitForURL("**/convert/**"); + await page.waitForURL(/\/f\/[^/]+\/convert/); }); test("the frame scrubber and the bonds toggle are keyboard-operable (M63-S2)", async ({ @@ -66,7 +70,7 @@ test("the frame scrubber and the bonds toggle are keyboard-operable (M63-S2)", a request, }) => { const fileId = await uploadFixture(request, FIXTURES.multiFrame); - await page.goto(`/files/${fileId}`); + await page.goto(`/f/${fileId}/structure`); await expect( page.getByRole("heading", { name: "Structure", exact: true }), ).toBeVisible({ timeout: 30_000 }); @@ -85,7 +89,7 @@ test("the frame scrubber and the bonds toggle are keyboard-operable (M63-S2)", a await expect(mount).toHaveAttribute("data-current-frame", "3", { timeout: 30_000 }); await page.keyboard.press("ArrowLeft"); await expect(mount).toHaveAttribute("data-current-frame", "2", { timeout: 30_000 }); - await expect(page.getByRole("status")).toContainText("2 / 6"); + await expect(page.getByRole("status").filter({ hasText: "/" })).toContainText("2 / 6"); // The bonds toggle is a real button: keyboard-Enter flips it, and the state is announced via // aria-pressed (never a color-only signal). @@ -129,7 +133,7 @@ test("the Structure/Compare tab control switches tabs by keyboard (M63-S2)", asy const done = await pollJob(request, jobId, ["completed"]); const conversionId = String((done.result as { conversion_id: string }).conversion_id); - await page.goto(`/conversions/${conversionId}`); + await page.goto(`/f/${fileId}/report/${conversionId}`); const structureTab = page.getByRole("tab", { name: "Structure" }); const compareTab = page.getByRole("tab", { name: "Compare" }); await expect(compareTab).toBeVisible({ timeout: 30_000 }); diff --git a/frontend/e2e/legacy-redirect.spec.ts b/frontend/e2e/legacy-redirect.spec.ts new file mode 100644 index 0000000..9a977e2 --- /dev/null +++ b/frontend/e2e/legacy-redirect.spec.ts @@ -0,0 +1,72 @@ +import { expect, test } from "@playwright/test"; +import { API_URL, seedAwaitingRecoveryJob, seedCompletedConversion } from "./support/api"; + +/** + * The no-bookmark-404 rule (UI redesign S2, D244): every pre-workspace URL keeps resolving. + * The old IA was route-per-surface (`/files/[id]`, `/convert/[job_id]`, `/conversions/[id]`, + * `/convert`); the new IA is one file-centric workspace (`/f/[file_id]`) with tabs. None of the + * old paths may 404 — a shared link, a bookmark, or a support thread pointing at one must land on + * the same content in its new home: + * + * /files/[id] → /f/[id] (server redirect → Inspect tab) + * /convert → / (server redirect → landing upload) + * /convert/[job_id] → /f/[id]/convert?job=… (client redirect, ?file_id= handed forward) + * /conversions/[id] → /f/[id]/report/[id] (client redirect, ?file_id= or history lookup) + * + * The job and conversion records carry no `file_id` on the wire (Part 6 §3.2 / §4.4), so the + * client redirects resolve the file only when the caller handed it forward or history still maps + * the record to its live source upload — otherwise the route renders the same content standalone + * (the reports-outlive-bytes path), which the record journeys in the other specs exercise. + */ +test("every legacy file URL redirects into the file's workspace", async ({ page, request }) => { + const { conversionId, fileId } = await seedCompletedConversion(request); + + // /files/[id] → the workspace's Inspect tab. + await page.goto(`/files/${fileId}`); + await page.waitForURL(`/f/${fileId}`); + await expect(page.getByText(/Detected\s+Extended XYZ/i)).toBeVisible({ timeout: 30_000 }); + + // /convert → the landing, where upload now lives. + await page.goto("/convert"); + await page.waitForURL("/"); + await expect(page.getByRole("heading", { name: "Convert a file" })).toBeVisible(); + + // /conversions/[id] with the file handed forward → the workspace's Report tab. + await page.goto(`/conversions/${conversionId}?file_id=${encodeURIComponent(fileId)}`); + await page.waitForURL(`/f/${fileId}/report/${conversionId}`); + await expect(page.getByRole("heading", { name: /^Converted —/ })).toBeVisible({ timeout: 30_000 }); +}); + +test("a bare bookmarked /conversions/[id] resolves the file through history into the Report tab", async ({ + page, + request, +}) => { + // No ?file_id=: the redirect must resolve the file from /v1/history (the upload is still live). + // The page's lookup is one-shot, so first make the precondition durable over the API: the row + // is listed with its file_id — otherwise the test would race the worker's persistence. + const { conversionId, fileId } = await seedCompletedConversion(request); + await expect + .poll(async () => { + const resp = await request.get(`${API_URL}/v1/history`); + expect(resp.ok(), await resp.text()).toBeTruthy(); + const items = (await resp.json()).items as { conversion_id: string; file_id: string | null }[]; + return items.some((i) => i.conversion_id === conversionId && Boolean(i.file_id)); + }, { timeout: 15_000 }) + .toBe(true); + + await page.goto(`/conversions/${conversionId}`); + await page.waitForURL(`/f/${fileId}/report/${conversionId}`); + await expect(page.getByRole("heading", { name: /^Converted —/ })).toBeVisible({ timeout: 30_000 }); +}); + +test("a bookmarked /convert/[job_id] resolves into the workspace's Convert tab", async ({ + page, + request, +}) => { + // A paused job is the honest live case: the URL a caller shares mid-recovery. + const { jobId, fileId } = await seedAwaitingRecoveryJob(request); + + await page.goto(`/convert/${jobId}?file_id=${encodeURIComponent(fileId)}`); + await page.waitForURL(`/f/${fileId}/convert?job=${jobId}`); + await expect(page.getByTestId("recovery-step")).toBeVisible({ timeout: 30_000 }); +}); diff --git a/frontend/e2e/qol.spec.ts b/frontend/e2e/qol.spec.ts new file mode 100644 index 0000000..4a7c966 --- /dev/null +++ b/frontend/e2e/qol.spec.ts @@ -0,0 +1,97 @@ +import { expect, test } from "@playwright/test"; + +/** + * The S4 quality-of-life layer journey (MASTER_SPEC Part 7; UI redesign S4, D246; design spec §6) + * — all client-side, driven in the browser over the live stack: + * + * 1. **⌘K** opens a focus-keeping, closable command palette. Opening moves focus into the dialog; + * Tab moves *within* the dialog (it never leaks to the page behind); Escape closes it. + * 2. **A sample file completes a conversion end-to-end**: "Start with a sample" uploads a vendored + * fixture through the normal upload path and the result lands in the workspace, then converts. + * 3. **A saved preset re-converts**: save the current target + posture under a name, and the + * "Re-convert" button starts a fresh conversion without re-choosing. + * + * These are the slice's done-means journeys, each asserting a *localStorage-only* capability — no + * new backend route is touched beyond the standard convert/inspect/upload flow. + */ +test("⌘K opens the palette, keeps focus inside it, and Escape closes (S4)", async ({ page }) => { + await page.goto("/"); + await expect(page.getByRole("heading", { name: "Xtalate" })).toBeVisible(); + + // The raw HTML heading is server-rendered long before the client hydrates — the global ⌘K + // listener only exists after the trigger commits client-side. Wait on its `data-hydrated` marker + // (set in the same commit that attaches the listener) so the shortcut press can never outrun + // hydration under full-run load. + await expect( + page.getByTestId("command-palette-trigger") + ).toHaveAttribute("data-hydrated", "true", { timeout: 30_000 }); + + // Open with the global shortcut, exactly as a user would. + await page.keyboard.press("Meta+K"); + const dialog = page.getByRole("dialog", { name: "Command palette" }); + await expect(dialog).toBeVisible({ timeout: 30_000 }); + const input = page.getByLabel("Search commands"); + await expect(input).toBeFocused(); + + // Focus stays inside the dialog: a Tab from the input lands on a result (still inside), not the + // page's header — the trap the palette is required to maintain. + await page.keyboard.press("Tab"); + const focused = await page.evaluate(() => document.activeElement?.closest("[role=dialog]") !== null); + expect(focused, "after Tab, focus must still be inside the dialog").toBe(true); + + // Escape closes it and focus returns to the page (the trigger). + await page.keyboard.press("Escape"); + await expect(page.getByRole("dialog", { name: "Command palette" })).not.toBeVisible(); +}); + +test("a sample file completes a conversion end-to-end via the normal upload path (S4)", async ({ + page, +}) => { + await page.goto("/"); + await expect(page.getByRole("heading", { name: "Xtalate" })).toBeVisible(); + + // "Start with a sample": the vendored water.xyz uploads through the same `useUpload` path as a + // dropped file, so it must land in the file's workspace (the Inspect tab). + await page.getByTestId("sample-water").click(); + await page.waitForURL(/\/f\/[^/]+$/); + await expect(page.getByRole("heading", { name: "water.xyz" })).toBeVisible({ timeout: 30_000 }); + + // Advance the guided spine to the Convert tab and commit a lossless conversion (water → plain XYZ). + await page.getByRole("link", { name: "Convert →" }).click(); + await expect(page.getByRole("heading", { name: "Convert", exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Plain XYZ", exact: true }).click(); + await page.getByRole("button", { name: /^Convert to Plain XYZ/ }).click(); + await page.getByRole("button", { name: "Convert", exact: true }).click(); + + // The conversion runs off the queue and polls to completion; when it lands, the durable record + // (with its loss report) is one click away — the sample's conversion is complete end-to-end. + const recordLink = page.getByRole("link", { name: /View the full record and download the file/i }); + await expect(recordLink).toBeVisible({ timeout: 60_000 }); + await recordLink.click(); + await expect(page).toHaveURL(/\/report\/[^/]+/, { timeout: 30_000 }); + await expect(page.getByRole("heading", { name: /^Converted —/ })).toBeVisible(); +}); + +test("a saved preset re-converts in one click (S4)", async ({ page }) => { + await page.goto("/"); + await expect(page.getByRole("heading", { name: "Xtalate" })).toBeVisible(); + + // Upload a sample, then go to the Convert tab to pick a target. + await page.getByTestId("sample-diatomic").click(); + await page.waitForURL(/\/f\/[^/]+$/); + await page.getByRole("link", { name: "Convert →" }).click(); + await expect(page.getByRole("heading", { name: "Convert", exact: true })).toBeVisible(); + + // Choose VASP POSCAR (a lossy target for the extXYZ sample) and save it as a named preset. + await page.getByRole("button", { name: "VASP POSCAR", exact: true }).click(); + await page.getByLabel("Preset name").fill("poscar from sample"); + await page.getByRole("button", { name: "Save preset" }).click(); + await expect(page.getByTestId("preset-list")).toContainText("poscar from sample"); + + // One-click re-convert: the preset's Re-convert starts a fresh conversion (routes to the job). + await page.getByRole("button", { name: "Re-convert" }).click(); + await expect(page).toHaveURL(/convert\?job=/, { timeout: 30_000 }); + // The re-launched job is the polled, completed conversion — its record link proves the convert ran. + const recordLink = page.getByRole("link", { name: /View the full record and download the file/i }); + await expect(recordLink).toBeVisible({ timeout: 60_000 }); +}); \ No newline at end of file diff --git a/frontend/e2e/recovery-flagship.spec.ts b/frontend/e2e/recovery-flagship.spec.ts index 43bc6a5..140926a 100644 --- a/frontend/e2e/recovery-flagship.spec.ts +++ b/frontend/e2e/recovery-flagship.spec.ts @@ -37,24 +37,25 @@ test("upload → convert → pause → decide → preview → record, the trajec await expect(page.getByRole("heading", { name: "Convert a file" })).toBeVisible(); await page.getByLabel("Choose a file to convert").setInputFiles(fixturePath(FIXTURES.relaxTraj.file)); - // 2. Inspection routes to the file resource and reports what it actually is. - await page.waitForURL("**/files/**"); + // 2. Inspection routes into the file's workspace (the Inspect tab) and reports what it is. + await page.waitForURL("**/f/**"); await expect(page.getByText(/Detected\s+ASE Trajectory/i)).toBeVisible({ timeout: 30_000 }); - // 3. Choose POSCAR and convert. Permissive is the default; the difference from v0.6 is that this - // now asks for interactive recovery, so the decision-needing conversion pauses rather than - // refusing. Since v1.1 M39-S4 (B2) the conversion is committed on an explicit confirm step — - // and this journey proves the recovery path is unaffected: confirm → POST → awaiting_recovery - // → decision cards, exactly as before. + // 3. Advance the guided spine with the rail's Convert CTA, choose POSCAR and convert. Permissive + // is the default; the difference from v0.6 is that this now asks for interactive recovery, so + // the decision-needing conversion pauses rather than refusing. Since v1.1 M39-S4 (B2) the + // conversion is committed on an explicit confirm step — and this journey proves the recovery + // path is unaffected: confirm → POST → awaiting_recovery → decision cards, exactly as before. + await page.getByRole("link", { name: "Convert →" }).click(); await page.getByRole("button", { name: "VASP POSCAR", exact: true }).click(); await page.getByRole("button", { name: /^Convert to VASP POSCAR$/ }).click(); await expect(page.getByTestId("convert-confirm")).toBeVisible(); await page.getByRole("button", { name: /^Convert$/ }).click(); - // 4. The live job page reaches the pause and renders it as the recovery step — named, with one - // card per decision, never a silent default. - await page.waitForURL("**/convert/**"); - pausedJobId = page.url().split("/convert/")[1]?.split("?")[0]; + // 4. The live job on the workspace's Convert tab reaches the pause and renders it as the + // recovery step — named, with one card per decision, never a silent default. + await page.waitForURL(/\/f\/[^/]+\/convert/); + pausedJobId = new URL(page.url()).searchParams.get("job") ?? undefined; await expect( page.getByRole("heading", { name: /needs \d+ decisions? before it can proceed/i }), ).toBeVisible({ timeout: 30_000 }); @@ -83,9 +84,10 @@ test("upload → convert → pause → decide → preview → record, the trajec pausedJobId = undefined; // The job is terminal now; nothing to clean up. await recordLink.click(); - // 8. The record carries the very sentences previewed — byte-for-byte, because both are the engine's - // own text. This is the audit trail the whole product exists to produce. - await page.waitForURL("**/conversions/**"); + // 8. The record (the workspace's Report tab) carries the very sentences previewed — byte-for-byte, + // because both are the engine's own text. This is the audit trail the whole product exists to + // produce. + await page.waitForURL(/\/f\/[^/]+\/report\//); await expect(page.getByText(/selected for the single-structure target/i)).toBeVisible(); await expect(page.getByText(/conversion artifact, not simulation data/i)).toBeVisible(); diff --git a/frontend/e2e/report-redesign.spec.ts b/frontend/e2e/report-redesign.spec.ts new file mode 100644 index 0000000..7c8f03e --- /dev/null +++ b/frontend/e2e/report-redesign.spec.ts @@ -0,0 +1,146 @@ +import { expect, test, type APIRequestContext } from "@playwright/test"; +import { API_URL, FIXTURES, pollJob, uploadFixture } from "./support/api"; + +/** + * The S3 report redesign journey (MASTER_SPEC Part 7 §4.3; UI redesign S3, D245; design spec §5 + * + §9's no-loss invariant). + * + * The guard is **the no-loss invariant, proven in the browser over the live stack**: the redesigned + * report shows the same set of rows/outcomes as the engine's report model for a known conversion — + * nothing filtered away by default, and a forced-loss conversion (extXYZ → POSCAR: the target + * *cannot* store forces/energy) still renders its lost rows, reasons verbatim. + * + * The journey is data-driven: it reads the conversion record from the API and asserts the rendered + * row set against the model's own arrays, so it adapts to whatever the seed actually produced + * rather than hard-coding a fixture's counts. It also walks the three new affordances — outcome + * grouping order, a filter narrowing then restoring, and Copy-as-JSON / Copy-link producing the + * expected strings. + */ + +/** A forced-loss conversion: the worked example (extXYZ with lattice/forces/charge/energy) → POSCAR. */ +async function seedForcedLoss( + request: APIRequestContext, +): Promise<{ conversionId: string; fileId: string }> { + const fileId = await uploadFixture(request, FIXTURES.workedExample); + const resp = await request.post(`${API_URL}/v1/convert`, { + data: { + file_id: fileId, + target_format_id: "poscar", + options: { allow_recovery: true }, + }, + }); + expect([200, 201, 202]).toContain(resp.status()); + const jobId = String((await resp.json()).job_id); + const done = await pollJob(request, jobId, ["completed"]); + expect(done.state).toBe("completed"); + const result = done.result as { conversion_id?: string }; + const conversionId = String(result.conversion_id); + expect(conversionId).toBeTruthy(); + return { conversionId, fileId }; +} + +/** Read the conversion record's report model straight from the wire — the ground truth to assert against. */ +/** The report-model fields the journey asserts against (the rest of the model is exported verbatim). */ +interface ReportModel { + removed: { path: string; reason: string }[]; + preserved: unknown[]; + assumptions: unknown[]; + warnings: unknown[]; +} + +/** Read the conversion record's report model straight from the wire — the ground truth to assert against. */ +async function readReportModel( + request: APIRequestContext, + conversionId: string, +): Promise { + const resp = await request.get(`${API_URL}/v1/conversions/${conversionId}`); + expect(resp.ok(), await resp.text()).toBeTruthy(); + const record = (await resp.json()) as { conversion_report: ReportModel }; + return record.conversion_report; +} + +test("the S3 report: no-loss invariant, outcome order, filter narrow/restore, category toggle, export", async ({ + page, + request, + context, +}) => { + // Clipboard for the export assertions (localhost is a secure context; the app writes through + // navigator.clipboard). Both the JSON body and the permalink are asserted from the clipboard. + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + + const { conversionId, fileId } = await seedForcedLoss(request); + const model = await readReportModel(request, conversionId); + // The seed must actually exercise the invariant: a conversion with nothing lost would make the + // forced-loss assertions vacuous. The worked example → POSCAR cannot carry forces/energy. + expect(model.removed.length, "the forced-loss seed must lose fields").toBeGreaterThan(0); + + await page.goto(`/f/${fileId}/report/${conversionId}`); + const panel = page.getByTestId("report-columns").first(); + + // 1. Outcome grouping order — the section headings must appear Assumed → Lost → Warned → Kept + // (whatever is present in this model), top to bottom. + const sectionOrder = ["Assumed", "Lost", "Warned", "Kept"] as const; + const present = sectionOrder.filter((title) => { + const count = + title === "Kept" + ? model.preserved.length + : title === "Lost" + ? model.removed.length + : title === "Warned" + ? model.warnings.length + : model.assumptions.length; + return count > 0; + }); + const headingBoxes = await Promise.all( + present.map(async (title) => { + const heading = panel.getByRole("heading", { name: new RegExp(`^${title}\\s+\\(`) }); + await expect(heading).toBeVisible({ timeout: 30_000 }); + const box = await heading.boundingBox(); + expect(box, `heading ${title} must render`).not.toBeNull(); + return { title, y: box!.y }; + }), + ); + for (let i = 1; i < headingBoxes.length; i += 1) { + expect( + headingBoxes[i].y, + `outcome section order: ${headingBoxes[i - 1].title} must sit above ${headingBoxes[i].title}`, + ).toBeGreaterThan(headingBoxes[i - 1].y); + } + + // 2. The no-loss invariant: the default, unfiltered row set equals the model's full row set. + await expect(panel.getByTestId("removed-row")).toHaveCount(model.removed.length, { timeout: 30_000 }); + await expect(panel.getByTestId("preserved-row")).toHaveCount(model.preserved.length); + await expect(panel.getByTestId("assumption-row")).toHaveCount(model.assumptions.length); + await expect(panel.getByTestId("warning-row")).toHaveCount(model.warnings.length); + + // 3. The forced loss is visible, reason verbatim — the report's own words, never a paraphrase. + const forced = model.removed[0]; + await expect(panel.getByText(forced.reason)).toBeVisible(); + + // 4. A filter narrows the *visible* rows only — and restoring All brings the full set back. + await panel.getByRole("button", { name: /^Kept\s+\d/ }).click(); + await expect(panel.getByTestId("removed-row")).toHaveCount(0); + await expect(panel.getByText(forced.reason)).not.toBeVisible(); + await expect(panel.getByTestId("preserved-row")).toHaveCount(model.preserved.length); + + await panel.getByRole("button", { name: /^All\s+\d/ }).click(); + await expect(panel.getByTestId("removed-row")).toHaveCount(model.removed.length); + await expect(panel.getByText(forced.reason)).toBeVisible(); + + // 5. Category grouping: the same rows, re-organized — none dropped by the toggle. + await panel.getByRole("button", { name: "Category" }).click(); + await expect(panel.getByTestId("removed-row")).toHaveCount(model.removed.length); + await expect(panel.getByTestId("preserved-row")).toHaveCount(model.preserved.length); + await expect(panel.getByTestId("assumption-row")).toHaveCount(model.assumptions.length); + + // 6. Copy-as-JSON produces the report model verbatim (pretty-printed, but the same document). + await panel.getByRole("button", { name: "Copy as JSON" }).click(); + await expect(panel.getByRole("button", { name: "Copied" })).toBeVisible(); + const jsonText = await page.evaluate(() => navigator.clipboard.readText()); + expect(JSON.parse(jsonText)).toEqual(model); + + // 7. Copy-link yields the durable workspace permalink. + await panel.getByRole("button", { name: "Copy link" }).click(); + const linkText = await page.evaluate(() => navigator.clipboard.readText()); + expect(linkText).toBe(`/f/${fileId}/report/${conversionId}`); +}); diff --git a/frontend/e2e/resolve-and-retry.spec.ts b/frontend/e2e/resolve-and-retry.spec.ts index 5702fc3..7f6ec5b 100644 --- a/frontend/e2e/resolve-and-retry.spec.ts +++ b/frontend/e2e/resolve-and-retry.spec.ts @@ -31,10 +31,10 @@ test("a refused record resolves and retries through the cards to a completed con page, request, }) => { - // 1. Seed a refused conversion; open its record with the source file_id still in hand (threaded in - // the URL exactly as the file/job pages do). The refusal is rendered as a considered outcome. + // 1. Seed a refused conversion; open its durable record in the workspace (the Report tab — the + // refusal is rendered as a considered outcome, not an error). const { conversionId, fileId } = await seedRefusedConversion(request); - await page.goto(`/conversions/${conversionId}?file_id=${encodeURIComponent(fileId)}`); + await page.goto(`/f/${fileId}/report/${conversionId}`); await expect(page.getByRole("heading", { name: /refused — no file was written/i })).toBeVisible({ timeout: 30_000, }); @@ -45,8 +45,8 @@ test("a refused record resolves and retries through the cards to a completed con await page.getByRole("button", { name: /resolve and retry/i }).click(); // 3. It routes to a new live job that pauses on the recovery step: one card per unresolved scenario. - await page.waitForURL("**/convert/**"); - retryJobId = page.url().split("/convert/")[1]?.split("?")[0]; + await page.waitForURL("**/convert?**"); + retryJobId = new URL(page.url()).searchParams.get("job") ?? undefined; await expect( page.getByRole("heading", { name: /needs \d+ decisions? before it can proceed/i }), ).toBeVisible({ timeout: 30_000 }); @@ -66,7 +66,7 @@ test("a refused record resolves and retries through the cards to a completed con // 6. A completed record this time — a different conversion from the refused one (new history), with // the fabrications recorded and a file to take. - await page.waitForURL("**/conversions/**"); + await page.waitForURL("**/f/*/report/**"); expect(page.url()).not.toContain(conversionId); await expect(page.getByText(/conversion artifact, not simulation data/i)).toBeVisible(); await expect(page.getByRole("button", { name: /^Download / })).toBeVisible(); diff --git a/frontend/e2e/responsive.spec.ts b/frontend/e2e/responsive.spec.ts index 19ff213..187a7d9 100644 --- a/frontend/e2e/responsive.spec.ts +++ b/frontend/e2e/responsive.spec.ts @@ -51,14 +51,16 @@ test("no wizard page scrolls sideways on a phone, inventory table included", asy await page.goto("/"); await assertNoHorizontalOverflow(page); - await page.goto("/convert"); + await page.goto("/history"); await assertNoHorizontalOverflow(page); - // The inventory table is the densest thing on a phone; inspect a real file and check it fits. + // The inventory table is the densest thing on a phone; back on the landing, upload a real file + // and check the resulting workspace fits. + await page.goto("/"); await page .getByLabel("Choose a file to convert") .setInputFiles(fixturePath(FIXTURES.workedExample.file)); - await page.waitForURL("**/files/**"); + await page.waitForURL("**/f/**"); await expect(page.getByText(/Detected\s+Extended XYZ/i)).toBeVisible({ timeout: 30_000 }); await assertNoHorizontalOverflow(page); }); diff --git a/frontend/e2e/seams.spec.ts b/frontend/e2e/seams.spec.ts new file mode 100644 index 0000000..d1c0b1c --- /dev/null +++ b/frontend/e2e/seams.spec.ts @@ -0,0 +1,82 @@ +import { expect, test } from "@playwright/test"; +import { FIXTURES, uploadFixture } from "./support/api"; + +/** + * The UI redesign S6 empty-seams + motion journeys (D247, design spec §7 / §4). Three done-means + * assertions over the live workspace: + * + * 1. Every reserved **seam renders its "coming later" state and does nothing** — File Repair is a + * genuinely `disabled` button (cannot be activated, navigates nowhere), the Assistant is a plain + * labelled box (not a control), and the Analysis tab is an inert placeholder page with no engine + * call. This is the P6 anti-scope-creep guarantee, proven behaviourally, not by inspection. + * 2. The seams appear on every workspace tab (they belong to the shell, not one surface). + * 3. `/f/[id]` respects **`prefers-reduced-motion`**: the global guard collapses the restrained + * tab transition to an instant when the user asks for reduced motion, and leaves it at its normal + * duration when they do not. + */ +test("the reserved seams render 'coming later' and are inert across the workspace (S6)", async ({ + page, + request, +}) => { + const fileId = await uploadFixture(request, FIXTURES.workedExample); + await page.goto(`/f/${fileId}`); + await expect(page.locator('aside[aria-label="Source file"]')).not.toContainText("Loading source…", { + timeout: 30_000, + }); + + // The seams belong to the shell, so they appear on every tab. + for (const path of [`/f/${fileId}`, `/f/${fileId}/structure`, `/f/${fileId}/convert`]) { + await page.goto(path); + await expect(page.getByTestId("future-seams")).toBeVisible({ timeout: 30_000 }); + } + + // File Repair is a disabled action affordance — it cannot be activated, so it does nothing. + const repair = page.getByRole("button", { name: "File repair" }); + await expect(repair).toBeDisabled(); + // The Assistant is a plain labelled seat, not a control (no role to trap focus or take a click). + await expect(page.getByTestId("seam-assistant")).toBeVisible(); + await expect(page.getByTestId("seam-assistant").locator("a, button, [role=button], [role=link]")).toHaveCount(0); + // Both seats say they are coming later — nothing claims to work today. + await expect(page.getByTestId("future-seams")).toContainText("coming later"); +}); + +test("the Analysis seam tab renders its placeholder and starts no conversation (S6)", async ({ + page, + request, +}) => { + const fileId = await uploadFixture(request, FIXTURES.workedExample); + // Track any engine call the seam might spuriously make — there must be none above the shell's own. + const convertCalls: string[] = []; + page.on("request", (r) => { + if (/\/v1\/(convert|files\/[^/]+\/geometry)/.test(r.url())) convertCalls.push(r.url()); + }); + + await page.goto(`/f/${fileId}/analysis`); + await expect(page.getByRole("heading", { name: "Analysis" })).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText(/reserved for per-atom and trajectory analysis — coming in a later version/i)).toBeVisible(); + + // The seam does nothing: neither a convert nor a geometry read fires because of this page. + expect(convertCalls.filter((u) => u.includes("/v1/convert"))).toEqual([]); +}); + +test("prefers-reduced-motion is honoured on the workspace (S6)", async ({ page, request }) => { + const fileId = await uploadFixture(request, FIXTURES.workedExample); + + // With no preference, the restrained tab transition runs at its normal speed. + await page.emulateMedia({ reducedMotion: "no-preference" }); + await page.goto(`/f/${fileId}`); + const tab = page.getByRole("link", { name: "Inspect" }); + await expect(tab).toBeVisible({ timeout: 30_000 }); + // Read the duration as a number of seconds (CSS serializes it that way — `0.15s`, never a literal + // "150ms" in computed style). + const durationSeconds = (el: Element) => + parseFloat(getComputedStyle(el).transitionDuration); + const normal = await tab.evaluate(durationSeconds); + expect(normal).toBeGreaterThanOrEqual(0.1); // the restrained tab transition runs at its normal speed + + // With reduced motion, the global guard collapses it to an instant (≈ 0.01ms → 1e-5 s). + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.reload(); + const reduced = await tab.evaluate(durationSeconds); + expect(reduced).toBeLessThan(0.001); +}); \ No newline at end of file diff --git a/frontend/e2e/shell.spec.ts b/frontend/e2e/shell.spec.ts index 0da2172..7810eb8 100644 --- a/frontend/e2e/shell.spec.ts +++ b/frontend/e2e/shell.spec.ts @@ -71,7 +71,9 @@ test("the completion-signal mute toggle renders, defaults on, and persists", asy * every non-landing page, never raw browser-back, so the user is never trapped. */ test("a sub-page offers a back link to its parent route", async ({ page }) => { - await page.goto("/convert"); + // With `/convert` redirected to the landing (UI redesign S2), the history list is a stable + // sub-page carrying the consistent back affordance. + await page.goto("/history"); await page.getByRole("link", { name: "Back to Home" }).click(); await expect(page).toHaveURL(/\/$/); await expect(page.getByRole("heading", { name: "Xtalate" })).toBeVisible(); diff --git a/frontend/e2e/structure-fidelity.spec.ts b/frontend/e2e/structure-fidelity.spec.ts index 6519c99..8af1b4d 100644 --- a/frontend/e2e/structure-fidelity.spec.ts +++ b/frontend/e2e/structure-fidelity.spec.ts @@ -22,7 +22,7 @@ test("a cell-less XYZ renders atoms in open space: no box, and the caption says }) => { const fileId = await uploadFixture(request, FIXTURES.noCellXyz); - await page.goto(`/files/${fileId}`); + await page.goto(`/f/${fileId}/structure`); await expect( page.getByRole("heading", { name: "Structure", exact: true }), ).toBeVisible({ timeout: 30_000 }); @@ -49,7 +49,7 @@ test("a celled POSCAR renders the unit-cell wireframe and its element legend", a }) => { const fileId = await uploadFixture(request, FIXTURES.celledPoscar); - await page.goto(`/files/${fileId}`); + await page.goto(`/f/${fileId}/structure`); await expect( page.getByRole("heading", { name: "Structure", exact: true }), ).toBeVisible({ timeout: 30_000 }); @@ -70,7 +70,7 @@ test("a celled CIF renders the unit-cell wireframe and its element legend", asyn }) => { const fileId = await uploadFixture(request, FIXTURES.celledCif); - await page.goto(`/files/${fileId}`); + await page.goto(`/f/${fileId}/structure`); await expect( page.getByRole("heading", { name: "Structure", exact: true }), ).toBeVisible({ timeout: 30_000 }); @@ -87,7 +87,7 @@ test("bonds are a display heuristic: off by default, the persistent badge when e page, request, }) => { - const { conversionId } = await seedCompletedConversion(request); + const { conversionId, fileId } = await seedCompletedConversion(request); // The D234 guarantee at the data level first: neither report body mentions bonds at all — the // Canonical Model holds no bonds, so no report ever will (no-report-mentions-bonds, §5.6). @@ -100,7 +100,7 @@ test("bonds are a display heuristic: off by default, the persistent badge when e expect(JSON.stringify(record.conversion_report)).not.toMatch(/bond/i); expect(JSON.stringify(record.validation_report)).not.toMatch(/bond/i); - await page.goto(`/conversions/${conversionId}`); + await page.goto(`/f/${fileId}/report/${conversionId}`); await expect( page.getByRole("heading", { name: "Structure", exact: true }), ).toBeVisible({ timeout: 30_000 }); diff --git a/frontend/e2e/structure-tab.spec.ts b/frontend/e2e/structure-tab.spec.ts index bcc6061..a05dea6 100644 --- a/frontend/e2e/structure-tab.spec.ts +++ b/frontend/e2e/structure-tab.spec.ts @@ -24,7 +24,8 @@ test("the file page's Structure tab renders the file's geometry from the endpoin }) => { const fileId = await uploadFixture(request, FIXTURES.workedExample); - await page.goto(`/files/${fileId}`); + // The file's structure lives on the workspace's Structure tab (UI redesign S2). + await page.goto(`/f/${fileId}/structure`); await expect( page.getByRole("heading", { name: "Structure", exact: true }), ).toBeVisible({ timeout: 30_000 }); @@ -37,13 +38,10 @@ test("the file page's Structure tab renders the file's geometry from the endpoin await expect(page.locator("canvas").first()).toBeVisible({ timeout: 30_000 }); }); -test("the conversion page's Structure tab renders the output geometry", async ({ - page, - request, -}) => { - const { conversionId } = await seedCompletedConversion(request); +test("the record's Structure tab renders the output geometry", async ({ page, request }) => { + const { conversionId, fileId } = await seedCompletedConversion(request); - await page.goto(`/conversions/${conversionId}`); + await page.goto(`/f/${fileId}/report/${conversionId}`); await expect( page.getByRole("heading", { name: "Structure", exact: true }), ).toBeVisible({ timeout: 30_000 }); @@ -59,9 +57,9 @@ test("a refused conversion shows no Structure tab — the refusal is the page", page, request, }) => { - const { conversionId } = await seedRefusedConversion(request); + const { conversionId, fileId } = await seedRefusedConversion(request); - await page.goto(`/conversions/${conversionId}`); + await page.goto(`/f/${fileId}/report/${conversionId}`); await expect( page.getByRole("heading", { name: /Refused — no file was written/ }), ).toBeVisible({ timeout: 30_000 }); @@ -75,7 +73,7 @@ test("an expired-output conversion shows the expired state while the reports sti page, request, }) => { - const { conversionId } = await seedCompletedConversion(request); + const { conversionId, fileId } = await seedCompletedConversion(request); // The one honest state that cannot be produced live without waiting out the byte lifecycle: // the record loads from persisted rows while its geometry answers `410 OUTPUT_EXPIRED` (D232). @@ -95,7 +93,7 @@ test("an expired-output conversion shows the expired state while the reports sti }), ); - await page.goto(`/conversions/${conversionId}`); + await page.goto(`/f/${fileId}/report/${conversionId}`); // Expired, not "not found": the tab says the bytes are gone… await expect(page.getByText(/The output bytes have expired/)).toBeVisible({ timeout: 30_000 }); diff --git a/frontend/e2e/structure-viewer.spec.ts b/frontend/e2e/structure-viewer.spec.ts index 72e632c..2ac685e 100644 --- a/frontend/e2e/structure-viewer.spec.ts +++ b/frontend/e2e/structure-viewer.spec.ts @@ -2,14 +2,16 @@ import { expect, test } from "@playwright/test"; import { FIXTURES, uploadFixture } from "./support/api"; /** - * The M59-S2 render proof (D233/D234): a canonical object renders in embedded Mol\* fed **from the - * geometry endpoint** with no intermediate format. Two load-bearing points, both proven over the + * The M59-S2 render proof (D233/D234), promoted under UI redesign S5 (Rev 1.91) onto its workspace + * tab: a canonical object renders in embedded Mol\* fed **from the geometry endpoint** with no + * intermediate format, at the viewer's promoted home `/f/{file_id}/structure` (the same + * `StructureViewer` the dev spike used to prove). Two load-bearing points, both proven over the * running stack: * - * 1. The dev spike surface mounts the viewer against `/v1/files/{id}/geometry` — the canvas is - * live, the declared atom count reached the loader, and **no request to the only export/ - * download route (`/v1/download`) ever fires** — the no-hidden-export rule is asserted - * behaviourally, not by inspection. + * 1. The workspace tab mounts the viewer against `/v1/files/{id}/geometry` — the canvas is live, + * the declared atom count reached the loader, and **no request to the only export/download + * route (`/v1/download`) ever fires** — the no-hidden-export rule is asserted behaviourally, + * not by inspection. * 2. Bonds are off by default and the heuristic badge appears iff toggled on (D234) — on the * real mount, not just in jsdom. */ @@ -28,7 +30,9 @@ test("a canonical object renders in embedded Mol* from the geometry endpoint, wi if (req.url().includes(`/v1/files/${fileId}/geometry`)) geometryRequests.push(req.url()); }); - await page.goto(`/dev/structure/${fileId}`); + // The promoted home of the viewer: the workspace's Structure tab (moved out of the dev spike by + // UI redesign S5). Same viewer, same render proof. + await page.goto(`/f/${fileId}/structure`); // The mount completes: Mol* initializes WebGL, builds the structure from the geometry JSON, and // only then marks the container `data-mounted`. The dev server compiles cold, so the timeout is diff --git a/frontend/e2e/structure-violet.spec.ts b/frontend/e2e/structure-violet.spec.ts index 00f1995..2a3949d 100644 --- a/frontend/e2e/structure-violet.spec.ts +++ b/frontend/e2e/structure-violet.spec.ts @@ -84,7 +84,7 @@ test("the flagship bounding-box lattice renders violet with its Assumption one c expect(assumption!.description).toMatch(/axis-aligned bounding box/i); // 4. The browser: the record's Structure tab draws the fabricated lattice violet. - await page.goto(`/conversions/${conversionId}`); + await page.goto(`/f/${fileId}/report/${conversionId}`); await expect( page.getByRole("heading", { name: "Structure", exact: true }), ).toBeVisible({ timeout: 30_000 }); @@ -112,7 +112,7 @@ test("the files-page tab never renders violet (a discovery record has no supplie request, }) => { const fileId = await uploadFixture(request, FIXTURES.workedExample); - await page.goto(`/files/${fileId}`); + await page.goto(`/f/${fileId}/structure`); await expect( page.getByRole("heading", { name: "Structure", exact: true }), ).toBeVisible({ timeout: 30_000 }); diff --git a/frontend/e2e/support/api.ts b/frontend/e2e/support/api.ts index 9bd397a..fa52dd6 100644 --- a/frontend/e2e/support/api.ts +++ b/frontend/e2e/support/api.ts @@ -142,7 +142,9 @@ export async function cancelJob(request: APIRequestContext, jobId: string): Prom * interactive recovery*, so the engine pauses on the frame-selection decision (a 3-frame trajectory * → a single-frame POSCAR) rather than refusing. Returns the paused job's id. */ -export async function seedAwaitingRecoveryJob(request: APIRequestContext): Promise { +export async function seedAwaitingRecoveryJob( + request: APIRequestContext, +): Promise<{ jobId: string; fileId: string }> { const fileId = await uploadFixture(request, FIXTURES.relaxTraj); const resp = await request.post(`${API_URL}/v1/convert`, { data: { @@ -157,12 +159,13 @@ export async function seedAwaitingRecoveryJob(request: APIRequestContext): Promi expect(paused.state, "expected the trajectory→POSCAR conversion to pause for a decision").toBe( "awaiting_recovery", ); - return jobId; + return { jobId, fileId }; } /** - * Seed a **completed conversion whose validation failed**, and return its `conversion_id` so a spec - * can drive the browser to `/conversions/{id}` and exercise the acknowledgment gate (slice M32-S1). + * Seed a **completed conversion whose validation failed**, and return its `conversion_id` plus the + * still-live source `file_id` so a spec can drive the browser to `/f/{file_id}/report/{id}` and + * exercise the acknowledgment gate (slice M32-S1; the workspace report tab, UI redesign S2). * * The failure is **real**, not mocked, and it is produced by a genuine representational limit of the * target format — no test hooks, no doctored bytes, no exotic tolerance. The source is a cubic cell @@ -184,7 +187,9 @@ export async function seedAwaitingRecoveryJob(request: APIRequestContext): Promi * positions — so this is exactly the state the gate exists for: a real file the service could not * verify faithfully. */ -export async function seedFailedValidationConversion(request: APIRequestContext): Promise { +export async function seedFailedValidationConversion( + request: APIRequestContext, +): Promise<{ conversionId: string; fileId: string }> { const fileId = await uploadFixture(request, FIXTURES.rotatedLattice); const resp = await request.post(`${API_URL}/v1/convert`, { data: { @@ -202,7 +207,7 @@ export async function seedFailedValidationConversion(request: APIRequestContext) result.download.requires_ack, "expected CIF's loss of lattice orientation to fail validation (requires_ack)", ).toBe(true); - return result.conversion_id; + return { conversionId: result.conversion_id, fileId }; } /** diff --git a/frontend/e2e/trajectory-scrubber.spec.ts b/frontend/e2e/trajectory-scrubber.spec.ts index 19f00d2..0114171 100644 --- a/frontend/e2e/trajectory-scrubber.spec.ts +++ b/frontend/e2e/trajectory-scrubber.spec.ts @@ -20,7 +20,7 @@ test("a multi-frame file's Structure tab scrubs frames, and a single-frame file }) => { const fileId = await uploadFixture(request, FIXTURES.multiFrame); - await page.goto(`/files/${fileId}`); + await page.goto(`/f/${fileId}/structure`); await expect(page.getByRole("heading", { name: "Structure", exact: true })).toBeVisible({ timeout: 30_000, }); @@ -28,7 +28,7 @@ test("a multi-frame file's Structure tab scrubs frames, and a single-frame file // The scrubber appears for a multi-frame object: a frame-number readout and a range control. const slider = page.getByRole("slider", { name: "Trajectory frame" }); await expect(slider).toBeVisible({ timeout: 30_000 }); - await expect(page.getByRole("status")).toContainText("0 / 6"); + await expect(page.getByRole("status").filter({ hasText: "/" })).toContainText("0 / 6"); const mount = page.locator("[data-mounted=true]"); await expect(mount).toBeVisible({ timeout: 60_000 }); @@ -38,11 +38,11 @@ test("a multi-frame file's Structure tab scrubs frames, and a single-frame file // Scrub to frame 5: the displayed frame advances (the mount reports the absolute report index). await slider.fill("5"); await expect(mount).toHaveAttribute("data-current-frame", "5", { timeout: 30_000 }); - await expect(page.getByRole("status")).toContainText("5 / 6"); + await expect(page.getByRole("status").filter({ hasText: "/" })).toContainText("5 / 6"); // A single-frame file shows the M60 static render — no scrubber anywhere. const single = await uploadFixture(request, FIXTURES.workedExample); - await page.goto(`/files/${single}`); + await page.goto(`/f/${single}/structure`); await expect(page.locator("[data-mounted=true]")).toBeVisible({ timeout: 60_000 }); await expect(page.getByRole("slider", { name: "Trajectory frame" })).toHaveCount(0); await expect(page.getByRole("button", { name: /Play|Pause/ })).toHaveCount(0); @@ -54,7 +54,7 @@ test("a variable-cell trajectory draws the wireframe per displayed frame — a c }) => { const fileId = await uploadFixture(request, FIXTURES.variableCell); - await page.goto(`/files/${fileId}`); + await page.goto(`/f/${fileId}/structure`); await expect( page.getByRole("heading", { name: "Structure", exact: true }), ).toBeVisible({ timeout: 30_000 }); @@ -92,7 +92,7 @@ test("a conversion whose output is multi-frame scrubs on the output side", async expect(result.conversion_report?.status).toBe("completed"); const conversionId = result.conversion_id; - await page.goto(`/conversions/${conversionId}`); + await page.goto(`/f/${fileId}/report/${conversionId}`); await expect(page.getByRole("heading", { name: "Structure", exact: true })).toBeVisible({ timeout: 30_000, }); @@ -102,7 +102,7 @@ test("a conversion whose output is multi-frame scrubs on the output side", async await expect(slider).toBeVisible({ timeout: 30_000 }); const mount = page.locator("[data-mounted=true]"); await expect(mount).toBeVisible({ timeout: 60_000 }); - await expect(page.getByRole("status")).toContainText("0 / 6"); + await expect(page.getByRole("status").filter({ hasText: "/" })).toContainText("0 / 6"); await slider.fill("4"); await expect(mount).toHaveAttribute("data-current-frame", "4", { timeout: 30_000 }); }); @@ -125,7 +125,7 @@ test("an NpT XDATCAR's cell animates — the wireframe persists while the per-fr ); expect(aLengths).toEqual([5.6, 5.8, 6.0]); // the cell breathes frame to frame - await page.goto(`/files/${fileId}`); + await page.goto(`/f/${fileId}/structure`); await expect( page.getByRole("heading", { name: "Structure", exact: true }), ).toBeVisible({ timeout: 30_000 }); @@ -143,7 +143,7 @@ test("an NpT XDATCAR's cell animates — the wireframe persists while the per-fr await slider.fill("2"); await expect(mount).toHaveAttribute("data-current-frame", "2", { timeout: 30_000 }); await expect(mount).toHaveAttribute("data-unitcell-drawn", "true"); - await expect(page.getByRole("status")).toContainText("2 / 3"); + await expect(page.getByRole("status").filter({ hasText: "/" })).toContainText("2 / 3"); }); test("a timestep-less XDATCAR scrubs by frame number with no invented time axis (§5.3)", async ({ @@ -152,7 +152,7 @@ test("a timestep-less XDATCAR scrubs by frame number with no invented time axis }) => { const fileId = await uploadFixture(request, FIXTURES.mdXdatcar); - await page.goto(`/files/${fileId}`); + await page.goto(`/f/${fileId}/structure`); await expect( page.getByRole("heading", { name: "Structure", exact: true }), ).toBeVisible({ timeout: 30_000 }); @@ -165,10 +165,10 @@ test("a timestep-less XDATCAR scrubs by frame number with no invented time axis // a frame number is the honest readout, never an invented time label. const slider = page.getByRole("slider", { name: "Trajectory frame" }); await expect(slider).toBeVisible({ timeout: 30_000 }); - await expect(page.getByRole("status")).toHaveText("0 / 3"); + await expect(page.getByRole("status").filter({ hasText: "/" })).toHaveText("0 / 3"); await slider.fill("2"); await expect(mount).toHaveAttribute("data-current-frame", "2", { timeout: 30_000 }); - await expect(page.getByRole("status")).toHaveText("2 / 3"); + await expect(page.getByRole("status").filter({ hasText: "/" })).toHaveText("2 / 3"); // No time axis anywhere in the viewer chrome: no unit label (ps/fs/picosecond/femtosecond) and // no timestep word — the frame-number readout is the whole time story. diff --git a/frontend/e2e/unknown-format.spec.ts b/frontend/e2e/unknown-format.spec.ts index 08d92de..42c0c57 100644 --- a/frontend/e2e/unknown-format.spec.ts +++ b/frontend/e2e/unknown-format.spec.ts @@ -9,13 +9,13 @@ import { fixturePath, FIXTURES } from "./support/api"; * with that machine code shown **verbatim**, not a guess and not a blank page. */ test("an unrecognized file inspects to a verbatim UNKNOWN_FORMAT envelope", async ({ page }) => { - await page.goto("/convert"); + await page.goto("/"); await page .getByLabel("Choose a file to convert") .setInputFiles(fixturePath(FIXTURES.notAStructure.file)); - // The transfer succeeds and the app routes to the file resource; inspection is what refuses. - await page.waitForURL("**/files/**"); + // The transfer succeeds and the app routes to the file's workspace; inspection is what refuses. + await page.waitForURL("**/f/**"); // The code is rendered as a verbatim badge (Part 6 §6) — a support thread and the screen match. await expect(page.getByText("UNKNOWN_FORMAT", { exact: true })).toBeVisible({ timeout: 30_000 }); diff --git a/frontend/lib/command/fuzzy.test.ts b/frontend/lib/command/fuzzy.test.ts new file mode 100644 index 0000000..03f5ea5 --- /dev/null +++ b/frontend/lib/command/fuzzy.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { fuzzyMatch, fuzzySearch, type CommandCandidate } from "./fuzzy"; + +/** + * The in-repo fuzzy matcher (S4, D246) — the no-dependency contract. The palette leans on three + * properties that must never regress: a query must be a *case-insensitive subsequence* (no match + * when a char is missing), contiguous/substring matches must outrank scattered ones, and word-start + * matches must outrank mid-word ones. These tests pin the scoring so a later "improvement" cannot + * silently reorder the palette in a way no journey would catch. + */ +const noMatch = () => expect(fuzzyMatch("zz", "atoms")).toBeNull(); + +describe("fuzzyMatch subsequence semantics", () => { + it("matches a case-insensitive subsequence", () => { + expect(fuzzyMatch("xyz", "eXtended XYZ")).not.toBeNull(); + expect(fuzzyMatch("xyz", "PXZ")).toBeNull(); // 'y' missing + }); + + it("does not match when a query character is absent or order breaks", () => { + noMatch(); + expect(fuzzyMatch("atomz", "atoms")).toBeNull(); + expect(fuzzyMatch("om", "atoms")).not.toBeNull(); // subsequence, not prefix + }); + + it("an empty (or blank) query matches nothing scored", () => { + expect(fuzzyMatch("", "atoms")).toEqual({ score: 0, highlight: [] }); + expect(fuzzyMatch(" ", "atoms")).toEqual({ score: 0, highlight: [] }); + }); + + it("a query longer than the target can never match", () => { + expect(fuzzyMatch("longerthantarget", "pos")).toBeNull(); + }); + + it("returns the matched indices for highlighting", () => { + const m = fuzzyMatch("pos", "POSCAR"); + expect(m).not.toBeNull(); + // 'p','o','s' are the first three letters — consecutive, matched in place. + expect(m && [...m.highlight]).toEqual([0, 1, 2]); + expect(m && m.score).toBeGreaterThan(0); + }); +}); + +describe("fuzzyMatch scoring (what the palette sorts on)", () => { + it("a contiguous substring outranks the same letters scattered", () => { + const contig = fuzzyMatch("pos", "POSCAR")!; + const scattered = fuzzyMatch("pos", "p1-o-s")!; + expect(contig.score).toBeGreaterThan(scattered.score); + }); + + it("a camelCase / separator word-start adds a boundary bonus over the same run mid-word", () => { + // Both are contiguous runs of "vec"; the camelCase 'V' in latticeVectors is a word start. + const camel = fuzzyMatch("vec", "latticeVectors")!; + const plain = fuzzyMatch("vec", "laveced")!; + expect(camel.score).toBeGreaterThan(plain.score); + }); + + it("an exact substring nearly always beats even a boundary-rich scattered match", () => { + // "cell.lattice_vectors" matches "cell" as a clean substring; the scattered competitor shares + // the letters in order but not contiguously — the substring must rank well above it. + const substring = fuzzyMatch("cell", "cell.lattice_vectors")!; + const scattered = fuzzyMatch("cell", "c.extra.e.little.l" )!; + expect(substring.score).toBeGreaterThan(scattered.score); + }); +}); + +describe("fuzzySearch ranking", () => { + const candidates: CommandCandidate[] = [ + { id: "1", label: "POSCAR", search: "POSCAR", payload: "a" }, + { id: "2", label: "CIF", search: "CIF", payload: "b" }, + { id: "3", label: "extXYZ", search: "extXYZ", payload: "c" }, + { id: "4", label: "XDATCAR", search: "XDATCAR", payload: "d" }, + ]; + + it("ranks best-first and drops non-matches", () => { + const ranked = fuzzySearch("pos", candidates); + expect(ranked.map((r) => r.candidate.id)).toEqual(["1"]); + }); + + it("an empty query returns no results (the palette shows everything by default instead)", () => { + expect(fuzzySearch("", candidates)).toEqual([]); + }); + + it("picks the exact substring over a scattered subsequence for a shared query", () => { + const loose: CommandCandidate[] = [ + { id: "sub", label: "POSCAR", search: "POSCAR", payload: "s" }, + { id: "loose", label: "pre-ordered", search: "pre-ordered", payload: "l" }, + ]; + const ranked = fuzzySearch("po", loose); + expect(ranked[0].candidate.id).toBe("sub"); + }); +}); \ No newline at end of file diff --git a/frontend/lib/command/fuzzy.ts b/frontend/lib/command/fuzzy.ts new file mode 100644 index 0000000..3270562 --- /dev/null +++ b/frontend/lib/command/fuzzy.ts @@ -0,0 +1,149 @@ +/** + * The in-repo fuzzy matcher for the command palette (UI redesign S4, D246). **No dependency by + * design** (the slice plan cuts fuzzy libraries: "a small in-repo implementation — no new + * dependency"). It finds and scores subsequence matches with the ordering cues a jump-to-anything + * palette needs: an exact substring and a matching word-start rank higher than scattered letters, + * and among matches the earlier and more-contiguous wins. Small, deterministic, unit-tested. + * + * The matcher is a *score + index return*, not a `filter` — callers feed it a large list and sort + * by score (or keep top-N), so the palette can include every candidate it knows and let the score + * decide, with a zero score meaning "does not match". + */ +import { useMemo } from "react"; + +/** The match quality. Zero (falsy) means the candidate does not match at all. */ +export type FuzzyScore = null | { + score: number; + /** Indices of the matched characters in `target` (already lower-cased per its own casing). */ + highlight: readonly number[]; +}; + +/** Scoring weights — tuned for a command palette, pinned by `fuzzy.test.ts`. The load-bearing rule: + * an **exact substring** (every query char contiguous in the target) beats a scattered subsequence. + * Below that, match-at-start and a word start (after `.`/`-`/`_`/`/`/space, or a camelCase hump) + * are the ordering cues a jump-to-anything palette is expected to respect. */ +const WEIGHTS = { + /** Per matched character. */ + base: 1, + /** The match begins at the very start of the target. */ + start: 2, + /** A matched character that starts a word (separator or camelCase hump). */ + boundary: 2, + /** Per adjacent matched pair within the longest contiguous run. */ + contiguous: 3, + /** The whole query matched contiguously — an exact substring, the strongest signal. */ + substring: 12, +}; + +type Indexed = { ch: string; index: number; boundary: number }; + +function tokenize(target: string): Indexed[] { + // Tokenize the *original-cased* target so a camelCase hump is still visible as one; matching + // compares on the lower-cased char below. + const low = target.toLowerCase(); + const out: Indexed[] = []; + for (let i = 0; i < target.length; i += 1) { + const raw = target[i]; + const prev = i > 0 ? target[i - 1] : null; + // A word-start: the SEARCHABLE char that a separator, slash, or a camelCase hump precedes. + const boundary = + prev !== null && (prev === "." || prev === "-" || prev === "_" || prev === "/" || prev === " " || prev === "(") + ? WEIGHTS.boundary + : /[A-Z]/.test(raw) && prev !== null && /[a-z0-9]/.test(prev) + ? WEIGHTS.boundary + : 0; + out.push({ ch: low[i], index: i, boundary }); + } + return out; +} + +/** The length of the longest contiguous run among sorted indices. */ +function longestRun(indices: readonly number[]): number { + let best = 1; + let run = 1; + for (let i = 1; i < indices.length; i += 1) { + if (indices[i] === indices[i - 1] + 1) { + run += 1; + best = Math.max(best, run); + } else { + run = 1; + } + } + return indices.length === 0 ? 0 : best; +} + +/** + * Return the match score + highlight indices for `query` as a (case-insensitive) subsequence of + * `target`, or `null` when it does not match at all. The query's characters must appear in `target` + * in order; beyond that, the score rewards contiguity, word starts, and a match at position zero. + */ +export function fuzzyMatch(query: string, target: string): FuzzyScore { + const q = query.trim().toLowerCase(); + if (q.length === 0) return { score: 0, highlight: [] }; + const t = target.toLowerCase(); + if (q.length > t.length) return null; + + const tokens = tokenize(target); // original casing, for camelCase hump detection + let qi = 0; + let base = 0; + let boundaries = 0; + let startBonus = 0; + const highlight: number[] = []; + // Greedy leftmost subsequence — simple, deterministic, and the *matching* decision never depends + // on the scoring under it (a char either completes the query in order or it does not). + for (let i = 0; i < tokens.length && qi < q.length; i += 1) { + if (tokens[i].ch !== q[qi]) continue; + base += WEIGHTS.base; + if (tokens[i].index === 0) startBonus += WEIGHTS.start; + boundaries += tokens[i].boundary; + highlight.push(tokens[i].index); + qi += 1; + } + if (qi < q.length) return null; // not all query chars found in order + + const run = longestRun(highlight); + const runBonus = (run - 1) * WEIGHTS.contiguous; + // An exact substring (every query char contiguous) gets the big bonus — the strongest match. + const substringBonus = run === q.length ? WEIGHTS.substring : 0; + const score = base + boundaries + startBonus + runBonus + substringBonus - t.length * 0.001; + return { score, highlight }; +} + +/** A command-palette candidate: an id, a human label, and the string the matcher ranks on. */ +export interface CommandCandidate { + id: string; + /** The primary label shown in the palette. */ + label: string; + /** The searchable text — usually the label, sometimes label + a category/shortcut hint. */ + search: string; + /** Opaque payload the palette's handler receives when this candidate is chosen. */ + payload: T; +} + +/** Rank `candidates` against `query`, best-first, keeping only non-zero scores. */ +export function fuzzySearch( + query: string, + candidates: readonly CommandCandidate[], +): { candidate: CommandCandidate; match: NonNullable }[] { + const scored: { candidate: CommandCandidate; match: NonNullable }[] = []; + for (const candidate of candidates) { + const match = fuzzyMatch(query, candidate.search); + if (match && match.score > 0) { + scored.push({ candidate, match }); + } + } + scored.sort((a, b) => b.match.score - a.match.score); + return scored; +} + +/** Sort a list of candidates import-stably / for React — a hook to memoize a ranked query. */ +export function useFuzzySearch( + query: string, + candidates: readonly CommandCandidate[], + limit?: number, +): { candidate: CommandCandidate; match: NonNullable }[] { + return useMemo(() => { + const ranked = fuzzySearch(query, candidates); + return limit ? ranked.slice(0, limit) : ranked; + }, [query, candidates, limit]); +} \ No newline at end of file diff --git a/frontend/lib/prefs/presets.ts b/frontend/lib/prefs/presets.ts new file mode 100644 index 0000000..54e5d7b --- /dev/null +++ b/frontend/lib/prefs/presets.ts @@ -0,0 +1,117 @@ +/** + * Conversion **presets** — a named target-format + loss-posture combo you can re-apply in one click + * (UI redesign S4, D246; D-R6 — client-side only, no backend table, no identity model). + * + * A preset captures exactly what a one-click re-convert can safely replay: **the target format and + * the loss posture** (permissive vs strict — the `POST /v1/convert` `options` the Convert tab + * already sends). It does **not** try to capture the interactive recovery decisions — those answer + * questions about a *specific* file (which frame survives, what lattice) and cannot be replayed + * blindly across files (P4: nothing is silently defaulted). So re-converting with a preset submits + * `allow_recovery: true`, and if the new file still needs a decision the job **pauses** again and + * asks — the same honest path as a fresh convert. A strict preset refuses rather than drop anything + * unacknowledged, exactly as strict always does. + * + * Persistence is localStorage under `xtalate-presets`, written through the SSR-safe + * {@link lib/prefs/storage.ts}. Every read is validated against the preset shape so a stale or + * hand-edited value is never trusted; every write is best-effort and returns whether it landed. + */ +import { readJson, writeJson } from "./storage"; + +/** The convert-posture half of a preset — mirrors the Convert tab's two modes. */ +export type PresetMode = "permissive" | "strict"; + +/** One saved preset: a target format plus a loss posture, named by the user. */ +export interface ConversionPreset { + id: string; + /** The user-facing name, e.g. "POSCAR for print". */ + name: string; + target_format_id: string; + target_format_name: string; + mode: PresetMode; + /** ISO timestamp (when the preset was created) — a stable sort key, not user-visible. */ + created_at: string; +} + +/** The storage key for the preset list. */ +export const PRESETS_STORAGE_KEY = "presets"; + +/** The newest-first ordering of a server-independent recency list uses `created_at`. */ +export const MAX_PRESETS = 12; + +function isPresetMode(v: unknown): v is PresetMode { + return v === "permissive" || v === "strict"; +} + +function isPreset(v: unknown): v is ConversionPreset { + if (typeof v !== "object" || v === null) return false; + const p = v as Record; + return ( + typeof p.id === "string" && + typeof p.name === "string" && + typeof p.target_format_id === "string" && + typeof p.target_format_name === "string" && + isPresetMode(p.mode) && + typeof p.created_at === "string" + ); +} + +function isPresetList(v: unknown): v is ConversionPreset[] { + return Array.isArray(v) && v.every(isPreset); +} + +/** Read the saved presets (never throws; falls back to empty). */ +export function listPresets(): ConversionPreset[] { + return readJson(PRESETS_STORAGE_KEY, isPresetList, []); +} + +function dedupeById(presets: ConversionPreset[]): ConversionPreset[] { + const seen = new Set(); + const out: ConversionPreset[] = []; + for (const p of presets) { + if (seen.has(p.id)) continue; + seen.add(p.id); + out.push(p); + } + return out; +} + +/** + * Save a preset. An existing preset with the **same name** is replaced (names are the user's handle + * on a preset — re-saving under a name means "update this one"), keeping its original id so a + * previous `created_at` sort is stable. Returns the new list (or the unchanged list if storage was + * blocked). Capped so the list can never grow unboundedly. + */ +export function savePreset(input: { + name: string; + target_format_id: string; + target_format_name: string; + mode: PresetMode; +}): { presets: ConversionPreset[]; saved: boolean } { + const existing = listPresets().find((p) => p.name.trim().toLowerCase() === input.name.trim().toLowerCase()); + const now = new Date().toISOString(); + const preset: ConversionPreset = existing + ? { ...existing, ...input, name: input.name.trim() } + : { + id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`, + name: input.name.trim(), + target_format_id: input.target_format_id, + target_format_name: input.target_format_name, + mode: input.mode, + created_at: now, + }; + const list = dedupeById([preset, ...listPresets().filter((p) => p.id !== preset.id)]).slice(0, MAX_PRESETS); + const saved = writeJson(PRESETS_STORAGE_KEY, list); + return { presets: list, saved }; +} + +/** Delete a preset by id; returns the resulting list (unchanged on storage failure). */ +export function deletePreset(id: string): ConversionPreset[] { + const list = listPresets().filter((p) => p.id !== id); + writeJson(PRESETS_STORAGE_KEY, list); + return list; +} + +/** Look up one preset, validated (or `null`). */ +export function getPreset(id: string): ConversionPreset | null { + return listPresets().find((p) => p.id === id) ?? null; +} \ No newline at end of file diff --git a/frontend/lib/prefs/recents.ts b/frontend/lib/prefs/recents.ts new file mode 100644 index 0000000..6d8fbce --- /dev/null +++ b/frontend/lib/prefs/recents.ts @@ -0,0 +1,91 @@ +/** + * **Recent files** — the strip (and the palette's "recent" section) that gets you back to a file + * you were just working on (UI redesign S4, D246; D-R6 — client-side only). + * + * Two sources, one list: + * - a **localStorage** recents list (`xtalate-recents`), written each time a workspace is visited + * (the Inspect tab pushes the file), so a file you opened seconds ago is one click away and + * survives a reload; and + * - **`/v1/history`**, seeded by the caller (the landing's strip) and merged in — the durable list + * of a session's conversions, so a just-converted output is reachable even before this browser + * touched the workspace (a shared link, a second tab). + * + * Every entry is keyed by `file_id` when the source upload is still live (a workspace URL), else by + * `conversion_id` (a durable record URL) — mirroring the HistoryRow rule. The list is capped and + * de-duplicated by that key, enthusiastically overwriting a re-seen entry so its position jumps to + * the front (recency, not birth order). + */ +import { readJson, writeJson } from "./storage"; + +/** One recent-file entry, normalized for the strip and the palette. */ +export interface RecentFile { + /** The stable identity — `file_id` when live, else `conversion_id`. */ + key: string; + /** A workspace URL when the source upload is live, else the durable record URL. */ + href: string; + filename: string; + /** The source format id (for the strip's "extXYZ" tag and the palette search). */ + format_id: string; + /** ISO timestamp of when it was *last seen* (a sort key). */ + last_seen_at: string; +} + +export const RECENTS_STORAGE_KEY = "recents"; + +/** Strip + palette cap — recency needs a handful, not a wall (Part 7 §4.2 lightweight). */ +export const MAX_RECENTS = 8; + +function isRecent(v: unknown): v is RecentFile { + if (typeof v !== "object" || v === null) return false; + const r = v as Record; + return ( + typeof r.key === "string" && + typeof r.href === "string" && + typeof r.filename === "string" && + typeof r.format_id === "string" && + typeof r.last_seen_at === "string" + ); +} + +function isRecentList(v: unknown): v is RecentFile[] { + return Array.isArray(v) && v.every(isRecent); +} + +/** Read the persisted recents (never throws). */ +export function listRecents(): RecentFile[] { + return readJson(RECENTS_STORAGE_KEY, isRecentList, []); +} + +/** Merge two recency lists, de-duplicated by `key`, most-recent-first, capped. */ +function mergeRecency(a: It[], b: It[], cap: number): It[] { + const seen = new Set(); + const out: It[] = []; + for (const it of [...a, ...b]) { + if (seen.has(it.key)) continue; + seen.add(it.key); + out.push(it); + if (out.length >= cap) break; + } + return out; +} + +/** + * Record that `entry` was just visited: it jumps to the front, pre-existing duplicates vanish, and + * the list stays capped. `href`/`filename` are refreshed from the current visit (a file's record + * URL may have outlived its upload). Returns whether the write landed. + */ +export function pushRecent(entry: Omit & { key: string }): boolean { + const now = new Date().toISOString(); + const current = listRecents().filter((r) => r.key !== entry.key); + const next = mergeRecency([{ ...entry, last_seen_at: now }, ...current], [], MAX_RECENTS); + return writeJson(RECENTS_STORAGE_KEY, next); +} + +/** + * Merge the persisted recents with a seeded list (from `/v1/history`) — most recent first, the + * persisted wins ties by key. The strip calls this with the history-derived entries so a just-made + * conversion appears without this browser having visited its workspace. + */ +export function mergeRecents(persisted: RecentFile[], seeded: RecentFile[]): RecentFile[] { + return mergeRecency(persisted, seeded, MAX_RECENTS); +} \ No newline at end of file diff --git a/frontend/lib/prefs/storage.test.ts b/frontend/lib/prefs/storage.test.ts new file mode 100644 index 0000000..ea9e1d2 --- /dev/null +++ b/frontend/lib/prefs/storage.test.ts @@ -0,0 +1,156 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + prefixedKey, + readJson, + readStorage, + removeStorage, + writeJson, + writeStorage, +} from "./storage"; +import { deletePreset, listPresets, savePreset } from "./presets"; +import { listRecents, MAX_RECENTS, mergeRecents, pushRecent } from "./recents"; + +/** + * The QoL persistence layer (S4, D246) — every read/write routes through the SSR-safe + * `lib/prefs/storage.ts`, whose "try/catch around every read/write" rule is itself tested here: + * a blocked `localStorage` (privacy mode, quota, `undefined` in SSR) must never throw, and a call + * must fall back to its empty/default value. Then the two consumers (`presets.ts`, `recents.ts`) + * prove the shape guards: a stale or hand-edited value is never trusted. + */ +afterEach(() => { + vi.unstubAllGlobals(); + window.localStorage.clear(); +}); + +describe("storage.ts (SSR-safe reads/writes)", () => { + it("reads back what it wrote, namespaced under xtalate-", () => { + expect(writeStorage(prefixedKey("k"), "v")).toBe(true); + expect(readStorage(prefixedKey("k"))).toBe("v"); + expect(readStorage("k")).toBeNull(); // unprefixed names are never read + removeStorage(prefixedKey("k")); + expect(readStorage(prefixedKey("k"))).toBeNull(); + }); + + it("is a no-op (not a throw) when localStorage is unavailable (SSR / disabled)", () => { + // No storage at all — the same null-`localStorage` a pre-hydration SSR pass or a block would + // see; every helper must fall back (never throw) and reads report `null`. + vi.stubGlobal("localStorage", undefined); + expect(readStorage(prefixedKey("k"))).toBeNull(); + expect(writeStorage(prefixedKey("k"), "v")).toBe(false); + removeStorage(prefixedKey("k")); + const isStr = (v: unknown): v is string => typeof v === "string"; + expect(readJson("k", isStr, "fallback")).toBe("fallback"); + }); + + it("swallows a throwing localStorage (privacy mode / quota)", () => { + // A localStorage whose every method throws on touch (blocked storage) — read/write fall back. + const throwing = { + getItem: () => { + throw new Error("denied"); + }, + setItem: () => { + throw new Error("quota"); + }, + removeItem: () => { + throw new Error("quota"); + }, + clear: () => { + throw new Error("quota"); + }, + }; + vi.stubGlobal("localStorage", throwing as unknown as Storage); + expect(readStorage(prefixedKey("k"))).toBeNull(); + expect(writeStorage(prefixedKey("k"), "v")).toBe(false); + removeStorage(prefixedKey("k")); + }); + + it("readJson validates shape and falls back on garbage JSON", () => { + const isStr = (v: unknown): v is string => typeof v === "string"; + writeJson("val", "ok"); + expect(readJson("val", isStr, "fallback")).toBe("ok"); + writeJson("val", 42); + expect(readJson("val", isStr, "fallback")).toBe("fallback"); + writeStorage(prefixedKey("val"), "{not json"); + expect(readJson("val", isStr, "fallback")).toBe("fallback"); + }); +}); + +describe("presets.ts (named target+posture combos)", () => { + function sample(target_format_id = "poscar", mode: "permissive" | "strict" = "strict") { + return { + name: "Print-ready POSCAR", + target_format_id, + target_format_name: "POSCAR", + mode, + }; + } + + it("saves and lists a preset", () => { + const { presets, saved } = savePreset(sample()); + expect(saved).toBe(true); + expect(listPresets()).toHaveLength(1); + expect(presets[0].name).toBe("Print-ready POSCAR"); + expect(presets[0].target_format_id).toBe("poscar"); + expect(presets[0].mode).toBe("strict"); + }); + + it("re-saving under the same name replaces and keeps the id (update semantics)", () => { + const first = savePreset(sample()); + const again = savePreset({ ...sample(), mode: "permissive" }); + expect(listPresets()).toHaveLength(1); + expect(again.presets[0].id).toBe(first.presets[0].id); + expect(again.presets[0].mode).toBe("permissive"); + }); + + it("deletes by id", () => { + const { presets } = savePreset(sample()); + const next = deletePreset(presets[0].id); + expect(next).toHaveLength(0); + expect(listPresets()).toHaveLength(0); + }); + + it("caps the list and never trusts a stale, unshaped value", () => { + writeStorage("xtalate-presets", JSON.stringify([{ bogus: true }])); + expect(listPresets()).toEqual([]); + for (let i = 0; i < MAX_RECENTS + 5; i += 1) savePreset(sample(`f${i}`)); + // MAX_PRESETS cap in presets.ts + expect(listPresets().length).toBeLessThanOrEqual(12); + }); +}); + +describe("recents.ts (recent files, localStorage + seeded history)", () => { + const at = (key: string, name = "a.extxyz", format = "extxyz") => + ({ + key, + href: `/f/${key}`, + filename: name, + format_id: format, + last_seen_at: "2026-08-30T00:00:00.000Z", + } as const); + + it("pushes a recent to the front and de-duplicates by key", () => { + pushRecent(at("file-a")); + pushRecent(at("file-b")); + pushRecent(at("file-a")); + const recents = listRecents(); + expect(recents.map((r) => r.key)).toEqual(["file-a", "file-b"]); + expect(recents[0].last_seen_at >= recents[1].last_seen_at).toBe(true); + }); + + it("caps at MAX_RECENTS", () => { + for (let i = 0; i < MAX_RECENTS + 5; i += 1) pushRecent(at(`f${i}`)); + expect(listRecents().length).toBe(MAX_RECENTS); + }); + + it("merges persisted with a seeded history list, most-recent-first", () => { + pushRecent(at("persisted")); + const seeded = [at("persisted"), at("history")]; + const merged = mergeRecents(listRecents(), seeded); + expect(merged.map((r) => r.key)).toEqual(["persisted", "history"]); + }); + + it("never trusts an unshaped stored value", () => { + window.localStorage.setItem("xtalate-recents", JSON.stringify([{ nope: 1 }])); + expect(listRecents()).toEqual([]); + }); +}); \ No newline at end of file diff --git a/frontend/lib/prefs/storage.ts b/frontend/lib/prefs/storage.ts new file mode 100644 index 0000000..b9962b4 --- /dev/null +++ b/frontend/lib/prefs/storage.ts @@ -0,0 +1,85 @@ +/** + * SSR-safe localStorage helpers for the QoL layer (UI redesign S4, D246; D-R6 — every QoL read/ + * write is client-side, never a new backend route or dependency). + * + * `localStorage` is a browser-only, quota- and policy-bound API: it can throw on access (privacy + * mode, storage disabled), is `undefined` during SSR, and its reads must never break a render. + * These helpers make the "try/catch around every read/write" rule (the slice plan's exact words) + * true in one place, so the callers in `presets.ts` / `recents.ts` / the palette are plain data + * code with no error plumbing. `STORAGE_PREFIX` is the `xtalate-` convention the theme and notify + * providers already use — every key stays namespaced and greppable. + */ + +/** The shared namespace prefix for every persisted client-side key. */ +export const STORAGE_PREFIX = "xtalate-"; + +function isServer(): boolean { + return typeof window === "undefined"; +} + +/** + * Read a stored string, or `null` when unset, unavailable (SSR), or storage is blocked (privacy + * mode / quota). Callers `JSON.parse`+validate the result themselves. Never throws. + */ +export function readStorage(key: string): string | null { + if (isServer()) return null; + try { + return window.localStorage.getItem(key); + } catch { + return null; + } +} + +/** + * Write a string. Best-effort — a blocked storage (quota, disabled) is detected and swallowed so a + * QoL preference can *never* break a render or a navigation. Returns whether the write landed, so + * a "did my preset save?" affordance can stay honest without throwing. + */ +export function writeStorage(key: string, value: string): boolean { + if (isServer()) return false; + try { + window.localStorage.setItem(key, value); + return true; + } catch { + return false; + } +} + +/** Remove a key (best-effort; used when a preset is deleted). */ +export function removeStorage(key: string): void { + if (isServer()) return; + try { + window.localStorage.removeItem(key); + } catch { + // Ignore — a blocked storage just cannot delete; reads treat it as absent anyway. + } +} + +/** The `xtalate-...` key for a bare name, kept consistent with the theme/notify keys. */ +export function prefixedKey(name: string): string { + return `${STORAGE_PREFIX}${name}`; +} + +/** + * Read a JSON value under `name`, validated by `isValid` (a type guard the caller provides, so a + * hand-edited / stale / schema-migrated value is never trusted). Falls back to `fallback`. + */ +export function readJson(name: string, isValid: (value: unknown) => value is T, fallback: T): T { + const raw = readStorage(prefixedKey(name)); + if (raw === null) return fallback; + try { + const parsed: unknown = JSON.parse(raw); + return isValid(parsed) ? parsed : fallback; + } catch { + return fallback; + } +} + +/** Write a JSON value under `name`; returns whether it landed. */ +export function writeJson(name: string, value: unknown): boolean { + try { + return writeStorage(prefixedKey(name), JSON.stringify(value)); + } catch { + return false; + } +} \ No newline at end of file diff --git a/frontend/lib/report/exportReport.test.ts b/frontend/lib/report/exportReport.test.ts new file mode 100644 index 0000000..bc9c3f1 --- /dev/null +++ b/frontend/lib/report/exportReport.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import type { ConversionReport } from "./types"; +import completedReport from "@/components/report/__fixtures__/conversion.completed.json"; +import { reportToJson, reportToMarkdown } from "./exportReport"; + +const report = completedReport as unknown as ConversionReport; + +/** + * The export serializers (UI redesign S3, D245) — pure functions of the report model, so the tests + * run against the strings themselves. Two load-bearing properties: + * + * - **Faithfulness.** JSON is the model verbatim (a loss can never be serialized away); Markdown + * contains every row, with the engine's reasons/descriptions verbatim and the canonical path + * always present beside the plain label. + * - **Determinism.** The same report always yields the same string — the e2e journey's + * "Copy-as-JSON produces the expected string" is built on this module, not on DOM scraping. + */ +describe("reportToJson", () => { + it("serializes the report model verbatim, pretty-printed", () => { + const out = reportToJson(report); + expect(JSON.parse(out)).toEqual(report); + expect(out).toContain("\n "); + }); + + it("never drops a row from the serialized model", () => { + const parsed = JSON.parse(reportToJson(report)) as ConversionReport; + expect(parsed.preserved).toHaveLength(report.preserved.length); + expect(parsed.removed).toHaveLength(report.removed.length); + expect(parsed.assumptions).toHaveLength(report.assumptions.length); + expect(parsed.warnings).toHaveLength(report.warnings.length); + }); +}); + +describe("reportToMarkdown", () => { + it("is deterministic for the same report", () => { + expect(reportToMarkdown(report)).toBe(reportToMarkdown(report)); + }); + + it("leads with the source → target header and the mode/status line", () => { + const md = reportToMarkdown(report); + expect(md).toContain(`**${report.source.filename}**`); + expect(md).toContain(`\`${report.source.format_id}\``); + expect(md).toContain(`\`${report.target.format_id}\``); + expect(md).toContain(`Mode \`${report.mode}\``); + }); + + it("renders every outcome section that has rows, in outcome-first order", () => { + const md = reportToMarkdown(report); + const assumed = md.indexOf("## Assumed"); + const lost = md.indexOf("## Lost"); + const warned = md.indexOf("## Warned"); + const kept = md.indexOf("## Kept"); + // The worked fixture has all four outcomes; Assumed must lead and Kept must trail. + expect(assumed).toBeGreaterThan(-1); + expect(kept).toBeGreaterThan(-1); + expect(assumed).toBeLessThan(lost); + expect(lost).toBeLessThan(warned); + expect(warned).toBeLessThan(kept); + }); + + it("renders every row with the canonical path and verbatim reasons", () => { + const md = reportToMarkdown(report); + for (const entry of report.removed) { + expect(md).toContain(entry.reason); + expect(md).toContain(`\`${entry.path}\``); + } + for (const warning of report.warnings) { + expect(md).toContain(warning.message); + expect(md).toContain(warning.code); + } + for (const assumption of report.assumptions) { + expect(md).toContain(assumption.description); + expect(md).toContain(`**${assumption.id}**`); + } + for (const entry of report.preserved) { + expect(md).toContain(`\`${entry.path}\``); + } + }); + + it("states what an assumption supplied, from the report's own join", () => { + const md = reportToMarkdown(report); + for (const supplied of report.supplied) { + expect(md).toContain(`\`${supplied.path}\``); + } + }); + + it("omits empty outcome sections (the affirmative zero accounting lives in the chips)", () => { + const lossless: ConversionReport = { ...report, removed: [], assumptions: [], supplied: [], warnings: [] }; + const md = reportToMarkdown(lossless); + expect(md).not.toContain("## Assumed"); + expect(md).not.toContain("## Lost"); + expect(md).not.toContain("## Warned"); + expect(md).toContain("## Kept"); + }); +}); diff --git a/frontend/lib/report/exportReport.ts b/frontend/lib/report/exportReport.ts new file mode 100644 index 0000000..f26208e --- /dev/null +++ b/frontend/lib/report/exportReport.ts @@ -0,0 +1,120 @@ +import { labelForPath, labelForScenario } from "@/lib/mapping"; +import type { + Assumption, + ConversionReport, + PreservedEntry, + RemovedEntry, + ReportWarning, +} from "./types"; +import { OUTCOME_ORDER, OUTCOME_LABELS, buildReportRows } from "./grouping"; + +/** + * Report export — Copy-as-JSON / Copy-as-Markdown (UI redesign S3, D245; design spec §5). + * + * Both serializers are **pure** functions of the report model already in hand (Part 7 §2: the + * client never re-derives a report — it renders, and here serializes, what the engine produced). + * They exist so the export is deterministic and unit-testable: the same report always yields the + * same string, and every assertion in the tests runs against those strings, not the DOM. + * + * JSON is the faithful serialization: the report model verbatim, pretty-printed, so a reader can + * diff it against the wire body. Markdown is the human rendering of the same facts — the S3 + * outcome-first order, the engine's verbatim reasons and descriptions, and the canonical field + * path always beside the plain-language label (the machine-readable truth never dropped). + */ + +/** + * The report model as a stable, pretty-printed JSON document. The whole `ConversionReport` is the + * export — nothing is filtered, reordered, or paraphrased, so a loss can never be serialized away. + */ +export function reportToJson(report: ConversionReport): string { + return `${JSON.stringify(report, null, 2)}\n`; +} + +/** A markdown bullet for one row, shared by the four outcome sections. */ +function markdownRow(report: ConversionReport, row: ReturnType[number]): string { + switch (row.kind) { + case "preserved": { + const entry = row.entry as PreservedEntry; + const detail = entry.detail ? ` — \`${entry.detail}\`` : ""; + return `- ${labelForPath(entry.path).label} \`${entry.path}\`${detail}`; + } + case "removed": { + const entry = row.entry as RemovedEntry; + return `- ${labelForPath(entry.path).label} \`${entry.path}\`: ${entry.reason}${ + entry.detail ? ` (\`${entry.detail}\`)` : "" + }`; + } + case "assumed": { + const entry = row.entry as Assumption; + const scenario = labelForScenario(entry.scenario); + const supplied = report.supplied + .filter((s) => s.from_assumption === entry.id) + .map((s) => `\`${s.path}\``) + .join(", "); + const fields = supplied ? ` — supplied ${supplied}` : ""; + return `- **${entry.id}** — ${scenario.label} (\`${entry.choice}\`): ${entry.description}${fields}`; + } + case "warned": { + const entry = row.entry as ReportWarning; + return `- [\`${entry.code}\`] ${entry.message}`; + } + } +} + +/** + * The report as a Markdown document — outcome-first (Assumed → Lost → Warned → Kept), every row + * present, reasons and descriptions verbatim. The header states the source → target and the mode, + * the same facts the rendered panel leads with. + */ +export function reportToMarkdown(report: ConversionReport): string { + const rows = buildReportRows(report); + const lines: string[] = [ + "# Conversion Report", + "", + `**${report.source.filename}** (\`${report.source.format_id}\`) → **${report.target.filename}** (\`${report.target.format_id}\`)`, + "", + `Mode \`${report.mode}\` · Status \`${report.status}\` · Report \`${report.report_id}\``, + "", + ]; + for (const outcome of OUTCOME_ORDER) { + const sectionRows = rows.filter((row) => row.outcome === outcome); + if (sectionRows.length === 0) continue; + lines.push(`## ${OUTCOME_LABELS[outcome]} (${sectionRows.length})`); + lines.push(""); + for (const row of sectionRows) lines.push(markdownRow(report, row)); + lines.push(""); + } + return lines.join("\n"); +} + +/** + * Copy `text` to the clipboard — `navigator.clipboard` when available (secure contexts, granted + * permission), with a hidden-textarea `execCommand("copy")` fallback for non-secure contexts and + * older engines. Returns whether the copy is believed to have succeeded; callers surface a + * transient confirmation, never a modal error. + */ +export async function copyText(text: string): Promise { + if (typeof navigator !== "undefined" && typeof navigator.clipboard?.writeText === "function") { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + // Permission denied or a browser policy — fall through to the legacy path. + } + } + try { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + textarea.style.pointerEvents = "none"; + document.body.appendChild(textarea); + textarea.select(); + const ok = document.execCommand("copy"); + textarea.remove(); + return ok; + } catch { + return false; + } +} diff --git a/frontend/lib/report/grouping.test.ts b/frontend/lib/report/grouping.test.ts index 4fb2cde..5ea68b4 100644 --- a/frontend/lib/report/grouping.test.ts +++ b/frontend/lib/report/grouping.test.ts @@ -1,5 +1,22 @@ -import { describe, expect, it } from "vitest"; -import { groupByKey, shouldCollapse } from "./grouping"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ConversionReport } from "./types"; +import completedReport from "@/components/report/__fixtures__/conversion.completed.json"; +import { + buildReportRows, + canonicalCategory, + countByFilter, + DEFAULT_GROUPING_MODE, + filterRows, + groupRowsByCategory, + groupRowsByOutcome, + groupByKey, + loadGroupingMode, + outcomeOf, + OUTCOME_ORDER, + REPORT_GROUPING_STORAGE_KEY, + saveGroupingMode, + shouldCollapse, +} from "./grouping"; /** * The pure grouping logic behind S4's collapsible report groups. Two guarantees the panel leans on: @@ -60,3 +77,180 @@ describe("shouldCollapse", () => { expect(shouldCollapse([])).toBe(false); }); }); + +// --- Outcome-first report grouping (UI redesign S3, D245) -------------------------------------- + +const report = completedReport as unknown as ConversionReport; + +/** A report whose four arrays hold one entry each, for order/structure assertions. */ +const oneOfEach: ConversionReport = { + ...report, + preserved: [{ path: "atoms.positions", detail: null }], + removed: [{ path: "dynamics.forces", reason: "dropped", detail: null }], + supplied: [], + assumptions: [ + { + id: "A1", + scenario: "missing_lattice", + choice: "bounding_box", + parameters: { padding_ang: 5 }, + origin: "user" as const, + description: "A box was built.", + }, + ], + warnings: [{ code: "FORMAT_LOSSY_NOTE", message: "note", source: "capability" as const }], +}; + +describe("outcomeOf / canonicalCategory", () => { + it("maps every row kind to its outcome", () => { + expect(outcomeOf("preserved")).toBe("kept"); + expect(outcomeOf("removed")).toBe("lost"); + expect(outcomeOf("assumed")).toBe("assumed"); + expect(outcomeOf("warned")).toBe("warned"); + }); + + it("derives the canonical category from the top-level path segment", () => { + expect(canonicalCategory("dynamics.forces")).toBe("dynamics"); + expect(canonicalCategory("user_metadata.custom_per_frame['extxyz:config_type']")).toBe( + "user_metadata", + ); + expect(canonicalCategory("atoms")).toBe("atoms"); + }); +}); + +describe("buildReportRows (the no-loss view model)", () => { + it("yields exactly one row per preserved / removed / assumption / warning entry", () => { + const rows = buildReportRows(report); + expect(rows).toHaveLength( + report.preserved.length + report.removed.length + report.assumptions.length + report.warnings.length, + ); + // The kinds are the four arrays, counted correctly. + expect(rows.filter((r) => r.kind === "preserved")).toHaveLength(report.preserved.length); + expect(rows.filter((r) => r.kind === "removed")).toHaveLength(report.removed.length); + expect(rows.filter((r) => r.kind === "assumed")).toHaveLength(report.assumptions.length); + expect(rows.filter((r) => r.kind === "warned")).toHaveLength(report.warnings.length); + }); + + it("assigns each row the outcome its kind belongs to", () => { + for (const row of buildReportRows(report)) { + expect(row.outcome).toBe(outcomeOf(row.kind)); + } + }); + + it("buckets an assumption by the category of the field it supplied", () => { + // A1 (frame_selection) supplies nothing in the worked fixture; A2 (missing_lattice) supplies + // cell.lattice_vectors + cell.pbc — so A2 lands in `cell`, A1 in the recovery bucket. + const rows = buildReportRows(report); + const a2 = rows.find((r) => r.kind === "assumed" && (r.entry as { id: string }).id === "A2"); + expect(a2?.category).toBe("cell"); + const a1 = rows.find((r) => r.kind === "assumed" && (r.entry as { id: string }).id === "A1"); + expect(a1?.category).toBe("recovery"); + }); + + it("keeps one row per duplicate warning code (warnings are never collapsed in the model)", () => { + const flood: ConversionReport = { + ...report, + warnings: [ + { code: "FORMAT_LOSSY_NOTE", message: "one", source: "capability" as const }, + { code: "FORMAT_LOSSY_NOTE", message: "two", source: "capability" as const }, + ], + }; + const rows = buildReportRows(flood); + expect(rows.filter((r) => r.kind === "warned")).toHaveLength(2); + expect(new Set(rows.filter((r) => r.kind === "warned").map((r) => r.id)).size).toBe(2); + }); +}); + +describe("groupRowsByOutcome", () => { + it("orders sections Assumed → Lost → Warned → Kept, dropping empty buckets", () => { + const sections = groupRowsByOutcome(buildReportRows(oneOfEach)); + expect(sections.map((s) => s.key)).toEqual(["assumed", "lost", "warned", "kept"]); + expect(sections.map((s) => s.label)).toEqual(["Assumed", "Lost", "Warned", "Kept"]); + }); + + it("respects the fixed OUTCOME_ORDER regardless of report-array order", () => { + // The report model lists preserved first; outcome-first must still lead with Assumed. + const sections = groupRowsByOutcome(buildReportRows(oneOfEach)); + expect(sections[0].key).toBe(OUTCOME_ORDER[0]); + expect(OUTCOME_ORDER[0]).toBe("assumed"); + }); + + it("returns an empty list for an empty report", () => { + const empty: ConversionReport = { ...oneOfEach, preserved: [], removed: [], assumptions: [], warnings: [] }; + expect(groupRowsByOutcome(buildReportRows(empty))).toEqual([]); + }); +}); + +describe("groupRowsByCategory", () => { + it("groups rows by canonical category in first-seen order", () => { + const sections = groupRowsByCategory(buildReportRows(oneOfEach)); + // Row order in the model: preserved (atoms) → removed (dynamics) → assumed (cell via supplied)… + // here A1 supplies nothing → recovery. First-seen: atoms, dynamics, recovery, warnings. + expect(sections.map((s) => s.key)).toEqual(["atoms", "dynamics", "recovery", "warnings"]); + expect(sections.map((s) => s.label)).toEqual(["Atoms", "Dynamics", "Recovery", "Warnings"]); + }); + + it("mixes outcomes inside one category (the point of the view)", () => { + const mixed: ConversionReport = { + ...oneOfEach, + preserved: [{ path: "dynamics.forces", detail: null }], + removed: [{ path: "dynamics.velocities", reason: "dropped", detail: null }], + assumptions: [], + warnings: [], + supplied: [], + }; + const sections = groupRowsByCategory(buildReportRows(mixed)); + const dynamics = sections.find((s) => s.key === "dynamics")!; + expect(dynamics.rows.map((r) => r.kind).sort()).toEqual(["preserved", "removed"]); + }); +}); + +describe("filterRows / countByFilter", () => { + it("keeps everything for 'all' and narrows to one outcome otherwise", () => { + const rows = buildReportRows(oneOfEach); + expect(filterRows(rows, "all")).toHaveLength(4); + expect(filterRows(rows, "lost").map((r) => r.outcome)).toEqual(["lost"]); + expect(filterRows(rows, "assumed").map((r) => r.kind)).toEqual(["assumed"]); + }); + + it("counts every outcome for the live chips", () => { + const counts = countByFilter(buildReportRows(oneOfEach)); + expect(counts).toEqual({ all: 4, kept: 1, lost: 1, assumed: 1, warned: 1 }); + }); +}); + +describe("loadGroupingMode / saveGroupingMode (persisted choice)", () => { + afterEach(() => { + vi.unstubAllGlobals(); + window.localStorage.clear(); + }); + + it("defaults to outcome-first when nothing is stored", () => { + expect(loadGroupingMode()).toBe(DEFAULT_GROUPING_MODE); + expect(DEFAULT_GROUPING_MODE).toBe("outcome"); + }); + + it("round-trips a saved choice through localStorage", () => { + saveGroupingMode("category"); + expect(window.localStorage.getItem(REPORT_GROUPING_STORAGE_KEY)).toBe("category"); + expect(loadGroupingMode()).toBe("category"); + saveGroupingMode("outcome"); + expect(loadGroupingMode()).toBe("outcome"); + }); + + it("falls back to the default on a garbage value and on storage failure", () => { + window.localStorage.setItem(REPORT_GROUPING_STORAGE_KEY, "by-alphabet"); + expect(loadGroupingMode()).toBe("outcome"); + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new Error("privacy mode"); + }); + expect(loadGroupingMode()).toBe("outcome"); + }); + + it("never throws when saving is unavailable", () => { + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("quota"); + }); + expect(() => saveGroupingMode("category")).not.toThrow(); + }); +}); diff --git a/frontend/lib/report/grouping.ts b/frontend/lib/report/grouping.ts index 7c14eba..435a5b7 100644 --- a/frontend/lib/report/grouping.ts +++ b/frontend/lib/report/grouping.ts @@ -47,3 +47,276 @@ export function groupByKey(items: readonly T[], keyOf: (item: T) => string): export function shouldCollapse(groups: readonly Group[]): boolean { return groups.some((group) => group.items.length >= 2); } + +// --- Outcome-first report grouping (UI redesign S3, D245) --------------------------------------- +// +// The report's two orthogonal views: **outcome-first** (Assumed → Lost → Warned → Kept, the S3 +// default) and **canonical-category** (Atoms / Cell / Dynamics / … across outcomes). This module +// owns the pure structure — which outcome/category each row belongs to, in what order, and the +// persisted choice — while the panel owns rendering. The ordering rules are load-bearing: +// outcome order is fixed by OUTCOME_ORDER, category order is **first-seen across the report's own +// array order**, and nothing is ever dropped from the view model — the no-loss invariant is +// asserted on this module (a row exists for every preserved/removed/assumption/warning entry). + +import type { + Assumption, + ConversionReport, + PreservedEntry, + RemovedEntry, + ReportWarning, + SuppliedEntry, +} from "./types"; + +/** The four outcome buckets, in the S3 default render order. */ +export type Outcome = "kept" | "lost" | "assumed" | "warned"; + +/** Outcome-first section order: the losses and fabrications lead, the kept follows. */ +export const OUTCOME_ORDER: readonly Outcome[] = ["assumed", "lost", "warned", "kept"]; + +/** Plain-language section titles, e.g. the "Assumed (2)" heading. */ +export const OUTCOME_LABELS: Record = { + assumed: "Assumed", + lost: "Lost", + warned: "Warned", + kept: "Kept", +}; + +/** The row kinds the Conversion Report panel renders, and the outcome each belongs to. */ +export type ReportRowKind = "preserved" | "removed" | "assumed" | "warned"; + +/** The outcome a report row belongs to — kept/lost/assumed/warned (Part 4 §2 arrays). */ +export function outcomeOf(kind: ReportRowKind): Outcome { + switch (kind) { + case "preserved": + return "kept"; + case "removed": + return "lost"; + case "assumed": + return "assumed"; + case "warned": + return "warned"; + } +} + +/** + * The top-level canonical category a field path belongs to, e.g. `dynamics.forces` → `dynamics` + * (Part 2 §3). The same concept the S4 Removed-body collapses on; exported here so both the panel + * and the grouping logic share one definition. + */ +export function canonicalCategory(path: string): string { + const dot = path.indexOf("."); + return dot === -1 ? path : path.slice(0, dot); +} + +/** A canonical category token as a plain heading, e.g. `user_metadata` → "User metadata". */ +export function categoryLabel(category: string): string { + const words = category.split("_"); + const [first, ...rest] = words; + const head = first.charAt(0).toUpperCase() + first.slice(1); + return [head, ...rest].join(" "); +} + +/** + * One normalized row of the report — the unit the outcome/category views are built from. `entry` + * carries the report-model element itself, so the panel renders without a lookup (and row + * completeness is checkable here: one row per model entry, never fewer). Warnings are one row per + * warning (duplicate codes are distinct rows); supplied fields ride inside their assumption's row + * rather than as separate rows — the panel renders them there, so the view model matches what is + * rendered. + */ +export interface ReportRow { + /** Stable unique key for React. */ + id: string; + kind: ReportRowKind; + outcome: Outcome; + /** The canonical category token (a top-level path, or the `warnings`/`recovery` pseudo-categories + * for rows that have no canonical field of their own). */ + category: string; + /** The canonical path for field rows; `null` for warnings and pathless assumptions. */ + path: string | null; + entry: PreservedEntry | RemovedEntry | Assumption | ReportWarning | SuppliedEntry; +} + +/** Category tokens for rows the report does not attach a canonical path to (warnings, pathless + * assumptions) — they are still rows and still render, so they get honest buckets of their own. */ +export const PATHLESS_CATEGORY = { + warnings: "warnings", + recovery: "recovery", +} as const; + +/** + * Flatten a report into one {@link ReportRow} per preserved / removed / assumption / warning entry, + * in the report's own array order. The no-loss invariant lives here: every entry of those four + * arrays appears exactly once. (Supplied entries are not separate rows — the panel renders them + * inside their assumption's row, the Part 4 §2 one-to-many join, with the orphan-proof rendering.) + */ +export function buildReportRows(report: ConversionReport): ReportRow[] { + const rows: ReportRow[] = []; + for (const entry of report.preserved) { + rows.push({ + id: `preserved-${entry.path}`, + kind: "preserved", + outcome: "kept", + category: canonicalCategory(entry.path), + path: entry.path, + entry, + }); + } + for (const entry of report.removed) { + rows.push({ + id: `removed-${entry.path}`, + kind: "removed", + outcome: "lost", + category: canonicalCategory(entry.path), + path: entry.path, + entry, + }); + } + for (const entry of report.assumptions) { + // An assumption's category is the canonical category of the field it supplied, when it + // supplied one — otherwise the recovery bucket (a decision with no field of its own). + const suppliedPath = report.supplied.find((s) => s.from_assumption === entry.id)?.path; + rows.push({ + id: `assumed-${entry.id}`, + kind: "assumed", + outcome: "assumed", + category: suppliedPath ? canonicalCategory(suppliedPath) : PATHLESS_CATEGORY.recovery, + path: null, + entry, + }); + } + report.warnings.forEach((entry, i) => { + rows.push({ + id: `warned-${entry.code}-${i}`, + kind: "warned", + outcome: "warned", + category: PATHLESS_CATEGORY.warnings, + path: null, + entry, + }); + }); + // Orphaned supplied entries — a `from_assumption` that matches no assumption in this report (an + // engine bug, a hand-edited fixture) — are still rendered by the panel as an assumption row, so + // the view model carries them too: row completeness means *every rendered row has a model row*. + const assumptionIds = new Set(report.assumptions.map((a) => a.id)); + for (const entry of report.supplied) { + if (assumptionIds.has(entry.from_assumption)) continue; + rows.push({ + id: `assumed-orphaned-${entry.path}`, + kind: "assumed", + outcome: "assumed", + category: canonicalCategory(entry.path), + path: entry.path, + entry, + }); + } + return rows; +} + +/** A group of rows with a stable key + plain heading — one section in the rendered report. */ +export interface RowSection { + key: string; + label: string; + rows: ReportRow[]; +} + +/** Bucket rows by outcome, in the fixed {@link OUTCOME_ORDER}, dropping empty buckets. */ +export function groupRowsByOutcome(rows: readonly ReportRow[]): RowSection[] { + const byOutcome = new Map(); + for (const row of rows) { + const list = byOutcome.get(row.outcome) ?? []; + list.push(row); + byOutcome.set(row.outcome, list); + } + const sections: RowSection[] = []; + for (const outcome of OUTCOME_ORDER) { + const group = byOutcome.get(outcome); + if (group && group.length > 0) { + sections.push({ key: outcome, label: OUTCOME_LABELS[outcome], rows: group }); + } + } + return sections; +} + +/** Bucket rows by canonical category, in **first-seen** order across the row list (the report's own + * array order), dropping empty buckets. First-seen keeps the order stable and free of an + * opinionated sort — the rendered categories track the report, never a re-sorted view. */ +export function groupRowsByCategory(rows: readonly ReportRow[]): RowSection[] { + const byCategory = new Map(); + const order: string[] = []; + for (const row of rows) { + const list = byCategory.get(row.category); + if (list === undefined) { + byCategory.set(row.category, [row]); + order.push(row.category); + } else { + list.push(row); + } + } + return order.map((category) => ({ + key: category, + label: categoryLabel(category), + rows: byCategory.get(category) ?? [], + })); +} + +/** The filter chips' state: `all` or one outcome. */ +export type ReportFilter = "all" | Outcome; + +/** Filter chips with the four outcomes — in the fixed OUTCOME_ORDER, so the chips never reorder. */ +export const FILTERS: readonly ReportFilter[] = ["all", "assumed", "lost", "warned", "kept"]; + +/** Narrow a row list to one outcome (or keep everything). */ +export function filterRows(rows: readonly ReportRow[], filter: ReportFilter): ReportRow[] { + if (filter === "all") return [...rows]; + return rows.filter((row) => row.outcome === filter); +} + +/** Counts per filter — live chip labels: All (n) · Kept (n) · … */ +export function countByFilter(rows: readonly ReportRow[]): Record { + const counts: Record = { all: rows.length, assumed: 0, lost: 0, warned: 0, kept: 0 }; + for (const row of rows) counts[row.outcome] += 1; + return counts; +} + +// --- Persisted grouping choice (D-R6: QoL persistence is client-side) --------------------------- + +/** The two grouping modes of the report. */ +export type GroupingMode = "outcome" | "category"; + +/** Plain labels for the grouping toggle. */ +export const GROUPING_MODE_LABELS: Record = { + outcome: "Outcome", + category: "Category", +}; + +/** localStorage key (the `xtalate-` prefix convention; see the theme/notify providers). */ +export const REPORT_GROUPING_STORAGE_KEY = "xtalate-report-grouping"; + +/** The S3 default: outcome-first. */ +export const DEFAULT_GROUPING_MODE: GroupingMode = "outcome"; + +function isGroupingMode(value: string | null): value is GroupingMode { + return value === "outcome" || value === "category"; +} + +/** Read the persisted grouping mode; falls back to the default when unset or unavailable (SSR, + * privacy mode — localStorage can throw, same guard the theme provider uses). */ +export function loadGroupingMode(): GroupingMode { + if (typeof window === "undefined") return DEFAULT_GROUPING_MODE; + try { + const value = window.localStorage.getItem(REPORT_GROUPING_STORAGE_KEY); + return isGroupingMode(value) ? value : DEFAULT_GROUPING_MODE; + } catch { + return DEFAULT_GROUPING_MODE; + } +} + +/** Persist the grouping choice (best-effort; a storage failure never breaks rendering). */ +export function saveGroupingMode(mode: GroupingMode): void { + try { + window.localStorage.setItem(REPORT_GROUPING_STORAGE_KEY, mode); + } catch { + // No persistence available (privacy mode, SSR) — the choice just doesn't survive a reload. + } +} diff --git a/frontend/public/samples/diatomic.extxyz b/frontend/public/samples/diatomic.extxyz new file mode 100644 index 0000000..228c78a --- /dev/null +++ b/frontend/public/samples/diatomic.extxyz @@ -0,0 +1,4 @@ +2 +Lattice="6.0 0.0 0.0 0.0 6.0 0.0 0.0 0.0 6.0" Properties=species:S:1:pos:R:3:masses:R:1:forces:R:3:charge:R:1 pbc="T T T" energy=-14.25 config_type=diatomic +C 1.0 1.0 1.0 12.011 0.5 0.0 0.0 0.3 +O 2.125 1.0 1.0 15.999 -0.5 0.0 0.0 -0.3 diff --git a/frontend/public/samples/nacl.poscar b/frontend/public/samples/nacl.poscar new file mode 100644 index 0000000..758b8e5 --- /dev/null +++ b/frontend/public/samples/nacl.poscar @@ -0,0 +1,10 @@ +NaCl primitive test +1.0 + 5.640 0.000 0.000 + 0.000 5.640 0.000 + 0.000 0.000 5.640 +Na Cl +1 1 +Direct + 0.00 0.00 0.00 + 0.50 0.50 0.50 diff --git a/frontend/public/samples/water.xyz b/frontend/public/samples/water.xyz new file mode 100644 index 0000000..0896f0e --- /dev/null +++ b/frontend/public/samples/water.xyz @@ -0,0 +1,10 @@ +3 +frame 0 +O 0.000 0.000 0.000 +H 0.757 0.586 0.000 +H -0.757 0.586 0.000 +3 +frame 1 +O 0.000 0.000 0.010 +H 0.757 0.586 0.010 +H -0.757 0.586 0.010 diff --git a/frontend/tailwind.config.test.ts b/frontend/tailwind.config.test.ts new file mode 100644 index 0000000..9ba4e41 --- /dev/null +++ b/frontend/tailwind.config.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import config from "./tailwind.config"; + +/** + * Pins the monospace type token (UI redesign S1, D243): values, counts, and identifiers render in + * `font-mono` across the app, so the family is defined once in tailwind.config.ts and never as a + * bespoke stack at a call site. The stack is system families only — no web font (the self-host stays + * lean and the CSP stays simple; D-R7), and the list ends at the generic `monospace` fallback. + */ +describe("tailwind theme tokens", () => { + it("pins a monospace family with a system fallback and no web font", () => { + const mono = (config.theme?.extend?.fontFamily as Record)?.mono; + expect(mono).toBeDefined(); + expect(mono).toContain("ui-monospace"); + expect(mono.join(",")).toMatch(/monospace$/); // ends at the generic family + // No web-font import: the stack is system families only. + expect(mono.join(",")).not.toMatch(/http|url\(|\.woff/); + }); +}); diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts index b802616..e32cc41 100644 --- a/frontend/tailwind.config.ts +++ b/frontend/tailwind.config.ts @@ -23,6 +23,19 @@ const config: Config = { ], theme: { extend: { + fontFamily: { + // Monospace for every value, count, and identifier (UI redesign S1, D243). System stack + // only — no web font (keeps the self-host lean and the CSP simple). Components write + // `font-mono`; the DataValue primitive applies it to scientific readouts. + mono: [ + "ui-monospace", + "SF Mono", + "SFMono-Regular", + "Menlo", + "Consolas", + "monospace", + ], + }, colors: { // Semantic surface chrome — the neutral tokens every page is built from (globals.css). // Components write `bg-surface` / `text-body` / `border-line` instead of `bg-white` / @@ -48,6 +61,10 @@ const config: Config = { accent: "var(--accent)", // bg-accent ← forward-action accent (S3) "accent-fg": "var(--accent-fg)", "accent-hover": "var(--accent-hover)", + // The accent used as text — links, the active-tab label (UI redesign S1, D243). A separate + // token from the fill because teal-as-text on the dark surface needs a lighter tone + // (`--accent-text` in globals.css) than the button fill can give. + "accent-text": "var(--accent-text)", // text-accent-text ← links / active tab cb: { // Foreground / icon colors, one per §4 meaning. preserve: "var(--cb-preserve)", diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 6089fc4..c1f9e09 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -18,7 +18,12 @@ export default defineConfig({ environment: "jsdom", globals: true, setupFiles: ["./vitest.setup.ts"], - include: ["{app,components,lib}/**/*.{test,spec}.{ts,tsx}"], + include: [ + "{app,components,lib}/**/*.{test,spec}.{ts,tsx}", + // The tailwind token test lives beside the config it pins (UI redesign S1) — outside the + // component dirs above, so it is matched explicitly or it would silently never run in CI. + "tailwind.config.test.ts", + ], exclude: ["e2e/**", "node_modules/**"], }, });