M4: component descriptions in the Design panel (#28)

- Components and component sets get a Description field (multi-line, saved when it loses
  focus; undo refreshes it). Instances show their main component's description (or the set's)
  under the swap menu.
- The frame status/resize block no longer shows for instances, where it doesn't apply.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BFlat 2026-10-10 04:30:48 +07:00 committed by GitHub
parent 5a66859ec6
commit b8687d7c0d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 75 additions and 6 deletions

View File

@ -60,8 +60,9 @@ dimension measured side by side.
panel, add variant, instance swap UI, per-layer overrides with reset, restore main, instance
structure protection; Assets panel with live thumbnails, search and drag-to-canvas (drops into
the frame or auto layout slot under the pointer); exposed nested instances (their properties
show on the main's instances). *Left:* nested override edge cases, slots,
local libraries (publish/use across files), component descriptions UI. 10–14 h.
show on the main's instances); component descriptions (edited on the main, shown on
instances). *Left:* nested override edge cases, slots, local libraries (publish/use across
files). 10–14 h.
5. **Prototyping depth**: *landed:* on-canvas connections with drag-to-connect, interaction editor,
flows, smart animate, overlays, scroll/fixed/sticky, change-to and set-variable in the presenter.
Custom bezier and custom spring easings with editor fields; device frames (phone, tablet,
@ -144,7 +145,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent).
| 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 |
| Components & variants | 9 | 60% | libraries, slots, nested override edge cases | 10–15 |
| Styles & variables | 5 | 60% | aliasing UI, scoping, binding padding/font size/opacity/text, string and boolean bindings in the panel | 6–10 |
| Prototyping | 6 | 65% | multi-selection editing, scrolling inside device frames | 4–7 |
| Import/export | 6 | 50% | PDF (in progress), .fig import, export panel polish, REST import of prototypes/variables | 18–32 |

View File

@ -23,8 +23,14 @@ pub fn instance_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[No
let Some(n) = doc.node(*id) else { return };
match &n.kind {
NodeKind::Instance { .. } => instance_ui(app, ui, doc, n),
NodeKind::ComponentSet { .. } => properties_ui(app, ui, doc, n.id),
NodeKind::Component { .. } => properties_ui(app, ui, doc, variant_set(doc, n.id).unwrap_or(n.id)),
NodeKind::ComponentSet { description } => {
properties_ui(app, ui, doc, n.id);
description_ui(app, ui, n.id, description);
}
NodeKind::Component { description, .. } => {
properties_ui(app, ui, doc, variant_set(doc, n.id).unwrap_or(n.id));
description_ui(app, ui, n.id, description);
}
_ => {}
}
if !matches!(n.kind, NodeKind::Instance { .. }) && n.source.is_some() && owning_instance(doc, n.id).is_some() {
@ -38,6 +44,50 @@ pub fn instance_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[No
}
}
/// A component's description: a multi-line field, saved when it loses focus.
fn description_ui(app: &mut FigApp, ui: &mut Ui, id: NodeId, description: &str) {
let t = Tokens::get(ui.ctx());
widgets::separator(ui);
widgets::sublabels(ui, "Description", None);
let buf_id = Id::new(("comp_desc", id));
// The draft lives only while the field has focus; otherwise it shows the stored text.
let focused = ui.memory(|m| m.has_focus(buf_id.with("te")));
let mut text: String = if focused { ui.data(|d| d.get_temp(buf_id)).unwrap_or_else(|| description.to_string()) } else { description.to_string() };
let mut done = None;
ui.horizontal(|ui| {
ui.add_space(crate::widgets::PAD);
let w = ui.available_width() - crate::widgets::PAD;
let r = egui::Frame::NONE.fill(t.field).corner_radius(egui::CornerRadius::same(5)).inner_margin(egui::Margin::same(6)).show(ui, |ui| {
ui.add(
egui::TextEdit::multiline(&mut text)
.id(buf_id.with("te"))
.frame(egui::Frame::NONE)
.font(crate::theme::regular(11.0))
.hint_text("Describe how and when to use this component")
.desired_rows(3)
.desired_width(w - 12.0),
)
});
if r.inner.lost_focus() {
done = Some(text.clone());
}
});
match done {
Some(new) => {
ui.data_mut(|d| d.remove::<String>(buf_id));
if new != description {
app.run("node.set", json!({"id": id, "props": {"description": new.chars().take(10_000).collect::<String>()}}));
}
}
None => {
if ui.memory(|m| m.has_focus(buf_id.with("te"))) {
ui.data_mut(|d| d.insert_temp(buf_id, text));
}
}
}
ui.add_space(8.0);
}
/// Local components (and component sets, not their variants), for swap menus: (id, label).
fn local_components(doc: &Document) -> Vec<(NodeId, String)> {
let mut out: Vec<(NodeId, String)> = Vec::new();
@ -411,6 +461,22 @@ fn instance_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node) {
app.run("component.goToMain", json!({"id": id}));
}
});
// The main component's description (the set's when the variant has none).
let desc = [Some(*component), set]
.into_iter()
.flatten()
.filter_map(|c| match doc.node(c).map(|n| &n.kind) {
Some(NodeKind::Component { description, .. } | NodeKind::ComponentSet { description }) => Some(description.trim().to_string()),
_ => None,
})
.find(|d| !d.is_empty());
if let Some(d) = desc {
ui.horizontal(|ui| {
ui.add_space(crate::widgets::PAD);
ui.add(egui::Label::new(egui::RichText::new(d).font(crate::theme::regular(11.0)).color(t.text_secondary)).wrap());
});
ui.add_space(4.0);
}
// A nested instance inside a main component can expose its properties on the main's instances.
if nested_in_main(doc, id) {
row(ui, 32.0, |ui| {

View File

@ -26,7 +26,9 @@ 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 { .. }))
n.kind.is_frame_like()
&& !matches!(n.kind, NodeKind::Instance { .. })
&& 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);