Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
06241ab
Keep multi-colour icons as <Image> instead of a single-colour mask
Oct 17, 2026
ee6c4ab
Warn in devup_ui_validate when a maskImage would flatten a multi-colo…
Oct 17, 2026
2310338
Compact export job polls, honour responseFields on them, explain pend…
Oct 17, 2026
8842947
Report a per-screen, per-width table from runtime audit ingest
Oct 17, 2026
cdbcfc3
Add devup_screen_plan and gate page-writing exports on a confirmed plan
Oct 17, 2026
e7bbe00
Ship the devup-implementation-plan skill and document the screen plan
Oct 17, 2026
2a27f4d
Ask screen plan questions in up-front rounds and per group, never aft…
Oct 17, 2026
c017bf9
Teach the up-front rounds and project config in the skill, guide and …
Oct 17, 2026
3f85e58
Keep the screen plan in scope, ask cross-section ambiguities, report …
Oct 17, 2026
ec18b43
Document scope, exclusions and design fixes in the skill, guide and R…
Oct 17, 2026
b02da9a
Warn when generated pages give no feedback or carry no metadata
Oct 17, 2026
d54e61f
Add site completeness to the screen plan and a /devup entry point
Oct 17, 2026
45ff2b9
Merge multicolor-icon-mask into screen-plan-first
Oct 17, 2026
84025eb
Judge interactions by element role, and report too much motion
Oct 17, 2026
ac4e51c
Add role recipes, view transitions, extra questions and a submission …
Oct 17, 2026
a385d54
Recognise vinext apps, decide view transitions for them, plan the upd…
Oct 17, 2026
bb00dc5
Teach the latest-first stack in the skills, guide and README
Oct 17, 2026
51283da
Add a view-transition check to the runtime audit
Oct 17, 2026
9be2ed2
Always plan view transitions, and pick the target app by meaning
Oct 17, 2026
2574ccf
Document always-on view transitions, the e2e check and app selection
Oct 17, 2026
5771a51
Add page-health, bounds, clipped-text, interaction and capture audit …
Oct 17, 2026
9b917f1
Plan a full visual audit per URL, classify its failures and work them…
Oct 17, 2026
1f6809a
Document the full visual audit and the never-wait rules
Oct 17, 2026
c23bcdd
Check layout exactly, tell code drift from converter bugs, and audit …
Oct 17, 2026
b89e0cf
Document exact layout, drift and converter bugs, per-width ids, dynam…
Oct 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changepacks/changepack_log_screen_plan_first.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"changes": {
"crates/devup-mcp/Cargo.toml": "Minor",
"crates/devup-mcp-devup-ui/Cargo.toml": "Minor"
},
"note": "Add devup_screen_plan (draft, confirm, status, assume) so every Figma screen under the linked node is mapped to an evidence-backed URL and confirmed in questions asked up front, never during implementation; keep the plan inside the linked scope and out of trash, archive, draft, copy and hidden areas (listed and confirmed, overridable with excludeKeywords/includeKeywords); ask cross-section and other structural ambiguities explicitly with a fix-the-Figma option whose frames are reported, not built; surface layer-name guesses; honour .devup/screen-plan.config.json; gate page-writing exports on the confirmed plan and refuse excluded or design-fix frames; report URL x width runtime status with assumptions; and ship the devup-implementation-plan skill. Add a site-completeness step: the plan records the defaults the agent fills in (interaction states from existing tokens, one shared transition, reduced motion, metadata, lang, alt, not-found) and asks only for files and facts (favicon, share image, site name and URL, analytics, motion); status reports site completeness and devup_ui_validate warns on stateless controls, pages without metadata and a missing app icon. Add the /devup entry point, installed as a skill (Claude Code) and an OpenCode command. Keep multi-colour icons as <Image> instead of a single-colour mask, warn multicolor-mask in devup_ui_validate when a maskImage would flatten a multi-colour SVG, and compact export job polls (honour responseFields on them, explain pending files). Replace the flat interaction default with a restrained recipe per element role, add view transitions by the mechanism the project's Next.js and React versions support, let draft take the agent's own extraQuestions, end the workflow with a submission checklist, and widen devup_ui_validate with focus-style, too-much-motion, icon-button-label (warnings) and button-active, motion-reduce, nav-current (info). Recognise vinext apps (no next dependency) across bun workspaces and report frameworks per app, decide view transitions for vinext with React 19.3 from the installed versions, run the dependency update (bunx @dependency-check-updates/cli -d -u --rm) before implementing and report it, start new projects from dev-five-git/service-template, ask once whether a next-CLI app migrates to vinext, and align devfive-frontend with service-template's AGENTS.md. Turn view transitions on for every project (opt out with viewTransitions: false) and prove them with a new runtime-audit view-transition check, and pick the target app of a multi-app repository by meaning - public site vs admin console, per screen in a mixed file - instead of name order; add devup_screen_plan auditPlan, which writes one runtime-audit plan per URL checking page health, overflow and clipped text at every width and the page, each section and each asset against its Figma render plus their size and position at the drawn widths, with hover, focus and a view-transition check ordered by code; classify every audit failure with its Figma node, code file and fix hint, track fix attempts up to maxFixAttempts before recording it unresolved with its evidence, and add defer for decisions met after confirm; new runtime-audit check kinds page-health, bounds, no-clipped-text, interaction and capture; exports into a project mark page, section, card and asset elements with data-figma-node-id; devup_visual_compare can write its diff PNG to diffPath; app selection takes the public app unless the frames clearly say admin; check geometry exactly (half a pixel of rounding, no tolerance) and classify a mismatch as code-drift against the export record each export writes to .devup/exports, or as a converter-bug with a repro in .devup/converter-bugs that is reported and never retried; mark pages merged from one frame per width with data-figma-node-ids per width and check elements a width does not draw are hidden; audit dynamic routes at concrete URLs from generateStaticParams, the site's links or the detail frame; strip the ids from production builds with a Vite plugin unless DEVUP_AUDIT=1.",
"date": "2026-10-17T00:00:00+09:00"
}
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,7 @@
/docs/
/feedback.txt
/devup-mcp-feedback/

# Installed by `npm ci` in harness/runtime-audit (and plugin/) to run the
# executor locally; never part of the tree.
node_modules/
57 changes: 55 additions & 2 deletions README.md

Large diffs are not rendered by default.

59 changes: 46 additions & 13 deletions crates/devup-mcp-devup-ui/src/codegen/compat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -330,9 +330,14 @@ pub fn render_viewport_component(input: &Value) -> Option<String> {
props.push((key.as_str(), prop, boolean));
}
let (_, prop, boolean) = props.first()?;
let asset_components = children
// A mask fills the shape with one colour, so only icons painted in one
// flat colour throughout may become one; anything else keeps its pixels.
let mask_color = children
.iter()
.all(|child| child.get("isAsset").and_then(Value::as_bool) == Some(true));
.all(|child| child.get("isAsset").and_then(Value::as_bool) == Some(true))
.then(|| single_solid_color(children))
.flatten();
let asset_components = mask_color.is_some();
let component = if asset_components { "Box" } else { "Image" };
let mut variants = Vec::new();
for child in children {
Expand All @@ -356,18 +361,9 @@ pub fn render_viewport_component(input: &Value) -> Option<String> {
} else {
prop.clone()
};
let extra = if asset_components {
let paint = children
.first()?
.get("children")?
.as_array()?
.first()?
.get("fills")?
.as_array()?
.first()?;
let extra = if let Some(color) = mask_color {
format!(
" bg=\"{}\"\n maskImage={{{{\n{variant_lines}\n }}[{index}]}}\n maskPos=\"center\"\n maskRepeat=\"no-repeat\"\n maskSize=\"contain\"",
color_hex(paint)?
" bg=\"{color}\"\n maskImage={{{{\n{variant_lines}\n }}[{index}]}}\n maskPos=\"center\"\n maskRepeat=\"no-repeat\"\n maskSize=\"contain\""
)
} else {
format!(" src={{{{\n{variant_lines}\n }}[{index}]}}")
Expand Down Expand Up @@ -563,6 +559,43 @@ fn normalize_prop_name(value: &str) -> String {
/// Takes the whole paint because Figma splits a translucent solid across
/// `color.a` and the paint's own `opacity`; the effective alpha is the product.
/// Reading `color` alone drops `opacity` and renders the fill opaque.
/// The one flat colour every visible fill and stroke under `nodes` shares,
/// or `None` when there is no paint, a paint is not `SOLID`, or two differ.
fn single_solid_color(nodes: &[Value]) -> Option<String> {
let mut color = None;
let mut pending = nodes.iter().collect::<Vec<_>>();
while let Some(node) = pending.pop() {
for field in ["fills", "strokes"] {
for paint in node
.get(field)
.and_then(Value::as_array)
.into_iter()
.flatten()
{
if paint.get("visible").and_then(Value::as_bool) == Some(false) {
continue;
}
if paint.get("type").and_then(Value::as_str) != Some("SOLID") {
return None;
}
let hex = color_hex(paint)?;
match &color {
None => color = Some(hex),
Some(current) if *current != hex => return None,
Some(_) => {}
}
}
}
pending.extend(
node.get("children")
.and_then(Value::as_array)
.into_iter()
.flatten(),
);
}
color
}

fn color_hex(paint: &Value) -> Option<String> {
let color = paint.get("color")?;
let channel =
Expand Down
1 change: 1 addition & 0 deletions crates/devup-mcp-devup-ui/src/codegen/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ mod component;
mod evidence;
mod image;
mod layout;
pub mod node_ids;
mod property_derivations;
pub(crate) use property_derivations::property_derivations;
pub mod responsive;
Expand Down
179 changes: 179 additions & 0 deletions crates/devup-mcp-devup-ui/src/codegen/node_ids.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
//! Stable Figma node identity on the rendered DOM, so a runtime audit can
//! find a section or an asset by the node it was generated from instead of
//! by a guessed selector.
use std::collections::BTreeSet;

use crate::codegen::CodegenOutput;
use crate::provenance::attributes::opening_elements;

pub const NODE_ID_ATTRIBUTE: &str = "data-figma-node-id";
/// On a page merged from one frame per width: `"<width>:<nodeId>"` for each
/// drawn width the element is shown at, widest first, separated by spaces,
/// so `[data-figma-node-ids~="390:56:78"]` selects it at one width.
pub const NODE_IDS_ATTRIBUTE: &str = "data-figma-node-ids";

/// Writes both attributes on every element of a merged page that a width
/// draws: `data-figma-node-id` is the node at the widest width it is shown
/// at, `data-figma-node-ids` the map. Returns each element written, with the
/// widths it is drawn hidden at.
pub fn annotate_merged(
screen: &mut crate::codegen::responsive::MergedScreen,
) -> Vec<serde_json::Value> {
let elements = opening_elements(&screen.tsx);
if elements.len() != screen.elements.len() {
return Vec::new();
}
let mut inserts = Vec::new();
let mut written = Vec::new();
for (element, merged) in elements.iter().zip(&screen.elements) {
let Some((_, widest)) = merged.ids.first() else {
continue;
};
let Some(opening) = screen.tsx.get(element.start..element.end) else {
continue;
};
if merged.in_text || opening.contains(NODE_ID_ATTRIBUTE) {
continue;
}
let name_end = opening[1..]
.find(|c: char| !(c.is_alphanumeric() || matches!(c, '_' | '.' | ':' | '-' | '$')))
.map_or(opening.len(), |offset| offset + 1);
let map = merged
.ids
.iter()
.map(|(width, id)| format!("{width}:{id}"))
.collect::<Vec<_>>()
.join(" ");
inserts.push((
element.start + name_end,
format!(" {NODE_ID_ATTRIBUTE}=\"{widest}\" {NODE_IDS_ATTRIBUTE}=\"{map}\""),
));
written.push(serde_json::json!({
"ids": merged.ids.iter().map(|(width, id)| serde_json::json!({"width": width, "nodeId": id})).collect::<Vec<_>>(),
"hiddenAt": merged.hidden_at,
}));
}
inserts.sort_by_key(|insert| std::cmp::Reverse(insert.0));
for (at, text) in inserts {
screen.tsx.insert_str(at, &text);
}
written
}

/// Writes `data-figma-node-id="<id>"` on the opening element generated for
/// each of `node_ids`, found through the source map, and returns the ids it
/// wrote. An element that already carries the attribute is left alone. The
/// source map's internal ranges are shifted with the text, so later checks
/// against this output still line up.
pub fn annotate_node_ids(output: &mut CodegenOutput, node_ids: &BTreeSet<String>) -> Vec<String> {
let elements = opening_elements(&output.tsx);
let mut targets = Vec::<(usize, String)>::new();
for entry in &output.source_map.entries {
let (Some(node_id), Some(ordinal)) = (&entry.node_id, entry.element_index) else {
continue;
};
if !node_ids.contains(node_id) || targets.iter().any(|(_, id)| id == node_id) {
continue;
}
if targets.iter().any(|(taken, _)| *taken == ordinal) {
continue;
}
targets.push((ordinal, node_id.clone()));
}
let mut inserts = Vec::new();
for (ordinal, node_id) in &targets {
let Some(element) = elements.iter().find(|element| element.ordinal == *ordinal) else {
continue;
};
let Some(opening) = output.tsx.get(element.start..element.end) else {
continue;
};
if opening.contains(NODE_ID_ATTRIBUTE) || !opening.starts_with('<') {
continue;
}
let name_end = opening[1..]
.find(|c: char| !(c.is_alphanumeric() || matches!(c, '_' | '.' | ':' | '-' | '$')))
.map_or(opening.len(), |offset| offset + 1);
if name_end <= 1 {
continue;
}
inserts.push((
element.start + name_end,
format!(" {NODE_ID_ATTRIBUTE}=\"{}\"", node_id.replace('"', "")),
node_id.clone(),
));
}
inserts.sort_by_key(|insert| std::cmp::Reverse(insert.0));
let mut written = Vec::new();
for (at, text, node_id) in inserts {
output.tsx.insert_str(at, &text);
for entry in &mut output.source_map.entries {
if let Some(range) = &mut entry.generated_range {
if range.start >= at {
range.start += text.len();
}
if range.end >= at {
range.end += text.len();
}
}
}
written.push(node_id);
}
written.sort();
written
}

#[cfg(test)]
mod tests {
use super::*;
use crate::codegen::{CodegenOptions, generate_component};
use devup_mcp_figma::Snapshot;
use serde_json::json;

#[test]
fn the_root_and_each_section_get_their_node_id_and_ranges_follow() {
let snapshot: Snapshot = serde_json::from_value(json!({
"fileKey": "ids", "roots": ["p"], "diagnostics": [], "nodes": {
"p": {"id": "p", "type": "FRAME", "fields": {"layoutMode": "VERTICAL", "width": 400, "height": 300,
"childrenIds": ["a", "b"]}},
"a": {"id": "a", "type": "FRAME", "fields": {"parentId": "p", "layoutMode": "HORIZONTAL",
"width": 400, "height": 100, "childrenIds": ["at"]}},
"at": {"id": "at", "type": "TEXT", "fields": {"parentId": "a", "characters": "Hello",
"fontSize": 16, "fills": [{"type": "SOLID", "color": {"r": 0, "g": 0, "b": 0, "a": 1}}]}},
"b": {"id": "b", "type": "FRAME", "fields": {"parentId": "p", "layoutMode": "HORIZONTAL",
"width": 400, "height": 200, "childrenIds": ["bt"]}},
"bt": {"id": "bt", "type": "TEXT", "fields": {"parentId": "b", "characters": "World",
"fontSize": 16, "fills": [{"type": "SOLID", "color": {"r": 0, "g": 0, "b": 0, "a": 1}}]}}
}
}))
.unwrap();
let mut output = generate_component(&snapshot, "p", &CodegenOptions::default()).unwrap();
let before = output.tsx.clone();
let wanted = ["p", "a", "b"].map(String::from).into_iter().collect();
let written = annotate_node_ids(&mut output, &wanted);
assert_eq!(written, ["a", "b", "p"], "{before}");
for id in ["p", "a", "b"] {
assert!(
output
.tsx
.contains(&format!("{NODE_ID_ATTRIBUTE}=\"{id}\"")),
"{}",
output.tsx
);
}
let elements = opening_elements(&output.tsx);
for entry in &output.source_map.entries {
if let (Some(range), Some(ordinal)) = (&entry.generated_range, entry.element_index) {
let element = elements.iter().find(|e| e.ordinal == ordinal).unwrap();
assert!(
element.start <= range.start && range.end <= element.end,
"{entry:?}"
);
}
}
assert!(
annotate_node_ids(&mut output, &wanted).is_empty(),
"a second pass writes nothing"
);
}
}
65 changes: 65 additions & 0 deletions crates/devup-mcp-devup-ui/src/codegen/responsive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1326,6 +1326,20 @@ pub struct MergedScreen {
pub verified: bool,
/// Where a width and the module part company, when they do.
pub gaps: Vec<Value>,
/// Each opening element of `tsx`, in document order: the node every
/// drawn width made it from, and the widths it is drawn hidden at.
pub elements: Vec<MergedElement>,
}

/// One merged element and where it came from.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MergedElement {
/// Inside another element's text (a `<br />`), not a node of its own.
pub in_text: bool,
/// (frame width, node id) for each width that draws it, widest first.
pub ids: Vec<(u32, String)>,
/// The drawn widths it is present but hidden at.
pub hidden_at: Vec<u32>,
}

/// Everything `project_tree` can name. Anything else the merged tree mentions
Expand Down Expand Up @@ -1400,6 +1414,7 @@ pub fn merge_breakpoints(
let mut by_slot: BySlot<Tree> = std::array::from_fn(|_| None);
let mut roots: BySlot<String> = std::array::from_fn(|_| None);
let mut precedence: Precedence = [usize::MAX; SLOTS];
let mut slot_widths: BySlot<u32> = [None; SLOTS];
let mut slots = Vec::new();
for (position, breakpoint) in found.iter().enumerate() {
let Some(node) = snapshot.nodes.get(&breakpoint.node_id) else {
Expand All @@ -1422,6 +1437,14 @@ pub fn merge_breakpoints(
by_slot[slot] = Some(tree);
roots[slot] = Some(breakpoint.node_id.clone());
precedence[slot] = position;
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "a frame is a small positive number of pixels wide"
)]
{
slot_widths[slot] = Some(width.max(0.0).round() as u32);
}
}
}
slots.sort_unstable();
Expand All @@ -1433,7 +1456,9 @@ pub fn merge_breakpoints(
collect_components(&tree, &mut components);
let tsx = render_merged(&tree, 2);
let (contract, gaps) = verify_merge(snapshot, options, &roots, &tree, &tsx);
let elements = merged_elements(&tree, &slot_widths);
Ok(Some(MergedScreen {
elements,
verified: gaps.is_empty() && contract.iter().all(|entry| entry["verified"] == true),
tsx,
components,
Expand All @@ -1444,6 +1469,46 @@ pub fn merge_breakpoints(
}))
}

/// Where each element of the merged module came from, in the order
/// `render_merged` writes the opening elements.
fn merged_elements(tree: &Tree, slot_widths: &BySlot<u32>) -> Vec<MergedElement> {
let mut placed = Vec::new();
place(tree, None, [true; SLOTS], &mut placed);
placed
.iter()
.map(|placed| {
if placed.in_text {
return MergedElement {
in_text: true,
ids: Vec::new(),
hidden_at: Vec::new(),
};
}
let mut ids = Vec::new();
let mut hidden_at = Vec::new();
for (slot, width) in slot_widths.iter().enumerate() {
let Some(width) = width else {
continue;
};
match placed.tree.slots.get(slot).cloned().flatten() {
Some((node, _)) if placed.shown[slot] => ids.push((*width, node)),
_ if !placed.shown[slot] => hidden_at.push(*width),
// Shown with no node of its own at that width: nothing
// in that frame to compare it with.
_ => {}
}
}
ids.sort_by_key(|(width, _)| std::cmp::Reverse(*width));
hidden_at.sort_by_key(|width| std::cmp::Reverse(*width));
MergedElement {
in_text: false,
ids,
hidden_at,
}
})
.collect()
}

/// One opening element of the merged module, in document order.
struct Placed<'a> {
tree: &'a Tree,
Expand Down
Loading
Loading