diff --git a/.agents/skills/frontend/information-architecture/SKILL.md b/.agents/skills/frontend/information-architecture/SKILL.md index 0b9932b1..1f0d92de 100644 --- a/.agents/skills/frontend/information-architecture/SKILL.md +++ b/.agents/skills/frontend/information-architecture/SKILL.md @@ -25,9 +25,11 @@ Navigation maps 1:1 to domain objects. This is the target structure; if implemen └─ frames in the way: subsection INSIDE Schema; each row links out to /projects/:id/batches/:batchId, once per holding batch batches Batches (workflow) — omitted when the host wires no batch route - dataset Dataset — a section, not a buried route; three views as tabs + dataset Dataset — a section, not a buried route; four views as tabs (component state, Overview by default): Overview (counts, - per class) · Assets (the trunk) · Releases (the timeline) + per class) · Assets (the trunk) · Pre-processing (the + project's recipes, applied at export) · Releases (the + timeline) /projects/:id/ingest Ingest flow /projects/:id/batches/:batchId Batch workspace (gallery) /jobs/:jobId Annotator (full-bleed) @@ -58,6 +60,7 @@ Rules: - **A batch shows one job's frames at a time, and one job is the batch.** With exactly one job the gallery draws no accordion and no job-level bar: the job's controls — its door, Pre-label, and the assignee as an editable line — sit under the batch header, followed by that job's filter, order, strip and frames, and the batch bar is the page's one bar. From two jobs the gallery is an accordion with at most one panel open and every panel closable; the open panel's counts, timeline and frames are that job's, each header names its assignee, and the panel open on arrival is the first job with work left. A draft batch, having no jobs, keeps the flat grid. The rule is observability: a batch-wide grid beneath a per-job control puts two scopes for the same frames on one screen, and a one-row accordion is a choice with nothing to choose between. Nothing about it changes an address — the gallery route is unchanged and the open panel is not in the URL. - **A correction batch is reached from the batch that needs correcting**, never from a "new batch" form: the gallery header and the Batches row both offer it on a `completed` batch, capability-gated on `create_correction`. The annotator's read-only banner and the gallery's bulk bar *link* to it rather than duplicating it — creating a batch is a curation act, curation lives on the batch view, and a second place batches are made is a second place the rules can drift. - **Dataset is first-class.** It is the product's central object and must be reachable in ≤1 click from any project section. It is never gated behind, or discoverable only through, onboarding UI. Promotion success links onward to it; the gallery links to it once a batch is `completed`. +- **Pre-processing is a view of the Dataset, not a section and not a dialog-only control.** A recipe is a named project resource with no state, chosen at export beside the target model, so it lives where releases are exported from: the fourth view of the Dataset section, between Assets and Releases. Its views are component state like the section's other three — a view of the same resource, not a place — and `ProjectScreen` holds which one is open, because the editor on that view carries the page's filled control (*Save recipe*, or the invitation to write one) and the navigation column that steps back for it (`contentOwnsTheAction`) is drawn outside the section. The Export dialog's recipe control offers what this view made; it never makes one. - **The frames blocking a narrowing are a subsection of Schema, not a screen.** They are a *view of* the draft on the editor above them, the same relation version history has to the schema. A row links to **every** batch holding its frame rather than to one: an annotation carries an `asset_id` and no batch, so there is no single annotator address to prefer. The section is omitted entirely when the host wires no batch route, on the rule the Batches section already follows. It shows a window of the frames and states the total as text rather than a "see all": the destination that control would need is a project-wide asset view, and there is none — the count is a property of the proposal, not the length of a list somebody can open. - **"Schema history" is not a sibling section.** Version history lives inside the Schema section, below the editor and beside the `VersionNavigator` seam. The two overlap on purpose: the navigator is the *reader* (one version, with what it changed), the history is the *ledger* (every version at once). `?tab=versions` remains as a redirect; it does not appear in the navigation. - **The 4-step checklist is onboarding, not navigation.** It retires itself twice over: when the journey is finished (`hasReleases` makes `done` derivable) and when somebody dismisses it. Dismissal is **per project** and persisted — finishing one project does not teach you the pipeline for the next. It gates nothing and is never the sole path to a screen. diff --git a/docs/content/preprocessing.md b/docs/content/preprocessing.md index 568e2196..52cf620a 100644 --- a/docs/content/preprocessing.md +++ b/docs/content/preprocessing.md @@ -181,6 +181,7 @@ scaled to match, and the response is never cached: the spec is the request's own | | | | --- | --- | +| UI | The Dataset section's **Pre-processing** view: the project's recipes as a list, an editor of four steps (target model, resize, augmentation, preview), and *Save recipe*; the Export dialog's **Pre-processing recipe** control chooses one by name, `None` by default. See [ui.md](ui.md#the-dataset-its-releases-and-getting-the-data-out). | | REST | `POST`/`GET /projects/{id}/preprocessing-recipes`, `GET`/`PUT`/`DELETE /projects/{id}/preprocessing-recipes/{name}`, `POST /projects/{id}/preprocessing-preview`; `recipe=` on `POST /releases/{id}/export` and `GET /releases/{id}/export-compatibility`. The job carries the recipe as a snapshot. | | CLI | `visionset recipe create NAME -p P --spec FILE` or `--resize letterbox:640x640 --augment hflip,brightness_contrast --variants 2 --target yolo11`; `recipe list`, `show`, `update`, `delete`; `export --recipe NAME`. See [cli.md](cli.md#visionset-release-and-visionset-export). | | MCP | `create_preprocessing_recipe`, `list_preprocessing_recipes`, `delete_preprocessing_recipe` (only with `--allow-destructive`); `recipe` on `export_release` and `check_export`. See [mcp.md](mcp.md#datasets-releases-and-export). | diff --git a/docs/content/ui.md b/docs/content/ui.md index 7ad99385..a82c492c 100644 --- a/docs/content/ui.md +++ b/docs/content/ui.md @@ -221,14 +221,74 @@ timeline offers no edit and no delete, because there is no `ReleaseService.delet - only a project's own cascade removes one, and the manifest blob survives even that. -**The section is three views behind one tab row** — Overview (the counts and the per-class -table), Assets (the trunk), Releases (the timeline) — because each answers a different -question and stacked in one column each pushed the next below the fold. The row is the -product's one tab shape (`Tabs`' `line` variant on a full-width hairline); Assets and -Releases carry their counts on the label. The view is component state, Overview by -default; `DatasetScreen` takes `tab`/`onTabChange` like every other screen, so a host -that wants the view in a URL can wire it without a router inside `ui-core`. Publish -release stays on the section header, which every view shares. +**The section is four views behind one tab row** — Overview (the counts and the per-class +table), Assets (the trunk), Pre-processing (the project's recipes), Releases (the timeline) — +because each answers a different question and stacked in one column each pushed the next +below the fold. The row is the product's one tab shape (`Tabs`' `line` variant on a +full-width hairline); Assets, Pre-processing and Releases carry their counts on the label. +The view is component state, Overview by default; `DatasetScreen` takes `tab`/`onTabChange` +like every other screen, and `ProjectScreen` is the host that holds it, because the +Pre-processing view's editor owns the page's filled control and the navigation column +that steps back for it (`contentOwnsTheAction`) is drawn outside the section. Publish +release stays on the section header, which every view shares, and is `secondary` on every +view. + +**Pre-processing is a recipe, written once and chosen at export.** A recipe is a named +project resource — no state, no `allowed_actions`, every write unconditional +(`docs/content/preprocessing.md`) — so the view gates nothing. The left panel is the list, +one row per recipe with its one-line summary (`letterbox 640×640 · flip · 2 variants`) and +the target its hints came from as a `quiet` chip, a small `New`, and the line that says where +a recipe acts: *Applied at export. Choose a recipe in the Export dialog; exports without one +apply no transform.* With no recipe yet the panel is an invitation with one verb-first +action. The first recipe opens on arrival, so the editor is on screen whenever there is +something to edit. + +The editor is **four steps, always visible, each settled or not** — not the ingest flow's +one-step-at-a-time stepper, because a recipe's choices have no order between them. The +marker at each step's head is the ingest flow's own (`patterns/StepMarker.tsx`), so a +decided step reads the same on both screens. *Target model* is the Export dialog's grouped +picker (`patterns/ExportTargetSelect.tsx`), and choosing one preselects the resize from the +target's hints — `recommended_strategy` on the strategy chips, marked *suggested*, and +`recommended_size` in the width and height — while the second line names the family, the +tasks, the format it writes, and the geometries it carries with the active schema's class +count. The hints are read off the wire; nothing in the browser computes a size. **Changing +the target rewrites the suggestion only for fields nobody has typed in**, so a size chosen on +purpose survives a change of model (`recipeDraft.ts`, `touched`). *Resize* leads with the +ambient line a trainer earns when `trainer_resizes` is set (*YOLO11 letterboxes to 640 on +its own. Pre-resizing shrinks the archive and speeds up loading.*), then Letterbox / Stretch +/ None, the size, the pad value for a letterbox, and the *Geometry exact* line. +*Augmentation* is three checkboxes — the amount beside brightness and contrast, the variants +per image beneath — and the line that says variants are written for the train fold only, so +an export with augmentation needs a release published with a split. Ticking the first +augmentation sets one variant and unticking the last clears it, which are the spec's own two +cross-field rules; every other bound of the request body is restated beside its field +(*Width is a whole number from 32 to 8192.*) so the reason a step is unsettled is next to +it and the footer carries the first one beside a shut *Save recipe*. Those are the body's +shape, not a kernel rule the client is mirroring: the server still answers 422 to a body the +form did not build. + +*Preview* renders three sample assets through `POST /projects/{id}/preprocessing-preview`, +the export's own kernel path over a one-asset manifest: the first three train-fold members +of the newest release with a split — variants are the train fold's — or the project's first +three assets when no release has one. Three columns: the asset as it is, after the resize +step alone, and the first augmented variant; a stage the recipe does not have says so +(*No resize step*) rather than repeating the original. Each cell is one request keyed on +the spec it renders, and the spec settles for 400 ms before a cell asks, so typing `640` +does not pay for `6` and `64`. The cell is the member dialog's own picture-with-labels +mechanism (`patterns/StaticAnnotationOverlay.tsx`): the rendered image, and the response's +placed annotations drawn over it in the rendering's own pixel frame with the schema's class +colours — a box after a letterbox sits where the export writes it. A rendering without a +size shows the picture alone. A refused rendering — `UNSUPPORTED_MEDIA` for an asset that is +not a JPEG, PNG or WebP — is prose in the cell. + +Save is a create for a new draft and a whole-replace `PUT` at the recipe's current name for +an open one; Discard puts the stored spec back; `PREPROCESSING_RECIPE_NAME_TAKEN` and every +other refusal render through the vocabulary. The Export dialog's second control, +**Pre-processing recipe**, lists the same recipes with the same one-line summary under +*None*, and sends the chosen name as `recipe=`; a project with none says so in a line rather +than offering a picker with one row. The two 409s an export can answer — +`AUGMENTATION_REQUIRES_SPLIT`, `PREPROCESSING_STEP_UNSUPPORTED_GEOMETRY` — are refusals, never +consents: prose with the remedy, and no checkbox. **The trunk is a grid of pictures, looked at one at a time.** Every promoted asset is a tile — the frame number over the picture and how many labels it carries underneath, both read diff --git a/docs/content/ui/navigation.md b/docs/content/ui/navigation.md index acf0c7df..5e60172e 100644 --- a/docs/content/ui/navigation.md +++ b/docs/content/ui/navigation.md @@ -63,7 +63,16 @@ without knowing the URL scheme. bottom. Every section's own header uses `secondary` actions — Ingest beside Annotate on Overview and Batches, Publish on Dataset, Save version on Schema. On a sub-view (the gallery, the ingest flow) the column draws no filled control at all: that page owns its - dominant action, and a second one beside it would be two answers to "what now?". + dominant action, and a second one beside it would be two answers to "what now?". A view + *inside* a section can own it too: while the Dataset's Pre-processing view is showing, its + editor's *Save recipe* — or, with no recipe yet, the invitation to write one — is the page's + one filled control, and the column's Annotate or Ingest steps back to `secondary` for as + long as that view is open, the way it does for the Overview's first-run invitation. +- **The Dataset's views are component state.** Overview, Assets, Pre-processing and Releases + are four lenses on one section, chosen with the product's one tab row; none is a path + segment or a query parameter, on the rule below that not everything selectable is a place. + The open view is held by the project screen rather than by the section, because the + navigation column has to know when the Pre-processing editor holds the filled control. - **The project's identity is an eyebrow above the content, not part of the column.** One line above a section's `h1`, at every width: the project's name and the active-version chip, omitted when there is no schema (`patterns/ProjectEyebrow.tsx`). It is identity and diff --git a/frontend/ui-core/src/data/refusals.ts b/frontend/ui-core/src/data/refusals.ts index 5410e4fc..77c1b4c6 100644 --- a/frontend/ui-core/src/data/refusals.ts +++ b/frontend/ui-core/src/data/refusals.ts @@ -160,6 +160,16 @@ export const REFUSAL_PROSE: Record = { RELEASE_CONTENT_WOULD_VIOLATE_SCHEMA: "The active schema no longer describes some annotations in this dataset.", + // Pre-processing recipes. The two 409s arrive on an export, where the + // remedy is a different release or a different recipe; the kernel's own + // sentences for the name pair carry a project id, which a person cannot use. + PREPROCESSING_RECIPE_NOT_FOUND: "That recipe is no longer on record.", + PREPROCESSING_RECIPE_NAME_TAKEN: "A recipe with that name already exists in this project.", + AUGMENTATION_REQUIRES_SPLIT: + "This recipe augments, and the release was published without a split — publish one with folds, or export with a recipe that only resizes.", + PREPROCESSING_STEP_UNSUPPORTED_GEOMETRY: + "A step in this recipe cannot move a shape this release holds — quarter turns cannot follow a polyline. Take the step out, or export without the recipe.", + // Inference connections. INFERENCE_CONNECTION_NOT_FOUND: "That model connection is no longer on record.", INFERENCE_CONNECTION_NOT_DOWNLOADABLE: diff --git a/frontend/ui-core/src/index.ts b/frontend/ui-core/src/index.ts index 1b950a72..341130f3 100644 --- a/frontend/ui-core/src/index.ts +++ b/frontend/ui-core/src/index.ts @@ -451,3 +451,45 @@ export { } from "./patterns/ExportTargetSelect.js"; export { describeTargetDrops } from "./data/refusals.js"; export { StaticAnnotationOverlay, type StaticAnnotationOverlayProps } from "./patterns/StaticAnnotationOverlay.js"; +export { DATASET_TABS, resolveDatasetTab, type DatasetTab } from "./screens/DatasetScreen.js"; +export { PreprocessingTab, type PreprocessingTabProps } from "./screens/PreprocessingTab.js"; +export { + AUGMENT_OPS, + applyTargetHints, + canonicalSpec, + describeRecipeSpec, + draftFromSpec, + draftToSpec, + EMPTY_DRAFT, + sameSpec, + touch, + type AugmentOp, + type RecipeDraft, + type RecipeSpec, + type ResizeChoice, + type SpecOutcome, + type SpecProblem, +} from "./screens/recipeDraft.js"; +export { + recipeKeys, + useCreatePreprocessingRecipe, + useDeletePreprocessingRecipe, + usePreprocessingPreview, + usePreprocessingRecipes, + useReleaseAssignment, + useUpdatePreprocessingRecipe, + type PreprocessingPreview, + type PreprocessingRecipe, + type PreprocessingRecipePage, + type PreviewAnnotation, + type SplitAssignment, +} from "./screens/queries.js"; +export { RecipeList, type RecipeListItem, type RecipeListProps } from "./patterns/RecipeList.js"; +export { + RecipeEditor, + targetCarries, + targetSubtitle, + trainerResizesLine, + type RecipeEditorProps, +} from "./patterns/RecipeEditor.js"; +export { StepMarker, type StepMarkerProps, type StepState } from "./patterns/StepMarker.js"; diff --git a/frontend/ui-core/src/patterns/RecipeEditor.tsx b/frontend/ui-core/src/patterns/RecipeEditor.tsx new file mode 100644 index 00000000..029d028d --- /dev/null +++ b/frontend/ui-core/src/patterns/RecipeEditor.tsx @@ -0,0 +1,452 @@ +/** + * The recipe editor: four steps, always visible, each settled or not. + * + * Not a wizard. The ingest flow opens one step at a time because its steps are + * ordered by the domain — a run cannot start before a source exists. A recipe + * has no such order: the target, the resize and the augmentation are three + * independent choices and the preview is a view of all three, so every step + * stays live and the marker at its head only says whether it has been decided. + * + * The hints are the target's, read off the wire and preselected; nothing here + * computes a size. Every reason a control is off — a step's problems, the + * unsaved state — is written beside it (`DESIGN.md` principle 9). + * + * Data-only: the draft comes in, edits go out, and the preview cells are the + * screen's to render. The name field is the one control the mockup did not + * draw and the resource cannot do without. + */ + +import type { JSX, ReactNode } from "react"; + +import { GEOMETRY_LABELS, GEOMETRY_PLURALS } from "../data/geometryCategory"; +import { refusalProse } from "../data/refusals"; +import { cn } from "../lib/cn"; +import { formatCount } from "../lib/format"; +import { Button } from "../primitives/Button"; +import { FieldError, FieldHint, Input, Label } from "../primitives/Input"; +import type { ExportTarget } from "../screens/queries"; +import { + AMOUNT_MAX, + AUGMENT_OPS, + applyTargetHints, + draftToSpec, + touch, + VARIANTS_MAX, + type AugmentOp, + type RecipeDraft, + type ResizeChoice, +} from "../screens/recipeDraft"; +import { ExportTargetSelect } from "./ExportTargetSelect"; +import { StepMarker, type StepState } from "./StepMarker"; + +const STRATEGIES: readonly { readonly value: ResizeChoice; readonly label: string }[] = [ + { value: "letterbox", label: "Letterbox" }, + { value: "stretch", label: "Stretch" }, + { value: "none", label: "None" }, +]; + +export interface RecipeEditorProps { + readonly draft: RecipeDraft; + readonly onDraftChange: (draft: RecipeDraft) => void; + readonly targets: readonly ExportTarget[]; + /** Rendered in the target step's place when the catalog could not be offered. */ + readonly targetsNotice?: ReactNode; + /** How many classes the active schema declares; absent while unknown. */ + readonly classCount?: number; + /** The preview step's body. */ + readonly preview: ReactNode; + /** Whether the preview step has something rendered, so its marker can settle. */ + readonly previewReady: boolean; + readonly dirty: boolean; + readonly saving: boolean; + /** The last save's refusal, rendered through the vocabulary. */ + readonly saveError?: unknown; + readonly onSave: () => void; + readonly onDiscard: () => void; +} + +export function RecipeEditor({ + draft, + onDraftChange, + targets, + targetsNotice, + classCount, + preview, + previewReady, + dirty, + saving, + saveError, + onSave, + onDiscard, +}: RecipeEditorProps): JSX.Element { + const target = targets.find((one) => one.name === draft.target); + const outcome = draftToSpec(draft); + const problems = outcome.kind === "problems" ? outcome.problems : []; + const resizeProblems = problems.filter((one) => one.step === "resize"); + const augmentProblems = problems.filter((one) => one.step === "augment"); + const nameProblem = draft.name.trim() === "" ? "A recipe needs a name." : null; + const canSave = dirty && problems.length === 0 && nameProblem === null && !saving; + const footerNote = saving + ? "Saving…" + : !dirty + ? "No unsaved changes" + : nameProblem ?? (problems.length > 0 ? problems[0]?.text : "Unsaved changes"); + + const resizeState: StepState = + draft.strategy === "none" || resizeProblems.length === 0 ? "complete" : "upcoming"; + const augmentState: StepState = augmentProblems.length === 0 ? "complete" : "upcoming"; + + function update(patch: Partial): void { + onDraftChange({ ...draft, ...patch }); + } + + function toggleOp(op: AugmentOp, on: boolean): void { + const ops = on ? [...draft.ops.filter((one) => one !== op), op] : draft.ops.filter((one) => one !== op); + // Turning the first augmentation on needs a variant to make; turning the + // last one off leaves nothing to make. Both are the spec's own rule, and + // moving the number with the tick spares a person the refusal. + const variants = + ops.length > 0 && draft.ops.length === 0 && Number(draft.variants) < 1 + ? "1" + : ops.length === 0 + ? "0" + : draft.variants; + update({ ops, variants }); + } + + return ( +
+
+ + update({ name: event.target.value })} + placeholder="yolo-640" + className="max-w-xs" + /> + + Lowercase letters, digits, dots, hyphens and underscores; unique in this project. This is + what an export names. + +
+ +
    + + {targetsNotice ?? ( +
    + + + onDraftChange(applyTargetHints(draft, targets.find((one) => one.name === name))) + } + placeholder="Choose the model this recipe is written for" + /> + {target !== undefined && ( + <> + {targetSubtitle(target)} + + {targetCarries(target, classCount)} + + + )} + {target === undefined && ( + + Optional: a recipe applies to any export. Choosing a model preselects its + recommended size and strategy. + + )} +
    + )} +
    + + +
    + {target !== undefined && target.hints.trainer_resizes && ( +

    + {trainerResizesLine(target)} +

    + )} +
    + {STRATEGIES.map((choice) => { + const active = draft.strategy === choice.value; + const suggested = target?.hints.recommended_strategy === choice.value; + return ( + + ); + })} +
    + {draft.strategy !== "none" && ( +
    + onDraftChange(touch({ ...draft, width: value }, "width"))} + /> + + onDraftChange(touch({ ...draft, height: value }, "height"))} + /> + {draft.strategy === "letterbox" && ( + update({ padValue: value })} + /> + )} +
    + )} + {resizeProblems.map((problem) => ( + + {problem.text} + + ))} + {draft.strategy !== "none" && resizeProblems.length === 0 && ( +

    + Geometry exact: boxes, polygons and polylines are scaled with the image + {draft.strategy === "letterbox" ? ", then offset into the padding" : ""}. +

    + )} +
    +
    + + +
    + {target !== undefined && target.hints.augmentation_common && ( +

    + Augmentation is the usual practice when training {target.label}. +

    + )} +
    + {AUGMENT_OPS.map((one) => { + const on = draft.ops.includes(one.op); + return ( +
    + + {one.op === "brightness_contrast" && on && ( + + )} +
    + ); + })} +
    +
    + + update({ variants: event.target.value })} + className="h-7 w-20" + /> +
    + {augmentProblems.map((problem) => ( + + {problem.text} + + ))} +

    + Variants are written for the train fold only, so an export with augmentation needs + a release published with a split; a release without one is refused at export. +

    +
    +
    + + + 3 sample assets · seeded + + } + last + > + {preview} + +
+ + {saveError !== undefined && saveError !== null && ( + {refusalProse(saveError)} + )} + +
+ + {footerNote} + + + +
+
+ ); +} + +function Step({ + index, + title, + optional = false, + state, + aside, + last = false, + testId, + children, +}: { + readonly index: number; + readonly title: string; + readonly optional?: boolean; + readonly state: StepState; + readonly aside?: ReactNode; + readonly last?: boolean; + readonly testId: string; + readonly children: ReactNode; +}): JSX.Element { + return ( +
  • + +
    +
    +

    + {title} + {optional && · optional} +

    + {aside !== undefined && {aside}} +
    + {children} +
    +
  • + ); +} + +function SizeField({ + id, + label, + value, + onChange, +}: { + readonly id: string; + readonly label: string; + readonly value: string; + readonly onChange: (value: string) => void; +}): JSX.Element { + return ( +
    + + onChange(event.target.value)} + className="w-24" + /> +
    + ); +} + +/** `Ultralytics YOLO · detect, segment · data.yaml (ultralytics)` */ +export function targetSubtitle(target: ExportTarget): string { + const family = FAMILY_WORDS[target.family] ?? target.family; + const tasks = target.tasks.length === 0 ? "no task vocabulary" : target.tasks.join(", "); + return `${family} · ${tasks} · writes ${target.format}`; +} + +const FAMILY_WORDS: Record = { + "ultralytics-yolo": "Ultralytics YOLO", + "community-yolo": "Community YOLO", + other: "Other format", +}; + +/** `Carries boxes and polygons · 12 classes` */ +export function targetCarries(target: ExportTarget, classCount: number | undefined): string { + const words = target.geometries.map( + (one) => (GEOMETRY_PLURALS as Record)[one] ?? (GEOMETRY_LABELS as Record)[one] ?? one, + ); + const carries = + words.length === 0 + ? "Carries no shape this build draws" + : `Carries ${words.length === 1 ? words[0] : `${words.slice(0, -1).join(", ")} and ${words[words.length - 1]}`}`; + if (classCount === undefined) return carries; + return `${carries} · ${formatCount(classCount)} ${classCount === 1 ? "class" : "classes"}`; +} + +/** `YOLO11 letterboxes to 640 on its own. Pre-resizing shrinks the archive and speeds up loading.` */ +export function trainerResizesLine(target: ExportTarget): string { + const size = target.hints.recommended_size; + const strategy = target.hints.recommended_strategy; + const verb = strategy === "letterbox" ? "letterboxes" : strategy === "stretch" ? "stretches" : "resizes"; + const to = size == null ? "" : ` to ${size[0] === size[1] ? size[0] : `${size[0]}×${size[1]}`}`; + return `${target.label} ${verb}${to} on its own. Pre-resizing shrinks the archive and speeds up loading.`; +} diff --git a/frontend/ui-core/src/patterns/RecipeList.tsx b/frontend/ui-core/src/patterns/RecipeList.tsx new file mode 100644 index 00000000..d0792d5e --- /dev/null +++ b/frontend/ui-core/src/patterns/RecipeList.tsx @@ -0,0 +1,95 @@ +/** + * A project's pre-processing recipes as a dense master list. + * + * The `ClassListRow` shape — the whole row is one ` + +
      + {recipes.map((recipe) => { + const chosen = recipe.name === selected; + return ( +
    • + +
    • + ); + })} +
    +

    + Applied at export. Choose a recipe in the Export dialog; exports without one apply no + transform. +

    + + ); +} diff --git a/frontend/ui-core/src/patterns/StepMarker.tsx b/frontend/ui-core/src/patterns/StepMarker.tsx new file mode 100644 index 00000000..c2c32b39 --- /dev/null +++ b/frontend/ui-core/src/patterns/StepMarker.tsx @@ -0,0 +1,44 @@ +/** + * The numeral at the head of one step of a vertical flow, and the rail under it. + * + * One spelling for two flows: the ingest stepper, where exactly one step is + * active and the rest are done or ahead, and the recipe editor, where every + * step stays live and the marker only says whether it is settled. `complete` is + * the check in a muted circle, `active` the filled numeral, `upcoming` the + * outlined one — so a step that has been decided reads the same on both screens. + */ + +import { Check } from "lucide-react"; +import type { JSX } from "react"; + +import { cn } from "../lib/cn"; + +export type StepState = "upcoming" | "active" | "complete"; + +export interface StepMarkerProps { + readonly index: number; + readonly state: StepState; + /** Draw the rail down to the next marker; off for the last step. */ + readonly rail?: boolean; +} + +export function StepMarker({ index, state, rail = true }: StepMarkerProps): JSX.Element { + return ( +
    + + {rail && + ); +} diff --git a/frontend/ui-core/src/screens/DatasetScreen.tsx b/frontend/ui-core/src/screens/DatasetScreen.tsx index 8ffee6eb..b668bb14 100644 --- a/frontend/ui-core/src/screens/DatasetScreen.tsx +++ b/frontend/ui-core/src/screens/DatasetScreen.tsx @@ -75,9 +75,12 @@ import { import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../primitives/Table"; import { EmptyState, ErrorState } from "../patterns/AsyncStates"; import { ExportTargetSelect } from "../patterns/ExportTargetSelect"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../primitives/Select"; import { AssetThumbnail } from "./AssetThumbnail"; import { DatasetAssetDialog, trunkAssetLabel } from "./DatasetAssetDialog"; import { saveBlob } from "./download"; +import { PreprocessingTab } from "./PreprocessingTab"; +import { describeRecipeSpec } from "./recipeDraft"; import { TRUNK_PAGE_SIZE, useBackgroundJob, @@ -88,6 +91,7 @@ import { useExportTargets, useFormats, useJobArtifact, + usePreprocessingRecipes, useProjectDataset, usePublishRelease, useReleases, @@ -114,16 +118,23 @@ const CONTENT_VIOLATES_SCHEMA = "RELEASE_CONTENT_WOULD_VIOLATE_SCHEMA"; * route is a redirect, so nothing can reach this screen standalone. */ /** - * The dataset's three views: what it holds in numbers, what it holds in pictures, - * what has been frozen out of it. Tabs, because each answers a different question - * and the page was all three at once — counts, a grid and a timeline in one - * column, each pushing the next below the fold. + * The dataset's four views: what it holds in numbers, what it holds in pictures, + * what an export does to the pictures, what has been frozen out of it. Tabs, + * because each answers a different question and the page was all of them at + * once — counts, a grid and a timeline in one column, each pushing the next + * below the fold. Pre-processing sits before Releases because a recipe is + * chosen at export, which is a release's own control. */ -export type DatasetTab = "overview" | "assets" | "releases"; +export type DatasetTab = "overview" | "assets" | "preprocessing" | "releases"; -const DATASET_TABS: readonly DatasetTab[] = ["overview", "assets", "releases"]; +export const DATASET_TABS: readonly DatasetTab[] = ["overview", "assets", "preprocessing", "releases"]; const DEFAULT_DATASET_TAB: DatasetTab = "overview"; +/** The `DatasetTab` a raw value names, or the default: the host's normaliser. */ +export function resolveDatasetTab(tab: string | undefined): DatasetTab { + return DATASET_TABS.find((one) => one === tab) ?? DEFAULT_DATASET_TAB; +} + export interface DatasetScreenProps { readonly projectId: string; /** Which view to open on. Absent or unrecognised opens on Overview. */ @@ -136,8 +147,9 @@ export function DatasetScreen({ projectId, tab, onTabChange }: DatasetScreenProp const dataset = useProjectDataset(projectId); const stats = useDatasetStats(dataset.data?.id); const releases = useReleases(dataset.data?.id); + const recipes = usePreprocessingRecipes(projectId); const [publishing, setPublishing] = useState(false); - const current = DATASET_TABS.find((one) => one === tab) ?? DEFAULT_DATASET_TAB; + const current = resolveDatasetTab(tab); return (
    @@ -167,8 +179,7 @@ export function DatasetScreen({ projectId, tab, onTabChange }: DatasetScreenProp ? { defaultValue: current } : { value: current, - onValueChange: (next: string) => - onTabChange(DATASET_TABS.find((one) => one === next) ?? DEFAULT_DATASET_TAB), + onValueChange: (next: string) => onTabChange(resolveDatasetTab(next)), })} data-testid="dataset-tabs" > @@ -191,6 +202,10 @@ export function DatasetScreen({ projectId, tab, onTabChange }: DatasetScreenProp Assets {stats.data !== undefined && {stats.data.asset_count}} + + Pre-processing + {recipes.data !== undefined && {recipes.data.total}} + Releases {releases.data !== undefined && {releases.data.total}} @@ -252,6 +267,10 @@ export function DatasetScreen({ projectId, tab, onTabChange }: DatasetScreenProp + + + + b.created_at.localeCompare(a.created_at)) .map((release) => ( - + ))}
    )} @@ -576,7 +595,13 @@ function Stat({ label, value }: { readonly label: string; readonly value: number ); } -function ReleaseCard({ release }: { readonly release: Release }): JSX.Element { +function ReleaseCard({ + projectId, + release, +}: { + readonly projectId: string; + readonly release: Release; +}): JSX.Element { const verify = useVerifyRelease(release.id); const manifest = useDownloadManifest(release.id); const [exporting, setExporting] = useState(false); @@ -668,7 +693,13 @@ function ReleaseCard({ release }: { readonly release: Release }): JSX.Element { )} - setExporting(false)} /> + setExporting(false)} + /> ); } @@ -918,12 +949,20 @@ const JOB_STATE_VARIANT: Record = { }; +/** + * A recipe name is a slug, so a tilde can never be one — which is what makes it + * safe as the "no recipe" option's value. `Select` refuses an empty string. + */ +const NO_RECIPE = "~none"; + function ExportDialog({ + projectId, releaseId, tag, open, onClose, }: { + readonly projectId: string; readonly releaseId: string; readonly tag: string; readonly open: boolean; @@ -931,9 +970,11 @@ function ExportDialog({ }): JSX.Element { const targets = useExportTargets(); const formats = useFormats(); + const recipes = usePreprocessingRecipes(projectId); const exportRelease = useExportRelease(releaseId); const artifact = useJobArtifact(); const [target, setTarget] = useState(""); + const [recipe, setRecipe] = useState(NO_RECIPE); const [consented, setConsented] = useState(false); // The job this dialog is watching. Null until a launch is accepted, and null // again once the archive has been saved — a finished download is not something @@ -966,7 +1007,11 @@ function ExportDialog({ setSaved(false); setOutcome(null); exportRelease.mutate( - { target, ...(allowLossy ? { allowLossy: true } : {}) }, + { + target, + ...(recipe === NO_RECIPE ? {} : { recipe }), + ...(allowLossy ? { allowLossy: true } : {}), + }, { onSuccess: (queued) => setJobId(queued.id) }, ); } @@ -1064,6 +1109,40 @@ function ExportDialog({ )}
    +
    + + {/* The same three answers as the target picker: a recipes read that + failed is said, a project with none is said, and the picker is + for when there is something to choose. `None` is always an + option, because an export without a recipe applies no transform. */} + {recipes.isError ? ( + + {refusalProse(recipes.error)} The export runs without a recipe. + + ) : recipes.data !== undefined && recipes.data.items.length === 0 ? ( + + None yet — a recipe is written on the Pre-processing view. This export applies no + transform. + + ) : ( + + )} +
    + {/* The status itself. The word is on the badge rather than only in its colour, and the sentences below stay: a badge is the glance, prose is the answer. */} diff --git a/frontend/ui-core/src/screens/IngestScreen.tsx b/frontend/ui-core/src/screens/IngestScreen.tsx index 0d874742..1360ef0b 100644 --- a/frontend/ui-core/src/screens/IngestScreen.tsx +++ b/frontend/ui-core/src/screens/IngestScreen.tsx @@ -120,7 +120,7 @@ * run still say which batch holds what it managed to read. */ -import { ArrowLeft, Check, Film, FolderOpen, Image, RefreshCw, RotateCw, TriangleAlert, Upload, X } from "lucide-react"; +import { ArrowLeft, Film, FolderOpen, Image, RefreshCw, RotateCw, TriangleAlert, Upload, X } from "lucide-react"; import { useQueryClient } from "@tanstack/react-query"; import { useEffect, @@ -138,6 +138,7 @@ import { cn } from "../lib/cn"; import { formatBytes, formatCount } from "../lib/format"; import { BackLink } from "../patterns/BackLink"; import { parentLabel } from "../patterns/parentLabel"; +import { StepMarker } from "../patterns/StepMarker"; import { Alert, Badge } from "../primitives/Badge"; import type { BadgeTone } from "./batchState"; import { Button } from "../primitives/Button"; @@ -740,22 +741,7 @@ function Step({ data-state={state} aria-current={state === "active" ? "step" : undefined} > -
    - - {!last && +
    diff --git a/frontend/ui-core/src/screens/PreprocessingTab.tsx b/frontend/ui-core/src/screens/PreprocessingTab.tsx new file mode 100644 index 00000000..65d30825 --- /dev/null +++ b/frontend/ui-core/src/screens/PreprocessingTab.tsx @@ -0,0 +1,503 @@ +/** + * The Pre-processing view of the Dataset section: the project's recipes, and + * the editor for the one that is open. + * + * ## A recipe is a value, and the editor is a draft of one + * + * There is no state on a recipe and no `allowed_actions`; every write is + * unconditional, so this screen gates nothing. What it holds is a draft — the + * stored recipe as typed fields — and `dirty` is the draft disagreeing with the + * spec it opened from. Save is a create for a new draft and a whole-replace + * `PUT` for an open one; Discard puts the stored spec back. + * + * ## The preview is the export's own path + * + * `POST /projects/{id}/preprocessing-preview` renders one asset through a spec + * on the same kernel path an export takes, so what the cells show is what the + * archive would hold. Three sample assets, and the choice is the release's: the + * first three train-fold members of the newest release with a split, because + * variants are written for the train fold only; without one, the first three + * assets of the project. The columns are the stages — the asset as it is, after + * the resize step alone, and the first augmented variant — each a request + * keyed on the spec it renders, so a keystroke re-renders only what it changed. + * + * `PreviewCell` hands the rendered image and its placed annotations to the + * static overlay pattern, so a label is drawn where the export would write it. + */ + +import { Image as ImageIcon, Plus } from "lucide-react"; +import { useEffect, useState, type JSX } from "react"; + +import { asApiError } from "../data/errors"; +import { refusalProse } from "../data/refusals"; +import type { WireAnnotation } from "../annotator/jobQueries"; +import { parseLabelClass, type LabelClass } from "@visionset/annotator"; +import { EmptyState, ErrorState, LoadingState } from "../patterns/AsyncStates"; +import { RecipeEditor } from "../patterns/RecipeEditor"; +import { RecipeList } from "../patterns/RecipeList"; +import { StaticAnnotationOverlay } from "../patterns/StaticAnnotationOverlay"; +import { Button } from "../primitives/Button"; +import { + useActiveSchema, + useCreatePreprocessingRecipe, + useExportTargets, + usePreprocessingPreview, + usePreprocessingRecipes, + useProjectAssets, + useReleaseAssignment, + useReleases, + useUpdatePreprocessingRecipe, + type ExportTarget, + type PreprocessingRecipe, + type Release, +} from "./queries"; +import { + canonicalSpec, + draftFromSpec, + draftToSpec, + EMPTY_DRAFT, + sameSpec, + type RecipeDraft, + type RecipeSpec, +} from "./recipeDraft"; + +export interface PreprocessingTabProps { + readonly projectId: string; + /** Absent while the project's dataset has not been read; the preview then samples the project. */ + readonly datasetId: string | undefined; +} + +/** A draft, and the spec it opened from: `null` for a recipe being written. */ +interface Editing { + readonly name: string | null; + readonly draft: RecipeDraft; + readonly stored: RecipeSpec | null; +} + +const NO_TRANSFORM: RecipeSpec = { target: null, steps: [], variants_per_asset: 0 }; + +export function PreprocessingTab({ projectId, datasetId }: PreprocessingTabProps): JSX.Element { + const recipes = usePreprocessingRecipes(projectId); + const targets = useExportTargets(); + const schema = useActiveSchema(projectId); + const create = useCreatePreprocessingRecipe(projectId); + const update = useUpdatePreprocessingRecipe(projectId); + const [editing, setEditing] = useState(null); + + const items = recipes.data?.items ?? []; + const catalog = targets.data?.items ?? []; + // A schema-less project answers 404 here, which is a real answer: the + // overlay colours from the engine's own palette and nothing is reported. + const classes = schema.data?.classes.map(parseLabelClass); + const classCount = classes?.length; + + // The first recipe opens on arrival, so the editor is on screen whenever + // there is something to edit; with none, the invitation stands in its place. + const first = items[0]; + useEffect(() => { + if (editing === null && first !== undefined) setEditing(open(first)); + }, [editing, first]); + + function open(recipe: PreprocessingRecipe): Editing { + return { name: recipe.name, draft: draftFromSpec(recipe.name, recipe.spec), stored: recipe.spec }; + } + + function startNew(): void { + setEditing({ name: null, draft: EMPTY_DRAFT, stored: null }); + create.reset(); + update.reset(); + } + + function labelFor(target: string): string { + return catalog.find((one) => one.name === target)?.label ?? target; + } + + if (recipes.isError) { + const failure = asApiError(recipes.error); + return ( +
    + void recipes.refetch()} + /> +
    + ); + } + if (recipes.data === undefined) return ; + + if (items.length === 0 && editing === null) { + return ( +
    + +
    + ); + } + + return ( +
    + { + const recipe = items.find((one) => one.name === name); + if (recipe !== undefined) { + setEditing(open(recipe)); + create.reset(); + update.reset(); + } + }} + onNew={startNew} + labelFor={labelFor} + /> + {editing !== null && ( + setEditing({ ...editing, draft })} + catalog={catalog} + targetsNotice={ + targets.isError ? ( +
    + void targets.refetch()} + /> +
    + ) : targets.data !== undefined && catalog.length === 0 ? ( +

    + No exporters are installed on this server, so there is no model to write for. The + recipe still applies to any export. +

    + ) : undefined + } + classCount={classCount} + classes={classes} + saving={create.isPending || update.isPending} + saveError={create.error ?? update.error ?? undefined} + onSave={(name, spec) => { + if (editing.name === null) { + create.mutate( + { name, spec }, + { onSuccess: (saved) => setEditing(open(saved)) }, + ); + } else { + update.mutate( + { current: editing.name, name, spec }, + { onSuccess: (saved) => setEditing(open(saved)) }, + ); + } + }} + onDiscard={() => { + const recipe = editing.name === null ? undefined : items.find((one) => one.name === editing.name); + create.reset(); + update.reset(); + if (recipe === undefined) { + setEditing(first === undefined ? null : open(first)); + } else { + setEditing(open(recipe)); + } + }} + /> + )} +
    + ); +} + +function Editor({ + projectId, + datasetId, + editing, + onDraftChange, + catalog, + targetsNotice, + classCount, + classes, + saving, + saveError, + onSave, + onDiscard, +}: { + readonly projectId: string; + readonly datasetId: string | undefined; + readonly editing: Editing; + readonly onDraftChange: (draft: RecipeDraft) => void; + readonly catalog: readonly ExportTarget[]; + readonly targetsNotice: JSX.Element | undefined; + readonly classCount: number | undefined; + readonly classes: readonly LabelClass[] | undefined; + readonly saving: boolean; + readonly saveError: unknown; + readonly onSave: (name: string, spec: RecipeSpec) => void; + readonly onDiscard: () => void; +}): JSX.Element { + const outcome = draftToSpec(editing.draft); + const spec = outcome.kind === "spec" ? outcome.spec : null; + const dirty = + editing.stored === null + ? true + : editing.draft.name !== editing.name || spec === null || !sameSpec(spec, editing.stored); + const [ready, setReady] = useState(false); + + return ( + + } + previewReady={ready} + dirty={dirty} + saving={saving} + saveError={saveError} + onSave={() => { + if (spec !== null) onSave(editing.draft.name.trim(), spec); + }} + onDiscard={onDiscard} + /> + ); +} + +/** The asset ids the preview renders: the train fold's first three, or the project's. */ +function useSampleAssets(projectId: string, datasetId: string | undefined): readonly string[] | undefined { + const releases = useReleases(datasetId); + const newest = newestRelease(releases.data?.items); + const withSplit = newest !== undefined && newest.split !== null && newest.split !== undefined; + const assignment = useReleaseAssignment(withSplit ? newest.id : undefined); + const project = useProjectAssets(projectId, 3); + if (datasetId !== undefined && releases.data === undefined && !releases.isError) return undefined; + if (withSplit) { + if (assignment.data === undefined && !assignment.isError) return undefined; + const train = assignment.data?.train.slice(0, 3) ?? []; + if (train.length > 0) return train; + } + if (project.data === undefined) return undefined; + return project.data.items.slice(0, 3).map((asset) => asset.id); +} + +function newestRelease(items: readonly Release[] | undefined): Release | undefined { + if (items === undefined || items.length === 0) return undefined; + return [...items].sort((a, b) => b.created_at.localeCompare(a.created_at))[0]; +} + +/** + * A spec settled long enough to render. The preview is a request per cell, and + * a person typing `640` should not pay for `6` and `64` on the way. + */ +function useSettledSpec(spec: RecipeSpec | null): RecipeSpec | null { + const key = spec === null ? null : canonicalSpec(spec); + const [settled, setSettled] = useState<{ key: string | null; spec: RecipeSpec | null }>({ key, spec }); + useEffect(() => { + if (settled.key === key) return; + const timer = setTimeout(() => setSettled({ key, spec }), 400); + return () => clearTimeout(timer); + }, [key, spec, settled.key]); + return settled.spec; +} + +function PreviewGrid({ + projectId, + datasetId, + spec, + classes, + onReady, +}: { + readonly projectId: string; + readonly datasetId: string | undefined; + readonly spec: RecipeSpec | null; + readonly classes: readonly LabelClass[] | undefined; + readonly onReady: (ready: boolean) => void; +}): JSX.Element { + const samples = useSampleAssets(projectId, datasetId); + const settled = useSettledSpec(spec); + const resizeOnly: RecipeSpec | null = + settled === null + ? null + : { target: settled.target ?? null, steps: settled.steps.filter((step) => step.kind === "resize"), variants_per_asset: 0 }; + const hasResize = resizeOnly !== null && resizeOnly.steps.length > 0; + const hasAugment = settled !== null && settled.variants_per_asset > 0; + + if (samples === undefined) return ; + if (samples.length === 0) { + return ( +

    + Nothing to preview yet — the project has no images. The recipe can still be saved. +

    + ); + } + + return ( +
    +
    + + Original + After resize + After augmentation +
    + {samples.map((assetId, index) => ( +
    + + {assetId.slice(0, 8)} + + + {hasResize ? ( + + ) : ( + + )} + {hasAugment ? ( + + ) : ( + + )} +
    + ))} +
    + ); +} + +/** + * One asset through one spec. The rendered image with its labels drawn where + * the export would write them, or a placeholder while it has not arrived, or + * the refusal when it will not — `UNSUPPORTED_MEDIA` for an asset that is not + * a JPEG, PNG or WebP. + * + * The response's `annotations` are already placed on the transformed image and + * carry no asset or job of their own, so each is completed with the response's + * asset before the overlay's wire mirror takes it. A rendering without a size — + * an asset that never recorded one and no resize step to decide it — has no + * frame to place a coordinate in, so it shows the picture alone. + */ +function PreviewCell({ + projectId, + assetId, + variant, + spec, + classes, + testId, + onReady, +}: { + readonly projectId: string; + readonly assetId: string; + readonly variant: number; + readonly spec: RecipeSpec | null; + readonly classes: readonly LabelClass[] | undefined; + readonly testId: string; + readonly onReady?: (ready: boolean) => void; +}): JSX.Element { + const preview = usePreprocessingPreview( + projectId, + assetId, + variant, + spec, + spec === null ? "" : canonicalSpec(spec), + ); + const ready = preview.data !== undefined; + useEffect(() => { + onReady?.(ready); + }, [onReady, ready]); + + if (preview.isError) { + return ( +
    + {refusalProse(preview.error)} +
    + ); + } + if (preview.data === undefined) return ; + const rendered = preview.data; + const src = `data:${rendered.media_type};base64,${rendered.image_base64}`; + const alt = `Sample ${rendered.asset_id.slice(0, 8)}, variant ${rendered.variant}`; + const placed: readonly WireAnnotation[] = rendered.annotations.map((one) => ({ + ...one, + asset_id: rendered.asset_id, + job_id: null, + })); + return ( +
    + {rendered.width === null || rendered.height === null ? ( + {alt} + ) : ( + + )} +
    + ); +} + +function Placeholder({ + text, + testId, + pending = false, +}: { + readonly text?: string; + readonly testId: string; + readonly pending?: boolean; +}): JSX.Element { + return ( +
    +
    + ); +} diff --git a/frontend/ui-core/src/screens/ProjectScreen.tsx b/frontend/ui-core/src/screens/ProjectScreen.tsx index 124d53ed..fa9073b7 100644 --- a/frontend/ui-core/src/screens/ProjectScreen.tsx +++ b/frontend/ui-core/src/screens/ProjectScreen.tsx @@ -81,7 +81,7 @@ import { DEFAULT_PROJECT_SECTION, PROJECT_SECTIONS, type ProjectSection } from " import { SectionHeader } from "../patterns/SectionHeader"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../primitives/Table"; import { BatchesScreen } from "./BatchesScreen"; -import { DatasetScreen } from "./DatasetScreen"; +import { DatasetScreen, type DatasetTab } from "./DatasetScreen"; import { AssetThumbnail } from "./AssetThumbnail"; import { firstRunInvitation, invitationOwnsTheAction, OverviewPanel } from "./OverviewPanel"; import { openForAnnotation, ProjectFrame } from "./ProjectFrame"; @@ -441,6 +441,13 @@ export function ProjectScreen({ const readiness = useProjectReadiness(projectId); const overviewOwnsTheAction = current === "overview" && readiness !== null && invitationOwnsTheAction(firstRunInvitation(readiness)); + // The Dataset's view is held here rather than in `DatasetScreen`, because the + // Pre-processing view's editor carries the page's filled control — Save + // recipe, or the invitation to write one — and the navigation column that has + // to step back for it is drawn outside the section. Component state, like the + // section's other views: a view of the same resource, not a place. + const [datasetTab, setDatasetTab] = useState("overview"); + const preprocessingOwnsTheAction = current === "dataset" && datasetTab === "preprocessing"; const holdsAnnotate = openForAnnotation(batches.data?.items).length > 0 && onOpenBatch !== undefined; // Ingest is reachable from every section: in the navigation's slot while @@ -460,7 +467,7 @@ export function ProjectScreen({ ...(onOpenBatch === undefined ? {} : { onOpenBatch }), ...(onOpenJob === undefined ? {} : { onOpenJob }), ...(onIngest === undefined ? {} : { onIngest }), - contentOwnsTheAction: overviewOwnsTheAction, + contentOwnsTheAction: overviewOwnsTheAction || preprocessingOwnsTheAction, }} {...(onDeleted === undefined ? {} : { onDeleted })} > @@ -474,6 +481,8 @@ export function ProjectScreen({ go={go} onIngest={onIngest} onOpenBatch={onOpenBatch} + datasetTab={datasetTab} + onDatasetTabChange={setDatasetTab} schema={{ draft: schemaDraft, onDraftChange: setSchemaDraft, @@ -522,6 +531,8 @@ function Section({ go, onIngest, onOpenBatch, + datasetTab, + onDatasetTabChange, schema, }: { readonly current: ProjectTab; @@ -534,6 +545,8 @@ function Section({ readonly go: (tab: ProjectTab) => void; readonly onIngest: (() => void) | undefined; readonly onOpenBatch: ((batchId: string) => void) | undefined; + readonly datasetTab: DatasetTab; + readonly onDatasetTabChange: (tab: DatasetTab) => void; readonly schema: { readonly draft: SchemaDraft | null; readonly onDraftChange: (draft: SchemaDraft | null) => void; @@ -600,7 +613,7 @@ function Section({ /> ); case "dataset": - return ; + return ; } } diff --git a/frontend/ui-core/src/screens/dataset.test.tsx b/frontend/ui-core/src/screens/dataset.test.tsx index 1b60bd41..5c3fb99a 100644 --- a/frontend/ui-core/src/screens/dataset.test.tsx +++ b/frontend/ui-core/src/screens/dataset.test.tsx @@ -250,8 +250,28 @@ function baseline(): void { }, }); on("GET", /\/export-targets$/, { status: 200, body: { items: TARGETS, total: TARGETS.length } }); + on("GET", /\/preprocessing-recipes$/, { status: 200, body: { items: RECIPES, total: RECIPES.length } }); } +/** One recipe of the project: what the export dialog offers beside the target. */ +const RECIPES = [ + { + id: "88888888-8888-4888-8888-888888888888", + project_id: PROJECT, + name: "yolo-640", + spec: { + target: "yolo11", + steps: [ + { kind: "resize", strategy: "letterbox", width: 640, height: 640, pad_value: 114 }, + { kind: "augment", op: "hflip", amount: 0.2 }, + ], + variants_per_asset: 1, + }, + created_at: "2026-08-20T10:00:00.000000Z", + updated_at: "2026-08-20T10:00:00.000000Z", + }, +]; + describe("the dataset view", () => { it("reports annotations and assets per class, because the two are different questions", async () => { baseline(); @@ -1167,3 +1187,107 @@ describe("looking at a member", () => { expect(screen.getByTestId("preview-metadata").textContent).toContain("—"); }); }); + +describe("export, and the recipe beside the target", () => { + const launch = () => sent.find((r) => r.method === "POST" && r.url.includes("/export")); + + it("sends no recipe by default: an export without one applies no transform", async () => { + baseline(); + handlers.push((request) => + request.method === "POST" && request.url.includes("/export") ? { status: 200, body: {} } : undefined, + ); + render(mount()); + await userEvent.click(await screen.findByTestId("export-v1")); + expect(screen.getByTestId("export-recipe").textContent).toContain("None"); + await userEvent.click(screen.getByTestId("export-target")); + await userEvent.click(await screen.findByRole("option", { name: /dummy/ })); + await userEvent.click(screen.getByTestId("export-submit")); + + await waitFor(() => expect(launch()).not.toBeUndefined()); + expect(new URL(launch()?.url ?? "").searchParams.get("recipe")).toBeNull(); + }); + + it("lists the project's recipes with a one-line summary, and sends the chosen one by name", async () => { + baseline(); + handlers.push((request) => + request.method === "POST" && request.url.includes("/export") ? { status: 200, body: {} } : undefined, + ); + render(mount()); + await userEvent.click(await screen.findByTestId("export-v1")); + await userEvent.click(screen.getByTestId("export-target")); + await userEvent.click(await screen.findByRole("option", { name: /dummy/ })); + await userEvent.click(screen.getByTestId("export-recipe")); + const option = await screen.findByRole("option", { name: /yolo-640/ }); + expect(option.textContent).toContain("letterbox 640×640 · flip · 1 variant"); + await userEvent.click(option); + await userEvent.click(screen.getByTestId("export-submit")); + + await waitFor(() => expect(launch()).not.toBeUndefined()); + const query = new URL(launch()?.url ?? "").searchParams; + expect(query.get("recipe")).toBe("yolo-640"); + expect(query.get("target")).toBe("dummy"); + }); + + it("says when the project has no recipes, instead of offering a picker with one row", async () => { + on("GET", /\/preprocessing-recipes$/, { status: 200, body: { items: [], total: 0 } }); + baseline(); + render(mount()); + await userEvent.click(await screen.findByTestId("export-v1")); + + expect((await screen.findByTestId("export-recipes-empty")).textContent).toContain("Pre-processing view"); + expect(screen.queryByTestId("export-recipe")).toBeNull(); + }); + + it("renders the split refusal as prose, with the remedy", async () => { + baseline(); + handlers.push((request) => + request.method === "POST" && request.url.includes("/export") + ? { + status: 409, + body: { + code: "AUGMENTATION_REQUIRES_SPLIT", + message: "the recipe asks for 1 augmented variant(s) per asset and this release has no split recipe", + }, + } + : undefined, + ); + render(mount()); + await userEvent.click(await screen.findByTestId("export-v1")); + await userEvent.click(screen.getByTestId("export-target")); + await userEvent.click(await screen.findByRole("option", { name: /dummy/ })); + await userEvent.click(screen.getByTestId("export-recipe")); + await userEvent.click(await screen.findByRole("option", { name: /yolo-640/ })); + await userEvent.click(screen.getByTestId("export-submit")); + + const said = (await screen.findByTestId("export-error")).textContent ?? ""; + expect(said).toContain("published without a split"); + expect(said).not.toContain("AUGMENTATION_REQUIRES_SPLIT"); + // A refusal, never a consent: no checkbox appears for it. + expect(screen.queryByTestId("lossy-consent")).toBeNull(); + }); + + it("renders the geometry refusal as prose", async () => { + baseline(); + handlers.push((request) => + request.method === "POST" && request.url.includes("/export") + ? { + status: 409, + body: { + code: "PREPROCESSING_STEP_UNSUPPORTED_GEOMETRY", + message: "the 'rot90' step cannot transform a polyline (asset 1234 carries one)", + detail: { step: "rot90", geometry: "polyline", asset_id: "1234" }, + }, + } + : undefined, + ); + render(mount()); + await userEvent.click(await screen.findByTestId("export-v1")); + await userEvent.click(screen.getByTestId("export-target")); + await userEvent.click(await screen.findByRole("option", { name: /dummy/ })); + await userEvent.click(screen.getByTestId("export-submit")); + + const said = (await screen.findByTestId("export-error")).textContent ?? ""; + expect(said).toContain("cannot follow a polyline"); + expect(said).not.toContain("PREPROCESSING_STEP_UNSUPPORTED_GEOMETRY"); + }); +}); diff --git a/frontend/ui-core/src/screens/preprocessing.test.tsx b/frontend/ui-core/src/screens/preprocessing.test.tsx new file mode 100644 index 00000000..033f055c --- /dev/null +++ b/frontend/ui-core/src/screens/preprocessing.test.tsx @@ -0,0 +1,626 @@ +/** + * The Pre-processing view: recipes as a project resource, the editor's four + * steps, and the preview through the export's own path. + * + * Two halves. `recipeDraft` is pure and holds the rules a form restates — the + * hints preselecting only untouched fields, the body's bounds, the cross-field + * constraints — so those are asserted without a DOM. The screen tests drive + * the tab through a stubbed wire and assert what a person sees and what the + * server was sent. + */ + +import { QueryClient } from "@tanstack/react-query"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { JSX, ReactNode } from "react"; + +import { ApiProvider } from "../data/ApiProvider"; +import { writeToken } from "../data/session"; +import { DatasetScreen } from "./DatasetScreen"; +import { PreprocessingTab } from "./PreprocessingTab"; +import type { ExportTarget } from "./queries"; +import { + applyTargetHints, + describeRecipeSpec, + draftFromSpec, + draftToSpec, + EMPTY_DRAFT, + touch, + type RecipeSpec, +} from "./recipeDraft"; + +const API = "http://visionset.test"; +const PROJECT = "11111111-1111-4111-8111-111111111111"; +const DATASET = "22222222-2222-4222-8222-222222222222"; +const RELEASE = "33333333-3333-4333-8333-333333333333"; +const ASSET_A = "aaaaaaaa-0000-4000-8000-000000000001"; +const ASSET_B = "aaaaaaaa-0000-4000-8000-000000000002"; +const ASSET_C = "aaaaaaaa-0000-4000-8000-000000000003"; +const ASSET_D = "aaaaaaaa-0000-4000-8000-000000000004"; + +const YOLO11: ExportTarget = { + name: "yolo11", + label: "YOLO11", + family: "ultralytics-yolo", + format: "ultralytics", + tasks: ["detect", "segment"], + geometries: ["bbox", "polygon"], + hints: { + recommended_size: [640, 640], + recommended_strategy: "letterbox", + trainer_resizes: true, + augmentation_common: true, + }, +}; +const WIDE: ExportTarget = { + ...YOLO11, + name: "wide", + label: "Wide", + hints: { ...YOLO11.hints, recommended_size: [1280, 720], recommended_strategy: "stretch" }, +}; +const DUMMY: ExportTarget = { + name: "dummy", + label: "dummy", + family: "other", + format: "dummy", + tasks: [], + geometries: ["bbox"], + hints: { + recommended_size: null, + recommended_strategy: null, + trainer_resizes: true, + augmentation_common: false, + }, +}; +const TARGETS = [YOLO11, WIDE, DUMMY]; + +const LETTERBOX: RecipeSpec = { + target: "yolo11", + steps: [ + { kind: "resize", strategy: "letterbox", width: 640, height: 640, pad_value: 114 }, + { kind: "augment", op: "hflip", amount: 0.2 }, + { kind: "augment", op: "brightness_contrast", amount: 0.3 }, + ], + variants_per_asset: 2, +}; + +function recipeRow(name: string, spec: RecipeSpec) { + return { + id: `cccccccc-0000-4000-8000-${name.padStart(12, "0")}`, + project_id: PROJECT, + name, + spec, + created_at: "2026-08-20T10:00:00.000000Z", + updated_at: "2026-08-20T10:00:00.000000Z", + }; +} + +function assetRow(id: string) { + return { + id, + project_id: PROJECT, + modality: "image", + content_hash: id.slice(-12).padStart(64, "0"), + width: 640, + height: 480, + format: "png", + source_id: null, + frame_index: null, + frame_timestamp: null, + thumbnail_hash: null, + ingested_at: null, + }; +} + +/** One rendering, carrying one placed box so the overlay has something to draw. */ +function previewRow(assetId: string, variant: number) { + return { + asset_id: assetId, + variant, + width: 512, + height: 384, + annotations: [ + { + id: variant === 0 ? "box-1" : `box-1-aug${variant}`, + label_class: "car", + schema_version: 1, + geometry: { type: "bbox", x: 10, y: 20, width: 100, height: 50 }, + attributes: {}, + provenance: "human", + model_ref: null, + confidence: null, + }, + ], + image_base64: "aGVsbG8=", + media_type: "image/png", + }; +} + +// --- the pure half --------------------------------------------------------------- + +describe("recipeDraft", () => { + it("preselects the strategy and size from the target's hints", () => { + const draft = applyTargetHints(EMPTY_DRAFT, YOLO11); + expect(draft.target).toBe("yolo11"); + expect(draft.strategy).toBe("letterbox"); + expect(draft.width).toBe("640"); + expect(draft.height).toBe("640"); + }); + + it("rewrites the suggestion only for fields nobody has touched", () => { + const typed = touch({ ...applyTargetHints(EMPTY_DRAFT, YOLO11), width: "800" }, "width"); + const moved = applyTargetHints(typed, WIDE); + // The width was chosen; the height and the strategy were not. + expect(moved.width).toBe("800"); + expect(moved.height).toBe("720"); + expect(moved.strategy).toBe("stretch"); + }); + + it("leaves untouched fields alone when the target has no recommendation", () => { + const from = applyTargetHints(EMPTY_DRAFT, YOLO11); + const moved = applyTargetHints(from, DUMMY); + expect(moved.target).toBe("dummy"); + expect(moved.width).toBe("640"); + expect(moved.strategy).toBe("letterbox"); + }); + + it("round-trips a stored spec through the draft", () => { + const outcome = draftToSpec(draftFromSpec("yolo-640", LETTERBOX)); + expect(outcome.kind).toBe("spec"); + if (outcome.kind === "spec") expect(outcome.spec).toEqual(LETTERBOX); + }); + + it("restates the body's bounds beside the field, with the rule named", () => { + const base = applyTargetHints(EMPTY_DRAFT, YOLO11); + const tooSmall = draftToSpec({ ...base, width: "16" }); + expect(tooSmall.kind).toBe("problems"); + if (tooSmall.kind === "problems") { + expect(tooSmall.problems.map((one) => one.step)).toEqual(["resize"]); + expect(tooSmall.problems[0]?.text).toContain("32 to 8192"); + } + const pad = draftToSpec({ ...base, padValue: "300" }); + expect(pad.kind === "problems" && pad.problems[0]?.text).toContain("0 to 255"); + // Stretch has no padding, so a pad value out of range is not a problem there. + expect(draftToSpec({ ...base, strategy: "stretch", padValue: "300" }).kind).toBe("spec"); + }); + + it("holds the cross-field rule in both directions", () => { + const base = applyTargetHints(EMPTY_DRAFT, YOLO11); + const opsWithoutVariants = draftToSpec({ ...base, ops: ["hflip"], variants: "0" }); + expect(opsWithoutVariants.kind === "problems" && opsWithoutVariants.problems[0]?.text).toContain( + "from 1 to 8", + ); + const variantsWithoutOps = draftToSpec({ ...base, ops: [], variants: "2" }); + expect(variantsWithoutOps.kind === "problems" && variantsWithoutOps.problems[0]?.text).toContain( + "at least one augmentation", + ); + const amount = draftToSpec({ ...base, ops: ["brightness_contrast"], variants: "1", amount: "0.7" }); + expect(amount.kind === "problems" && amount.problems[0]?.text).toContain("at most 0.5"); + }); + + it("says a spec in one line", () => { + expect(describeRecipeSpec(LETTERBOX)).toBe("letterbox 640×640 · flip, brightness/contrast · 2 variants"); + expect(describeRecipeSpec({ target: null, steps: [], variants_per_asset: 0 })).toBe("No transform"); + }); +}); + +// --- the screen ------------------------------------------------------------------ + +type Answer = { status: number; body?: unknown }; +let handlers: ((request: Request) => Answer | undefined)[] = []; +const sent: Request[] = []; +const bodies = new Map(); + +beforeEach(() => { + handlers = []; + sent.length = 0; + bodies.clear(); + writeToken("a-token"); + vi.stubGlobal("fetch", async (request: Request) => { + sent.push(request); + if (request.method !== "GET") bodies.set(request, await request.clone().text()); + for (const handler of handlers) { + const answer = handler(request); + if (answer !== undefined) { + return new Response(answer.status === 204 ? null : JSON.stringify(answer.body ?? null), { + status: answer.status, + headers: { "content-type": "application/json" }, + }); + } + } + return new Response(JSON.stringify({ code: "NO_STUB", message: request.url }), { + status: 500, + headers: { "content-type": "application/json" }, + }); + }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + globalThis.sessionStorage.clear(); +}); + +function on(method: string, pattern: RegExp, answer: Answer): void { + handlers.push((request) => + request.method === method && pattern.test(new URL(request.url).pathname) ? answer : undefined, + ); +} + +function mount(node: ReactNode): JSX.Element { + return ( + + {node} + + ); +} + +function pathOf(request: Request): string { + return new URL(request.url).pathname; +} + +function bodyOf(request: Request): Record { + return JSON.parse(bodies.get(request) ?? "{}") as Record; +} + +/** Every read the tab makes, with the project holding four assets and no release. */ +function baseline(recipes: ReturnType[] = []): void { + on("GET", /\/preprocessing-recipes$/, { status: 200, body: { items: recipes, total: recipes.length } }); + on("GET", /\/export-targets$/, { status: 200, body: { items: TARGETS, total: TARGETS.length } }); + on("GET", /\/schema$/, { + status: 200, + body: { + project_id: PROJECT, + version: 1, + classes: [ + { name: "car", geometries: ["bbox"], color: null, attributes: [] }, + { name: "lane", geometries: ["polygon"], color: null, attributes: [] }, + ], + }, + }); + on("GET", /\/dataset$/, { + status: 200, + body: { id: DATASET, project_id: PROJECT, name: "highway", description: null }, + }); + on("GET", /\/releases$/, { status: 200, body: { items: [], total: 0 } }); + on("GET", /\/projects\/[^/]+\/assets$/, { + status: 200, + body: { items: [ASSET_A, ASSET_B, ASSET_C, ASSET_D].map(assetRow), total: 4 }, + }); + handlers.push((request) => { + if (request.method !== "POST" || !pathOf(request).endsWith("/preprocessing-preview")) return undefined; + const body = bodyOf(request); + return { status: 200, body: previewRow(String(body["asset_id"]), Number(body["variant"])) }; + }); +} + +/** Choose a target through the grouped picker. */ +async function chooseTarget(label: RegExp): Promise { + await userEvent.click(screen.getByTestId("recipe-target")); + await userEvent.click(await screen.findByRole("option", { name: label })); +} + +describe("the dataset's tab roster", () => { + it("carries Pre-processing between Assets and Releases, with the recipe count", async () => { + baseline([recipeRow("yolo-640", LETTERBOX)]); + on("GET", /\/stats$/, { + status: 200, + body: { dataset_id: DATASET, asset_count: 0, annotated_asset_count: 0, annotation_count: 0, classes: [] }, + }); + render(mount()); + + const tabs = await screen.findByTestId("dataset-tabs"); + const labels = within(tabs) + .getAllByRole("tab") + .map((tab) => tab.getAttribute("data-testid")); + expect(labels).toEqual([ + "dataset-tab-overview", + "dataset-tab-assets", + "dataset-tab-preprocessing", + "dataset-tab-releases", + ]); + await waitFor(() => + expect(screen.getByTestId("dataset-tab-preprocessing").textContent).toContain("1"), + ); + }); + + it("opens on the view the host names, and hands a normalised one back", async () => { + baseline([recipeRow("yolo-640", LETTERBOX)]); + const changed = vi.fn(); + render(mount()); + + expect(await screen.findByTestId("preprocessing-tab")).not.toBeNull(); + await userEvent.click(screen.getByTestId("dataset-tab-assets")); + expect(changed).toHaveBeenCalledWith("assets"); + }); +}); + +describe("the recipe list", () => { + it("lists every recipe with its summary and the target's label, and opens the first", async () => { + baseline([recipeRow("yolo-640", LETTERBOX), recipeRow("plain", { target: null, steps: [], variants_per_asset: 0 })]); + render(mount()); + + const row = await screen.findByTestId("recipe-yolo-640"); + expect(row.textContent).toContain("letterbox 640×640"); + expect(row.textContent).toContain("YOLO11"); + expect(row.getAttribute("aria-current")).toBe("true"); + expect(screen.getByTestId("recipe-plain").textContent).toContain("No transform"); + expect(screen.getByTestId("recipe-list-note").textContent).toContain("Applied at export"); + // The open recipe's fields, as stored. + expect(screen.getByTestId("recipe-name")).toHaveProperty("value", "yolo-640"); + expect(screen.getByTestId("resize-width")).toHaveProperty("value", "640"); + expect(screen.getByTestId("augment-hflip")).toHaveProperty("checked", true); + expect(screen.getByTestId("augment-variants")).toHaveProperty("value", "2"); + }); + + it("is an invitation with one verb-first action while there are none", async () => { + baseline(); + render(mount()); + + const empty = await screen.findByTestId("recipes-empty"); + expect(within(empty).getAllByRole("button")).toHaveLength(1); + await userEvent.click(within(empty).getByTestId("recipe-new")); + expect(await screen.findByTestId("recipe-editor")).not.toBeNull(); + expect(screen.getByTestId("recipe-footer-note").textContent).toContain("A recipe needs a name"); + }); + + it("says when the recipes could not be read, rather than showing an empty list", async () => { + on("GET", /\/preprocessing-recipes$/, { status: 503, body: { code: "WORKSPACE_BUSY", message: "Busy." } }); + baseline(); + render(mount()); + + const said = (await screen.findByTestId("recipes-error")).textContent ?? ""; + expect(said).toContain("busy"); + expect(screen.queryByTestId("recipes-empty")).toBeNull(); + }); +}); + +describe("the editor", () => { + it("presets the resize from the target's hints and marks the suggestion", async () => { + baseline(); + render(mount()); + await userEvent.click(await screen.findByTestId("recipe-new")); + + expect(screen.getByTestId("recipe-step-target").getAttribute("data-state")).toBe("upcoming"); + await chooseTarget(/YOLO11/); + + expect(screen.getByTestId("recipe-step-target").getAttribute("data-state")).toBe("complete"); + expect(screen.getByTestId("recipe-target-meta").textContent).toContain("Ultralytics YOLO"); + expect(screen.getByTestId("recipe-target-meta").textContent).toContain("detect, segment"); + expect(screen.getByTestId("recipe-target-carries").textContent).toContain("boxes and polygons"); + expect(screen.getByTestId("recipe-target-carries").textContent).toContain("2 classes"); + expect(screen.getByTestId("resize-ambient").textContent).toContain("YOLO11 letterboxes to 640 on its own"); + expect(screen.getByTestId("resize-letterbox").getAttribute("aria-pressed")).toBe("true"); + expect(screen.getByTestId("resize-letterbox-suggested")).not.toBeNull(); + expect(screen.getByTestId("resize-width")).toHaveProperty("value", "640"); + expect(screen.getByTestId("resize-height")).toHaveProperty("value", "640"); + expect(screen.getByTestId("resize-pad")).toHaveProperty("value", "114"); + expect(screen.getByTestId("resize-geometry").textContent).toContain("Geometry exact"); + expect(screen.getByTestId("augment-ambient").textContent).toContain("usual practice"); + }); + + it("keeps a typed size when the target changes, and moves the rest", async () => { + baseline(); + render(mount()); + await userEvent.click(await screen.findByTestId("recipe-new")); + await chooseTarget(/YOLO11/); + await userEvent.clear(screen.getByTestId("resize-width")); + await userEvent.type(screen.getByTestId("resize-width"), "800"); + + await chooseTarget(/Wide/); + + expect(screen.getByTestId("resize-width")).toHaveProperty("value", "800"); + expect(screen.getByTestId("resize-height")).toHaveProperty("value", "720"); + expect(screen.getByTestId("resize-stretch").getAttribute("aria-pressed")).toBe("true"); + }); + + it("names the rule beside the field and keeps Save shut with the reason in the footer", async () => { + baseline(); + render(mount()); + await userEvent.click(await screen.findByTestId("recipe-new")); + await userEvent.type(screen.getByTestId("recipe-name"), "small"); + await chooseTarget(/YOLO11/); + await userEvent.clear(screen.getByTestId("resize-width")); + await userEvent.type(screen.getByTestId("resize-width"), "16"); + + expect(screen.getByTestId("resize-problem").textContent).toContain("32 to 8192"); + expect(screen.getByTestId("recipe-step-resize").getAttribute("data-state")).toBe("upcoming"); + expect(screen.getByTestId("recipe-save")).toHaveProperty("disabled", true); + expect(screen.getByTestId("recipe-footer-note").textContent).toContain("32 to 8192"); + }); + + it("ticks the first augmentation with one variant, and clears the count with the last", async () => { + baseline(); + render(mount()); + await userEvent.click(await screen.findByTestId("recipe-new")); + + await userEvent.click(screen.getByTestId("augment-brightness_contrast")); + expect(screen.getByTestId("augment-variants")).toHaveProperty("value", "1"); + expect(screen.getByTestId("augment-amount")).toHaveProperty("value", "0.2"); + await userEvent.click(screen.getByTestId("augment-brightness_contrast")); + expect(screen.getByTestId("augment-variants")).toHaveProperty("value", "0"); + expect(screen.queryByTestId("augment-amount")).toBeNull(); + expect(screen.getByTestId("augment-ambient-split").textContent).toContain("train fold"); + }); + + it("creates a new recipe with the spec the steps describe", async () => { + baseline(); + on("POST", /\/preprocessing-recipes$/, { status: 201, body: recipeRow("yolo-640", LETTERBOX) }); + render(mount()); + await userEvent.click(await screen.findByTestId("recipe-new")); + await userEvent.type(screen.getByTestId("recipe-name"), "yolo-640"); + await chooseTarget(/YOLO11/); + await userEvent.click(screen.getByTestId("augment-hflip")); + await userEvent.click(screen.getByTestId("augment-brightness_contrast")); + await userEvent.clear(screen.getByTestId("augment-amount")); + await userEvent.type(screen.getByTestId("augment-amount"), "0.3"); + await userEvent.clear(screen.getByTestId("augment-variants")); + await userEvent.type(screen.getByTestId("augment-variants"), "2"); + expect(screen.getByTestId("recipe-footer-note").textContent).toContain("Unsaved changes"); + + await userEvent.click(screen.getByTestId("recipe-save")); + + const create = () => + sent.find((r) => r.method === "POST" && pathOf(r).endsWith("/preprocessing-recipes")); + await waitFor(() => expect(create()).not.toBeUndefined()); + expect(bodyOf(create() as Request)).toEqual({ name: "yolo-640", spec: LETTERBOX }); + // Saved: the footer says so and the row is selected. + await waitFor(() => expect(screen.getByTestId("recipe-footer-note").textContent).toContain("No unsaved changes")); + }); + + it("replaces an open recipe with PUT, at its current name", async () => { + baseline([recipeRow("yolo-640", LETTERBOX)]); + on("PUT", /\/preprocessing-recipes\/yolo-640$/, { + status: 200, + body: recipeRow("yolo-640", { ...LETTERBOX, variants_per_asset: 3 }), + }); + render(mount()); + await screen.findByTestId("recipe-editor"); + expect(screen.getByTestId("recipe-save")).toHaveProperty("disabled", true); + expect(screen.getByTestId("recipe-footer-note").textContent).toContain("No unsaved changes"); + + await userEvent.clear(screen.getByTestId("augment-variants")); + await userEvent.type(screen.getByTestId("augment-variants"), "3"); + await userEvent.click(screen.getByTestId("recipe-save")); + + const replace = () => sent.find((r) => r.method === "PUT"); + await waitFor(() => expect(replace()).not.toBeUndefined()); + expect(pathOf(replace() as Request)).toMatch(/\/preprocessing-recipes\/yolo-640$/); + expect(bodyOf(replace() as Request)).toEqual({ + name: "yolo-640", + spec: { ...LETTERBOX, variants_per_asset: 3 }, + }); + }); + + it("discards back to the stored recipe", async () => { + baseline([recipeRow("yolo-640", LETTERBOX)]); + render(mount()); + await screen.findByTestId("recipe-editor"); + expect(screen.getByTestId("recipe-discard")).toHaveProperty("disabled", true); + + await userEvent.clear(screen.getByTestId("resize-width")); + await userEvent.type(screen.getByTestId("resize-width"), "320"); + expect(screen.getByTestId("recipe-discard")).toHaveProperty("disabled", false); + await userEvent.click(screen.getByTestId("recipe-discard")); + + expect(screen.getByTestId("resize-width")).toHaveProperty("value", "640"); + expect(sent.some((r) => r.method === "PUT")).toBe(false); + }); + + it("renders a refused save as prose, never as its code", async () => { + baseline(); + on("POST", /\/preprocessing-recipes$/, { + status: 409, + body: { code: "PREPROCESSING_RECIPE_NAME_TAKEN", message: "project 1111 already has a recipe named 'yolo-640'" }, + }); + render(mount()); + await userEvent.click(await screen.findByTestId("recipe-new")); + await userEvent.type(screen.getByTestId("recipe-name"), "yolo-640"); + await userEvent.click(screen.getByTestId("recipe-save")); + + const said = (await screen.findByTestId("recipe-save-error")).textContent ?? ""; + expect(said).toContain("already exists"); + expect(said).not.toContain("PREPROCESSING_RECIPE_NAME_TAKEN"); + expect(said).not.toContain("1111"); + }); +}); + +describe("the preview", () => { + it("samples the project's first three assets when no release has a split, and renders each stage", async () => { + baseline([recipeRow("yolo-640", LETTERBOX)]); + render(mount()); + + const grid = await screen.findByTestId("preview-grid"); + expect(within(grid).getAllByTestId(/^preview-row-/)).toHaveLength(3); + expect(screen.getByTestId("preview-aside").textContent).toBe("3 sample assets · seeded"); + + await waitFor(() => + expect(screen.getByTestId("preview-0-augment").getAttribute("data-state")).toBe("rendered"), + ); + // The original is the asset through no transform; the resize column the + // resize step alone; the augmentation column variant 1 of the whole spec. + const previews = sent.filter((r) => r.method === "POST" && pathOf(r).endsWith("/preprocessing-preview")); + const forFirst = previews.filter((r) => bodyOf(r)["asset_id"] === ASSET_A).map(bodyOf); + const specs = forFirst.map((body) => ({ + variant: body["variant"], + steps: (body["spec"] as RecipeSpec).steps.map((step) => step.kind), + })); + expect(specs).toContainEqual({ variant: 0, steps: [] }); + expect(specs).toContainEqual({ variant: 0, steps: ["resize"] }); + expect(specs).toContainEqual({ variant: 1, steps: ["resize", "augment", "augment"] }); + expect(previews.some((r) => bodyOf(r)["asset_id"] === ASSET_D)).toBe(false); + const original = screen.getByTestId("preview-0-original"); + const image = original.querySelector("img"); + expect(image?.getAttribute("src")).toBe("data:image/png;base64,aGVsbG8="); + // The placed label, drawn by the static overlay in the rendering's own frame. + expect(within(original).getByTestId("preview-overlay").getAttribute("viewBox")).toBe("0 0 512 384"); + expect(within(original).getByTestId("preview-shape-box-1")).not.toBeNull(); + expect(within(screen.getByTestId("preview-0-augment")).getByTestId("preview-shape-box-1-aug1")).not.toBeNull(); + expect(screen.getByTestId("recipe-step-preview").getAttribute("data-state")).toBe("complete"); + }); + + it("samples the newest release's train fold when it has a split", async () => { + on("GET", /\/releases$/, { + status: 200, + body: { + items: [ + { + id: RELEASE, + dataset_id: DATASET, + tag: "v1", + manifest_hash: "abcdef0123456789", + schema_version: 1, + asset_count: 4, + annotation_count: 0, + split: { train: 0.5, val: 0.25, test: 0.25, seed: 0 }, + created_at: "2026-08-01T10:00:00.000000Z", + visionset_version: "0.0.1.dev0", + }, + ], + total: 1, + }, + }); + on("GET", /\/assignment$/, { + status: 200, + body: { train: [ASSET_C, ASSET_D], val: [ASSET_A], test: [ASSET_B] }, + }); + baseline([recipeRow("plain", { target: null, steps: [], variants_per_asset: 0 })]); + render(mount()); + + await screen.findByTestId("preview-grid"); + await waitFor(() => + expect(screen.getByTestId("preview-1-original").getAttribute("data-state")).toBe("rendered"), + ); + const sampled = new Set( + sent + .filter((r) => r.method === "POST" && pathOf(r).endsWith("/preprocessing-preview")) + .map((r) => bodyOf(r)["asset_id"]), + ); + expect(sampled).toEqual(new Set([ASSET_C, ASSET_D])); + // No resize step and no augmentation: the two stages say so rather than + // repeating the original. + expect(screen.getByTestId("preview-0-resize").textContent).toContain("No resize step"); + expect(screen.getByTestId("preview-0-augment").textContent).toContain("No augmentation"); + }); + + it("shows a refused rendering as prose in the cell", async () => { + baseline([recipeRow("plain", { target: null, steps: [], variants_per_asset: 0 })]); + handlers.unshift((request) => + request.method === "POST" && pathOf(request).endsWith("/preprocessing-preview") + ? { status: 422, body: { code: "UNSUPPORTED_MEDIA", message: "That file is not an image this server reads." } } + : undefined, + ); + render(mount()); + + await screen.findByTestId("preview-grid"); + await waitFor(() => + expect(screen.getByTestId("preview-0-original").getAttribute("data-state")).toBe("error"), + ); + const cell = screen.getByTestId("preview-0-original"); + expect(cell.textContent).toContain("not an image"); + expect(cell.textContent).not.toContain("UNSUPPORTED_MEDIA"); + }); +}); diff --git a/frontend/ui-core/src/screens/queries.ts b/frontend/ui-core/src/screens/queries.ts index 31f44b85..13438355 100644 --- a/frontend/ui-core/src/screens/queries.ts +++ b/frontend/ui-core/src/screens/queries.ts @@ -77,6 +77,12 @@ import { checkListSchemaVersions, checkListSources, checkCreateCorrectionBatch, + checkCreatePreprocessingRecipe, + checkDeletePreprocessingRecipe, + checkGetReleaseAssignment, + checkListPreprocessingRecipes, + checkPreviewPreprocessing, + checkUpdatePreprocessingRecipe, checkPreLabelJob, checkPreLabelPlan, checkPreLabelProjectBatches, @@ -2145,13 +2151,14 @@ export function useVerifyRelease(releaseId: string): UseQueryResult + mutationFn: async (input: { target: string; recipe?: string; allowLossy?: boolean }) => unwrap( await client.POST("/releases/{release_id}/export", { params: { path: { release_id: releaseId }, query: { target: input.target, + ...(input.recipe === undefined ? {} : { recipe: input.recipe }), ...(input.allowLossy === true ? { allow_lossy: true } : {}), }, }, @@ -2161,6 +2168,152 @@ export function useExportRelease(releaseId: string) { }); } +// --- pre-processing recipes ---------------------------------------------------- + +export type PreprocessingRecipe = components["schemas"]["PreprocessingRecipeOut"]; +export type PreprocessingRecipePage = components["schemas"]["PreprocessingRecipePage"]; +export type PreprocessingPreview = components["schemas"]["PreprocessingPreviewOut"]; +export type PreviewAnnotation = components["schemas"]["PreviewAnnotationOut"]; +export type SplitAssignment = components["schemas"]["SplitAssignmentOut"]; +type RecipeSpecBody = components["schemas"]["RecipeSpecBody"]; + +export const recipeKeys = { + recipes: (projectId: string) => ["projects", projectId, "preprocessing-recipes"] as const, + // The spec is part of the key: two specs are two renderings of the same + // asset, and sharing one key would make editing the draft a cache overwrite. + preview: (projectId: string, assetId: string, variant: number, spec: string) => + ["projects", projectId, "preprocessing-preview", assetId, variant, spec] as const, + assignment: (releaseId: string) => ["releases", releaseId, "assignment"] as const, +}; + +/** + * A project's recipes. Named resources with no state and no `allowed_actions`: + * every operation is always offered, and nothing here gates one. + */ +export function usePreprocessingRecipes( + projectId: string, +): UseQueryResult { + const client = useApiClient(); + return useQuery({ + queryKey: recipeKeys.recipes(projectId), + queryFn: async () => + unwrap( + await client.GET("/projects/{project_id}/preprocessing-recipes", { + params: { path: { project_id: projectId } }, + }), + checkListPreprocessingRecipes, + ), + }); +} + +export function useCreatePreprocessingRecipe(projectId: string) { + const client = useApiClient(); + const queries = useQueryClient(); + return useMutation({ + mutationFn: async (input: { name: string; spec: RecipeSpecBody }) => + unwrap( + await client.POST("/projects/{project_id}/preprocessing-recipes", { + params: { path: { project_id: projectId } }, + body: { name: input.name, spec: input.spec }, + }), + checkCreatePreprocessingRecipe, + ), + onSuccess: () => queries.invalidateQueries({ queryKey: recipeKeys.recipes(projectId) }), + }); +} + +/** The whole recipe, replaced. `name` renames it when it differs from `current`. */ +export function useUpdatePreprocessingRecipe(projectId: string) { + const client = useApiClient(); + const queries = useQueryClient(); + return useMutation({ + mutationFn: async (input: { current: string; name: string; spec: RecipeSpecBody }) => + unwrap( + await client.PUT("/projects/{project_id}/preprocessing-recipes/{name}", { + params: { path: { project_id: projectId, name: input.current } }, + body: { name: input.name, spec: input.spec }, + }), + checkUpdatePreprocessingRecipe, + ), + onSuccess: () => queries.invalidateQueries({ queryKey: recipeKeys.recipes(projectId) }), + }); +} + +export function useDeletePreprocessingRecipe(projectId: string) { + const client = useApiClient(); + const queries = useQueryClient(); + return useMutation({ + mutationFn: async (name: string) => + unwrap( + await client.DELETE("/projects/{project_id}/preprocessing-recipes/{name}", { + params: { path: { project_id: projectId, name } }, + }), + checkDeletePreprocessingRecipe, + ), + onSuccess: () => queries.invalidateQueries({ queryKey: recipeKeys.recipes(projectId) }), + }); +} + +/** + * One asset through a spec, rendered for a screen — the export's own kernel + * path over a one-asset manifest, capped to 512 pixels on the longer side. + * + * A `POST` read as a query: the request creates nothing, and the response is + * a function of `(asset, variant, spec)`, which is what the key spells. The + * previous picture is kept while a new spec is rendering, so a keystroke in the + * width field changes the cell rather than blanking it. A spec that cannot be + * sent yet (`null`) leaves the query idle. + */ +export function usePreprocessingPreview( + projectId: string, + assetId: string | undefined, + variant: number, + spec: RecipeSpecBody | null, + specKey: string, +): UseQueryResult { + const client = useApiClient(); + return useQuery({ + queryKey: recipeKeys.preview(projectId, assetId ?? "none", variant, specKey), + enabled: assetId !== undefined && spec !== null, + placeholderData: keepPreviousData, + // The server says `no-store`, and a rendering of a spec that is still + // being edited is not worth refetching on focus either. + staleTime: 60_000, + queryFn: async () => + unwrap( + await client.POST("/projects/{project_id}/preprocessing-preview", { + params: { path: { project_id: projectId } }, + body: { asset_id: assetId ?? "", variant, spec: spec ?? { steps: [], variants_per_asset: 0 } }, + }), + checkPreviewPreprocessing, + ), + }); +} + +/** + * The folds a release's split recipe cuts over its frozen asset set. Enabled + * only for a release that has a split: one without answers `NO_SPLIT_RECIPE`, + * which is an answer about the release and not a failure to render. + */ +export function useReleaseAssignment( + releaseId: string | undefined, +): UseQueryResult { + const client = useApiClient(); + return useQuery({ + queryKey: recipeKeys.assignment(releaseId ?? "none"), + enabled: releaseId !== undefined, + // A release is immutable, so its cut is too. + staleTime: Infinity, + queryFn: async () => + unwrap( + await client.GET("/releases/{release_id}/assignment", { + params: { path: { release_id: releaseId ?? "" } }, + }), + checkGetReleaseAssignment, + ), + }); +} + /** * Watch a queued unit of work to its end. * diff --git a/frontend/ui-core/src/screens/recipeDraft.ts b/frontend/ui-core/src/screens/recipeDraft.ts new file mode 100644 index 00000000..c8a40f96 --- /dev/null +++ b/frontend/ui-core/src/screens/recipeDraft.ts @@ -0,0 +1,241 @@ +/** + * The recipe editor's state, and the two directions between it and the wire. + * + * A draft holds every field as the person typed it — strings, not numbers — so + * a half-typed width is a value the form can show rather than a `NaN` it has to + * hide. `draftToSpec` is the one place the draft becomes a `RecipeSpecBody`, and + * it refuses with the rule named rather than sending a body the server will + * answer 422 to: the bounds it checks are the request body's own shape, which + * is a different thing from the kernel's state gating that the `ui-capabilities` + * skill keeps out of the client. + * + * `touched` records the fields a person has edited by hand. A target's hints + * preselect the strategy and the size, and changing the target rewrites those + * suggestions — but only where nobody has typed, so a size chosen on purpose + * survives a change of model. + */ + +import type { ExportTarget } from "./queries"; +import type { components } from "../generated/api"; + +export type RecipeSpec = components["schemas"]["RecipeSpecBody"]; +export type ResizeStepSpec = components["schemas"]["ResizeStepBody"]; +export type AugmentStepSpec = components["schemas"]["AugmentStepBody"]; +export type AugmentOp = components["schemas"]["AugmentOp"]; + +export type ResizeChoice = "letterbox" | "stretch" | "none"; +export type ResizeField = "strategy" | "width" | "height"; + +export const AUGMENT_OPS: readonly { readonly op: AugmentOp; readonly label: string }[] = [ + { op: "hflip", label: "Horizontal flip" }, + { op: "brightness_contrast", label: "Brightness and contrast" }, + { op: "rot90", label: "Quarter turns" }, +]; + +export const SIZE_MIN = 32; +export const SIZE_MAX = 8192; +export const PAD_MIN = 0; +export const PAD_MAX = 255; +export const AMOUNT_MAX = 0.5; +export const VARIANTS_MAX = 8; + +export interface RecipeDraft { + readonly name: string; + /** The chosen target's `name`; `""` while none is chosen. */ + readonly target: string; + readonly strategy: ResizeChoice; + readonly width: string; + readonly height: string; + readonly padValue: string; + readonly ops: readonly AugmentOp[]; + readonly amount: string; + readonly variants: string; + readonly touched: readonly ResizeField[]; +} + +export const EMPTY_DRAFT: RecipeDraft = { + name: "", + target: "", + strategy: "none", + width: "", + height: "", + padValue: "114", + ops: [], + amount: "0.2", + variants: "0", + touched: [], +}; + +/** The draft a stored recipe opens as. Every field counts as touched: it was chosen. */ +export function draftFromSpec(name: string, spec: RecipeSpec): RecipeDraft { + const resize = spec.steps.find((step): step is ResizeStepSpec => step.kind === "resize"); + const augments = spec.steps.filter((step): step is AugmentStepSpec => step.kind === "augment"); + const brightness = augments.find((step) => step.op === "brightness_contrast"); + return { + name, + target: spec.target ?? "", + strategy: resize === undefined ? "none" : resize.strategy, + width: resize === undefined ? "" : String(resize.width), + height: resize === undefined ? "" : String(resize.height), + padValue: String(resize?.pad_value ?? 114), + ops: augments.map((step) => step.op), + amount: String(brightness?.amount ?? 0.2), + variants: String(spec.variants_per_asset), + touched: ["strategy", "width", "height"], + }; +} + +/** + * The target's hints written into the fields nobody has touched. + * + * A target with no recommendation leaves the untouched fields as they are: a + * hint that says nothing is not an instruction to clear what a previous target + * suggested. + */ +export function applyTargetHints(draft: RecipeDraft, target: ExportTarget | undefined): RecipeDraft { + if (target === undefined) return { ...draft, target: "" }; + const touched = new Set(draft.touched); + const strategy = target.hints.recommended_strategy; + const size = target.hints.recommended_size; + return { + ...draft, + target: target.name, + strategy: + !touched.has("strategy") && (strategy === "letterbox" || strategy === "stretch") + ? strategy + : draft.strategy, + width: !touched.has("width") && size != null ? String(size[0]) : draft.width, + height: !touched.has("height") && size != null ? String(size[1]) : draft.height, + }; +} + +/** Record a hand edit, so a later change of target leaves the field alone. */ +export function touch(draft: RecipeDraft, field: ResizeField): RecipeDraft { + return draft.touched.includes(field) ? draft : { ...draft, touched: [...draft.touched, field] }; +} + +function integer(text: string): number | null { + if (!/^\d+$/.test(text.trim())) return null; + return Number(text.trim()); +} + +export interface SpecProblem { + readonly step: "resize" | "augment"; + readonly text: string; +} + +export type SpecOutcome = + | { readonly kind: "spec"; readonly spec: RecipeSpec } + | { readonly kind: "problems"; readonly problems: readonly SpecProblem[] }; + +/** + * The draft as the wire takes it, or every reason it cannot be sent yet. + * + * The rules are the request body's own — `RecipeSpecBody`'s bounds and its + * cross-field constraints, restated so the reason sits beside the field rather + * than arriving as a 422 after the press. + */ +export function draftToSpec(draft: RecipeDraft): SpecOutcome { + const problems: SpecProblem[] = []; + const steps: (ResizeStepSpec | AugmentStepSpec)[] = []; + + if (draft.strategy !== "none") { + const width = integer(draft.width); + const height = integer(draft.height); + const pad = integer(draft.padValue); + if (width === null || width < SIZE_MIN || width > SIZE_MAX) { + problems.push({ step: "resize", text: `Width is a whole number from ${SIZE_MIN} to ${SIZE_MAX}.` }); + } + if (height === null || height < SIZE_MIN || height > SIZE_MAX) { + problems.push({ step: "resize", text: `Height is a whole number from ${SIZE_MIN} to ${SIZE_MAX}.` }); + } + if (draft.strategy === "letterbox" && (pad === null || pad < PAD_MIN || pad > PAD_MAX)) { + problems.push({ step: "resize", text: `Pad value is a whole number from ${PAD_MIN} to ${PAD_MAX}.` }); + } + if (width !== null && height !== null && pad !== null) { + steps.push({ + kind: "resize", + strategy: draft.strategy, + width, + height, + pad_value: draft.strategy === "letterbox" ? pad : 114, + }); + } + } + + const variants = integer(draft.variants); + const amount = Number(draft.amount.trim()); + const usesAmount = draft.ops.includes("brightness_contrast"); + if (usesAmount && !(draft.amount.trim() !== "" && amount > 0 && amount <= AMOUNT_MAX)) { + problems.push({ step: "augment", text: `Amount is above 0 and at most ${AMOUNT_MAX}.` }); + } + if (draft.ops.length > 0) { + if (variants === null || variants < 1 || variants > VARIANTS_MAX) { + problems.push({ + step: "augment", + text: `Variants per image is a whole number from 1 to ${VARIANTS_MAX} while an augmentation is on.`, + }); + } + } else if (variants !== null && variants > 0) { + problems.push({ + step: "augment", + text: "Variants need at least one augmentation — tick one, or set the variants to 0.", + }); + } else if (variants === null) { + problems.push({ step: "augment", text: `Variants per image is a whole number from 0 to ${VARIANTS_MAX}.` }); + } + for (const op of AUGMENT_OPS.map((one) => one.op)) { + if (draft.ops.includes(op)) { + steps.push({ kind: "augment", op, amount: op === "brightness_contrast" ? amount : 0.2 }); + } + } + + if (problems.length > 0) return { kind: "problems", problems }; + return { + kind: "spec", + spec: { + target: draft.target === "" ? null : draft.target, + steps, + variants_per_asset: draft.ops.length === 0 ? 0 : (variants ?? 0), + }, + }; +} + +const OP_WORDS: Record = { + hflip: "flip", + brightness_contrast: "brightness/contrast", + rot90: "rot90", +}; + +/** A spec in one line: `letterbox 640×640 · flip, brightness/contrast · 2 variants`. */ +export function describeRecipeSpec(spec: RecipeSpec): string { + const parts: string[] = []; + const resize = spec.steps.find((step): step is ResizeStepSpec => step.kind === "resize"); + if (resize !== undefined) parts.push(`${resize.strategy} ${resize.width}×${resize.height}`); + const ops = spec.steps + .filter((step): step is AugmentStepSpec => step.kind === "augment") + .map((step) => OP_WORDS[step.op] ?? step.op); + if (ops.length > 0) { + parts.push(ops.join(", ")); + parts.push(`${spec.variants_per_asset} ${spec.variants_per_asset === 1 ? "variant" : "variants"}`); + } + return parts.length === 0 ? "No transform" : parts.join(" · "); +} + +/** Whether two specs would be stored the same; `target` included, since it is stored. */ +export function sameSpec(a: RecipeSpec, b: RecipeSpec): boolean { + return canonicalSpec(a) === canonicalSpec(b); +} + +/** A stable spelling of a spec, for a query key and for comparison. */ +export function canonicalSpec(spec: RecipeSpec): string { + return JSON.stringify({ + target: spec.target ?? null, + variants_per_asset: spec.variants_per_asset, + steps: spec.steps.map((step) => + step.kind === "resize" + ? { kind: "resize", strategy: step.strategy, width: step.width, height: step.height, pad_value: step.pad_value } + : { kind: "augment", op: step.op, amount: step.op === "brightness_contrast" ? step.amount : null }, + ), + }); +} diff --git a/frontend/ui-core/src/screens/screens.test.tsx b/frontend/ui-core/src/screens/screens.test.tsx index 27454753..99d26bcd 100644 --- a/frontend/ui-core/src/screens/screens.test.tsx +++ b/frontend/ui-core/src/screens/screens.test.tsx @@ -3113,6 +3113,32 @@ describe("the project's identity and its one filled control", () => { expect(filled()).toEqual([screen.getByTestId("go-ingest")]); }); + it("steps Ingest back while the Pre-processing view holds the filled button, and forward again", async () => { + // The recipe editor owns the page's forward action — Save recipe, or the + // invitation to write one — so the column's control steps back for exactly + // as long as that view is showing, in both directions. + headerFor({}); + on("GET", /\/dataset$/, { + status: 200, + body: { id: "99999999-9999-4999-8999-999999999999", project_id: PROJECT, name: "highway", description: null }, + }); + on("GET", /\/preprocessing-recipes$/, { status: 200, body: { items: [], total: 0 } }); + on("GET", /\/releases$/, { status: 200, body: { items: [], total: 0 } }); + render(mount()); + + await screen.findByTestId("dataset-tabs"); + expect(filled()).toEqual([screen.getByTestId("go-ingest")]); + expect(screen.getByTestId("publish-release").className).not.toContain("bg-primary"); + + await userEvent.click(screen.getByTestId("dataset-tab-preprocessing")); + const invitation = await screen.findByTestId("recipe-new"); + expect(filled()).toEqual([invitation]); + expect(screen.getByTestId("publish-release").className).not.toContain("bg-primary"); + + await userEvent.click(screen.getByTestId("dataset-tab-releases")); + await waitFor(() => expect(filled()).toEqual([screen.getByTestId("go-ingest")])); + }); + it("moves Rename into the overflow, so only two buttons show", async () => { headerFor({ batchState: "in_annotation" }); render(mount());