Edit: settling an interaction keeps the warning of the preview it applies

With Real-time Drawing off, a command that ends a drag first (Undo, Redo,
Clear History, a plug-in command and the others) applies the drag's
deferred preview, but dropped the warning that preview gave, which
releasing the drag shows in the status bar.

- The applied preview's warning is now kept while a top-level command or
  the tool's actions run. A command's result carries it as
  `previewWarning`, and as `warning` when the command has no warning of
  its own, so the status bar shows it.
- Typing kept as its own step when a tool's drag begins, or when Cmd
  lends the Selection tool, shows the warning in the status bar.
- The Cmd-lend path no longer ignores an error from keeping the typing.

Docs: docs/mcp.md (undo/redo row), docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-11-13 04:36:38 -08:00
parent 1dbdbce91f
commit f918c91c13
9 changed files with 182 additions and 16 deletions
+13 -3
View File
@@ -678,12 +678,22 @@ fn undo_group_end(s: &mut Session, p: &Value) -> Result<Value> {
/// under an interaction that would then preview on, or cancel to, the document from before (#1719).
/// A typing session also ends in the Type tool, whose caret and marked text (IME) must follow the
/// document. A preview that Real-time Drawing off left unapplied is applied first, as the tool's own
/// commit applies it; when it fails, the interaction is canceled.
/// commit applies it; when it fails, the interaction is canceled. The applied preview's warning
/// goes to the running command's result or the tool's status messages, as the tool's own commit
/// reports it ([`Session::note_settled_warning`]).
pub(crate) fn settle_interaction(s: &mut Session) -> Result<()> {
let typing = typing_in_progress(s);
match s.unapplied_interaction_preview().filter(|_| !s.previewing) {
Some((cmd, p)) if s.preview(&cmd, &p).is_err() => s.cancel_interaction()?,
_ => s.commit_interaction()?,
Some((cmd, p)) => match s.preview(&cmd, &p) {
Ok(v) => {
if let Some(w) = v.get("warning").and_then(Value::as_str) {
s.note_settled_warning(w);
}
s.commit_interaction()?
}
Err(_) => s.cancel_interaction()?,
},
None => s.commit_interaction()?,
}
if typing {
s.set_tool_option("endTyping", &Value::Bool(true));
+3
View File
@@ -286,6 +286,9 @@ pub struct Session {
/// While the active tool's actions run: their commands aren't "a command from outside the
/// tool" ([`Session::after_command`]).
pub(crate) in_tool_actions: bool,
/// The warnings of the previews that settling an interaction applied, while a top-level command
/// or the tool's actions collect them ([`Session::collect_settled_warnings`]).
settled_warnings: Option<Vec<String>>,
/// While [`Session::preview`] runs its command: its edits are the interaction's own.
previewing: bool,
/// The images Remove Background decoded during the interaction's preview steps, emptied when
+1
View File
@@ -116,6 +116,7 @@ impl Session {
screens_hand_off: None,
recovery: Default::default(),
in_tool_actions: false,
settled_warnings: None,
previewing: false,
decoded_images: Default::default(),
envelope_defaults: None,
+21
View File
@@ -234,6 +234,27 @@ impl Session {
self.active().and_then(|d| d.interaction.as_ref().filter(|it| std::sync::Arc::ptr_eq(&it.doc, &d.doc)).and_then(|it| it.preview.clone()))
}
/// Keep `warning`, from a preview that settling an interaction applied, for whatever collects
/// such warnings now ([`Session::collect_settled_warnings`]). Nothing keeps it when nothing
/// collects, so it is never reported with a later, unrelated command.
pub(crate) fn note_settled_warning(&mut self, warning: &str) {
if let Some(kept) = &mut self.settled_warnings
&& !kept.iter().any(|w| w == warning)
{
kept.push(warning.to_string());
}
}
/// Run `f`, and → its result with the warnings of the previews that settling an interaction
/// applied while it ran ([`Session::note_settled_warning`]). Warnings an outer `f` collects are
/// kept for it.
pub(crate) fn collect_settled_warnings<R>(&mut self, f: impl FnOnce(&mut Self) -> R) -> (R, Vec<String>) {
let outer = self.settled_warnings.replace(Vec::new());
let r = f(self);
let settled = std::mem::replace(&mut self.settled_warnings, outer).unwrap_or_default();
(r, settled)
}
pub fn begin_interaction(&mut self, label: &str) -> Result<()> {
if self.script_running() {
return Err(EngineError::Busy);
+51 -1
View File
@@ -3,6 +3,27 @@ use super::{
strip_replay_only, waits_for_tool_edit,
};
/// A command's result `r` with the warnings of the previews that settling an interaction applied
/// before it ran: `previewWarning` holds them, and `warning` too when the command gives none of its
/// own, so the status bar shows the command's own warning when there is one. A null result becomes
/// an object holding them; any other result that isn't an object is returned as it is.
fn with_settled_warnings(mut r: Value, settled: &[String]) -> Value {
if settled.is_empty() {
return r;
}
if r.is_null() {
r = Value::Object(serde_json::Map::new());
}
if let Value::Object(m) = &mut r {
let w = Value::String(settled.join("; "));
if m.get("warning").is_none_or(Value::is_null) {
m.insert("warning".into(), w.clone());
}
m.insert("previewWarning".into(), w);
}
r
}
impl Session {
/// `spec`'s `enabled` with `ids` selected instead of the selection, which is then put back.
fn enabled_with_selection(&mut self, spec: &CommandSpec, ids: Vec<NodeId>) -> std::result::Result<(), String> {
@@ -54,7 +75,14 @@ impl Session {
self.finish_tool_edit_later()?;
}
let outer = std::mem::replace(&mut self.reading, !spec.journal || JOURNALED_READS.contains(&id));
let r = self.execute_spec(spec, id, params);
// A preview that a top-level command applied first (a drag with Real-time Drawing off, kept
// before Undo) reports its warning in the command's result.
let r = if self.depth == 0 {
let (r, settled) = self.collect_settled_warnings(|s| s.execute_spec(spec, id, params));
r.map(|v| with_settled_warnings(v, &settled))
} else {
self.execute_spec(spec, id, params)
};
self.reading = outer;
r
}
@@ -364,3 +392,25 @@ impl Session {
r
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::with_settled_warnings;
/// The command's own warning stays the one the status bar shows, and the settled preview's is
/// kept beside it; a null result holds the preview's; other results are left as they are.
#[test]
fn settled_warnings_join_the_result() {
let settled = ["a".to_string(), "b".to_string()];
assert_eq!(
with_settled_warnings(json!({"ran": 1, "warning": "own"}), &settled),
json!({"ran": 1, "warning": "own", "previewWarning": "a; b"})
);
assert_eq!(with_settled_warnings(json!({"warning": null}), &settled), json!({"warning": "a; b", "previewWarning": "a; b"}));
assert_eq!(with_settled_warnings(json!(null), &settled[..1]), json!({"warning": "a", "previewWarning": "a"}));
assert_eq!(with_settled_warnings(json!([1]), &settled), json!([1]));
assert_eq!(with_settled_warnings(json!({"ran": 1}), &[]), json!({"ran": 1}));
}
}
+59
View File
@@ -2473,6 +2473,65 @@ fn deferred_commit_forwards_preview_warnings() {
assert!(ui.iter().any(|r| matches!(r, UiRequest::Status(m) if m.contains("type"))), "{ui:?}");
}
/// A session with a rectangle selected, and type too with `with_type`, tool `tool` chosen,
/// Real-time Drawing off, and an interaction labeled `label` whose deferred Bloat preview warns
/// that type is skipped when type is selected.
fn deferred_bloat(label: &str, with_type: bool, tool: &str) -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let rect = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 200, "height": 200})).unwrap();
let text = s.execute("text.create", &json!({"x": 300, "y": 200, "text": "Hi"})).unwrap();
let ids = if with_type { json!([rect["id"], text["id"]]) } else { json!([rect["id"]]) };
s.execute("select.set", &json!({ "ids": ids })).unwrap();
s.select_tool(tool, ViewInfo::default()).unwrap();
set_pref(&mut s, "realTimeDrawing", json!(false));
s.begin_interaction(label).unwrap();
let p = json!({"tool": "bloat", "points": [[290.0, 200.0]], "width": 200.0, "height": 200.0});
s.doc_mut().unwrap().interaction.as_mut().unwrap().preview = Some(("object.liquify".into(), p));
s
}
/// A command that applies a deferred preview before it runs (Undo, Clear History) reports the
/// preview's warning in its result, as the tool's own commit reports it; the preview is its own
/// step, which Undo takes back. A preview without a warning adds none.
#[test]
fn settling_a_deferred_preview_keeps_its_warning() {
let mut s = deferred_bloat("Bloat", true, "selection");
let before = s.doc().unwrap().history.undo.len();
let r = s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(r["undone"], "Bloat", "{r}");
assert!(r["warning"].as_str().is_some_and(|w| w.contains("type")), "{r}");
assert_eq!(r["previewWarning"], r["warning"], "{r}");
assert_eq!(s.doc().unwrap().history.undo.len(), before, "the preview was a step of its own, and Undo took it back");
let r = s.execute("edit.redo", &json!({})).unwrap();
assert!(r.get("warning").is_none() && r.get("previewWarning").is_none(), "nothing was settled: {r}");
let mut s = deferred_bloat("Bloat", true, "selection");
let r = s.execute("edit.clearHistory", &json!({})).unwrap();
assert!(r["warning"].as_str().is_some_and(|w| w.contains("type")), "{r}");
let mut s = deferred_bloat("Bloat", false, "selection");
let r = s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(r, json!({"undone": "Bloat"}));
}
/// Typing kept as its own step when a tool's drag begins, or when a Cmd press lends the Selection
/// tool, shows its applied preview's warning in the status bar.
#[test]
fn settling_typing_for_a_tool_shows_its_preview_warning() {
use vectorcraft_tools::{Action, Mods};
let warned = |ui: &[UiRequest]| ui.iter().any(|r| matches!(r, UiRequest::Status(m) if m.contains("type")));
let mut s = deferred_bloat("Typing", true, "selection");
let ui = s.apply_actions(vec![Action::Begin("Move".into())]).unwrap();
assert!(warned(&ui), "{ui:?}");
let mut s = deferred_bloat("Typing", true, "rectangle");
let cmd = Mods { cmd: true, ..Default::default() };
let ui = s.pointer(&PointerEvent::new(PointerKind::Down, 50.0, 380.0).with_mods(cmd), ViewInfo::default()).unwrap();
assert!(warned(&ui), "{ui:?}");
s.pointer(&PointerEvent::new(PointerKind::Up, 50.0, 380.0).with_mods(cmd), ViewInfo::default()).unwrap();
}
/// File Handling › Use Low Resolution Proxy for Linked EPS (#394): off by default; toggling it does
/// not replace full-resolution pixels already in the document.
#[test]
+32 -11
View File
@@ -75,6 +75,15 @@ fn nine_slice_guide_preview(cmd: &str, p: &Value) -> bool {
&& p.as_object().is_some_and(|m| m.contains_key("nineSliceGuides") && m.keys().all(|k| k == "name" || k == "nineSliceGuides"))
}
/// `ui` with a status message first for each of `warnings` (which came before its requests) that it
/// doesn't already show.
fn with_status(mut ui: Vec<UiRequest>, warnings: Vec<String>) -> Vec<UiRequest> {
let new: Vec<UiRequest> =
warnings.into_iter().filter(|w| !ui.iter().any(|r| matches!(r, UiRequest::Status(s) if s == w))).map(UiRequest::Status).collect();
ui.splice(0..0, new);
ui
}
/// Requests from tools that only the frontend can fulfil.
#[derive(Clone, Debug, PartialEq)]
pub enum UiRequest {
@@ -227,24 +236,28 @@ impl Session {
/// A press with Cmd held (Ctrl elsewhere) and any other tool than a selection tool: the
/// selection tool [`Self::lent_tool`] takes the gesture, pressing without Cmd (a plain click,
/// not Select Behind). The tool lent to stays as it was, unaware, until the release.
fn lend_selection_tool(&mut self, ev: &PointerEvent) -> PointerEvent {
/// not Select Behind). The tool lent to stays as it was, unaware, until the release. → the event
/// for the tool, and the warnings of a preview that keeping the typing applied.
fn lend_selection_tool(&mut self, ev: &PointerEvent) -> Result<(PointerEvent, Vec<String>)> {
if ev.kind != PointerKind::Down || !ev.mods.cmd || self.lender.is_some() {
return *ev;
return Ok((*ev, vec![]));
}
let Some(id) = self.lent_tool(self.tool.id(), &ev.mods) else { return *ev };
let Some(id) = self.lent_tool(self.tool.id(), &ev.mods) else { return Ok((*ev, vec![])) };
// Typing in progress is kept as its own step first: the lent tool's drag previews from the
// document its interaction started from, which would drop the typing (#1881).
// document its interaction started from, which would drop the typing (#1881). When it
// can't be kept, the press is not lent.
let mut settled = vec![];
if crate::cmd::typing_in_progress(self) {
// Fails only without a document, and then there is no typing to keep.
let _ = crate::cmd::settle_interaction(self);
let (r, warnings) = self.collect_settled_warnings(crate::cmd::settle_interaction);
r?;
settled = warnings;
}
// The Selection and Group Selection tools act on the instance whole.
self.select_whole_instance(id);
let lent = self.make_tool(id);
self.selection_at_lend = self.active().map(|d| (d.uid, d.selection.clone()));
self.lender = Some(std::mem::replace(&mut self.tool, lent));
PointerEvent { mods: Mods { cmd: false, ..ev.mods }, ..*ev }
Ok((PointerEvent { mods: Mods { cmd: false, ..ev.mods }, ..*ev }, settled))
}
/// The lent selection tool's gesture is over: the tool it was lent to is back as it was (the
@@ -659,7 +672,8 @@ impl Session {
}
self.leave_draw_inside(ev, view)?;
self.tell_tool_history_moved();
let ev = &self.lend_selection_tool(ev);
let (ev, settled) = self.lend_selection_tool(ev)?;
let ev = &ev;
let lent = self.lender.is_some();
let mut acts = self.with_tool_cx(view, |t, cx| t.pointer(cx, ev));
if !keep {
@@ -670,7 +684,7 @@ impl Session {
if keep && ev.kind == PointerKind::Up {
self.keep_tool_settings();
}
self.apply_actions(acts)
self.apply_actions(acts).map(|ui| with_status(ui, settled))
});
// The tool's own edits do not move the history for it. A selection tool that Cmd lends
// edits for the lender, which hears of the edit at its next event.
@@ -1375,7 +1389,14 @@ impl Session {
/// Apply tool actions. Errors from previews are reported but keep the interaction alive.
pub fn apply_actions(&mut self, acts: Vec<Action>) -> Result<Vec<UiRequest>> {
let outer = std::mem::replace(&mut self.in_tool_actions, true);
let r = self.apply_tool_actions(acts);
// A preview that settling applied (typing kept as its own step before a drag begins) shows
// its warning in the status bar, as the tool's own commit shows its preview's.
let r = if !outer && self.depth == 0 {
let (r, settled) = self.collect_settled_warnings(|s| s.apply_tool_actions(acts));
r.map(|ui| with_status(ui, settled))
} else {
self.apply_tool_actions(acts)
};
self.in_tool_actions = outer;
if !outer && self.depth == 0 {
self.trim_histories();
+1 -1
View File
@@ -640,7 +640,7 @@ sampler.
| `transform` | `{ids?, dx?, dy?, rotate?, scale?, scaleX?, scaleY?, reflect?, shear?, origin?, copy?}` | Runs move, rotate, scale, reflect, shear in that order. `rotate` is in degrees counter-clockwise; a positive `shear` moves the top to the right. With `copy`, the first step duplicates. |
| `create_graph` | `{type?, x, y, width, height, csv? \| series?, categories?, rows?}` | The nine Illustrator graph types: column, stacked column, bar, stacked bar, line, area, scatter, pie and radar. An empty CSV cell or a `null` in `rows` is a blank value; a number in straight quotes is a label. Edit later with `graph.setData` / `graph.setType` via `run_command`. `graph.column {design, columnType: vertical\|uniform\|repeating\|sliding, each, fractions: chop\|scale}` draws the columns or bars of the series picked with Group Selection (or `seriesIndexes`, or all of them) with a graph design saved by `graph.design`; with no `design` it returns the series' current design and options. Type in the design that holds a `%` followed by two digits shows each column's value, with at least the first digit's places before the decimal point and the second digit's places after it. |
| `text_wrap` | `{ids?, offset?, invert?, release?}` | Area type below the objects (same layer) flows around them. |
| `undo` / `redo` | `{}` | Many calls that change nothing record no undo step, for example `object.align` with nothing to move, `object.move` by 0 without `copy` and `object.showAll` with nothing hidden. `undo` (`edit.undo`) after such a call undoes the step before it. `inspect_document` lists the undo steps as `history`. `run_command` with `edit.clearHistory` removes every undo and redo step of the active document and returns `{cleared}`, the number of steps removed; the document stays as it is, and the command records no undo step. `run_command` with `edit.newDocumentFromState` and `{index?, created?}` makes a new document holding the art as it was at that state and makes it the active document, and returns `{index, title}`. `index` is a row of the History panel, with 0 the oldest state kept, and an omitted `index` uses the current state. An `index` that names no state is an error and changes nothing. `created` is File Info's created date in Unix seconds or null, and defaults to now. The new document takes the next `Untitled-N` name, is marked unsaved, has nothing selected and has no steps to undo or redo. It is out of symbol, opacity-mask and pattern editing, and the art drawn in a pattern's tile is stored in the pattern, as `object.pattern.done` stores it. The source document and its steps stay as they are, except that typing or a drag in progress is first kept as a step. While the Type tool edits type, `undo` and `redo` put its caret, with no characters selected, at the end of the text the step changed: after the text it puts back or retypes, or where the text it takes out was. A step that changes no characters, such as a formatting change, leaves the caret and the selection where they are. |
| `undo` / `redo` | `{}` | Many calls that change nothing record no undo step, for example `object.align` with nothing to move, `object.move` by 0 without `copy` and `object.showAll` with nothing hidden. `undo` (`edit.undo`) after such a call undoes the step before it. `inspect_document` lists the undo steps as `history`. `run_command` with `edit.clearHistory` removes every undo and redo step of the active document and returns `{cleared}`, the number of steps removed; the document stays as it is, and the command records no undo step. `run_command` with `edit.newDocumentFromState` and `{index?, created?}` makes a new document holding the art as it was at that state and makes it the active document, and returns `{index, title}`. `index` is a row of the History panel, with 0 the oldest state kept, and an omitted `index` uses the current state. An `index` that names no state is an error and changes nothing. `created` is File Info's created date in Unix seconds or null, and defaults to now. The new document takes the next `Untitled-N` name, is marked unsaved, has nothing selected and has no steps to undo or redo. It is out of symbol, opacity-mask and pattern editing, and the art drawn in a pattern's tile is stored in the pattern, as `object.pattern.done` stores it. The source document and its steps stay as they are, except that typing or a drag in progress is first kept as a step. When a command keeps a drag whose preview Real-time Drawing off had not applied yet (`edit.undo`, `edit.redo`, `edit.clearHistory`, `edit.newDocumentFromState` and the other commands that end a drag first), and applying that preview gives a warning, the command's result carries it as `previewWarning`, and as `warning` too when the command gives no warning of its own; the app's status bar shows `warning`. While the Type tool edits type, `undo` and `redo` put its caret, with no characters selected, at the end of the text the step changed: after the text it puts back or retypes, or where the text it takes out was. A step that changes no characters, such as a formatting change, leaves the caret and the selection where they are. |
For example, create editable Japanese area type with the engine's vertical writing mode:
+1
View File
@@ -1816,6 +1816,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Filter plug-ins call commands (G17, #2186):** a filter at plug-in API level 2 with the `commands` permission names the commands it may run in its manifest's `commandAllowlist` and runs them with `env.vc_call`, reading each result with `env.vc_call_result`. Each call sees what the calls before it changed; the calls and the filter's output are one undo step, and a run that fails, is canceled or makes a call that fails leaves every document, its selection and its history as they were. Commands that read or write files, use the clipboard, change preferences or the view, run scripts, actions or plug-ins, switch documents or undo are never called, and a manifest that lists one is refused when the module is installed. The install dialog lists the allowed commands. A run makes at most 1,000 calls, and each call costs 10,000 instructions of its budget. Regressions: `crates/plugins/tests/call.rs`, `crates/engine/tests/plugin_call.rs`, `crates/engine/src/cmd/plugin_call.rs`. Scores and hours unchanged.
- **The command sweep sends file and folder params into its temp folder by the command catalog:** the sweeps in `crates/engine/tests/command_sweep.rs` take each command's file and folder keys from the catalog (`cmd::location_keys`, the same key list the plug-in call rules use, less the keys that name part of the document in a command), read every key a params description names (`{path | name …}`, `{folder? (…)}` included) and its schema's params, and send every string under such a key, in arrays and in objects inside them (`paths: [{ids, path}]`), as a location in the temp folder, so a command added later is swept and checked without editing the sweep. `diffPath` counts as a file key, and Slice Options' `url` and Export for Screens' `folders` do not. Regressions: `sweep_cases_name_files_only_inside_the_temp_folder` and `sweep_cases_send_the_file_keys_of_the_command_catalog` in `command_sweep`, `location_keys_come_from_the_command_catalog` in `cmd::plugin_call`. Scores and hours unchanged.
- **A drag ended by a command with Real-time Drawing off:** when Undo, Redo, a filter that calls commands or a plug-in command that calls commands ends a drag whose preview Real-time Drawing off has not applied yet, the preview is applied first and kept as an undo step of its own, as releasing the drag keeps it; a preview that fails cancels the drag. The filter or plug-in command is then given the objects where the drag left them. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/src/tests/prefs.rs`. Scores and hours unchanged.
- **The warning of a preview applied when a command ends a drag:** a preview that Real-time Drawing off left unapplied, and that Undo, Clear History or another command applies before it runs, keeps its warning: the command's result carries it as `previewWarning`, and as `warning` when the command has none of its own, and the status bar shows it, as releasing the drag shows it. Typing kept as its own step when a tool's drag begins, or when Cmd lends the Selection tool, shows its applied preview's warning in the status bar too. Regressions: `settling_a_deferred_preview_keeps_its_warning` and `settling_typing_for_a_tool_shows_its_preview_warning` in `crates/engine/src/tests/prefs.rs`, `settled_warnings_join_the_result` in `crates/engine/src/session/execute.rs`. Scores and hours unchanged.
- **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, a light filter with `progress` and no `commandAllowlist` runs in the background with the same progress window when it runs at once or OK runs it with Preview off, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged.
- **Live-effect plug-ins paint their output (G17, #121):** a live effect whose manifest has `"api": 2` and `"paintOutput": true` returns pieces, each drawn by itself in order with fills and strokes of its own in the filter's paint forms (a piece without them keeps the object's; an empty list paints none; an effect on a fill returns only fills, one on a stroke only strokes). The whole result is checked first: limits, numbers in range with nothing clamped, known keys only, and the swatches and patterns it names in the document; one fault draws that step of the stack unchanged, keeps the effect record and puts the plug-in's name and the field in `plugin.info`'s `lastError`. The pieces are drawn, measured and exported to PDF and SVG from the appearance stack without changing the document, and Expand Appearance writes them as ordinary paths in one undo step. Effects without the key behave as before. Regressions: `crates/plugins/src/painted/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged.
- **Live-effect plug-ins while a document opens (G17):** the live-effect plug-in runs of a document's first view on screen share a 2,000 ms budget (VectorCraft's own value). A stage left out for time, or one that times out, fails, has no plug-in or meets Safe Mode, draws its input unchanged and is deferred; the status bar says how many, `effect.deferredStatus` lists them with their object, effect and reason, and Effect › Refresh Deferred Effects (`effect.refreshDeferred`) runs them again with a fresh budget. Each document has its own budget, an edit runs the stages it changes, and exports, Expand Appearance and commands run every stage under the 5 s limit of each run. The web build times the budget and each run's limit with the page's `performance.now()`. Regressions: `crates/plugins/src/deferred.rs`, `crates/plugins/src/effect.rs`, `crates/plugins/src/host.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/canvas/tests.rs`. Scores and hours unchanged.