From 8e6a04baf69cf5607132d96013deef80a1ce725a Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sat, 31 Oct 2026 11:27:13 -0400 Subject: [PATCH 01/17] M8: shape builders cap sizes, radii and angles at 1e9, so an absurd value cannot build millions of segments Found while testing crates/geom/src/lib.rs (no existing issue): the builders replaced NaN and infinities with 0 but took any finite size, and kurbo subdivides an arc in proportion to the sixth root of its radius, so ellipse(1e36, ...) built about a million cubics and 1e60 never returned (sizes arrive from files and commands). The sanitizer now also clamps magnitudes to MAX_COORD (1e9, the bound measure::finite_path already uses). Polyline::nearest returns None for a non-finite point instead of Some((NaN, NaN)). Tests cover 1e36, 1e300 and f64::MAX for every builder. Co-Authored-By: Claude Fable 5.1 --- crates/geom/src/lib.rs | 40 +++++++++++++++++++++++--------------- crates/geom/src/measure.rs | 2 +- crates/geom/src/tests.rs | 40 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 17 deletions(-) diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 012fca429..b5e7f7dc8 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -18,8 +18,16 @@ pub use transform::*; /// Corner radii in clockwise order from the top-left: `[tl, tr, br, bl]`. pub type Radii = [f64; 4]; -fn finite(v: f64) -> f64 { - if v.is_finite() { v } else { 0.0 } +/// The largest magnitude a shape builder takes for a size, radius or angle. kurbo subdivides an +/// arc in proportion to the sixth root of its radius, so an uncapped radius means an unbounded +/// number of segments: 1e36 is about a million cubics and 1e60 never returns. Sizes come from +/// files and commands, so they are capped here (the sanity bound `measure::finite_path` uses). +pub const MAX_COORD: f64 = 1e9; + +/// `v` as a usable size, radius or angle: NaN and infinities are 0 and the magnitude is capped at +/// [`MAX_COORD`]. +fn bounded(v: f64) -> f64 { + if v.is_finite() { v.clamp(-MAX_COORD, MAX_COORD) } else { 0.0 } } /// A rectangle path at the origin with per-corner radii and corner smoothing (0..=1, Figma's @@ -28,15 +36,15 @@ fn finite(v: f64) -> f64 { /// Radii are clamped so adjacent corners never overlap; when smoothing doesn't fit, it is reduced /// first, then the radius. pub fn rounded_rect(width: f64, height: f64, radii: Radii, smoothing: f64) -> BezPath { - let w = finite(width).max(0.0); - let h = finite(height).max(0.0); + let w = bounded(width).max(0.0); + let h = bounded(height).max(0.0); let mut path = BezPath::new(); if w <= 0.0 && h <= 0.0 { return path; } - let s = finite(smoothing).clamp(0.0, 1.0); + let s = bounded(smoothing).clamp(0.0, 1.0); let max_r = w.min(h) / 2.0; - let radii = radii.map(|r| finite(r).clamp(0.0, max_r)); + let radii = radii.map(|r| bounded(r).clamp(0.0, max_r)); if radii.iter().all(|r| *r <= 0.0) { path.move_to((0.0, 0.0)); path.line_to((w, 0.0)); @@ -140,7 +148,7 @@ fn append_corner(path: &mut BezPath, corner: Point, din: Vec2, dout: Vec2, prof: /// first vertex at the top centre (Figma's polygon). pub fn polygon_points(width: f64, height: f64, count: u32) -> Vec { let n = count.clamp(3, 120); - let (rx, ry) = (finite(width) / 2.0, finite(height) / 2.0); + let (rx, ry) = (bounded(width) / 2.0, bounded(height) / 2.0); (0..n) .map(|i| { let t = -FRAC_PI_2 + TAU * f64::from(i) / f64::from(n); @@ -152,8 +160,8 @@ pub fn polygon_points(width: f64, height: f64, count: u32) -> Vec { /// The vertices of a star with `count` points and `inner_ratio` (0..1) inner radius. pub fn star_points(width: f64, height: f64, count: u32, inner_ratio: f64) -> Vec { let n = count.clamp(3, 120); - let k = finite(inner_ratio).clamp(0.001, 1.0); - let (rx, ry) = (finite(width) / 2.0, finite(height) / 2.0); + let k = bounded(inner_ratio).clamp(0.001, 1.0); + let (rx, ry) = (bounded(width) / 2.0, bounded(height) / 2.0); (0..n * 2) .map(|i| { let t = -FRAC_PI_2 + PI * f64::from(i) / f64::from(n); @@ -170,7 +178,7 @@ pub fn rounded_polygon(points: &[Point], radius: f64) -> BezPath { if n < 2 { return path; } - let radius = finite(radius).max(0.0); + let radius = bounded(radius).max(0.0); if radius <= 0.0 || n < 3 { for (i, p) in points.iter().enumerate() { if i == 0 { @@ -236,8 +244,8 @@ pub fn rounded_polygon(points: &[Point], radius: f64) -> BezPath { /// [`rounded_rect`], when a smoothed corner doesn't fit in half of its shorter edge, smoothing is /// reduced first, then the radius. Without smoothing this is [`rounded_polygon`]. pub fn smoothed_polygon(points: &[Point], radius: f64, smoothing: f64) -> BezPath { - let s = finite(smoothing).clamp(0.0, 1.0); - let radius = finite(radius).max(0.0); + let s = bounded(smoothing).clamp(0.0, 1.0); + let radius = bounded(radius).max(0.0); let n = points.len(); if s <= 0.0 || radius <= 0.0 || n < 3 { return rounded_polygon(points, radius); @@ -322,11 +330,11 @@ fn line_or_move(path: &mut BezPath, p: Point) { /// An ellipse in the `width`×`height` box; with arc data, a pie/arc/ring: `start` and `sweep` in /// radians (clockwise from 3 o'clock, y-down) and `inner` radius ratio 0..1. pub fn ellipse(width: f64, height: f64, start: f64, sweep: f64, inner: f64) -> BezPath { - let (rx, ry) = (finite(width).max(0.0) / 2.0, finite(height).max(0.0) / 2.0); + let (rx, ry) = (bounded(width).max(0.0) / 2.0, bounded(height).max(0.0) / 2.0); let c = Point::new(rx, ry); - let sweep = finite(sweep).clamp(-TAU, TAU); - let start = finite(start); - let inner = finite(inner).clamp(0.0, 1.0); + let sweep = bounded(sweep).clamp(-TAU, TAU); + let start = bounded(start); + let inner = bounded(inner).clamp(0.0, 1.0); let full = sweep.abs() >= TAU - 1e-9; let mut path = BezPath::new(); if full && inner <= 0.0 { diff --git a/crates/geom/src/measure.rs b/crates/geom/src/measure.rs index 0c24aa9db..010d8499f 100644 --- a/crates/geom/src/measure.rs +++ b/crates/geom/src/measure.rs @@ -66,7 +66,7 @@ impl Polyline { /// The nearest point to `p`: (t by length, distance). pub fn nearest(&self, p: Point) -> Option<(f64, f64)> { let len = self.length(); - if len <= 1e-9 { + if len <= 1e-9 || !(p.x.is_finite() && p.y.is_finite()) { return None; } let mut best: Option<(f64, f64)> = None; diff --git a/crates/geom/src/tests.rs b/crates/geom/src/tests.rs index 37e6ccab7..cbc5f0bc0 100644 --- a/crates/geom/src/tests.rs +++ b/crates/geom/src/tests.rs @@ -54,6 +54,46 @@ fn degenerate_inputs_dont_panic() { let _ = ellipse(f64::NAN, 10.0, f64::NAN, 100.0, 2.0); } +#[test] +fn absurd_sizes_build_bounded_paths() { + // kurbo subdivides an arc in proportion to the sixth root of its radius, so without a cap the + // segment count grows without bound with the input: a 1e36 ellipse was about a million + // elements and 1e60 never returned. Sizes are untrusted (files, commands), so they are capped. + for mag in [1e36, 1e300, f64::MAX] { + let paths = [ + ("ellipse", ellipse(mag, mag, 0.0, PI, 0.0)), + ("ring", ellipse(mag, mag, 0.0, TAU, 0.5)), + ("rounded_rect", rounded_rect(mag, mag, [mag; 4], 0.0)), + ("smoothed rect", rounded_rect(mag, mag, [mag; 4], 0.6)), + ("smoothed_polygon", smoothed_polygon(&polygon_points(mag, mag, 5), mag, 0.6)), + ]; + for (name, p) in paths { + assert!(p.elements().len() < 1_000, "{name} at {mag:e}: {} elements", p.elements().len()); + let b = p.bounding_box(); + assert!([b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite() && v.abs() <= 1.01 * MAX_COORD), "{name} at {mag:e}: {b:?}"); + } + // Points are the caller's to keep sane; a huge radius is still capped. + let p = smoothed_polygon(&[Point::new(0.0, 0.0), Point::new(mag, 0.0), Point::new(mag, mag)], mag, 0.6); + assert!(p.elements().len() < 1_000, "huge points at {mag:e}: {} elements", p.elements().len()); + } + // Sizes a document can hold are untouched. + let p = rounded_rect(1e6, 1e6, [1e5; 4], 0.6); + let b = p.bounding_box(); + assert!((b.width() - 1e6).abs() < 1e-3 && (b.height() - 1e6).abs() < 1e-3, "{b:?}"); +} + +#[test] +fn nearest_refuses_a_non_finite_point() { + let mut p = BezPath::new(); + p.move_to((0.0, 0.0)); + p.line_to((10.0, 0.0)); + let lines = measure::polylines(&p).unwrap(); + assert_eq!(lines[0].nearest(Point::new(f64::NAN, 0.0)), None); + assert_eq!(lines[0].nearest(Point::new(0.0, f64::INFINITY)), None); + let (t, d) = lines[0].nearest(Point::new(5.0, 3.0)).unwrap(); + assert!((t - 0.5).abs() < 1e-9 && (d - 3.0).abs() < 1e-9); +} + #[test] fn polygon_first_vertex_top_center() { let pts = polygon_points(100.0, 100.0, 3); From 33f32c11665de8befc5cc87711a5c5e3073be99f Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sun, 1 Nov 2026 00:14:06 -0400 Subject: [PATCH 02/17] Geom: the bounded-sizes test also covers the cap itself, NaN, infinities and negative sizes Review follow-up on #1774. Co-Authored-By: Claude Fable 5.1 --- crates/geom/src/tests.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/geom/src/tests.rs b/crates/geom/src/tests.rs index cbc5f0bc0..f50ea229b 100644 --- a/crates/geom/src/tests.rs +++ b/crates/geom/src/tests.rs @@ -59,7 +59,8 @@ fn absurd_sizes_build_bounded_paths() { // kurbo subdivides an arc in proportion to the sixth root of its radius, so without a cap the // segment count grows without bound with the input: a 1e36 ellipse was about a million // elements and 1e60 never returned. Sizes are untrusted (files, commands), so they are capped. - for mag in [1e36, 1e300, f64::MAX] { + // At the cap itself (1e9) an arc is still a few dozen segments. + for mag in [MAX_COORD, 1e36, 1e300, f64::MAX] { let paths = [ ("ellipse", ellipse(mag, mag, 0.0, PI, 0.0)), ("ring", ellipse(mag, mag, 0.0, TAU, 0.5)), @@ -76,6 +77,19 @@ fn absurd_sizes_build_bounded_paths() { let p = smoothed_polygon(&[Point::new(0.0, 0.0), Point::new(mag, 0.0), Point::new(mag, mag)], mag, 0.6); assert!(p.elements().len() < 1_000, "huge points at {mag:e}: {} elements", p.elements().len()); } + // NaN, infinities and negative sizes build nothing or a bounded path with a finite box. + for v in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1e36, -1.0] { + let paths = [ + ("ellipse", ellipse(v, v, v, v, v)), + ("rounded_rect", rounded_rect(v, v, [v; 4], v)), + ("smoothed_polygon", smoothed_polygon(&polygon_points(100.0, 100.0, 5), v, v)), + ]; + for (name, p) in paths { + assert!(p.elements().len() < 1_000, "{name} at {v}: {} elements", p.elements().len()); + let b = p.bounding_box(); + assert!([b.x0, b.y0, b.x1, b.y1].iter().all(|x| x.is_finite()), "{name} at {v}: {b:?}"); + } + } // Sizes a document can hold are untouched. let p = rounded_rect(1e6, 1e6, [1e5; 4], 0.6); let b = p.bounding_box(); From 5c34af35f80722046119a26f300211e5cd83b4a2 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Mon, 2 Nov 2026 02:00:21 -0500 Subject: [PATCH 03/17] Geom: the measurement bound includes the cap, as the builders clamp to it Co-Authored-By: Claude Fable 5.1 --- crates/geom/src/measure.rs | 6 ++++-- crates/geom/src/tests.rs | 21 +++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/geom/src/measure.rs b/crates/geom/src/measure.rs index 010d8499f..03de47cd0 100644 --- a/crates/geom/src/measure.rs +++ b/crates/geom/src/measure.rs @@ -8,9 +8,11 @@ pub const FLATTEN_TOL: f64 = 0.05; /// The most flattened points a path is measured with; longer paths are refused. pub const MAX_SAMPLES: usize = 40_000; -/// Every coordinate is finite and of a sane magnitude. +/// Every coordinate is finite and of a sane magnitude: within [`crate::MAX_COORD`], the cap the +/// shape builders hold their sizes, radii and angles to (inclusive, so a path built at the cap +/// still measures). pub fn finite_path(path: &BezPath) -> bool { - let ok = |p: &Point| p.x.is_finite() && p.y.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9; + let ok = |p: &Point| p.x.is_finite() && p.y.is_finite() && p.x.abs() <= crate::MAX_COORD && p.y.abs() <= crate::MAX_COORD; path.elements().iter().all(|el| match el { PathEl::MoveTo(p) | PathEl::LineTo(p) => ok(p), PathEl::QuadTo(a, b) => ok(a) && ok(b), diff --git a/crates/geom/src/tests.rs b/crates/geom/src/tests.rs index f50ea229b..3e9fdce1e 100644 --- a/crates/geom/src/tests.rs +++ b/crates/geom/src/tests.rs @@ -54,6 +54,27 @@ fn degenerate_inputs_dont_panic() { let _ = ellipse(f64::NAN, 10.0, f64::NAN, 100.0, 2.0); } +/// The measurement's sanity bound is the builders' cap, inclusive: a path built at `MAX_COORD` +/// has coordinates exactly there and still measures; one past it doesn't. +#[test] +fn paths_at_the_cap_still_measure() { + use crate::measure::{finite_path, polylines}; + let at_cap = rounded_rect(MAX_COORD, MAX_COORD, [0.0; 4], 0.0); + let b = at_cap.bounding_box(); + assert_eq!((b.x1, b.y1), (MAX_COORD, MAX_COORD), "corners exactly at the cap"); + assert!(finite_path(&at_cap)); + assert!(polylines(&at_cap).is_some(), "a rectangle at the cap measures"); + let mut line = BezPath::new(); + line.move_to(Point::new(-MAX_COORD, 0.0)); + line.line_to(Point::new(MAX_COORD, -MAX_COORD)); + assert!(polylines(&line).is_some(), "a line between the cap's corners measures"); + let mut past = BezPath::new(); + past.move_to(Point::new(0.0, 0.0)); + past.line_to(Point::new(MAX_COORD + 1.0, 0.0)); + assert!(!finite_path(&past)); + assert!(polylines(&past).is_none(), "a coordinate past the cap is refused"); +} + #[test] fn absurd_sizes_build_bounded_paths() { // kurbo subdivides an arc in proportion to the sixth root of its radius, so without a cap the From 5f89b05b7441ef819f9408c8592429d8d195d89b Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Mon, 2 Nov 2026 02:59:39 -0500 Subject: [PATCH 04/17] Format: the unmeasurable text path case sits past the geometry cap Co-Authored-By: Claude Fable 5.1 --- crates/format/src/fig/text_path.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/format/src/fig/text_path.rs b/crates/format/src/fig/text_path.rs index 8a93f7e49..90c631514 100644 --- a/crates/format/src/fig/text_path.rs +++ b/crates/format/src/fig/text_path.rs @@ -298,8 +298,9 @@ mod tests { }; assert_eq!(from_network(&many_lines, 1.0, 1.0, 0.5, true), Err("cannot be measured by text renderer")); + // Past the cap the builders hold their sizes to (`figcraft_geom::MAX_COORD`, inclusive). let huge = Network { - vertices: vec![vertex(0.0, 0.0), vertex(1e9, 0.0)], + vertices: vec![vertex(0.0, 0.0), vertex(2e9, 0.0)], segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0))], ..Network::default() }; From a6e7920b24b6bc1508778068c437408e46f4483b Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Mon, 2 Nov 2026 03:50:41 -0500 Subject: [PATCH 05/17] Geometry: measurement allows a capped arc's control points past the cap Co-Authored-By: Claude Fable 5.1 --- crates/geom/src/measure.rs | 13 +++++++++---- crates/geom/src/tests.rs | 18 ++++++++++++++++-- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/crates/geom/src/measure.rs b/crates/geom/src/measure.rs index 03de47cd0..717ec9d69 100644 --- a/crates/geom/src/measure.rs +++ b/crates/geom/src/measure.rs @@ -8,11 +8,16 @@ pub const FLATTEN_TOL: f64 = 0.05; /// The most flattened points a path is measured with; longer paths are refused. pub const MAX_SAMPLES: usize = 40_000; -/// Every coordinate is finite and of a sane magnitude: within [`crate::MAX_COORD`], the cap the -/// shape builders hold their sizes, radii and angles to (inclusive, so a path built at the cap -/// still measures). +/// How far out a measured coordinate may lie. The shape builders hold their sizes, radii and +/// angles to [`crate::MAX_COORD`], so a path built at the cap has its points there and the +/// control points of its curves a little past them (the cubics approximating an arc reach past +/// the arc's box by up to about a tenth of the radius). Half as much again keeps every built path +/// measurable and still refuses the absurd. +pub const MAX_MEASURED_COORD: f64 = crate::MAX_COORD * 1.5; + +/// Every coordinate is finite and of a sane magnitude (within [`MAX_MEASURED_COORD`]). pub fn finite_path(path: &BezPath) -> bool { - let ok = |p: &Point| p.x.is_finite() && p.y.is_finite() && p.x.abs() <= crate::MAX_COORD && p.y.abs() <= crate::MAX_COORD; + let ok = |p: &Point| p.x.is_finite() && p.y.is_finite() && p.x.abs() <= MAX_MEASURED_COORD && p.y.abs() <= MAX_MEASURED_COORD; path.elements().iter().all(|el| match el { PathEl::MoveTo(p) | PathEl::LineTo(p) => ok(p), PathEl::QuadTo(a, b) => ok(a) && ok(b), diff --git a/crates/geom/src/tests.rs b/crates/geom/src/tests.rs index 3e9fdce1e..24f47e1c5 100644 --- a/crates/geom/src/tests.rs +++ b/crates/geom/src/tests.rs @@ -68,11 +68,25 @@ fn paths_at_the_cap_still_measure() { line.move_to(Point::new(-MAX_COORD, 0.0)); line.line_to(Point::new(MAX_COORD, -MAX_COORD)); assert!(polylines(&line).is_some(), "a line between the cap's corners measures"); + // A curve at the cap: the cubics approximating the arc have control points past the cap, and + // the path still measures. + let arc = ellipse(MAX_COORD, 1.0, -0.15, 0.2, 0.0); + let reach = arc + .elements() + .iter() + .flat_map(|el| match el { + kurbo::PathEl::CurveTo(a, b, _) => vec![a.x, b.x], + _ => vec![], + }) + .fold(0.0, f64::max); + assert!(reach > MAX_COORD, "a control point lies past the cap: {reach}"); + assert!(finite_path(&arc)); + assert!(polylines(&arc).is_some(), "an arc at the cap measures"); let mut past = BezPath::new(); past.move_to(Point::new(0.0, 0.0)); - past.line_to(Point::new(MAX_COORD + 1.0, 0.0)); + past.line_to(Point::new(2.0 * MAX_COORD, 0.0)); assert!(!finite_path(&past)); - assert!(polylines(&past).is_none(), "a coordinate past the cap is refused"); + assert!(polylines(&past).is_none(), "a coordinate well past the cap is refused"); } #[test] From 89d411d50fd673b251ec7cec977543c5a0cf1c61 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Tue, 3 Nov 2026 09:29:49 -0500 Subject: [PATCH 06/17] M1: Layers panel component rows in the theme's component tint, at AA contrast (#1903) The Layers panel drew component, variant and instance names and icons in the canvas outline purple whatever the theme (3.1:1 on the dark panel, 2.7:1 on the selected row), and the rows inside a selected component in the plain selection's green. They take the theme's component token (the dark theme's lilac; the light theme's a shade darker than the canvas purple, so it reads at AA on a selected and a hovered row too), and a new component_row_child token paints the rows inside a selected component in a tone of its row. The canvas keeps its purple. Test: layers_component_rows_tests (both themes). Docs: ui-parity themes row, status line and revision history. Co-Authored-By: Claude Fable 5.1 --- .../src/layers_component_rows_tests.rs | 93 +++++++++++++++++++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels/layers.rs | 40 +++++--- crates/ui-egui/src/theme.rs | 15 ++- docs/ui-parity.md | 5 +- 5 files changed, 138 insertions(+), 17 deletions(-) create mode 100644 crates/ui-egui/src/layers_component_rows_tests.rs diff --git a/crates/ui-egui/src/layers_component_rows_tests.rs b/crates/ui-egui/src/layers_component_rows_tests.rs new file mode 100644 index 000000000..8b659eac7 --- /dev/null +++ b/crates/ui-egui/src/layers_component_rows_tests.rs @@ -0,0 +1,93 @@ +//! Component rows in the Layers panel (#1903), drawn headless in both themes: a component's name +//! reads at WCAG AA (4.5:1) on the panel and on its selected row, and the rows inside a selected +//! component take a tone of its row, not the plain selection's green. + +use egui::{Color32, Pos2, Shape}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; +use crate::theme::Tokens; + +fn id(v: serde_json::Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn color(x: Color32) -> figcraft_color::Color { + figcraft_color::Color::new(f32::from(x.r()) / 255.0, f32::from(x.g()) / 255.0, f32::from(x.b()) / 255.0, 1.0) +} + +/// The Layers panel with the component "Button" (holding the rectangle "Label") selected and +/// expanded, in the `dark` or light theme: the frame's shapes and the theme's tokens. +fn drawn(dark: bool) -> (Vec, Tokens) { + let mut s = Session::new(); + let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 40, "props": {"name": "Button"}})).unwrap()); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 20, "height": 20, "parent": f, "props": {"name": "Label"}})).unwrap(); + let c = id(s.execute("component.create", &json!({"ids": [f]})).unwrap()); + s.execute(if dark { "view.themeDark" } else { "view.themeLight" }, &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [c]})).unwrap(); + let mut u = Ui::new(s); + u.app.ui.expanded.insert(c); + u.frame(vec![]); + let shapes = crate::live_tests::flat(&u.frame_output(vec![]).shapes); + (shapes, Tokens::get(&u.ctx)) +} + +/// The colour the name `s` is painted in. +fn name_colour(shapes: &[Shape], s: &str) -> Color32 { + shapes + .iter() + .find_map(|sh| match sh { + Shape::Text(t) if t.galley.text() == s => Some(t.fallback_color), + _ => None, + }) + .unwrap_or_else(|| panic!("no name {s:?} painted")) +} + +/// Where the name `s` is painted (its top left). +fn name_pos(shapes: &[Shape], s: &str) -> Pos2 { + shapes + .iter() + .find_map(|sh| match sh { + Shape::Text(t) if t.galley.text() == s => Some(t.pos), + _ => None, + }) + .unwrap_or_else(|| panic!("no name {s:?} painted")) +} + +/// Whether a rectangle filled in `c` is painted under `p`. +fn rect_under(shapes: &[Shape], c: Color32, p: Pos2) -> bool { + shapes.iter().any(|s| matches!(s, Shape::Rect(r) if r.fill == c && r.rect.contains(p))) +} + +#[test] +fn component_names_read_at_aa_on_the_panel_and_on_their_selected_row() { + for dark in [false, true] { + let (shapes, t) = drawn(dark); + assert_eq!(t.dark, dark); + let col = name_colour(&shapes, "Button"); + assert_eq!(col, t.component, "the theme's component tint (dark {dark})"); + for (what, bg) in [("the panel", t.panel), ("the selected component row", t.component_row), ("a hovered row", t.hover)] { + let ratio = color(col).contrast(color(bg)); + assert!(ratio >= 4.5, "{ratio:.2}:1 on {what} (dark {dark})"); + } + // The layer inside it is a plain layer: the theme's text colour. + assert_eq!(name_colour(&shapes, "Label"), t.text); + } +} + +#[test] +fn rows_inside_a_selected_component_take_a_tone_of_its_row() { + for dark in [false, true] { + let (shapes, t) = drawn(dark); + let button = name_pos(&shapes, "Button"); + let label = name_pos(&shapes, "Label"); + // Checked under each name, by row: the pages list paints the current page in the plain + // selection's `selected_row` too. + assert!(rect_under(&shapes, t.component_row, button), "the component's own row (dark {dark})"); + assert!(rect_under(&shapes, t.component_row_child, label), "the row inside it (dark {dark})"); + assert!(!rect_under(&shapes, t.selected_row, button), "not the plain selection's row (dark {dark})"); + assert!(!rect_under(&shapes, t.selected_row_child, label), "not the plain selection's child tone (dark {dark})"); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e9969c106..f10d5c732 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -134,6 +134,8 @@ mod inspect_fields_tests; #[cfg(test)] mod layers_behaviour_tests; #[cfg(test)] +mod layers_component_rows_tests; +#[cfg(test)] mod layers_drag_tests; #[cfg(test)] mod layers_keyboard_tests; diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 164ec7e4a..9002cd031 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -10,7 +10,7 @@ use serde_json::json; use crate::a11y::{self, Region, RowInfo, Tip}; use crate::i18n::{tr, trf, trn}; use crate::icons::{self, Icon}; -use crate::theme::{ACCENT, COMPONENT, Tokens, regular, semibold}; +use crate::theme::{ACCENT, Tokens, regular, semibold}; use crate::{FigApp, LeftTab}; pub const ROW: f32 = 32.0; @@ -735,10 +735,17 @@ fn drag_autoscroll(ui: &mut Ui, dragging: bool) { } } -/// A row's icon or name colour: the component tint (faded when hidden), else `normal` or `faded`. -fn row_color(component: bool, shown: bool, normal: Color32, faded: Color32) -> Color32 { +/// Whether a layer is a component, a variant set or an instance: its rows take the component tint. +fn is_component(kind: &NodeKind) -> bool { + matches!(kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. }) +} + +/// A row's icon or name colour: the theme's component tint (faded when hidden; it reads at AA on +/// the panel and on the selected row, where the canvas purple doesn't, #1903), else `normal` or +/// `faded`. +fn row_color(t: &Tokens, component: bool, shown: bool, normal: Color32, faded: Color32) -> Color32 { if component { - COMPONENT.gamma_multiply(if shown { 1.0 } else { 0.5 }) + t.component.gamma_multiply(if shown { 1.0 } else { 0.5 }) } else if shown { normal } else { @@ -957,15 +964,16 @@ fn reveal_selection(app: &mut FigApp, doc: &Document, page: NodeId, sel: &[NodeI } /// A row's background: selected, inside a selected layer, or outlined when hovered. -/// `state` is [selected, in_selected, component]. -fn paint_row_bg(ui: &Ui, t: &Tokens, rr: Rect, state: [bool; 3], hovered: bool) { - let [selected, in_selected, is_comp] = state; +/// `state` is [selected, in_selected, component, in_component]: a selected component's row and the +/// rows inside it take the component tint, not the plain selection's green (#1903). +fn paint_row_bg(ui: &Ui, t: &Tokens, rr: Rect, state: [bool; 4], hovered: bool) { + let [selected, in_selected, is_comp, in_comp] = state; if selected { ui.painter().rect_filled(rr, CornerRadius::same(5), if is_comp { t.component_row } else { t.selected_row }); } else if in_selected { - ui.painter().rect_filled(rr, CornerRadius::ZERO, t.selected_row_child); + ui.painter().rect_filled(rr, CornerRadius::ZERO, if in_comp { t.component_row_child } else { t.selected_row_child }); } else if hovered { - ui.painter().rect_stroke(rr, CornerRadius::same(5), Stroke::new(1.0, if is_comp { COMPONENT } else { ACCENT }), egui::StrokeKind::Inside); + ui.painter().rect_stroke(rr, CornerRadius::same(5), Stroke::new(1.0, if is_comp { t.component } else { ACCENT }), egui::StrokeKind::Inside); } } @@ -1087,13 +1095,17 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { locked: n.locked, }; a11y::tree_item(&resp, if n.name.is_empty() { tr(n.kind.label()) } else { &n.name }, &info); - let in_selected = !selected && doc.ancestors(row.id).iter().any(|a| sel.contains(a)); - let is_comp = matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. }); + // The nearest selected layer above this row: inside a selected component, variant set + // or instance, the rows take its tint. + let selected_above = if selected { None } else { doc.ancestors(row.id).into_iter().find(|a| sel.contains(a)) }; + let in_selected = selected_above.is_some(); + let in_comp = selected_above.and_then(|a| doc.node(a)).is_some_and(|a| is_component(&a.kind)); + let is_comp = is_component(&n.kind); let rr = r.shrink2(vec2(8.0, 0.0)); a11y::ring_around(ui, rr, &resp); // Over the row, its eye and padlock included (they sit on top of it). let over = resp.contains_pointer(); - paint_row_bg(ui, &t, rr, [selected, in_selected, is_comp], over || hover == Some(row.id)); + paint_row_bg(ui, &t, rr, [selected, in_selected, is_comp, in_comp], over || hover == Some(row.id)); if over { hovered_row = Some(row.id); } @@ -1115,7 +1127,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { } let icon = node_icon(&doc, row.id); let shown = n.visible && !row.dimmed; - let icon_col = row_color(is_comp || icon == Icon::Slot, shown, t.icon, t.text_tertiary); + let icon_col = row_color(&t, is_comp || icon == Icon::Slot, shown, t.icon, t.text_tertiary); icons::paint(ui.painter(), Rect::from_center_size(pos2(x0 + 22.0, r.center().y), vec2(14.0, 14.0)), icon, icon_col); let name_x = x0 + 36.0; if app.ui.rename.is_some_and(|rn| rn.id == row.id) { @@ -1123,7 +1135,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { rename_done = Some((row.id, new)); } } else { - let col = row_color(is_comp, shown, t.text, t.text_tertiary); + let col = row_color(&t, is_comp, shown, t.text, t.text_tertiary); let top_level = doc.node(n.parent.unwrap_or(page)).is_some_and(|p| p.is_page()); let font = if top_level && (n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. })) { semibold(11.0) } else { regular(11.0) }; diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 200dd72bc..173bdde4d 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -29,8 +29,15 @@ pub struct Tokens { pub accent_text: Color32, pub selected_row: Color32, pub selected_row_child: Color32, + /// Component, variant and instance names and icons in the panels: at least 4.5:1 (WCAG AA + /// for text) on the panel, on a hovered row and on `component_row`. The canvas keeps + /// [`COMPONENT`] for its outlines and labels. pub component: Color32, + /// A selected component's row in the Layers panel. pub component_row: Color32, + /// The rows inside a selected component: a tone of its row (`selected_row_child` is the plain + /// selection's). + pub component_row_child: Color32, pub danger: Color32, pub success: Color32, pub warning: Color32, @@ -115,8 +122,11 @@ impl Tokens { accent_text: Color32::from_rgb(0x00, 0x7A, 0x56), selected_row: Color32::from_rgb(0xE0, 0xFB, 0xF1), selected_row_child: Color32::from_rgb(0xF1, 0xFC, 0xF7), - component: COMPONENT, + // A shade darker than the canvas purple: 5.5:1 on white, 4.7:1 on the selected + // component row and 5.0:1 on a hovered row (the canvas purple reaches 3.8:1 and 4.1:1). + component: Color32::from_rgb(0x86, 0x38, 0xF0), component_row: Color32::from_rgb(0xF1, 0xE9, 0xFF), + component_row_child: Color32::from_rgb(0xF8, 0xF4, 0xFF), danger: Color32::from_rgb(0xF2, 0x48, 0x22), success: DEV_READY, warning: Color32::from_rgb(0xFF, 0xCD, 0x29), @@ -155,8 +165,11 @@ impl Tokens { accent_text: Color32::from_rgb(0x52, 0xE9, 0xB7), selected_row: Color32::from_rgb(0x16, 0x42, 0x36), selected_row_child: Color32::from_rgb(0x12, 0x37, 0x2D), + // 5.4:1 on the panel, 4.6:1 on the selected component row, 4.5:1 on a hovered row + // (the canvas purple reaches 3.1:1 and 2.7:1, #1903). component: Color32::from_rgb(0xD2, 0x7C, 0xFF), component_row: Color32::from_rgb(0x3E, 0x30, 0x52), + component_row_child: Color32::from_rgb(0x36, 0x2B, 0x47), danger: Color32::from_rgb(0xF2, 0x48, 0x22), success: DEV_READY, warning: Color32::from_rgb(0xFF, 0xCD, 0x29), diff --git a/docs/ui-parity.md b/docs/ui-parity.md index cc0fc9db8..b541a4649 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-11-02 · **Change:** minor (a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; 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; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; 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; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **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: @@ -16,7 +16,7 @@ interaction feel not yet compared. | Item | Status | Notes | |---|---|---| | UI3 layout: tab strip as title bar, icon rail, file header, pages + layers, floating bottom toolbar with flyouts, Design / Prototype / Inspect panels | done | frameless window on Windows/Linux with drawn buttons; on a Wayland compositor that offers no server-side decorations (GNOME) the window gets winit's Adwaita controls instead (#592; checked by its author on GNOME, KDE not run) | -| Light and dark themes | done | colours from `theme::Tokens`; a file or a page made while the dark theme shows gets a `#1E1E1E` page (`#F5F5F5` in the light theme), its own from then on: changing the theme repaints no page and opened files keep theirs; the file the app opens with takes the theme once it is known; the CLI, MCP and exports have no theme and keep `#F5F5F5`; text drawn with the Text tool is white on a dark page and black on a light one (so is text a command makes in the app without naming a fill; headless sessions keep black) (`theme_pages_tests`, `dark_page_text_tests`) | +| Light and dark themes | done | colours from `theme::Tokens`; component, variant and instance rows in the Layers panel take the theme's component tint, at AA on the panel and on their selected row, and the rows inside a selected component a tone of its row (`layers_component_rows_tests`, #1903); a file or a page made while the dark theme shows gets a `#1E1E1E` page (`#F5F5F5` in the light theme), its own from then on: changing the theme repaints no page and opened files keep theirs; the file the app opens with takes the theme once it is known; the CLI, MCP and exports have no theme and keep `#F5F5F5`; text drawn with the Text tool is white on a dark page and black on a light one (so is text a command makes in the app without naming a fill; headless sessions keep black) (`theme_pages_tests`, `dark_page_text_tests`) | | Minimize UI (Shift+Cmd/Ctrl+\\, the Minimize button by the file name) and Show/Hide UI (Cmd/Ctrl+\\) | done | Minimize collapses the navigation bar and both sidebars and keeps the toolbar and the panel widths; "If you select an object while the panels are minimized, the left sidebar remains minimized while the right sidebar expands [...] Once all objects are deselected, the right panel minimizes again" (Explore the navigation bar and left sidebar); a small Expand button at the top left of the canvas puts them back (FigCraft's own, the article names no control for it). Hide UI takes the toolbar too (`panel_width_tests`, `shortcuts_tests`; #1835) | | Resizable panels | done | each panel's edge drags its width (left 200–480, right 240–480, 240 by default; a double-click on the edge or View ▸ Panels ▸ Reset panel widths puts the default back); the widths are each window's own, the main window's are kept in the preferences when a drag ends and come back at the next start, a new window opens with those of the window it comes from; Minimize UI hides the panels and brings them back as they were; the view keeps its centre and zoom while a panel is resized; in a window too small for them the panels give way so the canvas keeps 160 px (the left one first), down to the smallest window (800×500) (`panel_width_tests`). Not compared with Figma's widths; no snap points | | Design panel's sections on one grid | done | every section lays its rows out on the same grid: the panel is 240 pt, its content 16 pt in from the left and 8 pt from the right, two 88 pt columns at that width with 8 pt gutters and a 24 pt column for the buttons at a row's end (the columns are flex, so they share the panel when it is dragged wider); rows 24 pt in a 32 pt track, 10 pt captions over them; section titles 11 pt semibold and a row's values 11 pt, their buttons 24 pt and 4 pt apart. The panel's edge is drawn inside it: a frame stroke counts as margin and took 1 pt off each side, putting every right margin 1–2 pt out (checked in the app on Windows over the control channel) | @@ -178,6 +178,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-11-03 | patch | M1, #1903: the Layers panel drew component, variant and instance names in the canvas outline purple whatever the theme (3.1:1 on the dark panel, 2.7:1 on the selected row, under WCAG AA's 4.5:1), and the rows inside a selected component in the green of a plain selection. They take the theme's component tint (`Tokens::component`: the dark theme's lilac; the light theme's a shade darker than the canvas purple, so it reads at AA on a selected and a hovered row too), and the rows inside a selected component a tone of its row (`component_row_child`). The selection's own green is FigCraft's and stays; the canvas keeps its purple. Test: `layers_component_rows_tests`. Numbers unchanged. | | 2026-11-02 | patch | M8, #1875: a click with the Bend tool on a sharp point between two segments gives it handles along the line between its neighbours, a third of the way to each ("Click on a point or path where you want to add a curve", Edit vector layers); a click on a curved point still makes it sharp, a drag still sets the handles by hand, an endpoint is left alone. Between a handle's end and a point both within reach of the pointer, the nearer one on the page is hit (the handle on a tie), so a short path's point (its handles a few pixels long) can be clicked again. Before, only a drag curved a point, and a short one left two tiny handles in its direction. Tests: `vectoredit::tests::short_handles_leave_their_point_clickable`, `vectoredit::tests::a_bend_click_curves_a_sharp_point_between_its_neighbours`, `bend_tool_smooths_and_sharpens_points`. Numbers unchanged. | | 2026-11-02 | minor | M4, #1827: wrap style on a text layer (`WrapStyle`: Auto, Balance, Pretty): Balance re-breaks each paragraph at the narrowest width that keeps its line count (bisection over the greedy breaker, as browsers balance), Pretty narrows one of the last four lines just enough so the last line isn't one word; `text.wrapStyle`, `node.set wrapStyle` (`textWrap` too), inspect `wrapStyle`, Dev Mode CSS `text-wrap` (Tailwind `text-balance` / `text-pretty`), the Type settings control under Vertical trim. Per-paragraph settings, text-style support and the `.fig` import/export mapping stay open. Test: `text_behaviour_tests::balance_and_pretty_wrap_styles_break_lines_as_documented`. | | 2026-11-02 | patch | M4: Backspace or Delete at the start of a list item takes its marker and keeps its level ("Use Backspace or Delete at the beginning of a list item to delete the counter, but keep the same level of indentation", Create bulleted and numbered lists): a plain paragraph may now carry a list level, and the text layout starts it where that level's item text starts (the gutters stay even when no list is left). Backspace used to drop the level too, and Delete at an item's start deleted the next character. Tab on such a paragraph types a tab, as outside a list. Tests: `text_tail_tests::list_keys_at_list_items`, text crate `behaviour_tests::a_plain_paragraph_at_a_list_level_starts_where_the_items_text_does` | From 4682c9dd755c791f06271911913fed2a522ec04e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 18:03:47 +0000 Subject: [PATCH 07/17] fix(cli,ui): escape control characters in document and plugin text sent to the terminal --- apps/figcraft-cli/src/files.rs | 22 +++++++++++++++------- crates/ui-egui/src/app/commands.rs | 3 ++- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index 29f8cd5b6..c4cc7d5f6 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -199,26 +199,33 @@ pub fn info(path: &str, as_json: bool) -> CliResult { return Ok(ExitCode::SUCCESS); } for n in ¬es { - eprintln!("note: {n}"); + eprintln!("note: {}", n.escape_debug()); } println!( "{} — {} ({} nodes, {} styles, {} variables, {} images)", - path, - doc.name, + path.escape_debug(), + doc.name.escape_debug(), doc.node_count(), doc.styles.len(), doc.variables.variables.len(), doc.images.len() ); for p in &pages { - println!("page {} \"{}\": {} nodes, {} top-level", p["id"], p["name"].as_str().unwrap_or(""), p["nodes"], p["topLevel"]); + println!("page {} \"{}\": {} nodes, {} top-level", p["id"], p["name"].as_str().unwrap_or("").escape_debug(), p["nodes"], p["topLevel"]); for f in p["frames"].as_array().into_iter().flatten() { - println!(" {} {} \"{}\" {}×{}", f["id"], f["type"].as_str().unwrap_or(""), f["name"].as_str().unwrap_or(""), f["width"], f["height"]); + println!( + " {} {} \"{}\" {}×{}", + f["id"], + f["type"].as_str().unwrap_or(""), + f["name"].as_str().unwrap_or("").escape_debug(), + f["width"], + f["height"] + ); } } for m in &missing { let layers = if m.layers == 1 { "text layer" } else { "text layers" }; - println!("missing font \"{}\": {} {layers} (drawn in a fallback font)", m.family, m.layers); + println!("missing font \"{}\": {} {layers} (drawn in a fallback font)", m.family.escape_debug(), m.layers); } Ok(ExitCode::SUCCESS) } @@ -438,7 +445,8 @@ pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliRe let cmd = steps.get(failed_at).map_or("", |step| step.command.as_str()); let i = failed_at; println!("{}", json!({"step": i, "command": cmd, "ok": false, "error": e.to_string(), "rolledBack": true})); - eprintln!("error: step {i} ({cmd}) failed: {e}"); + // Plugin-supplied text: escape terminal controls and line breaks. + eprintln!("error: step {i} ({}) failed: {}", cmd.escape_debug(), e.to_string().escape_debug()); eprintln!("the script's document changes were rolled back"); return Ok(ExitCode::from(1)); } diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 93762e579..960291382 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -55,7 +55,8 @@ impl FigApp { /// Show command `id`'s error. pub(crate) fn failed(&mut self, id: &str, e: &figcraft_engine::EngineError) { let msg = e.to_string(); - log::warn!("{id}: {msg}"); + // A plugin's failure text may carry CR or terminal controls: log it escaped. + log::warn!("{id}: {}", msg.escape_debug()); self.show_error(msg); } From 5233c451de5b570eb472adbd4b9151088ad492fd Mon Sep 17 00:00:00 2001 From: SoldierPePe Date: Sat, 31 Oct 2026 18:21:35 +0300 Subject: [PATCH 08/17] M10: a right-to-left UI language mirrors the window With the UI in Arabic (or another right-to-left language), the icon rail and the layers panel go on the right and the Design panel on the left. The panel edges size the panels dragged the other way, with the same width preferences. The Design panel's edge line, the colour picker, the style picker and the interaction editor sit on its canvas side, and egui's own chrome mirrors through Style::right_to_left. Rows inside the panels, the toolbar and the tab bar keep their order for now. --- ROADMAP.md | 2 +- crates/ui-egui/src/app.rs | 37 +++++++-- crates/ui-egui/src/i18n.rs | 12 +++ crates/ui-egui/src/i18n/tests.rs | 15 +++- crates/ui-egui/src/lib.rs | 6 ++ crates/ui-egui/src/mirror_tests.rs | 91 ++++++++++++++++++++++ crates/ui-egui/src/panels/proto_panel.rs | 15 ++-- crates/ui-egui/src/panels/right.rs | 4 +- crates/ui-egui/src/popover.rs | 10 +++ crates/ui-egui/src/popover_tests.rs | 21 +++++ crates/ui-egui/src/tabs.rs | 4 +- crates/ui-egui/src/widgets/colorpicker.rs | 14 +++- crates/ui-egui/src/widgets/style_picker.rs | 14 ++-- docs/gaps.md | 4 +- docs/localization-parity.md | 18 +++-- 15 files changed, 235 insertions(+), 32 deletions(-) create mode 100644 crates/ui-egui/src/mirror_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 410705919..4c0b108af 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -103,7 +103,7 @@ native speakers' reviews of the nine language drafts need people. | Simplified Chinese | zh-Hans | 95% | draft (needs native review) | | Spanish | es, es-419 | 100% (es-419: an overlay of 101 entries) | draft (needs native review) | | Hindi | hi | 0% | none | -| Arabic | ar | 2,508 / 2,508 entries (100%, counted 2026-10-28) | draft; native review and panel mirroring pending | +| Arabic | ar | 2,508 / 2,508 entries (100%, counted 2026-10-28) | draft; the window mirrors (panels swap sides); native review and mirrored panel rows pending | | French | fr | 95% | draft (needs native review) | | Portuguese (Brazil) | pt-BR | 95% | draft (needs native review) | | Indonesian | id | 99% | draft (needs native review) | diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index e13acce68..bd16057ad 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -79,6 +79,13 @@ impl FigApp { if self.language.as_ref() != Some(&self.session.prefs.language) { i18n::set(&self.session.prefs.language, self.services.system_language().as_deref()); self.language = Some(self.session.prefs.language.clone()); + self.ui.mirrored = i18n::right_to_left(); + } + // egui's own chrome follows: scroll bars on the left, submenus opening to the left, + // checkboxes with their label on the left. + if ctx.global_style().right_to_left != self.ui.mirrored { + let mirrored = self.ui.mirrored; + ctx.all_styles_mut(|s| s.right_to_left = mirrored); } // Screen-reader and bidi language of the UI (fork E17). ctx.set_language(i18n::current().map(|l| l.code.to_string())); @@ -288,8 +295,12 @@ impl FigApp { self.ui.narrow = narrow; // A narrow window shows the right panel when its button asks for it, a board's too. let right_open = (self.right_panel_shown(hidden) && !narrow && !right_minimized) || mobile == Some(MobilePanel::Right); + // A right-to-left UI language mirrors the window: the "left" rail and panel go on the + // right, the "right" (Design) panel on the left. Their ids and widths stay as they are. + let mirrored = self.ui.mirrored; + let side = |start: bool, id: &'static str| if start != mirrored { egui::Panel::left(id) } else { egui::Panel::right(id) }; if !hidden && !minimized && (!narrow || mobile == Some(MobilePanel::Left)) { - egui::Panel::left("left_rail") + side(true, "left_rail") .exact_size(layers::RAIL_WIDTH) .resizable(false) .frame(egui::Frame::NONE.fill(t.panel).stroke(egui::Stroke::new(1.0, t.border))) @@ -308,11 +319,18 @@ impl FigApp { }; let rows = ui.available_rect_before_wrap().y_range(); let mut edges: Vec<(&str, f32, f32, f32)> = Vec::new(); + // The x of the edge facing the canvas of a panel `w` wide on the window's start (left) + // side, or its end side, and which way a drag to the right sizes it. + let edge = |ui: &egui::Ui, start: bool, w: f32| { + let room = ui.available_rect_before_wrap(); + if start != mirrored { (room.min.x + w, 1.0) } else { (room.max.x - w, -1.0) } + }; if left_open { if !narrow { - edges.push(("left", ui.available_rect_before_wrap().min.x + left_w, left_w, 1.0)); + let (x, grows) = edge(ui, true, left_w); + edges.push(("left", x, left_w, grows)); } - egui::Panel::left("left_panel") + side(true, "left_panel") .resizable(false) .exact_size(left_w) .frame(egui::Frame::NONE.fill(t.panel).stroke(egui::Stroke::new(1.0, t.border))) @@ -329,9 +347,10 @@ impl FigApp { self.ui.right_panel_rect = None; if right_open { if !narrow { - edges.push(("right", ui.available_rect_before_wrap().max.x - right_w, right_w, -1.0)); + let (x, grows) = edge(ui, false, right_w); + edges.push(("right", x, right_w, grows)); } - egui::Panel::right("right_panel") + side(false, "right_panel") .resizable(false) .exact_size(right_w) // No stroke: a frame's stroke is part of its margin, which would take 1 pt off @@ -360,10 +379,14 @@ impl FigApp { } }); if narrow { - let x = if mobile == Some(MobilePanel::Left) { ctx.content_rect().max.x - MOBILE_STRIP + 4.0 } else { ctx.content_rect().min.x + 4.0 }; + // In the strip beside the open panel; with none open, on the Design panel's side. + let at_right = (mobile == Some(MobilePanel::Left)) != mirrored; + let x = if at_right { ctx.content_rect().max.x - MOBILE_STRIP + 4.0 } else { ctx.content_rect().min.x + 4.0 }; self.mobile_buttons(&ctx, egui::pos2(x, rows.min + 8.0)); } else if minimized { - self.expand_button(&ctx, egui::pos2(ctx.content_rect().min.x + 8.0, rows.min + 8.0)); + // Where the navigation bar was: on the right in a mirrored window. + let x = if mirrored { ctx.content_rect().max.x - 40.0 } else { ctx.content_rect().min.x + 8.0 }; + self.expand_button(&ctx, egui::pos2(x, rows.min + 8.0)); } // The panels' edges, over the canvas and the panels (drawn after them: they take the press). for (name, x, shown, grows) in edges { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index d05f4d03a..380a78076 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -60,6 +60,18 @@ pub fn current() -> Option<&'static Lang> { LANGS.get(CURRENT.load(Ordering::Relaxed)) } +/// Whether the language the UI shows is written right to left, so the window mirrors. +pub fn right_to_left() -> bool { + current().is_some_and(|l| writes_right_to_left(l.code)) +} + +/// Whether language `code` ("ar", "he-IL"…) is written right to left: Arabic, Hebrew, Persian, +/// Urdu, Pashto, Sindhi, Kurdish (Sorani), Uyghur, Dhivehi and Yiddish. +fn writes_right_to_left(code: &str) -> bool { + let base = code.split(['-', '_']).next().unwrap_or(""); + ["ar", "he", "fa", "ur", "ps", "sd", "ckb", "ug", "dv", "yi"].iter().any(|rtl| rtl.eq_ignore_ascii_case(base)) +} + /// Show the language of `pref` (a language code, or "system" for `system`, the OS or browser /// languages, most preferred first and comma-separated). Unknown languages show English. pub fn set(pref: &str, system: Option<&str>) { diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 1cc4623e8..489672f0f 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -4,7 +4,7 @@ use std::collections::BTreeSet; -use super::{Forms, LANGS, Lang, Plural, Text, chain, counted, fill, find, lookup, plural, plural_one, resolve}; +use super::{Forms, LANGS, Lang, Plural, Text, chain, counted, fill, find, lookup, plural, plural_one, resolve, writes_right_to_left}; /// Keys looked up from data rather than written as `tr("…")` in the code: the main menu's /// submenu names (the engine's menu catalog) and labels of engine and document enums. @@ -550,6 +550,19 @@ fn arabic_tags_and_count_neutral_forms() { } } +#[test] +fn right_to_left_languages_mirror_the_window() { + for code in ["ar", "ar-SA", "AR", "he", "he-IL", "fa", "ur", "ckb", "yi"] { + assert!(writes_right_to_left(code), "{code}"); + } + for code in ["en", "", "es-419", "zh-Hans", "tr", "ru", "arn", "hey"] { + assert!(!writes_right_to_left(code), "{code}"); + } + // Every catalog that mirrors is one of them, and the Arabic one does. + assert!(LANGS.iter().any(|l| l.code == "ar" && writes_right_to_left(l.code))); + assert!(!writes_right_to_left(LANGS[0].code), "English reads left to right"); +} + #[test] fn the_built_in_arabic_face_draws_the_arabic_catalog() { if !figcraft_text::CRAFT_FONTS.iter().any(|f| f.family == "Noto Sans Arabic" && f.style == "Regular") { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e9969c106..d41d51ace 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -172,6 +172,8 @@ mod menu_contrast_tests; #[cfg(test)] mod menu_input_tests; #[cfg(test)] +mod mirror_tests; +#[cfg(test)] mod missing_fonts_tests; #[cfg(test)] mod online_tests; @@ -545,6 +547,9 @@ pub struct UiState { pub mobile_panel: Option, /// The window was narrow (one side panel at a time) at the last frame. pub narrow: bool, + /// The window is mirrored for a right-to-left UI language: the icon rail and the layers panel + /// on the right, the Design panel on the left, popovers beside it on its right. + pub mirrored: bool, pub comments: comments::CommentUi, pub versions: versions::VersionUi, /// The branch review dialog, while open. @@ -693,6 +698,7 @@ impl Default for UiState { left_collapsed: false, mobile_panel: None, narrow: false, + mirrored: false, comments: comments::CommentUi::default(), versions: versions::VersionUi::default(), branch_review: None, diff --git a/crates/ui-egui/src/mirror_tests.rs b/crates/ui-egui/src/mirror_tests.rs new file mode 100644 index 000000000..b64ae828c --- /dev/null +++ b/crates/ui-egui/src/mirror_tests.rs @@ -0,0 +1,91 @@ +//! A right-to-left UI language mirrors the window, driven through egui headless: the icon rail +//! and the layers panel go on the right, the Design panel on the left, their edges still size them +//! (now dragged the other way) and the widths are the same preferences; egui's own chrome (scroll +//! bars, submenus) mirrors with it, and an English window is as it was. + +use egui::pos2; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; +use crate::{PANEL_DEFAULT, layers}; + +const WINDOW_W: f32 = 1440.0; + +fn ui(mirrored: bool) -> Ui { + let mut s = Session::new(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 300, "height": 200})).unwrap(); + let mut u = Ui::new(s); + u.app.ui.mirrored = mirrored; + u.frame(vec![]); + u.frame(vec![]); + u +} + +/// The canvas's left and right x. +fn canvas(u: &Ui) -> (f32, f32) { + (u.app.canvas.rect.min.x, u.app.canvas.rect.max.x) +} + +/// Press on `from`, move to `to` in steps and let go. +fn drag(u: &mut Ui, from: egui::Pos2, to: egui::Pos2) { + u.drag_to(from, to); + u.button(to, false); + u.frame(vec![]); +} + +#[test] +fn the_design_panel_is_on_the_left_and_the_layers_on_the_right() { + let u = ui(true); + let (left, right) = canvas(&u); + assert_eq!(left, PANEL_DEFAULT, "the Design panel from the window's left edge"); + assert_eq!(right, WINDOW_W - layers::RAIL_WIDTH - PANEL_DEFAULT, "the layers panel and the rail on the right"); + // egui keeps a point between a side panel and the canvas, on the canvas side. + let design = u.app.ui.right_panel_rect.expect("the Design panel drew"); + assert_eq!((design.min.x, design.max.x), (0.0, PANEL_DEFAULT - 1.0)); + assert!(u.ctx.global_style().right_to_left, "egui's chrome mirrors too"); +} + +#[test] +fn an_english_window_is_as_it_was() { + let u = ui(false); + let (left, right) = canvas(&u); + assert_eq!((left, right), (layers::RAIL_WIDTH + PANEL_DEFAULT, WINDOW_W - PANEL_DEFAULT)); + let design = u.app.ui.right_panel_rect.expect("the Design panel drew"); + assert_eq!((design.min.x, design.max.x), (WINDOW_W - PANEL_DEFAULT + 1.0, WINDOW_W)); + assert!(!u.ctx.global_style().right_to_left); +} + +#[test] +fn mirrored_edges_size_their_panels_dragged_the_other_way() { + let mut u = ui(true); + let (left, right) = canvas(&u); + // The layers panel's edge (the canvas's right), 60 to the left: the layers panel 60 wider. + drag(&mut u, pos2(right, 400.0), pos2(right - 60.0, 400.0)); + assert_eq!((u.app.ui.left_width, u.app.ui.right_width), (PANEL_DEFAULT + 60.0, PANEL_DEFAULT)); + assert_eq!(canvas(&u), (left, right - 60.0)); + // The Design panel's edge (the canvas's left), 40 to the right: the Design panel 40 wider. + drag(&mut u, pos2(left, 400.0), pos2(left + 40.0, 400.0)); + assert_eq!((u.app.ui.left_width, u.app.ui.right_width), (PANEL_DEFAULT + 60.0, PANEL_DEFAULT + 40.0)); + assert_eq!(canvas(&u), (left + 40.0, right - 60.0)); + // The same preferences as an English window's. + assert_eq!(u.app.session.prefs.panel_widths, (PANEL_DEFAULT + 60.0, PANEL_DEFAULT + 40.0)); +} + +#[test] +fn closing_the_layers_panel_gives_the_canvas_the_right_side() { + let mut u = ui(true); + u.app.ui.close_left_panel(); + u.frame(vec![]); + assert_eq!(canvas(&u), (PANEL_DEFAULT, WINDOW_W - layers::RAIL_WIDTH), "only the rail is left on the right"); +} + +#[test] +fn switching_back_unmirrors_the_window_and_egui() { + let mut u = ui(true); + u.app.ui.mirrored = false; + u.frame(vec![]); + u.frame(vec![]); + assert_eq!(canvas(&u), (layers::RAIL_WIDTH + PANEL_DEFAULT, WINDOW_W - PANEL_DEFAULT)); + assert!(!u.ctx.global_style().right_to_left); +} diff --git a/crates/ui-egui/src/panels/proto_panel.rs b/crates/ui-egui/src/panels/proto_panel.rs index 2bc635244..d7728cbdc 100644 --- a/crates/ui-egui/src/panels/proto_panel.rs +++ b/crates/ui-egui/src/panels/proto_panel.rs @@ -639,30 +639,33 @@ fn interactions(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, nod && n == id && let Some(it) = list.get(k) { - let panel_left = ui.clip_rect().min.x; + let panel = ui.clip_rect().x_range(); let y = anchor.map(|a| a.min.y).unwrap_or(ui.clip_rect().min.y + 80.0); - editor_popover(app, ui.ctx(), InteractionPopover { doc, page, node: id, index: k, interaction: it, anchor: pos2(panel_left - 8.0, y) }); + editor_popover(app, ui.ctx(), InteractionPopover { doc, page, node: id, index: k, interaction: it, panel, y }); } else if open.is_some_and(|(n, _)| n == id) { open_editor(ui.ctx(), None); } } -/// The interaction editor, floating left of the panel (`at` is its top-right corner). +/// The interaction editor, floating beside the panel (`panel`, its x range) on the canvas side, +/// its top at `y`. struct InteractionPopover<'a> { doc: &'a Document, page: NodeId, node: NodeId, index: usize, interaction: &'a Interaction, - anchor: Pos2, + panel: egui::Rangef, + y: f32, } fn editor_popover(app: &mut FigApp, ctx: &egui::Context, popover: InteractionPopover<'_>) { - let InteractionPopover { doc, page, node, index: k, interaction: it, anchor: at } = popover; + let InteractionPopover { doc, page, node, index: k, interaction: it, panel, y } = popover; let t = Tokens::get(ctx); let width = 280.0; let screen = ctx.content_rect(); - let pos = pos2(at.x - width, at.y.min(screen.max.y - 420.0).max(screen.min.y + 48.0)); + let x = crate::popover::beside_panel(panel, width, 8.0, app.ui.mirrored); + let pos = pos2(x, y.min(screen.max.y - 420.0).max(screen.min.y + 48.0)); let mut close = false; egui::Area::new(Id::unique("proto_editor_area")).fixed_pos(pos).order(egui::Order::Foreground).show(ctx, |ui| { egui::Frame::new() diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 6dde13136..a38dfd0d3 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -113,7 +113,9 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { // take 1 pt off each side of the panel (240 pt by default)). let edge = ui.max_rect(); app.ui.right_panel_rect = Some(edge); - ui.painter().line_segment([edge.left_top(), edge.left_bottom()], Stroke::new(1.0, t.border)); + // On the side facing the canvas (its right in a mirrored window). + let x = if app.ui.mirrored { edge.max.x } else { edge.min.x }; + ui.painter().vline(x, edge.y_range(), Stroke::new(1.0, t.border)); let w = ui.available_width(); // Top bar: people (you and the others in a live session), present, share. let (top, _) = ui.allocate_exact_size(vec2(w, 48.0), Sense::hover()); diff --git a/crates/ui-egui/src/popover.rs b/crates/ui-egui/src/popover.rs index 14d1e133f..f9fe61fc3 100644 --- a/crates/ui-egui/src/popover.rs +++ b/crates/ui-egui/src/popover.rs @@ -51,6 +51,9 @@ pub enum Open { /// Its right edge on `x` (the right panel's left edge), its top on `y` (the button's top), /// lifted when that would run its bottom past the screen's bottom. Anchor(Pos2), + /// [`Open::Anchor`] in a mirrored (right-to-left) window: its left edge on `x` (the Design + /// panel's right edge). + AnchorMirrored(Pos2), /// Its top-left exactly here. At(Pos2), } @@ -167,6 +170,7 @@ impl Popover { let p = match open { Open::At(p) => p, Open::Anchor(a) => open_pos(a, self.size, screen), + Open::AnchorMirrored(a) => open_pos(a, self.size, screen) + vec2(self.size.x, 0.0), }; clamp(p, self.size, screen) } @@ -276,6 +280,12 @@ fn open_pos(anchor: Pos2, size: Vec2, screen: Rect) -> Pos2 { pos2(anchor.x - size.x, y) } +/// The left x of a window `width` wide that floats beside the Design panel (`panel`, its x range) +/// `gap` out over the canvas: left of the panel, or right of it in a mirrored window. +pub fn beside_panel(panel: egui::Rangef, width: f32, gap: f32, mirrored: bool) -> f32 { + if mirrored { panel.max + gap } else { panel.min - gap - width } +} + /// Keeps the panel inside the screen: whole when it fits, its top-left corner when it doesn't. fn clamp(pos: Pos2, size: Vec2, screen: Rect) -> Pos2 { let (x0, y0) = (screen.min.x + EDGE, screen.min.y + EDGE); diff --git a/crates/ui-egui/src/popover_tests.rs b/crates/ui-egui/src/popover_tests.rs index 713e8d396..06daca851 100644 --- a/crates/ui-egui/src/popover_tests.rs +++ b/crates/ui-egui/src/popover_tests.rs @@ -84,6 +84,27 @@ fn it_opens_with_its_right_edge_on_the_anchor_and_its_top_aligned() { assert!((p.rect.min.y - 200.0).abs() < 0.01, "its top on the anchor: {:?}", p.rect); } +#[test] +fn mirrored_it_opens_with_its_left_edge_on_the_anchor_and_lifts_the_same() { + let ctx = context(); + let mut t = 0.0; + let mut p = Popover::new("popover_anchor_mirrored", "Popup"); + settle(&ctx, &mut t, &mut p, Open::AnchorMirrored(pos2(300.0, 200.0))); + assert!((p.rect.min.x - 300.0).abs() < 0.01, "its left edge on the anchor: {:?}", p.rect); + assert!((p.rect.min.y - 200.0).abs() < 0.01, "its top on the anchor: {:?}", p.rect); + let mut p = Popover::new("popover_lift_mirrored", "Popup"); + settle(&ctx, &mut t, &mut p, Open::AnchorMirrored(pos2(300.0, 860.0))); + assert!((p.rect.max.y - (SCREEN.y - 16.0)).abs() < 0.01, "lifted off the screen's bottom: {:?}", p.rect); +} + +#[test] +fn a_window_beside_the_design_panel_floats_over_the_canvas() { + let panel = egui::Rangef::new(1200.0, 1440.0); + assert_eq!(crate::popover::beside_panel(panel, 280.0, 8.0, false), 1200.0 - 8.0 - 280.0, "left of the panel"); + let panel = egui::Rangef::new(0.0, 240.0); + assert_eq!(crate::popover::beside_panel(panel, 280.0, 8.0, true), 248.0, "right of a mirrored one"); +} + #[test] fn it_lifts_so_its_bottom_keeps_16_from_the_screens_bottom() { let ctx = context(); diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index e59552d12..2eea157ef 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -319,9 +319,9 @@ fn close_tab(app: &mut FigApp, ui: &Ui, i: usize) { } /// The Home tab at `x`; returns its width. Without inset window buttons its divider continues the -/// icon rail's edge below. +/// icon rail's edge below (a mirrored window has the rail on the right: no edge to continue). fn home_tab(app: &mut FigApp, ui: &mut Ui, t: &Tokens, x: f32, rect: Rect) -> f32 { - let w = if app.ui.title_bar == TitleBar::Inset { 44.0 } else { rect.min.x + crate::layers::RAIL_WIDTH - x }; + let w = if app.ui.title_bar == TitleBar::Inset || app.ui.mirrored { 44.0 } else { rect.min.x + crate::layers::RAIL_WIDTH - x }; let hr = Rect::from_min_size(pos2(x, rect.min.y), vec2(w, HEIGHT)); let hresp = ui.interact(hr, ui.scope_id().with("home"), Sense::click()).tip(tr("Home")); let home_active = (app.ui.home || !app.has_files()) && app.ui.present.is_none(); diff --git a/crates/ui-egui/src/widgets/colorpicker.rs b/crates/ui-egui/src/widgets/colorpicker.rs index 5fbcc7b40..eb2ffe34e 100644 --- a/crates/ui-egui/src/widgets/colorpicker.rs +++ b/crates/ui-egui/src/widgets/colorpicker.rs @@ -967,8 +967,18 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { }; let open = beside.map(Open::At).unwrap_or_else(|| { let top = app.ui.picker_button.map_or(140.0, |r| r.min.y); - let edge = app.ui.right_panel_rect.map_or(screen.max.x - app.ui.right_width, |r| r.min.x); - Open::Anchor(pos2(edge - 8.0, top)) + // Beside the Design panel, on its canvas side (its right in a mirrored window). + let panel = app.ui.right_panel_rect.map_or_else( + || { + if app.ui.mirrored { + egui::Rangef::new(screen.min.x, screen.min.x + app.ui.right_width) + } else { + egui::Rangef::new(screen.max.x - app.ui.right_width, screen.max.x) + } + }, + |r| r.x_range(), + ); + if app.ui.mirrored { Open::AnchorMirrored(pos2(panel.max + 8.0, top)) } else { Open::Anchor(pos2(panel.min - 8.0, top)) } }); // The colour picker's own header — title 16 in, the X 8 in from the right — is the shared // popover's chrome now; the picker keeps its body. Its area id is [`window_id`], the one the diff --git a/crates/ui-egui/src/widgets/style_picker.rs b/crates/ui-egui/src/widgets/style_picker.rs index eb2a2b44b..8ba7c01d5 100644 --- a/crates/ui-egui/src/widgets/style_picker.rs +++ b/crates/ui-egui/src/widgets/style_picker.rs @@ -1082,11 +1082,15 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { return; } let screen = ctx.content_rect(); - // Left of the Design panel; further left while the colour picker (which sits there) is open. - let mut x = screen.max.x - app.ui.right_width - W - 32.0; - if app.ui.picker.is_some() { - x -= 260.0; - } + // Beside the Design panel, on its canvas side; further out while the colour picker (which + // sits there) is open. + let gap = if app.ui.picker.is_some() { 32.0 + 260.0 } else { 32.0 }; + let panel = if app.ui.mirrored { + egui::Rangef::new(screen.min.x, screen.min.x + app.ui.right_width) + } else { + egui::Rangef::new(screen.max.x - app.ui.right_width, screen.max.x) + }; + let x = crate::popover::beside_panel(panel, W, gap, app.ui.mirrored); let y = p.y.clamp(screen.min.y + 48.0, (screen.max.y - 460.0).max(screen.min.y + 48.0)); let mut out = Frame { act: None, hover: None, budget: 4 }; // The title and the X are the shared popover's now; the list keeps its tools row. diff --git a/docs/gaps.md b/docs/gaps.md index 5437c6766..45e344b2f 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -18,7 +18,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | 2 | Multiplayer and sharing are a preview, without accounts (**beta blocker**) | Feature | 42–84 | [collaboration-parity](collaboration-parity.md) | | 3 | No representative corpus checked against real Figma files | Fidelity | 15–30 | [target-app-parity](target-app-parity.md) | | 4 | Interaction fidelity not compared tool by tool | UI/UX | 40–60 | [ui-parity](ui-parity.md) | -| 5 | Localization: English, reviewed Russian and Turkish, plus unreviewed Spanish (and Latin American Spanish), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; Turkish and Swedish catalogs; Arabic draft: 2,508 entries, native review and panel mirroring pending | Localization | 29–51 | [localization-parity](localization-parity.md) | +| 5 | Localization: English, reviewed Russian and Turkish, plus unreviewed Spanish (and Latin American Spanish), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; Turkish and Swedish catalogs; Arabic draft: 2,508 entries, native review and mirrored panel rows pending (the window mirrors) | Localization | 29–51 | [localization-parity](localization-parity.md) | | 6 | Plugins: widgets without timers, a self-hosted directory but no public catalogue; Figma's plugins don't run | Ecosystem | 6–22 | [target-app-parity](target-app-parity.md) | | 7 | CPU canvas where Figma uses the GPU | Hardware/performance | 30–60 | [hardware-parity](hardware-parity.md) | | 8 | Platform QA: Linux and the browser left (Windows pass done 2026-10-11) | Stability/platforms | 10–20 | [hardware-parity](hardware-parity.md) | @@ -209,7 +209,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe ### 5. Localization: English, reviewed Russian and Turkish, plus unreviewed drafts - **Missing:** native reviews of the Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts and the Latin American Spanish - overlay, the strings added since each catalog was drafted (1–5% of each), the Arabic draft's native review and panel mirroring, Hindi and + overlay, the strings added since each catalog was drafted (1–5% of each), the Arabic draft's native review and mirrored rows inside the panels (the window mirrors since 2026-10-31), Hindi and Vietnamese, localized engine messages and number formatting; CJK UI text on the web without the craft-fonts CJK build. - **Evidence (measured from source):** the layer landed 2026-10-11: catalogs in diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 541e7ab90..7b404a8bf 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (Bosnian draft catalog, one/few/other plurals; German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft; Arabic draft catalog, count-neutral plurals and web font gate; Thai draft catalog and Thai UI fonts; a missing translation fails no check: `cargo xtask i18n-report` lists them; Vietnamese draft catalog; string counts re-measured: 2,352 entries; Russian and Turkish catalogs reviewed by native speakers, Russian one/few/many plurals; Czech and Indonesian drafts; the Japanese draft completed; counts re-measured; Windows UI language from the first preferred language; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog; macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (a right-to-left UI language (Arabic) mirrors the window: the icon rail and layers panel on the right, the Design panel on the left, popovers beside it over the canvas, egui's scroll bars, submenus and checkboxes mirrored; Bosnian draft catalog, one/few/other plurals; German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft; Arabic draft catalog, count-neutral plurals and web font gate; Thai draft catalog and Thai UI fonts; a missing translation fails no check: `cargo xtask i18n-report` lists them; Vietnamese draft catalog; string counts re-measured: 2,352 entries; Russian and Turkish catalogs reviewed by native speakers, Russian one/few/many plurals; Czech and Indonesian drafts; the Japanese draft completed; counts re-measured; Windows UI language from the first preferred language; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog; macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed) · **Target:** Figma Design (desktop app 126.10.7) FigCraft's interface has a localization layer with an English catalog, a Russian catalog reviewed by a native speaker, and Spanish (with a Latin American overlay), French, German, Brazilian @@ -63,8 +63,14 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v harfrust. No vertical text (Figma has none either). - **Script support in the UI:** menus widen to their longest label; dropdowns, the two-column labels of the right panel and the home screen's cards keep a long translation on one line with an - ellipsis and show it in full on hover (checked in German, the longest); no mirrored - (right-to-left) layout of panels. Cyrillic, Turkish and Czech letters draw from the UI fonts + ellipsis and show it in full on hover (checked in German, the longest). A right-to-left UI + language (Arabic; Hebrew, Persian, Urdu and the other right-to-left languages when they get a + catalog) mirrors the window: the icon rail and the layers panel on the right, the Design panel on + the left with its edge and its popovers (colour picker, style picker, interaction editor) on the + canvas side, the panel edges dragged the other way (same widths in the preferences), and egui's + own chrome mirrored (scroll bars on the left, submenus opening left, checkbox labels on the left; + `Style::right_to_left` in the egui fork). The rows inside the panels keep their left-to-right + order (labels on the left, values on the right) and the toolbar and tab bar keep theirs. Cyrillic, Turkish and Czech letters draw from the UI fonts (no font added; Russian checked in screenshots). CJK translations are shorter than English; the same ellipsis and tooltip cover the few that don't fit (the sizing dropdowns). - **CJK UI fonts:** egui's fonts and Inter have no CJK glyphs. Once the UI language, the open @@ -91,7 +97,7 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v | Simplified Chinese (Mandarin) | zh-Hans | 2,216 / 2,352 (94.2%, draft) | yes | CJK text and IME in documents; UI text in an installed Chinese face, ahead of Japanese ones (web: the `FIGCRAFT_EMBED_CJK` build); no Traditional Chinese catalog (zh-TW, zh-HK read English) | no | draft | 1–2 (after native review) | | Spanish | es, es-419 | 2,352 / 2,352 (100%, draft); es-419 overlays 104 Latin American wordings (draft) | yes | Latin | no | draft | 0–1 (after native review) | | Hindi | hi | 0 (0%) | no | Devanagari shaping works in documents; UI font fallback to check | no | none | 4–7 | -| Arabic | ar | 1,857 / 1,857 UI strings, 620 command labels, 31 menu wordings (draft, counted 2026-10-28) | catalog complete; native review pending | Existing bidi/shaping and Arabic system fallback; web not offered (font budget excludes Arabic); panels do not mirror | no | draft | review + panel mirroring (not re-estimated) | +| Arabic | ar | 1,857 / 1,857 UI strings, 620 command labels, 31 menu wordings (draft, counted 2026-10-28) | catalog complete; native review pending | Existing bidi/shaping and Arabic system fallback; web not offered (font budget excludes Arabic); the window mirrors (panels swap sides), rows inside the panels do not | no | draft | review + mirrored panel rows (not re-estimated) | | French | fr | 2,328 / 2,352 (99.0%, draft) | yes | Latin | no | draft | 1–2 (after native review) | | Portuguese | pt-BR | 2,331 / 2,352 (99.1%, draft) | yes | Latin | no | draft | 1–2 (after native review) | | Indonesian | id | 2,292 / 2,352 (97.4%, draft) | yes | Latin | no | draft | 1–2 (after native review) | @@ -113,7 +119,8 @@ The layer is done. Per-language hours above are agent hours for a catalog of ~1, (a machine draft, then a pass over the panels for overflow), plus each language's plural rule where it isn't one/other; every language also needs a native speaker's review (human). Left before full parity besides the languages: localizing engine messages and number formatting (4–6 h); -right-to-left mirroring of the panels is in Arabic's hours. Total: **29–51 h** (kept on +right-to-left mirroring of the rows inside the panels is in Arabic's hours (the window itself +mirrors since 2026-10-31). Total: **29–51 h** (kept on 2026-10-17: Indonesian's draft takes 2–3 h off, about what bringing the drafts back from 95% to every string adds). @@ -125,6 +132,7 @@ Hindi and Arabic. Traditional Chinese is not planned (no catalog; its users see | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | A right-to-left UI language mirrors the window: `i18n::right_to_left` (Arabic, Hebrew, Persian, Urdu, Pashto, Sindhi, Sorani, Uyghur, Dhivehi, Yiddish); the icon rail and layers panel on the right, the Design panel on the left, its edge line and popovers on the canvas side, panel edges and narrow-window buttons mirrored, egui's `Style::right_to_left` on. Rows inside the panels, the toolbar and the tab bar keep their order. Evidence: `crates/ui-egui/src/mirror_tests.rs`, `popover_tests.rs`, `i18n/tests.rs`. No grade change | | 2026-10-30 | minor | Bosnian (`bs`, Bosanski) draft catalog: every UI string (1,906), command label (629) and menu wording (31), machine-drafted in Latin script with ijekavian forms and marked as a draft awaiting native review. Bosnian plurals: one for 1, 21, 31… (not 11), few for 2–4, 22–24… (not 12–14), other for the rest. Grade and hours unchanged (Bosnian is not one of the twelve key languages) | | 2026-10-29 | minor | German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft. Nine existing translations corrected; keys, placeholders, coverage, grade and estimates unchanged | | 2026-10-28 | minor | Arabic draft: 1,857 UI entries, 620 command labels and 31 menu wordings, with count-neutral plurals. AI-assisted, native review pending. Desktop uses the existing Arabic fallback and bidi; web requires an embedded Arabic face. No panel mirroring or parity-grade increase claimed | From 30fe9642442e4d424b2212f212965408ef85526c Mon Sep 17 00:00:00 2001 From: SoldierPePe Date: Sat, 31 Oct 2026 19:38:01 +0300 Subject: [PATCH 09/17] M10: a mirrored style picker sits beside the Design panel as drawn In a small or narrow window the Design panel is drawn narrower than its saved width, so the mirrored style picker read the saved width and could open over the panel. It now places itself from the panel's drawn rect, as the colour picker does; the left-to-right placement is unchanged. --- crates/ui-egui/src/widgets/style_picker.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/widgets/style_picker.rs b/crates/ui-egui/src/widgets/style_picker.rs index 8ba7c01d5..0cbb62359 100644 --- a/crates/ui-egui/src/widgets/style_picker.rs +++ b/crates/ui-egui/src/widgets/style_picker.rs @@ -1085,8 +1085,10 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { // Beside the Design panel, on its canvas side; further out while the colour picker (which // sits there) is open. let gap = if app.ui.picker.is_some() { 32.0 + 260.0 } else { 32.0 }; + // Mirrored, the panel as drawn: a small window draws it narrower than asked, and a narrow one + // across the window, so the asked width would put the picker over it. let panel = if app.ui.mirrored { - egui::Rangef::new(screen.min.x, screen.min.x + app.ui.right_width) + app.ui.right_panel_rect.map_or_else(|| egui::Rangef::new(screen.min.x, screen.min.x + app.ui.right_width), |r| r.x_range()) } else { egui::Rangef::new(screen.max.x - app.ui.right_width, screen.max.x) }; From e32a5dc120f8886919ff31e0ca6607edbbb036da Mon Sep 17 00:00:00 2001 From: SoldierPePe Date: Thu, 5 Nov 2026 03:08:19 +0300 Subject: [PATCH 10/17] M10: a minimized mirrored window keeps its Expand button where the rail was Minimize (#1835) puts an Expand button where the navigation bar was. In a mirrored window the rail is on the right, so the button goes to the top right there; the test pins both sides. --- crates/ui-egui/src/mirror_tests.rs | 15 +++++++++++++++ crates/ui-egui/src/panel_width_tests.rs | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/mirror_tests.rs b/crates/ui-egui/src/mirror_tests.rs index b64ae828c..a316165e6 100644 --- a/crates/ui-egui/src/mirror_tests.rs +++ b/crates/ui-egui/src/mirror_tests.rs @@ -89,3 +89,18 @@ fn switching_back_unmirrors_the_window_and_egui() { assert_eq!(canvas(&u), (layers::RAIL_WIDTH + PANEL_DEFAULT, WINDOW_W - PANEL_DEFAULT)); assert!(!u.ctx.global_style().right_to_left); } + +#[test] +fn a_minimized_window_has_its_expand_button_where_the_rail_was() { + use egui::accesskit::Role; + for mirrored in [true, false] { + let mut u = ui(mirrored); + u.app.run("select.set", json!({"ids": []})); + u.app.run("view.minimize", json!({"on": true})); + u.frame(vec![]); + let expand = crate::panel_width_tests::control(&mut u, Role::Button, "Expand the interface").expect("the Expand button"); + // 32 wide, 8 from the window's edge. + let x = if mirrored { WINDOW_W - 24.0 } else { 24.0 }; + assert_eq!(expand.x, x, "mirrored: {mirrored}"); + } +} diff --git a/crates/ui-egui/src/panel_width_tests.rs b/crates/ui-egui/src/panel_width_tests.rs index 357b13669..73b5859eb 100644 --- a/crates/ui-egui/src/panel_width_tests.rs +++ b/crates/ui-egui/src/panel_width_tests.rs @@ -211,7 +211,7 @@ fn minimize_ui_hides_the_panels_and_keeps_their_widths() { } /// The middle of the accessible control with `role` and `label`, if one shows. -fn control(u: &mut Ui, role: egui::accesskit::Role, label: &str) -> Option { +pub(crate) fn control(u: &mut Ui, role: egui::accesskit::Role, label: &str) -> Option { u.ctx.enable_accesskit(); u.frame(vec![]); let out = u.frame_output(vec![]); From 78502eea6a1452b2fd1c70ac35730f4f7720f733 Mon Sep 17 00:00:00 2001 From: Helen Kwok <107752876+helenkwok@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:09:24 +0800 Subject: [PATCH 11/17] figcraft-web: window.figcraft takes its request functions from craft-web-api request, execute, commands and screenshot move onto the shared crate (storytold/craft-libs#413): a timeout on every call, one data field for the screenshot's PNG, and docInspect and uiInspect beside them. A call the page gave up on cancels its request if the app has not started it, so the app skips it. openFile, captureDownloads, files, readFile and inspect stay FigCraft's own, on the same object. --- Cargo.lock | 14 ++- apps/figcraft-web/Cargo.toml | 4 +- apps/figcraft-web/src/api.rs | 166 ++++++++++++++++++++++------------- apps/figcraft-web/src/web.rs | 1 - docs/control-protocol.md | 8 +- 5 files changed, 125 insertions(+), 68 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 36bd121b6..5fdb9e27b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1157,10 +1157,21 @@ dependencies = [ "zip 9.1.1", ] +[[package]] +name = "craft-web-api" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505" +dependencies = [ + "js-sys", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", +] + [[package]] name = "craft-web-host" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6" +source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505" dependencies = [ "wasm-bindgen", "web-sys", @@ -2130,6 +2141,7 @@ name = "figcraft-web" version = "0.15.0" dependencies = [ "base64 0.22.1", + "craft-web-api", "craft-web-host", "eframe", "egui", diff --git a/apps/figcraft-web/Cargo.toml b/apps/figcraft-web/Cargo.toml index 7f0b4ded5..c37663258 100644 --- a/apps/figcraft-web/Cargo.toml +++ b/apps/figcraft-web/Cargo.toml @@ -20,7 +20,9 @@ figcraft-ui-egui = { workspace = true } figcraft-collab-net = { workspace = true } figcraft-text = { workspace = true } # The host messages (storytold/craft-libs): one contract and one bridge for the web builds. -craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" } +# The page command API (storytold/craft-libs#413): the request plumbing behind window.figcraft. +craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" } +craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" } base64 = { workspace = true } egui = { workspace = true } log = { workspace = true } diff --git a/apps/figcraft-web/src/api.rs b/apps/figcraft-web/src/api.rs index 7f6a0ec4d..167ea7687 100644 --- a/apps/figcraft-web/src/api.rs +++ b/apps/figcraft-web/src/api.rs @@ -1,36 +1,54 @@ -//! `window.figcraft`: the control channel for the page, as promises. +//! `window.figcraft`: the page command API (`craft-web-api`, storytold/craft-libs#413) plus FigCraft's +//! own file functions. //! -//! The same JSON methods as the desktop control server (`docs/control-protocol.md`), so an agent -//! driving FigCraft in a browser tab (or an `