M6: Dev Mode refuses edits from dropped files, the Pages list and the Assets panel

In Dev Mode the menus, shortcuts, clipboard, canvas and layers panel
refuse design edits, but an image or SVG dropped on the canvas was
still imported. The Pages list still added, renamed, reordered,
duplicated and deleted pages. The Assets panel still placed instances,
swapped them on an Alt-drop, applied styles, changed Hide when
publishing, accepted library updates and added, removed or swapped
libraries.

All of these now go through dev_mode_refuses with the command they
would run, and show its note. A dropped document still opens as a file
of its own, and the drop overlay shows the note instead of "Drop to
import". A click in the Pages list still opens a page, and page rows
take no drags in Dev Mode.
This commit is contained in:
dotCooCoo
2026-10-24 02:57:11 -07:00
parent cb38f68c0c
commit 29ab17c222
7 changed files with 302 additions and 33 deletions
+32 -14
View File
@@ -346,6 +346,7 @@ fn updates_banner(app: &mut FigApp, ui: &mut Ui, w: f32, count: usize) {
let mut child =
ui.new_child(egui::UiBuilder::new().max_rect(Rect::from_min_max(pos2(r.max.x - 70.0, r.min.y + 6.0), pos2(r.max.x - 6.0, r.max.y))));
if crate::widgets::button(&mut child, tr("Update"), true, true).clicked()
&& !crate::menus::dev_mode_refuses(app, "library.acceptUpdates")
&& let Some(v) = app.run("library.acceptUpdates", json!({}))
{
let (c, s) = (v["components"].as_u64().unwrap_or(0), v["styles"].as_u64().unwrap_or(0));
@@ -666,7 +667,9 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
}
Some(Act::Style(src, style)) => apply_style(app, &src, &style),
Some(Act::Hide(id, on)) => {
app.run("component.hideWhenPublishing", json!({"ids": [id], "on": on}));
if !crate::menus::dev_mode_refuses(app, "component.hideWhenPublishing") {
app.run("component.hideWhenPublishing", json!({"ids": [id], "on": on}));
}
}
Some(Act::Toggle(id)) => {
let mut c = collapsed;
@@ -683,29 +686,39 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
/// Place component `id` of `src` centred on page point (`x`, `y`).
fn place(app: &mut FigApp, src: &Source, id: NodeId, x: f64, y: f64) {
match src {
Source::Local => app.run("instance.place", json!({"component": id, "x": x, "y": y})),
Source::Library(lib) => app.run("library.place", json!({"library": lib, "component": id, "x": x, "y": y})),
let (cmd, params) = match src {
Source::Local => ("instance.place", json!({"component": id, "x": x, "y": y})),
Source::Library(lib) => ("library.place", json!({"library": lib, "component": id, "x": x, "y": y})),
};
if !crate::menus::dev_mode_refuses(app, cmd) {
app.run(cmd, params);
}
}
fn apply_style(app: &mut FigApp, src: &Source, style: &str) {
let (cmd, params) = match src {
Source::Local => ("style.apply", json!({"style": style})),
Source::Library(lib) => ("library.applyStyle", json!({"library": lib, "style": style})),
};
if crate::menus::dev_mode_refuses(app, cmd) {
return;
}
if app.session.selection().is_empty() {
app.toast(tr("Select layers to apply the style to"));
return;
}
match src {
Source::Local => app.run("style.apply", json!({"style": style})),
Source::Library(lib) => app.run("library.applyStyle", json!({"library": lib, "style": style})),
};
app.run(cmd, params);
}
/// Swap the instance at page point (`x`, `y`) to component `id` of `src`.
fn swap_at(app: &mut FigApp, src: &Source, id: NodeId, x: f64, y: f64) {
match src {
Source::Local => app.run("instance.swapAt", json!({"component": id, "x": x, "y": y})),
Source::Library(lib) => app.run("library.swapAt", json!({"library": lib, "component": id, "x": x, "y": y})),
let (cmd, params) = match src {
Source::Local => ("instance.swapAt", json!({"component": id, "x": x, "y": y})),
Source::Library(lib) => ("library.swapAt", json!({"library": lib, "component": id, "x": x, "y": y})),
};
if !crate::menus::dev_mode_refuses(app, cmd) {
app.run(cmd, params);
}
}
/// The component set (by name) a dragged asset belongs to, in the document it comes from: the
@@ -1075,7 +1088,9 @@ fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) {
crate::widgets::text_field(ui, Id::new("asset_add_link"), "", w - PAD * 2.0, tr("Paste a team library link (a view link)"))
})
.inner;
if let Some(link) = pasted.map(|l| l.trim().to_string()).filter(|l| !l.is_empty()) {
if let Some(link) = pasted.map(|l| l.trim().to_string()).filter(|l| !l.is_empty())
&& !crate::menus::dev_mode_refuses(app, ADD)
{
app.run_background(&ui.ctx().clone(), ADD, json!({"path": link}), Box::new(|app, _, r| added(app, &r)));
}
if app.session.waiting_for(ADD) {
@@ -1087,10 +1102,13 @@ fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) {
}
});
}
if let Some(id) = remove {
if let Some(id) = remove
&& !crate::menus::dev_mode_refuses(app, "library.remove")
{
app.run("library.remove", json!({"id": id}));
}
if let Some((from, to)) = swap
&& !crate::menus::dev_mode_refuses(app, "library.swap")
&& let Some(r) = app.run("library.swap", json!({"from": from, "to": to}))
{
let moved = ["components", "styles", "variables"].iter().map(|k| r[*k].as_u64().unwrap_or(0)).sum::<u64>();
@@ -1101,7 +1119,7 @@ fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) {
trn("Swapped {n} assets; {missed} had no match and still use the old library", moved as usize, &[("missed", &missed)])
});
}
if add {
if add && !crate::menus::dev_mode_refuses(app, ADD) {
app.pick_open(tr("Add library"), &["figcraft"], |app, p| {
if let Some(r) = app.run(ADD, json!({"path": p.to_string_lossy()})) {
added(app, &r);
+17 -3
View File
@@ -183,9 +183,17 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
}
}
let own_cursor = input(app, ui, &resp);
// A browser names no dragged file until the drop, so documents there still say "import".
// A browser names no dragged file until the drop, so documents there still say "import", or
// in Dev Mode show the Dev Mode note, though they open when dropped.
let documents = ctx.input(|i| i.raw.hovered_files.iter().all(|f| f.path.as_deref().is_some_and(crate::is_document)));
crate::drop_overlay(&ctx, ctx.content_rect(), if documents { tr("Drop to open") } else { tr("Drop to import") });
let label = if documents {
tr("Drop to open")
} else if app.ui.right_tab == crate::RightTab::Inspect {
tr("Dev Mode only inspects: switch to Design (Shift+D) to edit")
} else {
tr("Drop to import")
};
crate::drop_overlay(&ctx, ctx.content_rect(), label);
crate::guides::end_released(&ctx);
crate::live::track_pointer(app, &ctx);
app.sync_preedit();
@@ -510,14 +518,20 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool {
return false;
}
// Dropped files import onto the canvas, behind a card while they do ([`crate::pending_open`]);
// a dropped document opens as a file of its own, as on the home screen.
// a dropped document opens as a file of its own, as on the home screen. Dev Mode imports
// nothing into the design, and says so once for the whole drop.
let dropped = ctx.input(|i| i.raw.dropped_files.clone());
let mut refused = false;
for f in dropped {
let path = f.path().to_path_buf();
if crate::is_document(&path) {
crate::pending_open::open_dropped(app, &f);
continue;
}
if refused || crate::menus::dev_mode_refuses(app, "file.import") {
refused = true;
continue;
}
let pt = hover_pos.map(|h| app.to_page(h)).unwrap_or_default();
let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped.png".into());
let (params, read) = if path.exists() {
+218
View File
@@ -164,6 +164,82 @@ fn dev_mode_layers_panel_selects_and_expands_but_edits_nothing() {
assert!(u.app.ui.rename.is_some_and(|r| r.id == a));
}
#[test]
fn dev_mode_pages_list_opens_pages_but_edits_none() {
use egui::{Event, Pos2};
let mut s = Session::new();
let home = s.page().unwrap();
s.execute("page.rename", &json!({"id": home, "name": "Home"})).unwrap();
let specs = id(&s.execute("page.add", &json!({"name": "Specs"})).unwrap());
s.execute("page.select", &json!({"id": home})).unwrap();
let mut u = crate::layers_drag_tests::Ui::new(s);
u.app.ui.right_tab = crate::RightTab::Inspect;
let shapes = |u: &mut crate::layers_drag_tests::Ui| crate::live_tests::flat(&u.frame_output(vec![]).shapes);
let s = shapes(&mut u);
let (rh, rs) = (crate::live_tests::text_center(&s, "Home"), crate::live_tests::text_center(&s, "Specs"));
// The add button, found by its name in the AccessKit tree.
u.ctx.enable_accesskit();
let nodes = u.frame_output(vec![]).platform_output.accesskit_update.unwrap().nodes;
let b = nodes.iter().find(|(_, n)| n.label() == Some("Add new page")).and_then(|(_, n)| n.bounds()).unwrap();
let add = Pos2::new(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32);
u.ctx.disable_accesskit();
// The pointer arrives first: a row that also takes drags counts a press there only then.
let click = |u: &mut crate::layers_drag_tests::Ui, p: Pos2| {
let button = |pressed| Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
u.frame(vec![Event::PointerMoved(p)]);
u.frame(vec![button(true)]);
u.frame(vec![button(false)]);
};
let rest = |u: &mut crate::layers_drag_tests::Ui| {
for _ in 0..30 {
u.frame(vec![]);
}
};
let pages = |u: &crate::layers_drag_tests::Ui| u.app.session.doc().unwrap().pages.clone();
let said_why = |u: &mut crate::layers_drag_tests::Ui| u.app.ui.toast.take().is_some_and(|(m, _)| m.contains("Shift+D"));
// A click opens the page.
click(&mut u, rs);
assert_eq!(u.app.session.page().unwrap(), specs);
// A double click doesn't rename, and says why.
click(&mut u, rs);
assert!(u.app.ui.rename_page.is_none(), "no rename");
assert!(said_why(&mut u));
rest(&mut u);
// Dragging a page doesn't reorder.
u.drag_to(rs, rh - egui::vec2(0.0, 10.0));
u.button(rh - egui::vec2(0.0, 10.0), false);
rest(&mut u);
assert_eq!(pages(&u), [home, specs], "not reordered");
// The add button adds no page, and says why.
click(&mut u, add);
assert_eq!(pages(&u), [home, specs], "no page added");
assert!(said_why(&mut u));
rest(&mut u);
// The row's Duplicate page and Delete page do nothing, and say why.
for item in ["Duplicate page", "Delete page"] {
u.frame(vec![
Event::PointerMoved(rs),
Event::PointerButton { pos: rs, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() },
]);
u.frame(vec![Event::PointerButton { pos: rs, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() }]);
let s = shapes(&mut u);
click(&mut u, crate::live_tests::text_center(&s, item));
assert_eq!(pages(&u), [home, specs], "{item} changes no page");
assert!(said_why(&mut u), "{item} says why");
rest(&mut u);
}
// In the design the double click renames and the add button adds a page.
u.app.ui.right_tab = crate::RightTab::Design;
u.frame(vec![]);
click(&mut u, rs);
click(&mut u, rs);
assert!(u.app.ui.rename_page.as_ref().is_some_and(|(p, _)| *p == specs));
u.app.ui.rename_page = None;
rest(&mut u);
click(&mut u, add);
assert_eq!(pages(&u).len(), 3, "a page is added");
}
#[test]
fn measurements_kept_on_the_canvas_draw_in_dev_mode_and_follow_their_layers() {
let mut s = Session::new();
@@ -186,3 +262,145 @@ fn measurements_kept_on_the_canvas_draw_in_dev_mode_and_follow_their_layers() {
u.frame(vec![]);
assert!(labels(&u).is_empty());
}
/// A file dropped with its contents and no file on disk.
#[derive(Debug)]
struct DroppedBytes(std::path::PathBuf, Vec<u8>);
impl egui::DroppedFile for DroppedBytes {
fn path(&self) -> &std::path::Path {
&self.0
}
fn bytes(&self) -> Result<Vec<u8>, String> {
Ok(self.1.clone())
}
}
/// Hover `names` over the canvas (one frame: what it shows), then drop them there with `bytes`.
fn drop_on_canvas(u: &mut crate::layers_drag_tests::Ui, names: &[&str], bytes: &[u8]) -> Vec<egui::Shape> {
let at = u.app.canvas.rect.center();
let path = |n: &str| std::path::Path::new("figcraft-dev-mode-drop").join(n);
let hovered = names.iter().map(|n| egui::HoveredFile { path: Some(path(n)), mime: String::new() }).collect();
let out = u.frame_raw(egui::RawInput { events: vec![egui::Event::PointerMoved(at)], hovered_files: hovered, ..Default::default() });
let files = names.iter().map(|n| std::sync::Arc::new(DroppedBytes(path(n), bytes.to_vec())) as egui::DroppedFileHandle).collect();
u.frame_raw(egui::RawInput { events: vec![egui::Event::PointerMoved(at)], dropped_files: files, ..Default::default() });
crate::live_tests::flat(&out.shapes)
}
#[test]
fn dev_mode_imports_no_dropped_file_into_the_design_and_says_why() {
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20"><rect width="40" height="20" fill="red"/></svg>"#;
let refusal = "Dev Mode only inspects: switch to Design (Shift+D) to edit";
let mut u = crate::layers_drag_tests::Ui::new(Session::new());
let layers = |u: &crate::layers_drag_tests::Ui| u.app.session.doc().unwrap().node(u.app.session.page().unwrap()).unwrap().children.len();
u.app.ui.right_tab = crate::RightTab::Inspect;
u.frame(vec![]);
let s = drop_on_canvas(&mut u, &["Logo.svg", "Mark.svg"], svg);
assert!(crate::live_tests::has_text(&s, refusal), "dragging images over the canvas says why they won't import");
assert!(u.app.ui.pending_opens.is_empty(), "nothing waits to import");
assert!(u.app.ui.toast.as_ref().is_some_and(|(m, _)| m == refusal));
for _ in 0..3 {
u.frame(vec![]);
}
assert_eq!(layers(&u), 0, "the design is unchanged");
// A dropped document still opens as a file of its own.
let s = drop_on_canvas(&mut u, &["Other.figcraft"], b"");
assert!(crate::live_tests::has_text(&s, "Drop to open"));
assert_eq!(u.app.ui.pending_opens.iter().map(|p| p.command).collect::<Vec<_>>(), ["file.open"]);
u.app.ui.pending_opens.clear();
// In the design the same drop imports.
u.app.ui.right_tab = crate::RightTab::Design;
u.frame(vec![]);
let s = drop_on_canvas(&mut u, &["Logo.svg"], svg);
assert!(crate::live_tests::has_text(&s, "Drop to import"));
assert_eq!(u.app.ui.pending_opens.iter().map(|p| p.command).collect::<Vec<_>>(), ["file.import"]);
for _ in 0..3 {
u.frame(vec![]);
}
assert_eq!(layers(&u), 1, "the image is imported");
}
#[test]
fn dev_mode_assets_panel_places_and_applies_nothing() {
let mut s = Session::new();
let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 80, "height": 40, "name": "Card"})).unwrap());
s.execute("component.create", &json!({"ids": [f]})).unwrap();
s.execute("style.create", &json!({"type": "paint", "name": "Brand", "value": [{"type": "solid", "color": "#0D99FF"}]})).unwrap();
let r = id(&s.execute("shape.rectangle", &json!({"x": 200, "y": 0, "width": 40, "height": 40})).unwrap());
s.execute("select.set", &json!({"ids": [r]})).unwrap();
let mut u = crate::layers_drag_tests::Ui::new(s);
u.app.ui.left_tab = crate::LeftTab::Assets;
u.app.ui.right_tab = crate::RightTab::Inspect;
let instances = |u: &crate::layers_drag_tests::Ui| {
u.app.session.doc().unwrap().nodes().filter(|n| matches!(n.kind, figcraft_doc::NodeKind::Instance { .. })).count()
};
let styled = |u: &crate::layers_drag_tests::Ui| u.app.session.doc().unwrap().node(r).is_some_and(|n| n.styles.fill.is_some());
let click = |u: &mut crate::layers_drag_tests::Ui, text: &str| {
let s = crate::live_tests::flat(&u.frame_output(vec![]).shapes);
let at = crate::live_tests::text_center(&s, text);
u.button(at, true);
u.button(at, false);
u.frame(vec![]);
};
click(&mut u, "Card");
click(&mut u, "Brand");
let s = crate::live_tests::flat(&u.frame_output(vec![]).shapes);
let (tile, canvas) = (crate::live_tests::text_center(&s, "Card"), u.app.canvas.rect.center());
u.drag_to(tile, canvas);
u.button(canvas, false);
u.frame(vec![]);
assert_eq!(instances(&u), 0, "a click on a component, or a drag onto the canvas, places no instance");
assert!(!styled(&u), "a click on a style doesn't apply it");
assert!(u.app.ui.toast.as_ref().is_some_and(|(m, _)| m.contains("Shift+D")));
// In the design both edit.
u.app.ui.right_tab = crate::RightTab::Design;
click(&mut u, "Card");
assert_eq!(instances(&u), 1);
u.drag_to(tile, canvas);
u.button(canvas, false);
u.frame(vec![]);
assert_eq!(instances(&u), 2, "the same drag places one");
u.app.session.execute("select.set", &json!({"ids": [r]})).unwrap();
click(&mut u, "Brand");
assert!(styled(&u));
}
#[test]
fn dev_mode_assets_panel_accepts_no_library_updates() {
let dir = std::env::temp_dir().join(format!("figcraft-dev-mode-updates-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("Library.figcraft");
let mut lib = Session::new();
lib.execute("style.create", &json!({"type": "paint", "name": "Brand", "value": [{"type": "solid", "color": "#0000FF"}]})).unwrap();
lib.execute("library.publish", &json!({})).unwrap();
lib.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap();
let mut s = Session::new();
let added = s.execute("library.add", &json!({"path": path.to_string_lossy()})).unwrap();
let r = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 40, "height": 40})).unwrap());
s.execute("select.set", &json!({"ids": [r]})).unwrap();
s.execute("library.applyStyle", &json!({"library": added["id"], "style": "Brand"})).unwrap();
lib.execute("style.update", &json!({"style": "Brand", "value": "#00FF00"})).unwrap();
lib.execute("library.publish", &json!({})).unwrap();
lib.execute("file.save", &json!({})).unwrap();
let mut u = crate::layers_drag_tests::Ui::new(s);
u.app.ui.left_tab = crate::LeftTab::Assets;
u.app.ui.right_tab = crate::RightTab::Inspect;
let updates = |u: &mut crate::layers_drag_tests::Ui| u.app.session.execute("library.updates", &json!({})).unwrap()["count"].as_u64();
let update = |u: &mut crate::layers_drag_tests::Ui| {
let s = crate::live_tests::flat(&u.frame_output(vec![]).shapes);
let at = crate::live_tests::text_center(&s, "Update");
u.button(at, true);
u.button(at, false);
u.frame(vec![]);
};
assert_eq!(updates(&mut u), Some(1));
update(&mut u);
assert_eq!(updates(&mut u), Some(1), "Update in Dev Mode pulls nothing in");
assert!(u.app.ui.toast.as_ref().is_some_and(|(m, _)| m.contains("Shift+D")));
u.app.ui.right_tab = crate::RightTab::Design;
update(&mut u);
assert_eq!(updates(&mut u), Some(0), "in the design it does");
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_dir(&dir);
}
+24 -8
View File
@@ -175,6 +175,11 @@ pub fn left_panel(app: &mut FigApp, ui: &mut Ui) {
fn pages(app: &mut FigApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
// Dev Mode only inspects: rows open pages, but don't add, rename, reorder, duplicate or delete.
let dev = app.ui.right_tab == crate::RightTab::Inspect;
if dev {
app.ui.rename_page = None;
}
let w = ui.available_width();
let (hr, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover());
let tresp = ui.interact(Rect::from_min_size(hr.min, vec2(w - 40.0, 40.0)), ui.id().with("pages_toggle"), Sense::click());
@@ -205,7 +210,9 @@ fn pages(app: &mut FigApp, ui: &mut Ui) {
icons::paint(ui.painter(), ar.shrink(5.0), Icon::Plus, t.icon);
if aresp.clicked() {
app.ui.pages_focus = app.session.ds().ok().map(|d| d.uid);
app.run("page.add", json!({}));
if !crate::menus::dev_mode_refuses(app, "page.add") {
app.run("page.add", json!({}));
}
}
if !app.ui.pages_open {
return;
@@ -239,7 +246,7 @@ fn pages(app: &mut FigApp, ui: &mut Ui) {
if filtering && (divider || !name.to_lowercase().contains(&filter)) {
continue;
}
let sense = if filtering { Sense::click() } else { Sense::click_and_drag() };
let sense = if filtering || dev { Sense::click() } else { Sense::click_and_drag() };
let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), sense);
if id == cur {
current_row_rendered = true;
@@ -247,7 +254,7 @@ fn pages(app: &mut FigApp, ui: &mut Ui) {
ui.scroll_to_rect(r, None);
}
}
if !filtering {
if !filtering && !dev {
reorder.row_by(&resp, k, r, || if divider { tr("Divider").to_string() } else { name.clone() });
}
a11y::list_item(&resp, if divider { tr("Divider") } else { &name }, id == cur);
@@ -287,7 +294,7 @@ fn pages(app: &mut FigApp, ui: &mut Ui) {
app.ui.pages_focus = Some(uid);
app.ui.layers_focus = false;
}
if resp.double_clicked() {
if resp.double_clicked() && !crate::menus::dev_mode_refuses(app, "page.rename") {
app.ui.rename_page = Some((id, name.clone()));
// The field takes the focus next frame, once it's drawn: focusing it now would put a
// node that isn't there yet in the screen reader tree, which AccessKit aborts on.
@@ -295,15 +302,21 @@ fn pages(app: &mut FigApp, ui: &mut Ui) {
}
crate::right::context_popup(&resp).show(|ui| {
if ui.button(tr("Rename page")).clicked() {
app.ui.rename_page = Some((id, name.clone()));
if !crate::menus::dev_mode_refuses(app, "page.rename") {
app.ui.rename_page = Some((id, name.clone()));
}
ui.close();
}
if ui.button(tr("Duplicate page")).clicked() {
app.run("page.duplicate", json!({"id": id}));
if !crate::menus::dev_mode_refuses(app, "page.duplicate") {
app.run("page.duplicate", json!({"id": id}));
}
ui.close();
}
if ui.button(tr("Delete page")).clicked() {
delete_page(app, id);
if !crate::menus::dev_mode_refuses(app, "page.delete") {
delete_page(app, id);
}
ui.close();
}
});
@@ -321,6 +334,7 @@ fn pages(app: &mut FigApp, ui: &mut Ui) {
}
if let Some((from, to)) = moved.inner
&& let Some((id, ..)) = pages.get(from)
&& !crate::menus::dev_mode_refuses(app, "page.move")
{
app.run("page.move", json!({"id": id, "index": to}));
}
@@ -328,7 +342,9 @@ fn pages(app: &mut FigApp, ui: &mut Ui) {
app.ui.rename_page = None;
app.run("page.rename", json!({"id": id, "name": new}));
}
if let Some((page, name)) = move_to {
if let Some((page, name)) = move_to
&& !crate::menus::dev_mode_refuses(app, "object.moveToPage")
{
let ids = dragged_layers(dragging, &sel);
if app.run("object.moveToPage", json!({"ids": ids, "page": page})).is_some() {
app.toast(trf("Moved to {name}", &[("name", &name)]));
+3 -4
View File
@@ -107,10 +107,9 @@ fn clipboard_png(app: &mut FigApp) -> Option<(Vec<u8>, u32, u32)> {
Some((out.into_inner(), w, h))
}
/// Dev Mode (the Inspect tab) refuses command `id` from the menus, shortcuts and clipboard: it
/// edits the design ([`figcraft_engine::devinspect::dev_mode_allows`]). Says so when it does.
/// shortcut: files dropped on the canvas still edit in Dev Mode; route them through here if people
/// trip over it.
/// Dev Mode (the Inspect tab) refuses command `id` from the menus, shortcuts, clipboard, the
/// Pages list, the layers panel, files dropped on the canvas and the Assets panel: it edits the
/// design ([`figcraft_engine::devinspect::dev_mode_allows`]). Says so when it does.
pub fn dev_mode_refuses(app: &mut FigApp, id: &str) -> bool {
let refused = app.ui.right_tab == crate::RightTab::Inspect && !figcraft_engine::devinspect::dev_mode_allows(id);
if refused {
+2 -1
View File
@@ -10,7 +10,7 @@ repository, third-party integrations. Remaining: 1–2 h.
| Feature | Status | Notes |
|---|---|---|
| Dev Mode toggle (Shift+D), Inspect panel | done | the canvas only inspects: presses select, pan and comment but never move, resize, draw or edit text and vectors; menus and shortcuts refuse design edits with a note (Dev Mode's annotations, statuses, dev resources, measurements, code connections and comments still work); a drawing tool chosen in the toolbar goes back to the design. The layers panel only selects and expands (no renaming, reordering, hiding or locking; a double click says why); files dropped on the canvas still edit (ours to tighten) |
| Dev Mode toggle (Shift+D), Inspect panel | done | the canvas only inspects: presses select, pan and comment but never move, resize, draw or edit text and vectors; menus and shortcuts refuse design edits with a note (Dev Mode's annotations, statuses, dev resources, measurements, code connections and comments still work); a drawing tool chosen in the toolbar goes back to the design. The layers panel only selects and expands (no renaming, reordering, hiding or locking; a double click says why), and the Pages list only opens pages (no adding, renaming, reordering, duplicating or deleting). Files dropped on the canvas aren't imported (a dropped document still opens as a file of its own), and the Assets panel places, swaps and applies nothing, doesn't change Hide when publishing, and doesn't accept library updates or add, remove or swap libraries; each says why |
| Properties with their styles and variables; redlines on hover | done | positions from the frame around the layer (groups aren't frames), rotation, radius (CSS corner order), opacity, blend; colours in hex, RGB, CSS, HSL or HSB (`inspect.properties {colorFormat}`, the Inspect tab's Color format) |
| Code: CSS, iOS (SwiftUI), Android (Compose) | done | plus HTML, React and Leptos (ours; Leptos: `#[component]` functions with `#[prop]` defaults, variant enums and `view!` markup); components take properties as parameters, instances are calls; CSS in px or rem (`inspect.code {unit, rootFontSize}`), with rotation, blend modes, gradient directions and kinds (linear, radial, conic), text alignment, case and decoration |
| Assets list with export | done | layers with export settings, icons (small, vector shapes only: SVG) and images (PNG); the icon rule is ours |
@@ -28,6 +28,7 @@ repository, third-party integrations. Remaining: 1–2 h.
| Date | Change | Summary |
|---|---|---|
| 2026-10-24 | minor | Dev Mode refuses design edits from files dropped on the canvas (a dropped document still opens as a file of its own, and the drop overlay shows the Dev Mode note for other files), from the Pages list's add, rename, reorder, duplicate and delete, and from the Assets panel's place, drag, Alt-drop swap, style, Hide when publishing, Update, and adding, removing and swapping libraries. Percentages and hours unchanged |
| 2026-10-24 | minor | Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Breadth 97% → 98%, ready 76% → 78%, 2–4 h → 1–2 h |
| 2026-10-15 | minor | Dev Mode tail (gap 16): generated layer trees (React, Leptos, SwiftUI, Compose) start with the import lines of the connected components they call, once each, also those in a component's definition, in slots an instance fills and placed by a connected template's instance swaps; Compare changes takes any saved version as the before (`dev.compare {version}`, the view's Compare with menu; frames that aren't ready for dev compare with versions). Behaviour suite (5 tests; 4 failed before the fixes): the Dev Mode canvas only inspects (no moving, resizing, drawing, text or vector editing; menus, shortcuts, nudges, opacity keys and paste refuse design edits with a note; toolbar drawing tools go back to the design), the ready list filters and badges Changed items, statuses are refused on layers inside frames. Read-only plugins run from the Inspect tab show their panels (the runtime's panels, reused). Breadth 96% → 97% (measurements kept on the canvas and the read-only layers panel counted as missing), ready 72% → 76%, 0–1 h → 2–4 h |
| 2026-10-12 | minor | Dev resources (gap 16): links for developers (a URL and a name) on any layer: `devResource.add` / `edit` / `remove` / `list`, undoable, saved and synced; an instance also lists its main component's (and that component's set's) links read-only with where they come from, and layers inside an instance show their main layer's; URLs are checked as web links (http or https, a host, no spaces, at most 2,048 characters) and never fetched (a link added without a name is named after its URL), opened only when clicked; the Inspect tab lists, adds, edits and removes them; `get_node` and `inspect_document` return them as `devResources`. The editor extension stays out of scope (the MCP server serves agents and editors). Dev Mode breadth 93% → 96%, ready 70% → 72%, 1–3 h → 0–1 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~304–593 h → ~303–591 h |
+6 -3
View File
@@ -392,9 +392,12 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
without them), drawn in Dev Mode as labelled redlines that follow the layers, and listed, placed
and removed in the Inspect tab. The layers panel is read-only in Dev Mode (rows select and
expand; no renaming, reordering, hiding or locking).
- **Missing:** files dropped on the canvas still edit in Dev Mode; publishing mappings from the
code repository with a CLI. An editor extension is out of scope: the MCP server already serves
design data, code and the connected snippets to agents and editors.
- **Landed (2026-10-24):** files dropped on the canvas, the Pages list and the Assets panel no
longer edit the design in Dev Mode (a dropped document still opens as a file of its own, and
the Pages list only opens pages).
- **Missing:** publishing mappings from the code repository with a CLI. An editor extension is out
of scope: the MCP server already serves design data, code and the connected snippets to agents
and editors.
- **Estimate:** 1–2 h.
### 17. Branching and merging