From bf3a45d938c67db07cda5635a866499bc910101a Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 10 Oct 2026 04:20:23 +0700 Subject: [PATCH] M1: sections and frames: resize to fit, section colours, ready-for-dev status (#23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Object ▸ Resize to fit (Alt+Shift+Cmd+R, `object.resizeToFit`): frames, sections and components shrink or grow to their visible contents without moving them on the page; auto layout frames switch to hug instead. - Sections get colour swatches (light tints) in the Design panel. - Frames and sections get a handoff status (`devStatus`: ready for dev / completed) set from the panel and shown as a badge next to their name on the canvas; `node.get` reports it. Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/doc/src/node.rs | 37 ++++++++++++++ crates/engine/src/catalog.rs | 1 + crates/engine/src/cmd/props.rs | 8 +++ crates/engine/src/cmd/structure.rs | 54 ++++++++++++++++++++ crates/engine/src/inspect.rs | 3 ++ crates/engine/src/lib.rs | 2 + crates/engine/src/section_tests.rs | 52 +++++++++++++++++++ crates/ui-egui/src/canvas.rs | 16 ++++++ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/right.rs | 5 +- crates/ui-egui/src/section_panel.rs | 78 +++++++++++++++++++++++++++++ docs/mcp.md | 2 +- docs/parity.md | 4 +- 14 files changed, 260 insertions(+), 5 deletions(-) create mode 100644 crates/engine/src/section_tests.rs create mode 100644 crates/ui-egui/src/section_panel.rs diff --git a/ROADMAP.md b/ROADMAP.md index ee92fac..74dc01f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -140,7 +140,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent). | Paint | 7 | 65% | colour profiles (Display P3), paint style picker polish, image adjustment presets | 6–10 | | Strokes | 3 | 70% | stroke styles, variable width, arrowhead previews in the menus | 3–5 | | Effects | 4 | 70% | glass/texture fidelity, shadow spread on non-rects exactness | 4–7 | -| Frames, groups, sections, masks | 5 | 70% | section tooling (section presets, auto-size to contents, section-level properties) | 3–5 | +| Frames, groups, sections, masks | 5 | 75% | hiding section contents UI, section-level prototype/dev affordances | 2–4 | | Auto layout & constraints | 9 | 60% | grid/wrap-aware drag-reorder, drag to grid cell, nested hug/fill edge cases | 6–10 | | Text | 8 | 60% | right-to-left/bidi, text style picker depth, axis hover preview | 8–12 | | Components & variants | 9 | 60% | libraries, slots, descriptions, nested override edge cases | 10–15 | diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 0ecb1eb..a7985c4 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -147,6 +147,37 @@ impl MaskType { } } +/// Handoff status of a frame or section. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum DevStatus { + #[default] + None, + ReadyForDev, + Completed, +} + +impl DevStatus { + pub const ALL: [DevStatus; 3] = [DevStatus::None, DevStatus::ReadyForDev, DevStatus::Completed]; + pub fn is_none(&self) -> bool { + *self == DevStatus::None + } + pub fn id(self) -> &'static str { + match self { + DevStatus::None => "none", + DevStatus::ReadyForDev => "readyForDev", + DevStatus::Completed => "completed", + } + } + pub fn label(self) -> &'static str { + match self { + DevStatus::None => "No status", + DevStatus::ReadyForDev => "Ready for dev", + DevStatus::Completed => "Completed", + } + } +} + /// A ruler guide on a page or frame. #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] pub struct Guide { @@ -345,6 +376,10 @@ pub struct Node { /// A nested instance inside a main component whose properties show on the main's instances. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub exposed: bool, + + /// Handoff status of a frame or section (shown as a badge next to its name). + #[serde(default, skip_serializing_if = "DevStatus::is_none")] + pub dev_status: DevStatus, /// Inside an instance: property groups overridden locally ("fills", "characters", …). #[serde(default)] pub overrides: BTreeSet, @@ -433,6 +468,8 @@ impl Node { constrain_proportions: false, source: None, exposed: false, + + dev_status: DevStatus::None, overrides: BTreeSet::new(), prop_refs: BTreeMap::new(), plugin_data: BTreeMap::new(), diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index ab245b7..96cdab0 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -113,6 +113,7 @@ pub static MENU: &[MenuItem] = &[ m!("Object", "Group selection", "object.group", "Cmd+G"), m!("Object", "Ungroup selection", "object.ungroup", "Shift+Cmd+G"), m!("Object", "Remove frame", "object.removeFrame", ""), + m!("Object", "Resize to fit", "object.resizeToFit", "Alt+Shift+Cmd+R"), m!("Object", SEP, "", ""), m!("Object", "Add auto layout", "layout.add", "Shift+A"), m!("Object", "Remove auto layout", "layout.remove", "Alt+Shift+A"), diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index facd4ea..4c93096 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -289,6 +289,14 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map doc.get_mut(id)?.effects = serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("effects: {e}")))?, "clip" | "clipsContent" => doc.get_mut(id)?.clip = v.as_bool().ok_or_else(|| bad(cmd, "`clip` must be a boolean"))?, "isMask" => doc.get_mut(id)?.is_mask = v.as_bool().unwrap_or(false), + "devStatus" => { + let st = if v.is_null() { + Some(DevStatus::None) + } else { + v.as_str().and_then(|s| DevStatus::ALL.into_iter().find(|d| d.id().eq_ignore_ascii_case(s))) + }; + doc.get_mut(id)?.dev_status = st.ok_or_else(|| bad(cmd, "devStatus: none|readyForDev|completed"))?; + } "maskType" => { let t = v.as_str().and_then(|s| MaskType::ALL.into_iter().find(|m| m.id().eq_ignore_ascii_case(s))); doc.get_mut(id)?.mask_type = t.ok_or_else(|| bad(cmd, "maskType: alpha|vector|luminance"))?; diff --git a/crates/engine/src/cmd/structure.rs b/crates/engine/src/cmd/structure.rs index 1d2878b..fe4e357 100644 --- a/crates/engine/src/cmd/structure.rs +++ b/crates/engine/src/cmd/structure.rs @@ -16,6 +16,13 @@ pub fn specs() -> Vec { spec!("object.ungroup", "Ungroup selection", "{ids?}", true, ungroup), spec!("object.frameSelection", "Frame selection", "{ids?, name?}", true, |s, p| wrap(s, p, Wrap::Frame)), spec!("object.removeFrame", "Remove frame", "{ids?}", true, ungroup), + spec!( + "object.resizeToFit", + "Resize to fit", + "{ids?} (frames, sections and components shrink or grow to their contents; the contents stay put)", + true, + resize_to_fit + ), spec!("object.mask", "Use as mask", "{ids?}", true, mask), spec!("object.releaseMask", "Release mask", "{ids?}", true, |s, p| { for i in req_targets("object.releaseMask", s, p)? { @@ -477,3 +484,50 @@ fn join(s: &mut Session, p: &Value) -> Result { } flatten(s, &json!({"ids": ids})) } + +/// Fit containers to their visible children's bounds, keeping the children where they are on the +/// page (auto layout frames hug instead); rotated containers fit in their own axes. +fn resize_to_fit(s: &mut Session, p: &Value) -> Result { + let cmd = "object.resizeToFit"; + let ids = req_targets(cmd, s, p)?; + let doc = s.doc_mut()?; + let mut fitted = Vec::new(); + for id in ids { + let Some(n) = doc.node(id).cloned() else { continue }; + let container = n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. }); + if !container || n.children.is_empty() || matches!(n.kind, NodeKind::Instance { .. }) { + continue; + } + // Auto layout frames fit by hugging their contents. + if n.layout.is_some() { + let node = doc.get_mut(id)?; + node.child_layout.sizing_h = Sizing::Hug; + node.child_layout.sizing_v = Sizing::Hug; + fitted.push(id); + continue; + } + // Children's bounds in the container's own coordinates. + let mut b: Option = None; + for c in n.children.iter().filter_map(|c| doc.node(*c)).filter(|c| c.visible) { + let r = figcraft_geom::transform_rect_bbox(c.transform, c.local_rect()); + if r.x0.is_finite() && r.y0.is_finite() && r.x1.is_finite() && r.y1.is_finite() { + b = Some(b.map_or(r, |u| u.union(r))); + } + } + let Some(b) = b else { continue }; + let (w, h) = (b.width().max(0.01), b.height().max(0.01)); + // Move the container by the offset, and its children back by it. + let shift = Affine::translate((b.x0, b.y0)); + let node = doc.get_mut(id)?; + node.transform *= shift; + node.width = w; + node.height = h; + for c in &n.children { + if let Ok(cn) = doc.get_mut(*c) { + cn.transform = shift.inverse() * cn.transform; + } + } + fitted.push(id); + } + Ok(json!({"ids": fitted})) +} diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 3701227..da89606 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -101,6 +101,9 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value { if n.kind.is_frame_like() { o.insert("clip".into(), json!(n.clip)); } + if !n.dev_status.is_none() { + o.insert("devStatus".into(), json!(n.dev_status.id())); + } if n.is_mask { o.insert("isMask".into(), json!(true)); o.insert("maskType".into(), json!(n.mask_type.id())); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7105540..7bd01f8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -756,6 +756,8 @@ mod frame_tests; #[cfg(test)] mod mask_tests; #[cfg(test)] +mod section_tests; +#[cfg(test)] mod style_tests; #[cfg(test)] mod tests; diff --git a/crates/engine/src/section_tests.rs b/crates/engine/src/section_tests.rs new file mode 100644 index 0000000..3c61712 --- /dev/null +++ b/crates/engine/src/section_tests.rs @@ -0,0 +1,52 @@ +//! Resize to fit and handoff status. + +use serde_json::json; + +use crate::Session; +use crate::params::id_of; + +fn id(v: serde_json::Value) -> figcraft_doc::NodeId { + id_of(&v["id"]).unwrap() +} + +#[test] +fn resize_to_fit_keeps_contents_in_place() { + let mut s = Session::new(); + let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 400, "height": 300})).unwrap()); + let a = id(s.execute("shape.rectangle", &json!({"x": 50, "y": 40, "width": 100, "height": 50, "parent": f})).unwrap()); + let b = id(s.execute("shape.ellipse", &json!({"x": 120, "y": 100, "width": 30, "height": 30, "parent": f})).unwrap()); + let before = (s.doc().unwrap().world_bounds(a).unwrap(), s.doc().unwrap().world_bounds(b).unwrap()); + s.execute("object.resizeToFit", &json!({"ids": [f]})).unwrap(); + let doc = s.doc().unwrap(); + let fb = doc.world_bounds(f).unwrap(); + assert_eq!((fb.x0, fb.y0, fb.width(), fb.height()), (50.0, 40.0, 100.0, 90.0)); + assert_eq!((doc.world_bounds(a).unwrap(), doc.world_bounds(b).unwrap()), before, "contents don't move on the page"); + s.undo().unwrap(); + assert_eq!(s.doc().unwrap().world_bounds(f).unwrap().width(), 400.0); + // Sections too; empty containers and plain shapes are left alone. + let sec = id(s.execute("node.create", &json!({"type": "section", "x": 600, "y": 0, "width": 500, "height": 500})).unwrap()); + s.execute("node.reparent", &json!({"ids": [f], "parent": sec})).unwrap(); + let empty = id(s.execute("frame.create", &json!({"x": 0, "y": 600, "width": 80, "height": 80})).unwrap()); + let r = s.execute("object.resizeToFit", &json!({"ids": [sec, empty, a]})).unwrap(); + assert_eq!(r["ids"].as_array().unwrap().len(), 1); + assert_eq!(s.doc().unwrap().world_bounds(sec).unwrap().width(), 400.0, "section wraps the frame"); + // Auto layout frames hug instead. + s.execute("layout.add", &json!({"ids": [f]})).unwrap(); + s.execute("node.set", &json!({"id": f, "props": {"width": 600}})).unwrap(); + s.execute("object.resizeToFit", &json!({"ids": [f]})).unwrap(); + let n = s.doc().unwrap().node(f).unwrap().clone(); + assert_eq!(n.child_layout.sizing_h, figcraft_doc::Sizing::Hug); + assert!(n.width < 600.0); +} + +#[test] +fn dev_status_prop() { + let mut s = Session::new(); + let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 40, "height": 40})).unwrap()); + s.execute("node.set", &json!({"id": f, "props": {"devStatus": "readyForDev"}})).unwrap(); + assert_eq!(s.doc().unwrap().node(f).unwrap().dev_status, figcraft_doc::DevStatus::ReadyForDev); + assert_eq!(s.execute("node.get", &json!({"id": f})).unwrap()["devStatus"], json!("readyForDev")); + s.execute("node.set", &json!({"id": f, "props": {"devStatus": null}})).unwrap(); + assert!(s.doc().unwrap().node(f).unwrap().dev_status.is_none()); + assert!(s.execute("node.set", &json!({"id": f, "props": {"devStatus": "shipped"}})).is_err()); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3edd048..8257caa 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -505,6 +505,7 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) { let r = Rect::from_min_size(tl + vec2(0.0, 0.0), galley.size() + vec2(16.0, 8.0)); painter.rect_filled(r, CornerRadius::same(4), if selected { ACCENT } else { t.hover }); painter.galley(r.min + vec2(8.0, 4.0), galley, if selected { Color32::WHITE } else { t.text }); + status_badge(painter, n.dev_status, pos2(r.max.x + 6.0, r.center().y)); } else { let max_w = (b.width() * zoom) as f32; let mut x = tl.x; @@ -514,7 +515,9 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) { } let galley = painter.layout(n.name.clone(), regular(11.0), col, max_w.max(20.0)); if galley.rows.len() <= 1 { + let end = x + galley.size().x; painter.galley(pos2(x, tl.y - 16.0), galley, col); + status_badge(painter, n.dev_status, pos2(end + 6.0, tl.y - 9.0)); } } } @@ -815,6 +818,19 @@ fn hatch(polys: &[Vec], gap: f32) -> Vec<[Pos2; 2]> { out } +/// A frame's or section's handoff status as a small pill left-centred on `at`. +fn status_badge(painter: &egui::Painter, status: figcraft_doc::DevStatus, at: Pos2) { + let (label, fill) = match status { + figcraft_doc::DevStatus::None => return, + figcraft_doc::DevStatus::ReadyForDev => ("Ready for dev", Color32::from_rgb(0x14, 0xAE, 0x5C)), + figcraft_doc::DevStatus::Completed => ("Completed", Color32::from_rgb(0x6E, 0x6E, 0x73)), + }; + let galley = painter.layout_no_wrap(label.to_string(), semibold(10.0), Color32::WHITE); + let r = Rect::from_min_size(pos2(at.x, at.y - galley.size().y / 2.0 - 2.0), galley.size() + vec2(10.0, 4.0)); + painter.rect_filled(r, CornerRadius::same(4), fill); + painter.galley(r.min + vec2(5.0, 2.0), galley, Color32::WHITE); +} + fn rulers(app: &FigApp, painter: &egui::Painter, t: &Tokens, doc: &Document, sel: &[NodeId]) { let r = app.canvas.rect; let size = 20.0; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index cb5c36c..90b704c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -26,6 +26,7 @@ pub mod proto_canvas; pub mod proto_panel; pub mod render_worker; pub mod right; +pub mod section_panel; pub mod shortcuts; pub mod tabs; pub mod theme; diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index 5ad3f28..fbd6e34 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -204,7 +204,10 @@ fn design(app: &mut FigApp, ui: &mut Ui) { widgets::separator(ui); match vector_edit { Some(ve) => crate::vector_panel::points(app, ui, &doc, &ve), - None => position(app, ui, &doc, &sel), + None => { + crate::section_panel::section(app, ui, &doc, &sel); + position(app, ui, &doc, &sel); + } } widgets::separator(ui); crate::layout_panel::layout_section(app, ui, &doc, &sel); diff --git a/crates/ui-egui/src/section_panel.rs b/crates/ui-egui/src/section_panel.rs new file mode 100644 index 0000000..da9136b --- /dev/null +++ b/crates/ui-egui/src/section_panel.rs @@ -0,0 +1,78 @@ +//! Design panel extras for frames and sections: section colours, handoff status and resize to +//! fit. + +use egui::{CornerRadius, Sense, Stroke, Ui, vec2}; +use figcraft_doc::{DevStatus, Document, NodeId, NodeKind}; +use serde_json::json; + +use crate::FigApp; +use crate::icons::Icon; +use crate::right::{common, menu_item, row}; +use crate::widgets; + +/// Section fill presets: light tints that keep dark text readable. +const SECTION_COLORS: [(&str, &str); 8] = [ + ("White", "#FFFFFF"), + ("Gray", "#EDEDED"), + ("Red", "#FFE0DE"), + ("Orange", "#FFE8D1"), + ("Yellow", "#FFF4C2"), + ("Green", "#DDF5E3"), + ("Blue", "#DCEBFF"), + ("Violet", "#EBE3FF"), +]; + +pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { + let is_section = |i: &NodeId| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Section { .. })); + let top_frame = |i: &NodeId| { + doc.node(*i).is_some_and(|n| { + n.kind.is_frame_like() && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.is_page() || matches!(p.kind, NodeKind::Section { .. })) + }) + }; + let sections = !sel.is_empty() && sel.iter().all(is_section); + if sel.is_empty() || !sel.iter().all(|i| is_section(i) || top_frame(i)) { + return; + } + let t = crate::theme::Tokens::get(ui.ctx()); + let ids = sel.to_vec(); + let col = widgets::col_width(ui.available_width()); + if sections { + widgets::sublabels(ui, "Section color", None); + row(ui, 32.0, |ui| { + ui.spacing_mut().item_spacing.x = 6.0; + let current = common(doc, sel, |n| n.fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex())); + for (name, hex) in SECTION_COLORS { + let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click()); + let c = figcraft_color::Color::from_hex(hex).unwrap_or(figcraft_color::Color::WHITE); + ui.painter().rect_filled(r, CornerRadius::same(9), crate::theme::c32(c)); + let on = current.as_ref().and_then(|c| c.as_ref()).is_some_and(|h| h.eq_ignore_ascii_case(hex.trim_start_matches('#'))); + let stroke = if on { Stroke::new(2.0, crate::theme::ACCENT) } else { Stroke::new(1.0, t.border) }; + ui.painter().rect_stroke(r, CornerRadius::same(9), stroke, egui::StrokeKind::Inside); + if resp.on_hover_text(name).clicked() { + app.set_props(&ids, json!({"fills": [hex]})); + } + } + }); + } + widgets::sublabels(ui, "Status", None); + row(ui, 32.0, |ui| { + let st = common(doc, sel, |n| n.dev_status); + let resp = widgets::dropdown(ui, st.map(DevStatus::label).unwrap_or("Mixed"), col * 2.0 + 8.0, Some(Icon::Code)); + egui::Popup::menu(&resp).show(|ui| { + for d in DevStatus::ALL { + if menu_item(ui, d.label(), "") { + app.set_props(&ids, json!({"devStatus": d.id()})); + ui.close(); + } + } + }); + }); + row(ui, 32.0, |ui| { + let fit = ui.add_sized(vec2(col * 2.0 + 8.0, widgets::FIELD_H), egui::Button::new("Resize to fit")).on_hover_text("Fit to contents ⌥⇧⌘R"); + if fit.clicked() { + app.run("object.resizeToFit", json!({"ids": ids})); + } + }); + ui.add_space(4.0); + widgets::separator(ui); +} diff --git a/docs/mcp.md b/docs/mcp.md index cfc3c0b..cd590b0 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -98,7 +98,7 @@ Or in a project's `.mcp.json`: | `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `component.exposeInstance` / `instance.exposed` (nested instance properties on instances), `inspect.code` (CSS, SwiftUI or Jetpack Compose for a layer), `style.create`, `variable.bind`, `proto.interaction.add`). | | `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`); all-or-nothing. | | `create_node {type, x, y, width, height, parent, index, name, text, path, props}` | Create a frame, rectangle, ellipse, polygon, star, line, arrow, text, vector, section, component or slice; returns `{id, node}`. | -| `set_properties {ids, props}` | `node.set` on layers (geometry, fills, strokes, effects, corner radius, auto layout, sizing, constraints, masks (`isMask`, `maskType: alpha\|vector\|luminance`), text…); returns the updated layers. | +| `set_properties {ids, props}` | `node.set` on layers (geometry, fills, strokes, effects, corner radius, auto layout, sizing, constraints, masks (`isMask`, `maskType: alpha\|vector\|luminance`), handoff status (`devStatus: none\|readyForDev\|completed`), text…); returns the updated layers. | | `inspect_document {depth, allPages}` | The document as JSON (layer tree, styles, selection, history). | | `get_node {id, depth}` | One layer as JSON. | | `select {ids}` | Replace the selection (`[]` deselects). | diff --git a/docs/parity.md b/docs/parity.md index c5ed74e..16b7650 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -1,8 +1,8 @@ # Parity report -Generated by `cargo xtask parity`: FigCraft commands registered for the incumbent's main menu and toolbar (337 commands in the engine). +Generated by `cargo xtask parity`: FigCraft commands registered for the incumbent's main menu and toolbar (340 commands in the engine). -**193 / 199 menu items (97.0%)** +**194 / 200 menu items (97.0%)** ## Missing