feat: paste overrides through a reviewable dialog, any origin (REQ-190)

navigator.clipboard.readText() only exists in a secure context, so on plain http
over a LAN address it is absent, not merely refused — the previous one-click
paste could never work there. A keyboard paste into a focused textarea is an
ordinary user gesture and is not gated, so paste now opens a dialog: the YAML
goes in a textarea, prefilled only when the browser allows the read.

Nothing is written until Apply, and Apply stays disabled while the text does not
parse, so a typo cannot be clicked through. A live line names the classes the
block matched, the container flags it would flip and the classes it would ignore.
Esc closes and returns focus to the button; Enter is left to the textarea,
because YAML needs newlines.

planPaste() produces that review line and applyPasted() does the writing, keeping
the selected-classes-only rule and the single batched container PATCH.
This commit is contained in:
asus committed 2026-10-05 14:41:22 +07:00
1 parent 4575c46f4c
commit 3eeb529e3d
6 files changed
+208 -63

No files matched your search

+6 -3
View File
@@ -368,9 +368,12 @@ selected class's effective settings (per-class override where set, else the glob
container = the checkbox state) on the clipboard as **YAML** — one block per class, fields
named after the row labels (`conf`, `iou`, `minbox`, `maxbox`, `container`), through
`clipboard.js`'s `copyText` (Clipboard API with an `execCommand` fallback for insecure
contexts) — read-only, no network, no setting changed. **Paste** reads the clipboard back
(`readClipboardText`, no legacy fallback, so a blocked read is reported) and fills the
overrides for the classes selected in this modal; `container` diffs go out as one
contexts) — read-only, no network, no setting changed. **Paste** opens `PasteYamlDialog` — a textarea plus a live review line, nothing written until
Apply, and Apply disabled while the text does not parse. It prefills from the clipboard when
the browser allows a read and otherwise waits for Ctrl+V, which is what makes paste work on
plain http on a LAN address (`readText` needs a secure origin; a keyboard paste does not).
`planPaste()` in `ClassParamsTable.jsx` is what produces that review line; `applyPasted()`
writes it. `container` diffs go out as one
`PATCH /api/projects/{id} { containers: { classId: bool } }` and revert together on
rejection. Parsing is `parseClassYaml` in the same file — a strict subset reader for exactly
what Copy emits, no YAML dependency, all-or-nothing with a `line N: …` error. Because
+16 -11
View File
@@ -160,22 +160,27 @@ changes.
review assist filter), surfaces as `MaxBox` in the params table, and is included in the
REQ-186 copy line. Size bounds are ordinary per-class tuning: the `container` flag
(REQ-184) stays purely the NMS containment carve-out and gains no size semantics.
- **REQ-190** — The same table has a **Paste YAML** button that reads the clipboard and fills
the per-class overrides from it, so a tuned configuration moves between projects without
retyping. It applies only the classes currently **selected in this modal**; a pasted class
that is not selected is reported as ignored rather than written, because `buildClassParams`
walks every entry and an unseen override would otherwise ride along in `class_params`.
Numbers merge per field (a pasted block overwrites the four numbers, leaves other state
alone); `container` goes out as one `PATCH /projects/{id} {containers: {classId: bool}}`
covering every flag that changed, reverted together with a message if the save is rejected.
- **REQ-190** — The same table has a **Paste YAML** button that opens a **dialog** holding the
YAML in a textarea, so a tuned configuration moves between projects without retyping. The
textarea is the real path and must work **anywhere the app is served** — no https, no
localhost, no permission prompt: `navigator.clipboard.readText()` only exists on a secure
origin, but a keyboard paste into a focused textarea is an ordinary user gesture and is not
gated. The dialog therefore *prefills* from the clipboard when the browser allows it and
otherwise just waits for Ctrl+V. Before anything is written, a live line reports what the
text would do — the classes it matched, the `container` flags it would flip, the classes it
would ignore — and any parse error, with **Apply disabled** while the text does not parse.
It applies only the classes currently **selected in this modal**; a pasted class that is not
selected is reported as ignored rather than written, because `buildClassParams` walks every
entry and an unseen override would otherwise ride along in `class_params`. Numbers merge per
field (only the fields the block mentions are overwritten, omitted ones keep their value);
`container` goes out as one `PATCH /projects/{id} {containers: {classId: bool}}` covering
every flag that changed, reverted together with a message if the save is rejected.
Parsing is strict on purpose — a subset reader for exactly what Copy emits (`#` comments,
`class:` at the left margin, `key: value` indented under it) rather than a general YAML
engine, and **any** malformed line throws with its line number and applies **nothing**, so a
half-understood paste cannot quietly set wrong parameters. A value must *look* like a number,
not merely start like one (`0.5abc` is rejected rather than read as `0.5`), and values
outside 0–1 are rejected the same way. Clipboard **read** has no legacy fallback, so a blocked or denied read (the app
served over plain http on a LAN address, or the browser prompt refused) is reported in the
status line instead of failing silently. Paste never touches the global sliders.
outside 0–1 are rejected the same way. Paste never touches the global sliders.
## E. Review & correction
+34 -15
View File
@@ -1407,8 +1407,8 @@ read it.
## 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).
> Code, parser and build verified below; the **paste round trip in a real browser is still
> owed** (neither a keyboard paste nor a `Ctrl+V` prefill can 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
@@ -1435,18 +1435,36 @@ read it.
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.
4. **Paste opens a review dialog** `frontend/src/components/PasteYamlDialog.jsx` (new, 83 lines)
— a textarea plus a live review line, nothing written until Apply. Why a dialog and not a
direct read: `navigator.clipboard.readText()` only exists in a secure context, so on plain
http over a LAN address it is *absent*, not merely refused — while a keyboard paste into a
focused textarea is an ordinary user gesture and is not gated at all. The dialog therefore
prefills when the browser allows and otherwise says so and waits for Ctrl+V.
`planPaste(text, classNames, containers)` (exported from `ClassParamsTable.jsx`) produces
the review line — matched classes, `container` flips, ignored classes, or the parse error —
and is what disables Apply. `applyPasted()` in the table does the writing: only classes
selected in this modal, per-field merge, and `container` diffs 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]** `planPaste` and `parseClassYaml` exercised through
`esbuild --bundle --platform=node`, both exported from the real module: a three-block document
returns `2 classes: sack, box · container: sack → true · ignored: trailer`; a flag already in
the wanted state is not reported as a flip (`1 class: box`); empty, `conf: 40`, `conf: 0.5abc`,
`- sack`, and a document naming only an unselected class each return the error that keeps
Apply disabled. The merge was checked too: pasting `conf`+`iou` over an existing
`box: {threshold: 0.55, iou_threshold: 0.8}` leaves `iou` at `0.8` and sends no phantom
zeros — `buildClassParams` strips the empty entries. First harness run showed `sack` written
empty, which turned out to be a bug in the harness itself (`{ key }` destructured out of
`['threshold']`), not in the component; the re-run confirms the merge. `Esc` closes and
returns focus to the trigger; Enter is deliberately not bound, YAML needs newlines; a nested
`position: fixed` overlay is safe here because nothing above `ClassParamsTable` has a
`transform`/`filter`/`backdrop-filter` to become its containing block (checked).
**Owed, user browser**: dialog opens focused, Ctrl+V fills it over plain http on a LAN
address, live line names the classes, Apply fills the four inputs and ticks Container, status
line reports the ignored class; `conf: 40` shows `line 2: …` with Apply disabled; Container
survives a modal reopen; `Esc` closes and focus is back on the button.
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`
@@ -1455,7 +1473,8 @@ read it.
`--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.
`ClassParamsTable.jsx` 300 lines and `PasteYamlDialog.jsx` 83, both inside the 400-line
rule.
## Task — Configurable Track Forward + track-to-end (REQ-189) `[DONE]`
+21 -9
View File
@@ -468,10 +468,10 @@ Two columns inside one dialog (920 px wide, max 96 vw / 90 vh, scrollable).
with revert on rejection — preview and batch job read the same stored flag.
- **Copy / Paste YAML (REQ-186, REQ-190)** — a small right-aligned toolbar above the overrides
block: two real buttons, `Copy YAML` and `Paste YAML`, each an inline SVG icon (Lucide copy /
clipboard-paste, no emoji) plus its label, `btn` styling at `0.76rem`, both disabled while a
paste is in flight. A `role="status"` line beside them carries the outcome — `Copied`,
`Pasted 3 classes`, `Pasted 2 classes — ignored crane`, or the failure in `--danger` — success
text clears itself after 2 s, a failure stays until the next action.
clipboard-paste, no emoji) plus its label, `btn` styling at `0.76rem`. A `role="status"` line
beside them carries the outcome — `Copied`, `Pasted 3 classes`, `Pasted 2 classes — ignored
crane`, or the failure in `--danger` — success text clears itself after 2 s, a failure stays
until the next action.
Copy puts one YAML block per selected class on the clipboard:
```yaml
@@ -487,8 +487,21 @@ Two columns inside one dialog (920 px wide, max 96 vw / 90 vh, scrollable).
Field names are the row labels as written on screen; a name is quoted only when YAML would
misread it (`"weird: name":`). An empty input copies the global value, so the block is a
complete configuration, not a diff.
Paste reads the clipboard and fills the overrides for the classes **selected in this modal**;
an unselected class is named in the status line and never written. Numbers merge per field.
Paste opens a dialog and never applies blind. A dark overlay card (`rgba(0,0,0,0.85)`, above
the auto-annotate modal in z-order, `role="dialog" aria-modal="true"`), the title *Paste
per-class overrides*, a monospace `textarea` focused on open, and a live review line under it
(`role="status"`, recomputed per keystroke, no debounce): the matched classes, the `container`
flags it would flip, the classes it would ignore — or the parse error in `--danger`.
**Apply stays disabled while the text does not parse**, so a typo cannot be clicked through;
*Cancel* is a ghost button and a backdrop click cancels. Keyboard paste works everywhere: the
dialog prefills from the clipboard only when the browser permits a read (localhost or https)
and otherwise says so and waits for Ctrl+V — no permission prompt and no https requirement.
`Esc` closes and returns focus to the *Paste YAML* button; **Enter is never bound to Apply**,
because YAML needs newlines. Focus moves into the textarea on open; no focus trap (nothing in
this app has one).
Apply fills the overrides for the classes **selected in this modal**; an unselected class is
named in the review line and the status line, and never written. Numbers merge per field — the
fields the block mentions are overwritten, the ones it omits keep their value.
`container` is saved to the server like ticking the box by hand (one PATCH for all changed
classes, reverted together on rejection, and the status line says the flags were not saved).
Parsing is strict and **atomic**: `#` comments (whole lines, or trailing on a value), `class:`
@@ -496,9 +509,7 @@ Two columns inside one dialog (920 px wide, max 96 vw / 90 vh, scrollable).
(`0.5abc` is a typo, not `0.5`), `container` `true|false|yes|no|1|0`; a list item, a nested
map, a top-level scalar, an unknown key, a malformed number, an out-of-range value or a
key before any class throws `line N: …` and applies **nothing**. A class repeated in the same
document merges rather than replacing. Where the browser blocks the clipboard read — the
app on plain http over a LAN address, or the permission prompt refused — the status line says
so rather than doing nothing.
document merges rather than replacing.
Both buttons live in the shared per-class table, so they exist in the engine-chooser modal
**and** the mass modal.
- `ClassPromptPanel`:
@@ -1161,6 +1172,7 @@ Handlers are attached in the **capture** phase on `document`, and:
| hold `S` | SAM3 assist mode while held | — |
| `Enter` | apply the exemplar preview (panel open) | — |
| `Esc` | discard the exemplar preview (panel open) | — |
| `Esc` | cancel the Paste YAML dialog, focus back on its trigger | — |
**In select mode the marquee owns `Delete` and the digits.** Otherwise a 40-box selection is
thrown away by one keystroke meant for it.
+47 -25
View File
@@ -1,7 +1,8 @@
import React, { useState } from 'react'
import React, { useRef, useState } from 'react'
import { api } from '../api'
import { copyText, readClipboardText } from '../clipboard'
import { copyText } from '../clipboard'
import { ClipboardPasteIcon, CopyIcon } from './Icons'
import PasteYamlDialog from './PasteYamlDialog'
// Per-class overrides of the job's globals (REQ-181). Empty input = inherit.
const KEYS = [
@@ -79,6 +80,28 @@ export function buildClassParams(classParams) {
return Object.keys(out).length > 0 ? out : undefined
}
// What the pasted text would do, with nothing written yet — the dialog runs this
// on every keystroke. Selected classes only: an override for a deselected class
// would ride along in class_params unseen (buildClassParams walks all of them).
export function planPaste(text, classNames, containers) {
let parsed
try {
parsed = parseClassYaml(text)
} catch (err) {
return { error: err.message }
}
const names = Object.keys(parsed)
if (!names.length) return { error: 'No classes in the pasted YAML' }
const known = names.filter((name) => classNames.includes(name))
if (!known.length) return { error: `None of these classes are selected here: ${names.join(', ')}` }
const unknown = names.filter((name) => !classNames.includes(name))
const flags = known.filter((name) => parsed[name].container !== undefined && parsed[name].container !== containers.has(name))
const parts = [`${known.length} ${known.length === 1 ? 'class' : 'classes'}: ${known.join(', ')}`]
if (flags.length) parts.push(`container: ${flags.map((name) => `${name} → ${parsed[name].container}`).join(', ')}`)
if (unknown.length) parts.push(`ignored: ${unknown.join(', ')}`)
return { summary: parts.join(' · ') }
}
export default function ClassParamsTable({ classNames, globals, value, onChange, project }) {
// Container flag (REQ-184): saved immediately on toggle, like the prompt
// saves in the modals; there is no error slot in this component, so a
@@ -87,7 +110,8 @@ export default function ClassParamsTable({ classNames, globals, value, onChange,
new Set((project?.classes || []).filter(c => c.container).map(c => c.name))
)
const [note, setNote] = useState({ text: '', failed: false })
const [busy, setBusy] = useState(false)
const [pasting, setPasting] = useState(false)
const triggerRef = useRef(null)
const say = (text, failed = false) => {
setNote({ text, failed })
if (!failed) setTimeout(() => setNote((n) => (n.text === text ? { text: '', failed: false } : n)), 2000)
@@ -153,17 +177,12 @@ export default function ClassParamsTable({ classNames, globals, value, onChange,
say('Could not copy — your browser refused the clipboard', true)
}
}
const paste = async () => {
setBusy(true)
try {
const parsed = parseClassYaml(await readClipboardText())
// Only reachable once planPaste() said the text parses, so no re-validation here.
const applyPasted = async (text) => {
const parsed = parseClassYaml(text)
const names = Object.keys(parsed)
if (!names.length) throw new Error('No classes in the pasted YAML')
const known = names.filter((name) => classNames.includes(name))
if (!known.length) throw new Error(`None of these classes are selected here: ${names.join(', ')}`)
const unknown = names.filter((name) => !classNames.includes(name))
// Only selected classes: an override for a deselected class would ride
// along in class_params unseen (buildClassParams walks all of them).
const next = { ...value }
const flags = new Set(containers)
const changes = []
@@ -181,19 +200,15 @@ export default function ClassParamsTable({ classNames, globals, value, onChange,
}
}
onChange(next)
setPasting(false)
const saved = await applyContainers(flags, changes)
const suffix = unknown.length ? ` — ignored ${unknown.join(', ')}` : ''
say(
saved
? `Pasted ${known.length} ${known.length === 1 ? 'class' : 'classes'}${suffix}`
: `Pasted ${known.length} ${known.length === 1 ? 'class' : 'classes'}${suffix} — container flags not saved`,
!saved
)
} catch (err) {
say(err.message, true)
} finally {
setBusy(false)
const what = `${known.length} ${known.length === 1 ? 'class' : 'classes'}${suffix}`
say(saved ? `Pasted ${what}` : `Pasted ${what} — container flags not saved`, !saved)
}
const closeDialog = () => {
setPasting(false)
triggerRef.current?.focus()
}
return (
<>
@@ -202,7 +217,6 @@ export default function ClassParamsTable({ classNames, globals, value, onChange,
type="button"
className="btn"
onClick={copy}
disabled={busy}
title="Copy these per-class overrides as YAML"
>
<CopyIcon /> Copy YAML
@@ -210,9 +224,9 @@ export default function ClassParamsTable({ classNames, globals, value, onChange,
<button
type="button"
className="btn"
onClick={paste}
disabled={busy}
title="Fill these overrides from YAML on the clipboard"
ref={triggerRef}
onClick={() => setPasting(true)}
title="Fill these overrides from YAML — paste the text into a box"
>
<ClipboardPasteIcon /> Paste YAML
</button>
@@ -220,6 +234,14 @@ export default function ClassParamsTable({ classNames, globals, value, onChange,
{note.text}
</span>
</div>
{pasting && (
<PasteYamlDialog
describe={(text) => planPaste(text, classNames, containers)}
placeholder={`# auto-annotate per-class overrides\n${classNames[0] || 'class'}:\n conf: 0.45\n container: false`}
onCancel={closeDialog}
onApply={applyPasted}
/>
)}
{/* Stacked rows (REQ-181): the name line carries the container flag, the
grid below it the four overrides, so a wide class list never squeezes
the numbers or strands the checkbox away from its class. */}
@@ -0,0 +1,84 @@
import React, { useEffect, useRef, useState } from 'react'
import { readClipboardText } from '../clipboard'
// Clipboard *read* only exists on a secure origin, but Ctrl+V into a textarea is
// a plain user gesture, so prefill is a nicety and the box is the real path.
export default function PasteYamlDialog({ describe, placeholder, onCancel, onApply }) {
const [text, setText] = useState('')
const [hint, setHint] = useState('')
const boxRef = useRef(null)
useEffect(() => {
boxRef.current?.focus()
readClipboardText().then(setText, () =>
setHint('Press Ctrl+V — the browser only lets this page read the clipboard on localhost or https')
)
}, [])
// Escape closes; Enter is left to the textarea because YAML needs newlines.
useEffect(() => {
function onKey(event) {
if (event.key !== 'Escape') return
event.preventDefault()
event.stopPropagation()
onCancel()
}
window.addEventListener('keydown', onKey, true)
return () => window.removeEventListener('keydown', onKey, true)
}, [onCancel])
// describe(text) -> { error } | { summary }. Recomputed per keystroke: a few
// hundred bytes through one pure function is not worth debouncing.
const review = describe(text)
const failed = Boolean(review.error)
return (
<div
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.85)', display: 'flex',
alignItems: 'center', justifyContent: 'center', zIndex: 10000, padding: 16 }}
onClick={onCancel}
>
<div
className="panel"
role="dialog"
aria-modal="true"
aria-label="Paste per-class overrides"
onClick={(event) => event.stopPropagation()}
style={{ width: 560, maxWidth: '96vw', background: '#18181b', padding: 18,
border: '1px solid rgba(168, 85, 247, 0.4)', borderRadius: 8 }}
>
<h3 style={{ margin: '0 0 4px', color: '#e4e4e7', fontSize: '0.95rem' }}>
Paste per-class overrides
</h3>
<p className="hint" style={{ margin: '0 0 10px', fontSize: '0.76rem' }}>
{hint}
</p>
<textarea
ref={boxRef}
className="mono"
rows={11}
value={text}
spellCheck={false}
placeholder={placeholder}
aria-label="Per-class override YAML"
aria-describedby="yaml-review"
onChange={(event) => setText(event.target.value)}
style={{ resize: 'vertical', fontSize: '0.78rem', lineHeight: 1.45 }}
/>
<p
id="yaml-review"
role="status"
style={{ margin: '8px 0 0', minHeight: '1.2em', fontSize: '0.76rem',
color: failed ? 'var(--danger)' : 'var(--text-muted)' }}
>
{failed ? review.error : review.summary}
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 14 }}>
<button type="button" className="btn btn-ghost" onClick={onCancel}>Cancel</button>
<button type="button" className="btn" disabled={failed || !text.trim()} onClick={() => onApply(text)}>
Apply
</button>
</div>
</div>
</div>
)
}