diff --git a/ROADMAP.md b/ROADMAP.md index c6c0d75..6ddbd9a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~173–310 h away (one Opus 5.5 agent; ~35–70 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **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 @@ -19,7 +19,7 @@ start with [`docs/gaps.md`](docs/gaps.md). | Essentials user | **~57%** | estimated: core features 77.8% × 0.92 launch/stability × 0.92 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) | | Mainstream practitioner | **~48%** | estimated: weekly areas 69.1% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) | | Remaining to beta | **~173–310 h** one agent | estimated | -| Remaining to full parity | **~314–606 h** one agent (~86–150 h wall-clock with 5 agents) | estimated | +| Remaining to full parity | **~312–604 h** one agent (~86–150 h wall-clock with 5 agents) | estimated | ### Readiness by audience @@ -56,7 +56,7 @@ Figma's own languages on the landed localization layer (15–25 h). | Dimension | Ready | Hours | Doc | |---|---|---|---| -| Features (depth) | 57% (core editor 69%) | 53–103 | [target-app-parity](docs/target-app-parity.md) | +| Features (depth) | 57% (core editor 69%) | 51–101 | [target-app-parity](docs/target-app-parity.md) | | UI/UX fidelity | 58% | 40–60 | [ui-parity](docs/ui-parity.md) | | File formats | 55% (`.fig` read best effort, no write) | 8–15 | [file-format-parity](docs/file-format-parity.md) | | Collaboration | 13% (core in; live sessions with document sync, cursors and text carets, the browser joins, invite links open the app; a self-hosted server with edit and view links and team libraries; preview) | 49–98 | [collaboration-parity](docs/collaboration-parity.md) | @@ -76,9 +76,9 @@ Figma's own languages on the landed localization layer (15–25 h). | Selection & transform | 97% | 72% | 3–5 | [ui-parity](docs/ui-parity.md) | | Layers panel | 98% | 82% | 1–2 | [ui-parity](docs/ui-parity.md) | | Shapes & vector | 97% | 68% | 1–3 | [target-app-parity](docs/target-app-parity.md) | -| Paint | 90% | 67% | 3–6 | [target-app-parity](docs/target-app-parity.md) | +| Paint | 90% | 71% | 2–5 | [target-app-parity](docs/target-app-parity.md) | | Strokes | 95% | 75% | 1–2 | [target-app-parity](docs/target-app-parity.md) | -| Effects | 100% | 72% | 3–6 | [target-app-parity](docs/target-app-parity.md) | +| Effects | 100% | 75% | 2–5 | [target-app-parity](docs/target-app-parity.md) | | Frames, groups, sections, masks | 97% | 80% | 1–3 | [target-app-parity](docs/target-app-parity.md) | | Auto layout & constraints | 98% | 74% | 3–6 | [auto-layout-parity](docs/auto-layout-parity.md) | | Text | 87% | 68% | 5–10 | [target-app-parity](docs/target-app-parity.md) | @@ -128,6 +128,11 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-11** · Paint and effects behaviour suite: every documented rule for fills, blend modes, + gradients, images, colour models, shadows, blurs and effect exports is a table-driven case. Fixes: Normal + groups isolate and clipping frames don't, plus darker is drawn, drop shadows hide behind translucent + layers in every export, Fill images reach the box's edges, "Normal" no longer means pass-through. + Paint 70%, effects 75%. - **2026-10-11** · `.fig` import, best effort (gap 1): `file.open`, `file.import`, drag and drop, the CLI and MCP open `.fig` files. A generic kiwi decoder reads each file's own schema, and the decoded nodes map by field name onto the REST importer: layers, paints and styles, text runs, vectors as outlines, auto layout, components and @@ -195,6 +200,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Paint and effects behaviour suite written from the public documentation and its fixes: Normal groups and frames isolate their children and clipping frames don't (canvas, SVG, PDF); plus darker drawn on the canvas and in PNG exports; drop shadows hidden behind what the layer covers unless shown behind transparent areas, SVG included, with filter regions that reach as far as the effects; Fill images cover the box to its edges; "Normal" names Normal in commands and the blend menu. Paint 85%/66% → 87%/70% (breadth from the GIF/TIFF row), 5–9 h → 3–6 h; effects 72% → 75%, 3–6 h → 2–5 h; percentages unchanged at this rounding; full parity ~321–618 h → ~319–616 h | | 2026-10-11 | minor | Frames, groups, sections and masks behaviour suite written from the public documentation (15 frame, group and section tests, 6 mask tests) and its fixes: a layer a mask hides can't be clicked there (clicks reach what's behind; Select layer agrees), a mask group's box and exports are the mask's (plus any layers under it), sections stay on the page or in sections (moves, drags, pastes, grouping and new sections in frames refused or kept on the page), drawing a frame or section around layers puts those fully inside it in it, resizing a section leaves its contents where they are (and a frame resized with Cmd/Ctrl held keeps its layers' sizes too), and X/Y of layers in groups are measured in the frame (groups have no coordinate space). Frames, groups, sections, masks 95%/75% → 97%/80%, 2–4 h → 1–3 h; percentages unchanged at this rounding; full parity ~323–621 h → ~322–620 h | | 2026-10-11 | minor | Styles and variables behaviour suite written from the public documentation (18 command-level tests) and its fixes: editing a styled fill, stroke, effect or layout grid list (directly, a paint row, an added fill or a bound variable) unlinks that style instead of being overwritten by the next style edit; applying a style replaces variables bound to what it sets; text styles go on text layers only; deleting a variable (or its collection) unbinds the layers using it, which keep their last value, and aliases to it take its values; bindings are type-checked (a colour variable can't drive a width) and number variables fill text content; aliases are checked when a variable is created. Added: duplicate, move (reorder, into a folder) and rename folder for styles, with a Local styles list grouped by type and folder (click to edit, right-click, drag to reorder); duplicate mode, set as default and move column for modes; per-platform code syntax (Web, iOS, Android) used by generated CSS and the variables JSON; font weight, paragraph spacing/indent and text content scopes for number variables. Found missing: variables inside styles. Styles & variables 92%/72% → 94%/76%, 1–2 h → 3–5 h; percentages unchanged at this rounding; full parity ~323–621 h → ~325–624 h | | 2026-10-11 | minor | Prototyping behaviour suite written from the public documentation (7 model tests, 12 player tests with 21 gestures) and its fixes: on drag starts only for a drag its animation's way and the animation follows the pointer, key combinations (`Shift+K`) fire (and are recorded and imported), an overlay's after-delay timer starts when it opens, instances of interactive components play their variant's interactions and "Change to" changes the right instance, spring presets are physical springs that play until they settle, smart animate overshoots with spring and back easings, fixed layers draw above scrolling content; added scroll to offsets. Prototyping 88%/70% → 90%/74%, 5–9 h → 3–6 h; mainstream weekly areas 68.1% → 68.4%, mainstream ~46% → ~47%; other percentages unchanged at this rounding; full parity ~320–615 h → ~318–612 h | diff --git a/crates/color/src/blend.rs b/crates/color/src/blend.rs index 51e4ca5..734759e 100644 --- a/crates/color/src/blend.rs +++ b/crates/color/src/blend.rs @@ -100,8 +100,13 @@ impl BlendMode { } } + /// The mode named by its label or CSS keyword, ignoring case, spaces and dashes. Labels come + /// first: "normal" is Normal, not pass-through (whose CSS keyword is also `normal`). pub fn from_name(s: &str) -> Option { let key: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::().to_ascii_lowercase(); - Self::ALL.into_iter().find(|m| m.css().replace('-', "") == key || m.label().replace(' ', "").to_ascii_lowercase() == key) + Self::ALL + .into_iter() + .find(|m| m.label().replace(' ', "").to_ascii_lowercase() == key) + .or_else(|| Self::ALL.into_iter().find(|m| m.css().replace('-', "") == key)) } } diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 8ca35b6..8b33a01 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -684,6 +684,12 @@ impl Node { (0..n).filter_map(move |i| self.children.get(if rev { n - 1 - i } else { i }).copied()) } + /// Whether this group or frame blends its children among themselves before the result meets + /// what is behind it: set to Normal rather than pass-through (with children to blend). + pub fn isolates_children(&self) -> bool { + self.blend == BlendMode::Normal && self.kind.is_container() && !matches!(self.kind, NodeKind::BooleanOp { .. }) && !self.children.is_empty() + } + /// Whether painted strokes are present. pub fn has_visible_stroke(&self) -> bool { self.strokes.iter().any(|p| p.visible) && self.stroke.weight > 0.0 diff --git a/crates/engine/src/fx_behaviour_tests.rs b/crates/engine/src/fx_behaviour_tests.rs new file mode 100644 index 0000000..eb8da9b --- /dev/null +++ b/crates/engine/src/fx_behaviour_tests.rs @@ -0,0 +1,129 @@ +//! Paint and effects behaviour through commands (the rules of `figcraft_render`'s paint and +//! effects suites that involve edits or exports): the eyedropper, blend modes and effects set +//! through commands, and how the SVG and PDF exports carry isolating groups and shadows that hide +//! behind their layer. + +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; +use crate::tools::{PointerEvent, PointerKind, ToolId}; +use figcraft_color::BlendMode; +use figcraft_doc::{EffectKind, NodeId}; + +fn id(v: Value) -> NodeId { + id_of(&v["id"]).unwrap() +} + +fn rect(s: &mut Session, parent: Option, (x, y, w, h): (f64, f64, f64, f64), fill: &str) -> NodeId { + let mut p = json!({"x": x, "y": y, "width": w, "height": h, "props": {"fill": fill}}); + if let Some(parent) = parent { + p["parent"] = json!(parent); + } + id(s.execute("shape.rectangle", &p).unwrap()) +} + +fn set(s: &mut Session, node: NodeId, props: Value) { + s.execute("node.set", &json!({"id": node, "props": props})).unwrap(); +} + +fn svg(s: &Session, ids: &[NodeId]) -> String { + figcraft_svg::export_svg(s.doc().unwrap(), ids, &figcraft_svg::SvgOptions::default()).unwrap() +} + +#[test] +fn the_eyedropper_picks_the_colour_seen_and_fills_the_selection() { + // Documented: the eyedropper picks the colour under the pointer as it looks on the canvas and + // applies it to the selected layer's fill (opaque: the colour seen, not a layer's alpha). + let mut s = Session::new(); + s.execute("page.setBackground", &json!({"color": "#FFFFFF"})).unwrap(); + let a = rect(&mut s, None, (0.0, 0.0, 100.0, 100.0), "#FF0000"); + set(&mut s, a, json!({"opacity": 0.5})); + let b = rect(&mut s, None, (200.0, 0.0, 50.0, 50.0), "#00FF00"); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + s.execute("edit.pickColor", &json!({})).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Down, 50.0, 50.0, 1.0)).unwrap(); + let fill = s.doc().unwrap().node(b).unwrap().fills[0].solid_color().unwrap(); + let [r, g, bl, al] = fill.to_rgba8(); + assert!(r == 255 && g.abs_diff(128) <= 1 && bl.abs_diff(128) <= 1 && al == 255, "picked {}", fill.to_css_hex()); + assert_eq!(s.recent_colors.first(), Some(&fill), "the picked colour joins the recent colours"); + assert_eq!(s.tool.tool, ToolId::Move, "picking ends the eyedropper"); + // One undo step takes the fill back. + s.undo().unwrap(); + assert_eq!(s.doc().unwrap().node(b).unwrap().fills[0].solid_color().map(|c| c.to_hex()), Some("00FF00".into())); +} + +#[test] +fn blend_modes_and_effects_through_commands() { + let mut s = Session::new(); + let r = rect(&mut s, None, (0.0, 0.0, 40.0, 40.0), "#CC6633"); + s.execute("fill.add", &json!({"ids": [r], "paint": "#66CCEE"})).unwrap(); + s.execute("fill.set", &json!({"ids": [r], "index": 1, "blend": "plusDarker", "opacity": 50})).unwrap(); + let n = s.doc().unwrap().node(r).unwrap(); + assert_eq!((n.fills[1].blend, n.fills[1].opacity), (BlendMode::PlusDarker, 0.5)); + // Inspecting the layer reports the paint's blend mode. + let fills = s.execute("node.get", &json!({"id": r})).unwrap()["fills"].clone(); + assert_eq!((fills[0].get("blend"), fills[1]["blend"].as_str()), (None, Some("plusDarker"))); + // A new group passes through; Normal is a choice. + let g = id(s.execute("object.group", &json!({"ids": [r]})).unwrap()); + assert_eq!(s.doc().unwrap().node(g).unwrap().blend, BlendMode::PassThrough); + set(&mut s, g, json!({"blend": "normal"})); + assert!(s.doc().unwrap().node(g).unwrap().isolates_children()); + // A new drop shadow hides behind its layer; the option turns that off. + s.execute("effect.add", &json!({"ids": [r], "type": "dropShadow"})).unwrap(); + let shown = |s: &Session| match &s.doc().unwrap().node(r).unwrap().effects[0].kind { + EffectKind::DropShadow(sh) => sh.show_behind, + _ => panic!("not a shadow"), + }; + assert!(!shown(&s)); + s.execute("effect.set", &json!({"ids": [r], "index": 0, "effect": {"type": "dropShadow", "showBehind": true}})).unwrap(); + assert!(shown(&s)); +} + +#[test] +fn exports_isolate_normal_groups_and_hide_shadows_behind_their_layer() { + let mut s = Session::new(); + let g = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()); + let k = rect(&mut s, Some(g), (10.0, 10.0, 50.0, 50.0), "#00FFFF"); + set(&mut s, k, json!({"blend": "multiply"})); + // Pass-through: no isolation; Normal: an isolated group, in SVG and PDF. + let out = svg(&s, &[g]); + assert!(!out.contains("isolation:isolate")); + // The frame clips (by default) without a clipped group around its children, which would + // isolate them: the blending child carries the clip itself. + assert!(!out.contains("", blend_style(n.blend), back.unwrap_or_default()); + let _ = write!(self.body, "{}{img}", blend_style(n.blend), clip_attr(&self.clip), back.unwrap_or_default()); true } @@ -580,27 +632,72 @@ impl W<'_> { format!("{}", paths(color, None)) } - /// A Gaussian blur filter over `region` (page units); `None` for no blur. - fn blur_filter(&mut self, radius: f64, region: Rect) -> Option { - if !(radius.is_finite() && radius > 0.0) { + /// A Gaussian blur filter over `region` (page units), cut out by `cover` (a filter input + /// named `cover`, see [`W::cover_input`]); `None` for no blur and no cover. + fn blur_filter(&mut self, radius: f64, region: Rect, cover: Option<&str>) -> Option { + let blur = radius.is_finite() && radius > 0.0; + if !blur && cover.is_none() { return None; } let fid = self.id("filter"); let _ = write!( self.defs, - "", + "", num(region.x0), num(region.y0), num(region.width()), num(region.height()), - num(radius / 2.0) ); + if blur { + let _ = write!(self.defs, "", num(radius / 2.0)); + } + if let Some(c) = cover { + let _ = write!(self.defs, "{c}", if blur { "b" } else { "SourceGraphic" }); + } + self.defs.push_str(""); Some(fid) } + /// The area `n`'s paints cover, as a filter input named `cover` (an image of its outlines + /// through `xf`), when its drop shadows hide behind it there (see + /// [`shadow::hiding_cover`](figcraft_render::shadow::hiding_cover)). + fn cover_input(&self, n: &Node, xf: Affine) -> Option { + let cover = figcraft_render::shadow::hiding_cover(self.doc, n)?; + let b = cover + .iter() + .map(|(p, _)| figcraft_geom::transform_rect_bbox(xf, figcraft_geom::Shape::bounding_box(p))) + .reduce(|a, b| a.union(b))? + .inflate(1.0, 1.0); + if !(b.width() > 0.0 && b.height() > 0.0 && [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite())) { + return None; + } + let mut img = format!( + "", + num(b.x0), + num(b.y0), + num(b.width()), + num(b.height()), + num(b.width()), + num(b.height()) + ); + for (p, rule) in &cover { + let fr = if *rule == figcraft_render::FillRule::EvenOdd { " fill-rule=\"evenodd\"" } else { "" }; + let _ = write!(img, "", matrix(xf), path_d(p)); + } + img.push_str(""); + let b64 = base64::engine::general_purpose::STANDARD.encode(img); + Some(format!( + "", + num(b.x0), + num(b.y0), + num(b.width()), + num(b.height()) + )) + } + /// Drop shadows with spread, as geometry under the layer: its silhouette offset by the /// spread (exactly, like the renderer), moved, blurred and faded. - fn drop_shadows(&mut self, n: &Node, sil: &[Outline], xf: Affine) { + fn drop_shadows(&mut self, n: &Node, sil: &[Outline], xf: Affine, clip: &str) { for e in n.effects.iter().filter(|e| e.visible) { let EffectKind::DropShadow(s) = &e.kind else { continue }; if s.spread == 0.0 || !(s.offset.0.is_finite() && s.offset.1.is_finite()) { @@ -613,7 +710,9 @@ impl W<'_> { continue; }; let reach = s.spread.abs() * SPREAD_MITER_LIMIT + s.radius.abs() * 1.5 + 2.0; - let filter = self.blur_filter(s.radius, b.inflate(reach, reach)).map(|f| format!(" filter=\"url(#{f})\"")).unwrap_or_default(); + let cover = if s.show_behind { None } else { self.cover_input(n, xf) }; + let filter = + self.blur_filter(s.radius, b.inflate(reach, reach), cover.as_deref()).map(|f| format!(" filter=\"url(#{f})\"")).unwrap_or_default(); let op = f64::from(s.color.a * n.opacity.clamp(0.0, 1.0)); let blend = if matches!(s.blend, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) { String::new() @@ -621,7 +720,7 @@ impl W<'_> { format!(" style=\"mix-blend-mode:{}\"", s.blend.css()) }; let body = self.spread_paths(sil, s.spread, at, &hex(s.color)); - let _ = write!(self.body, "{body}", num(op)); + let _ = write!(self.body, "{body}", num(op)); } } @@ -651,7 +750,7 @@ impl W<'_> { num(r.height()), rect("white") ); - let filter = self.blur_filter(s.radius, r).map(|f| format!(" filter=\"url(#{f})\"")).unwrap_or_default(); + let filter = self.blur_filter(s.radius, r, None).map(|f| format!(" filter=\"url(#{f})\"")).unwrap_or_default(); let _ = write!( self.body, "{}", @@ -679,7 +778,7 @@ fn sniff_mime(b: &[u8]) -> &'static str { pub fn export_svg(doc: &Document, ids: &[NodeId], opts: &SvgOptions) -> Option { let bounds: Rect = ids.iter().filter_map(|i| figcraft_render::export_bounds(doc, *i)).reduce(|a, b| a.union(b))?; let origin = Affine::translate((-bounds.x0, -bounds.y0)); - let mut w = W { doc, roots: ids, page: origin, defs: String::new(), body: String::new(), next: 0, opts, mask: None }; + let mut w = W { doc, roots: ids, page: origin, defs: String::new(), body: String::new(), next: 0, opts, mask: None, clip: None }; for id in ids { let parent_xf = doc.node(*id).and_then(|n| n.parent).map(|p| doc.world_transform(p)).unwrap_or(Affine::IDENTITY); w.node(*id, origin * parent_xf, 0); diff --git a/crates/ui-egui/src/colorpicker.rs b/crates/ui-egui/src/colorpicker.rs index 481c037..9030cb1 100644 --- a/crates/ui-egui/src/colorpicker.rs +++ b/crates/ui-egui/src/colorpicker.rs @@ -459,6 +459,19 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { } }); } + // The paint's own blend mode, with the paints and layers under it. + if !matches!(target, PickerTarget::Effect { .. } | PickerTarget::PageBackground | PickerTarget::Variable { .. }) { + ui.horizontal(|ui| { + ui.add_space(16.0); + let resp = widgets::dropdown(ui, tr(paint.blend.label()), 208.0, Some(Icon::Blend)).on_hover_text(tr("Blend mode")); + crate::right::menu_popup(&resp).width(200.0).show(|ui| { + if let Some(b) = crate::right::blend_menu(ui, false) { + paint.blend = b; + write(app, &target, &paint, false); + } + }); + }); + } // Gradient stops. let mut edit_color: Option = match &paint.kind { PaintKind::Solid { color } => Some(color.with_alpha(color.a * paint.opacity)), diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index 158bb15..8e1c343 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -205,6 +205,21 @@ pub(crate) fn menu_style(s: &mut egui::Style) { /// (else its 600 px default area width) and keeps the size measured; rows ([`menu_row`]) take the /// larger of that width and their content, so the menu fits its content. Chain `.width(w)` for a /// menu at least `w` wide. +/// The blend modes as menu items in groups (pass-through only for layers); the one picked. +pub fn blend_menu(ui: &mut Ui, with_pass_through: bool) -> Option { + let mut picked = None; + for b in BlendMode::ALL.into_iter().filter(|b| with_pass_through || *b != BlendMode::PassThrough) { + if menu_item(ui, tr(b.label()), "") { + picked = Some(b); + ui.close(); + } + if matches!(b, BlendMode::Normal | BlendMode::ColorBurn | BlendMode::ColorDodge | BlendMode::HardLight | BlendMode::Exclusion) { + ui.separator(); + } + } + picked +} + pub fn menu_popup(anchor: &egui::Response) -> egui::Popup<'static> { egui::Popup::menu(anchor).width(anchor.rect.width().max(MENU_MIN_W)).style(menu_style) } @@ -829,14 +844,8 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let label = blend.map(|b| tr(b.label())).unwrap_or(tr("Mixed")); let resp = widgets::dropdown(ui, label, col * 2.0 + 8.0, Some(Icon::Blend)); menu_popup(&resp).width(200.0).show(|ui| { - for b in BlendMode::ALL { - if menu_item(ui, tr(b.label()), "") { - app.set_props(&ids, json!({"blend": b.css()})); - ui.close(); - } - if matches!(b, BlendMode::Normal | BlendMode::ColorBurn | BlendMode::ColorDodge | BlendMode::HardLight | BlendMode::Exclusion) { - ui.separator(); - } + if let Some(b) = blend_menu(ui, true) { + app.set_props(&ids, json!({"blend": b.label()})); } }); }); diff --git a/docs/mcp.md b/docs/mcp.md index c608fd3..2d40ee8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -103,6 +103,18 @@ Or in a project's `.mcp.json`: tools, `color` may also be a hex string and gradients can be written `{"type": "linearGradient", "stops": ["#7048E8", "#1098AD"]}`; the server converts them to the engine's paint JSON. +- **Blend modes**: `blend` on a layer (`node.set`) or a paint (`fill.set` / `stroke.set`) is a + menu label or CSS keyword (`"Normal"`, `"pass through"`, `"multiply"`, `"plusDarker"`, ...; + `normal` is Normal). Groups and frames start as pass-through: their children blend with what is + behind them, through clipping too (SVG exports put a clipping frame's clip on what it holds, since + a clipped group would isolate it); Normal blends the children among themselves first (SVG + `isolation:isolate`, a PDF isolated group). Plus darker is exact in renders and pixel exports; + PDF has no such mode (multiply) and SVG writes the CSS `plus-darker` keyword. Inspecting a layer + reports a paint's `blend` when it isn't normal. +- **Drop shadows** hide behind what their layer covers, its fills (translucent and 0% ones too) + and strokes, unless the effect has `showBehind: true`; groups, and layers whose pixels a layer + blur or texture moves, hide them behind their pixels instead. SVG exports cut them out the same + way, with filter regions as large as the effects reach. - **Colour profile**: colour values are in the file's profile, sRGB (default) or Display P3 (`inspect_document` reports `colorProfile`). `file.colorProfile {profile: srgb|displayP3, convert?}` changes it: `convert` (default true) converts every colour value so colours keep their diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 79960f7..0ad52a1 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -21,7 +21,7 @@ summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu c | Essentials user | **~57%** | estimated ([below](#essentials-user): core features 77.8% × discounts 0.92 × 0.92 × 0.88) | | Mainstream practitioner | **~48%** | estimated ([below](#mainstream-practitioner): weekly areas 69.1% × discounts 0.9 × 0.9 × 0.85) | | Remaining to beta | **~173–310 h** one Opus 5.5 agent (~35–70 h wall-clock with 5 parallel agents) | estimated | -| Remaining to full parity | **~314–606 h** one agent (~86–150 h wall-clock with 5 parallel agents) | estimated | +| Remaining to full parity | **~312–604 h** one agent (~86–150 h wall-clock with 5 parallel agents) | estimated | | Stage | **alpha**: the alpha gate passes ([roadmap](roadmap.md#alpha-gate)); multiplayer and checking the best-effort `.fig` import on real files are beta gaps (see [ROADMAP](../ROADMAP.md#stage)) | | The previous figures (ROADMAP of 2026-10-10, "feature depth ~79%", "full parity 115–195 h") were @@ -98,9 +98,9 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 2 | Selection & transform | 8 | 97% | 72% | Handles, measuring and snapping checked tool by tool against Figma and real files (snap distance, handle reach, cursors); children turned off a right angle scale without skew, approximately (behaviour suite from the documentation, rotated groups and mixed selections, equal spacing snapping, Cmd/Ctrl to ignore constraints and marquees inside frames landed) | 3–5 | | 3 | Layers panel | 5 | 98% | 82% | QA with long real files (keyboard box selection, F2 rename, Shift+F10 context menu and 10k-layer reveal/expand/collapse/arrow timings landed) | 1–2 | | 4 | Shapes & vector | 8 | 97% | 68% | Vector QA with real files (live boolean editing, boolean hit testing on the result, arc fields, shape builder on strokes and open paths and one-sided widths landed) | 1–2 | -| 5 | Paint | 7 | 90% | 67% | Video fills, wide-gamut canvas, HEIC images (animated GIF fills, TIFF images and embedded image colour profiles landed) | 3–6 | +| 5 | Paint | 7 | 90% | 71% | Video fills, wide-gamut canvas, HEIC images; plus darker in PDF (behaviour suite from the documentation, pass-through and isolating groups, clipping frames that don't isolate, plus darker, animated GIF fills, TIFF images and embedded image colour profiles landed) | 2–5 | | 6 | Strokes | 3 | 95% | 75% | Stroke QA with real files (colour styles on strokes, stroke weight and per-side weight variables, brush strokes landed, see 20) | 1–2 | -| 7 | Effects | 4 | 100% | 72% | Glass and progressive blur tuned side by side | 3–6 | +| 7 | Effects | 4 | 100% | 75% | Glass and progressive blur tuned side by side; inner shadow and background blur on groups (behaviour suite from the documentation, shadows hidden behind translucent layers on the canvas and in every export landed) | 2–5 | | 8 | Frames, groups, sections, masks | 5 | 97% | 80% | Visual QA with real files; a one-step frame/group convert (behaviour suite from the documentation landed: masked-out areas don't take clicks, mask group bounds and exports follow the mask, sections kept out of frames and groups, frames and sections drawn around layers take them, section resizing leaves contents in place, X/Y measured in the frame) | 1–3 | | 9 | Auto layout & constraints | 9 | 98% | 74% | Behaviour against real Figma files; wrapped-line content alignment (behaviour suite from the documentation, `fr` tracks like CSS, vertical wrap, auto gaps evenly/around and cell alignment landed) ([auto-layout-parity](auto-layout-parity.md)) | 3–6 | | 10 | Text | 8 | 87% | 68% | Spell check, real-file text metrics, list spacing, hanging punctuation, underline styles (behaviour suite with CJK line breaking, vertical trim and carets by grapheme cluster landed) | 5–10 | @@ -115,14 +115,14 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 19 | Team libraries & organization | 2 | 70% | 40% | Libraries kept on `figcraft-server` under their file and enabled by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names. Left: team-wide library lists, accounts and permissions, analytics over time, pushed update notices | 4–8 | | 20 | Figma Draw extras | 2 | 80% | 55% | Have: variable width, lasso, shape builder, noise/texture, text on a path, pattern fills, stretch and scatter brushes, pen pressure (Windows pens, browser styluses). Missing: multi-edit, pressure from macOS/Linux tablets | 2–5 | | 21 | AI features | 3 | 0% | 0% | First draft, rename layers, image edit/remove background, visual search, Make: needs local models (owner decision) | 30–60 | -| | **Weighted (÷113)** | 113 | **~80%** | **~58%** | | **156–320** | -| | Core editor only (1–16, ÷94) | 94 | ~91% | ~69% | | 51–101 | +| | **Weighted (÷113)** | 113 | **~80%** | **~58%** | | **154–318** | +| | Core editor only (1–16, ÷94) | 94 | ~91% | ~69% | | 49–99 | ## By dimension (ready for real work) | Dimension | Weight | Ready | Hours | Evidence | Doc | |---|---|---|---|---|---| -| Features (depth) | 35 | 57% | 53–103 (areas 1–13, 15, 16, 20) | The table above; core editor ~69% | this file | +| Features (depth) | 35 | 57% | 51–101 (areas 1–13, 15, 16, 20) | The table above; core editor ~69% | this file | | UI/UX fidelity | 15 | 58% | 40–60 | Look & feel compared side by side with UI3 (~65% by the builders); tool-by-tool interaction (cursors, modifiers while dragging, hover states, popovers) not yet compared; accessibility names and keyboard navigation landed | [ui-parity](ui-parity.md) | | File formats | 12 | 55% | 8–15 | `.fig` read, best effort (tested on generated fixtures, not real files), no `.fig` write; REST JSON import, SVG in/out, PNG/JPG/WebP/PDF export solid | [file-format-parity](file-format-parity.md) | | Collaboration | 10 | 13% | 55–110 | Local comments, version history and branches with review and merge; the multiplayer core (sync model, server ordering, client reconciliation, convergence tests) landed in #103, and live sessions (preview) co-edit a file hosted by a desktop app or kept on a self-hosted `figcraft-server` with edit and view links, joined from the app or the browser (`docs/collab-protocol.md`) | [collaboration-parity](collaboration-parity.md) | @@ -133,7 +133,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | Platforms | 3 | 75% | 10–20 | macOS, Windows (x64/x86/arm64), Linux, FreeBSD, web all packaged (v0.2.0); Figma also has iOS/Android apps; non-macOS QA thin | [hardware-parity](hardware-parity.md) | | Ecosystem/plugins | 3 | 28% | 15–40 | Own WASM plugins with a Rust SDK and two examples (#101), plugin panels and settings (ABI v2); no Figma plugin compatibility, no widgets, no distribution or community files; the CLI, control channel and MCP server are ahead of Figma for agents | [gaps](gaps.md) | | AI features | 2 | 0% | 30–60 | None | [gaps](gaps.md) | -| **Ready for real work** | 100 | **~49%** | **~314–606** | | | +| **Ready for real work** | 100 | **~49%** | **~312–604** | | | Dimension hours add up to the full-parity total without double counting: the feature-area rows for import/export (14), multiplayer and team libraries (17, 19), plugins (18) and AI (21) are @@ -550,7 +550,8 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th diamond gradients, images with fill/fit/crop/tile + adjustments), multiple strokes with inside/center/outside alignment, per-side weights, dashes, caps/arrowheads, joins; effects (drop and inner shadow with spread that offsets the outline exactly: ellipses, stars, vectors, text; - inner shadows over the fills and under the children and strokes; layer blur; background blur + inner shadows over the fills and under the children and strokes; drop shadows hidden behind what + the layer covers, translucent fills included, unless shown behind transparent areas; layer blur; background blur that sees the whole backdrop, inside clipping frames, faded groups and masks too; both blurs uniform or progressive (the radius ramps between two points, dragged on the canvas); noise in mono, duo and multi colour with grain size, density and blend mode; texture with clip to shape; @@ -600,6 +601,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Paint and effects behaviour suite written from the public documentation (14 paint and 9 effects tests with table rows for fills, opacity, every blend mode by its formula, pass-through and Normal containers, gradient handles and stops, image scale modes and adjustments, colour models, the colour profile, shadows, blurs, effect order and exports; 3 command tests) and its fixes: groups and frames set to Normal blend their children among themselves first, and clipping frames no longer isolate their children (blend modes inside a clipping frame met only the frame's children, not its fill or what is behind it) — canvas, SVG (`isolation`; clips put on what blends, since a clipped group isolates) and PDF (isolated groups); plus darker is drawn (it was multiply) on the canvas and in PNG exports; drop shadows hide behind what the layer covers, translucent fills included, unless shown behind transparent areas, in SVG too (it never cut them out) and the SVG filter region reaches as far as the effects; Fill images cover the box to its edges (upscaled ones faded out at the edge); "Normal" names Normal, not pass-through, in commands and the blend menu (the menu's Normal set pass-through). Added: a blend menu for each paint in the colour picker. Paint 66% → 70%, 4–7 h → 3–6 h; effects 72% → 75%, 3–6 h → 2–5 h; percentages unchanged at this rounding; full parity ~321–618 h → ~319–616 h | | 2026-10-11 | minor | Frames, groups, sections and masks behaviour suite written from the public documentation (15 frame, group and section tests, 6 mask tests) and its fixes: a layer a mask hides can't be clicked there (clicks reach what's behind; Select layer agrees), a mask group's box and exports are the mask's (plus any layers under it), sections stay on the page or in sections (moves, drags, pastes, grouping and new sections in frames refused or kept on the page), drawing a frame or section around layers puts those fully inside it in it, resizing a section leaves its contents where they are (and a frame resized with Cmd/Ctrl held keeps its layers' sizes too), and X/Y of layers in groups are measured in the frame (groups have no coordinate space). Frames, groups, sections, masks 95%/75% → 97%/80%, 2–4 h → 1–3 h; percentages unchanged at this rounding; full parity ~323–621 h → ~322–620 h | | 2026-10-11 | minor | Styles and variables behaviour suite written from the public documentation (18 command-level tests) and its fixes: editing a styled fill, stroke, effect or layout grid list (directly, a paint row, an added fill or a bound variable) unlinks that style instead of being overwritten by the next style edit; applying a style replaces variables bound to what it sets; text styles go on text layers only; deleting a variable (or its collection) unbinds the layers using it, which keep their last value, and aliases to it take its values; bindings are type-checked (a colour variable can't drive a width) and number variables fill text content; aliases are checked when a variable is created. Added: duplicate, move (reorder, into a folder) and rename folder for styles, with a Local styles list grouped by type and folder (click to edit, right-click, drag to reorder); duplicate mode, set as default and move column for modes; per-platform code syntax (Web, iOS, Android) used by generated CSS and the variables JSON; font weight, paragraph spacing/indent and text content scopes for number variables. Found missing: variables inside styles. Styles & variables 92%/72% → 94%/76%, 1–2 h → 3–5 h; percentages unchanged at this rounding; full parity ~323–621 h → ~325–624 h | | 2026-10-11 | minor | Prototyping behaviour suite written from the public documentation (7 model tests, 12 player tests with 21 gestures) and its fixes: on drag starts only for a drag its animation's way and the animation follows the pointer, key combinations (`Shift+K`) fire (and are recorded and imported), an overlay's after-delay timer starts when it opens, instances of interactive components play their variant's interactions and "Change to" changes the right instance, spring presets are physical springs that play until they settle, smart animate overshoots with spring and back easings, fixed layers draw above scrolling content; added scroll to offsets. Prototyping 88%/70% → 90%/74%, 5–9 h → 3–6 h; mainstream weekly areas 68.1% → 68.4%, mainstream ~46% → ~47%; other percentages unchanged at this rounding; full parity ~320–615 h → ~318–612 h | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e75749c..190b56f 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -25,6 +25,7 @@ interaction feel not yet compared. | Keyboard shortcuts panel | done | docked, tabbed by group, searchable, per-platform keycaps | | Several windows | partial | File ▸ New window, Open in new window on a tab, a tab dragged off the strip; screen-reader tree in the main window only; positions not restored | | Multiplayer avatars, Share button | missing | waits for multiplayer | +| Blend mode menus: the layer's (Design panel) and each paint's (colour picker) | done | Pass through and Normal are distinct (Normal isolates a group's or frame's children); paints have no pass-through; plus darker drawn exactly on the canvas | ## Selection and transform @@ -61,7 +62,7 @@ interaction feel not yet compared. | Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | | | Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | | | Text: point, auto height, fixed; caret, words, lines, IME | done | carets step over whole emoji and accented letters; CJK words by script run (no dictionary); vertical trim in Type settings; no spell check | -| Eyedropper (I) | done | | +| Eyedropper (I) | done | picks the colour seen under the pointer (opaque) into the selection's fill and the recent colours, one undo step (`fx_behaviour_tests`) | | Comment (C), Shift+C to show/hide | done | local only | | Text on a path (Figma Draw) | done | put on from the Design panel or `text.onPath`, not by clicking a path with the Text tool; the path is edited by resizing only | | Stretch and scatter brushes (Figma Draw) | done | eight presets drawn in code; the Pencil's brush is set by `tool.pencilBrush`, not from the toolbar | @@ -102,6 +103,7 @@ interaction feel not yet compared. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Paint and effects behaviour suite: the blend menu's Normal sets Normal (it set pass-through), a blend menu for each paint in the colour picker, the eyedropper's rules covered by tests | | 2026-10-11 | minor | Frames, groups, sections and masks: drawing a frame or section around layers takes them, sections stay out of frames, section resizing leaves contents in place, masked-out areas don't take clicks, a mask group's box follows the mask, X/Y of layers in groups are measured in the frame | | 2026-10-11 | minor | Layers panel keyboard: F2 renames (also from a row with the keyboard, which gets it back), Shift+F10 opens the context menu at the focused row or below the canvas selection, F8 draws a selection box the arrows move and size; stroke weights bind to variables side by side | | 2026-10-11 | minor | Selection and transform behaviour suite and fixes: Alt shrinks, groups and mixed rotations scale each layer along its own axes, several layers flip together, whole-pixel resizes, small layers move from inside, locked layers stay put, marquees from a top-level frame's background, equal spacing snapping, Cmd/Ctrl ignores constraints while resizing a frame |