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

+12 -6
View File
@@ -363,12 +363,18 @@ per-class overrides block (REQ-181) stacks one row per class — name plus **Con
checkbox on the first line, the four override inputs in a wrapping grid below — and that checkbox on the first line, the four override inputs in a wrapping grid below — and that
checkbox toggles checkbox toggles
`project_classes.container` through `PATCH /api/projects/{id} { containers: … }`. The same `project_classes.container` through `PATCH /api/projects/{id} { containers: … }`. The same
block carries a **Copy** button (REQ-186): one click puts every selected class's effective block carries a **Copy** and a **Paste** button (REQ-186, REQ-190): one click puts every
settings (per-class override where set, else the global slider; container = the checkbox selected class's effective settings (per-class override where set, else the global slider;
state) on the clipboard as short lines `class <name> conf … iou … minbox … maxbox … container container = the checkbox state) on the clipboard as **YAML** — one block per class, fields
true|false`, through `clipboard.js`'s `copyText` (Clipboard API with an `execCommand` named after the row labels (`conf`, `iou`, `minbox`, `maxbox`, `container`), through
fallback for insecure contexts) — read-only, no network, no setting changed. Because `clipboard.js`'s `copyText` (Clipboard API with an `execCommand` fallback for insecure
`ClassParamsTable` is shared, the mass modal carries the button too. 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
`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
`ClassParamsTable` is shared, the mass modal carries both buttons too.
**Review sidebar class rows (REQ-180, REQ-183, REQ-185).** Each class row's frame-clear `×` **Review sidebar class rows (REQ-180, REQ-183, REQ-185).** Each class row's frame-clear `×`
(REQ-180) gains an eye toggle in front of it: session-only `Set` state in `ReviewPage`, so it (REQ-180) gains an eye toggle in front of it: session-only `Set` state in `ReviewPage`, so it
+25 -6
View File
@@ -141,14 +141,17 @@ changes.
returns that class to its full-set detections, so clearing them all is a plain full-set view returns that class to its full-set detections, so clearing them all is a plain full-set view
again. The preview and the batch job still use the same thresholds and per-class overrides. again. The preview and the batch job still use the same thresholds and per-class overrides.
- **REQ-186** — The auto-annotate parameters table has a **copy button** that puts every - **REQ-186** — The auto-annotate parameters table has a **copy button** that puts every
selected class's effective settings on the clipboard as short text, one class per line: selected class's effective settings on the clipboard as **YAML**, one class per block:
`class <name> conf <v> iou <v> minbox <v> maxbox <v> container <true|false>`. Effective `"<name>:"` at the left margin then the five fields `conf`, `iou`, `minbox`, `maxbox` (the
means the per-class override where one is set, otherwise the global slider value; labels as written on screen) and `container: <true|false>`, each on its own indented line,
preceded by a `#` comment line. A class name is quoted only when YAML would misread it.
Effective means the per-class override where one is set, otherwise the global slider value;
`container` is the class's current checkbox state. The button sits in the shared `container` is the class's current checkbox state. The button sits in the shared
per-class table, so it exists in both the per-batch auto-annotate modal and the mass per-class table, so it exists in both the per-batch auto-annotate modal and the mass
(batches tab) modal, and the copied text is a plain baseline for later tuning — no (batches tab) modal, and the copied YAML is a plain baseline for later tuning. Copying needs
structure beyond the line format. Copying needs no network round-trip and never changes no network round-trip and never changes any setting. (Amended by REQ-188: `maxbox` added to
any setting. (Amended by REQ-188: `maxbox` added to the line.) the block. Amended by REQ-190: the wire format is YAML instead of the old one-line
`class <name> conf <v> …` text, and a **Paste** button round-trips it.)
- **REQ-188** — Auto-annotation accepts a per-class **maximum box fraction** - **REQ-188** — Auto-annotation accepts a per-class **maximum box fraction**
(`max_box_frac`, default `1.0` = off) beside the existing minimum override of REQ-181: (`max_box_frac`, default `1.0` = off) beside the existing minimum override of REQ-181:
@@ -157,6 +160,22 @@ changes.
review assist filter), surfaces as `MaxBox` in the params table, and is included in the 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-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-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.
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.
## E. Review & correction ## E. Review & correction
+52
View File
@@ -1405,6 +1405,58 @@ read it.
(accent border + glow) — no CSS added; **browser eyeball still owed by the user** (accent border + glow) — no CSS added; **browser eyeball still owed by the user**
(375 px 2-col wrap, long class names). (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]` ## 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 > Status caveat: code, build and docs are verified below; the **end-to-end GPU run in the
+35 -6
View File
@@ -466,12 +466,41 @@ Two columns inside one dialog (920 px wide, max 96 vw / 90 vh, scrollable).
unmarked classes are governed by IoU alone. Toggling is optimistic unmarked classes are governed by IoU alone. Toggling is optimistic
`PATCH /projects/{id} { containers: { [classId]: bool } }` into `project_classes.container` `PATCH /projects/{id} { containers: { [classId]: bool } }` into `project_classes.container`
with revert on rejection — preview and batch job read the same stored flag. with revert on rejection — preview and batch job read the same stored flag.
- **Copy (REQ-186)** — small right-aligned ghost button above the overrides block; label - **Copy / Paste YAML (REQ-186, REQ-190)** — a small right-aligned toolbar above the overrides
`Copy`, flips to `Copied` for 1.5 s inside an `aria-live` span, and on failure silently block: two real buttons, `Copy YAML` and `Paste YAML`, each an inline SVG icon (Lucide copy /
stays `Copy`. One line per selected class, e.g. clipboard-paste, no emoji) plus its label, `btn` styling at `0.76rem`, both disabled while a
`class sack conf 0.4 iou 0.6 minbox 0.005 maxbox 1 container false` — an empty input copies the paste is in flight. A `role="status"` line beside them carries the outcome — `Copied`,
global value. Present in the engine-chooser modal **and** the mass modal (shared `Pasted 3 classes`, `Pasted 2 classes — ignored crane`, or the failure in `--danger` — success
component). 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
# auto-annotate per-class overrides
sack:
conf: 0.4
iou: 0.6
minbox: 0.005
maxbox: 1
container: false
```
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.
`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:`
at the left margin, `key: value` indented under it, numbers in 0–1 written as numbers
(`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.
Both buttons live in the shared per-class table, so they exist in the engine-chooser modal
**and** the mass modal.
- `ClassPromptPanel`: - `ClassPromptPanel`:
- Target-class chips. Without SAM3 a chip is a simple toggle. - Target-class chips. Without SAM3 a chip is a simple toggle.
- **With SAM3 a chip is two buttons**: the *name* selects-and-activates, the `×` deselects. - **With SAM3 a chip is two buttons**: the *name* selects-and-activates, the `×` deselects.
+33
View File
@@ -657,6 +657,39 @@ main.page {
font-size: 0.78rem; font-size: 0.78rem;
} }
/* Copy/Paste YAML above the per-class override rows (REQ-190). */
.class-params-tools {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 6px;
}
.class-params-tools .btn {
padding: 4px 10px;
font-size: 0.76rem;
cursor: pointer;
}
.class-params-tools .btn:disabled {
cursor: progress;
opacity: 0.6;
}
.class-params-note {
/* Not .hint: --text-faint is 2.7:1 on the modal panel, this text needs 4.5:1. */
color: var(--text-muted);
font-size: 0.72rem;
text-align: right;
min-width: 0;
}
.class-params-note[data-failed] {
color: var(--danger);
}
.review-side { .review-side {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+13
View File
@@ -1,6 +1,8 @@
// Clipboard API needs a secure context — on http://<ip>:9000 it is undefined. // Clipboard API needs a secure context — on http://<ip>:9000 it is undefined.
// execCommand('copy') still works there inside a click gesture; if both fail, // execCommand('copy') still works there inside a click gesture; if both fail,
// the path is revealed in a pre-selected field so one Ctrl+C always works. // the path is revealed in a pre-selected field so one Ctrl+C always works.
// Reading has no legacy fallback (execCommand('paste') is not a thing), so it
// reports why instead of failing quietly.
function legacyCopy(text) { function legacyCopy(text) {
const area = document.createElement('textarea') const area = document.createElement('textarea')
area.value = text area.value = text
@@ -34,3 +36,14 @@ export async function copyText(text) {
return false return false
} }
} }
export async function readClipboardText() {
if (!navigator.clipboard?.readText) {
throw new Error('Clipboard read is blocked here — open the app on localhost or https to paste')
}
try {
return await navigator.clipboard.readText()
} catch {
throw new Error('Clipboard read was denied — allow it in the browser prompt, then paste again')
}
}
+159 -31
View File
@@ -1,6 +1,7 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { api } from '../api' import { api } from '../api'
import { copyText } from '../clipboard' import { copyText, readClipboardText } from '../clipboard'
import { ClipboardPasteIcon, CopyIcon } from './Icons'
// Per-class overrides of the job's globals (REQ-181). Empty input = inherit. // Per-class overrides of the job's globals (REQ-181). Empty input = inherit.
const KEYS = [ const KEYS = [
@@ -10,6 +11,59 @@ const KEYS = [
{ key: 'max_box_frac', label: 'MaxBox', step: 0.05, color: '#f472b6' }, { key: 'max_box_frac', label: 'MaxBox', step: 0.05, color: '#f472b6' },
] ]
// The YAML wire format uses the labels as written on screen (REQ-190).
const FIELD_BY_LABEL = Object.fromEntries(KEYS.map(({ key, label }) => [label.toLowerCase(), key]))
// A class name only needs quoting when YAML would misread it.
const PLAIN_NAME = /^[A-Za-z0-9][A-Za-z0-9 _-]*$/
const yamlName = (name) => (PLAIN_NAME.test(name) ? name : `"${name.replace(/["\\]/g, '\\$&')}"`)
// parseFloat would take `0.5abc` as 0.5 and hide the typo, so the text has to
// look like a number first. Inline `# comment` is stripped from the value only,
// never from the key — a quoted class name may legitimately contain " #".
const NUMBER = /^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/
const TRUE_FALSE = { true: true, yes: true, '1': true, false: false, no: false, '0': false }
// Strict reader for exactly what compose() emits: `#` comments and blank lines,
// a class name at the left margin, `key: value` indented under it. Anything
// else throws with its line number so a bad paste changes nothing at all.
export function parseClassYaml(text) {
const classes = {}
let current = null
text.split(/\r?\n/).forEach((raw, i) => {
const line = raw.trim()
const at = `line ${i + 1}`
if (!line || line.startsWith('#')) return
const match = line.match(/^(".*"|'.*'|[^:]+):\s*(.*)$/)
if (!match) throw new Error(`${at}: expected "class:" or "key: value"`)
let name = match[1].trim()
if (name.startsWith('"') && name.endsWith('"')) name = name.slice(1, -1).replace(/\\(["\\])/g, '$1')
else if (name.startsWith("'") && name.endsWith("'")) name = name.slice(1, -1).replace(/''/g, "'")
const value = match[2].replace(/\s+#.*$/, '').trim()
if (!/^[ \t]/.test(raw)) {
if (value) throw new Error(`${at}: unexpected value "${value}"`)
current = name
classes[name] = classes[name] || {}
return
}
if (!current) throw new Error(`${at}: "${name}" before any class name`)
if (!value) throw new Error(`${at}: nested values are not supported`)
if (name === 'container') {
const flag = TRUE_FALSE[value.toLowerCase()]
if (flag === undefined) throw new Error(`${at}: container must be true or false, got "${value}"`)
classes[current].container = flag
return
}
const field = FIELD_BY_LABEL[name]
if (!field) throw new Error(`${at}: unknown key "${name}"`)
if (!NUMBER.test(value)) throw new Error(`${at}: ${name} must be a number, got "${value}"`)
const num = parseFloat(value)
if (num < 0 || num > 1) throw new Error(`${at}: ${name} must be between 0 and 1, got ${value}`)
classes[current][field] = num
})
return classes
}
export function buildClassParams(classParams) { export function buildClassParams(classParams) {
const out = {} const out = {}
for (const [name, values] of Object.entries(classParams || {})) { for (const [name, values] of Object.entries(classParams || {})) {
@@ -32,28 +86,47 @@ export default function ClassParamsTable({ classNames, globals, value, onChange,
const [containers, setContainers] = useState(() => const [containers, setContainers] = useState(() =>
new Set((project?.classes || []).filter(c => c.container).map(c => c.name)) new Set((project?.classes || []).filter(c => c.container).map(c => c.name))
) )
const [copied, setCopied] = useState(false) const [note, setNote] = useState({ text: '', failed: false })
const [busy, setBusy] = useState(false)
const say = (text, failed = false) => {
setNote({ text, failed })
if (!failed) setTimeout(() => setNote((n) => (n.text === text ? { text: '', failed: false } : n)), 2000)
}
const set = (name, key, raw) => const set = (name, key, raw) =>
onChange({ ...value, [name]: { ...(value[name] || {}), [key]: raw } }) onChange({ ...value, [name]: { ...(value[name] || {}), [key]: raw } })
const toggleContainer = async (cls) => {
const on = !containers.has(cls.name) // One PATCH for every flag that changed, reverted together on rejection.
const next = new Set(containers) const applyContainers = async (next, changes) => {
if (on) next.add(cls.name) const patch = {}
else next.delete(cls.name) for (const [name, on] of changes) {
const cls = project?.classes.find((c) => c.name === name)
if (cls) patch[cls.class_id] = on
}
setContainers(next) setContainers(next)
if (!Object.keys(patch).length) return true
try { try {
await api.patchProject(project.id, { containers: { [cls.class_id]: on } }) await api.patchProject(project.id, { containers: patch })
return true
} catch (err) { } catch (err) {
// Functional revert: only undo this toggle, even if another landed meanwhile. console.error('Could not save the container flags:', err)
setContainers(prev => { setContainers((prev) => {
const reverted = new Set(prev) const reverted = new Set(prev)
if (on) reverted.delete(cls.name) for (const [name, on] of changes) {
else reverted.add(cls.name) if (on) reverted.delete(name)
else reverted.add(name)
}
return reverted return reverted
}) })
console.error('Could not save the container flag:', err) return false
} }
} }
const toggleContainer = (cls) =>
applyContainers(
containers.has(cls.name)
? new Set([...containers].filter((n) => n !== cls.name))
: new Set(containers).add(cls.name),
[[cls.name, !containers.has(cls.name)]]
)
if (!classNames.length) return null if (!classNames.length) return null
const fmt = (v) => String(Math.round(v * 1000) / 1000) const fmt = (v) => String(Math.round(v * 1000) / 1000)
const effective = (name, key) => { const effective = (name, key) => {
@@ -62,35 +135,90 @@ export default function ClassParamsTable({ classNames, globals, value, onChange,
return Number.isFinite(num) ? num : globals[key] return Number.isFinite(num) ? num : globals[key]
} }
const compose = () => const compose = () =>
classNames [
.map( '# auto-annotate per-class overrides',
(name) => ...classNames.map((name) =>
`class ${name} conf ${fmt(effective(name, 'threshold'))} iou ${fmt( [
effective(name, 'iou_threshold') `${yamlName(name)}:`,
)} minbox ${fmt(effective(name, 'min_box_frac'))} maxbox ${fmt( ...KEYS.map(({ key, label }) => ` ${label.toLowerCase()}: ${fmt(effective(name, key))}`),
effective(name, 'max_box_frac') ` container: ${containers.has(name)}`,
)} container ${containers.has(name)}` ].join('\n')
) ),
.join('\n') ].join('\n')
const copy = async () => { const copy = async () => {
const ok = await copyText(compose()) const ok = await copyText(compose())
if (ok) { if (ok) say('Copied')
setCopied(true) else {
setTimeout(() => setCopied(false), 1500)
} else {
console.error('Could not copy the effective auto-annotate parameters') console.error('Could not copy the effective auto-annotate parameters')
say('Could not copy — your browser refused the clipboard', true)
}
}
const paste = async () => {
setBusy(true)
try {
const parsed = parseClassYaml(await readClipboardText())
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 = []
for (const name of known) {
const merged = { ...(next[name] || {}) }
for (const { key } of KEYS) {
if (parsed[name][key] !== undefined) merged[key] = String(parsed[name][key])
}
next[name] = merged
const on = parsed[name].container
if (on !== undefined && on !== flags.has(name)) {
if (on) flags.add(name)
else flags.delete(name)
changes.push([name, on])
}
}
onChange(next)
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)
} }
} }
return ( return (
<> <>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}> <div className="class-params-tools">
<button <button
className="btn btn-ghost" type="button"
className="btn"
onClick={copy} onClick={copy}
style={{ fontSize: '0.76rem', padding: '4px 8px', cursor: 'pointer' }} disabled={busy}
title="Copy these per-class overrides as YAML"
> >
<span aria-live="polite">{copied ? 'Copied' : 'Copy'}</span> <CopyIcon /> Copy YAML
</button> </button>
<button
type="button"
className="btn"
onClick={paste}
disabled={busy}
title="Fill these overrides from YAML on the clipboard"
>
<ClipboardPasteIcon /> Paste YAML
</button>
<span className="class-params-note" role="status" data-failed={note.failed || undefined}>
{note.text}
</span>
</div> </div>
{/* Stacked rows (REQ-181): the name line carries the container flag, the {/* 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 grid below it the four overrides, so a wide class list never squeezes
+15
View File
@@ -95,6 +95,21 @@ export const CpuIcon = (props) => (
</Icon> </Icon>
) )
export const CopyIcon = (props) => (
<Icon {...props}>
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</Icon>
)
export const ClipboardPasteIcon = (props) => (
<Icon {...props}>
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
<rect width="8" height="4" x="8" y="2" rx="1" ry="1" />
<path d="M12 11v4M10 13h4" />
</Icon>
)
export const RocketIcon = (props) => ( export const RocketIcon = (props) => (
<Icon {...props}> <Icon {...props}>
<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.71.79-1.81.79-1.81l-3-3s-1.1.08-1.79.81z" /> <path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.71.79-1.81.79-1.81l-3-3s-1.1.08-1.79.81z" />