diff --git a/crates/ui-egui/src/chrome/status_bar.rs b/crates/ui-egui/src/chrome/status_bar.rs index 1ed8df873..9501d9f83 100644 --- a/crates/ui-egui/src/chrome/status_bar.rs +++ b/crates/ui-egui/src/chrome/status_bar.rs @@ -230,24 +230,73 @@ pub(super) fn status_readouts(app: &VectorcraftApp, ui: &mut Ui, t: &Tokens) { .collect(); // Reserve the spinner's space while idle too: starting a background job changes the text, // not its origin. Messages take priority over optional readouts in a narrow window. - let message = if let Some(job) = busy.first() { + let (message, notes) = if let Some(job) = busy.first() { let spinner = egui::Rect::from_center_size(egui::pos2(area.left() + 6.0, area.center().y), vec2(12.0, 12.0)); ui.put(spinner, egui::Spinner::new().size(12.0).color(t.accent)); let label = crate::i18n::msg(job); let more = busy.len() - 1; - if more > 0 { format!("{label}… (+{more})") } else { format!("{label}…") } + (if more > 0 { format!("{label}… (+{more})") } else { format!("{label}…") }, &[][..]) } else { - crate::i18n::msg(&app.ui.status) + (crate::i18n::msg(&app.ui.status), status_notes(&app.ui)) }; if !message.is_empty() { let rect = egui::Rect::from_min_max(egui::pos2((area.left() + 18.0).min(right), area.top()), egui::pos2(right, area.bottom())); - status_field(ui, "message", rect, message, egui::FontId::proportional(11.0), t.text); + status_field_with_notes(ui, "message", rect, message, egui::FontId::proportional(11.0), t.text, notes); } } +/// Most notes the status message's hover and access information list. +const MAX_LISTED_NOTES: usize = 30; + +/// The notes behind the status message (the open's, [`crate::state::UiState::status_notes`]) when +/// there are several and the status bar still shows the message they came with, else none. +fn status_notes(ui: &crate::state::UiState) -> &[String] { + let notes = ui.status_notes.as_ref().filter(|(status, notes)| *status == ui.status && notes.len() > 1); + notes.map(|(_, notes)| notes.as_slice()).unwrap_or_default() +} + +/// `notes` in the interface language, one to a line, the first `most` of them (at most +/// [`MAX_LISTED_NOTES`]) and how many more. +pub(super) fn listed_notes(notes: &[String], most: usize) -> String { + let lang = crate::i18n::current(); + let most = most.min(MAX_LISTED_NOTES); + let mut lines: Vec = notes.iter().take(most).map(|n| format!("• {}", crate::i18n::message(lang, n))).collect(); + if notes.len() > most { + lines.push(format!("… (+{})", notes.len() - most)); + } + lines.join("\n") +} + +/// The hover of the status message `text` with `notes`: the message, then as many of the notes +/// ([`listed_notes`]) as fit in the window above or below the field `field`, wrapped within the +/// window's width. +fn notes_tooltip(response: egui::Response, field: egui::Rect, text: &str, notes: &[String]) -> egui::Response { + response.on_hover_ui(|ui| { + let screen = ui.ctx().content_rect(); + let margins = ui.spacing().menu_margin.sum() + 2.0 * ui.spacing().item_spacing; + let width = ui.spacing().tooltip_width.min(screen.width() - margins.x).max(0.0); + ui.set_max_width(width); + let room = (field.top() - screen.top()).max(screen.bottom() - field.bottom()) - 2.0 * margins.y; + let tip = |most: usize| format!("{text}\n{}", listed_notes(notes, most)); + let height = + |s: &str| egui::WidgetText::from(s).into_galley(ui, Some(egui::TextWrapMode::Wrap), width, egui::FontSelection::Default).size().y; + let mut most = notes.len().min(MAX_LISTED_NOTES); + while most > 0 && height(&tip(most)) > room { + most -= 1; + } + ui.add(egui::Label::new(tip(most)).wrap()); + }) +} + /// A field never grows with its text. Clipping also bounds over-wide glyphs; hover and access /// information retain the complete value when its on-screen label is truncated. fn status_field(ui: &mut Ui, id: &str, rect: egui::Rect, text: String, font: egui::FontId, color: egui::Color32) { + status_field_with_notes(ui, id, rect, text, font, color, &[]); +} + +/// [`status_field`], whose hover, when there are `notes`, lists them ([`notes_tooltip`]) and whose +/// access information has them after the text. +fn status_field_with_notes(ui: &mut Ui, id: &str, rect: egui::Rect, text: String, font: egui::FontId, color: egui::Color32, notes: &[String]) { if rect.width() <= 0.0 || rect.height() <= 0.0 { return; } @@ -261,9 +310,17 @@ fn status_field(ui: &mut Ui, id: &str, rect: egui::Rect, text: String, font: egu let elided = galley.elided; ui.painter().with_clip_rect(ui.clip_rect().intersect(rect)).galley(at, galley, color); let response = ui.interact(rect, ui.scope_id().with(("status-field", id)), Sense::hover()); - response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, &text)); - if elided { - full_text_tooltip(response, text); + if notes.is_empty() { + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, &text)); + if elided { + full_text_tooltip(response, text); + } + return; + } + // The list is made only for a hover or a screen reader. + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, format!("{text}\n{}", listed_notes(notes, MAX_LISTED_NOTES)))); + if response.hovered() { + notes_tooltip(response, rect, &text, notes); } } diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs index 745c477c7..5f7bdbbe7 100644 --- a/crates/ui-egui/src/chrome/tests.rs +++ b/crates/ui-egui/src/chrome/tests.rs @@ -461,6 +461,65 @@ fn a_clipped_status_message_shows_in_full_on_hover() { assert_eq!(hovered.iter().filter(|t| t.text.starts_with("Saved")).count(), 1, "a message that fits has no tooltip: {hovered:?}"); } +/// The status message of an open with several notes lists them all on hover, and screen readers +/// receive them after the message, while the status bar shows that message. A later message +/// leaves the list out, and so does an open with one note. +#[test] +fn the_message_of_an_open_with_several_notes_lists_them_on_hover() { + let (mut app, ctx) = status_readout_app(); + ctx.enable_accesskit(); + let notes = vec!["the file's layers weren't read".to_string(), "a brush wasn't read".to_string()]; + app.ui.status = crate::io::opened_with_notes(2, ¬es[0]); + app.ui.status_notes = Some((app.ui.status.clone(), notes)); + let listed = "• the file's layers weren't read\n• a brush wasn't read"; + let full = format!("{}\n{listed}", app.ui.status); + let (texts, nodes, hovered) = bar_hover(&mut app, &ctx, 1400.0, "Opened", super::status_bar); + assert!(!status_text(&texts, "Opened").elided); + assert!(nodes.iter().any(|n| n.value() == Some(full.as_str())), "screen readers receive the notes: {nodes:?}"); + assert!(hovered.iter().any(|t| t.text == full), "the tooltip lists the notes: {hovered:?}"); + + app.ui.status = "Saved recovery data for Untitled-1".to_owned(); + let (_, nodes, hovered) = bar_hover(&mut app, &ctx, 1400.0, "Saved", super::status_bar); + assert!(!nodes.iter().any(|n| n.value().is_some_and(|v| v.contains("• "))), "{nodes:?}"); + assert_eq!(hovered.iter().filter(|t| t.text.starts_with("Saved")).count(), 1, "no tooltip: {hovered:?}"); + + // One note is the message itself: no list. + app.ui.status = crate::io::opened_with_notes(1, "a brush wasn't read"); + app.ui.status_notes = Some((app.ui.status.clone(), vec!["a brush wasn't read".to_string()])); + let (_, nodes, hovered) = bar_hover(&mut app, &ctx, 1400.0, "Opened", super::status_bar); + assert!(!nodes.iter().any(|n| n.value().is_some_and(|v| v.contains("• "))), "{nodes:?}"); + assert_eq!(hovered.iter().filter(|t| t.text.starts_with("Opened")).count(), 1, "no tooltip: {hovered:?}"); +} + +/// The list has at most 30 notes, then how many more there are. +#[test] +fn the_hover_lists_thirty_notes_and_counts_the_rest() { + crate::i18n::set_current(crate::i18n::Lang::EN); + let notes: Vec = (1..=32).map(|i| format!("note {i}")).collect(); + let listed = super::status_bar::listed_notes(¬es, usize::MAX); + let lines: Vec<&str> = listed.lines().collect(); + assert_eq!((lines.len(), lines[0], lines[29], lines[30]), (31, "• note 1", "• note 30", "… (+2)")); + assert_eq!(super::status_bar::listed_notes(¬es[..2], usize::MAX), "• note 1\n• note 2"); + assert_eq!(super::status_bar::listed_notes(¬es[..3], 1), "• note 1\n… (+2)"); +} + +/// A hover with more long notes than the window can show lists the ones that fit and how many +/// more there are, inside the window. A window 300 points tall is shorter than the main window's +/// smallest size (500 points) at UI Scaling 1.5. +#[test] +fn the_hover_of_many_notes_stays_inside_a_small_window() { + let (mut app, ctx) = status_readout_app(); + let notes: Vec = (1..=32).map(|i| format!("note {i}: {}", "this file's PostScript couldn't be read ".repeat(3))).collect(); + app.ui.status = crate::io::opened_with_notes(notes.len(), ¬es[0]); + app.ui.status_notes = Some((app.ui.status.clone(), notes)); + let (_, _, hovered) = bar_hover(&mut app, &ctx, 700.0, "Opened", super::status_bar); + let tip: Vec<_> = hovered.iter().filter(|t| t.text.contains("\n• note 1:")).collect(); + let [tip] = &tip[..] else { panic!("one tooltip lists the notes: {hovered:?}") }; + assert!(tip.bounds.top() >= 0.0 && tip.bounds.bottom() <= 300.0 && tip.bounds.right() <= 700.0, "{tip:?}"); + let last = tip.text.lines().last().unwrap_or_default(); + assert!(last.starts_with("… (+") && !tip.text.contains("• note 30:"), "{last:?}"); +} + /// The Help Bar's hint ends in an ellipsis when it is wider than the window and shows in full on /// hover, in a tooltip that wraps within the window (#6622). The Dimension tool's hint is wider /// than the smallest main window (800 points); 400 points is that window at UI Scaling 2.0. diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index cc6f5d226..93abdfd31 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -80,6 +80,51 @@ mod tests { assert!(app.ui.dialog.is_none()); } + /// A file opened with a note about its page first and a note that its layers weren't read + /// second: the status bar names the layers note, counts both, and keeps both, the layers note + /// first, for its hover. A message about its linked files still replaces that message. Two + /// notes of what was left out keep the order the import gave them, and an open without notes + /// leaves the message and the notes an earlier open left. + #[test] + fn an_open_names_the_note_of_what_was_left_out_first() { + use vectorcraft_testkit::ai; + let (mut app, _) = app(); + let damaged = b"%AI12_CompressedData not zlib"; + let notes = |r: &Value| -> Vec { r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).map(str::to_string).collect() }; + let pdf = ai::pdf(damaged, "0 g 150 20 100 60 re f"); + let r = open_bytes(&mut app, "damaged.pdf", &pdf, None).unwrap(); + let [page, layers] = ¬es(&r)[..] else { panic!("two notes: {r}") }; + assert!(layers.contains("layers weren't read") && !page.contains("layers weren't read"), "{r}"); + assert_eq!(app.ui.status, opened_with_notes(2, layers)); + assert_eq!(app.ui.status_notes, Some((app.ui.status.clone(), vec![layers.clone(), page.clone()]))); + + // A message about its linked files replaces the open's message, as before. + app.session.prefs.update_links = "manually".into(); + let mut linked = r.clone(); + linked["modifiedLinks"] = json!([{ "ids": [1] }]); + opened(&mut app, &linked); + assert_eq!(app.ui.status, dialogs::missing_links::changed_on_disk_status(1)); + assert_eq!(app.ui.status_notes, Some((opened_with_notes(2, layers), vec![layers.clone(), page.clone()]))); + + let r = open_bytes(&mut app, "damaged.ai", &ai::ai(damaged, ai::page_pdf()), None).unwrap(); + let both = notes(&r); + assert_eq!(both.len(), 2, "{r}"); + assert_eq!(app.ui.status, opened_with_notes(2, &both[0])); + assert_eq!(app.ui.status_notes, Some((app.ui.status.clone(), both))); + + let r = open_document(&mut app, "page.pdf", &pdf, None, &json!({"editingData": false})).unwrap(); + let [only] = ¬es(&r)[..] else { panic!("one note: {r}") }; + assert_eq!(app.ui.status, opened_with_notes(1, only)); + assert_eq!(app.ui.status_notes, Some((app.ui.status.clone(), vec![only.clone()]))); + + let clean = br#""#; + open_bytes(&mut app, "damaged.pdf", &pdf, None).unwrap(); + let shown = (app.ui.status.clone(), app.ui.status_notes.clone()); + let r = open_bytes(&mut app, "clean.svg", clean, None).unwrap(); + assert_eq!(r["warnings"], json!([]), "{r}"); + assert_eq!((app.ui.status.clone(), app.ui.status_notes.clone()), shown); + } + /// #1685: a save or an export that can't write its file says so in a dialog that names the /// file and the reason and stays until it is closed; the document stays modified and keeps /// the file it had. An agent's request only gets the error, and a message that arrives while diff --git a/crates/ui-egui/src/io/open.rs b/crates/ui-egui/src/io/open.rs index 50f13e695..18ebf8368 100644 --- a/crates/ui-egui/src/io/open.rs +++ b/crates/ui-egui/src/io/open.rs @@ -137,15 +137,30 @@ pub fn open_document(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: O } /// A document just opened (`r`: `document.open`'s result): the views follow, the status bar says -/// what didn't come in as it was, and missing links are asked about. +/// what didn't come in as it was, and missing links are asked about. The status bar names the +/// first of the notes that say reading the file left something out (the ones that make Save ask +/// before writing over it), else the first note, and keeps the notes in that order for its hover. pub(crate) fn opened(app: &mut VectorcraftApp, r: &Value) { app.sync_views(); - if let Some(w) = r["warnings"].as_array().filter(|w| !w.is_empty()) { - app.status(opened_with_notes(w.len(), w[0].as_str().unwrap_or_default())); + let notes = open_notes(app, r); + if let Some(first) = notes.first() { + app.status(opened_with_notes(notes.len(), first)); + app.ui.status_notes = Some((app.ui.status.clone(), notes)); } crate::dialogs::missing_links::after_open(app, r); } +/// The notes of the open `r`, those of what reading the file left out (its document's +/// `import_losses`) first, each group in the order the import gave them. +fn open_notes(app: &VectorcraftApp, r: &Value) -> Vec { + let notes: Vec = r["warnings"].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect(); + let index = r["index"].as_u64().and_then(|i| usize::try_from(i).ok()); + let losses = index.and_then(|i| app.session.documents().get(i)).map(|st| st.import_losses.as_slice()).unwrap_or_default(); + let (mut first, rest): (Vec, Vec) = notes.into_iter().partition(|n| losses.contains(n)); + first.extend(rest); + first +} + /// The status bar's line for a document opened with `n` notes, the first of them `first`. pub(crate) fn opened_with_notes(n: usize, first: &str) -> String { crate::i18n::msg_n(n as u64, "Opened with {n} note: {first}", "Opened with {n} notes: {first}", &[("first", first)]) diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 59553120c..60e2ab400 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -533,6 +533,10 @@ pub struct UiState { /// The Replace Font request behind the status bar's warning ([`ReplaceFontWarning`]). Not saved. #[serde(skip)] pub replace_font_warning: Option, + /// The notes of the last open that gave any, with the status text that names the first of + /// them: hovering that text lists them when there are several. Not saved. + #[serde(skip)] + pub status_notes: Option<(String, Vec)>, pub palette_open: bool, pub palette_query: String, /// Screen mode: 0 normal, 1 full screen with menu, 2 full screen, 3 presentation. Not saved: @@ -1060,6 +1064,7 @@ impl Default for UiState { group_tool: vectorcraft_tools::TOOL_GROUPS.iter().map(|g| g[0].id.to_string()).collect(), status: String::new(), replace_font_warning: None, + status_notes: None, palette_open: false, palette_query: String::new(), screen_mode: 0, diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 04715474d..9e9bacd1a 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -107,7 +107,7 @@ the two can still win that race. | `ui.render` | `{path?, scale?, artboard?, data?, traceViews?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first; `traceViews: true`: Image Trace objects draw in their View, as on the canvas, instead of their tracing result): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | | `ui.render` | `{path?, scale?, artboard?, data?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | | `ui.resize` / `ui.focus` | | | -| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, importer?, params?}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`) and returns its result (`{index, title, format, warnings, …}`; `warnings` say what didn't come in as it was, such as an EPS shown as its preview image and why its PostScript couldn't be read), or null when a dialog asks first or a library loads. With `importer` (an importer plug-in's format id, `plugin.`) it runs `file.openWith`: the file opens with that importer, also when a built-in format reads its extension, with the importer's parameters `params` (their defaults when omitted; no dialog asks for them). Without `importer`, `params` go to the importer that opens the file, and a built-in format does not read them. `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. It refuses a key that no format takes, and an unknown key inside a PDF or EPS option object, before it writes anything (`docs/mcp.md`, Parameter keys). `app.save` refuses a key that no save format takes, beside the path or in `options`, the same way. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit | +| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, importer?, params?}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`) and returns its result (`{index, title, format, warnings, …}`; `warnings` say what didn't come in as it was, such as an EPS shown as its preview image and why its PostScript couldn't be read), or null when a dialog asks first or a library loads. The status bar (`ui.inspect` → `ui.status`) counts the `warnings` and names the first that says reading the file left something out (the notes that make Save ask before writing over it), else the first; with several, hovering it lists them in that order, as many as fit in the window (at most 30), then how many more. With `importer` (an importer plug-in's format id, `plugin.`) it runs `file.openWith`: the file opens with that importer, also when a built-in format reads its extension, with the importer's parameters `params` (their defaults when omitted; no dialog asks for them). Without `importer`, `params` go to the importer that opens the file, and a built-in format does not read them. `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. It refuses a key that no format takes, and an unknown key inside a PDF or EPS option object, before it writes anything (`docs/mcp.md`, Parameter keys). `app.save` refuses a key that no save format takes, beside the path or in `options`, the same way. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit | | `app.export` with `useArtboards` | `{path, useArtboards: true, range? \| artboards?, …}` | Export As: PNG, JPEG and WebP write one file per chosen artboard (default all; SVG does so for a `range`), `-.`, and return `{path, files: [path…]}`; a PDF keeps them as pages of `path`. `useArtboards: false` covers the bounds of the visible art. The menu's Export As… is `file.exportAs`: the `exportAs` dialog (`format`, `useArtboards`, `all`, `range`), then a save dialog and the format's options: `pngOptions` / `jpgOptions` / `webpOptions`, `svgOptions`, or `savePdf` for a PDF with Use Artboards | | `ui.dialog.*` on `gradientStop` | `{field: "color" \| "opacity" \| "location", value}` | double-clicking a stop on the Gradient tool's annotator opens its popover (fields `index`, `x`, `y`, `tab`); set `color` (hex), `opacity` or `location` (percentages) and `ui.dialog.confirm` to apply them to the selected stop. `gradient.selectStop {index}` picks the stop the annotator, the panels and Delete/←/→ (`ui.key`) act on | | `engine.execute` `ui.savePdfDialog` | `{path?, preset?, range?, …document.exportPdf options}` | opens the Save PDF dialog (`file.export.pdf` with no params opens it too). Its fields are the `document.exportPdf` options: set `compatibility` with `ui.dialog.set {field: "compatibility", value: "1.5"}`, a whole section with `{field: "compression", value: {…}}`, the artboards with `__allArtboards: false` and `range`, the visible section with `__section` (General, Compression, Marks and Bleeds, Output, Advanced, Security, Summary). `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4c949f74..751fe82da 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -575,6 +575,7 @@ since 2026-10-01. - **Stable status-bar readouts:** The status message keeps its left anchor and clipping allocation as live values, canvas hover and background-job state change. Coordinate and spinner slots stay reserved; narrow windows hide optional diagnostics, then coordinates, to preserve message space. Widths are measured from the active font, translated labels and fixed numeric samples, never current readings. Coordinates through +/-9999.99 selected units, timings through 9999.9 ms and FPS through 9999 remain readable. Extreme values are elided with full tooltip/accessibility text; only elided fields show a tooltip. Coordinate inspection retains the last point per document and clears on PointerGone. Regressions cover message origin/clip/bounds/elision invariance, all registered languages and ten units, responsive layouts, coordinate inspection and document switching, and accessible busy transitions. The contributor rebuilt and confirmed the original status-bar issue is fixed with their own artwork on 2026-10-19; the subsequent upstream rebase is checked by regressions and full CI. The artwork stays local. Scores and estimates are unchanged. Sample widths use a bounded single-entry UI cache keyed by language, unit and pixels per point; two regressions verify reuse during live updates and invalidation after each display-setting change. - **Clipped bottom-bar text (#6622):** A Help Bar hint wider than the window, such as the Dimension tool's hint in an 800-point window, ends in an ellipsis, as the status message does. In a narrow status bar the tool name ends in an ellipsis and leaves the status message its room. Resting the pointer on a clipped hint, tool name or message shows the full text in a tooltip that wraps within the window. Text that fits shows no tooltip, and screen readers receive the full text. Scores and estimates are unchanged. +- **Notes of an open in the status bar:** After File › Open the status message names the first note that says reading the file left something out (the notes that make Save ask before writing over the file), else the first note, and counts them all. With several notes, resting the pointer on the message lists them in that order, as many as fit in the window and at most 30, then how many more. Screen readers receive the list after the message. Scores and estimates are unchanged. - **Canvas review follow-up:** Cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission. Controlled-worker regression checks oversized width and height, high DPI and the render transform without allocating oversized images. Scores and estimates are unchanged. - **Cached canvas frames during viewport changes:** Cached screen textures use their own raster dimensions and pixels-per-point when reprojected. `canvas::tests::cached_frames_keep_their_scale_during_resize_rotation_and_dpi_changes` holds the worker and checks every UV corner against the cached render transform, including isolated viewport-only resize at zero/30-degree rotation, fractional raster dimensions, zoom/pan, changed rotation, monitor-DPI changes in both directions, Pixel Preview and replacement-frame delivery. The contributor rebuilt and confirmed the original resize issue is fixed with their own artwork on 2026-10-19; the subsequent upstream rebase is checked by regressions and full CI. The artwork stays local. Scores and estimates are unchanged.