From aba296c6b88ecf1e8bb21ca7edd20dd96466f9e5 Mon Sep 17 00:00:00 2001 From: lileeei Date: Mon, 26 Oct 2026 12:23:06 +0800 Subject: [PATCH] M6: transliterate non-ASCII layer names into code-export class names (#1235) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit class_name runs layer names through deunicode (new dependency: pure Rust tables, zero deps, BSD-3-Clause, the slug crate's base) before its ASCII filter, so Korean, Cyrillic, Latin-diacritic and CJK names become readable ASCII classes in HTML/CSS/React/Leptos output, component roots and data-* variant attributes: 로그인 화면 → rogeuin-hwamyeon, кнопка → knopka, über → uber, 按钮 → an-niu (before, every one came out layer-, layer--2, ...). Symbols-only names take the node's kind (.text, .frame) instead of layer-, the 40-character cut lands on a word boundary, and a leading digit keeps the layer- prefix. SwiftUI/Compose type names are unchanged (their own sanitizer); ASCII-only names are byte-identical. html_codegen_tests updated and extended. --- Cargo.lock | 7 ++++ Cargo.toml | 3 ++ ROADMAP.md | 4 +- crates/engine/Cargo.toml | 1 + crates/engine/src/codegen.rs | 54 ++++++++++++++++++++++--- crates/engine/src/component_codegen.rs | 4 +- crates/engine/src/html_codegen_tests.rs | 48 +++++++++++++++++++++- docs/dev-mode-parity.md | 5 ++- 8 files changed, 114 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b8c6127b..219dc9bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1043,6 +1043,12 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + [[package]] name = "digest" version = "0.10.7" @@ -1569,6 +1575,7 @@ version = "0.11.1" dependencies = [ "base64 0.22.1", "craft-docpath", + "deunicode", "figcraft-collab", "figcraft-color", "figcraft-doc", diff --git a/Cargo.toml b/Cargo.toml index 9403f16b..91b7ffb0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,6 +63,9 @@ skrifa = "0.47" harfrust = "0.13" unicode-bidi = "0.3" unicode-segmentation = "1.13" +# Code export: layer names transliterated into ASCII identifiers (pure Rust data tables, zero +# dependencies, BSD-3-Clause, no_std so the web build keeps it too). +deunicode = "1.6" linesweeper = "0.4" zip = { version = "2", default-features = false, features = ["deflate"] } # `.fig` import: raw deflate chunks (already built for zip) and zstd chunks (pure Rust, MIT, wasm-safe). diff --git a/ROADMAP.md b/ROADMAP.md index 69d3bf73..a90376b3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Dev Mode measurements kept on the canvas and a read-only layers panel; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (non-ASCII layer names transliterate in code export (#1235); Dev Mode measurements kept on the canvas and a read-only layers panel; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -132,6 +132,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-26** · Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` runs the name through `deunicode` before its ASCII filter, so 로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber` in the HTML/CSS/React/Leptos class names, component roots and `data-*` variant attributes (SwiftUI/Compose type names unchanged); a name left with nothing (symbols only) takes the node's kind (`.text`, `.frame`) instead of `layer-`, and the 40-character cut lands on a word boundary. New dependency: deunicode (pure Rust tables, zero deps, BSD-3-Clause, the `slug` crate's base). `html_codegen_tests`. Numbers unchanged. - **2026-10-24** · Dev Mode tail (M6, gap 16): 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. Dev Mode 98% / 78%. - **2026-10-24** · Boards (M12): File ▸ New board makes a board, a file kind of its own saved in `.figcraft` and carried to people joining a live session. Sticky notes (8 colours, text that shrinks as the note grows, the author's name, Shift+Cmd/Ctrl+Enter for the next one), shapes with text (7 outlines), connectors that re-route when what they join moves (computed in the engine's normalize pass, so local edits, undo and others' edits agree), marker, highlighter and eraser, stamps with the placer's name on hover; a board toolbar, a contextual bar over the selection and the Design panel hidden until asked for; prototyping and Dev Mode refuse on boards. Design files and boards have their own marks (a pen nib, a note) on tabs, the home screen and the Open files list. Checked in the app, with two app instances in one live session, and through MCP; the web build compiles ([boards-parity](docs/boards-parity.md)). - **2026-10-15** · Auto-save (M1, #305): Preferences ▸ Save files automatically (`pref.autoSave`, off by default, desktop only) saves each open file that has a path 3 s after its last change, never during a gesture, a live text preview or while a version is shown; the file is encoded off the UI thread, a failed save says why and is tried again a minute later. Numbers unchanged. @@ -329,6 +330,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-26 | minor | Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` transliterates through the new `deunicode` dependency (pure Rust tables, zero deps, BSD-3-Clause), so Korean, Cyrillic, Latin-diacritic and CJK names become readable ASCII classes (`rogeuin-hwamyeon`, `knopka`, `uber`, `an-niu`) in HTML/CSS/React/Leptos output, component roots and `data-*` variant attributes; symbols-only names take the node's kind (`.text`) instead of `layer-`, and the 40-character cut lands on a word boundary. `html_codegen_tests` updated and extended. Numbers unchanged | | 2026-10-24 | minor | Dev Mode tail (M6, gap 16): 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. Dev Mode 97% → 98% breadth, 76% → 78% ready, 2–4 h → 1–2 h; headline numbers unchanged at this rounding | | 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Boards row added outside the weighted numbers; headline numbers unchanged at this rounding | | 2026-10-15 | minor | Auto-save (#305, #296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only, the browser has no files to save to) saves each open file that has a path 3 s after its last change, so steady editing doesn't save every few seconds, and never during a gesture, a live text preview or while a version is shown. The file is encoded on a thread of its own and written as of that moment (edits made meanwhile stay unsaved); a failed save says why in a toast and is tried again a minute later, and version history checkpoints keep coming with it on. Numbers unchanged | diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 545d872c..cd5b5d50 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -28,6 +28,7 @@ thiserror = { workspace = true } log = { workspace = true } base64 = { workspace = true } regex = { workspace = true } +deunicode = { workspace = true } web-time = "1.1.0" [dev-dependencies] diff --git a/crates/engine/src/codegen.rs b/crates/engine/src/codegen.rs index 7ed81b30..961ab33e 100644 --- a/crates/engine/src/codegen.rs +++ b/crates/engine/src/codegen.rs @@ -594,18 +594,60 @@ fn css_gradient(g: &figcraft_color::Gradient, w: f64, h: f64) -> String { // ------------------------------------------------------------------ HTML and React markup -/// A CSS class name from a layer name ("Card / Title" → "card-title"), unique in `used`. -pub(crate) fn class_name(name: &str, used: &mut Vec) -> String { +/// A word for the node's kind, naming layers whose name transliterates to nothing ("###" → +/// `.text`, `.frame`), instead of the anonymous `layer-`. +fn kind_word(n: &Node) -> &str { + match &n.kind { + NodeKind::Page { .. } => "page", + NodeKind::Frame => "frame", + NodeKind::Group => "group", + NodeKind::Section { .. } => "section", + NodeKind::Rectangle => "rectangle", + NodeKind::Ellipse { .. } => "ellipse", + NodeKind::Polygon { .. } => "polygon", + NodeKind::Star { .. } => "star", + NodeKind::Line => "line", + NodeKind::Vector { .. } => "vector", + NodeKind::Text(_) => "text", + NodeKind::BooleanOp { .. } => "boolean", + NodeKind::Component { .. } | NodeKind::ComponentSet { .. } => "component", + NodeKind::Instance { .. } => "instance", + NodeKind::Slice => "slice", + } +} + +/// A CSS class name from a layer name ("Card / Title" → "card-title"), unique in `used`. The name +/// is transliterated to ASCII first, so non-Latin names stay readable: 로그인 화면 → +/// `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber` (per-codepoint transliteration, the slug +/// libraries' way); a name left with nothing (symbols only) takes `kind`, and one starting with a +/// digit keeps the `layer-` prefix. +pub(crate) fn class_name(name: &str, kind: &str, used: &mut Vec) -> String { let mut base = String::new(); - for c in name.chars().flat_map(char::to_lowercase) { + for c in deunicode::deunicode(name).chars().flat_map(char::to_lowercase) { if c.is_ascii_alphanumeric() { base.push(c); } else if !base.ends_with('-') && !base.is_empty() { base.push('-'); } } - let base = base.trim_end_matches('-').chars().take(40).collect::(); - let base = if base.is_empty() || base.starts_with(|c: char| c.is_ascii_digit()) { format!("layer-{base}") } else { base }; + // The 40-character cut lands on a word boundary when there is one past the first word. + let base = if base.chars().count() > 40 { + let cut: String = base.chars().take(40).collect(); + match cut.rfind('-') { + Some(i) if i > 0 => cut[..i].to_string(), + _ => cut, + } + } else { + base + }; + let base = base.trim_end_matches('-'); + let base = if base.is_empty() { + kind.to_string() + } else if base.starts_with(|c: char| c.is_ascii_digit()) { + format!("layer-{base}") + } else { + base.to_string() + }; let name = (1..).map(|k| if k == 1 { base.clone() } else { format!("{base}-{k}") }).find(|n| !used.contains(n)).unwrap_or(base); used.push(name.clone()); name @@ -726,7 +768,7 @@ impl<'c, 'a> Markup<'c, 'a> { self.used.push(c.clone()); c } - None => class_name(&n.name, &mut self.used), + None => class_name(&n.name, kind_word(n), &mut self.used), }; let pad = self.pad(depth); let attr = if self.lang == CodeLang::React { "className" } else { "class" }; diff --git a/crates/engine/src/component_codegen.rs b/crates/engine/src/component_codegen.rs index a8217be1..e623b5f9 100644 --- a/crates/engine/src/component_codegen.rs +++ b/crates/engine/src/component_codegen.rs @@ -618,7 +618,7 @@ pub fn definition(doc: &Document, c: &Component, lang: CodeLang) -> String { /// `data-*` attribute names for variant parameters, unique among them (in order). fn data_attrs(variants: &[&Param]) -> Vec { let mut used = Vec::new(); - variants.iter().map(|p| format!("data-{}", class_name(&p.prop, &mut used))).collect() + variants.iter().map(|p| format!("data-{}", class_name(&p.prop, "prop", &mut used))).collect() } /// `c`'s markup in React or Leptos (two levels in) and its stylesheet (variables as custom @@ -626,7 +626,7 @@ fn data_attrs(variants: &[&Param]) -> Vec { fn web(doc: &Document, c: &Component, lang: CodeLang) -> (String, String, String) { let ctx = Ctx { doc, root: c.comp, comp: Some(c), base: 2 }; let root_name = c.set.and_then(|s| doc.node(s)).or_else(|| doc.node(c.comp)).map(|n| n.name.clone()).unwrap_or_default(); - let root_class = class_name(&root_name, &mut Vec::new()); + let root_class = class_name(&root_name, "component", &mut Vec::new()); let variants: Vec<&Param> = c.params.iter().filter(|p| matches!(p.kind, ParamKind::Variant(_))).collect(); let attr_names = data_attrs(&variants); let pass = |root: NodeId| { diff --git a/crates/engine/src/html_codegen_tests.rs b/crates/engine/src/html_codegen_tests.rs index b7fbb9ac..cd1318ee 100644 --- a/crates/engine/src/html_codegen_tests.rs +++ b/crates/engine/src/html_codegen_tests.rs @@ -53,11 +53,57 @@ fn class_names_are_valid_and_deep_trees_are_bounded() { parent = id(s.execute("frame.create", &json!({"x": 1, "y": 1, "width": 90, "height": 90, "parent": parent})).unwrap()); } let code = code_for(s.doc().unwrap(), root, CodeLang::Html); - assert!(code.starts_with("
"), "{}", code.lines().next().unwrap()); + assert!(code.starts_with("
"), "{}", code.lines().next().unwrap()); // Nesting stops at the depth limit instead of running away. assert!(code.matches(""), "{code}"); + assert!(code.contains("

") && code.contains("

"), "{code}"); +} + +#[test] +fn other_scripts_and_symbol_names_transliterate_or_fall_back_to_the_kind() { + let mut s = Session::new(); + let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100, "name": "按钮"})).unwrap()); + let k = id(s.execute("text.create", &json!({"text": "Ok", "x": 0, "y": 0, "parent": f})).unwrap()); + s.execute("node.set", &json!({"id": k, "props": {"name": "кнопка"}})).unwrap(); + let hash = id(s.execute("text.create", &json!({"text": "###", "x": 0, "y": 20, "parent": f})).unwrap()); + s.execute("node.set", &json!({"id": hash, "props": {"name": "###"}})).unwrap(); + let digit = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 40, "width": 10, "height": 10, "parent": f})).unwrap()); + s.execute("node.set", &json!({"id": digit, "props": {"name": "4버튼"}})).unwrap(); + let code = code_for(s.doc().unwrap(), f, CodeLang::Html); + assert!(code.contains("

"), "Chinese: {code}"); + assert!(code.contains("

"), "Cyrillic: {code}"); + assert!(code.contains("

"), "symbols fall back to the kind: {code}"); + assert!(code.contains("layer-4beoteun"), "a leading digit keeps the layer- prefix: {code}"); +} + +#[test] +fn long_names_cut_at_a_word_boundary() { + let mut s = Session::new(); + let long = format!("{}-{}", "a".repeat(30), "b".repeat(30)); + let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 10, "height": 10, "name": long})).unwrap()); + let code = code_for(s.doc().unwrap(), f, CodeLang::Html); + let word = "a".repeat(30); + assert!(code.contains(&format!("

")), "cut back to the first word: {code}"); + let unbroken = "c".repeat(50); + let g = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 10, "height": 10, "name": unbroken})).unwrap()); + let code = code_for(s.doc().unwrap(), g, CodeLang::Html); + assert!(code.contains(&format!("class=\"{}\"", "c".repeat(40))), "one long word cuts at 40: {code}"); +} + #[test] fn leptos_wraps_the_markup_in_a_component_with_text_as_string_literals() { let mut s = Session::new(); diff --git a/docs/dev-mode-parity.md b/docs/dev-mode-parity.md index 9565730e..3368153e 100644 --- a/docs/dev-mode-parity.md +++ b/docs/dev-mode-parity.md @@ -1,6 +1,6 @@ # Dev Mode parity -> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** minor (measurements kept on the canvas; a read-only layers panel in Dev Mode; earlier: import lines in generated layer trees; compare with any saved version; Dev Mode behaviour suite: an inspect-only canvas, Changed in the ready list, statuses only on frames and sections; plugin panels from the Inspect tab) · **Target:** Figma Design Dev Mode (desktop app 126.10.7) +> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-26 · **Change:** minor (class names transliterate non-ASCII layer names (#1235); measurements kept on the canvas; a read-only layers panel in Dev Mode; earlier: import lines in generated layer trees; compare with any saved version; Dev Mode behaviour suite: an inspect-only canvas, Changed in the ready list, statuses only on frames and sections; plugin panels from the Inspect tab) · **Target:** Figma Design Dev Mode (desktop app 126.10.7) **Breadth ~98%, ready ~78% (estimated).** The inspect surface is broad, components connect to the code components of a repository (with their imports, also inside generated layer trees), @@ -12,7 +12,7 @@ repository, third-party integrations. Remaining: 1–2 h. |---|---|---| | 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 | +| 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; class names from layer names transliterate non-ASCII scripts into readable ASCII (로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber`; symbols-only names take the node's kind, `.text`), and the 40-character cut lands on a word boundary (#1235) | | Assets list with export | done | layers with export settings, icons (small, vector shapes only: SVG) and images (PNG); the icon rule is ours | | Annotations: pinned properties, categories, coloured callouts, filter | done | | | Ready for dev list, statuses, "Changed" after edits | done | the list filters Ready for dev, Changed, Completed or any, and badges changed ones (`inspect.readyForDev {status: changed}`, `changed` per item); only sections and frames at the top of a page or section take a status (others are refused) | @@ -28,6 +28,7 @@ repository, third-party integrations. Remaining: 1–2 h. | Date | Change | Summary | |---|---|---| +| 2026-10-26 | minor | Class names from layer names transliterate non-ASCII scripts (#1235): `class_name` runs names through `deunicode` (new dependency: pure Rust tables, zero deps, BSD-3-Clause), so Korean, Cyrillic, Latin-diacritic and CJK names become readable ASCII classes in HTML/CSS/React/Leptos output, component roots and `data-*` variant attributes (`rogeuin-hwamyeon`, `knopka`, `uber`, `an-niu`; before, every one of them came out `layer-`); symbols-only names take the node's kind (`.text`, `.frame`) instead of `layer-`, and the 40-character cut lands on a word boundary. ASCII-only names are unchanged. `html_codegen_tests` updated (its diacritics expectation improves from `layer-1-n-code-name` to `layer-1-unicode-name`) and extended. Percentages and hours unchanged | | 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 |