diff --git a/.changepacks/changepack_log_pF7kQ2wNx9LmR4tVb8sYc.json b/.changepacks/changepack_log_pF7kQ2wNx9LmR4tVb8sYc.json new file mode 100644 index 00000000..680be0ca --- /dev/null +++ b/.changepacks/changepack_log_pF7kQ2wNx9LmR4tVb8sYc.json @@ -0,0 +1,7 @@ +{ + "changes": { + "Cargo.toml": "Patch" + }, + "note": "Keep Korean word boundaries with textWrap pretty, reset generated heading margins, keep extra-fill asset paths distinct, label missing-only devupJson as additions-only, flag hidden component masters, and report on-disk facts for every asset output path.", + "date": "2026-11-10T00:00:00.000000000Z" +} diff --git a/crates/devup-mcp-devup-ui/src/codegen/property_derivations.rs b/crates/devup-mcp-devup-ui/src/codegen/property_derivations.rs index 3e0fdf7a..57283086 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/property_derivations.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/property_derivations.rs @@ -126,6 +126,7 @@ pub(crate) fn property_derivations( "flexShrink" => (vec!["layoutSizingHorizontal", "layoutSizingVertical", "layoutGrow", "parentId", "width", "height"], "push_layout_props: preserve the fixed primary-axis size in the parent flex layout by disabling shrink."), "wordBreak" | "overflowWrap" => (vec!["characters", "styledTextSegments", "width"], "Korean captured text uses keep-all word boundaries and break-word emergency wrapping for a token wider than its container; explicit local/theme policies are preserved."), + "textWrap" => (vec!["characters", "textAutoResize", "maxLines", "width"], "Korean soft-wrapping text adds textWrap pretty beside keep-all so line breaks stay on word boundaries without large trailing gaps; it is omitted for whiteSpace pre/nowrap (text-wrap would re-enable wrapping) and for explicit local/theme textWrap."), "boxShadow" if style::asset_kind(snapshot, node).is_none() && style::single_outside_stroke(&node.typed_view()).is_some() => (vec!["strokes", "strokeAlign", "strokeTopWeight", "strokeRightWeight", "strokeBottomWeight", "strokeLeftWeight", "effects"], include_str!("../text/property_derivations-property_derivations-f4103b4e.md")), @@ -154,6 +155,7 @@ pub(crate) fn property_derivations( "whiteSpace" if text::preserves_hard_break_spaces(&node.typed_view()) => (vec!["characters", "styledTextSegments", "textAutoResize", "maxLines"], include_str!("../text/property_derivations-property_derivations-2ec1244f.md")), "textDecoration" => (vec!["textDecoration", "styledTextSegments"], "Text UNDERLINE/STRIKETHROUGH maps to underline/line-through."), "textTransform" => (vec!["textCase", "styledTextSegments"], "Text case projection follows push_text_props."), + "my" if *stage == "semantic" => (vec!["name", "parentId"], "A named heading layer becomes an HTML heading; its user-agent block margins are reset to 0px because the drawn Figma text has no such margin."), "as" | "alt" if *stage == "semantic" => (vec!["name", "parentId"], "Explicit semantic layer/component names choose a non-visual HTML tag; descriptive asset names supply alt text and generic shape/icon names use empty alt. Variant roots use the named parent component set."), "as" | "my" => (vec!["styledTextSegments", "listOptions", "paragraphSpacing"], "Text list/paragraph projection uses the emitted semantic tag and spacing policy."), "animationName" | "animationDuration" | "animationTimingFunction" | "animationFillMode" | "animationDelay" | "animationIterationCount" => diff --git a/crates/devup-mcp-devup-ui/src/codegen/semantics.rs b/crates/devup-mcp-devup-ui/src/codegen/semantics.rs index ee961087..22a29816 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/semantics.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/semantics.rs @@ -117,5 +117,18 @@ pub(super) fn push_semantic_props( }; if let Some(tag) = tag { string_prop(props, "as", tag); + // Headings carry user-agent block margins (e.g. ~0.67em on h1) that the + // drawn Figma text never had; neutralize them unless a margin is drawn. + if is_heading(tag) + && !props + .iter() + .any(|(name, _)| matches!(name.as_str(), "m" | "my" | "mt" | "mb")) + { + string_prop(props, "my", "0px"); + } } } + +fn is_heading(tag: &str) -> bool { + matches!(tag, "h1" | "h2" | "h3" | "h4" | "h5" | "h6") +} diff --git a/crates/devup-mcp-devup-ui/src/codegen/style/image_fill_source.rs b/crates/devup-mcp-devup-ui/src/codegen/style/image_fill_source.rs index 4bce2fda..2166cdd7 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/style/image_fill_source.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/style/image_fill_source.rs @@ -8,7 +8,7 @@ /// `{nodeId}:fills:{index}`, so the reference keeps the node's name and, past /// the first fill, its index — a lone fill keeps the plain /// `/images/{name}.png` the `` element already emits, so the two agree -/// on the same asset. +/// on the same asset. See `fill_file_stem` for the extra-fill suffix. fn image_fill_source( snapshot: &Snapshot, node: &RawNode, @@ -18,8 +18,8 @@ fn image_fill_source( let source = if fill_index == 0 { asset_source(snapshot, node, "images", "png", per_node) } else { - let stem = asset_stem(snapshot, node, per_node); - format!("/images/{stem}-{fill_index}.png") + let stem = fill_file_stem(&asset_stem(snapshot, node, per_node), fill_index); + format!("/images/{stem}.png") }; if source.contains(' ') { format!("'{source}'") @@ -64,8 +64,21 @@ pub fn asset_path(snapshot: &Snapshot, node_id: &str, per_node: bool) -> Option< Some(asset_source(snapshot, node, folder, extension, per_node)) } +/// The file stem for a node's extra image fill (`fill_index > 0`): +/// `{stem}--fill-{fill_index}`, with the same 0-based index as the manifest's +/// `{nodeId}:fills:{index}`. +/// +/// A plain `-{index}` suffix collided with the `-2`, `-3` that `asset_stem` +/// gives a different asset sharing the base name: fill 2 of `hero` and the +/// second `hero` asset were both `hero-2.png`. Every stem is kebab-case with +/// hyphens collapsed, so no stem (deduplicated or not) contains `--`, and an +/// extra-fill path cannot equal any other asset's path. +fn fill_file_stem(stem: &str, fill_index: usize) -> String { + format!("{stem}--fill-{fill_index}") +} + /// 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 +/// for the first fill and `/images/x--fill-1.png` for the second, the same name /// `image_fill_source` writes into the code but without the quoting a CSS /// `url()` puts around a name with a space in it. /// @@ -85,7 +98,7 @@ pub fn image_fill_path( Some(if fill_index == 0 { format!("/images/{stem}.png") } else { - format!("/images/{stem}-{fill_index}.png") + format!("/images/{}.png", fill_file_stem(&stem, fill_index)) }) } diff --git a/crates/devup-mcp-devup-ui/src/codegen/text/wrapping_policy.rs b/crates/devup-mcp-devup-ui/src/codegen/text/wrapping_policy.rs index 3cad8e0f..573ef068 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/text/wrapping_policy.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/text/wrapping_policy.rs @@ -18,19 +18,24 @@ fn segments_contain_korean(view: &TypedNode<'_>) -> bool { }) } -fn theme_has_word_break(value: &Value) -> bool { +fn theme_has_key(value: &Value, key: &str) -> bool { match value { - Value::Array(values) => values.iter().any(theme_has_word_break), - Value::Object(value) => value.contains_key("wordBreak"), + Value::Array(values) => values.iter().any(|value| theme_has_key(value, key)), + Value::Object(value) => value.contains_key(key), _ => false, } } /// Korean copy wraps at word boundaries even in measured fixed-width text. +/// `textWrap="pretty"` lets the browser rebalance word-boundary breaks so +/// keep-all does not leave a large trailing gap or a single orphaned word. +/// `text-wrap` is a CSS shorthand that also resets `text-wrap-mode` to +/// `wrap`, so it is never added to text whose `whiteSpace` (`pre`/`nowrap`) +/// deliberately disables soft wrapping: it would re-enable wrapping there. fn push_korean_wrap_policy(view: &TypedNode<'_>, theme: Option<&Value>, props: &mut Vec) { if !segments_contain_korean(view) || props.iter().any(|(name, _)| name == "wordBreak") - || theme.is_some_and(theme_has_word_break) + || theme.is_some_and(|value| theme_has_key(value, "wordBreak")) { return; } @@ -38,5 +43,14 @@ fn push_korean_wrap_policy(view: &TypedNode<'_>, theme: Option<&Value>, props: & if !props.iter().any(|(name, _)| name == "overflowWrap") { string_prop(props, "overflowWrap", "break-word"); } + if !props.iter().any(|(name, _)| name == "textWrap") + && !props.iter().any(|(name, value)| { + name == "whiteSpace" + && matches!(value, PropValue::String(mode) if mode == "pre" || mode == "nowrap") + }) + && !theme.is_some_and(|value| theme_has_key(value, "textWrap")) + { + string_prop(props, "textWrap", "pretty"); + } } diff --git a/crates/devup-mcp-devup-ui/src/codegen/text/wrapping_policy_tests.rs b/crates/devup-mcp-devup-ui/src/codegen/text/wrapping_policy_tests.rs index 8dd12ca6..9e3645f7 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/text/wrapping_policy_tests.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/text/wrapping_policy_tests.rs @@ -12,5 +12,66 @@ mod wrapping_policy_tests { assert_eq!(props.len(), 1); assert!(matches!(&props[0].1, PropValue::String(value) if value == "break-all")); } -} + fn props_for(characters: &str, existing: Vec) -> Vec { + let snapshot: devup_mcp_figma::Snapshot = serde_json::from_value(serde_json::json!({ + "fileKey": "test", "version": null, "diagnostics": [], "nodes": {"t": {"id": "t", "type": "TEXT", "fields": {"name": "Copy", "characters": characters}}}, + "roots": ["t"] + })) + .unwrap(); + let mut props = existing; + push_korean_wrap_policy(&snapshot.nodes["t"].typed_view(), None, &mut props); + props + } + + fn value_of<'a>(props: &'a [Prop], key: &str) -> Option<&'a str> { + props.iter().find(|(name, _)| name == key).map(|(_, value)| { + let PropValue::String(value) = value; + value.as_str() + }) + } + + #[test] + fn korean_wrapping_text_keeps_word_boundaries_and_pretty_wrap() { + let props = props_for("진단 결과를 바탕으로 개선을 제안합니다", Vec::new()); + assert_eq!(value_of(&props, "wordBreak"), Some("keep-all")); + assert_eq!(value_of(&props, "overflowWrap"), Some("break-word")); + assert_eq!(value_of(&props, "textWrap"), Some("pretty")); + } + + #[test] + fn pretty_wrap_never_reenables_wrapping_for_pre_or_nowrap_text() { + for mode in ["pre", "nowrap"] { + let props = props_for( + "편집 설정", + vec![("whiteSpace".into(), PropValue::String(mode.into()))], + ); + assert_eq!(value_of(&props, "wordBreak"), Some("keep-all"), "{mode}"); + assert_eq!(value_of(&props, "textWrap"), None, "{mode}"); + } + let props = props_for( + "편집 설정", + vec![("whiteSpace".into(), PropValue::String("pre-wrap".into()))], + ); + assert_eq!(value_of(&props, "textWrap"), Some("pretty")); + } + + #[test] + fn explicit_local_or_theme_text_wrap_is_preserved() { + let props = props_for( + "한국어 문장", + vec![("textWrap".into(), PropValue::String("balance".into()))], + ); + assert_eq!(value_of(&props, "textWrap"), Some("balance")); + let snapshot: devup_mcp_figma::Snapshot = serde_json::from_value(serde_json::json!({ + "fileKey": "test", "version": null, "diagnostics": [], "nodes": {"t": {"id": "t", "type": "TEXT", "fields": {"name": "Copy", "characters": "한국어 문장"}}}, + "roots": ["t"] + })) + .unwrap(); + let theme = serde_json::json!([{"textWrap": "balance"}]); + let mut props = Vec::new(); + push_korean_wrap_policy(&snapshot.nodes["t"].typed_view(), Some(&theme), &mut props); + assert_eq!(value_of(&props, "textWrap"), None); + assert_eq!(value_of(&props, "wordBreak"), Some("keep-all")); + } +} diff --git a/crates/devup-mcp-devup-ui/src/ui_validate/delivery_quality.rs b/crates/devup-mcp-devup-ui/src/ui_validate/delivery_quality.rs index 4101c360..ebafb86e 100644 --- a/crates/devup-mcp-devup-ui/src/ui_validate/delivery_quality.rs +++ b/crates/devup-mcp-devup-ui/src/ui_validate/delivery_quality.rs @@ -121,7 +121,7 @@ impl<'a> Visit<'a> for Collector<'_> { && matches!(&attribute.name, JSXAttributeName::Identifier(name) if name.name=="wordBreak") && literal(attribute, self.source).as_deref()==Some("normal") { self.findings.push(site::violation(self.source, attribute.span, "korean-word-boundary", Severity::Warning, - "Korean content explicitly uses wordBreak normal and can split within a word.".into(), "Use keep-all with normal wrapping; separately handle long URLs and tokens.")); + "Korean content explicitly uses wordBreak normal and can split within a word.".into(), "Use keep-all with textWrap pretty so lines break on word boundaries without large trailing gaps; handle long URLs and tokens with overflowWrap break-word.")); } } } diff --git a/crates/devup-mcp-devup-ui/tests/asset_boundaries.rs b/crates/devup-mcp-devup-ui/tests/asset_boundaries.rs index 6b721bff..7efca7a6 100644 --- a/crates/devup-mcp-devup-ui/tests/asset_boundaries.rs +++ b/crates/devup-mcp-devup-ui/tests/asset_boundaries.rs @@ -117,7 +117,7 @@ fn separate_image_fills_do_not_claim_the_same_file() { ); assert!(tsx.contains("/images/card.png"), "{tsx}"); - assert!(tsx.contains("/images/card-1.png"), "{tsx}"); + assert!(tsx.contains("/images/card--fill-1.png"), "{tsx}"); } #[test] diff --git a/crates/devup-mcp-devup-ui/tests/codegen.rs b/crates/devup-mcp-devup-ui/tests/codegen.rs index d429d575..eda4422f 100644 --- a/crates/devup-mcp-devup-ui/tests/codegen.rs +++ b/crates/devup-mcp-devup-ui/tests/codegen.rs @@ -67,6 +67,7 @@ fn generates_deterministic_devup_ui_tsx() { " fontSize=\"16px\"\n", " lineHeight=\"24px\"\n", " overflowWrap=\"break-word\"\n", + " textWrap=\"pretty\"\n", " wordBreak=\"keep-all\"\n", " >\n", " 안녕 {\"<\"}Devup{\">\"}\n", diff --git a/crates/devup-mcp-devup-ui/tests/cropped_export_background.rs b/crates/devup-mcp-devup-ui/tests/cropped_export_background.rs index a1fbf867..f51392d6 100644 --- a/crates/devup-mcp-devup-ui/tests/cropped_export_background.rs +++ b/crates/devup-mcp-devup-ui/tests/cropped_export_background.rs @@ -93,7 +93,7 @@ fn cropped_export_background_maps_to_its_actual_fill_and_asset() { let mut wrong = output.clone(); wrong.tsx = wrong .tsx - .replace("/images/portrait-1.png", "/images/unrelated.png"); + .replace("/images/portrait--fill-1.png", "/images/unrelated.png"); let report = devup_mcp_devup_ui::provenance::validate_fidelity(&snapshot, "picture", &wrong).unwrap(); assert!(!report.assets.complete()); diff --git a/crates/devup-mcp-devup-ui/tests/image_asset_names.rs b/crates/devup-mcp-devup-ui/tests/image_asset_names.rs index cfa857ab..cf60c925 100644 --- a/crates/devup-mcp-devup-ui/tests/image_asset_names.rs +++ b/crates/devup-mcp-devup-ui/tests/image_asset_names.rs @@ -119,3 +119,52 @@ fn distinct_svg_variants_do_not_share_a_filename_when_per_node_is_off() { asset_path(&snapshot, "b", false) ); } + +/// Feedback 006: extra fills were `{stem}-{index}` while a different asset +/// with the same base name was deduplicated to `{stem}-2`, so fill 2 of the +/// first `hero` and the second `hero` asset both became `hero-2.png`. +#[test] +fn extra_fill_paths_cannot_collide_with_deduplicated_asset_names() { + let snapshot: Snapshot = serde_json::from_value(json!({"fileKey":"fixture","roots":["root"],"diagnostics":[],"nodes":{ + "root":{"id":"root","type":"FRAME","fields":{"name":"Main","childrenIds":["1:1","1:2","1:3"]}}, + "1:1":{"id":"1:1","type":"RECTANGLE","fields":{"name":"Hero","parentId":"root","isAsset":true,"fills":[ + {"type":"IMAGE","imageHash":"photo-a"},{"type":"IMAGE","imageHash":"photo-b"},{"type":"IMAGE","imageHash":"photo-c"}]}}, + "1:2":{"id":"1:2","type":"RECTANGLE","fields":{"name":"Hero","parentId":"root","isAsset":true,"fills":[{"type":"IMAGE","imageHash":"photo-d"}]}}, + "1:3":{"id":"1:3","type":"RECTANGLE","fields":{"name":"Hero","parentId":"root","isAsset":true,"fills":[{"type":"IMAGE","imageHash":"photo-e"}]}} + }})).unwrap(); + for per_node in [false, true] { + let mut paths = Vec::new(); + for fill in 0..3 { + paths.push(image_fill_path(&snapshot, "1:1", fill, per_node).unwrap()); + } + paths.push(image_fill_path(&snapshot, "1:2", 0, per_node).unwrap()); + paths.push(image_fill_path(&snapshot, "1:3", 0, per_node).unwrap()); + assert_eq!( + paths, + [ + "/images/hero.png", + "/images/hero--fill-1.png", + "/images/hero--fill-2.png", + "/images/hero-2.png", + "/images/hero-3.png", + ], + "per_node={per_node}" + ); + let unique = paths.iter().collect::>(); + assert_eq!(unique.len(), paths.len(), "{paths:?}"); + // The code refers to exactly the paths the manifest reports. + let tsx = generate_component( + &snapshot, + "root", + &CodegenOptions { + asset_names_per_node: per_node, + ..Default::default() + }, + ) + .unwrap() + .tsx; + for path in &paths[1..3] { + assert!(tsx.contains(path.as_str()), "{path}: {tsx}"); + } + } +} diff --git a/crates/devup-mcp-devup-ui/tests/semantic_html.rs b/crates/devup-mcp-devup-ui/tests/semantic_html.rs index 3491b614..5dc6b82d 100644 --- a/crates/devup-mcp-devup-ui/tests/semantic_html.rs +++ b/crates/devup-mcp-devup-ui/tests/semantic_html.rs @@ -157,3 +157,61 @@ fn responsive_inlining_retains_concrete_text_and_style_overrides() { } assert!(expanded.referenced_components().is_empty()); } + +#[test] +fn semantic_headings_reset_user_agent_block_margins() { + let nodes = json!({ + "page":{"id":"page","type":"FRAME","fields":{"name":"Screen","childrenIds":["h1","h2","body"],"layoutMode":"VERTICAL"}}, + "h1":{"id":"h1","type":"TEXT","fields":{"name":"Page title","characters":"Research","fontSize":48}}, + "h2":{"id":"h2","type":"TEXT","fields":{"name":"Heading 2","characters":"Results","fontSize":40}}, + "body":{"id":"body","type":"TEXT","fields":{"name":"Body","characters":"Plain copy","fontSize":16}} + }); + let output = generate_component( + &snapshot(nodes, json!(["page"])), + "page", + &CodegenOptions::default(), + ) + .unwrap(); + for tag in ["h1", "h2"] { + // Formatted JSX puts props on separate lines; inspect the element's + // whole opening-tag prop block (from `<` up to the closing `>`). + let marker = format!("as=\"{tag}\""); + let at = output + .tsx + .find(&marker) + .unwrap_or_else(|| panic!("{}", output.tsx)); + let start = output.tsx[..at].rfind('<').unwrap(); + let end = at + output.tsx[at..].find('>').unwrap(); + let element = &output.tsx[start..end]; + assert!(element.contains("my=\"0px\""), "{tag}: {}", output.tsx); + } + assert_eq!( + output.tsx.matches("my=\"0px\"").count(), + 2, + "{}", + output.tsx + ); + // The reset is not a silent addition: each emitted margin carries a + // verified source derivation from the semantic stage. + let derived = output + .diagnostics + .iter() + .filter(|d| { + d.code == "DEVUP_CODEGEN_PROPERTY_EVIDENCE" + && d.details.as_ref().is_some_and(|details| { + details["generatedProperty"] == "my=\"0px\"" + && details["stage"] == "semantic" + && details["propertyMappingVerified"] == true + }) + }) + .count(); + assert_eq!(derived, 2, "{:?}", output.diagnostics); + assert!( + !output + .diagnostics + .iter() + .any(|d| d.code == "DEVUP_CODEGEN_PROPERTY_UNMAPPED"), + "{:?}", + output.diagnostics + ); +} diff --git a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_devup_ui.snap b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_devup_ui.snap index bc0ca8bd..0c93ee87 100644 --- a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_devup_ui.snap +++ b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_devup_ui.snap @@ -29,6 +29,7 @@ export function AStoryFProofread() { lineHeight="39px" overflowWrap="break-word" textAlign="center" + textWrap="pretty" typography="h3" w="100%" whiteSpace="pre-wrap" @@ -136,6 +137,7 @@ export function AStoryFProofread() { lineHeight="24px" overflowWrap="break-word" textAlign="center" + textWrap="pretty" typography="caption" w="100%" whiteSpace="pre-wrap" @@ -154,6 +156,7 @@ export function AStoryFProofread() { letterSpacing="-0.01em" lineHeight="22px" overflowWrap="break-word" + textWrap="pretty" typography="captionSm" wordBreak="keep-all" > @@ -186,6 +189,7 @@ export function AStoryFProofread() { letterSpacing="-0.01em" lineHeight="26px" overflowWrap="break-word" + textWrap="pretty" typography="bodyXs" whiteSpace="pre-wrap" wordBreak="keep-all" @@ -262,6 +266,7 @@ export function AStoryFProofread() { letterSpacing="-0.02em" lineHeight="31px" overflowWrap="break-word" + textWrap="pretty" typography="h5" wordBreak="keep-all" > @@ -316,6 +321,7 @@ export function AStoryFProofread() { letterSpacing="-0.01em" lineHeight="32px" overflowWrap="break-word" + textWrap="pretty" typography="body" w="100%" wordBreak="keep-all" @@ -371,6 +377,7 @@ export function AStoryFProofread() { letterSpacing="-0.01em" lineHeight="32px" overflowWrap="break-word" + textWrap="pretty" typography="body" w="100%" wordBreak="keep-all" @@ -434,6 +441,7 @@ export function AStoryFProofread() { letterSpacing="-0.01em" lineHeight="32px" overflowWrap="break-word" + textWrap="pretty" typography="body" w="100%" wordBreak="keep-all" @@ -489,6 +497,7 @@ export function AStoryFProofread() { letterSpacing="-0.01em" lineHeight="32px" overflowWrap="break-word" + textWrap="pretty" typography="body" w="100%" wordBreak="keep-all" diff --git a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_devup_ui_embedded.snap b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_devup_ui_embedded.snap index e0637504..27954651 100644 --- a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_devup_ui_embedded.snap +++ b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_devup_ui_embedded.snap @@ -23,6 +23,7 @@ export function AStoryFProofread() { lineHeight="39px" overflowWrap="break-word" textAlign="center" + textWrap="pretty" typography="h3" w="100%" whiteSpace="pre-wrap" @@ -130,6 +131,7 @@ export function AStoryFProofread() { lineHeight="24px" overflowWrap="break-word" textAlign="center" + textWrap="pretty" typography="caption" w="100%" whiteSpace="pre-wrap" @@ -148,6 +150,7 @@ export function AStoryFProofread() { letterSpacing="-0.01em" lineHeight="22px" overflowWrap="break-word" + textWrap="pretty" typography="captionSm" wordBreak="keep-all" > @@ -180,6 +183,7 @@ export function AStoryFProofread() { letterSpacing="-0.01em" lineHeight="26px" overflowWrap="break-word" + textWrap="pretty" typography="bodyXs" whiteSpace="pre-wrap" wordBreak="keep-all" @@ -256,6 +260,7 @@ export function AStoryFProofread() { letterSpacing="-0.02em" lineHeight="31px" overflowWrap="break-word" + textWrap="pretty" typography="h5" wordBreak="keep-all" > @@ -310,6 +315,7 @@ export function AStoryFProofread() { letterSpacing="-0.01em" lineHeight="32px" overflowWrap="break-word" + textWrap="pretty" typography="body" w="100%" wordBreak="keep-all" @@ -365,6 +371,7 @@ export function AStoryFProofread() { letterSpacing="-0.01em" lineHeight="32px" overflowWrap="break-word" + textWrap="pretty" typography="body" w="100%" wordBreak="keep-all" @@ -428,6 +435,7 @@ export function AStoryFProofread() { letterSpacing="-0.01em" lineHeight="32px" overflowWrap="break-word" + textWrap="pretty" typography="body" w="100%" wordBreak="keep-all" @@ -483,6 +491,7 @@ export function AStoryFProofread() { letterSpacing="-0.01em" lineHeight="32px" overflowWrap="break-word" + textWrap="pretty" typography="body" w="100%" wordBreak="keep-all" diff --git a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_diagnostics.snap b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_diagnostics.snap index 1235e6d9..5b3f006a 100644 --- a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_diagnostics.snap +++ b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_diagnostics.snap @@ -53,7 +53,7 @@ expression: diagnostics "appliedValue": { "state": "emitted", "generatedNodeId": "3879:35547", - "generatedSource": " \n 작은 시장, 큰 사랑\n ", + "generatedSource": " \n 작은 시장, 큰 사랑\n ", "sourceTruncated": false, "propertyMappingVerified": false }, @@ -68,7 +68,7 @@ expression: diagnostics "parentLayoutMode": "HORIZONTAL", "childSizing": "FILL", "generatedParent": "
@@ -194,6 +197,7 @@ export function Wquw151Frame387935518() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="bodyXs" whiteSpace="pre-wrap" wordBreak="keep-all" @@ -272,6 +276,7 @@ export function Wquw151Frame387935518() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="h5" wordBreak="keep-all" > @@ -328,6 +333,7 @@ export function Wquw151Frame387935518() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="body" w="100%" wordBreak="keep-all" @@ -385,6 +391,7 @@ export function Wquw151Frame387935518() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="body" w="100%" wordBreak="keep-all" @@ -450,6 +457,7 @@ export function Wquw151Frame387935518() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="body" w="100%" wordBreak="keep-all" @@ -507,6 +515,7 @@ export function Wquw151Frame387935518() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="body" w="100%" wordBreak="keep-all" diff --git a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35569.snap b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35569.snap index 4b5d22a1..a9b7d516 100644 --- a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35569.snap +++ b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35569.snap @@ -31,6 +31,7 @@ export function Wquw151Frame387935569() { overflowWrap="break-word" textAlign="center" textOverflow="ellipsis" + textWrap="pretty" typography="h3" w="100%" whiteSpace="pre-wrap" @@ -84,6 +85,7 @@ export function Wquw151Frame387935569() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="caption" wordBreak="keep-all" > @@ -210,6 +212,7 @@ export function Wquw151Frame387935569() { overflowWrap="break-word" textAlign="center" textOverflow="ellipsis" + textWrap="pretty" typography="caption" w="100%" whiteSpace="pre-wrap" @@ -230,6 +233,7 @@ export function Wquw151Frame387935569() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="captionSm" wordBreak="keep-all" > @@ -264,6 +268,7 @@ export function Wquw151Frame387935569() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="bodyXs" whiteSpace="pre-wrap" wordBreak="keep-all" @@ -342,6 +347,7 @@ export function Wquw151Frame387935569() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="h5" wordBreak="keep-all" > @@ -398,6 +404,7 @@ export function Wquw151Frame387935569() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="body" w="100%" wordBreak="keep-all" @@ -455,6 +462,7 @@ export function Wquw151Frame387935569() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="body" w="100%" wordBreak="keep-all" @@ -520,6 +528,7 @@ export function Wquw151Frame387935569() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="body" w="100%" wordBreak="keep-all" @@ -577,6 +586,7 @@ export function Wquw151Frame387935569() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="body" w="100%" wordBreak="keep-all" diff --git a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35652.snap b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35652.snap index 09d15bf6..44856307 100644 --- a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35652.snap +++ b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35652.snap @@ -31,6 +31,7 @@ export function Wquw151Frame387935652() { overflowWrap="break-word" textAlign="center" textOverflow="ellipsis" + textWrap="pretty" typography="h3" w="100%" whiteSpace="pre-wrap" @@ -84,6 +85,7 @@ export function Wquw151Frame387935652() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="caption" wordBreak="keep-all" > @@ -210,6 +212,7 @@ export function Wquw151Frame387935652() { overflowWrap="break-word" textAlign="center" textOverflow="ellipsis" + textWrap="pretty" typography="caption" w="100%" whiteSpace="pre-wrap" @@ -230,6 +233,7 @@ export function Wquw151Frame387935652() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="captionSm" wordBreak="keep-all" > @@ -264,6 +268,7 @@ export function Wquw151Frame387935652() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="bodyXs" whiteSpace="pre-wrap" wordBreak="keep-all" @@ -342,6 +347,7 @@ export function Wquw151Frame387935652() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="h5" wordBreak="keep-all" > @@ -398,6 +404,7 @@ export function Wquw151Frame387935652() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="body" w="100%" wordBreak="keep-all" @@ -455,6 +462,7 @@ export function Wquw151Frame387935652() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="body" w="100%" wordBreak="keep-all" @@ -520,6 +528,7 @@ export function Wquw151Frame387935652() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="body" w="100%" wordBreak="keep-all" @@ -577,6 +586,7 @@ export function Wquw151Frame387935652() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="body" w="100%" wordBreak="keep-all" diff --git a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35729.snap b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35729.snap index c5266104..4d6c0ba5 100644 --- a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35729.snap +++ b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35729.snap @@ -31,6 +31,7 @@ export function Wquw151Frame387935729() { overflowWrap="break-word" textAlign="center" textOverflow="ellipsis" + textWrap="pretty" typography="h3" w="100%" whiteSpace="pre-wrap" @@ -84,6 +85,7 @@ export function Wquw151Frame387935729() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="caption" wordBreak="keep-all" > @@ -210,6 +212,7 @@ export function Wquw151Frame387935729() { overflowWrap="break-word" textAlign="center" textOverflow="ellipsis" + textWrap="pretty" typography="caption" w="100%" whiteSpace="pre-wrap" @@ -230,6 +233,7 @@ export function Wquw151Frame387935729() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="captionSm" wordBreak="keep-all" > @@ -264,6 +268,7 @@ export function Wquw151Frame387935729() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="bodyXs" whiteSpace="pre-wrap" wordBreak="keep-all" @@ -342,6 +347,7 @@ export function Wquw151Frame387935729() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="h5" wordBreak="keep-all" > @@ -398,6 +404,7 @@ export function Wquw151Frame387935729() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="body" w="100%" wordBreak="keep-all" @@ -455,6 +462,7 @@ export function Wquw151Frame387935729() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="body" w="100%" wordBreak="keep-all" @@ -520,6 +528,7 @@ export function Wquw151Frame387935729() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="body" w="100%" wordBreak="keep-all" @@ -577,6 +586,7 @@ export function Wquw151Frame387935729() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="body" w="100%" wordBreak="keep-all" diff --git a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35887.snap b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35887.snap index 89733251..232d51a8 100644 --- a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35887.snap +++ b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35887.snap @@ -31,6 +31,7 @@ export function Wquw151Frame387935887() { overflowWrap="break-word" textAlign="center" textOverflow="ellipsis" + textWrap="pretty" typography="h3" w="100%" whiteSpace="pre-wrap" @@ -84,6 +85,7 @@ export function Wquw151Frame387935887() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="caption" wordBreak="keep-all" > @@ -210,6 +212,7 @@ export function Wquw151Frame387935887() { overflowWrap="break-word" textAlign="center" textOverflow="ellipsis" + textWrap="pretty" typography="caption" w="100%" whiteSpace="pre-wrap" @@ -230,6 +233,7 @@ export function Wquw151Frame387935887() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="captionSm" wordBreak="keep-all" > @@ -264,6 +268,7 @@ export function Wquw151Frame387935887() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="bodyXs" whiteSpace="pre-wrap" wordBreak="keep-all" @@ -342,6 +347,7 @@ export function Wquw151Frame387935887() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="h5" wordBreak="keep-all" > @@ -398,6 +404,7 @@ export function Wquw151Frame387935887() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="body" w="100%" wordBreak="keep-all" @@ -455,6 +462,7 @@ export function Wquw151Frame387935887() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="body" w="100%" wordBreak="keep-all" @@ -520,6 +528,7 @@ export function Wquw151Frame387935887() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="body" w="100%" wordBreak="keep-all" @@ -577,6 +586,7 @@ export function Wquw151Frame387935887() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="body" w="100%" wordBreak="keep-all" @@ -736,6 +746,7 @@ export function Wquw151Frame387935887() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="caption" wordBreak="keep-all" > @@ -777,6 +788,7 @@ export function Wquw151Frame387935887() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="caption" wordBreak="keep-all" > @@ -948,6 +960,7 @@ export function Wquw151Frame387935887() { overflowWrap="break-word" textAlign="center" textOverflow="ellipsis" + textWrap="pretty" typography="bodySm" w="100%" whiteSpace="pre-wrap" diff --git a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35973.snap b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35973.snap index f2d4f7a1..9c6102f5 100644 --- a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35973.snap +++ b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_35973.snap @@ -31,6 +31,7 @@ export function Wquw151Frame387935973() { overflowWrap="break-word" textAlign="center" textOverflow="ellipsis" + textWrap="pretty" typography="h3" w="100%" whiteSpace="pre-wrap" @@ -84,6 +85,7 @@ export function Wquw151Frame387935973() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="caption" wordBreak="keep-all" > @@ -210,6 +212,7 @@ export function Wquw151Frame387935973() { overflowWrap="break-word" textAlign="center" textOverflow="ellipsis" + textWrap="pretty" typography="caption" w="100%" whiteSpace="pre-wrap" @@ -230,6 +233,7 @@ export function Wquw151Frame387935973() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="captionSm" wordBreak="keep-all" > @@ -264,6 +268,7 @@ export function Wquw151Frame387935973() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="bodyXs" whiteSpace="pre-wrap" wordBreak="keep-all" @@ -342,6 +347,7 @@ export function Wquw151Frame387935973() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="h5" wordBreak="keep-all" > @@ -398,6 +404,7 @@ export function Wquw151Frame387935973() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="body" w="100%" wordBreak="keep-all" @@ -455,6 +462,7 @@ export function Wquw151Frame387935973() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="body" w="100%" wordBreak="keep-all" @@ -520,6 +528,7 @@ export function Wquw151Frame387935973() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="body" w="100%" wordBreak="keep-all" @@ -577,6 +586,7 @@ export function Wquw151Frame387935973() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="body" w="100%" wordBreak="keep-all" @@ -736,6 +746,7 @@ export function Wquw151Frame387935973() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="caption" wordBreak="keep-all" > @@ -777,6 +788,7 @@ export function Wquw151Frame387935973() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="caption" wordBreak="keep-all" > @@ -949,6 +961,7 @@ export function Wquw151Frame387935973() { overflowWrap="break-word" textAlign="center" textOverflow="ellipsis" + textWrap="pretty" typography="bodySm" w="100%" whiteSpace="pre-wrap" diff --git a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_36059.snap b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_36059.snap index 44589f81..a84ba3fe 100644 --- a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_36059.snap +++ b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_36059.snap @@ -77,6 +77,7 @@ export function Wquw151Frame387936059() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="h4" w="100%" wordBreak="keep-all" @@ -124,6 +125,7 @@ export function Wquw151Frame387936059() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="recordText" w="100%" wordBreak="keep-all" @@ -258,6 +260,7 @@ export function Wquw151Frame387936059() { overflowWrap="break-word" textAlign="center" textOverflow="ellipsis" + textWrap="pretty" typography="bodySm" w="100%" wordBreak="keep-all" @@ -281,6 +284,7 @@ export function Wquw151Frame387936059() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="caption" wordBreak="keep-all" > diff --git a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_36108.snap b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_36108.snap index 6bfe1259..d62a5eb4 100644 --- a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_36108.snap +++ b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_36108.snap @@ -71,6 +71,7 @@ export function Wquw151Frame387936108() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="h4" w="100%" wordBreak="keep-all" @@ -118,6 +119,7 @@ export function Wquw151Frame387936108() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="recordText" w="100%" wordBreak="keep-all" diff --git a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_36144.snap b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_36144.snap index f7eeb228..fcc06d8a 100644 --- a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_36144.snap +++ b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151_frames__wquw_151_frame_3879_36144.snap @@ -31,6 +31,7 @@ export function Wquw151Frame387936144() { overflowWrap="break-word" textAlign="center" textOverflow="ellipsis" + textWrap="pretty" typography="h3" w="100%" whiteSpace="pre-wrap" @@ -111,6 +112,7 @@ export function Wquw151Frame387936144() { overflowWrap="break-word" textAlign="center" textOverflow="ellipsis" + textWrap="pretty" typography="caption" w="100%" whiteSpace="pre-wrap" @@ -131,6 +133,7 @@ export function Wquw151Frame387936144() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="captionSm" wordBreak="keep-all" > @@ -165,6 +168,7 @@ export function Wquw151Frame387936144() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="bodyXs" whiteSpace="pre-wrap" wordBreak="keep-all" @@ -243,6 +247,7 @@ export function Wquw151Frame387936144() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="h5" wordBreak="keep-all" > @@ -299,6 +304,7 @@ export function Wquw151Frame387936144() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="body" w="100%" wordBreak="keep-all" @@ -356,6 +362,7 @@ export function Wquw151Frame387936144() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="body" w="100%" wordBreak="keep-all" @@ -421,6 +428,7 @@ export function Wquw151Frame387936144() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="body" w="100%" wordBreak="keep-all" @@ -478,6 +486,7 @@ export function Wquw151Frame387936144() { overflow="hidden" overflowWrap="break-word" textOverflow="ellipsis" + textWrap="pretty" typography="body" w="100%" wordBreak="keep-all" diff --git a/crates/devup-mcp/src/server/asset_jobs.rs b/crates/devup-mcp/src/server/asset_jobs.rs index 433656fe..f951fc4c 100644 --- a/crates/devup-mcp/src/server/asset_jobs.rs +++ b/crates/devup-mcp/src/server/asset_jobs.rs @@ -37,10 +37,54 @@ const COMPACT_ASSET_FIELDS: &[&str] = &[ "requestedOutputPath", "fileState", "fileStateReason", + "fileFacts", + "outputPathRedirected", + "requestedPathFacts", "suggestedFilename", "duplicateOf", ]; +/// What is actually on disk at `path`: existence, byte count and sha256. +fn file_facts(path: &str) -> Value { + match std::fs::read(path) { + Ok(bytes) => json!({ + "path": path, + "exists": true, + "bytes": bytes.len(), + "sha256": Sha256::digest(&bytes).iter().map(|b| format!("{b:02x}")).collect::(), + }), + Err(_) => json!({"path": path, "exists": false, "bytes": null, "sha256": null}), + } +} + +fn same_path(left: &str, right: &str) -> bool { + let normalize = |path: &str| { + let path = path.replace('\\', "/"); + if cfg!(windows) { path.to_lowercase() } else { path } + }; + normalize(left) == normalize(right) +} + +/// Why a requested output path received no file, from the job's own outcome. +fn unwritten_reason(asset: &Value, result: Option<&Result>) -> String { + match result { + Some(Err(error)) => format!( + "The projection-and-write stage failed ({:?}); the output transaction was rolled back and wrote nothing.", + error.code + ), + Some(Ok(value)) if value.get("assetQuarantine").is_some() => "Quarantined: asset naming review is required, so no public file was written. Follow nextAction with descriptive assetRequests.outputPath values.".to_owned(), + _ if asset["status"] != "exported" => format!( + "Not acquired ({}), so there were no bytes to write.", + asset["errorCode"].as_str().unwrap_or("no export") + ), + _ if asset.get("duplicateOf").is_some() => format!( + "Identical bytes to {}; they are delivered once at that asset's path.", + asset["duplicateOf"].as_str().unwrap_or_default() + ), + _ => "Acquired, but the projection mapped no output file to this asset (for example a hidden or excluded node). See outputPathResults and assetSummary.".to_owned(), + } +} + #[derive(Clone, Default)] pub(super) struct AssetJobs(Arc>>>); @@ -366,13 +410,27 @@ impl AssetJob { asset["outputPath"] = json!(path); // A path can be shared by conflicting assets. The export hash, // not the manifest's path alone, proves which bytes were written. - let written = std::fs::read(path).ok().is_some_and(|bytes| { - let hash = Sha256::digest(bytes) - .iter() - .map(|b| format!("{b:02x}")) - .collect::(); - asset["sha256"].as_str() == Some(&hash) - }); + let facts = file_facts(path); + let written = facts["sha256"].is_string() && asset["sha256"] == facts["sha256"]; + let requested = asset["assetId"] + .as_str() + .and_then(|id| self.paths.get(id)); + if let Some(requested) = requested { + // Identical bytes share one canonical file, and a derived + // grayscale reuses its colour original: the file can live + // at a path other than the one requested. + asset["outputPathRedirected"] = json!(!same_path(requested, path)); + } + if !written { + asset["fileStateReason"] = json!(if facts["exists"] == false { + "The projected file does not exist: the write was blocked or rolled back, so this asset's bytes are not on disk." + } else if asset["sha256"].is_string() { + "A file exists at the projected path with different bytes (another asset or an existing file kept by the collision guard); this asset's bytes are not there." + } else { + "This asset has no acquired sha256 to compare with the file on disk, so the write is unproven." + }); + } + asset["fileFacts"] = facts; asset["fileState"] = json!(if written { "written" } else { "not-written" }); } } @@ -489,6 +547,12 @@ impl AssetJob { } else { "pending" }); + if asset["fileState"] == "not-written" { + asset["fileStateReason"] = json!(unwritten_reason(&asset, state.result.as_ref())); + if let Some(path) = path { + asset["requestedPathFacts"] = file_facts(path); + } + } // Bytes are held until the whole job is projected, so an // exported asset reads `pending` until then; say so. if asset["fileState"] == "pending" { diff --git a/crates/devup-mcp/src/server/asset_jobs_tests.rs b/crates/devup-mcp/src/server/asset_jobs_tests.rs index 4275857b..f06ef84f 100644 --- a/crates/devup-mcp/src/server/asset_jobs_tests.rs +++ b/crates/devup-mcp/src/server/asset_jobs_tests.rs @@ -451,3 +451,120 @@ fn r39_node_request_adopts_written_source_fill_evidence() { assert_eq!(response["exportJob"]["assetProgress"]["total"], 1); std::fs::remove_file(path).unwrap(); } + +fn finished_job(paths: BTreeMap, assets: Vec) -> AssetJob { + AssetJob { + id: "facts".into(), + key: "facts".into(), + budget: json!({}), + started: Instant::now(), + resume: Notify::new(), + cancellation: Notify::new(), + cancelled: std::sync::atomic::AtomicBool::new(false), + delivery: Default::default(), + paths, + state: Mutex::new(JobState { + state: "running", + stage: "projection-and-write", + calls: vec![], + completed_calls: 0, + error: None, + result: None, + finished: None, + assets, + }), + } +} + +fn sha(bytes: &[u8]) -> String { + Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect() +} + +/// Feedback 011: every output path reports what is on disk, and an unwritten +/// row says why instead of only `not-written`. +#[test] +fn feedback_output_paths_report_disk_facts_and_unwritten_reasons() { + let dir = std::env::temp_dir().join(format!("devup-f011-{}", rand::random::())); + std::fs::create_dir_all(&dir).unwrap(); + let written = dir.join("hero.png").to_string_lossy().to_string(); + std::fs::write(&written, b"hero bytes").unwrap(); + let collided = dir.join("shared.png").to_string_lossy().to_string(); + std::fs::write(&collided, b"someone else").unwrap(); + let missing = dir.join("missing.png").to_string_lossy().to_string(); + let quarantined = dir.join("anonymous.png").to_string_lossy().to_string(); + let job = finished_job( + BTreeMap::from([ + ("hero".into(), written.clone()), + ("collided".into(), collided.clone()), + ("missing".into(), missing.clone()), + ]), + vec![ + json!({"assetId":"hero","status":"exported","sha256":sha(b"hero bytes")}), + json!({"assetId":"collided","status":"exported","sha256":sha(b"mine")}), + json!({"assetId":"missing","status":"exported","sha256":sha(b"lost")}), + ], + ); + job.finish(Ok(json!({"outputPaths":{ + "asset:hero":written,"asset:collided":collided,"asset:missing":missing}}))); + let response = job.response().unwrap(); + let rows = response["exportJob"]["assets"].as_array().unwrap(); + assert_eq!(rows[0]["fileState"], "written"); + assert_eq!(rows[0]["fileFacts"]["exists"], true); + assert_eq!(rows[0]["fileFacts"]["bytes"], 10); + assert_eq!(rows[0]["fileFacts"]["sha256"], sha(b"hero bytes")); + assert_eq!(rows[0]["outputPathRedirected"], false); + assert!(rows[0].get("fileStateReason").is_none()); + assert_eq!(rows[1]["fileState"], "not-written"); + assert_eq!(rows[1]["fileFacts"]["sha256"], sha(b"someone else")); + assert!(rows[1]["fileStateReason"].as_str().unwrap().contains("different bytes")); + assert_eq!(rows[2]["fileState"], "not-written"); + assert_eq!(rows[2]["fileFacts"]["exists"], false); + assert!(rows[2]["fileStateReason"].as_str().unwrap().contains("does not exist")); + + // A quarantined projection publishes nothing; the reason says so. + let job = finished_job( + BTreeMap::from([("anonymous".into(), quarantined.clone())]), + vec![json!({"assetId":"anonymous","status":"exported","sha256":sha(b"x")})], + ); + job.finish(Ok(json!({"status":"review-required","assetQuarantine":{"state":"review-required"},"outputPaths":{}}))); + let row = &job.response().unwrap()["exportJob"]["assets"][0]; + assert_eq!(row["fileState"], "not-written"); + assert!(row["fileStateReason"].as_str().unwrap().starts_with("Quarantined")); + assert_eq!(row["requestedPathFacts"]["exists"], false); + + // A failed projection rolled back its transaction. + let job = finished_job( + BTreeMap::from([("rolled".into(), missing.clone())]), + vec![json!({"assetId":"rolled","status":"exported","sha256":sha(b"x")})], + ); + job.finish(Err(DevupError::new(ErrorCode::DevupInvalidInput, "refused", false))); + let failed = job.response().unwrap_err(); + let row = &failed.details["exportJob"]["assets"][0]; + assert_eq!(row["fileState"], "not-written"); + assert!(row["fileStateReason"].as_str().unwrap().contains("rolled back")); + std::fs::remove_dir_all(dir).unwrap(); +} + +/// A derived grayscale reuses its colour original's file: that is a written, +/// redirected path, not a false not-written. +#[test] +fn feedback_grayscale_reuse_is_written_at_a_redirected_path() { + let dir = std::env::temp_dir().join(format!("devup-f011-gray-{}", rand::random::())); + std::fs::create_dir_all(&dir).unwrap(); + let color = dir.join("logo.png").to_string_lossy().to_string(); + std::fs::write(&color, b"colour original").unwrap(); + let requested = dir.join("logo-muted.png").to_string_lossy().to_string(); + let job = finished_job( + BTreeMap::from([("gray".into(), requested)]), + vec![json!({"assetId":"gray","status":"exported"})], + ); + // The projected manifest carries the delivered (canonical) hash. + job.finish(Ok(json!({ + "assetManifest":{"assets":[{"assetId":"gray","status":"exported","sha256":sha(b"colour original")}]}, + "outputPaths":{"asset:gray":color}}))); + let row = &job.response().unwrap()["exportJob"]["assets"][0]; + assert_eq!(row["fileState"], "written", "{row:#}"); + assert_eq!(row["outputPathRedirected"], true); + assert!(row.get("fileStateReason").is_none()); + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/crates/devup-mcp/src/server/handlers/devup_figma_export.rs b/crates/devup-mcp/src/server/handlers/devup_figma_export.rs index 8f4d846c..7041240f 100644 --- a/crates/devup-mcp/src/server/handlers/devup_figma_export.rs +++ b/crates/devup-mcp/src/server/handlers/devup_figma_export.rs @@ -213,6 +213,11 @@ impl DevupServer { input.project_root.as_deref(), ) .map_err(to_mcp_error)?; + projection::theme_export::validate_output_path( + &input.devup_json_mode, + input.output_paths.get("devupJson").map(String::as_str), + ) + .map_err(to_mcp_error)?; if asset_public_root.is_some() && !web_ready_images && input.page_scaffold.is_none() diff --git a/crates/devup-mcp/src/server/projection/canonical_assets.rs b/crates/devup-mcp/src/server/projection/canonical_assets.rs index 18d9d504..4fa03bf3 100644 --- a/crates/devup-mcp/src/server/projection/canonical_assets.rs +++ b/crates/devup-mcp/src/server/projection/canonical_assets.rs @@ -41,6 +41,55 @@ fn error(message: impl Into) -> DevupError { DevupError::new(ErrorCode::DevupInvalidInput, message, false) } +/// The first asset that claimed a code placeholder in this projection. +struct PlaceholderOwner { + asset_id: String, + node_id: String, + image_hash: Option, + path: String, +} + +/// One code placeholder resolved to two different canonical pictures. +/// +/// Per-node naming only separates placeholders that come from different +/// image hashes: nodes sharing one Figma imageHash deliberately share a name, +/// with or without `assetNamesPerNode`, so recommending that flag there sends +/// the caller into a retry that reproduces the same refusal. The recovery is +/// chosen from the conflicting assets themselves. +fn ambiguous_placeholder( + placeholder: &str, + first: Option<&PlaceholderOwner>, + second: &PlaceholderOwner, +) -> DevupError { + let shared_hash = first.is_some_and(|first| { + first.image_hash.is_some() && first.image_hash == second.image_hash + }); + let conflicts = first + .into_iter() + .chain([second]) + .map(|owner| json!({"assetId":owner.asset_id,"nodeId":owner.node_id,"imageHash":owner.image_hash,"canonicalPath":owner.path})) + .collect::>(); + let (message, next_action, recovery) = if shared_hash { + ( + "One code placeholder identifies different pictures rendered from one shared Figma imageHash (different crop, size or effects). assetNamesPerNode cannot separate them because naming follows the shared image; no files were written.", + "Export the conflicting nodes in separate calls, each with its own assetRequests.outputPath, or bind each element's src from its sourceMap nodeId/assetId to the listed canonicalPath. Do not reuse the first matching placeholder for both.", + Value::Null, + ) + } else { + ( + "One code placeholder identifies different pictures. Re-export with assetNamesPerNode:true; no files were written.", + "Re-export with assetNamesPerNode:true so each node receives its own placeholder.", + json!({"assetNamesPerNode":true}), + ) + }; + DevupError::with_details( + ErrorCode::DevupInvalidInput, + message, + false, + json!({"reason":"ambiguous-code-placeholder","placeholder":placeholder,"sharedImageHash":shared_hash,"conflicts":conflicts,"recoveryArguments":recovery,"nextAction":next_action}), + ) +} + pub(super) fn meaningful_name(path: &str) -> bool { let decoded = super::super::mask_guard::percent_decode(path); let path = decoded.as_str(); @@ -353,6 +402,7 @@ pub(super) fn reconcile( } captures.sort_by_key(|(_, _, identity, _, _)| identity.filter.is_some()); let mut replacements = BTreeMap::new(); + let mut owners = BTreeMap::::new(); let mut naming_review = Vec::new(); let mut filters = BTreeMap::new(); let mut bytes_by_path = BTreeMap::>::new(); @@ -489,13 +539,18 @@ pub(super) fn reconcile( (path, acquired) }; let previous = asset.sha256.clone(); + let owner = PlaceholderOwner { + asset_id: asset.asset_id.clone(), + node_id: asset.node_id.clone(), + image_hash: asset.image_hash.clone(), + path: path.clone(), + }; if let Some(prior) = replacements.insert(old.clone(), path.clone()) && prior != path { - return Err(error( - "One code placeholder identifies different pictures. Re-export with assetNamesPerNode:true; no files were written.", - )); + return Err(ambiguous_placeholder(&old, owners.get(&old), &owner)); } + owners.entry(old.clone()).or_insert(owner); if !asset.variants.is_empty() { replacements.insert( asset_policy::variant_path(&old), diff --git a/crates/devup-mcp/src/server/projection/canonical_assets_tests.rs b/crates/devup-mcp/src/server/projection/canonical_assets_tests.rs index 24d33b8b..c0a61052 100644 --- a/crates/devup-mcp/src/server/projection/canonical_assets_tests.rs +++ b/crates/devup-mcp/src/server/projection/canonical_assets_tests.rs @@ -524,3 +524,57 @@ fn derived_filters_only_touch_the_exact_asset_url() { ); assert_eq!(value["tsx"].as_str().unwrap().matches("filter=").count(), 1); } + +fn hashed(id: &str, node: &str, hash: &str, body: &[u8]) -> devup_mcp_figma::AssetManifestEntry { + let mut entry = asset(id, "/images/hero.png", body, "png"); + entry.node_id = node.into(); + entry.image_hash = Some(hash.into()); + entry +} + +/// Feedback 006: one placeholder that resolves to two pictures is refused, and +/// the recovery must be one that can work for those assets. +#[test] +fn ambiguous_placeholder_from_a_shared_image_hash_does_not_recommend_per_node_names() { + let policy = OutputPolicy::from_roots(vec![std::env::temp_dir()]).unwrap(); + let mut shared = manifest(vec![ + hashed("1:1:fills:0", "1:1", "same", b"crop top"), + hashed("1:2:fills:0", "1:2", "same", b"crop bottom"), + ]); + let refused = reconcile(&mut shared, &mut BTreeMap::new(), None, None, &policy, &result("")) + .err() + .expect("a placeholder with two pictures is refused"); + assert!(!refused.message.contains("assetNamesPerNode:true"), "{}", refused.message); + assert_eq!(refused.details["reason"], "ambiguous-code-placeholder"); + assert_eq!(refused.details["placeholder"], "/images/hero.png"); + assert_eq!(refused.details["sharedImageHash"], true); + assert_eq!(refused.details["recoveryArguments"], Value::Null); + let conflicts = refused.details["conflicts"].as_array().unwrap(); + assert_eq!(conflicts.len(), 2); + assert_eq!(conflicts[0]["nodeId"], "1:1"); + assert_eq!(conflicts[1]["nodeId"], "1:2"); + assert_ne!(conflicts[0]["canonicalPath"], conflicts[1]["canonicalPath"]); + assert!(refused.details["nextAction"].as_str().unwrap().contains("sourceMap")); + + let mut distinct = manifest(vec![ + hashed("1:1:fills:0", "1:1", "photo-a", b"first photo"), + hashed("1:2:fills:0", "1:2", "photo-b", b"second photo"), + ]); + let refused = reconcile(&mut distinct, &mut BTreeMap::new(), None, None, &policy, &result("")) + .err() + .expect("a placeholder with two pictures is refused"); + assert!(refused.message.contains("assetNamesPerNode:true"), "{}", refused.message); + assert_eq!(refused.details["sharedImageHash"], false); + assert_eq!(refused.details["recoveryArguments"], json!({"assetNamesPerNode":true})); +} + +#[test] +fn identical_pictures_behind_one_placeholder_are_not_refused() { + let policy = OutputPolicy::from_roots(vec![std::env::temp_dir()]).unwrap(); + let mut same = manifest(vec![ + hashed("1:1:fills:0", "1:1", "same", b"one picture"), + hashed("1:2:fills:0", "1:2", "same", b"one picture"), + ]); + reconcile(&mut same, &mut BTreeMap::new(), None, None, &policy, &result("")).unwrap(); + assert_eq!(same.assets[0].path, same.assets[1].path); +} diff --git a/crates/devup-mcp/src/server/projection/feedback_hidden_master_tests.rs b/crates/devup-mcp/src/server/projection/feedback_hidden_master_tests.rs new file mode 100644 index 00000000..813d903b --- /dev/null +++ b/crates/devup-mcp/src/server/projection/feedback_hidden_master_tests.rs @@ -0,0 +1,26 @@ +/// Feedback 008: a master hidden at every variant reaches the export response +/// as a warning naming the visible page instance, not as silent display=none. +#[tokio::test] +async fn feedback_hidden_master_export_warns_with_the_visible_instance() { + let mut data = payload(); + data.target.node_id = Some("1:0".into()); + data.snapshot = serde_json::from_value(json!({ + "fileKey": "W1Fixture", "version": null, "roots": ["1:0"], "diagnostics": [], + "nodes": { + "1:0": {"id":"1:0","type":"COMPONENT_SET","fields":{"name":"Card","visible":false,"width":390,"height":200,"childrenIds":["1:1","1:2"]}}, + "1:1": {"id":"1:1","type":"COMPONENT","fields":{"name":"Breakpoint=Desktop","parentId":"1:0","width":390,"height":100,"variantProperties":{"Breakpoint":"Desktop"},"childrenIds":[]}}, + "1:2": {"id":"1:2","type":"COMPONENT","fields":{"name":"Breakpoint=Mobile","parentId":"1:0","width":390,"height":100,"variantProperties":{"Breakpoint":"Mobile"},"childrenIds":[]}}, + "9:1": {"id":"9:1","type":"INSTANCE","fields":{"name":"Card","mainComponentId":"1:1","childrenIds":[]}} + } + })) + .unwrap(); + let result = project(data, operation(&["tsx", "componentTsx"])).await.unwrap(); + assert_eq!(result["componentSetVariants"]["allVariantsHidden"], true, "{result:#}"); + let warning = result["warnings"] + .as_array() + .unwrap() + .iter() + .find(|warning| warning["code"] == "DEVUP_HIDDEN_MASTER_EXPORT") + .unwrap_or_else(|| panic!("{result:#}")); + assert_eq!(warning["details"]["visibleInstanceIdsInSnapshot"], json!(["9:1"])); +} diff --git a/crates/devup-mcp/src/server/projection/feedback_theme_tests.rs b/crates/devup-mcp/src/server/projection/feedback_theme_tests.rs index 3dcf9ddc..5b78c5b5 100644 --- a/crates/devup-mcp/src/server/projection/feedback_theme_tests.rs +++ b/crates/devup-mcp/src/server/projection/feedback_theme_tests.rs @@ -203,10 +203,45 @@ async fn feedback_theme_missing_only_preserves_project_and_reports_conflict() { assert_eq!(result["themeDiff"]["existingTypographyConflicts"][0]["token"], "body"); assert_eq!(result["themeDiff"]["visualFidelity"], "unverified"); assert_eq!(result["quality"]["theme"], "conflicted"); + // Feedback 007: the additions-only fragment says so and how to merge it. + assert_eq!(result["devupJsonMode"], "missing-only"); + assert_eq!(result["devupJsonScope"], "additions-only"); + assert_eq!(result["devupJsonMerge"]["strategy"], "deep-merge-add-missing-keys"); + assert_eq!(result["devupJsonMerge"]["existingValuesPreserved"], true); + assert_eq!(result["devupJsonMerge"]["projectFileWritten"], false); + assert!(result["devupJsonMerge"]["target"].as_str().unwrap().ends_with("/devup.json")); + assert!(result["devupJsonMerge"]["instruction"].as_str().unwrap().contains("never use it as the whole theme")); assert_eq!(std::fs::read_to_string(directory.join("devup.json")).unwrap(), original); std::fs::remove_dir_all(directory).unwrap(); } +#[tokio::test] +async fn feedback_theme_full_and_used_only_exports_state_their_scope() { + for (mode, scope) in [("full", "complete-theme"), ("used-only", "used-tokens")] { + let mut op = operation(&["tsx", "devupJson"]); + if let PendingOperation::Export { theme_options, .. } = &mut op { + theme_options.mode = mode.into(); + } + let result = project(feedback_theme_payload(), op).await.unwrap(); + assert_eq!(result["devupJsonScope"], scope, "{mode}"); + assert!(result.get("devupJsonMerge").is_none(), "{mode}"); + } +} + +#[test] +fn feedback_theme_missing_only_fragment_cannot_be_saved_as_devup_json() { + use theme_export::validate_output_path; + for path in ["/p/devup.json", r"C:\p\Devup.JSON", "devup.json"] { + let refused = validate_output_path("missing-only", Some(path)).unwrap_err(); + assert_eq!(refused.details["devupJsonScope"], "additions-only", "{path}"); + assert!(refused.details["nextAction"].as_str().unwrap().contains("deep-merge")); + } + assert!(validate_output_path("missing-only", Some("/p/devup.additions.json")).is_ok()); + assert!(validate_output_path("missing-only", None).is_ok()); + assert!(validate_output_path("full", Some("/p/devup.json")).is_ok()); + assert!(validate_output_path("used-only", Some("/p/devup.json")).is_ok()); +} + fn feedback_responsive_section() -> CollectedPayload { let mut data = section(3); for (index, (name, width)) in [("Desktop", 1440), ("992~1280", 1280), ("Mobile", 360)].iter().enumerate() { diff --git a/crates/devup-mcp/src/server/projection/project_node_and_theme_outputs.rs b/crates/devup-mcp/src/server/projection/project_node_and_theme_outputs.rs index a963d49f..bca5d77e 100644 --- a/crates/devup-mcp/src/server/projection/project_node_and_theme_outputs.rs +++ b/crates/devup-mcp/src/server/projection/project_node_and_theme_outputs.rs @@ -220,6 +220,14 @@ pub(super) fn project_node_and_theme_outputs( } } + if (keep_tsx || scaffold_requested || outputs.iter().any(|output| output == "componentTsx")) + && !section_tsx_projected + && let Some(node_id) = payload.target.node_id.as_deref() + && let Some(warning) = hidden_master_warning(&payload.snapshot, node_id) + { + warnings.push(warning); + } + if outputs.iter().any(|output| output == "devupJson") { let variables = payload.variables.as_ref().ok_or_else(|| unavailable_output(payload, "devupJson", "No variable/style resources were collected. Re-collect from the URL with devupJson in outputs and the desired scope."))?; @@ -309,18 +317,93 @@ fn component_set_variants(snapshot: &devup_mcp_figma::Snapshot, node_id: &str) - "nodeId": node.id, "name": node.typed_view().name(), "variantProperties": node.typed_view().value("variantProperties"), + "visible": !hidden(set) && !hidden(node), }) }) .collect(); + let all_hidden = !variants.is_empty() && variants.iter().all(|v| v["visible"] == false); Some(json!({ "nodeId": node_id, "variantCount": variants.len(), "defaultVariant": view.value("defaultVariant").and_then(|v| v.get("name")), "variants": variants, + "allVariantsHidden": all_hidden, "note": "This node is a component set. Export a single variant by nodeId to get that variant's own tsx.", })) } +fn hidden(node: &devup_mcp_figma::RawNode) -> bool { + node.typed_view().bool("visible") == Some(false) +} + +/// A library master can be hidden on its own page while every placed instance +/// is visible. Its generated code is then `display="none"` at every variant +/// and breakpoint, which looks like a finished component but renders nothing +/// and carries none of the page's text/visibility overrides. +fn hidden_master_warning(snapshot: &devup_mcp_figma::Snapshot, node_id: &str) -> Option { + let node = snapshot.nodes.get(node_id)?; + let view = node.typed_view(); + let (hidden_ids, total) = match view.node_type() { + "COMPONENT_SET" => { + let variants = view + .child_ids() + .filter_map(|id| snapshot.nodes.get(id)) + .filter(|child| child.typed_view().node_type() == "COMPONENT") + .collect::>(); + let hidden_ids = variants + .iter() + .filter(|variant| hidden(node) || hidden(variant)) + .map(|variant| variant.id.clone()) + .collect::>(); + (hidden_ids, variants.len()) + } + "COMPONENT" => { + let set_hidden = view + .string("parentId") + .and_then(|id| snapshot.nodes.get(id)) + .is_some_and(|parent| { + parent.typed_view().node_type() == "COMPONENT_SET" && hidden(parent) + }); + let ids = if hidden(node) || set_hidden { + vec![node.id.clone()] + } else { + Vec::new() + }; + (ids, 1) + } + _ => return None, + }; + if total == 0 || hidden_ids.len() != total { + return None; + } + let instances = snapshot + .nodes + .values() + .filter(|other| { + let other = other.typed_view(); + other.node_type() == "INSTANCE" + && (other + .string("mainComponentId") + .is_some_and(|id| id == node_id || hidden_ids.iter().any(|h| h == id)) + || other.string("mainComponentSetId") == Some(node_id)) + }) + .map(|instance| instance.id.clone()) + .collect::>(); + Some(json!({ + "code": "DEVUP_HIDDEN_MASTER_EXPORT", + "severity": "warning", + "nodeId": node_id, + "message": "Every variant of this exported master is hidden in Figma, so its generated code renders display=\"none\" at every variant and breakpoint. It is not usable screen code.", + "details": { + "nodeType": view.node_type(), + "hiddenVariantIds": hidden_ids, + "variantCount": total, + "visibleInstanceIdsInSnapshot": instances, + "nextAction": "Export the visible page INSTANCE that uses this component (its own node id, which carries its text/visibility/variant overrides) instead of the hidden master. Do not delete display=\"none\" by hand to make the master appear." + } + })) +} + #[cfg(test)] mod r32_tests { use super::*; @@ -341,5 +424,45 @@ mod r32_tests { assert_eq!(summary["defaultVariant"], "State=Default"); assert_eq!(summary["variants"][1]["nodeId"], "1:2"); assert!(component_set_variants(&snapshot, "1:1").is_none()); + assert_eq!(summary["allVariantsHidden"], false); + assert_eq!(summary["variants"][0]["visible"], true); + assert!(hidden_master_warning(&snapshot, "1:0").is_none()); + assert!(hidden_master_warning(&snapshot, "1:1").is_none()); + } + + fn master(set_visible: bool, variants_visible: [bool; 2]) -> devup_mcp_figma::Snapshot { + serde_json::from_value(json!({ + "fileKey": "k", "version": null, "roots": ["1:0"], "diagnostics": [], + "nodes": { + "1:0": {"id":"1:0","type":"COMPONENT_SET","fields":{"name":"Card","visible":set_visible,"childrenIds":["1:1","1:2"]}}, + "1:1": {"id":"1:1","type":"COMPONENT","fields":{"name":"Breakpoint=Desktop","parentId":"1:0","visible":variants_visible[0],"variantProperties":{"Breakpoint":"Desktop"}}}, + "1:2": {"id":"1:2","type":"COMPONENT","fields":{"name":"Breakpoint=Mobile","parentId":"1:0","visible":variants_visible[1],"variantProperties":{"Breakpoint":"Mobile"}}}, + "9:1": {"id":"9:1","type":"INSTANCE","fields":{"name":"Card","mainComponentId":"1:2"}} + } + })) + .unwrap() + } + + /// Feedback 008: a master hidden at every variant/breakpoint is flagged and + /// the agent is sent to the visible page instance. + #[test] + fn all_hidden_master_variants_warn_and_point_to_the_visible_instance() { + for snapshot in [master(true, [false, false]), master(false, [true, true])] { + let summary = component_set_variants(&snapshot, "1:0").unwrap(); + assert_eq!(summary["allVariantsHidden"], true); + assert_eq!(summary["variants"][1]["visible"], false); + let warning = hidden_master_warning(&snapshot, "1:0").unwrap(); + assert_eq!(warning["code"], "DEVUP_HIDDEN_MASTER_EXPORT"); + assert_eq!(warning["details"]["hiddenVariantIds"], json!(["1:1", "1:2"])); + assert_eq!(warning["details"]["visibleInstanceIdsInSnapshot"], json!(["9:1"])); + assert!(warning["details"]["nextAction"].as_str().unwrap().contains("INSTANCE")); + // A single variant export of a hidden master is flagged as well. + assert!(hidden_master_warning(&snapshot, "1:2").is_some()); + } + let partly = master(true, [false, true]); + assert_eq!(component_set_variants(&partly, "1:0").unwrap()["allVariantsHidden"], false); + assert!(hidden_master_warning(&partly, "1:0").is_none()); + assert!(hidden_master_warning(&partly, "1:2").is_none()); + assert!(hidden_master_warning(&partly, "1:1").is_some()); } } diff --git a/crates/devup-mcp/src/server/projection/theme_export.rs b/crates/devup-mcp/src/server/projection/theme_export.rs index e33f3783..16605221 100644 --- a/crates/devup-mcp/src/server/projection/theme_export.rs +++ b/crates/devup-mcp/src/server/projection/theme_export.rs @@ -44,6 +44,53 @@ pub fn validate( Ok(()) } +/// A missing-only `devupJson` holds only the tokens the project's devup.json +/// lacks. Saved as a `devup.json` it would replace the project theme with that +/// fragment, so such a path is refused before any Figma call. +pub fn validate_output_path(mode: &str, path: Option<&str>) -> Result<(), DevupError> { + let Some(path) = path.filter(|_| mode == "missing-only") else { + return Ok(()); + }; + let name = path.rsplit(['/', '\\']).next().unwrap_or(path); + if name.eq_ignore_ascii_case("devup.json") { + return Err(DevupError::with_details( + ErrorCode::DevupInvalidInput, + "missing-only devupJson contains only additions and cannot be saved as devup.json; it would replace the project theme.", + false, + json!({"devupJsonScope":"additions-only","outputPath":path, + "nextAction":"Save it under another name (for example devup.additions.json) and deep-merge its keys into the existing devup.json, or request devupJsonMode full for a complete theme."}), + )); + } + Ok(()) +} + +/// What the returned `devupJson` holds, so an additions-only fragment cannot be +/// mistaken for a complete theme (`themeCompleteness` describes token +/// resolution, not whether the file is a whole theme). +pub(super) fn scope_markers(mode: &str, project_root: Option<&str>) -> Map { + let mut markers = Map::new(); + let scope = match mode { + "missing-only" => "additions-only", + "used-only" => "used-tokens", + _ => "complete-theme", + }; + markers.insert("devupJsonScope".into(), json!(scope)); + if mode == "missing-only" { + let target = project_root.map_or_else( + || "devup.json".to_owned(), + |root| format!("{}/devup.json", root.trim_end_matches(['/', '\\'])), + ); + markers.insert("devupJsonMerge".into(), json!({ + "target": target, + "strategy": "deep-merge-add-missing-keys", + "existingValuesPreserved": true, + "projectFileWritten": false, + "instruction": "devupJson lists only tokens absent from the existing devup.json. Deep-merge these keys into that file without replacing existing values; never use it as the whole theme. Re-validate tokens against the final assembled JSX after merging." + })); + } + markers +} + fn usage(result: &Map) -> Result<(TokenUsage, usize), DevupError> { fn add( result: &Map, @@ -195,6 +242,7 @@ pub fn prepare( &options.mode }), ); + result.extend(scope_markers(&options.mode, options.project_root.as_deref())); result.insert("skippedStyles".into(), json!(output.skipped_styles)); result.insert( "themeModesDropped".into(), diff --git a/crates/devup-mcp/src/server/projection/w1_regressions.rs b/crates/devup-mcp/src/server/projection/w1_regressions.rs index d4c25f79..39b4050f 100644 --- a/crates/devup-mcp/src/server/projection/w1_regressions.rs +++ b/crates/devup-mcp/src/server/projection/w1_regressions.rs @@ -2,6 +2,7 @@ include!("responsive_paint_regressions.rs"); include!("design_drift_tests.rs"); include!("feedback_theme_tests.rs"); +include!("feedback_hidden_master_tests.rs"); include!("feedback_codegen_tests.rs"); include!("feedback_delivery_tests.rs"); include!("truncated_delivery_tests.rs"); diff --git a/crates/devup-mcp/src/server/skills/devfive-frontend/references/delivery-quality.md b/crates/devup-mcp/src/server/skills/devfive-frontend/references/delivery-quality.md index af97a864..e59bc5d9 100644 --- a/crates/devup-mcp/src/server/skills/devfive-frontend/references/delivery-quality.md +++ b/crates/devup-mcp/src/server/skills/devfive-frontend/references/delivery-quality.md @@ -20,10 +20,14 @@ binary ships this document in its usage resource and skill installation. Do not invent tokens from matching numbers. Collapse equal `pl/pr` to `px`, `pt/pb` to `py`, and matching margin pairs, only when precedence is unchanged. Verify computed styles at each breakpoint after changing token syntax. -- Preserve Figma line breaks and measured wrapping at each captured width. - Do not apply `wordBreak="keep-all"` solely because text is Korean. Explicit - line breaks, font metrics and available width take precedence over a language - heuristic; verify automatic wrapping against the native Figma reference. +- Korean text keeps word boundaries: `wordBreak="keep-all"` plus + `textWrap="pretty"` (rebalances lines so keep-all leaves no large trailing + gap) and `overflowWrap="break-word"` for an overlong token. Never use + `wordBreak="normal"` on Korean copy. Explicit Figma line breaks are still + preserved, `pre`/`nowrap` text gets no `textWrap`, and explicit theme/local + wrapping props win. A visual diff caused only by Figma breaking inside a + Korean word is an intentional, documented policy difference: record it, and + never overwrite the native Figma reference to make the comparison pass. ## Shared server shells and usable controls @@ -179,6 +183,11 @@ does not establish runtime completion. Report verified and unverified scopes. Verify/reset computed margins, appearance and box sizing. Test input bounds, focus and ARIA before and after validation messages. Shared button wrappers must preserve the caller's width and alignment in every usage. +- A validation message added beside a native input must not take its width: + `flex="1"` becomes `flex-basis: 0%` and a 100%-basis message can collapse the + input to 0px. Keep an explicit width contract (input `flexBasis`/`minW`, + message on its own row). E2E must trigger the real error, then assert input + bounds (width > 0), visibility, focus and the message text in that state. - Compare blur/crop/blend against the composed backdrop; a negative z-index or isolation can change multiply into white. Preserve intentional baked effects explicitly and retain pixel evidence for lossy image/absolute fallbacks. @@ -207,6 +216,23 @@ work use a clean fixture, the actual package archive paths and archive contents (name, version and internal dependencies). Never accept stale tarballs, dry-run output or a retry of a pinned old release source as proof of a current release. +Report a CI failure by the stage that actually failed (setup, install, build, +typecheck, unit, E2E) with its original message. Local pass, remote CI pass, a +push/permission rejection and a transient external HTTP error (for example +`setup-bun` returning 504 before any build ran) are separate evidence; one +never stands in for another. When a push is rejected, finish the independent +work, leave an applicable patch (`git apply --check`), state the constraint and +do not bypass it with other credentials. + +A dependency minor bump can enable new accessibility rules: axe-core 4.14 +promoted `label-content-name-mismatch` (WCAG 2.5.3 Label in Name) to wcag21a. +Visible text includes `aria-hidden` split-letter animation copies, so an +`aria-label` that merely repeats the button text fails. Drop redundant +`aria-label`s (the name then comes from content minus `aria-hidden` parts); +make any remaining label contain the visible text verbatim ("1 페이지", not +"1페이지"; "KOR, 언어 선택"); put extra hints such as "(새 창에서 열림)" in +sr-only text. Fix the components; never disable or downgrade the rule. + ## Concurrent projects and split-screen desktop Another running project is not a desktop-wide lock. When authorized projects diff --git a/crates/devup-mcp/src/server/skills/manifest.json b/crates/devup-mcp/src/server/skills/manifest.json index 20e874ac..f359ca22 100644 --- a/crates/devup-mcp/src/server/skills/manifest.json +++ b/crates/devup-mcp/src/server/skills/manifest.json @@ -76,8 +76,8 @@ }, { "path": "references/delivery-quality.md", - "bytes": 15985, - "sha256": "aaadb68606fe7aac6d3cdc52bf27c80f95d5de6c23ed7ed49ad2d0b4057d451e" + "bytes": 17775, + "sha256": "cf25b0dfef30dc2cdf106a353c396bc2fbb15673ada487fefad988b1a1f3a029" } ], "sourceUrl": "https://github.com/dev-five-git/devup-mcp/blob/HEAD/crates/devup-mcp/src/server/skills/devfive-frontend", diff --git a/crates/devup-mcp/src/server/text/devup-figma-export.md b/crates/devup-mcp/src/server/text/devup-figma-export.md index a3b7fbe2..8898c612 100644 --- a/crates/devup-mcp/src/server/text/devup-figma-export.md +++ b/crates/devup-mcp/src/server/text/devup-figma-export.md @@ -1,4 +1,4 @@ -Export a small Figma selection. outputs:[outline] returns a bounded captured tree with logical layout metrics and responseOffset/responseLimit pagination. responseFields or path use a validated field/index/exact-name-or-id grammar (e.g. nodes[name="Cards"].layout), retaining node identity and linking full omitted evidence as resources. For only explicit node dimensions, prefer devup_figma_measure, which bypasses snapshot collection and code generation; the Figma-to-code entry point. url is optional while exactly one Devup Bridge plugin is attached (devup_figma_auth status shows what is attached): omitted, or given as figma-bridge://current, the export reads the file that plugin has open and the node selected in Figma; frameIds without url name frames in it - one id is that frame, several are screens of the selected Section. Never invent a Figma file key to fill url. Asset requests: recommend 1–3 per call, maximum 6; split larger assetRequests before calling. All fresh exports return exportJob (assetJob compatibility alias) within a one-second initial wait when collection is still running, with per-call frame/root IDs, pagination and elapsed time. Slow asset calls also retain per-asset progress. Poll with jobId; use jobAction=resume when paused. Polls return compact asset rows (status, bytes, sha256, fileState); responseFields on a poll selects from its reply, e.g. exportJob.assets for full rows; assetJob only references exportJob. Generated code uses maskImage only for single-colour icons; multi-colour icons stay . Never convert an icon to a mask or recolour it by hand. Jobs retain accepted reads/bytes across client timeouts for 30 minutes in this server process, not across restart. Identical arguments recover a lost job reply. Completed results are retained for 5 minutes. Recommend 1–3 frames per call; allow at most 6 frames and 12 frame-times-output units. Budget roughly 5–20 seconds per frame-output unit (15–60 seconds per frame for three outputs) as a planning heuristic, not a guarantee: paging, complexity and throttling can exceed it and clients commonly time out at 300 seconds. Oversized selections are refused before screen collection; split frameIds into one-frame calls when isolating latency, or poll jobId. Partial per-frame projection failures retain successful frame outputs. projectionIssues always explains reported approximations, unclassified layout loss and missing generated-property provenance, even without includeDiagnostics. mappingComplete=false identifies mapping gaps; mapping-incomplete is not value loss and cannot be exact. projectionEvidence includes source fields and calculations for generated attributes. outputPathResults lists supported keys and diagnostics; frame file keys are frame::, while outputPaths reports actual committed writes. Resource responses offer nextAction.tool/arguments for same-artifact body comparison and sizeEstimate with explicit unmeasured wire overhead; coupled asset/path arguments remain together. quality.assets grades binary collection; assetSummary.description explains collection state. Merge saved batch responses offline with devup-mcp --merge-asset-batches batch1.json batch2.json for cumulative collection, unrequested, failed and conflict counts. SECTION links use two stages: receive selection_required, then run nextAction.example to export a selected screen. Asked to build pages or a site from Figma, follow the devup-implementation-plan workflow (/devup) even if not asked: it plans the URLs, updates dependencies to the latest, and adds interaction states, transitions and metadata; App Router apps may run on vinext with no next dependency. Building pages: with projectRoot, a page write (pageScaffold write, outputPaths under app/, or several frames with outputPaths) needs a confirmed devup_screen_plan and otherwise answers plan_required; pageScaffold then takes its route from the plan. Ask the plan's questions up front only; after confirm never ask the user, record assumptions instead. Excluded and design-fix frames are refused (skill devup-implementation-plan). The `tsx` this returns is devup-ui code, not plain React: its components are compile-time placeholders, `$token` names an entry in the project's devup.json, and a style prop takes a responsive array. Call devup_skills before you write or edit it - it reports which of those conventions this workspace is missing and installs them where your runtime loads skills from. An agent that skips this does not know it is guessing, and this server cannot see the guesses; the gap is repeated as `skillGap` on the response that carries the code. This output is the design, already read. Do not rewrite it from a screenshot or from `get_design_context` - pictures and visual reasoning verify, they do not author. Do not read the node tree and write devup-ui by hand, and do not infer layout from coordinates. Never guess a colour, spacing, radius or typography value: if a value did not come back, say so and stop, because an invented one is indistinguishable from a real one in the code and is the single failure this server exists to prevent. A failed call is a fact to report, not something to route around. Ask only for what you will read: `tsx` is the deliverable, and the response always carries `status`, `quality`, `cache.artifactId`, `collection` and `source` beside it. `outputs` defaults to `["tsx"]`. Add `devupJson` only when the project has no `devup.json` yet, or when you are introducing tokens it does not define - if it already has one, that file is what the code must match, and `devup_project_context` is what reads it. If you already know the node ids you want - a brief named them, or an earlier call did - pass them straight to `frameIds` and skip `devup_figma_explore`; exploring to rediscover ids you are already holding spends a Figma call for nothing. Explore is for when a Section link is all you have. Each output adds its own keys and nothing else - tsx adds `tsx`; componentTsx adds `componentTsx`; responsiveTsx adds `responsiveTsx`, `responsiveSlots` and, where a width asked for something one tree cannot say, `responsiveUnrepresented`; devupJson adds `devupJson`, `themeCounts`, `themeCompleteness`, `conflicts` and `unresolvedVariables`; sourceMap and assetManifest each add the key they name; referencePng adds `referencePng` and, when the Devup Bridge plugin's picture may hold layers get_screenshot leaves out, `referencePngNote` naming them. `rawSnapshot` and `rawPayload` are the collected design in raw form and need `debug: true`. Use them for one question only - a screen looks wrong and you must decide whether the generator is at fault or the design says so - never to implement, since the tsx already carries what they carry. `fidelity` and `completenessReport` appear only when the result is not exact or complete, or when includeDiagnostics is set. tsx expands every instance into primitives while componentTsx keeps them as references, so requesting both gives the same screen twice and the difference between them is each component's body. responsiveTsx merges every width the capture carries into one module whose differing values are devup-ui responsive arrays, and is produced whenever there is more than one width. `assetManifest` lists the assets and their ids and writes nothing. To get files, call again with `assetRequests`, giving each entry an `outputPath` under an allowed write root - and make that call with the original `url` rather than `artifactId`, because an acquisition made without asset capture cannot serve asset requests. By default manifest `path` and TSX references remain placeholders. Opt in with `assetPublicRoot`, an existing absolute local directory served at URL /, together with assetRequests.outputPath in this call (saved output mappings are not part of artifacts): files below that root map to percent-encoded relative URLs in manifest and all TSX outputs. Identical exported bytes share one canonical path/outputPath; inspect the returned paths. assetNamesPerNode remains true by default; matching names alone never trigger deduplication. Reuse a previous acquisition with `artifactId` from `cache` to project further outputs without calling Figma again. Omitted frameIds/allScreens reuse the stored Section selection; explicit selection overrides it. Debug outputs can use resource delivery in auto mode: read the linked resources or follow nextAction to reproject inline. sourceMap is a node/field/generatedProperty/resolution map without offsets; source.generatedOutput identifies the clean output; node identities and instanceOverrides remain structured sidecars. Even when reusing artifactId, sourceMap requires tsx, componentTsx or devupJson in outputs in the same call. Example: {"artifactId":"","outputs":["tsx","rawSnapshot","sourceMap"],"debug":true} +Export a small Figma selection. outputs:[outline] returns a bounded captured tree with logical layout metrics and responseOffset/responseLimit pagination. responseFields or path use a validated field/index/exact-name-or-id grammar (e.g. nodes[name="Cards"].layout), retaining node identity and linking full omitted evidence as resources. For only explicit node dimensions, prefer devup_figma_measure, which bypasses snapshot collection and code generation; the Figma-to-code entry point. url is optional while exactly one Devup Bridge plugin is attached (devup_figma_auth status shows what is attached): omitted, or given as figma-bridge://current, the export reads the file that plugin has open and the node selected in Figma; frameIds without url name frames in it - one id is that frame, several are screens of the selected Section. Never invent a Figma file key to fill url. Asset requests: recommend 1–3 per call, maximum 6; split larger assetRequests before calling. All fresh exports return exportJob (assetJob compatibility alias) within a one-second initial wait when collection is still running, with per-call frame/root IDs, pagination and elapsed time. Slow asset calls also retain per-asset progress. Poll with jobId; use jobAction=resume when paused. Polls return compact asset rows (status, bytes, sha256, fileState, on-disk fileFacts, fileStateReason when not written); responseFields on a poll selects from its reply, e.g. exportJob.assets for full rows; assetJob only references exportJob. Generated code uses maskImage only for single-colour icons; multi-colour icons stay . Never convert an icon to a mask or recolour it by hand. Jobs retain accepted reads/bytes across client timeouts for 30 minutes in this server process, not across restart. Identical arguments recover a lost job reply. Completed results are retained for 5 minutes. Recommend 1–3 frames per call; allow at most 6 frames and 12 frame-times-output units. Budget roughly 5–20 seconds per frame-output unit (15–60 seconds per frame for three outputs) as a planning heuristic, not a guarantee: paging, complexity and throttling can exceed it and clients commonly time out at 300 seconds. Oversized selections are refused before screen collection; split frameIds into one-frame calls when isolating latency, or poll jobId. Partial per-frame projection failures retain successful frame outputs. projectionIssues always explains reported approximations, unclassified layout loss and missing generated-property provenance, even without includeDiagnostics. mappingComplete=false identifies mapping gaps; mapping-incomplete is not value loss and cannot be exact. projectionEvidence includes source fields and calculations for generated attributes. outputPathResults lists supported keys and diagnostics; frame file keys are frame::, while outputPaths reports actual committed writes. Resource responses offer nextAction.tool/arguments for same-artifact body comparison and sizeEstimate with explicit unmeasured wire overhead; coupled asset/path arguments remain together. quality.assets grades binary collection; assetSummary.description explains collection state. Merge saved batch responses offline with devup-mcp --merge-asset-batches batch1.json batch2.json for cumulative collection, unrequested, failed and conflict counts. SECTION links use two stages: receive selection_required, then run nextAction.example to export a selected screen. Asked to build pages or a site from Figma, follow the devup-implementation-plan workflow (/devup) even if not asked: it plans the URLs, updates dependencies to the latest, and adds interaction states, transitions and metadata; App Router apps may run on vinext with no next dependency. Building pages: with projectRoot, a page write (pageScaffold write, outputPaths under app/, or several frames with outputPaths) needs a confirmed devup_screen_plan and otherwise answers plan_required; pageScaffold then takes its route from the plan. Ask the plan's questions up front only; after confirm never ask the user, record assumptions instead. Excluded and design-fix frames are refused (skill devup-implementation-plan). The `tsx` this returns is devup-ui code, not plain React: its components are compile-time placeholders, `$token` names an entry in the project's devup.json, and a style prop takes a responsive array. Call devup_skills before you write or edit it - it reports which of those conventions this workspace is missing and installs them where your runtime loads skills from. An agent that skips this does not know it is guessing, and this server cannot see the guesses; the gap is repeated as `skillGap` on the response that carries the code. This output is the design, already read. Do not rewrite it from a screenshot or from `get_design_context` - pictures and visual reasoning verify, they do not author. Do not read the node tree and write devup-ui by hand, and do not infer layout from coordinates. Never guess a colour, spacing, radius or typography value: if a value did not come back, say so and stop, because an invented one is indistinguishable from a real one in the code and is the single failure this server exists to prevent. A failed call is a fact to report, not something to route around. Ask only for what you will read: `tsx` is the deliverable, and the response always carries `status`, `quality`, `cache.artifactId`, `collection` and `source` beside it. `outputs` defaults to `["tsx"]`. Add `devupJson` only when the project has no `devup.json` yet, or when you are introducing tokens it does not define - if it already has one, that file is what the code must match, and `devup_project_context` is what reads it. If you already know the node ids you want - a brief named them, or an earlier call did - pass them straight to `frameIds` and skip `devup_figma_explore`; exploring to rediscover ids you are already holding spends a Figma call for nothing. Explore is for when a Section link is all you have. Each output adds its own keys and nothing else - tsx adds `tsx`; componentTsx adds `componentTsx`; responsiveTsx adds `responsiveTsx`, `responsiveSlots` and, where a width asked for something one tree cannot say, `responsiveUnrepresented`; devupJson adds `devupJson`, `themeCounts`, `themeCompleteness`, `conflicts` and `unresolvedVariables`; sourceMap and assetManifest each add the key they name; referencePng adds `referencePng` and, when the Devup Bridge plugin's picture may hold layers get_screenshot leaves out, `referencePngNote` naming them. `rawSnapshot` and `rawPayload` are the collected design in raw form and need `debug: true`. Use them for one question only - a screen looks wrong and you must decide whether the generator is at fault or the design says so - never to implement, since the tsx already carries what they carry. `fidelity` and `completenessReport` appear only when the result is not exact or complete, or when includeDiagnostics is set. tsx expands every instance into primitives while componentTsx keeps them as references, so requesting both gives the same screen twice and the difference between them is each component's body. responsiveTsx merges every width the capture carries into one module whose differing values are devup-ui responsive arrays, and is produced whenever there is more than one width. `assetManifest` lists the assets and their ids and writes nothing. To get files, call again with `assetRequests`, giving each entry an `outputPath` under an allowed write root - and make that call with the original `url` rather than `artifactId`, because an acquisition made without asset capture cannot serve asset requests. By default manifest `path` and TSX references remain placeholders. Opt in with `assetPublicRoot`, an existing absolute local directory served at URL /, together with assetRequests.outputPath in this call (saved output mappings are not part of artifacts): files below that root map to percent-encoded relative URLs in manifest and all TSX outputs. Identical exported bytes share one canonical path/outputPath; inspect the returned paths. assetNamesPerNode remains true by default; matching names alone never trigger deduplication. Reuse a previous acquisition with `artifactId` from `cache` to project further outputs without calling Figma again. Omitted frameIds/allScreens reuse the stored Section selection; explicit selection overrides it. Debug outputs can use resource delivery in auto mode: read the linked resources or follow nextAction to reproject inline. sourceMap is a node/field/generatedProperty/resolution map without offsets; source.generatedOutput identifies the clean output; node identities and instanceOverrides remain structured sidecars. Even when reusing artifactId, sourceMap requires tsx, componentTsx or devupJson in outputs in the same call. Example: {"artifactId":"","outputs":["tsx","rawSnapshot","sourceMap"],"debug":true} An asset request targeting a frame by nodeId defaults to the original IMAGE fill when one is available. This keeps hero headings and buttons live instead of baking them into the background image. Explicit renderMode node retains whole-node capture; its renderInfo warns when child text would be baked into the asset. interactiveCandidates may include role="marquee" with states.confidence="heuristic" for single-line text or repeated text rows exceeding a clipsContent ancestor, or long repeated-separator strips. Figma does not prove animation intent. Review the supplied recipe: duplicate content in a nowrap track, keyframes translateX(0) to translateX(-50%) with infinite linear animation, _motionReduce animation none, and aria-hidden on the duplicate. Export remains static until this behavior is implemented and tested. diff --git a/fixtures/devup-figma-plugin/README.md b/fixtures/devup-figma-plugin/README.md index 017abd2a..332bdb2d 100644 --- a/fixtures/devup-figma-plugin/README.md +++ b/fixtures/devup-figma-plugin/README.md @@ -111,3 +111,17 @@ every legacy fixture. Full-parent overlays (021 and 191), fluid constraints and read-error cases retain their previous policy. Original case JSON and upstream identity are unchanged; only four reviewed code snapshots and their LF-normalized checksums change. No expected image is regenerated from code. + +### Prime Korean pretty wrapping (2026-11-10) + +The user-confirmed policy keeps Korean word boundaries (keep-all) and avoids +large trailing gaps at line ends with `text-wrap: pretty`. Seven reviewed +codegen goldens add exactly fifteen `textWrap="pretty"` lines (165: 2, 191: 1, +213: 1, 223: 6, 248: 1, 252: 2, 253: 2) and nothing else. Text with +`whiteSpace="pre"`/`nowrap` gets no `textWrap`, because the CSS `text-wrap` +shorthand resets `text-wrap-mode` to `wrap` and would re-enable soft wrapping. +Explicit local/theme `textWrap` wins. Source case JSON, upstream identity and +inventory are unchanged; only these seven snapshot checksums change. This is a +browser line-balancing policy, not new pixel evidence for the pinned screens; +a diff caused only by Figma breaking inside a Korean word is a documented +policy difference. diff --git a/fixtures/devup-figma-plugin/manifest.json b/fixtures/devup-figma-plugin/manifest.json index 50b608c4..17dc3f5c 100644 --- a/fixtures/devup-figma-plugin/manifest.json +++ b/fixtures/devup-figma-plugin/manifest.json @@ -1803,7 +1803,7 @@ }, { "path": "snapshots/codegen/upstream-codegen-165-501ee1df12.snap", - "sha256": "6ff36023e7a4b5f4bd7b94e32dd860858790306ec388c3e18f9329f47bbb1c67" + "sha256": "c75e81c1bb8880944ea3af6e732c3bf3cb1d65c57fc08f927ae3936e4bbadece" }, { "path": "snapshots/codegen/upstream-codegen-166-cda14f0b08.snap", @@ -1907,7 +1907,7 @@ }, { "path": "snapshots/codegen/upstream-codegen-191-2021d398f8.snap", - "sha256": "a0cfd38459810097773e40db834410d21737fbc00c35e4896c5ddccada3f47b4" + "sha256": "3d5f7f2ff7c31e034494d44d53b40b9503a4cedb6bb2e380ab72707d36544193" }, { "path": "snapshots/codegen/upstream-codegen-192-dd2c3f0c1c.snap", @@ -1995,7 +1995,7 @@ }, { "path": "snapshots/codegen/upstream-codegen-213-aa53408d2f.snap", - "sha256": "7482a0fb474a4a3ab5344ffaa7270ad9b63077a51064796f6275c8cf61fe66b5" + "sha256": "72286fcd4fdf9b7e1d37ed6d1c440215534382bf51a00d4348d1a4e4c784e822" }, { "path": "snapshots/codegen/upstream-codegen-214-6dc49544c0.snap", @@ -2035,7 +2035,7 @@ }, { "path": "snapshots/codegen/upstream-codegen-223-4dc8092992.snap", - "sha256": "d7a250d817e57a89a679b55f2a912401e829d65720a0be1126222a3e2d551c4f" + "sha256": "d3faf189f7216a005ddac21cb28ceeff661a4019a2f37110750b0b34cf0032a2" }, { "path": "snapshots/codegen/upstream-codegen-224-075a41e65c.snap", @@ -2135,7 +2135,7 @@ }, { "path": "snapshots/codegen/upstream-codegen-248-d1bc18e068.snap", - "sha256": "ddfb5a069948c994c79ee685695670f2d518c08ac43386616e21fad852d031a0" + "sha256": "82066a7dfc03e9452a1d6fdead72d0ad4ea5421f5a6d881f6f74e3502996228f" }, { "path": "snapshots/codegen/upstream-codegen-249-6b8a42981d.snap", @@ -2151,11 +2151,11 @@ }, { "path": "snapshots/codegen/upstream-codegen-252-49e979239e.snap", - "sha256": "52ab20ca9f59b307a6e04e143fa30191cb37914ea3baffc91d5d44d482d5e29f" + "sha256": "56645bf5c1c7924e03a1d4336d930ee187f8757c53d5f088a0268558e547e015" }, { "path": "snapshots/codegen/upstream-codegen-253-347a2e5fb2.snap", - "sha256": "50b99d951b8c831b892a0c56fa0d13d929da72bb328f4abd9b70939f915aab36" + "sha256": "eb90618d14e93b280230b68373576b090b95a79b6792ced6c7299c85a061cf5d" }, { "path": "snapshots/codegen/upstream-codegen-254-9f340b6a10.snap", diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-165-501ee1df12.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-165-501ee1df12.snap index 5940f23a..3732c76e 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-165-501ee1df12.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-165-501ee1df12.snap @@ -15,6 +15,7 @@ expression: actual letterSpacing="-0.04em" lineHeight="28px" overflowWrap="break-word" + textWrap="pretty" wordBreak="keep-all" > @@ -30,6 +31,7 @@ expression: actual letterSpacing="-0.06em" lineHeight="26px" overflowWrap="break-word" + textWrap="pretty" whiteSpace="pre-wrap" wordBreak="keep-all" > diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-191-2021d398f8.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-191-2021d398f8.snap index b8d5f247..a7157ffd 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-191-2021d398f8.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-191-2021d398f8.snap @@ -78,6 +78,7 @@ expression: actual letterSpacing="-0.04em" lineHeight="22px" overflowWrap="break-word" + textWrap="pretty" whiteSpace="pre-wrap" wordBreak="keep-all" > diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-213-aa53408d2f.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-213-aa53408d2f.snap index cbe27d35..1c14e839 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-213-aa53408d2f.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-213-aa53408d2f.snap @@ -82,6 +82,7 @@ expression: actual letterSpacing="-1px" lineHeight="24px" overflowWrap="break-word" + textWrap="pretty" wordBreak="keep-all" > 댓글 내용 출력 댓글 내용 출력 댓글 내용 출력 댓글 내용 출력 댓글 내용 출력 diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-223-4dc8092992.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-223-4dc8092992.snap index 1ea2b44c..2ee1d3c9 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-223-4dc8092992.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-223-4dc8092992.snap @@ -21,6 +21,7 @@ expression: actual letterSpacing="-1px" lineHeight="30px" overflowWrap="break-word" + textWrap="pretty" w="100%" wordBreak="keep-all" > @@ -43,6 +44,7 @@ expression: actual letterSpacing="-1px" lineHeight="24px" overflowWrap="break-word" + textWrap="pretty" wordBreak="keep-all" > 카카오 @@ -98,6 +100,7 @@ expression: actual letterSpacing="-1px" lineHeight="24px" overflowWrap="break-word" + textWrap="pretty" wordBreak="keep-all" > 구글 @@ -154,6 +157,7 @@ expression: actual letterSpacing="-1px" lineHeight="24px" overflowWrap="break-word" + textWrap="pretty" wordBreak="keep-all" > 서비스 이용약관 @@ -184,6 +188,7 @@ expression: actual letterSpacing="-1px" lineHeight="24px" overflowWrap="break-word" + textWrap="pretty" wordBreak="keep-all" > 개인정보 처리방침 @@ -214,6 +219,7 @@ expression: actual letterSpacing="-1px" lineHeight="24px" overflowWrap="break-word" + textWrap="pretty" wordBreak="keep-all" > 회원 탈퇴 diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-248-d1bc18e068.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-248-d1bc18e068.snap index 8fdac1ce..6dac0a79 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-248-d1bc18e068.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-248-d1bc18e068.snap @@ -16,6 +16,7 @@ expression: actual my="0px" overflowWrap="break-word" pl="1.5em" + textWrap="pretty" wordBreak="keep-all" >
  • diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-252-49e979239e.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-252-49e979239e.snap index 74cba665..52d290a9 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-252-49e979239e.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-252-49e979239e.snap @@ -64,6 +64,7 @@ expression: actual lineHeight="53px" overflowWrap="break-word" textAlign="center" + textWrap="pretty" w="100%" whiteSpace="pre-wrap" wordBreak="keep-all" @@ -81,6 +82,7 @@ expression: actual lineHeight="34px" overflowWrap="break-word" textAlign="center" + textWrap="pretty" w="100%" whiteSpace="pre-wrap" wordBreak="keep-all" diff --git a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-253-347a2e5fb2.snap b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-253-347a2e5fb2.snap index 0f7a42dd..da85ede1 100644 --- a/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-253-347a2e5fb2.snap +++ b/fixtures/devup-figma-plugin/snapshots/codegen/upstream-codegen-253-347a2e5fb2.snap @@ -71,6 +71,7 @@ expression: actual letterSpacing="-0.03em" lineHeight="42px" overflowWrap="break-word" + textWrap="pretty" whiteSpace="pre-wrap" wordBreak="keep-all" > @@ -84,6 +85,7 @@ expression: actual letterSpacing="-0.02em" lineHeight="29px" overflowWrap="break-word" + textWrap="pretty" wordBreak="keep-all" > 음악과 자연이 함께 어우러지는 제주국제관악제가 30주년을 맞게 되었습니다.

    제주국제관악제를 아껴주시는 모든 분들과 함께 세계 속의 제주관악의 역사를 써 왔음에 깊이 감사드립니다.
    그동안 힘들고 어려운 순간들 속에서도 관악의 힘찬 울림은 우리에게 큰 위로와 보람을 안겨주었습니다.
    새로운 세대를 여는 앞으로의 시간과 함께 켜켜이 쌓여가는 역사 속에서, 세계를 향한 우리의 각오와 노력이 더해질 것입니다.

    희망찬 봄을 여는 관악의 울림이 더 널리, 더 멀리 퍼져나가기를 기원합니다.
    감사합니다. diff --git a/harness/runtime-audit/test/codegen-layout.test.mjs b/harness/runtime-audit/test/codegen-layout.test.mjs index bae01b46..5ad7de7d 100644 --- a/harness/runtime-audit/test/codegen-layout.test.mjs +++ b/harness/runtime-audit/test/codegen-layout.test.mjs @@ -214,3 +214,57 @@ test('reconciled title and summary render once and preserve parent layout throug await page.close(); },{responsive:true,reset:true}); }); + +test('Korean keep-all with textWrap pretty compiles, and generated headings reset user-agent margins', {timeout:360000}, async()=> { + const copy='진단 결과를 바탕으로 개선을 제안하고 실행 계획을 함께 세웁니다'; + const text=(id,name,characters,extra={})=>({id,type:'TEXT',fields:{name,parentId:'root',characters,fontSize:24, + lineHeight:{unit:'PIXELS',value:34},width:360,height:68,textAutoResize:'HEIGHT',textTruncation:'DISABLED', + layoutSizingHorizontal:'FIXED',layoutSizingVertical:'HUG',...extra}}); + const snapshot={fileKey:'prime-wrap',version:null,roots:['root'],diagnostics:[],nodes:{ + root:{id:'root',type:'FRAME',fields:{name:'RenderFixture',width:400,height:400,layoutMode:'VERTICAL', + layoutSizingHorizontal:'FIXED',layoutSizingVertical:'HUG',itemSpacing:0,childrenIds:['heading','body','label']}}, + heading:text('heading','Heading 2','실행 계획'), + body:text('body','Body',copy), + label:text('label','Label','편집 설정',{textAutoResize:'WIDTH_AND_HEIGHT',width:80,height:34,layoutSizingHorizontal:'HUG'}), + }}; + // No reset stylesheet: the user-agent h2 margin must be neutralized by the + // generated my="0px" itself. + await rendered(snapshot,async(browser,url,dir)=> { + const page=await browser.newPage({viewport:{width:400,height:600},deviceScaleFactor:1}); + await page.goto(url);await page.evaluate(()=>document.fonts.ready); + const source=await readFile(join(dir,'Generated.tsx'),'utf8'); + assert.match(source,/textWrap="pretty"/); + assert.match(source,/as="h2"/); + const styles=await page.evaluate(()=>{ + const pick=(selector)=>{const element=document.querySelector(selector);const style=getComputedStyle(element); + return {tag:element.tagName,marginTop:style.marginTop,marginBottom:style.marginBottom,wordBreak:style.wordBreak, + overflowWrap:style.overflowWrap,textWrapStyle:style.textWrapStyle,textWrapMode:style.textWrapMode,whiteSpace:style.whiteSpace};}; + return {heading:pick('[data-figma-node-id="heading"]'),body:pick('[data-figma-node-id="body"]'),label:pick('[data-figma-node-id="label"]')}; + }); + assert.equal(styles.heading.tag,'H2',JSON.stringify(styles)); + assert.equal(styles.heading.marginTop,'0px',JSON.stringify(styles)); + assert.equal(styles.heading.marginBottom,'0px',JSON.stringify(styles)); + assert.equal(styles.body.wordBreak,'keep-all',JSON.stringify(styles)); + assert.equal(styles.body.overflowWrap,'break-word',JSON.stringify(styles)); + assert.equal(styles.body.textWrapStyle,'pretty',JSON.stringify(styles)); + assert.equal(styles.body.textWrapMode,'wrap',JSON.stringify(styles)); + // Intrinsic-width text keeps whiteSpace pre and never gets text-wrap, which + // would reset text-wrap-mode and re-enable soft wrapping. + assert.equal(styles.label.whiteSpace,'pre',JSON.stringify(styles)); + assert.equal(styles.label.textWrapMode,'nowrap',JSON.stringify(styles)); + // keep-all: every line break falls on a word boundary. + const lines=await page.evaluate(()=>{ + const element=document.querySelector('[data-figma-node-id="body"]'); + const node=[...element.childNodes].find(child=>child.nodeType===Node.TEXT_NODE); + const range=document.createRange();const tops=[];let line='';const out=[]; + for(let index=0;index1,JSON.stringify(lines)); + for(const line of lines.slice(0,-1)) assert.match(line,/\s$/,`line must end at a word boundary: ${JSON.stringify(lines)}`); + await page.close(); + }); +}); diff --git a/reports/prime-feedback-2026-11.md b/reports/prime-feedback-2026-11.md index c7bd9e7a..fb79f534 100644 --- a/reports/prime-feedback-2026-11.md +++ b/reports/prime-feedback-2026-11.md @@ -75,3 +75,37 @@ shipped instruction change; this update does not claim a new live GUI concurrency experiment. Existing skill-install/digest and bootstrap/guide delivery checks verify that the rule reaches clients. The 1,500-byte bootstrap instruction limit is retained. + +## Follow-up (2026-11-10) + +This follow-up implements the remaining MCP-scope items after PR #119 and the +user's binding decision on Korean wrapping. It supersedes the 002 and 016 rows +above where they differ. + +| Feedback | Change in this follow-up | +| --- | --- | +| 002 | Semantic `h1`–`h6` conversion now emits `my="0px"` unless the node already has a margin (`m`/`my`/`mt`/`mb`), with a verified semantic-stage property derivation. The browser regression compiles the installed Devup UI without a reset stylesheet and measures a generated `h2` margin of 0px. | +| 006 | Extra image fills are named `{stem}--fill-{index}` (0-based, as in `{nodeId}:fills:{index}`). Every stem is kebab-case with collapsed hyphens, so this suffix cannot collide with the `-2`/`-3` name of a different asset (previously fill 2 and the second same-named asset were both `stem-2.png`). The one-placeholder/two-pictures refusal now lists the conflicting assets, nodes, image hashes and canonical paths; it recommends `assetNamesPerNode:true` only when the hashes differ. For a shared imageHash (named deliberately as one file, with or without that flag) it points to separate exports or sourceMap nodeId/assetId binding. Existing collision guards and the shared-hash naming test are unchanged. | +| 007 | Every `devupJson` response carries `devupJsonScope` (`complete-theme`, `used-tokens` or `additions-only`). Missing-only adds `devupJsonMerge` (target file, deep-merge/add-missing-keys strategy, instruction). A missing-only `devupJson` output path named `devup.json` is refused before any Figma call because it would replace the project theme. | +| 008 | `componentSetVariants` reports per-variant `visible` and `allVariantsHidden`. When every variant of an exported COMPONENT_SET (or a single master) is hidden, the response adds `DEVUP_HIDDEN_MASTER_EXPORT` with the hidden variant ids, visible instances found in the snapshot and a next action: export the visible page INSTANCE with its overrides. | +| 011 | Asset job rows report `fileFacts` (exists, bytes, sha256 read from disk) for each projected path, `outputPathRedirected` when the file lives at a canonical path rather than the requested one, and a `fileStateReason` for every `not-written` row: missing after a blocked or rolled-back write, different bytes kept by the collision guard, quarantine, failed transaction, no acquisition or a duplicate. Grayscale reuse of a colour original is reported as written and redirected, not as not-written. | +| 014 | Guidance only: validation messages must not take a native input's width (flex-basis/minW contract, message on its own row); E2E must trigger the error and assert input bounds, visibility, focus and message. | +| 016 | Decision: Korean keeps word boundaries. Generated Korean text keeps `wordBreak="keep-all"` and `overflowWrap="break-word"` and adds `textWrap="pretty"` against large trailing gaps. Text with `whiteSpace` `pre`/`nowrap` gets no `textWrap`, because the shorthand resets `text-wrap-mode` and would re-enable wrapping. Explicit local/theme `textWrap` wins. The shipped guidance now states this policy: a diff caused only by Figma's mid-word Korean break is a documented policy difference, and the native Figma reference is never overwritten to pass. The `korean-word-boundary` validation rule's next action matches. | +| 032 | Guidance only: report CI failures by actual stage with the original message. Keep local pass, remote CI pass, push/permission rejection and transient external HTTP errors (for example setup-bun 504) as separate evidence. On a rejected push, finish independent work, leave an applicable patch and state the constraint. | +| axe-core 4.14 | Guidance only: a minor bump promoted `label-content-name-mismatch` (WCAG 2.5.3) to wcag21a. Drop redundant `aria-label`s that repeat visible text (including `aria-hidden` split-letter copies), make remaining labels contain the visible text verbatim and use sr-only text for extra hints. Fix components; never disable the rule. | + +Evidence: Rust regressions cover the textWrap policy (including pre/nowrap and +explicit policies), heading margins with verified derivations, fill/dedupe path +uniqueness, both ambiguous-placeholder recoveries, theme scope markers and the +devup.json write refusal, hidden-master warnings (unit and full projection) and +per-path asset facts/reasons including grayscale reuse. Seven pinned plugin +goldens add exactly fifteen `textWrap="pretty"` lines; the 151 frame snapshots +add only `textWrap` lines and their property evidence. The harness browser +regression compiles with the installed Devup UI and checks computed +`word-break: keep-all`, `text-wrap-style: pretty`, a `nowrap` mode for +intrinsic-width text, line ends on word boundaries and a 0px `h2` margin. + +Out of scope, unchanged: 005 (general blend/backdrop compositor), 009 (Devup UI +compiler transform of polymorphic Link), 013 (Vite watcher/HMR behavior) and +029 (the application's destructive postprocessor). These belong to other +components or the application and still need their own evidence.