Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changepacks/changepack_log_cropped_export_background.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"changes": {
"crates/devup-mcp-devup-ui/Cargo.toml": "Minor"
},
"note": "Stop cropping a photograph twice. A CROP image fill carries its framing as a matrix over the image's own coordinate space, and the generator read that matrix and turned it into a CSS background position and size. That would be right if the file being positioned were the original image, but it is not: the emitted file is a node export, and Figma has already applied the crop when producing those pixels. Applying the matrix again framed an already-framed picture, which is why the about page's photographs came out zoomed and offset against Figma's own render of the same frame - and why the earlier attempt to honour the crop appeared to help while measuring worse. A cropped fill now maps its exported frame once onto the layout box, and the matrix reader is deleted rather than left for someone to reach for again; FIT, FILL and TILE are unchanged. Measured against Figma's reference PNG, the three about screens improve from 7.46, 4.06 and 2.41 percent to 5.51, 3.01 and 1.77, with page heights unchanged and every other screen byte-identical. All 268 plugin byte-parity goldens are unchanged, and the responsive-screen expectation that asserted the old doubled crop is corrected to state why the export is mapped once.",
"date": "2026-09-13T14:15:00+09:00"
}
48 changes: 6 additions & 42 deletions crates/devup-mcp-devup-ui/src/codegen/style.rs
Original file line number Diff line number Diff line change
Expand Up @@ -917,37 +917,6 @@ pub fn asset_path(snapshot: &Snapshot, node_id: &str, per_node: bool) -> Option<
Some(asset_source(snapshot, node, folder, extension, per_node))
}

/// The `position/size` a cropped image fill is painted with, read from
/// Figma's `imageTransform`. The matrix maps the image's own 0..1 space onto
/// the box: the part on show runs from `tx` for `sx` across and from `ty` for
/// `sy` down. Scaling the picture by `1/sx` makes that part as wide as the
/// box, and `tx / (1 - sx)` is where along the overflow it has to sit - which
/// is exactly the percentage CSS positions a background by. A scale of one
/// leaves no overflow to position within, so it sits at the start.
fn image_crop(paint: &Value) -> Option<String> {
let rows = paint.get("imageTransform")?.as_array()?;
let cell = |row: usize, column: usize| rows.get(row)?.as_array()?.get(column)?.as_f64();
let (scale_x, offset_x) = (cell(0, 0)?, cell(0, 2)?);
let (scale_y, offset_y) = (cell(1, 1)?, cell(1, 2)?);
if scale_x == 0.0 || scale_y == 0.0 {
return None;
}
let position = |scale: f64, offset: f64| {
if (1.0 - scale).abs() < 1e-6 {
0.0
} else {
offset / (1.0 - scale) * 100.0
}
};
Some(format!(
"{}% {}%/{}% {}%",
format_number(position(scale_x, offset_x)),
format_number(position(scale_y, offset_y)),
format_number(100.0 / scale_x),
format_number(100.0 / scale_y),
))
}

/// Where the code draws one of a node's image fills from: `/images/x.png`
/// for the first fill and `/images/x-2.png` past it, the same name
/// `image_fill_source` writes into the code but without the quoting a CSS
Expand Down Expand Up @@ -1048,19 +1017,14 @@ fn paint_css(
"GRADIENT_DIAMOND" => gradient_css(node, paint, "diamond", variable_tokens),
"IMAGE" => {
let source = image_fill_source(snapshot, node, fill_index, per_node);
// A cropped fill carries its crop as a matrix over the image's own
// 0..1 space. Painted `center/cover` that is thrown away and the
// whole picture is shown instead, which is a different crop: the
// about page's photographs came out zoomed in against the render
// Figma draws of the same frame.
if paint.get("scaleMode").and_then(Value::as_str) == Some("CROP")
&& let Some(crop) = image_crop(paint)
{
return Some(format!("url({source}) {crop} no-repeat"));
}
let fit = match paint.get("scaleMode").and_then(Value::as_str) {
// image_fill_source names a node.exportAsync rendition, not
// the original imageHash bytes. Figma has already applied
// imageTransform to that rendition; applying its inverse here
// crops and stretches the exported picture a second time.
Some("CROP") => "0 0/100% 100% no-repeat",
Some("FIT") => "center/contain no-repeat",
Some("FILL" | "CROP") => "center/cover no-repeat",
Some("FILL") => "center/cover no-repeat",
Some("TILE") => "repeat",
_ => "center/cover no-repeat",
};
Expand Down
90 changes: 90 additions & 0 deletions crates/devup-mcp-devup-ui/tests/cropped_export_background.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
use devup_mcp_devup_ui::codegen::{CodegenOptions, generate_component};
use devup_mcp_figma::Snapshot;
use serde_json::{Value, json};

fn picture(mode: &str, transform: Value, width: u32, height: u32) -> Snapshot {
serde_json::from_value(json!({
"fileKey":"crop-export", "version":"1", "roots":["picture"], "diagnostics":[],
"nodes":{"picture":{"id":"picture","type":"FRAME","fields":{
"name":"Portrait", "isAsset":true,
"width":width, "height":height,
"layoutSizingHorizontal":"FIXED", "layoutSizingVertical":"FIXED",
"fills":[
{"type":"SOLID","color":{"r":0.9,"g":0.9,"b":0.9}},
{"type":"IMAGE","imageHash":"photo","scaleMode":mode,
"imageTransform":transform}
]
}}}
}))
.unwrap()
}

// The files image_fill_path names are node exports, not getImageByHash bytes.
// Reapplying any source crop to those files distorts an already cropped image.
#[test]
fn cropped_node_export_does_not_apply_the_source_transform_twice() {
for (transform, width, height) in [
(json!([[0.5, 0, 0.25], [0, 0.8, 0.1]]), 240, 180),
(json!([[1.1, 0, -0.05], [0, 0.7, 0.2]]), 513, 271),
(json!([[0, -1, 1], [1, 0, 0]]), 137, 219),
(Value::Null, 211, 149),
] {
let snapshot = picture("CROP", transform, width, height);
let output = generate_component(&snapshot, "picture", &CodegenOptions::default()).unwrap();
assert!(
output
.tsx
.contains("url(/images/Portrait-1.png) 0 0/100% 100% no-repeat"),
"{}",
output.tsx
);
}
}

#[test]
fn non_crop_background_modes_keep_their_existing_behavior() {
for (mode, expected) in [
("FILL", "center/cover no-repeat"),
("FIT", "center/contain no-repeat"),
("TILE", "repeat"),
] {
let snapshot = picture(mode, json!([[0.5, 0, 0.25], [0, 0.8, 0.1]]), 240, 180);
let output = generate_component(&snapshot, "picture", &CodegenOptions::default()).unwrap();
assert!(
output
.tsx
.contains(&format!("url(/images/Portrait-1.png) {expected}")),
"{}",
output.tsx
);
}
}

#[test]
fn cropped_export_background_maps_to_its_actual_fill_and_asset() {
let snapshot = picture("CROP", json!([[0.5, 0, 0.25], [0, 0.8, 0.1]]), 240, 180);
let output = generate_component(&snapshot, "picture", &CodegenOptions::default()).unwrap();
assert!(output.source_map.entries.iter().any(|entry| {
entry.node_id.as_deref() == Some("picture")
&& entry.property.as_deref() == Some("fills")
&& entry
.generated_property
.as_deref()
.is_some_and(|property| property.contains("0 0/100% 100% no-repeat"))
}));
assert!(
output
.source_map
.entries
.iter()
.any(|entry| { entry.asset_id.as_deref() == Some("picture:fills:1") })
);
assert!(output.fidelity_report.assets.complete());
let mut wrong = output.clone();
wrong.tsx = wrong
.tsx
.replace("/images/Portrait-1.png", "/images/unrelated.png");
let report =
devup_mcp_devup_ui::provenance::validate_fidelity(&snapshot, "picture", &wrong).unwrap();
assert!(!report.assets.complete());
}
39 changes: 6 additions & 33 deletions crates/devup-mcp-devup-ui/tests/responsive_screen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -718,43 +718,16 @@ const ABOUT_DIFFERS_ON_PURPOSE: &[(&str, &str)] = &[
"flex=\"1\"",
"plugin leaves a filled height unsaid, which CSS cannot reach past a positioned child",
),
// A cropped image fill carries its crop as a matrix over the image's own
// 0..1 space. The plugin paints every image fill `center/cover` and drops
// it, showing the whole picture where the designer framed a part of it.
// These are the crops Figma itself draws, read off `imageTransform`:
// rendered against Figma's own PNG of this page, the photographs only sit
// right once the crop is honoured.
// Image-fill files are node exports: Figma already baked the crop into
// the pixels. Reapplying imageTransform distorts those pixels. The
// background maps the exported frame once onto its layout box instead.
(
"bg=\"url(IMAGEFILL) 53.51% -25.92%/91.26% 104.43% no-repeat, $gray200\"",
"plugin drops the crop matrix and paints center/cover",
),
(
"bg=\"url(IMAGEFILL) 0% 41.89%/100% 114.38% no-repeat, $gray200\"",
"plugin drops the crop matrix and paints center/cover",
),
(
"bg=\"url(IMAGEFILL) 0% 11.18%/100% 117.53% no-repeat, $gray200\"",
"plugin drops the crop matrix and paints center/cover",
),
(
"bg=\"url(IMAGEFILL) 0% 11.43%/100% 117.49% no-repeat, $gray200\"",
"plugin drops the crop matrix and paints center/cover",
),
(
"bg=\"url(IMAGEFILL) 0% 4.04%/100% 120.06% no-repeat, $gray200\"",
"plugin drops the crop matrix and paints center/cover",
),
(
"bg=\"url(IMAGEFILL) 0% 24.48%/100% 117.53% no-repeat, $gray200\"",
"plugin drops the crop matrix and paints center/cover",
),
(
"bg=\"url(IMAGEFILL) 0% 30.1%/100% 117.49% no-repeat, $gray200\"",
"plugin drops the crop matrix and paints center/cover",
"bg=\"url(IMAGEFILL) 0 0/100% 100% no-repeat, $gray200\"",
"cropped node export already contains the imageTransform",
),
(
"bg=\"url(IMAGEFILL) center/cover no-repeat, $gray200\"",
"plugin drops the crop matrix and paints center/cover",
"plugin applies cover to the already-cropped node export",
),
(
"성인 ADHD,{\" \"}",
Expand Down
155 changes: 155 additions & 0 deletions docs/about-mobile-composition.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# About mobile: an exported crop was cropped again

Base: `b7451aa410246f066e1dc5fd685a23456d46d94a`, measured on 2026-09-13.

## Localisation before implementation

Read the three preceding geometry reports before building the unmodified base
in this worktree's own target. The fresh binary SHA-256 is
`2933385fdb7a495f0fb2b05afe9b1228270ed80cc2318b605a685a685bd9dca6`.
Fresh acquisition and rendering reproduce about **7.46 / 4.06 / 2.41%**,
with rendered heights **7240 / 5620 / 4758px**. These are measurements of
the base binary, not values copied from the thresholds file.

`bands.mjs` places a major non-text difference at y=6000–6100 (24.25%).
`drift.mjs` cannot explain the portrait differences by a uniform page shift.
The y=5720 crop, `elements.mjs`, and the snapshot identify the element and
axis: portrait `422:3551` has the correct **300×400** box at **(30,5746)**,
but its image is stretched vertically *inside* that box. Its generated
background is **100% 117.53%**, positioned at **0% 24.48%**. Horizontal
frame placement and page height are correct.

The same defect affects mobile portraits `422:3514` and `422:3560`:

| Node | Layout position | Layout/export size | Generated background size |
| --- | --- | --- | --- |
| `422:3514` | 30,4514 | 300×400 | 100% 120.06% |
| `422:3551` | 30,5746 | 300×400 | 100% 117.53% |
| `422:3560` | 30,6275 | 300×400 | 100% 117.49% |

Each untransformed PNG, composited onto white at its collected position,
has **zero differing pixels at the harness's 24-channel tolerance** against
the corresponding reference region. The same zero-difference comparison
holds for all eight wider CROP portraits: tablet `422:3320`, `422:3340`,
`422:3358`, `422:3367`, and desktop `422:3125`, `422:3145`, `422:3162`,
`422:3171`. Those exports are 305×400, not the mobile 300×400.
This comparison uses exported pixels,
not an assumption about what the original photograph looks like.

`crates/devup-mcp-figma/src/scripts/assets.js` validates a `fills/N` request
against the selected image hash, then calls **`node.exportAsync(settings)`**.
It does not return `figma.getImageByHash(...).getBytesAsync()`.
The resulting PNG already contains the source crop. The chunked transport
also re-exports the node. Meanwhile, `paint_css` applies `imageTransform` to
that exported file as if it were the original photograph. For `422:3551`,
the vertical scale 0.8508567214 becomes 117.53% and expands the already
cropped 400px image to roughly 470px.

This rules out the previous line-advance, stroke, weight, overflowing row,
and collapsed-mask causes for these portrait pixels. Korean `keep-all`
remains intentional and is not part of this defect. The prior crop comments
in `responsive_screen.rs` describe original-image semantics; the actual
export transport supplies node-render semantics instead.

## Test-first record

`harness/render/out/w18-crop-red.log` in the main checkout records the
regression against unchanged production: **1 passed / 2 failed**.
The failing synthetic crop renders `50% 50%/200% 125%`; the node-export
contract requires displaying the already cropped image once. Inputs vary
both transform axes, node dimensions, a rotated transform, and a missing
transform. The negative test preserves FILL, FIT, and TILE behavior. The second RED
assertion requires the mapped `fills` property to describe the corrected
background. After implementation, all three tests pass; changing the mapped
image URL to an unrelated asset still makes asset fidelity incomplete.

All **268 plugin goldens** pass unchanged, along with the manifest hash and
coverage registry checks. No snapshot or manifest checksum was updated.
`responsive_screen.rs` removes seven obsolete crop-matrix allowlist strings
and records the actual node-export semantics; its comparison remains exact
outside the explicitly enumerated differences.

## Generator change

The IMAGE paint's `scaleMode == CROP` branch now emits
`0 0/100% 100% no-repeat`. The source filename still identifies the same
node and fill index. Its crop is preserved in the exported pixels rather
than reapplied as a CSS transform. FILL, FIT, TILE, layer ordering, theme
colors, Korean wrapping, and layout emission retain their existing behavior.
No raw-image source exists in this export path, so the unused inverse-crop
helper is removed rather than retained behind an invented heuristic.
The relative 100% sizing is the complete exported frame, not a tuned pixel
constant. No node identity, viewport width, or breakpoint is consulted.

The existing source-map path maps `bg` to `fills` and the asset to its real
`nodeId:fills:index`; the regression verifies both. No provenance or fidelity
expectation was weakened, and no new exactness claim is made about the
screen's remaining lossy projection.

## Fresh measurements

Candidate binary SHA-256:
`7f1bf937c8da32739851e45900a40d03d9cc051d9a6ec7fbf0e3eebca7264924`.
It was copied to `target/debug/devup-mcp-w18-candidate.exe` immediately after
building, so the full gate cannot overwrite the measured binary. Every
measurement reacquires with that exact executable and the unchanged harness.

| About screen | Fresh baseline | Candidate | Fresh repeat | Rendered height, all |
| --- | ---: | ---: | ---: | ---: |
| `about-422-3376` | 7.46% | 5.51% | 5.51% | 7240 |
| `about-422-3180` | 4.06% | 3.01% | 3.01% | 5620 |
| `about-422-2987` | 2.41% | 1.77% | 1.77% | 4758 |

Mobile's exact ratio falls from **0.07457565991405771** to
**0.05514426028238183**, a **1.9431399632 percentage-point** decrease.
The browser-only probe produces the same candidate ratio. The revised
portrait crop was visually inspected against the reference at full size.

| Other group | Fresh baseline = candidate, narrow to wide |
| --- | --- |
| landing | 4.99 / 2.47 / 1.50% |
| notice | 5.25 / 3.22 / 2.20% |
| popup | 3.64 / 2.06 / 0.85% |
| grid | 2.96% |
| keyframes | 6.71% |
| report | 1.28% |

All **12 non-about actual PNGs are byte-identical** to their fresh baseline.
All 15 reference hashes, theme hashes, and reported rendered sizes match.
No increase is hidden by rounding or by the harness threshold tolerance.
[about-mobile-evidence.json](about-mobile-evidence.json) records exact ratios,
actual/reference hashes, theme hashes, sizes, and binary identities.

## Full gate

The full gate runs in this worktree's own target with
`CARGO_PROFILE_DEV_DEBUG=0`, `CARGO_PROFILE_TEST_DEBUG=0`,
`CARGO_INCREMENTAL=0`, and two jobs (`CARGO_BUILD_JOBS=2` for insta).
`CARGO_TARGET_DIR` is never set.

| Gate | Result |
| --- | --- |
| `cargo fmt --all -- --check` | Pass |
| `cargo clippy --locked --workspace --all-targets --all-features -j 2 -- -D warnings` | Pass, zero warnings |
| `cargo test --workspace -j 2 --no-fail-fast` | 1070 passed, 0 failed, 2 ignored |
| `cargo insta test --workspace --all-features --check` | 1070 passed, 0 failed, 2 ignored; no snapshots to review |
| `cargo test --locked -p devup-mcp --test stdio_smoke -j 2` | 2 passed, 0 failed |

The ordinary MSVC link steps print the existing localized library-creation
warning; Clippy itself has zero warnings. Logs are
`w18-{fmt,clippy,workspace,insta,smoke}.log` under the main harness's `out/`.
No forbidden Rust file, harness script, plugin golden, manifest checksum,
Korean word-break rule, or corpus consistency assertion changed.

Measurement logs, diagnostic crops, and saved baseline PNGs are under the
main checkout's ignored `harness/render/out/w18-*` paths. Harness scripts
are unchanged.

The second fresh about acquire/render run reproduces every exact ratio,
rendered size, and actual/reference PNG hash from the first candidate.
Only the three measured about thresholds are lowered, after that repetition,
to **5.51 / 3.01 / 1.77%**. The other thresholds remain unchanged.

This delivery addresses one cause in one local commit, without pushing.
The worktree target is cleaned after the commit, and all acquisition,
diagnostic browser, and test processes started for this task are closed.
Loading