diff --git a/docs/content/ui.md b/docs/content/ui.md index c68b6710..7ad99385 100644 --- a/docs/content/ui.md +++ b/docs/content/ui.md @@ -265,16 +265,25 @@ one guards emitting an **incomplete copy of something that stays intact**. The kernel never catches the three together and neither does the UI: three dialogs, three questions. -There is no pre-export validation route, so consent here is attempt-shaped: attempt → read -`LOSSY_EXPORT_NOT_CONSENTED` off the 409 → ask → retry with the flag. The schema editor does -not have this shape - it previews first - and the difference is exactly the routed preview -that export lacks. The 409 carries the compatibility report as `detail.compatibility`, and the -consent banner lists from it the classes the format drops or degrades - each with its -annotation and asset counts and the format's own reason - so the question names what is lost -rather than only that something is. -`FormatOut.lossy` makes the question predictable in advance, because lossiness is -declared by the **format** - a bbox-only format loses a polygon whether or not -today's dataset holds one. +**An export is addressed to a target model, never to a format.** The dialog's one control +is *Target model*: the catalog `GET /export-targets` answers, grouped under *Ultralytics +YOLO*, *Community YOLO* and *Other formats*, each option carrying a second line with the +tasks the model accepts — or, for a format with no task vocabulary, the geometries it +carries. The choice is sent as `target=`; the format it resolves to is the server's to +work out, and the retired `yolo` alias is never offered. A family the build has no heading +for lands under *Other formats* rather than out of the list. + +`GET /releases/{id}/export-compatibility` would answer the lossy question before anything +is attempted; the dialog does not call it. Consent here is attempt-shaped: attempt → read +`LOSSY_EXPORT_NOT_CONSENTED` off the 409 → ask → retry with the flag. The 409 carries the +compatibility report as `detail.compatibility`, judged for the chosen target, and the +consent banner reads it twice over: one sentence naming the target, what it accepts and +how much of this release it would drop or degrade — *YOLOv10 accepts boxes only — 1,204 +polygons would be dropped.* — and beneath it the classes lost, each with its annotation +and asset counts and the exporter's own reason. So the question names what is lost rather +than only that something is. `FormatOut.lossy`, read through the target's format, makes +the question predictable in advance, because lossiness is declared by the **format** — a +bbox-only format loses a polygon whether or not today's dataset holds one. ### Downloads, and the fourth instance of one finding diff --git a/frontend/app/cycle/cycle.spec.ts b/frontend/app/cycle/cycle.spec.ts index 97659a63..0d03758a 100644 --- a/frontend/app/cycle/cycle.spec.ts +++ b/frontend/app/cycle/cycle.spec.ts @@ -28,15 +28,17 @@ * install of ffmpeg and a decode inside a five-minute budget, for coverage that * exists. Recorded rather than skipped quietly. * - * **The export is `dummy`, which writes nothing.** It is the only installed - * exporter until M6, and `file_count: 0` is an export that ran. What is proved here - * is the *round trip* — the request carries the format, the response is an archive, - * and the browser saves it — which is exactly the part real exporters will inherit. + * **The export is addressed to a target, and the archive is opened.** The release + * holds a box, a polygon and a polyline; `yolo11` takes the first two and drops the + * third, so the walk meets the real lossy consent — the sentence naming the target + * and the count — before the archive arrives, and then reads `data.yaml` out of + * the download to see the class map the trainer would. */ import { expect, test, type Download, type Page, type TestInfo } from "@playwright/test"; import { readFileSync, readdirSync } from "node:fs"; import path from "node:path"; +import { inflateRawSync } from "node:zlib"; import { saveNow } from "../e2e/_frame"; import { emptyWorkspace } from "./_workspace"; @@ -1448,31 +1450,49 @@ test("the whole cycle, from opening the app to a downloaded export", async ({ pa await expect(page.getByTestId(`verified-${TAG}`)).toContainText("Intact"); }); - await test.step("export through the dummy format and download the archive", async () => { + await test.step("export for yolo11, consent to the polyline it drops, and download", async () => { await page.getByTestId(`export-${TAG}`).click(); - await page.getByTestId("export-format").click(); - - // The five lane plugins, discovered by the *running server* through the - // real entry-point group rather than by an import in a test — and each - // declaring itself lossy, which is the one thing the picker shows about a - // format before you choose it. A lane format that arrived silently unmarked - // would let somebody export a release believing nothing was dropped. + await page.getByTestId("export-target").click(); + + // The catalog the *running server* derived through the real entry-point + // group, under its three headings. The lane plugins have no task vocabulary, + // so their second line is the geometry they carry — which is the one thing + // the picker says about them before you choose, and what tells somebody a + // lane format will not take their boxes. + const listbox = page.getByRole("listbox"); + await expect(listbox.getByRole("group").first()).toContainText("Ultralytics YOLO"); + await expect(listbox).toContainText("Community YOLO"); + await expect(listbox).toContainText("Other formats"); for (const lane of [/tusimple/, /culane/, /openlane-2d/]) { - await expect(page.getByRole("option", { name: lane })).toContainText("(lossy)"); + await expect(page.getByRole("option", { name: lane })).toContainText("polyline"); } - - await page.getByRole("option", { name: /dummy/ }).click(); + // The retired alias is a format the route still accepts, never a target. + await expect(page.getByRole("option", { name: /^yolo$/ })).toHaveCount(0); + + await page.getByRole("option", { name: /YOLO11/ }).click(); + await expect(page.getByTestId("lossy-hint")).toContainText("YOLO11"); + + // The release holds a `centerline` polyline and YOLO11 takes boxes, tags + // and polygons, so the launch is refused with the report and the banner + // says what the target accepts and what this release loses — the kernel's + // counts, in the kernel's order, not the browser's. + await page.getByTestId("export-submit").click(); + const consent = page.getByTestId("lossy-consent"); + await expect(consent).toContainText("YOLO11 accepts boxes, tags and polygons"); + await expect(consent).toContainText("1 polyline would be dropped."); + await expect(consent.getByTestId("lossy-classes")).toContainText("centerline"); + await expect(page.getByTestId("export-submit")).toBeDisabled(); + await consent.getByTestId("lossy-checkbox").check(); // **Three requests behind one click.** The launch answers 202 with // a job id, the screen polls `/background-jobs/{id}` until it succeeds, and - // only then fetches the artifact and saves it. The assertion is unchanged - // because the *outcome* is unchanged — which is the point of waiting on the - // download event rather than on any of the steps that produce it. + // only then fetches the artifact and saves it — which is the point of waiting + // on the download event rather than on any of the steps that produce it. // // It is also the only place the whole queue runs for real: a spawned worker - // opens this workspace, resolves the `dummy` exporter through the entry-point - // group, and writes into `exports/`. Every other test of that path runs the - // handler inline. + // opens this workspace, resolves the target's exporter through the + // entry-point group, and writes into `exports/`. Every other test of that + // path runs the handler inline. const [download] = await Promise.all([ page.waitForEvent("download"), page.getByTestId("export-submit").click(), @@ -1611,6 +1631,12 @@ test("the whole cycle, from opening the app to a downloaded export", async ({ pa * wherever it is present. `curatedSizeRefused` carries what the form rendered, * so the expectation follows the installation instead of guessing at it. * + * The last entry is the export's own 409: the first launch is addressed to + * `yolo11` without `allow_lossy`, and the release holds a polyline that + * target drops, so the refusal is the consent question itself — the one + * refusal in the walk a person is meant to see. Exactly one, because the + * retry carries the flag. + * * Anything else — a route that starts refusing, a 404 that becomes a 500, a * second refusal from a route allowed one — fails here with its method, its * path and its status in the message. Before this list existed every one of @@ -1620,6 +1646,7 @@ test("the whole cycle, from opening the app to a downloaded export", async ({ pa const schema = /^GET \/projects\/[0-9a-f-]+\/schema 404$/; const curatedDraft = /^GET \/projects\/[0-9a-f-]+\/schema\/drafts\/curated 404$/; const annotationDraft = /^GET \/projects\/[0-9a-f-]+\/schema\/drafts\/annotation 404$/; + const lossyLaunch = /^POST \/releases\/[0-9a-f-]+\/export 409$/; expect(refusedApiCalls).toEqual([ expect.stringMatching(schema), expect.stringMatching(curatedDraft), @@ -1628,6 +1655,7 @@ test("the whole cycle, from opening the app to a downloaded export", async ({ pa ...(curatedSizeRefused ? ["GET /inference/download-size 500"] : []), expect.stringMatching(annotationDraft), expect.stringMatching(annotationDraft), + expect.stringMatching(lossyLaunch), ]); // And the icon is genuinely served under the mount, rather than absent and // unnoticed: `vite preview` would answer 200 with `index.html` here, which is @@ -1750,17 +1778,57 @@ async function drawPolygon(page: Page): Promise { } /** - * The archive arrived and is a zip. + * The archive arrived, is a zip, and carries the descriptor a trainer reads first. * - * `dummy` writes nothing, so the *contents* are not the claim — `file_count: 0` is - * an export that ran. What is asserted is that the browser received a download with - * the right name and a zip's own four magic bytes, which is the part every real - * exporter inherits. + * The download is named for the target, and `data.yaml` inside it maps class ids + * to the schema's names in schema order — the shape `ultralytics` writes and the + * one thing that decides whether a training run reads the labels as the right + * classes. Read out of the bytes the browser saved rather than off the server's + * disk, because the download is what a person has. */ async function expectArchive(download: Download): Promise { - expect(download.suggestedFilename()).toBe(`${TAG}-dummy.zip`); + expect(download.suggestedFilename()).toBe(`${TAG}-yolo11.zip`); const saved = await download.path(); expect(saved).not.toBeNull(); - const head = readFileSync(saved as string).subarray(0, 2).toString("latin1"); - expect(head).toBe("PK"); + const archive = readFileSync(saved as string); + expect(archive.subarray(0, 2).toString("latin1")).toBe("PK"); + + const descriptor = zipEntry(archive, "data.yaml").toString("utf8"); + expect(descriptor).toMatch(/^names:$/m); + expect(descriptor).toMatch(/^\s+0: "vehicle"$/m); + expect(descriptor).toMatch(/^\s+1: "lane"$/m); +} + +/** + * One file out of a zip, by name — enough of the format to read a descriptor. + * + * Walks the central directory from the end-of-central-directory record, which + * is where a zip says what it holds, then inflates the entry from its local + * header. Stored and deflated entries are the two `shutil.make_archive` writes. + */ +function zipEntry(archive: Buffer, name: string): Buffer { + let end = archive.length - 22; + while (end >= 0 && archive.readUInt32LE(end) !== 0x06054b50) end -= 1; + expect(end, "end-of-central-directory record").toBeGreaterThanOrEqual(0); + const entries = archive.readUInt16LE(end + 10); + let offset = archive.readUInt32LE(end + 16); + for (let index = 0; index < entries; index += 1) { + expect(archive.readUInt32LE(offset)).toBe(0x02014b50); + const method = archive.readUInt16LE(offset + 10); + const compressed = archive.readUInt32LE(offset + 20); + const nameLength = archive.readUInt16LE(offset + 28); + const extraLength = archive.readUInt16LE(offset + 30); + const commentLength = archive.readUInt16LE(offset + 32); + const local = archive.readUInt32LE(offset + 42); + const entryName = archive.subarray(offset + 46, offset + 46 + nameLength).toString("utf8"); + if (entryName === name) { + expect(archive.readUInt32LE(local)).toBe(0x04034b50); + const start = + local + 30 + archive.readUInt16LE(local + 26) + archive.readUInt16LE(local + 28); + const bytes = archive.subarray(start, start + compressed); + return method === 8 ? inflateRawSync(bytes) : Buffer.from(bytes); + } + offset += 46 + nameLength + extraLength + commentLength; + } + throw new Error(`${name} is not in the archive`); } diff --git a/frontend/ui-core/src/data/refusals.ts b/frontend/ui-core/src/data/refusals.ts index 8dae8069..5410e4fc 100644 --- a/frontend/ui-core/src/data/refusals.ts +++ b/frontend/ui-core/src/data/refusals.ts @@ -87,6 +87,7 @@ */ import { asApiError } from "./errors.js"; +import { GEOMETRY_LABELS, GEOMETRY_PLURALS } from "./geometryCategory.js"; import type { components } from "../generated/api.js"; import { formatCount } from "../lib/format.js"; @@ -150,8 +151,9 @@ export const REFUSAL_PROSE: Record = { RELEASE_NOT_FOUND: "That release is no longer on record.", RELEASE_TAG_TAKEN: "A release with that tag already exists — tags are never reused.", NO_SPLIT_RECIPE: "This release was published without a split, so there are no folds to show.", - LOSSY_EXPORT_NOT_CONSENTED: "This format cannot express every shape in the dataset.", + LOSSY_EXPORT_NOT_CONSENTED: "This target cannot take every shape in the dataset.", EXPORT_FORMAT_NOT_FOUND: "No exporter for that format is installed on this server.", + EXPORT_TARGET_NOT_FOUND: "No installed exporter writes for that model.", UNSERIALIZABLE_MANIFEST: "This release's manifest cannot be read back — the workspace may be damaged.", EMPTY_RELEASE: "This dataset has no frames yet — promote a completed batch first.", // The dialog lists the classes beside this, from the refusal's own `blockers`. @@ -301,3 +303,49 @@ export function lostClasses(detail: Record | null): readonly Cl function isClassCompatibility(value: unknown): value is ClassCompatibility { return isClassCount(value) && typeof (value as Record)["status"] === "string"; } + +/** `"boxes"`, or `"box"` for exactly one; a geometry the build has no word for stays raw. */ +function geometryNoun(geometry: string, count: number): string { + const table: Record = count === 1 ? GEOMETRY_LABELS : GEOMETRY_PLURALS; + return table[geometry] ?? geometry; +} + +function listed(words: readonly string[]): string { + if (words.length <= 1) return words.join(""); + return `${words.slice(0, -1).join(", ")} and ${words[words.length - 1]}`; +} + +/** + * The lossy consent as one sentence about the target: what it accepts, and how + * much of this release it would drop or degrade. + * + * `"YOLOv10 accepts boxes only — 1,204 polygons would be dropped."` The counts + * are the report's, summed per geometry across classes, so the sentence answers + * the question the per-class list beneath it then itemises. A target that + * accepts nothing the release holds still names what it does accept, so the + * remedy — pick another target — is readable off the sentence. + */ +export function describeTargetDrops( + target: { readonly label: string; readonly geometries: readonly string[] }, + lost: readonly ClassCompatibility[], +): string { + const accepted = target.geometries.map((one) => geometryNoun(one, 2)); + const accepts = + accepted.length === 0 + ? `${target.label} accepts none of the shapes here` + : accepted.length === 1 + ? `${target.label} accepts ${accepted[0]} only` + : `${target.label} accepts ${listed(accepted)}`; + + const totals = new Map(); + for (const one of lost) { + const key = `${one.status}:${one.geometry}`; + totals.set(key, (totals.get(key) ?? 0) + one.annotations); + } + const clauses = [...totals].map(([key, count]) => { + const [status, geometry] = key.split(":") as [string, string]; + const verb = status === "degraded" ? "would be degraded" : "would be dropped"; + return `${formatCount(count)} ${geometryNoun(geometry, count)} ${verb}`; + }); + return clauses.length === 0 ? `${accepts}.` : `${accepts} — ${listed(clauses)}.`; +} diff --git a/frontend/ui-core/src/index.ts b/frontend/ui-core/src/index.ts index 695430c9..ed31f4ee 100644 --- a/frontend/ui-core/src/index.ts +++ b/frontend/ui-core/src/index.ts @@ -441,3 +441,12 @@ export { type ReleaseVerification, type SplitRecipe, } from "./screens/queries.js"; +export { useExportTargets, type ExportTarget } from "./screens/queries.js"; +export { + ExportTargetSelect, + exportTargetMeta, + groupExportTargets, + type ExportTargetGroup, + type ExportTargetSelectProps, +} from "./patterns/ExportTargetSelect.js"; +export { describeTargetDrops } from "./data/refusals.js"; diff --git a/frontend/ui-core/src/patterns/ExportTargetSelect.tsx b/frontend/ui-core/src/patterns/ExportTargetSelect.tsx new file mode 100644 index 00000000..42adb512 --- /dev/null +++ b/frontend/ui-core/src/patterns/ExportTargetSelect.tsx @@ -0,0 +1,100 @@ +/** + * The target picker: which model a release is exported for. + * + * Options are grouped by family — the trainer's own YOLO line, the community + * YOLO forks, and everything else — because a person choosing a model knows + * which of those they are training and reads the list that way. The family is + * a string on the wire and may grow; one this build has no heading for lands + * under *Other formats* rather than out of the list, so nothing declared is + * invisible. A group with nothing under it renders nothing. + * + * Each option's second line is what the target takes: the tasks it accepts for a + * model, and, for a self-named format with no task vocabulary, the geometries it + * carries. `SelectItem`'s `meta` puts the same two lines on the closed control. + */ + +import type { JSX } from "react"; + +import { GEOMETRY_LABELS } from "../data/geometryCategory"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "../primitives/Select"; +import type { ExportTarget } from "../screens/queries"; + +const FAMILY_HEADINGS: readonly (readonly [family: string, heading: string])[] = [ + ["ultralytics-yolo", "Ultralytics YOLO"], + ["community-yolo", "Community YOLO"], +]; +const OTHER_HEADING = "Other formats"; + +export interface ExportTargetGroup { + readonly heading: string; + readonly targets: readonly ExportTarget[]; +} + +/** The catalog under its headings, in heading order, groups with nothing omitted. */ +export function groupExportTargets(targets: readonly ExportTarget[]): readonly ExportTargetGroup[] { + const known = new Set(FAMILY_HEADINGS.map(([family]) => family)); + const groups = FAMILY_HEADINGS.map(([family, heading]) => ({ + heading, + targets: targets.filter((one) => one.family === family), + })); + groups.push({ heading: OTHER_HEADING, targets: targets.filter((one) => !known.has(one.family)) }); + return groups.filter((group) => group.targets.length > 0); +} + +/** The option's second line, or nothing when the target declares neither tasks nor geometries. */ +export function exportTargetMeta(target: ExportTarget): string | undefined { + if (target.tasks.length > 0) return target.tasks.join(" · "); + if (target.geometries.length > 0) { + return target.geometries + .map((one) => (GEOMETRY_LABELS as Record)[one] ?? one) + .join(" · "); + } + return undefined; +} + +export interface ExportTargetSelectProps { + readonly id?: string; + readonly targets: readonly ExportTarget[]; + /** The chosen target's `name`; `""` while nothing is chosen. */ + readonly value: string; + readonly onValueChange: (name: string) => void; + readonly placeholder?: string; + readonly "data-testid"?: string; +} + +export function ExportTargetSelect({ + id, + targets, + value, + onValueChange, + placeholder = "Choose a model", + "data-testid": testId, +}: ExportTargetSelectProps): JSX.Element { + return ( + + ); +} diff --git a/frontend/ui-core/src/screens/DatasetScreen.tsx b/frontend/ui-core/src/screens/DatasetScreen.tsx index e1dda620..8ffee6eb 100644 --- a/frontend/ui-core/src/screens/DatasetScreen.tsx +++ b/frontend/ui-core/src/screens/DatasetScreen.tsx @@ -18,13 +18,15 @@ * and the UI keeps them apart too: the delete dialog, the schema dialog and this * one are three. * - * There is no pre-export validation route, so consent here is attempt-shaped: - * attempt, read `LOSSY_EXPORT_NOT_CONSENTED` off the 409, ask, retry with the flag. - * The schema editor does not have this shape — it previews first — and the - * difference is exactly the routed preview that export lacks. - * `FormatOut.lossy` is what makes the question predictable — it is declared by the - * *format*, because a bbox-only format loses a polygon whether or not today's - * dataset holds one. + * `GET /releases/{id}/export-compatibility` exists and would answer the question + * before anything is attempted; this screen does not call it. Consent here is + * attempt-shaped: attempt, read `LOSSY_EXPORT_NOT_CONSENTED` off the 409, ask, + * retry with the flag — and the 409 carries the same compatibility report the + * route would, judged for the chosen target, so the banner can say what the + * target accepts and how much would be lost. `FormatOut.lossy`, read through the + * target's format, is what makes the question predictable before the attempt — + * declared by the *format*, because a bbox-only format loses a polygon whether or + * not today's dataset holds one. * * ## The trunk's membership is on the screen now, and so is curation * @@ -65,19 +67,14 @@ import { FieldError, FieldHint, Input, Label } from "../primitives/Input"; import { classBlockers, describeClassCount, + describeTargetDrops, jobFailureProse, lostClasses, refusalProse, } from "../data/refusals"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "../primitives/Select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../primitives/Table"; import { EmptyState, ErrorState } from "../patterns/AsyncStates"; +import { ExportTargetSelect } from "../patterns/ExportTargetSelect"; import { AssetThumbnail } from "./AssetThumbnail"; import { DatasetAssetDialog, trunkAssetLabel } from "./DatasetAssetDialog"; import { saveBlob } from "./download"; @@ -88,6 +85,7 @@ import { useDatasetStats, useDownloadManifest, useExportRelease, + useExportTargets, useFormats, useJobArtifact, useProjectDataset, @@ -931,10 +929,11 @@ function ExportDialog({ readonly open: boolean; readonly onClose: () => void; }): JSX.Element { + const targets = useExportTargets(); const formats = useFormats(); const exportRelease = useExportRelease(releaseId); const artifact = useJobArtifact(); - const [format, setFormat] = useState(""); + const [target, setTarget] = useState(""); 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 @@ -947,10 +946,13 @@ function ExportDialog({ const [outcome, setOutcome] = useState(null); const job = useBackgroundJob(jobId); - const installed = formats.data?.items ?? []; - const chosen = installed.find((one) => one.name === format); - // **The refusals still arrive on the launch.** An unknown format is a 404 and a - // lossy format without consent is a 409, both answered by the request rather + const catalog = targets.data?.items ?? []; + const chosen = catalog.find((one) => one.name === target); + // Lossiness is the format's declaration, reached through the target's format; + // a formats read that failed leaves the hint out and the 409 still asks. + const lossy = formats.data?.items.find((one) => one.name === chosen?.format)?.lossy === true; + // **The refusals still arrive on the launch.** An unknown target is a 404 and a + // lossy export without consent is a 409, both answered by the request rather // than by the job — so the consent flow below is exactly the one that shipped // before export was queued. const failure = exportRelease.isError ? asApiError(exportRelease.error) : null; @@ -964,7 +966,7 @@ function ExportDialog({ setSaved(false); setOutcome(null); exportRelease.mutate( - { format, ...(allowLossy ? { allowLossy: true } : {}) }, + { target, ...(allowLossy ? { allowLossy: true } : {}) }, { onSuccess: (queued) => setJobId(queued.id) }, ); } @@ -982,11 +984,11 @@ function ExportDialog({ setOutcome("succeeded"); artifact.mutate(jobId, { onSuccess: (blob) => { - saveBlob(blob, `${tag}-${format}.zip`); + saveBlob(blob, `${tag}-${target}.zip`); setJobId(null); }, }); - }, [saved, jobId, job.data?.state, artifact, tag, format]); + }, [saved, jobId, job.data?.state, artifact, tag, target]); // The badge's subject: the live job while there is one, the outcome once the // archive has been handed over and the poll has stopped. @@ -997,65 +999,67 @@ function ExportDialog({ Export {tag} - Writes the release through an installed exporter and downloads the result. + Writes the release for the model you will train and downloads the result.
- + {/* Three renderings for three answers, because `?? []` above used to give the first two the same one — the swallowed-refusal pattern. - A failed `GET /formats` is not an answer at all; a + A failed `GET /export-targets` is not an answer at all; a successful empty page is an answer about this server's plugins; - and the combobox is for when there is something to choose. Rolling + and the picker is for when there is something to choose. Rolling the first two together produces a control offering nothing and saying nothing, which is also the visible signature of an install whose exporters are not discoverable — so the screen that should tell a broken install from a broken request is what makes the two indistinguishable. - Loading is deliberately not a fourth branch: `formats.data === - undefined` while pending, so the combobox stands empty for the one - tick it takes, exactly as it did before. + Loading is deliberately not a fourth branch: `targets.data === + undefined` while pending, so the picker stands empty for the one + tick it takes. */} - {formats.isError ? ( -
+ {targets.isError ? ( +
{/* No `code`: the identifier is not the half a person can act on, and the sibling refusal on this screen (`manifest-error-*`) already renders prose without one. */} void formats.refetch()} + message={`${refusalProse(targets.error)} Try again to choose a model.`} + onRetry={() => void targets.refetch()} />
- ) : formats.data !== undefined && installed.length === 0 ? ( -
+ ) : targets.data !== undefined && catalog.length === 0 ? ( +
) : ( - + )} {/* Declared by the format, never by the release: a bbox-only format loses a polygon whether or not today's dataset holds one. */} - {chosen?.lossy === true && ( + {lossy && ( - This format cannot express everything the schema allows. + {chosen?.label} cannot take everything the schema allows. + + )} + {/* A formats read that failed is said, not swallowed: the hint is + absent for a reason, and the launch still asks before dropping. */} + {chosen !== undefined && formats.isError && ( + + Whether {chosen.label} loses anything could not be read — the export asks before + dropping a shape. )}
@@ -1082,7 +1086,13 @@ function ExportDialog({ title="Some shapes cannot be exported" data-testid="lossy-consent" > -

{refusalProse(failure)}

+ {/* The sentence names the target and the counts when the 409 + carried its report; without one, the vocabulary's own line. */} +

+ {chosen !== undefined && lost !== null + ? describeTargetDrops(chosen, lost) + : refusalProse(failure)} +

{lost !== null && lost.length > 0 && (
    {lost.map((one) => ( @@ -1140,7 +1150,7 @@ function ExportDialog({ data-testid="export-submit" // The consent gate: while the API is asking, the button stays shut until // the box is ticked. It is `allow_lossy` and never `confirm`. - disabled={format === "" || running || (needsConsent && !consented)} + disabled={target === "" || running || (needsConsent && !consented)} onClick={() => run(needsConsent)} >