diff --git a/ROADMAP.md b/ROADMAP.md index 8a5303e..63dd370 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-13 · **Change:** minor (core editor leftovers: command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; core editor leftovers: a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; core editor leftovers: delays on mouse triggers and manual overlay offsets, wrapped-line alignment; core editor leftovers: wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (core editor leftovers: inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; core editor leftovers: command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; core editor leftovers: a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; core editor leftovers: delays on mouse triggers and manual overlay offsets, wrapped-line alignment; core editor leftovers: wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; 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 @@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-13** · Effects on groups, inner shadows and background blur (M3): a group is no outline of its own, so its inner shadows are cast inwards from around what its layers draw together (their pixels, over them: no shade at a seam between two layers) and its background blur and glass show the backdrop through the outlines of its layers (under them). Canvas and PNG; SVG embeds a group with an inner shadow as a render and its background blur as an image under the layers; PDF places both as images, the blur matching the PNG. Where the group holds one shape the pixels match that shape, or a frame filled like it, carrying the effect (`group_effects_tests` in render, svg and pdf). Offsets and spreads of a group's inner shadow go by whole pixels. Numbers unchanged. - **2026-10-13** · Text made by commands on a dark page (M1): in the app, `text.create` and `node.create {type: text}` that name no fill give white text on a dark page, by the same rule as the Text tool (the page's colour decides, also inside a frame); a named `fill`, `fills` or `fillStyle` is kept. Sessions without an interface (CLI, MCP, exports) still make black text everywhere. Numbers unchanged. - **2026-10-13** · Frames and groups, a one-step convert (M1): `object.convertToGroup` and `object.convertToFrame` (the Design panel's type menu on a frame or a group) change the layer in place, its layers staying where they are on the page. A frame turned into a group loses what only a frame has (auto layout, with the positions it gave kept and fill children fixed; fills, strokes, corner radius, clipping, layout grids, guides, scrolling) and takes its layers' box; a group turned into a frame keeps its box, with no fill and no clipping. Instances, main components, layers inside instances and empty frames are refused with the reason, all or nothing; one undo step. Numbers unchanged. - **2026-10-13** · Prototyping, delays on mouse triggers and manual overlay offsets (M5): mouse enter, leave, down and up wait `delayMs` before their actions run (once while waiting; dropped when the screen or overlay they were on goes away; a pointer that leaves early doesn't cancel, the documentation is silent), and a manual overlay sits at `offset: [x, y]` from the top-left of the layer that opens it (without one, right below it, as before). Delay and X/Y fields in the interaction editor, the player on its own clock, REST import (`delay`, `overlayRelativePosition`), `.fig` import and export (the export's field for mouse delays is unchecked against other software). Numbers unchanged. @@ -301,6 +302,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Effects on groups, inner shadows and background blur (M3): a group is no outline of its own, so its inner shadows are cast inwards from around what its layers draw together (their pixels, over them: no shade at a seam between two layers) and its background blur and glass show the backdrop through the outlines of its layers (under them). Canvas and PNG; SVG embeds a group with an inner shadow as a render and its background blur as an image under the layers; PDF places both as images, the blur matching the PNG. Where the group holds one shape the pixels match that shape, or a frame filled like it, carrying the effect (`group_effects_tests` in render, svg and pdf). Offsets and spreads of a group's inner shadow go by whole pixels. Numbers unchanged. | | 2026-10-13 | minor | Text made by commands on a dark page (M1): in the app, `text.create` and `node.create {type: text}` that name no fill give white text on a dark page, by the same rule as the Text tool (the page's colour decides, also inside a frame); a named `fill`, `fills` or `fillStyle` is kept. Sessions without an interface (CLI, MCP, exports) still make black text everywhere. Numbers unchanged. | | 2026-10-13 | minor | Frames and groups, a one-step convert (M1): `object.convertToGroup` and `object.convertToFrame` (the Design panel's type menu on a frame or a group) change the layer in place, its layers staying where they are on the page. A frame turned into a group loses what only a frame has (auto layout, with the positions it gave kept and fill children fixed; fills, strokes, corner radius, clipping, layout grids, guides, scrolling) and takes its layers' box; a group turned into a frame keeps its box, with no fill and no clipping. Instances, main components, layers inside instances and empty frames are refused with the reason, all or nothing; one undo step. Numbers unchanged. | | 2026-10-13 | minor | Prototyping, delays on mouse triggers and manual overlay offsets (M5): mouse enter, leave, down and up wait `delayMs` before their actions run (once while waiting; dropped when the screen or overlay they were on goes away; a pointer that leaves early doesn't cancel, the documentation is silent), and a manual overlay sits at `offset: [x, y]` from the top-left of the layer that opens it (without one, right below it, as before). Delay and X/Y fields in the interaction editor, the player on its own clock, REST import (`delay`, `overlayRelativePosition`), `.fig` import and export (the export's field for mouse delays is unchecked against other software). Numbers unchanged. | diff --git a/crates/pdf/src/draw.rs b/crates/pdf/src/draw.rs index 7b57510..1be8b78 100644 --- a/crates/pdf/src/draw.rs +++ b/crates/pdf/src/draw.rs @@ -294,7 +294,11 @@ impl<'a> Exporter<'a> { fn content(&mut self, s: &mut Surface, n: &Node, xf: Affine, depth: usize, inner: &[Raster]) { let (w, h) = (n.width, n.height); match &n.kind { - NodeKind::Page { .. } | NodeKind::Group => self.children(s, n, xf, depth), + NodeKind::Page { .. } | NodeKind::Group => { + self.children(s, n, xf, depth); + // A group's inner shadows lie over its layers. + self.place_inner(s, inner); + } NodeKind::Text(_) => crate::text::draw_text(self, s, n, xf, inner), _ => { // The page's frame reaches out to the bleed (fills and clip; paints keep their box). diff --git a/crates/pdf/src/group_effects_tests.rs b/crates/pdf/src/group_effects_tests.rs new file mode 100644 index 0000000..c2512d5 --- /dev/null +++ b/crates/pdf/src/group_effects_tests.rs @@ -0,0 +1,50 @@ +//! Effects on groups in the PDF export: a background blur shows through the outlines of the +//! group's layers and matches the PNG; an inner shadow is an image cast from the layers' pixels. + +use figcraft_color::Color; +use figcraft_doc::{Blur, Effect, EffectKind, NodeKind, Shadow}; + +use crate::backdrop_tests::{pdf_vs_png, scene}; +use crate::draw::Exporter; +use crate::{PdfOptions, export_nodes_pdf}; + +fn on(kind: EffectKind) -> Vec { + vec![Effect { kind, visible: true }] +} + +#[test] +fn a_groups_background_blur_matches_the_png() { + // The striped scene's pane, now alone in a group that carries the blur. + let (mut d, f, pane) = scene(EffectKind::BackgroundBlur(Blur::uniform(12.0))); + let g = d.create(f, NodeKind::Group, "G", 0.0, 0.0, 240.0, 160.0).unwrap(); + d.reparent(pane, g, None).unwrap(); + let effects = std::mem::take(&mut d.get_mut(pane).unwrap().effects); + d.get_mut(g).unwrap().effects = effects; + let diff = pdf_vs_png(&d, f, g); + assert!(diff <= 3, "{diff}"); + assert!(export_nodes_pdf(&d, &[f]).unwrap().starts_with(b"%PDF")); +} + +#[test] +fn a_groups_inner_shadow_is_an_image_of_its_layers_shade() { + let (mut d, f, pane) = scene(EffectKind::Noise(Default::default())); + let g = d.create(f, NodeKind::Group, "G", 0.0, 0.0, 240.0, 160.0).unwrap(); + d.reparent(pane, g, None).unwrap(); + let n = d.get_mut(pane).unwrap(); + n.effects.clear(); + n.fills = vec![figcraft_color::Paint::solid(Color::WHITE)]; + let shadow = Shadow { color: Color::BLACK, offset: (6.0, 6.0), radius: 0.0, ..Shadow::default() }; + d.get_mut(g).unwrap().effects = on(EffectKind::InnerShadow(shadow)); + let opts = PdfOptions::default(); + let mut ex = Exporter::new(&d, &opts); + let b = figcraft_render::export_bounds(&d, f).unwrap(); + (ex.page_origin, ex.page_rect, ex.page_ids) = (figcraft_geom::Affine::translate((-b.x0, -b.y0)), b, vec![f]); + let sh = ex.shadows(g).unwrap(); + assert_eq!((sh.inner.len(), sh.drop.len()), (1, 0)); + // With the shadow the file holds one more image than without. + let with = export_nodes_pdf(&d, &[f]).unwrap(); + d.get_mut(g).unwrap().effects.clear(); + let without = export_nodes_pdf(&d, &[f]).unwrap(); + let images = |pdf: &[u8]| pdf.windows(6).filter(|w| *w == b"/Image").count(); + assert!(images(&with) > images(&without), "{} vs {}", images(&with), images(&without)); +} diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 37ab300..cfeeaae 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -212,6 +212,8 @@ mod effect_order_tests; #[cfg(test)] mod export_tests; #[cfg(test)] +mod group_effects_tests; +#[cfg(test)] mod print_tests; #[cfg(test)] mod tests; diff --git a/crates/pdf/src/raster.rs b/crates/pdf/src/raster.rs index f53ceb1..76ec584 100644 --- a/crates/pdf/src/raster.rs +++ b/crates/pdf/src/raster.rs @@ -11,7 +11,7 @@ //! node on the page, and placed under its content. use figcraft_color::BlendMode; -use figcraft_doc::{EffectKind, NodeId}; +use figcraft_doc::{EffectKind, NodeId, NodeKind}; use figcraft_geom::{Affine, Rect}; use figcraft_render::{Alone, Pixmap, View, backdrop, shadow}; use krilla::geom::Size; @@ -164,7 +164,13 @@ impl Exporter<'_> { } } EffectKind::InnerShadow(sh) => { - if let Some(p) = shape.as_ref().and_then(|o| shadow::inner_shadow(o, w, h, xf, view.scale, sh)) { + // A group is no outline: its layers' pixels, together, are what is shaded. + let inner = match &shape { + Some(o) => shadow::inner_shadow(o, w, h, xf, view.scale, sh), + None if matches!(n.kind, NodeKind::Group) => shadow::inner_shadow_of(&layer, view.scale, sh), + None => None, + }; + if let Some(p) = inner { out.inner.push(Raster::new(&p, rect, self.ink())); } } diff --git a/crates/render/src/backdrop.rs b/crates/render/src/backdrop.rs index 70db17d..bdd8405 100644 --- a/crates/render/src/backdrop.rs +++ b/crates/render/src/backdrop.rs @@ -9,7 +9,7 @@ //! The PDF and SVG exports, which have no pixels to read, render what lies under the layer instead //! ([`effects_beneath`]) and draw the same [`effects`] from it. -use figcraft_doc::{Document, EffectKind, Node, NodeId}; +use figcraft_doc::{Document, EffectKind, Node, NodeId, NodeKind}; use figcraft_geom::{Affine, Rect}; use tiny_skia::{Mask, Pixmap, PixmapPaint, Transform}; @@ -192,9 +192,18 @@ pub fn render_beneath(doc: &Document, roots: &[NodeId], id: NodeId, view: &View) Some(pix) } +/// The shape a node's backdrop effects show through: its outline; a group is no outline of its +/// own, so the outlines of the layers in it, together. +pub fn outline(doc: &Document, n: &Node) -> Option { + match n.kind { + NodeKind::Group => crate::subtree_outline(doc, n, 0).filter(|(p, _)| !p.elements().is_empty()), + _ => crate::node_outline(doc, n), + } +} + /// Where node `id`'s shape lies on its page (what its backdrop effects cover). pub fn shape_bounds(doc: &Document, id: NodeId) -> Option { - let shape = crate::node_outline(doc, doc.node(id)?)?; + let shape = outline(doc, doc.node(id)?)?; Some(figcraft_geom::transform_rect_bbox(doc.world_transform(id), figcraft_geom::Shape::bounding_box(&shape.0))) } @@ -204,7 +213,7 @@ pub fn shape_bounds(doc: &Document, id: NodeId) -> Option { /// coordinates), when given. pub fn effects_beneath(doc: &Document, roots: &[NodeId], id: NodeId, view: &View, keep: Option) -> Option { let n = doc.node(id)?; - let shape = crate::node_outline(doc, n)?; + let shape = outline(doc, n)?; let pad = pad(n, view.scale); let p = f64::from(pad) / view.scale.max(1e-9); let around = View { diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index 150f148..2bf713c 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -313,6 +313,9 @@ pub fn effect_margin(n: &Node) -> f64 { EffectKind::BackgroundBlur { .. } | EffectKind::Glass(_) => e.backdrop_radius() * 2.0, EffectKind::DropShadow(s) => s.radius * 2.0 + s.spread.abs() * figcraft_doc::SPREAD_MITER_LIMIT + s.offset.0.abs() + s.offset.1.abs(), EffectKind::Texture(t) => t.radius.abs(), + // A group's inner shadows are cast from around its pixels: a layer cut off at the + // view would cast one from the cut. + EffectKind::InnerShadow(s) if matches!(n.kind, NodeKind::Group) => s.radius * 2.0 + s.spread.abs() + s.offset.0.abs() + s.offset.1.abs(), _ => 0.0, }) .filter(|m| m.is_finite()) @@ -415,8 +418,8 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep let shape = node_outline(ctx.doc, n); // Background blur and glass: the whole backdrop under the layer (every surface below this // one, composited), through the shape, under the content. - if let Some(sh) = &shape - && backdrop::has_effects(n) + if backdrop::has_effects(n) + && let Some(sh) = &backdrop::outline(ctx.doc, n) { let pad = backdrop::pad(n, ctx.scale); let p = i32::try_from(pad).unwrap_or(0); @@ -444,6 +447,18 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep let under = Under { opacity: op, blend: n.blend, ..Under::new(pix, (lx, ly), ctx.under) }; draw_content(&Ctx { under: Some(&under), ..*ctx }, &mut layer, n, lxf, depth, has_inner.then_some(&inner as &dyn Fn(&mut Pixmap))); } + // A group is no outline of its own: its inner shadows are cast from around what its layers + // draw together, over them. + if matches!(n.kind, NodeKind::Group) && n.effects.iter().any(|e| e.visible && matches!(e.kind, EffectKind::InnerShadow(_))) { + let cover = layer.clone(); + for e in n.effects.iter().filter(|e| e.visible) { + if let EffectKind::InnerShadow(s) = &e.kind + && let Some(sh) = shadow::inner_shadow_of(&cover, ctx.scale, s) + { + layer.draw_pixmap(0, 0, sh.as_ref(), &PixmapPaint::default(), Transform::identity(), None); + } + } + } // Layer blur, noise and texture, in list order, over everything else. for e in n.effects.iter().filter(|e| e.visible) { match &e.kind { diff --git a/crates/render/src/group_effects_tests.rs b/crates/render/src/group_effects_tests.rs new file mode 100644 index 0000000..871bbfd --- /dev/null +++ b/crates/render/src/group_effects_tests.rs @@ -0,0 +1,169 @@ +//! Inner shadows and background blurs on groups. A group is no outline of its own, so these go +//! by what its layers draw together: an inner shadow is cast inwards from around their pixels +//! (over them), a background blur shows through their outlines (under them). Where the group's content is +//! one shape, the pixels must match that shape (or a frame filled like it) carrying the effect. +//! Scenes are small and synthetic, drawn on a white page at 1×. + +use figcraft_color::{Color, Paint}; +use figcraft_doc::{Blur, Document, Effect, EffectKind, NodeId, NodeKind, Shadow}; + +use super::paint_behaviour_tests::{hex, near, page, px, rect, render, solid_at}; +use super::*; + +const BLACK: [u8; 3] = [0, 0, 0]; +const RED: [u8; 3] = [255, 0, 0]; +const BLUE: [u8; 3] = [0, 0, 255]; + +fn fx(kind: EffectKind) -> Effect { + Effect { kind, visible: true } +} + +fn inner(offset: (f64, f64), radius: f64, spread: f64) -> Effect { + fx(EffectKind::InnerShadow(Shadow { color: Color::BLACK, offset, radius, spread, ..Shadow::default() })) +} + +fn group(d: &mut Document, parent: NodeId) -> NodeId { + d.create(parent, NodeKind::Group, "G", 0.0, 0.0, 200.0, 200.0).unwrap() +} + +/// How a 40 × 40 square at (50, 50) filled with `fills` carries `effects`. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Holder { + /// A rectangle with the effects. + Shape, + /// A clipping frame of the square's box, filled like it, with the effects. + Frame, + /// The rectangle alone in a group that has the effects. + Group, + /// The same group without the effects. + Plain, +} + +/// The scene: `backdrop` (drawn first), then the square held as `holder` says. +fn scene(holder: Holder, fills: &[Paint], effects: &[Effect], backdrop: &dyn Fn(&mut Document, NodeId)) -> Pixmap { + let (mut d, p) = page(); + backdrop(&mut d, p); + let on = match holder { + Holder::Shape => rect(&mut d, p, (50.0, 50.0, 40.0, 40.0), fills.to_vec()), + Holder::Frame => { + let f = d.create(p, NodeKind::Frame, "F", 50.0, 50.0, 40.0, 40.0).unwrap(); + let n = d.get_mut(f).unwrap(); + (n.fills, n.clip) = (fills.to_vec(), true); + f + } + Holder::Group | Holder::Plain => { + let g = group(&mut d, p); + rect(&mut d, g, (50.0, 50.0, 40.0, 40.0), fills.to_vec()); + g + } + }; + if holder != Holder::Plain { + d.get_mut(on).unwrap().effects = effects.to_vec(); + } + render(&d) +} + +/// The largest difference of any channel of any pixel. +fn worst(a: &Pixmap, b: &Pixmap) -> u8 { + a.data().iter().zip(b.data()).map(|(x, y)| x.abs_diff(*y)).max().unwrap_or(0) +} + +fn nothing(_: &mut Document, _: NodeId) {} + +/// A black half under the square's left part: an edge at x = 70 for blurs to smear. +fn half_black(d: &mut Document, p: NodeId) { + rect(d, p, (0.0, 0.0, 70.0, 200.0), vec![Paint::solid(Color::BLACK)]); +} + +#[test] +fn an_inner_shadow_on_a_group_of_one_shape_matches_the_shape() { + let red = [Paint::solid(hex("FF0000"))]; + // (shadow, most a pixel may differ, why) + for (effect, tol, why) in [ + (inner((6.0, 6.0), 0.0, 0.0), 0, "sharp, whole-pixel offset: the same pixels"), + (inner((0.0, 4.0), 8.0, 0.0), 2, "blurred: the same blur of the same edge"), + (inner((0.0, 0.0), 0.0, 5.0), 0, "spread: 5 px in from every edge"), + ] { + let g = scene(Holder::Group, &red, std::slice::from_ref(&effect), ¬hing); + for other in [Holder::Shape, Holder::Frame] { + let diff = worst(&g, &scene(other, &red, std::slice::from_ref(&effect), ¬hing)); + assert!(diff <= tol, "{why}: the group differs from {other:?} by {diff}"); + } + assert!(worst(&g, &scene(Holder::Plain, &red, &[], ¬hing)) > 100, "{why}: and it does draw a shadow"); + } + // Where: offset (6, 6) shades the top and left 6 px inside the square, nothing outside. + let g = scene(Holder::Group, &red, &[inner((6.0, 6.0), 0.0, 0.0)], ¬hing); + for ((x, y), want, why) in [ + ((52, 70), BLACK, "inside the left edge"), + ((70, 52), BLACK, "inside the top edge"), + ((70, 70), RED, "the middle"), + ((87, 87), RED, "the far corner"), + ((48, 70), [255, 255, 255], "outside: an inner shadow stays inside"), + ] { + assert!(near(px(&g, x, y), want, 2), "{why} at ({x}, {y}): {:?}", px(&g, x, y)); + } +} + +#[test] +fn a_groups_inner_shadow_is_cast_by_its_layers_together() { + // Red (50, 50) and blue (90, 50), 40 × 40, side by side; shadow offset (6, 0). On the group the + // pair is one silhouette: only its left edge shades. On each layer, each left edge does. + for (on_group, seam, why) in [(true, BLUE, "on the group: no shadow at the seam"), (false, BLACK, "on each layer: blue is shaded too")] { + let (mut d, p) = page(); + let g = group(&mut d, p); + let a = rect(&mut d, g, (50.0, 50.0, 40.0, 40.0), vec![Paint::solid(hex("FF0000"))]); + let b = rect(&mut d, g, (90.0, 50.0, 40.0, 40.0), vec![Paint::solid(hex("0000FF"))]); + for id in if on_group { vec![g] } else { vec![a, b] } { + d.get_mut(id).unwrap().effects = vec![inner((6.0, 0.0), 0.0, 0.0)]; + } + let pix = render(&d); + assert!(near(px(&pix, 93, 70), seam, 2), "{why}: {:?}", px(&pix, 93, 70)); + assert!(near(px(&pix, 53, 70), BLACK, 2), "{why}: the pair's left edge is shaded"); + } + // Translucent content shades in proportion: a 50% red square's shadow at 50%, over the page. + let half = scene(Holder::Group, &[solid_at("FF0000", 0.5)], &[inner((6.0, 6.0), 0.0, 0.0)], ¬hing); + assert!(near(px(&half, 52, 70), [128, 64, 64], 3), "{:?}", px(&half, 52, 70)); +} + +#[test] +fn a_background_blur_on_a_group_of_one_shape_matches_the_shape() { + let glassy = [solid_at("FFFFFF", 0.5)]; + let blur = [fx(EffectKind::BackgroundBlur(Blur::uniform(16.0)))]; + let g = scene(Holder::Group, &glassy, &blur, &half_black); + for other in [Holder::Shape, Holder::Frame] { + let diff = worst(&g, &scene(other, &glassy, &blur, &half_black)); + assert!(diff <= 2, "the group differs from {other:?} by {diff}"); + } + let plain = scene(Holder::Plain, &glassy, &[], &half_black); + // Without the blur the edge at x = 70 is sharp under the square; with it, smeared. + assert!(near(px(&plain, 68, 70), [128, 128, 128], 2) && near(px(&plain, 72, 70), [255, 255, 255], 2)); + let (l, r) = (px(&g, 68, 70), px(&g, 72, 70)); + assert!(l[0] > 140 && r[0] < 245, "blurred under the square: {l:?} {r:?}"); + // Only what the group's layers cover: around the square the backdrop is as it was. + for (x, y) in [(68, 45), (72, 45), (68, 95), (45, 70), (95, 70)] { + assert_eq!(px(&g, x, y), px(&plain, x, y), "({x}, {y}) is outside the square"); + } +} + +#[test] +fn hidden_empty_and_junk_effects_on_groups_draw_nothing_odd() { + let red = [Paint::solid(hex("FF0000"))]; + let plain = scene(Holder::Plain, &red, &[], &half_black); + let hidden = + [Effect { visible: false, ..inner((6.0, 6.0), 0.0, 0.0) }, Effect { kind: EffectKind::BackgroundBlur(Blur::uniform(16.0)), visible: false }]; + assert_eq!(worst(&scene(Holder::Group, &red, &hidden, &half_black), &plain), 0, "hidden effects"); + assert_eq!(worst(&scene(Holder::Group, &red, &[fx(EffectKind::BackgroundBlur(Blur::uniform(0.0)))], &half_black), &plain), 0, "a blur of 0"); + // An opaque square hides a background blur whole. + assert_eq!(worst(&scene(Holder::Group, &red, &[fx(EffectKind::BackgroundBlur(Blur::uniform(16.0)))], &half_black), &plain), 0); + for v in [f64::NAN, f64::INFINITY] { + for e in [inner((v, 0.0), 4.0, 0.0), inner((0.0, 0.0), v, 0.0), inner((2.0, 2.0), 2.0, v), fx(EffectKind::BackgroundBlur(Blur::uniform(v)))] { + let pix = scene(Holder::Group, &red, &[e], &half_black); + assert_eq!((pix.width(), pix.height()), (200, 200), "{v}"); + } + } + // An empty group with effects draws nothing. + let (mut d, p) = page(); + let g = group(&mut d, p); + d.get_mut(g).unwrap().effects = vec![inner((6.0, 6.0), 4.0, 0.0), fx(EffectKind::BackgroundBlur(Blur::uniform(8.0)))]; + assert!(render(&d).data().chunks_exact(4).all(|c| c[..3] == [255, 255, 255])); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 090a189..1fa8bea 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -438,6 +438,8 @@ mod gif_tests; #[cfg(test)] mod grain_tests; #[cfg(test)] +mod group_effects_tests; +#[cfg(test)] mod image_profile_tests; #[cfg(test)] mod lod_tests; diff --git a/crates/render/src/shadow.rs b/crates/render/src/shadow.rs index 49358c8..4966e8e 100644 --- a/crates/render/src/shadow.rs +++ b/crates/render/src/shadow.rs @@ -223,10 +223,34 @@ pub fn inner_shadow(shape: &Outline, w: u32, h: u32, xf: Affine, scale: f64, s: let reach = s.radius.abs() / 2.0 * scale * 3.0 + spread.abs() * scale * SPREAD_MITER_LIMIT + dx.abs().max(dy.abs()); let pad = if reach.is_finite() { reach.ceil().clamp(0.0, 2048.0) as u32 + 2 } else { 2 }; let at = Affine::translate((f64::from(pad) + dx, f64::from(pad) + dy)) * xf; - let mut big = spread_alpha(std::slice::from_ref(shape), -spread, at, w + pad * 2, h + pad * 2)?; + let big = spread_alpha(std::slice::from_ref(shape), -spread, at, w + pad * 2, h + pad * 2)?; + inner_from(big, pad, &spread_alpha(std::slice::from_ref(shape), 0.0, xf, w, h)?, scale, s) +} + +/// An inner shadow for a layer that isn't one outline (a group): cast inwards from everything +/// around what its pixels `cover` (the layers it draws, together), over them. Offsets and spreads +/// go by whole pixels. +pub fn inner_shadow_of(cover: &Pixmap, scale: f64, s: &Shadow) -> Option { + let (dx, dy) = (s.offset.0 * scale, s.offset.1 * scale); + let spread = if s.spread.is_finite() { s.spread * scale } else { 0.0 }; + if !(dx.is_finite() && dy.is_finite()) { + return None; + } + let reach = s.radius.abs() / 2.0 * scale * 3.0 + spread.abs() + dx.abs().max(dy.abs()); + let pad = if reach.is_finite() { reach.ceil().clamp(0.0, 2048.0) as u32 + 2 } else { 2 }; + let at = i32::try_from(pad).ok()?; + let mut big = Pixmap::new(cover.width() + pad * 2, cover.height() + pad * 2)?; + big.draw_pixmap(at + whole_px(dx), at + whole_px(dy), cover.as_ref(), &PixmapPaint::default(), Transform::identity(), None); + fx::morph(&mut big, -whole_px(spread)); + inner_from(big, pad, cover, scale, s) +} + +/// The inner shadow `s` from `big`: what casts it (the shape at the shadow's offset, less its +/// spread), `pad` pixels larger each way than the layer, whose pixels `cover` the shape. +fn inner_from(mut big: Pixmap, pad: u32, cover: &Pixmap, scale: f64, s: &Shadow) -> Option { fx::invert_alpha(&mut big); fx::blur(&mut big, s.radius / 2.0 * scale); - let mut out = Pixmap::new(w, h)?; + let mut out = Pixmap::new(cover.width(), cover.height())?; let pad = i32::try_from(pad).ok()?; out.draw_pixmap( -pad, @@ -236,8 +260,7 @@ pub fn inner_shadow(shape: &Outline, w: u32, h: u32, xf: Affine, scale: f64, s: Transform::identity(), None, ); - let sa = spread_alpha(std::slice::from_ref(shape), 0.0, xf, w, h)?; - fx::mask_by(&mut out, &sa, false); + fx::mask_by(&mut out, cover, false); fx::tint(&mut out, color4(s.color)); Some(out) } diff --git a/crates/svg/src/group_effects_tests.rs b/crates/svg/src/group_effects_tests.rs new file mode 100644 index 0000000..3eff150 --- /dev/null +++ b/crates/svg/src/group_effects_tests.rs @@ -0,0 +1,63 @@ +//! Effects on groups in the SVG export: an inner shadow (cast from the pixels of the group's +//! layers, over them) makes the group an embedded render; a background blur is an image of what +//! lies beneath, through the outlines of the group's layers, under them. + +use figcraft_color::{Color, Paint}; +use figcraft_doc::{Blur, Document, Effect, EffectKind, NodeId, NodeKind, Shadow}; + +use super::*; + +const FILL: &str = "#3366CC"; + +/// A backdrop square and, over it, a group of one rectangle (filled when `filled`) with `effect`. +fn scene(effect: Option, filled: bool) -> (Document, NodeId) { + let mut d = Document::new("svg group fx"); + let p = d.first_page().unwrap(); + let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 200.0, 120.0).unwrap(); + let back = d.create(f, NodeKind::Rectangle, "Back", 0.0, 0.0, 100.0, 120.0).unwrap(); + d.get_mut(back).unwrap().fills = vec![Paint::solid(Color::BLACK)]; + let g = d.create(f, NodeKind::Group, "G", 0.0, 0.0, 200.0, 120.0).unwrap(); + let r = d.create(g, NodeKind::Rectangle, "R", 60.0, 30.0, 80.0, 60.0).unwrap(); + d.get_mut(r).unwrap().fills = if filled { vec![Paint::solid(Color::from_hex(FILL).unwrap())] } else { vec![] }; + d.get_mut(g).unwrap().effects = effect.into_iter().map(|kind| Effect { kind, visible: true }).collect(); + (d, f) +} + +/// Export and check the result parses as SVG. +fn export(d: &Document, id: NodeId) -> String { + let svg = export_svg(d, &[id], &SvgOptions::default()).unwrap(); + let mut d2 = Document::new("back"); + let p2 = d2.first_page().unwrap(); + import_svg(&mut d2, svg.as_bytes(), p2, 0.0, 0.0, "Imported").unwrap(); + svg +} + +#[test] +fn a_group_with_an_inner_shadow_is_an_embedded_render() { + let plain = export(&scene(None, true).0, scene(None, true).1); + assert!(plain.contains(&format!("fill=\"{FILL}\"")) && !plain.contains(" { let has = |f: &dyn Fn(&EffectKind) -> bool| n.effects.iter().any(|e| e.visible && f(&e.kind)); let whole = has(&|k| matches!(k, EffectKind::Texture(_))) || has(&|k| matches!(k, EffectKind::LayerBlur(b) if b.is_progressive())) - || (has(&|k| matches!(k, EffectKind::Noise(_))) && has(&|k| matches!(k, EffectKind::LayerBlur(_)))); + || (has(&|k| matches!(k, EffectKind::Noise(_))) && has(&|k| matches!(k, EffectKind::LayerBlur(_)))) + // A group's inner shadows are cast by its layers' pixels, over them. + || (matches!(n.kind, NodeKind::Group) && has(&|k| matches!(k, EffectKind::InnerShadow(_)))); if !whole { return false; } @@ -956,6 +958,8 @@ pub fn export_svg(doc: &Document, ids: &[NodeId], opts: &SvgOptions) -> Option **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (core editor leftovers: command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; core editor leftovers: a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; core editor leftovers: delays on mouse triggers and manual overlay offsets, wrapped-line alignment; core editor leftovers: wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (core editor leftovers: inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; core editor leftovers: command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; core editor leftovers: a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; core editor leftovers: delays on mouse triggers and manual overlay offsets, wrapped-line alignment; core editor leftovers: wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; 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 @@ -100,7 +100,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 4 | Shapes & vector | 8 | 99% | 73% | Corner smoothing on polygons, stars and vectors; the transform box around points of several layers; per-point caps and joins on dashed, variable-width and brush strokes; vector QA with real files (behaviour suites from the documentation, corner radius and star ratio handles, rounded vector points drawn, also beside curved segments, a cap and join per point, several handles dragged together, cut and paint bucket drags, delete and heal along the curve, a boolean group's look by operation and the miter angle landed) | 1–3 | | 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% | 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 | +| 7 | Effects | 4 | 100% | 75% | Glass and progressive blur tuned side by side (behaviour suite from the documentation, inner shadows and background blur on groups, 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, and a 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 (behaviour suite from the documentation, `fr` tracks like CSS, vertical wrap, auto gaps evenly/around, cell alignment and wrapped-line alignment landed) ([auto-layout-parity](auto-layout-parity.md)) | 3–6 | | 10 | Text | 8 | 90% | 70% | Spell check, text metrics on real files and in other fonts (text box sizes measured against documents re-saved by other software: all 230 self-sizing boxes in 7 documents come out to the pixel, one font family; behaviour suite with CJK line breaking, vertical trim and carets by grapheme cluster landed; lists per paragraph with nesting and list spacing, hanging punctuation and lists, decoration style, thickness, offset, skip ink and colour landed) | 3–7 | @@ -625,6 +625,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Effects on groups, inner shadows and background blur (M3): a group is no outline of its own, so its inner shadows are cast inwards from around what its layers draw together (their pixels, over them: no shade at a seam between two layers) and its background blur and glass show the backdrop through the outlines of its layers (under them). Canvas and PNG; SVG embeds a group with an inner shadow as a render and its background blur as an image under the layers; PDF places both as images, the blur matching the PNG. Where the group holds one shape the pixels match that shape, or a frame filled like it, carrying the effect (`group_effects_tests` in render, svg and pdf). Offsets and spreads of a group's inner shadow go by whole pixels. Numbers unchanged. | | 2026-10-13 | minor | Text made by commands on a dark page (M1): in the app, `text.create` and `node.create {type: text}` that name no fill give white text on a dark page, by the same rule as the Text tool (the page's colour decides, also inside a frame); a named `fill`, `fills` or `fillStyle` is kept. Sessions without an interface (CLI, MCP, exports) still make black text everywhere. Numbers unchanged. | | 2026-10-13 | minor | Frames and groups, a one-step convert (M1): `object.convertToGroup` and `object.convertToFrame` (the Design panel's type menu on a frame or a group) change the layer in place, its layers staying where they are on the page. A frame turned into a group loses what only a frame has (auto layout, with the positions it gave kept and fill children fixed; fills, strokes, corner radius, clipping, layout grids, guides, scrolling) and takes its layers' box; a group turned into a frame keeps its box, with no fill and no clipping. Instances, main components, layers inside instances and empty frames are refused with the reason, all or nothing; one undo step. Numbers unchanged. | | 2026-10-13 | minor | Prototyping, delays on mouse triggers and manual overlay offsets (M5): mouse enter, leave, down and up wait `delayMs` before their actions run (once while waiting; dropped when the screen or overlay they were on goes away; a pointer that leaves early doesn't cancel, the documentation is silent), and a manual overlay sits at `offset: [x, y]` from the top-left of the layer that opens it (without one, right below it, as before). Delay and X/Y fields in the interaction editor, the player on its own clock, REST import (`delay`, `overlayRelativePosition`), `.fig` import and export (the export's field for mouse delays is unchecked against other software). Numbers unchanged. |