From dde4b8dca8c46e28c0dc823954a9ac9a5161a217 Mon Sep 17 00:00:00 2001 From: Lorenzo Fasolino Date: Tue, 13 Oct 2026 16:15:28 +0200 Subject: [PATCH 01/15] docs(perf): how to measure one scenario and read its numbers Task T-0003 del lab artcraft-lab. File toccati: docs/development.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) Evidenze: artifacts/T-0003/ (nel lab, non in questo repo). --- docs/development.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/docs/development.md b/docs/development.md index bb0de12690..49d7a1c444 100644 --- a/docs/development.md +++ b/docs/development.md @@ -332,6 +332,40 @@ different machine classes are never compared, and `--quick` numbers are only com quick baseline. Machines that run other work give noisy numbers: check the load average in the summary before trusting a regression, and record it next to any number you quote. +**Measuring one scenario.** A full run is tens of minutes; one row is seconds once its bench is +built. `xtask perf`'s own flags are `--quick`, `--update-baseline`, `--threshold`, `--bench` +(repeatable, one bench per name), `--skip-build` and `--reuse`; it has no way to pick a single +*row*. The bench binary takes those filters itself — `--only ` (row names are matched +with `contains`), `--group ` and `--reps N` — so to time one row run the bench directly: + +```sh +cargo run --release -p photocraft-ui-egui --example perf_scenarios -- \ + --only "Select > Modify > Smooth" --json target/perf/p26.json +``` + +That writes the row's own report and stops there: it does **not** evaluate the budget, so read +`p50_ms`/`p95_ms`/`max_ms` out of the JSON yourself. Use `cargo xtask perf --bench ` when you +want the verdict, the merged `results.json`/`summary.md` and the regression check. + +Read the sample count before quoting a percentile. `--reps` defaults to 7 in `--quick` and 15 +otherwise, it is per bench, and some rows cap it themselves (the `#211` algorithm rows use +`b.reps.min(5)`); percentiles are nearest-rank, so **at n ≤ 5 the "p95" is simply the maximum**. A +budgeted scenario the baseline has no row for fails the run (`missing` is in `Status::fails()`), +so add the `[[scenario]]` and its baseline row together. + +**A red `cargo xtask perf` is not always your budget.** The committed baseline carries +`failed.P12` (a wgpu panic writing `pc_compose_uniforms`), so the command exits non-zero before +any of this is about your change: read the row, not the exit code. And do not read the committed +verdicts as a clean-machine measurement: `perf/baseline.json` was written on 2026-10-05 at a load +average near 225 on a 14-core Apple M4 Pro, which is why P26 and P27 are over budget there while +`perf/budgets.toml` records 20–45 ms p95 for P26 on a clean 16-core machine (`enforce = false` +until CI cores are measured). Quote a number with the load average and the machine class beside it. + +**Flipping `enforce`.** `enforce = true` makes the nightly fail whenever that budget breaks, and a +baseline file belongs to exactly one machine class. Flip the row and refresh the baseline on the +nightly's own class in the same change, or `docs/scorecard.md` turns "BUDGET BROKEN" from numbers +nobody measured on that machine. + **Adding a scenario.** Add a row to a bench (keep its name stable: it is the key), give the bench `--json` support through `photocraft_testkit::perf::{row, report, write_report}`, then add a `[[scenario]]` to `perf/budgets.toml` and run `cargo xtask scorecard`. From ce383de7d8ee13955349be9740f8ee7720df036d Mon Sep 17 00:00:00 2001 From: Abdul Karim Mia Date: Wed, 14 Oct 2026 07:55:29 +0530 Subject: [PATCH 02/15] Map .atn duplicate-into-new-document (Mk) to layer.duplicate Photoshop records Layer > Duplicate Layer with Destination: New as a Mk event (document class + layer Usng, Nm = new doc name, LyrN = copy name), verified against a ScriptListener/XTensions export. Import maps it to layer.duplicate {document: new}; export writes it back, so the steps round-trip instead of becoming kept Photoshop steps that fail at playback. A Dplc with a T document reference (another open doc) now imports as a kept step rather than misplaying as a same-doc copy. --- crates/engine/src/atn_cmds.rs | 45 +++++++++++++++++++++++++++++ crates/engine/src/atn_cmds/tests.rs | 17 +++++++++-- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index 391c21abd8..ebeda48687 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -983,6 +983,26 @@ fn pattern_desc(p: &Value) -> Option { } fn make_from(d: &Descriptor) -> Mapped { + // Duplicate layer/group into a new document: `Mk` with a document class, a layer + // `Usng`, an optional `Nm` (the new document's name) and an optional `LyrN` + // (the copy's name). A `Dplc` with a `T` document reference (duplicate into another + // open document) stays a kept Photoshop step: document indices mean nothing at replay. + if let Some(r) = get_ref(d, "null") + && ref_class(r).as_deref() == Some("Dcmn") + && matches!(target(r), Target::Class) + && let Some(u) = get_ref(d, "Usng") + && matches!(ref_class(u).as_deref(), Some("Lyr") | Some("layerSection")) + && let Some(mut p) = layer_target(u) + { + p.insert("document".into(), "new".into()); + if let Some(n) = get_text(d, "Nm ") { + p.insert("documentName".into(), n.into()); + } + if let Some(n) = get_text(d, "LyrN") { + p.insert("name".into(), n.into()); + } + return step("layer.duplicate", Value::Object(p)); + } if let Some(nw) = get_obj(d, "Nw ") { // Layer via Copy / Cut (`Usng` = the selection), or a new document. if get_enum(d, "Usng").as_deref() == Some("Slct") { @@ -1117,6 +1137,21 @@ fn make_to(id: &str, p: &Value) -> Option { let t = pattern_desc(p)?; desc("null").with("null", class_ref("contentLayer")).with("Usng", D::Descriptor(desc("contentLayer").with("Type", D::Descriptor(t)))) } + "layer.duplicate" => { + // Duplicate into a new document is a `Mk`, not a `Dplc` (see `make_from`). + // Anything else falls through to the `Dplc` event below. + if jstr(p, "document") != Some("new") { + return None; + } + let mut d = desc("null").with("null", class_ref("Dcmn")).with("Usng", layer_ref(p)); + if let Some(n) = jstr(p, "documentName") { + d = d.with("Nm ", text(n)); + } + if let Some(n) = jstr(p, "name") { + d = d.with("LyrN", text(n)); + } + d + } "file.newFromClipboard" => desc("null").with("Nw ", D::Descriptor(desc("Dcmn").with("preset", text("Clipboard")))), "file.new" => { let mut n = desc("Dcmn"); @@ -1876,6 +1911,11 @@ fn duplicate_from(d: &Descriptor) -> Mapped { let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } }; match ref_class(r).as_deref() { Some("Lyr") => { + // A `T` document reference (duplicate into another open document) has no + // replayable meaning here: keep the step verbatim rather than misplay it. + if get_ref(d, "T ").is_some() { + return Mapped::Unsupported { harmless: false }; + } let Some(mut p) = layer_target(r) else { return Mapped::Unsupported { harmless: false } }; if let Some(n) = get_text(d, "Nm ") { p.insert("name".into(), n.into()); @@ -1897,6 +1937,10 @@ fn duplicate_from(d: &Descriptor) -> Mapped { } fn duplicate_to(id: &str, p: &Value) -> Option { + // Duplicate into a new document is a `Mk` (handled above); let it fall through. + if id == "layer.duplicate" && jstr(p, "document") == Some("new") { + return None; + } let mut d = match id { "layer.duplicate" => desc("null").with("null", layer_ref(p)), "image.duplicate" => { @@ -2696,6 +2740,7 @@ const EVENTS: &[Event] = &[ "layer.new.layer", "layer.new.group", "layer.groupLayers", + "layer.duplicate", "layer.new.layerViaCopy", "layer.new.layerViaCut", "layer.newFillLayer.solidColor", diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index bdb839fa30..b5c3a9e545 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -212,6 +212,12 @@ fn every_mapped_command_exports_and_imports_back() { ("image.mode.grayscale", json!({})), ("image.mode.bits16", json!({})), ("layer.duplicate", json!({"name": "copy"})), + // Duplicate into a new document is a `Mk`, not a `Dplc` (the three shapes of + // a recorded Duplicate Layer with Destination: New, plus a named layer). + ("layer.duplicate", json!({"document": "new"})), + ("layer.duplicate", json!({"document": "new", "documentName": "test new name"})), + ("layer.duplicate", json!({"document": "new", "name": "Laer name change"})), + ("layer.duplicate", json!({"layer": "Shadow", "document": "new", "documentName": "Doc"})), ("image.duplicate", json!({"mergedOnly": true})), ("layer.new.layer", json!({"name": "Ink", "opacity": 0.5, "mode": "Multiply"})), ("layer.new.group", json!({"name": "G"})), @@ -448,6 +454,13 @@ fn unsupported_shapes_become_photoshop_steps_not_errors() { Some(desc("null").with("null", class_ref("AdjL")).with("Usng", D::Descriptor(desc("AdjL").with("Type", D::Descriptor(desc("GdMp")))))), ), item("GsnB", "Gaussian Blur", None), + // Duplicate into another open document: the `T` reference means nothing at + // replay, so the step is kept verbatim instead of misplayed as a same-doc copy. + item( + "Dplc", + "Duplicate", + Some(desc("null").with("null", target_ref("Lyr ")).with("T ", D::Reference(vec![ReferenceItem::Name { class: class("Dcmn"), name: UnicodeString::new_nul("Other") }]))), + ), ]; let file = AtnFile { name: UnicodeString::new_nul("Odd"), @@ -457,8 +470,8 @@ fn unsupported_shapes_become_photoshop_steps_not_errors() { }; let imp = import(&atn::write(&file).unwrap()).unwrap(); let ids: Vec<&str> = imp.actions[0].steps.iter().map(|(id, _)| id.as_str()).collect(); - assert_eq!(ids, [PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, "filter.blur.gaussianBlur"]); - assert_eq!(imp.unsupported.len(), 4); + assert_eq!(ids, [PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, "filter.blur.gaussianBlur", PHOTOSHOP_STEP]); + assert_eq!(imp.unsupported.len(), 5); assert!(imp.actions[0].disabled.is_empty(), "steps with a document effect stay on so playback reports them"); } From fb3997514fc214cfb1bb82bd548dc425748343f7 Mon Sep 17 00:00:00 2001 From: Abdul Karim Mia Date: Wed, 14 Oct 2026 07:59:57 +0530 Subject: [PATCH 03/15] Credit Abdul karim mia in contributors --- contributors/people.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 6f630a0c12..eb1d57052e 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -17,6 +17,10 @@ # To remove your names, delete your entry (or set fields empty) in a PR. The next # contributors.py run, and the next build, drop them. +[people.abdul-karim-mia] +real_name = "Abdul karim mia" +sync_github_name = false + [people.jonathanwxh-cell] display_name = "jonathnwxh" sync_github_name = false From 03b6af0c1220a56f93c21d31b00afbe80d8e0efc Mon Sep 17 00:00:00 2001 From: Abdul karim mia <97598943+abdul-karim-mia@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:12:24 +0530 Subject: [PATCH 04/15] Keep contributor credit PR focused on contributor entry --- crates/engine/src/atn_cmds.rs | 45 ----------------------------------- 1 file changed, 45 deletions(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index ebeda48687..391c21abd8 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -983,26 +983,6 @@ fn pattern_desc(p: &Value) -> Option { } fn make_from(d: &Descriptor) -> Mapped { - // Duplicate layer/group into a new document: `Mk` with a document class, a layer - // `Usng`, an optional `Nm` (the new document's name) and an optional `LyrN` - // (the copy's name). A `Dplc` with a `T` document reference (duplicate into another - // open document) stays a kept Photoshop step: document indices mean nothing at replay. - if let Some(r) = get_ref(d, "null") - && ref_class(r).as_deref() == Some("Dcmn") - && matches!(target(r), Target::Class) - && let Some(u) = get_ref(d, "Usng") - && matches!(ref_class(u).as_deref(), Some("Lyr") | Some("layerSection")) - && let Some(mut p) = layer_target(u) - { - p.insert("document".into(), "new".into()); - if let Some(n) = get_text(d, "Nm ") { - p.insert("documentName".into(), n.into()); - } - if let Some(n) = get_text(d, "LyrN") { - p.insert("name".into(), n.into()); - } - return step("layer.duplicate", Value::Object(p)); - } if let Some(nw) = get_obj(d, "Nw ") { // Layer via Copy / Cut (`Usng` = the selection), or a new document. if get_enum(d, "Usng").as_deref() == Some("Slct") { @@ -1137,21 +1117,6 @@ fn make_to(id: &str, p: &Value) -> Option { let t = pattern_desc(p)?; desc("null").with("null", class_ref("contentLayer")).with("Usng", D::Descriptor(desc("contentLayer").with("Type", D::Descriptor(t)))) } - "layer.duplicate" => { - // Duplicate into a new document is a `Mk`, not a `Dplc` (see `make_from`). - // Anything else falls through to the `Dplc` event below. - if jstr(p, "document") != Some("new") { - return None; - } - let mut d = desc("null").with("null", class_ref("Dcmn")).with("Usng", layer_ref(p)); - if let Some(n) = jstr(p, "documentName") { - d = d.with("Nm ", text(n)); - } - if let Some(n) = jstr(p, "name") { - d = d.with("LyrN", text(n)); - } - d - } "file.newFromClipboard" => desc("null").with("Nw ", D::Descriptor(desc("Dcmn").with("preset", text("Clipboard")))), "file.new" => { let mut n = desc("Dcmn"); @@ -1911,11 +1876,6 @@ fn duplicate_from(d: &Descriptor) -> Mapped { let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } }; match ref_class(r).as_deref() { Some("Lyr") => { - // A `T` document reference (duplicate into another open document) has no - // replayable meaning here: keep the step verbatim rather than misplay it. - if get_ref(d, "T ").is_some() { - return Mapped::Unsupported { harmless: false }; - } let Some(mut p) = layer_target(r) else { return Mapped::Unsupported { harmless: false } }; if let Some(n) = get_text(d, "Nm ") { p.insert("name".into(), n.into()); @@ -1937,10 +1897,6 @@ fn duplicate_from(d: &Descriptor) -> Mapped { } fn duplicate_to(id: &str, p: &Value) -> Option { - // Duplicate into a new document is a `Mk` (handled above); let it fall through. - if id == "layer.duplicate" && jstr(p, "document") == Some("new") { - return None; - } let mut d = match id { "layer.duplicate" => desc("null").with("null", layer_ref(p)), "image.duplicate" => { @@ -2740,7 +2696,6 @@ const EVENTS: &[Event] = &[ "layer.new.layer", "layer.new.group", "layer.groupLayers", - "layer.duplicate", "layer.new.layerViaCopy", "layer.new.layerViaCut", "layer.newFillLayer.solidColor", From 007581b7a46ad117b03991c0d3ad80a202a00e34 Mon Sep 17 00:00:00 2001 From: Abdul karim mia <97598943+abdul-karim-mia@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:12:26 +0530 Subject: [PATCH 05/15] Remove feature changes from contributor credit PR --- crates/engine/src/atn_cmds/tests.rs | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index b5c3a9e545..bdb839fa30 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -212,12 +212,6 @@ fn every_mapped_command_exports_and_imports_back() { ("image.mode.grayscale", json!({})), ("image.mode.bits16", json!({})), ("layer.duplicate", json!({"name": "copy"})), - // Duplicate into a new document is a `Mk`, not a `Dplc` (the three shapes of - // a recorded Duplicate Layer with Destination: New, plus a named layer). - ("layer.duplicate", json!({"document": "new"})), - ("layer.duplicate", json!({"document": "new", "documentName": "test new name"})), - ("layer.duplicate", json!({"document": "new", "name": "Laer name change"})), - ("layer.duplicate", json!({"layer": "Shadow", "document": "new", "documentName": "Doc"})), ("image.duplicate", json!({"mergedOnly": true})), ("layer.new.layer", json!({"name": "Ink", "opacity": 0.5, "mode": "Multiply"})), ("layer.new.group", json!({"name": "G"})), @@ -454,13 +448,6 @@ fn unsupported_shapes_become_photoshop_steps_not_errors() { Some(desc("null").with("null", class_ref("AdjL")).with("Usng", D::Descriptor(desc("AdjL").with("Type", D::Descriptor(desc("GdMp")))))), ), item("GsnB", "Gaussian Blur", None), - // Duplicate into another open document: the `T` reference means nothing at - // replay, so the step is kept verbatim instead of misplayed as a same-doc copy. - item( - "Dplc", - "Duplicate", - Some(desc("null").with("null", target_ref("Lyr ")).with("T ", D::Reference(vec![ReferenceItem::Name { class: class("Dcmn"), name: UnicodeString::new_nul("Other") }]))), - ), ]; let file = AtnFile { name: UnicodeString::new_nul("Odd"), @@ -470,8 +457,8 @@ fn unsupported_shapes_become_photoshop_steps_not_errors() { }; let imp = import(&atn::write(&file).unwrap()).unwrap(); let ids: Vec<&str> = imp.actions[0].steps.iter().map(|(id, _)| id.as_str()).collect(); - assert_eq!(ids, [PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, "filter.blur.gaussianBlur", PHOTOSHOP_STEP]); - assert_eq!(imp.unsupported.len(), 5); + assert_eq!(ids, [PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, "filter.blur.gaussianBlur"]); + assert_eq!(imp.unsupported.len(), 4); assert!(imp.actions[0].disabled.is_empty(), "steps with a document effect stay on so playback reports them"); } From 549b4387b15fbb18dddb22cdc8a76c02635fb058 Mon Sep 17 00:00:00 2001 From: Ahmed Tammam Date: Sat, 17 Oct 2026 00:34:36 +0300 Subject: [PATCH 06/15] docs(contributors): add Gotix-eg to people.toml --- contributors/people.toml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 8694c72698..eab377f81f 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -162,3 +162,9 @@ sync_github_name = true [people.sidney] real_name = "Sidney Markowitz" sync_github_name = true + +[people.Gotix-eg] +real_name = "Ahmed Tammam" +display_name = "Gotix" +sync_github_name = true + From 242d1cfec089beeb1317ce5d394e267fb6d7a6c7 Mon Sep 17 00:00:00 2001 From: wasif Date: Sat, 17 Oct 2026 12:37:34 +0400 Subject: [PATCH 07/15] docs(xtask): point the layers help text at docs/architecture.md cargo xtask --help and the layers command output both referred to plan/architecture.md, which does not exist in this repository; there is no plan/ directory at the root. The dependency layering rules are documented as section 3 of docs/architecture.md, so point both references there. --- xtask/src/main.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 6c0f016464..d971ea7bdf 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -22,7 +22,7 @@ const USAGE: &str = "\ usage: cargo xtask commands: - layers enforce the crate dependency layering (plan/architecture.md §3) + layers enforce the crate dependency layering (docs/architecture.md §3) wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates ci fmt --check, clippy -D warnings, test, layers, wasm (stops at first failure) corpus [--all | --pngsuite | --psd | --psd-tools | --pixls | --photoshop] [--local] [--update-manifest] @@ -106,7 +106,7 @@ pub fn metadata() -> Result { fn cmd_layers() -> Result<(), String> { let crates = layers::from_metadata(&metadata()?)?; - println!("Dependency layering (plan/architecture.md §3)\n"); + println!("Dependency layering (docs/architecture.md §3)\n"); println!("{:<28} {:<14} workspace deps", "crate", "layer"); for c in &crates { let ws: Vec = c From 2240a9cb05516b2959e9d96db619f143709cf1d6 Mon Sep 17 00:00:00 2001 From: Jazz1-6 Date: Sat, 17 Oct 2026 21:40:34 +0530 Subject: [PATCH 08/15] docs: fix subject-verb agreement in downloads section --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 973fb5330d..f108293053 100644 --- a/README.md +++ b/README.md @@ -334,7 +334,7 @@ sha256-verified. Fetch them with `cargo xtask corpus --all` and run the tests wi **New to PhotoCraft?** Download it from the [PhotoCraft page on getartcraft.com](https://getartcraft.com/apps/photocraft). That's the easiest way to install it. -**Want a specific build or format?** On GitHub, the [latest release](https://github.com/storytold/photocraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/photocraft/releases) has earlier versions and their notes. `` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file. +**Want a specific build or format?** On GitHub, the [latest release](https://github.com/storytold/photocraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/photocraft/releases) have earlier versions and their notes. `` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file. ### Windows From a8b7d2bbca837b86b2fccbb02927ee30151e53fe Mon Sep 17 00:00:00 2001 From: VishnuByrraju Date: Wed, 21 Oct 2026 12:27:12 +0530 Subject: [PATCH 09/15] docs(parity): Crop rows in ui-parity.md match the code and the scorecard (#6562) --- docs/ui-parity.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 793f09840c..6027cc64b9 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (Crop rows corrected: saved crop presets and Delete Cropped Pixels off are done, #6562; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed) · **Target:** Adobe Photoshop 2026 How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation, @@ -142,9 +142,9 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Crop shield, Show Cropped Area (H) | done | TOOL-214-22 | | Default mode: the image turns and moves behind an upright box; Auto Center Preview; Use Classic Mode | done | TOOL-214-23 | | W × H × Resolution with units, built-in size presets, Front Image | done | TOOL-214-9 (`crop_size.rs`) | -| Saved custom crop presets | missing | TOOL-214-9 | +| Saved custom crop presets (New / Delete Crop Preset, kept with the preferences; moving them to another computer is not offered) | done | TOOL-214-9 (`crop_presets.rs`, `tool.presets.*` with tool `crop`, #2723) | | Fill: Content-Aware Fill: a turned or extending crop fills the uncovered corners and strips from the image (each piece with its own sampling window), on the Background or else the active pixel layer, in the crop's one undo step; Generative Expand not offered | done | `tests/crop_content_aware.rs`, `crop_ui.rs::commit_passes_the_fill_choice` (#3823, 2026-10-12) | -| Delete Cropped Pixels off | partial | | +| Delete Cropped Pixels off: the crop only hides the pixels outside the frame; editing the frame again shows them past the canvas and a recrop handle snaps to their edge | done | `image.crop` `deleteCroppedPixels`, the options-bar checkbox (`panels.rs`), `tests/crop_beyond_canvas.rs::editing_a_crop_frame_shows_the_pixels_past_the_canvas`, `crop_mode.rs::a_recrop_handle_snaps_exactly_to_the_hidden_pixels_edge` (#3638, #3640) | ## Snapping, guides, rulers, precision @@ -257,3 +257,4 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-14 | minor | Last selected tool restored across launches; temporary overrides excluded, explicit Eraser choices retained, and final selection flushed on normal exit | | 2026-10-13 | minor | Free Transform with a selection: the box is the selection's rectangle, and the marching ants follow it while it is transformed (#5297) | | 2026-10-14 | minor | Move › Show Transform Controls frames every selected layer and outlines the selected shapes | +| 2026-10-21 | minor | Crop rows corrected against the code and the scorecard: saved custom crop presets (TOOL-214-9, #2723) and Delete Cropped Pixels off are done (#6562); no percentage changed | From e6717c7485c6ef5aa333216a371929729d12f92a Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 16 Oct 2026 02:20:15 +0200 Subject: [PATCH 10/15] Contributors: add my name for the About window --- contributors/people.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index b3cab8e04f..c6c3b0e7ae 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -191,3 +191,8 @@ sync_github_name = true [people.ha1des] sync_github_name = true + +[people.marcoarnulfo] +real_name = "Marco Arnulfo" +display_name = "Marco Arnulfo" +sync_github_name = false From c8ffb9a8b0e098e40dfa22cc80ebe722d9339a65 Mon Sep 17 00:00:00 2001 From: Aditya Vishwakarma <72960955+adi-1108@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:45:23 +0530 Subject: [PATCH 11/15] docs(architecture): drop references to the removed rust-framework-options.md The guide pointed to rust-framework-options.md, which is no longer in the repo, for version pinning and the UI toolkit choice. Say where versions are pinned, and drop the two open decisions this document already records as made (egui/eframe on wgpu; the WebAssembly plug-in API). Co-Authored-By: Claude Opus 5.5 --- docs/architecture.md | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 79c674bf23..a6db032805 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -555,7 +555,7 @@ codegen-units = 1 opt-level = 2 # image code is unusable at opt-level 0 ``` -Versions get pinned when we scaffold, using the numbers in `rust-framework-options.md`. +Versions are pinned in the workspace `Cargo.toml` (`[workspace.dependencies]`). --- @@ -578,17 +578,16 @@ Each phase ends with a demoable build and green CI on all targets. Phases 8–10 ## 16. Open decisions -1. **UI toolkit.** The leading option is egui on winit + wgpu. Bevy, Slint and Qt are the alternatives (see `rust-framework-options.md`). This plan works with any of them. -2. **License.** MIT/Apache-2.0 is maximally reusable. GPL-3.0 is the Krita/GIMP model and protects against proprietary forks. Note that some candidate deps are LGPL (rawler) or GPL-or-commercial (Slint), and MIT/Apache would constrain which of them we can use. -3. **Project name.** `photocraft` (the repo name) is assumed. -4. **PSD crate.** Build our own, or adopt or fork `ag-psd`/`psd`. Decide after the week-1 spike. -5. **Default working depth and blending.** 8-bit + gamma-space blending matches Photoshop. 16-bit/float + linear is the modern default. The proposal is to follow Photoshop by default and make the other a per-document option. -6. **Scope of Photoshop plugin compatibility.** Decided: no native `.8BF` hosting; a sandboxed WebAssembly plug-in API instead (see [`plugins.md`](plugins.md)). +1. **License.** MIT/Apache-2.0 is maximally reusable. GPL-3.0 is the Krita/GIMP model and protects against proprietary forks. Note that some candidate deps are LGPL (rawler) or GPL-or-commercial (Slint), and MIT/Apache would constrain which of them we can use. +2. **Project name.** `photocraft` (the repo name) is assumed. +3. **PSD crate.** Build our own, or adopt or fork `ag-psd`/`psd`. Decide after the week-1 spike. +4. **Default working depth and blending.** 8-bit + gamma-space blending matches Photoshop. 16-bit/float + linear is the modern default. The proposal is to follow Photoshop by default and make the other a per-document option. ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-21 | minor | Removed references to the deleted `rust-framework-options.md`; dropped the UI toolkit and plug-in items from Open decisions, both recorded as decided | | 2026-10-20 | minor | GPU display matrix/shaper path preserves near-black ICC values; complex transforms retain the 3D LUT fallback (#7958) | | 2026-10-13 | minor | Workspace size and `#[test]` counts recounted (393k → 539k lines, 5,460 → 8,040 tests); other current-state figures unchanged | | 2026-10-10 | minor | Current-state summary and deviations from the plan; status line; broken link to `photoshop-parity.md` repointed to `target-app-parity.md` | From ef2d8b1a7d1b0217d2a869db552805de1c1cb83f Mon Sep 17 00:00:00 2001 From: Aditya Vishwakarma <72960955+adi-1108@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:54:16 +0530 Subject: [PATCH 12/15] docs(architecture): requirements, not pins; Cargo.lock has the exact versions Co-Authored-By: Claude Opus 5.5 --- docs/architecture.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture.md b/docs/architecture.md index a6db032805..f4b80270b7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -555,7 +555,7 @@ codegen-units = 1 opt-level = 2 # image code is unusable at opt-level 0 ``` -Versions are pinned in the workspace `Cargo.toml` (`[workspace.dependencies]`). +Dependency requirements are centralized in the workspace `Cargo.toml` (`[workspace.dependencies]`); `Cargo.lock` records the exact resolved versions. --- From 4c0e2d8613f804aa2bdad5bcfde2183824b71983 Mon Sep 17 00:00:00 2001 From: Aditya Vishwakarma <72960955+adi-1108@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:55:41 +0530 Subject: [PATCH 13/15] contributors: add adi-1108 Co-Authored-By: Claude Opus 5.5 --- contributors/people.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index b3cab8e04f..26b8ff42db 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -191,3 +191,7 @@ sync_github_name = true [people.ha1des] sync_github_name = true + +[people.adi-1108] +real_name = "Aditya Vishwakarma" +sync_github_name = true From 8d7b5324ef021b981879007eeb79bdb13761941e Mon Sep 17 00:00:00 2001 From: Reynold Hu <88558425+reynold-hu@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:28:07 +0800 Subject: [PATCH 14/15] docs: answer the Canvas Size white border question (support) --- .../canvas-size-white-border.md | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 docs/user/troubleshooting/canvas-size-white-border.md diff --git a/docs/user/troubleshooting/canvas-size-white-border.md b/docs/user/troubleshooting/canvas-size-white-border.md new file mode 100644 index 0000000000..f8a41eb5a3 --- /dev/null +++ b/docs/user/troubleshooting/canvas-size-white-border.md @@ -0,0 +1,32 @@ +--- +title: Canvas Size added a white border around my photo +description: Why Image > Canvas Size fills the new canvas with white around a photo, and how to extend the canvas with transparency instead. +order: 25 +verified: 0.11.1 +--- + +The photo did not change size. **Image** ▸ **Canvas Size…** only moves the layers; the new canvas area is filled with the **Canvas extension color**, which defaults to **Background** — your background color, usually white. Choose **Transparent** there to add empty canvas instead. + +## Symptoms + +- You open a photo, choose **Image** ▸ **Canvas Size…**, make the canvas larger and click **OK**. +- The photo's layer now has a white or background-colored border, so the layer looks resized or padded. +- The photo content itself keeps its size and position. + +## Fix + +1. Choose **Image** ▸ **Canvas Size…** again. +2. Set **Canvas extension color:** to **Transparent**. +3. Enter the canvas size you want and click **OK**. + +The new area is now empty. If you also want to change the photo's size, use **Image** ▸ **Image Size…**; that scales the image pixels. + +## Why this happens + +An opened JPEG or another fully opaque image becomes a locked **Background** layer, as in Photoshop. A Background layer cannot contain transparent pixels, so when the canvas grows, PhotoCraft fills the new area with the canvas extension color. **Background** is the default, so the border takes your background color — white until you change it. Layers that are not the Background are only moved and keep transparency. + +Canvas Size never scales layer pixels: it moves them by the **Anchor:** you choose. Scaling is **Image** ▸ **Image Size…**'s job. + +## Still stuck? + +If the photo itself is scaled or cropped, or the border appears with **Transparent** selected, report it in the [issue tracker](https://github.com/storytold/photocraft/issues) with your version from **Help** ▸ **System Info…** and a screenshot of the Canvas Size settings. From b2611ce8460944d6a9c66751c6306490161a67e5 Mon Sep 17 00:00:00 2001 From: Reynold Hu <88558425+reynold-hu@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:28:07 +0800 Subject: [PATCH 15/15] docs: list the canvas-size page in troubleshooting --- docs/user/troubleshooting/index.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/user/troubleshooting/index.md b/docs/user/troubleshooting/index.md index 4785ae412b..a92501d040 100644 --- a/docs/user/troubleshooting/index.md +++ b/docs/user/troubleshooting/index.md @@ -8,4 +8,5 @@ verified: 0.8.2 - [PhotoCraft does not start](photocraft-does-not-start.md) - [The Windows taskbar turns black when PhotoCraft is maximized](taskbar-turns-black.md) - [Text moves when I start editing a PSD text layer](text-moves-when-editing.md) +- [Canvas Size added a white border around my photo](canvas-size-white-border.md) - [Get help with a problem](get-help.md)