feat: copy/paste per-class auto-annotate overrides as YAML (REQ-186, REQ-190)

Copy now emits YAML instead of the one-line "class <name> conf ..." text: one
block per selected class, fields named after the row labels, globals resolved
into the block, a name quoted only when YAML would misread it.

Paste reads the clipboard back and fills the overrides for the classes selected
in that modal, naming any it ignored — an override for a deselected class would
otherwise ride along in class_params unseen. container diffs go out as one
PATCH /projects/{id} and revert together if the save is rejected.

Parsing is parseClassYaml, a strict subset reader for exactly what Copy emits,
so no YAML dependency: any malformed line throws with its line number and
applies nothing. Values must look like numbers, not merely start like them —
parseFloat would have taken "0.5abc" as 0.5.

Both are real buttons with Lucide icons plus a status line reporting the
outcome, and both live in the shared table so the mass modal carries them too.
This commit is contained in:
asus committed 2026-10-05 14:17:17 +07:00
1 parent 4f15335092
commit 4575c46f4c
8 files changed
+344 -49

No files matched your search

+52
View File
@@ -1405,6 +1405,58 @@ read it.
(accent border + glow) — no CSS added; **browser eyeball still owed by the user**
(375 px 2-col wrap, long class names).
## Task — Copy/Paste YAML for per-class overrides (REQ-186 amended, REQ-190) `[DONE]`
> Code, parser and build verified below; the **clipboard round trip in a real browser is still
> owed** (the Clipboard API cannot be exercised from the shell).
1. **Icons** `frontend/src/components/Icons.jsx` — `CopyIcon` and `ClipboardPasteIcon` added
in Lucide geometry, inheriting `currentColor`; the shared `Icon` wrapper already sets
`aria-hidden` and `focusable="false"`.
2. **Copy emits YAML** `frontend/src/components/ClassParamsTable.jsx` — `compose()` writes a
`#` comment line plus one block per selected class, fields named from the row labels
(`conf`/`iou`/`minbox`/`maxbox`/`container`) via `FIELD_BY_LABEL` so the wire format cannot
drift from what is on screen. A name is quoted only when `PLAIN_NAME` fails, i.e. only when
YAML would misread it. Effective-value resolution is unchanged (empty input still copies the
global), so the block is a whole configuration rather than a diff.
3. **Strict reader** `parseClassYaml()` in the same file, exported so it is testable without a
DOM. Deliberately **not** `js-yaml`: a subset reader for exactly what Copy emits is ~35
lines and needs no dependency. Rejects (with the line number) a list item, a nested map, a
top-level scalar, an unknown key, a malformed number, a value outside 0–1 and an indented key
before any class. Throwing happens before any state write, so a bad paste is atomic —
nothing is half-applied.
→ verify: **[DONE]** parsed the real parser through `esbuild --bundle --platform=node`: a
two-class document with a quoted `"weird: name"` key round-trips to the internal key names
(`threshold`, `iou_threshold`, `min_box_frac`, `max_box_frac`, `container`), and the malformed
cases each throw the expected `line N: …` message (an empty document returns `{}`, which
paste turns into "No classes in the pasted YAML"). A self-review pass caught a real hole the
first harness run missed: `parseFloat('0.5abc')` is `0.5`, so a typo'd number was being
accepted silently — exactly the failure the strict design exists to prevent. Fixed by
matching the text against `NUMBER` before `parseFloat`. Same harness after the fix:
`0.5abc`, `Infinity`, `NaN`, `0.5z`, `" 0.5 "` all throw; `1e-2`, tabs, CRLF, an inline
`# comment` and a quoted name containing ` #` all parse. Repeated class blocks merge.
4. **Paste** `frontend/src/components/ClassParamsTable.jsx` — reads the clipboard
(`readClipboardText` in `frontend/src/clipboard.js`), applies only the classes selected in
this modal, and names the ignored ones in the status line. `container` diffs are applied
through `applyContainers()`, which folds the per-toggle logic into one `PATCH
/projects/{id} {containers: {classId: bool}}` (`backend/projects.py:232` iterates the dict)
and reverts all of them together on rejection, the same functional-revert trick the checkbox
already used.
→ verify: **[DONE]** `backend/projects.py` read: one PATCH covers N class ids; build green.
**Owed, user browser**: copy → paste back → same numbers; paste a block for a deselected
class → named as ignored and nothing written; `conf: 40` → `line 2: conf must be between 0
and 1, got 40`, table untouched; container flag survives a modal reopen; serve over a LAN IP
→ status line explains the blocked clipboard read.
5. **Button design** `frontend/src/app.css` — `.class-params-tools` right-aligned wrapping row,
`.btn` at `0.76rem` with `cursor: pointer` and a `progress` cursor while disabled (the old
`btn-ghost` text button had neither), `.class-params-note` status line that turns `--danger`
when the outcome failed. It does **not** use the app's `.hint` class: that is `--text-faint`,
measured 2.68:1 on the modal panel, and the status line is small text that needs 4.5:1 —
`--text-muted` measures 6.98:1 and `--danger` 4.71:1. Hover/focus-visible come from the
shared `.btn` (`theme.css:114`, `:108`).
→ verify: **[DONE]** `npm run build` green; contrast ratios computed from the tokens;
`ClassParamsTable.jsx` 271 lines, inside the 400-line rule.
## Task — Configurable Track Forward + track-to-end (REQ-189) `[DONE]`
> Status caveat: code, build and docs are verified below; the **end-to-end GPU run in the