Merge main into #2147

This commit is contained in:
bflatastic committed 2026-11-12 11:49:59 +07:00
commit 7cef98535e
10 files changed
+410 -38

No files matched your search

+29 -8
View File
@@ -674,27 +674,48 @@ fn grid_rules() {
rects: &[(0.0, 0.0, 60.0, 20.0), (60.0, 0.0, 240.0, 20.0)],
},
Case {
rule: "fr tracks behave like the CSS fr unit: a fixed child taller than its share keeps its row",
// Share 50 < 80: row 1 keeps 80, row 2 gets the remaining 20.
rule: "fr tracks as measured in Figma: a fixed child taller than its share keeps its row's share and overflows",
// Share 50 < 80: rows stay 50 / 50, child 1 overflows into row 2.
frame: Frame { mode: Grid, size: (100.0, 100.0), columns: &[fr(1.0)], rows: &[fr(1.0), fr(1.0)], ..FRAME },
kids: &[kid(20.0, 80.0), Kid { sizing: (Fixed, Fill), ..KID }],
want: (100.0, 100.0),
rects: &[(0.0, 0.0, 20.0, 80.0), (0.0, 80.0, 20.0, 20.0)],
rects: &[(0.0, 0.0, 20.0, 80.0), (0.0, 50.0, 20.0, 50.0)],
},
Case {
rule: "fr tracks: children bigger than every share keep their sizes (the grid overflows)",
rule: "fr tracks as measured in Figma: children bigger than every share keep their sizes (the grid overflows)",
frame: Frame { mode: Grid, size: (100.0, 100.0), columns: &[fr(1.0)], rows: &[fr(1.0), fr(1.0)], ..FRAME },
kids: &[kid(20.0, 80.0), kid(20.0, 70.0)],
want: (100.0, 100.0),
rects: &[(0.0, 0.0, 20.0, 80.0), (0.0, 80.0, 20.0, 70.0)],
rects: &[(0.0, 0.0, 20.0, 80.0), (0.0, 50.0, 20.0, 70.0)],
},
Case {
rule: "fr columns: a fixed child wider than its share keeps its column",
// Share 100 < 150: column 1 keeps 150; column 2 (2fr alone) gets the other 150.
rule: "fr columns as measured in Figma: a fixed child wider than its share overflows its column",
// Share 100 < 150: columns stay 100 / 200.
frame: Frame { mode: Grid, size: (300.0, 20.0), columns: &[fr(1.0), fr(2.0)], ..FRAME },
kids: &[kid(150.0, 20.0), Kid { sizing: (Fill, Fixed), ..KID }],
want: (300.0, 20.0),
rects: &[(0.0, 0.0, 150.0, 20.0), (150.0, 0.0, 150.0, 20.0)],
rects: &[(0.0, 0.0, 150.0, 20.0), (100.0, 0.0, 200.0, 20.0)],
},
Case {
rule: "fr rows as measured in Figma: 300 x 200, 1fr / 1fr, a 150-tall child does not widen its row",
frame: Frame { mode: Grid, size: (300.0, 200.0), columns: &[fr(1.0)], rows: &[fr(1.0), fr(1.0)], ..FRAME },
kids: &[kid(100.0, 150.0), kid(100.0, 20.0)],
want: (300.0, 200.0),
rects: &[(0.0, 0.0, 100.0, 150.0), (0.0, 100.0, 100.0, 20.0)],
},
Case {
rule: "fr rows as measured in Figma: the row gap comes off first (95 / 95, second child at 105)",
frame: Frame { mode: Grid, size: (300.0, 200.0), cross_gap: 10.0, columns: &[fr(1.0)], rows: &[fr(1.0), fr(1.0)], ..FRAME },
kids: &[kid(100.0, 150.0), kid(100.0, 20.0)],
want: (300.0, 200.0),
rects: &[(0.0, 0.0, 100.0, 150.0), (0.0, 105.0, 100.0, 20.0)],
},
Case {
rule: "fr rows as measured in Figma: with the frame on Hug the rows size to content (150 / 20, frame 170)",
frame: Frame { mode: Grid, size: (300.0, 200.0), sizing: (Fixed, Hug), columns: &[fr(1.0)], rows: &[fr(1.0), fr(1.0)], ..FRAME },
kids: &[kid(100.0, 150.0), kid(100.0, 20.0)],
want: (300.0, 170.0),
rects: &[(0.0, 0.0, 100.0, 150.0), (0.0, 150.0, 100.0, 20.0)],
},
Case {
rule: "placement: left to right, top to bottom; rows grow to fit the children",
+22 -6
View File
@@ -794,9 +794,9 @@ fn track_spec(specs: &[figcraft_doc::GridTrack], i: usize) -> (Sizing, f64) {
}
/// Resolve track sizes: fixed tracks keep their size, hug tracks fit their content, fill tracks
/// share what is left by their fractional units like the CSS `fr` unit (a track never gets less
/// than its content: one whose share is too small keeps its content size and the others share the
/// rest). `avail` is None when the frame hugs that axis (fill tracks then hug their content too).
/// share what is left by their fractional units (a track whose share is below its content, which
/// only a Fill child's minimum or a spanning child asks for, keeps that size and the others share
/// the rest). `avail` is None when the frame hugs that axis (fill tracks then hug their content too).
fn resolve_tracks(specs: &[figcraft_doc::GridTrack], content: &[f64], avail: Option<f64>) -> Vec<f64> {
let mut out: Vec<f64> = (0..content.len())
.map(|i| match track_spec(specs, i) {
@@ -891,13 +891,29 @@ pub fn grid_geometry(doc: &Document, frame: NodeId) -> Option<GridGeometry> {
let gy = finite_or_zero(al.cross_gap).max(0.0);
let hug_w = f.child_layout.sizing_h == Sizing::Hug;
let hug_h = f.child_layout.sizing_v == Sizing::Hug;
// What each child asks of its tracks: its size, or (filling a non-hugging axis) its minimum.
// What each child asks of its tracks: its size, its minimum when it fills a non-hugging axis, or
// nothing when it sits alone in a Fill track of a non-hugging axis.
let (mut ws, mut hs) = (Vec::with_capacity(cells.len()), Vec::with_capacity(cells.len()));
for c in &cells {
let (w, h) = outer_size(doc, c.id);
let cl = doc.node(c.id).map(|n| n.child_layout.clone()).unwrap_or_default();
ws.push(if cl.sizing_h == Sizing::Fill && !hug_w { cl.min_w.unwrap_or(0.0) } else { w });
hs.push(if cl.sizing_v == Sizing::Fill && !hug_h { cl.min_h.unwrap_or(0.0) } else { h });
// Measured in Figma: a Fill track of a fixed-size axis shares the space by its fraction and
// ignores a fixed-size (or hug-size) child in it, which overflows into the next track.
let in_fill = |tracks: &[figcraft_doc::GridTrack], start: usize, span: usize| span == 1 && track_spec(tracks, start).0 == Sizing::Fill;
ws.push(if cl.sizing_h == Sizing::Fill && !hug_w {
cl.min_w.unwrap_or(0.0)
} else if !hug_w && in_fill(&al.column_tracks, c.column, c.column_span) {
0.0
} else {
w
});
hs.push(if cl.sizing_v == Sizing::Fill && !hug_h {
cl.min_h.unwrap_or(0.0)
} else if !hug_h && in_fill(&al.row_tracks, c.row, c.row_span) {
0.0
} else {
h
});
}
let col_content = track_content(&cells, &ws, &al.column_tracks, cols, gx, true, hug_w);
let row_content = track_content(&cells, &hs, &al.row_tracks, rows, gy, false, hug_h);
+9 -3
View File
@@ -14,6 +14,7 @@ use crate::i18n::{tr, trf, trn};
use crate::render_worker::{Done, Job, Worker};
use crate::theme::{COMPONENT, Tokens, doc_c32, regular, semibold};
mod background;
mod builder;
mod input;
mod outline;
@@ -339,15 +340,20 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
app.canvas.tex_seq = done.seq;
}
let painter = ui.painter_at(rect);
painter.rect_filled(rect, CornerRadius::ZERO, bg);
crate::theme::set_canvas_page(&ctx, bg);
if let (Some(tex), Some(v), true) = (&app.canvas.texture, &app.canvas.tex_view, shown.is_some() && app.canvas.tex_of == shown) {
let image = if let (Some(tex), Some(v), true) = (&app.canvas.texture, &app.canvas.tex_view, shown.is_some() && app.canvas.tex_of == shown) {
// Re-project the last render onto the current view.
let tl = app.to_screen(v.origin.0, v.origin.1);
let zoom = app.session.ds().map(|d| d.view.zoom).unwrap_or(1.0);
let pixel_preview = app.session.ds().ok().and_then(|ds| ds.view.pixel_preview_scale()) == Some(v.scale);
let image_rect = presented_image_rect(tl, v, zoom, ppp, pixel_preview);
painter.image(tex.id(), image_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
Some((tex.id(), image_rect))
} else {
None
};
background::paint(&painter, rect, bg, image.map(|(_, r)| r));
if let Some((texture, image_rect)) = image {
painter.image(texture, image_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
crate::dev_mode::refresh_measurements(app);
app.session.tool.labels = overlays(app, &painter, &t);
+230
View File
@@ -0,0 +1,230 @@
//! The transparency grid behind the page; rendered pixels already contain the page colour.
use egui::{Color32, CornerRadius, Id, Painter, Rect, TextureHandle, pos2};
/// Reuse the checkerboard texture, updating its pixels when the active theme changes.
fn grid_texture(ctx: &egui::Context) -> TextureHandle {
let id = Id::unique("canvas_transparency_grid");
let tokens = crate::theme::Tokens::get(ctx);
let colors = [tokens.panel, tokens.canvas];
let cached = ctx.data(|data| data.get_temp::<(TextureHandle, [Color32; 2])>(id));
if let Some((texture, old_colors)) = &cached
&& *old_colors == colors
{
return texture.clone();
}
let image = egui::ColorImage::new([2, 2], vec![colors[0], colors[1], colors[1], colors[0]]);
let texture = match cached {
Some((mut texture, _)) => {
texture.set(image, egui::TextureOptions::NEAREST_REPEAT);
texture
}
None => ctx.load_texture("canvas-transparency", image, egui::TextureOptions::NEAREST_REPEAT),
};
ctx.data_mut(|data| data.insert_temp(id, (texture.clone(), colors)));
texture
}
/// Draw the transparency grid and apply page colour only outside the rendered image.
pub(super) fn paint(painter: &Painter, rect: Rect, background: Color32, rendered: Option<Rect>) {
if background.a() < 255 {
// Repeat four pixels instead of allocating one shape per square on a large canvas.
let texture = grid_texture(painter.ctx());
let uv = Rect::from_min_size(egui::Pos2::ZERO, rect.size() / 16.0);
painter.image(texture.id(), rect, uv, Color32::WHITE);
}
// The render contains the page colour and its alpha. Painting that colour beneath it
// applies alpha twice (50% becomes 75%). Only uncovered areas need the fallback colour.
let covered = rendered.map(|r| r.intersect(rect)).filter(|r| r.is_positive());
let areas = match covered {
Some(r) => [
Rect::from_min_max(rect.min, pos2(rect.right(), r.top())),
Rect::from_min_max(pos2(rect.left(), r.bottom()), rect.max),
Rect::from_min_max(pos2(rect.left(), r.top()), r.left_bottom()),
Rect::from_min_max(r.right_top(), pos2(rect.right(), r.bottom())),
],
None => [rect, Rect::NOTHING, Rect::NOTHING, Rect::NOTHING],
};
for area in areas.into_iter().filter(|r| r.is_positive()) {
painter.rect_filled(area, CornerRadius::ZERO, background);
}
}
#[cfg(test)]
mod tests {
use super::*;
use egui::{Mesh, TextureId, vec2};
/// Paint a small canvas and consume its texture uploads as the UI harness does.
fn shapes(alpha: u8, rendered: Option<Rect>) -> egui::FullOutput {
let ctx = egui::Context::default();
let mut out = ctx.run_ui(Default::default(), |ui| {
paint(ui.painter(), canvas(), Color32::from_rgba_unmultiplied(255, 0, 0, alpha), rendered);
});
out.textures_delta.clear();
out
}
/// Fixed canvas bounds make clipping and uncovered-area checks independent of UI layout.
fn canvas() -> Rect {
Rect::from_min_size(egui::Pos2::ZERO, vec2(100.0, 100.0))
}
/// Check the actual checkerboard quad, including clipping and its screen-sized UV mapping.
fn assert_grid(out: &egui::FullOutput, tile: TextureId, rect: Rect) -> usize {
let meshes: Vec<_> = out
.shapes
.iter()
.enumerate()
.filter_map(|(index, shape)| match &shape.shape {
egui::Shape::Mesh(mesh) if mesh.texture_id == tile => Some((index, shape.clip_rect, mesh)),
_ => None,
})
.collect();
assert_eq!(meshes.len(), 1, "one checkerboard covers the canvas");
let (index, clip, mesh) = meshes[0];
assert_eq!(mesh.calc_bounds(), rect);
assert!(clip.contains_rect(rect), "the whole checkerboard is visible");
assert_eq!(mesh.vertices.len(), 4);
for vertex in &mesh.vertices {
let expected = (vertex.pos - rect.min) / 16.0;
assert!((vertex.uv.to_vec2() - expected).length() < 0.001, "UVs keep the squares at a fixed screen size");
}
index
}
/// Assert strips have the correct colour and partition exactly the area outside the render.
fn assert_partition(alpha: u8, rendered: Option<Rect>) {
let out = shapes(alpha, rendered);
let fills: Vec<_> = out
.shapes
.iter()
.enumerate()
.filter_map(|(index, s)| match &s.shape {
egui::Shape::Rect(r) => Some((index, r)),
_ => None,
})
.collect();
let covered = rendered.map(|r| r.intersect(canvas())).filter(|r| r.is_positive());
let background = Color32::from_rgba_unmultiplied(255, 0, 0, alpha);
for (i, (_, strip)) in fills.iter().enumerate() {
assert_eq!(strip.fill.a(), alpha, "each fallback strip applies alpha exactly once");
assert_eq!(strip.fill, background, "the fallback keeps the page colour too");
assert!(strip.rect.is_positive() && canvas().contains_rect(strip.rect));
if let Some(covered) = covered {
assert!(!strip.rect.intersect(covered).is_positive(), "no fallback beneath rendered pixels");
}
for (_, other) in fills.iter().skip(i + 1) {
assert!(!strip.rect.intersect(other.rect).is_positive(), "fallback strips do not overlap");
}
}
let area: f32 = fills.iter().map(|(_, strip)| strip.rect.area()).sum();
assert_eq!(area, canvas().area() - covered.map_or(0.0, |r| r.area()), "no uncovered gaps");
if covered == Some(canvas()) {
assert!(fills.is_empty(), "full coverage needs no fallback fills");
}
let meshes: Vec<&Mesh> = out
.shapes
.iter()
.filter_map(|s| match &s.shape {
egui::Shape::Mesh(mesh) => Some(mesh.as_ref()),
_ => None,
})
.collect();
if alpha < 255 {
assert_eq!(meshes.len(), 1);
let index = assert_grid(&out, meshes[0].texture_id, canvas());
assert!(fills.iter().all(|(fill_index, _)| *fill_index > index), "the grid is behind every fallback strip");
} else {
assert!(meshes.is_empty(), "opaque pages need no checkerboard");
}
}
/// Exercise the real canvas with a render present, checking the grid covers it and draws first.
#[test]
fn the_canvas_draws_the_transparency_grid_for_a_transparent_page() {
let mut ui = crate::layers_drag_tests::Ui::new(figcraft_engine::Session::new());
ui.app.run("page.setBackground", serde_json::json!({"color": "#FF000000"}));
let ds = ui.app.session.ds().unwrap();
let shown = (ds.uid, ds.page);
// Seed an image so this test does not depend on when the asynchronous worker finishes.
ui.app.canvas.texture =
Some(ui.ctx.load_texture("test-page-render", egui::ColorImage::filled([16, 16], Color32::TRANSPARENT), egui::TextureOptions::NEAREST));
ui.app.canvas.tex_view = Some(figcraft_render::View { origin: (0.0, 0.0), scale: 1.0, width: 16, height: 16 });
ui.app.canvas.tex_of = Some(shown);
let out = ui.frame_output(vec![]);
let (tile, _) =
ui.ctx.data(|d| d.get_temp::<(TextureHandle, [Color32; 2])>(Id::unique("canvas_transparency_grid"))).expect("the canvas checkerboard");
let grid_index = assert_grid(&out, tile.id(), ui.app.canvas.rect);
let rendered = ui.app.canvas.texture.as_ref().unwrap().id();
let render_index =
out.shapes.iter().position(|s| matches!(&s.shape, egui::Shape::Mesh(m) if m.texture_id == rendered)).expect("the rendered page");
assert!(grid_index < render_index, "the checkerboard is behind the rendered page");
}
/// Theme changes must reach drawing, update the pixels used by its mesh and reuse the texture.
#[test]
fn the_grid_updates_its_colours_when_the_theme_changes() {
let ctx = egui::Context::default();
let mut previous_id = None;
for tokens in [crate::theme::Tokens::light(), crate::theme::Tokens::dark(), crate::theme::Tokens::light()] {
crate::theme::apply(&ctx, tokens);
let mut out = ctx.run_ui(Default::default(), |ui| paint(ui.painter(), canvas(), Color32::TRANSPARENT, None));
let (tile, _) =
ctx.data(|d| d.get_temp::<(TextureHandle, [Color32; 2])>(Id::unique("canvas_transparency_grid"))).expect("drawn checkerboard");
let grid_index = assert_grid(&out, tile.id(), canvas());
assert!(out.shapes.iter().enumerate().all(|(index, s)| !matches!(&s.shape, egui::Shape::Rect(_)) || index > grid_index));
if let Some(previous_id) = previous_id {
assert_eq!(tile.id(), previous_id, "changing theme updates the same texture");
}
let delta = out.textures_delta.set.get(&tile.id()).and_then(|d| d.first()).expect("updated checkerboard pixels");
let egui::ImageData::Color(image) = &delta.image;
assert_eq!(image.pixels, [tokens.panel, tokens.canvas, tokens.canvas, tokens.panel]);
previous_id = Some(tile.id());
out.textures_delta.clear();
}
}
/// Interior, edge-straddling and fully covering renders must all apply the page alpha once.
#[test]
fn translucent_pages_show_the_grid_without_applying_page_alpha_twice() {
let renders = [
Rect::from_min_max(pos2(20.0, 20.0), pos2(80.0, 80.0)),
Rect::from_min_max(pos2(-20.0, 20.0), pos2(80.0, 80.0)),
Rect::from_min_max(pos2(20.0, -20.0), pos2(80.0, 80.0)),
Rect::from_min_max(pos2(20.0, 20.0), pos2(120.0, 80.0)),
Rect::from_min_max(pos2(20.0, 20.0), pos2(80.0, 120.0)),
Rect::from_min_max(pos2(-20.0, -20.0), pos2(80.0, 80.0)),
canvas(),
Rect::from_min_max(pos2(-20.0, -20.0), pos2(120.0, 120.0)),
];
for alpha in [0, 128, 255] {
for rendered in renders {
assert_partition(alpha, Some(rendered));
}
}
}
/// Missing, offscreen and merely edge-touching images leave exactly one fallback page fill.
#[test]
fn absent_or_offscreen_renders_fill_the_page_once() {
for rendered in
[None, Some(Rect::from_min_size(pos2(200.0, 200.0), vec2(10.0, 10.0))), Some(Rect::from_min_max(pos2(-20.0, 20.0), pos2(0.0, 80.0)))]
{
for alpha in [0, 128, 255] {
assert_partition(alpha, rendered);
let out = shapes(alpha, rendered);
let fills: Vec<_> = out
.shapes
.iter()
.filter_map(|s| match &s.shape {
egui::Shape::Rect(r) => Some(r),
_ => None,
})
.collect();
assert_eq!(fills.len(), 1);
assert_eq!(fills[0].rect, canvas());
}
}
}
}
+74
View File
@@ -1485,6 +1485,80 @@ fn sidebar_field_text_stays_in_place_on_focus() {
}
}
/// The shared paint control shows the same precision as Fill and keeps the picker toggle.
#[test]
fn page_background_shows_fractional_opacity_like_fill_and_keeps_it_when_opening_the_picker() {
let mut u = Ui::new(Session::new());
u.ctx.enable_accesskit();
u.app.run("page.setBackground", json!({"color": "#FF000080"}));
u.frame(vec![]);
assert_eq!(field(&mut u, "Opacity").1, "50.2%");
let page = u.app.session.page().unwrap();
let background = |u: &Ui| match u.app.session.doc().unwrap().node(page).unwrap().kind {
figcraft_doc::NodeKind::Page { background, .. } => background,
_ => panic!("the page"),
};
let original = background(&u);
let steps = undo_steps(&u);
let swatch = u.ctx.read_response(Id::unique("pagebg_swatch")).expect("shared page swatch").rect.center();
click(&mut u, swatch);
assert!(matches!(u.app.ui.picker, Some(crate::colorpicker::PickerTarget::PageBackground)));
assert_eq!(undo_steps(&u), steps, "opening the picker does not edit the colour");
assert_eq!(background(&u), original, "opening preserves the stored alpha");
assert_eq!(field(&mut u, "Opacity").1, "50.2%");
click(&mut u, swatch);
assert!(u.app.ui.picker.is_none(), "the swatch toggles the picker closed");
assert_eq!(background(&u), original, "closing preserves the stored alpha");
assert_eq!(field(&mut u, "Opacity").1, "50.2%");
assert_eq!(undo_steps(&u), steps, "neither toggle creates an edit");
}
/// The shared page colour row uses the sidebar's width without hiding either field.
#[test]
fn page_background_colour_and_opacity_fit_a_resized_sidebar() {
// Find the whole galley, rather than its already-clipped bounding box.
fn hex_text(shape: &egui::Shape) -> Option<&egui::epaint::TextShape> {
match shape {
egui::Shape::Vec(shapes) => shapes.iter().find_map(hex_text),
egui::Shape::Text(text) if text.galley.text() == "123ABC" => Some(text),
_ => None,
}
}
let mut widths = Vec::new();
for width in [240.0, 500.0] {
let mut session = Session::new();
session.prefs.panel_widths.1 = width;
session.execute("page.setBackground", &json!({"color": "#123ABC80"})).unwrap();
let mut u = Ui::new(session);
let mut previous = None;
let mut settled = None;
// Stop at stable geometry, with a bound so a layout regression cannot hang the test.
for _ in 0..16 {
let out = u.frame_output(vec![]);
let hex = u.ctx.read_response(Id::unique("pagebg").with("click")).unwrap().rect;
let alpha = u.ctx.read_response(Id::unique("pagebg_a").with("field")).unwrap().rect;
let panel = u.app.ui.right_panel_rect.unwrap();
if previous == Some((hex, alpha, panel)) {
settled = Some((out, hex, alpha, panel));
break;
}
previous = Some((hex, alpha, panel));
}
let (out, hex, alpha, panel) = settled.expect("the page row settles");
assert!(hex.left() >= u.app.canvas.rect.right(), "the colour stays in the sidebar");
assert!(hex.right() < alpha.left(), "the colour and opacity never overlap");
// Half a point permits device-pixel rounding, not an arbitrary gap from the panel edge.
assert!((alpha.right() - (panel.right() - crate::widgets::PAD_RIGHT)).abs() <= 0.5, "opacity stays at the right edge");
assert!((hex.center().y - alpha.center().y).abs() <= 0.5, "both fields stay on the same row");
let (text, clip) = out.shapes.iter().find_map(|s| hex_text(&s.shape).map(|text| (text, s.clip_rect))).expect("the HEX text");
assert!(!text.galley.elided, "the whole HEX value fits at {width} pt");
assert!(hex.expand(0.5).contains_rect(text.visual_bounding_rect()), "the HEX text fits its field");
assert!(clip.expand(0.5).contains_rect(text.visual_bounding_rect()), "the HEX text is not clipped");
widths.push(hex.width());
}
assert!(widths[1] > widths[0], "a wider sidebar gives the colour field more room");
}
/// The Page section's opacity field edits the page background's alpha (#1748; it was a label),
/// one undo step per commit, and the hex field keeps that opacity unless the typed colour
/// carries its own.
+13 -12
View File
@@ -332,21 +332,22 @@ fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) {
let mut new_color: Option<(figcraft_color::Color, Option<bool>)> = None;
row(ui, 32.0, |ui| {
let p = Paint::solid(bg);
let sw = widgets::swatch(ui, &p, 16.0);
let (sw, opacity) =
widgets::paint_field(ui, Id::unique("pagebg_swatch"), Id::unique("pagebg_a"), &p, ui.available_width(), |child, text_r| {
// A typed colour keeps the page's opacity unless it carries one: 4 or 8 hex digits
// (`FF000080`; `FF0000FF` asks for opaque).
if let Some(h) = widgets::text_field_inline(child, Id::unique("pagebg"), &bg.to_hex(), text_r.shrink2(vec2(0.0, 4.0)), regular(11.0))
&& let Some(c) = figcraft_color::Color::from_hex_or_name(&h)
{
let digits = h.trim().trim_start_matches('#');
let carries_alpha = matches!(digits.len(), 4 | 8) && digits.chars().all(|ch| ch.is_ascii_hexdigit());
new_color = Some((if carries_alpha { c } else { c.with_alpha(bg.a) }, None));
}
});
app.picker_control(crate::colorpicker::PickerTarget::PageBackground, &sw);
// A typed colour keeps the page's opacity unless it carries one: 4 or 8 hex digits
// (`FF000080`; `FF0000FF` asks for opaque).
if let Some(h) = widgets::text_field(ui, Id::unique("pagebg"), &bg.to_hex(), 96.0, "")
&& let Some(c) = figcraft_color::Color::from_hex_or_name(&h)
{
let digits = h.trim().trim_start_matches('#');
let carries_alpha = matches!(digits.len(), 4 | 8) && digits.chars().all(|ch| ch.is_ascii_hexdigit());
new_color = Some((if carries_alpha { c } else { c.with_alpha(bg.a) }, None));
}
// The opacity: a field like every other opacity field, not a label (#1748).
let alpha = |v: f64| bg.with_alpha((v / 100.0).clamp(0.0, 1.0) as f32);
let shown = Some(f64::from((bg.a * 100.0).round()));
match widgets::number_field(ui, Id::unique("pagebg_a"), Lead::Named(tr("Opacity")), shown, "%", 56.0, 1.0) {
match opacity {
Some(FieldEdit::Set(v)) => new_color = Some((alpha(v), None)),
Some(FieldEdit::Scrub(v, done)) => new_color = Some((alpha(v), Some(done))),
None => {}
+1 -1
View File
@@ -1151,7 +1151,7 @@ pub fn checker(painter: &egui::Painter, rect: Rect, cell: f32) {
}
}
/// Shared Fill/Stroke/Selection colors control: one background, a 16 pt swatch,
/// Shared sidebar paint control: one background, a 16 pt swatch,
/// caller-provided colour content, and a separated, scrubbable opacity field.
/// The caller owns picker routing and commands; this widget only draws and reports edits.
pub fn paint_field(
+5 -4
View File
@@ -1,11 +1,11 @@
# Auto layout and constraints parity
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-09 · **Change:** patch (a child dragged within its own auto layout row or column passes a sibling when its leading edge reaches the sibling's middle, as measured in Figma's web app, wherever it is grabbed; a Cmd/Ctrl-click on a padding field sets all four sides with the value typed, as the Guide to auto layout's shortcut guide says; Suggest auto layout is its own command, as the help center says: it adds as many auto layout frames as needed, splitting a frame's layers into rows or columns by the gaps between them, and keeps the placement of the design where the gaps are even (#1823); baseline alignment puts a layer without text with its bottom on the line, as the help center's figure shows an icon next to text (#1825); fill children share the free space by their content area, border-box fashion, as the Flexbox article says (#1824); double-click an edge of an auto layout frame's box for Hug contents, Alt+double-click an edge of a child's box for Fill container, as the Guide to auto layout says (#1838); a child resized to the full available space of its auto layout frame becomes Fill container on that axis, as the Guide to auto layout says; a text keeps a max height or a number of max lines, not both, as the Guide to auto layout says: a max height sets max lines to Auto and max lines remove the max height; Remove all auto layout, as the help center's More layout options has it: `layout.removeAll` takes the auto layout off a frame and every frame nested in it in one undo step; grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); Layout sidebar: Add auto layout (+) is available for groups and converts a single group to a frame in place, with mask order, flow-only inference and Fill sizing preserved; adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys only to frames, with warnings (#603); core editor leftovers: auto gap handles, wrapped-line alignment; behaviour suite written from the public documentation, every rule's status; fixes and the missing settings it found) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-09 · **Change:** patch (grid Fill (`fr`) tracks share the space of a fixed-size axis by their fractions and let a fixed-size child overflow, as measured in Figma (300 x 200, two rows, 100 x 150 and 100 x 20 children: rows 100 / 100, second child at y 100; with a row gap of 10, rows 95 / 95 and y 105; on Hug the rows fit their content, 150 / 20) -- this replaces the 2026-10-11 rule from the documentation; a child dragged within its own auto layout row or column passes a sibling when its leading edge reaches the sibling's middle, as measured in Figma's web app, wherever it is grabbed; a Cmd/Ctrl-click on a padding field sets all four sides with the value typed, as the Guide to auto layout's shortcut guide says; Suggest auto layout is its own command, as the help center says: it adds as many auto layout frames as needed, splitting a frame's layers into rows or columns by the gaps between them, and keeps the placement of the design where the gaps are even (#1823); baseline alignment puts a layer without text with its bottom on the line, as the help center's figure shows an icon next to text (#1825); fill children share the free space by their content area, border-box fashion, as the Flexbox article says (#1824); double-click an edge of an auto layout frame's box for Hug contents, Alt+double-click an edge of a child's box for Fill container, as the Guide to auto layout says (#1838); a child resized to the full available space of its auto layout frame becomes Fill container on that axis, as the Guide to auto layout says; a text keeps a max height or a number of max lines, not both, as the Guide to auto layout says: a max height sets max lines to Auto and max lines remove the max height; Remove all auto layout, as the help center's More layout options has it: `layout.removeAll` takes the auto layout off a frame and every frame nested in it in one undo step; grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); Layout sidebar: Add auto layout (+) is available for groups and converts a single group to a frame in place, with mask order, flow-only inference and Fill sizing preserved; adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys only to frames, with warnings (#603); core editor leftovers: auto gap handles, wrapped-line alignment; behaviour suite written from the public documentation, every rule's status; fixes and the missing settings it found) · **Target:** Figma Design (desktop app 126.10.7)
Auto layout is the most used layout feature in Figma and the one where small differences (a
hugging frame one pixel off, text wrapping at another width) break real files. **Breadth ~98%,
ready ~74% (estimated)**: every rule of the public documentation is a test case with the sizes and
positions it implies (`crates/layout/src/behaviour_tests.rs`, 64 cases, and
positions it implies (`crates/layout/src/behaviour_tests.rs`, 68 cases, and
`crates/engine/src/al_behaviour_tests.rs` through commands), and all pass; nothing yet against real
Figma files. Remaining: 3–6 h.
@@ -67,7 +67,7 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver
| Ignore auto layout: out of the flow, kept in the frame; constraints apply only then | pass | `absolute_position_rules` |
| Canvas stacking: last on top by default, first on top on request; the layer order stays | **fixed** + **added** | `reverseZ` was stored but never drawn: now the canvas, PNG, SVG and PDF exports and hit testing paint in that order; Design panel control |
| Grid: `fr` tracks share by fraction; fixed tracks keep their size; hug tracks fit their content | pass | `grid_rules` |
| Grid `fr` tracks behave like the CSS `fr` unit: a track never gets less than its content | **fixed** | was `minmax(0, 1fr)`: a fixed-size child taller than its share overflowed into the next row |
| Grid `fr` tracks as measured in Figma: on a fixed-size axis they share the space left after gaps and padding by their fractions and a fixed-size child bigger than its share overflows into the next track (`minmax(0, 1fr)` for fixed-size and hug-size children); a Fill child's own minimum width or height still keeps its track at least that big (not measured, kept from before: two 1fr rows in 200 with a 150 minimum resolve to 150 / 50); on a Hug axis they size to their content | **fixed** | 300 x 200 frame, rows 1fr / 1fr, children 100 x 150 and 100 x 20: rows 100 / 100, second child at y 100; row gap 10: rows 95 / 95, y 105; frame height Hug: rows 150 / 20, frame 170; columns the same. Measured in Figma 2026-11-11; was the CSS rule from the documentation (2026-10-11) |
| Grid placement left to right, top to bottom; rows grow; anchors; spans; gaps; padding | pass | `grid_rules` |
| Grid children align independently in their cells | **added** | `gridAlignH` / `gridAlignV` (Auto follows the frame), Design panel cell alignment |
| A hugging grid with `fr` tracks | silent | the file format calls it invalid; `fr` tracks hug their content |
@@ -79,7 +79,7 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver
| Feature | Status | Evidence / notes |
|---|---|---|
| Horizontal, vertical, wrap (both directions) | done | `crates/layout` |
| Grid auto layout: fixed / hug / `fr` tracks, cells, spans, gaps, cell alignment | done | `fr` like CSS `fr` |
| Grid auto layout: fixed / hug / `fr` tracks, cells, spans, gaps, cell alignment | done | `fr` as measured |
| Gap, auto gap (between / evenly / around), padding (per side), alignment 3×3 | done | a Cmd/Ctrl-click on a padding field (the pair or a side) makes the value typed go to all four sides in one undo step (Guide to auto layout, Keyboard shortcut guide; `padding_individual_tests`) |
| Text baseline alignment | done | |
| Hug / Fill / Fixed sizing with coherent rules (Fill turns a hugging parent Fixed) | done | `al_nesting_tests.rs`, `al_behaviour_tests.rs` |
@@ -101,6 +101,7 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver
| Date | Change | Summary |
|---|---|---|
| 2026-11-11 | patch | M2.1: grid Fill (`fr`) tracks share the space of a fixed-size axis by their fractions and let a fixed-size child overflow, as measured in Figma (300 x 200, two rows, 100 x 150 and 100 x 20 children: rows 100 / 100, second child at y 100; with a row gap of 10, rows 95 / 95 and y 105; on Hug the rows fit their content, 150 / 20) (this replaces the 2026-10-11 rule from the documentation). Layout behaviour suite: three cases flipped, three added |
| 2026-11-05 | patch | M2, #1823: Suggest auto layout is no longer Add auto layout under another name: `layout.suggest` splits a frame's layers into rows (Vertical) or else columns (Horizontal) by the gaps between their boxes, wraps each band of several layers in a new frame without fill or clipping that hugs it, and repeats inside it (16 levels, 1,000 layers per frame at most). The frame keeps its size; gap and padding come from the bands, so layers keep their boxes where the gaps are even and the bands start at the same offset; instances and layers inside them are refused; uneven gaps (smallest used), bands that start at different offsets across the flow (lined up at the smallest) and layers that overlap along both axes come back as `warnings`. A band frame hugs only when its own split succeeds: a band whose layers overlap along both axes gets no auto layout and stays Fixed, so it cannot reflow those children. `direction` sets the top level only; one undo step; `layout.add` is unchanged. Tests in `layout_suggest_tests.rs` |
| 2026-11-02 | patch | M2: a Cmd/Ctrl-click on a padding field (the horizontal or vertical pair, or a side with Individual padding open) makes the value typed go to all four sides in one undo step (Guide to auto layout, Keyboard shortcut guide); a plain click still edits that axis or side alone. The modifier was ignored before. Test: `padding_individual_tests` |
| 2026-11-03 | patch | M2, #1825: baseline alignment of a layer without text (an icon next to a text) puts its bottom on the line's baseline, as the help center's figure shows ("the bottoms of the icon and the word home are aligned on the red line"); it kept to the top of the line. A layer reaching below the text's baseline moves the text down, and a line with no text lines the layers' bottoms up (the documentation is silent; CSS baseline alignment does the same). Tests: `alignment_rules` (three cases), engine `baseline_alignment_puts_an_icons_bottom_on_the_text_baseline`. Numbers unchanged. |
+4 -4
View File
@@ -1,6 +1,6 @@
# FigCraft parity with Figma
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (an exported layer with a background blur or glass keeps the blurred content behind it within its bounds (#2213); a typed number and a full stop at the start of a paragraph starts a numbered list at that number, as measured in Figma 2026-11-11; justified Chinese, Japanese and Korean text, written without spaces, spreads each line's room between its characters so its right edge aligns (#1970); joining the two ends of a path broken open by a deleted point closes it again, so its fill and selection outline follow the new segment and its corner radius instead of a straight line across the gap (#1951); glass's docs say which centre splay bends away from and where a shape gets no core; glass bends towards its core, rounded rectangles in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps, measured against the reference tool; bulleted lists use the same bullet at every level of indentation, as the help center says, instead of a white bullet and a square at the deeper levels; typing `1)` and a space at the start of a paragraph starts a numbered list, as `1.` does, both being the help center's creation characters; a link on text is underlined by default, as the help center says, and Underline takes the underline off while the link stays; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); Thai, Lao, Khmer and Burmese lines break between words (#1219); MCP and control calls name their document (#998); shadows of images with transparent pixels follow their alpha (#1289); Arabic draft catalog; truncated text fills its last line before the ellipsis and keeps truncating when resized; auto-height text with max lines (#1375, #1376, #1377), numbers unchanged; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suiLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (grid Fill (`fr`) tracks share the space of a fixed-size axis by their fractions and let a fixed-size child overflow, as measured in Figma (300 x 200, two rows, 100 x 150 and 100 x 20 children: rows 100 / 100, second child at y 100; with a row gap of 10, rows 95 / 95 and y 105; on Hug the rows fit their content, 150 / 20) -- this replaces the 2026-10-11 rule from the documentation; an exported layer with a background blur or glass keeps the blurred content behind it within its bounds (#2213); a typed number and a full stop at the start of a paragraph starts a numbered list at that number, as measured in Figma 2026-11-11; justified Chinese, Japanese and Korean text, written without spaces, spreads each line's room between its characters so its right edge aligns (#1970); joining the two ends of a path broken open by a deleted point closes it again, so its fill and selection outline follow the new segment and its corner radius instead of a straight line across the gap (#1951); glass's docs say which centre splay bends away from and where a shape gets no core; glass bends towards its core, rounded rectangles in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps, measured against the reference tool; bulleted lists use the same bullet at every level of indentation, as the help center says, instead of a white bullet and a square at the deeper levels; typing `1)` and a space at the start of a paragraph starts a numbered list, as `1.` does, both being the help center's creation characters; a link on text is underlined by default, as the help center says, and Underline takes the underline off while the link stays; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); Thai, Lao, Khmer and Burmese lines break between words (#1219); MCP and control calls name their document (#998); shadows of images with transparent pixels follow their alpha (#1289); Arabic draft catalog; truncated text fills its last line before the ellipsis and keeps truncating when resized; auto-height text with max lines (#1375, #1376, #1377), numbers unchanged; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessiLine truncated
The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md)
summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is
@@ -104,7 +104,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
| 6 | Strokes | 3 | 95% | 75% | Stroke QA with real files (colour styles on strokes, stroke weight and per-side weight variables, brush strokes landed, see 20) | 1–2 |
| 7 | Effects | 4 | 100% | 75% | Glass and progressive blur tuned side by side (behaviour suite from the documentation, inner shadows and background blur on groups, shadows hidden behind translucent layers on the canvas and in every export landed; a background blur or glass exported on its own keeps the content behind it within its bounds, measured 2026-11-10, #2213) | 2–5 |
| 8 | Frames, groups, sections, masks | 5 | 97% | 80% | Visual QA with real files (a one-step frame/group convert, and a behaviour suite from the documentation landed: masked-out areas don't take clicks, mask group bounds and exports follow the mask, sections kept out of frames and groups, frames and sections drawn around layers take them, section resizing leaves contents in place, X/Y measured in the frame) | 1–3 |
| 9 | Auto layout & constraints | 9 | 98% | 74% | Behaviour against real Figma files (behaviour suite from the documentation, `fr` tracks like CSS, vertical wrap, auto gaps evenly/around, cell alignment and wrapped-line alignment landed) ([auto-layout-parity](auto-layout-parity.md)) | 3–6 |
| 9 | Auto layout & constraints | 9 | 98% | 74% | Behaviour against real Figma files (behaviour suite from the documentation, `fr` tracks as measured, vertical wrap, auto gaps evenly/around, cell alignment and wrapped-line alignment landed) ([auto-layout-parity](auto-layout-parity.md)) | 3–6 |
| 10 | Text | 8 | 90% | 70% | Spell check, text metrics on real files and in other fonts (text box sizes measured against documents re-saved by other software: all 230 self-sizing boxes in 7 documents come out to the pixel, one font family; behaviour suite with CJK line breaking, Thai, Lao, Khmer and Burmese breaks between words, vertical trim and carets by grapheme cluster landed; lists per paragraph with nesting and list spacing (typing `-`, `*`, `1.` or `1)` and a space starts one), hanging punctuation and lists, decoration style, thickness, offset, skip ink and colour landed) | 3–7 |
| 11 | Components & variants | 9 | 96% | 72% | Real (not synthetic) libraries, `.fig` libraries (a component playground for variant, boolean, text and instance-swap properties, a behaviour suite from the documentation, overrides of padding, gap, grids, exports and interactions, refused geometry overrides, variant-switch and Assets-swap override rules, top-left default variant landed) ([components-parity](components-parity.md)) | 4–9 |
| 12 | Styles & variables | 5 | 95% | 76% | Real-library QA (variables inside styles, behaviour suite from the documentation, style folders and order, mode order, code syntax, extended collections with their tail (new library variables as updates, chains through libraries) and variables import/export landed) ([components-parity](components-parity.md)) | 2–4 |
@@ -330,8 +330,7 @@ Hours in this narrative are the builders' estimates; the tables above supersede
"Navigate to" a section shows its frame shown last, else its first; ready for dev turns
"Changed" when the contents are edited, resolved with a reason; Compare changes shows what a
changed frame or section looked like when it was marked next to how it is now, with the
changed layers and their property changes. *Left:* whether `fr` rows of a fixed-height grid
should make room for fixed-size children (now `minmax(0, 1fr)`-like). 1–2 h.
changed layers and their property changes. 1–2 h.
4. **Components depth**: *landed:* component properties (edit/delete/bind) with the properties
panel, add variant, instance swap UI, per-layer overrides with reset, restore main, instance
structure protection; Assets panel with live thumbnails, search and drag-to-canvas (drops into
@@ -651,6 +650,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
| Date | Change | Summary |
|---|---|---|
| 2026-11-11 | patch | M4: typing a number, a full stop and a space at the start of a plain paragraph starts a numbered list at that number (`5. ` then "x", Enter, "y" reads 5. then 6.), as measured in Figma web on 2026-11-11 (it has no start-number field: the start comes only from the typed number); before, only `1. ` and `1)` started a list. `0. `, `12a. ` and a bracket after a number other than 1 stay text (not measured), and the number is capped at 9999. Also measured: a plain paragraph or a bullet item between numbered items restarts the count at 1, and an indented sub-item does not break the outer count; those rows were pinned as silent. Tests: `text_tail_tests::typed_list_starts`, `typed_number_starts_the_list_there`, `tail_tests` restart rows. Numbers unchanged |
| 2026-11-11 | patch | M2.1: grid Fill (`fr`) tracks share the space of a fixed-size axis by their fractions and let a fixed-size child overflow, as measured in Figma (300 x 200, two rows, 100 x 150 and 100 x 20 children: rows 100 / 100, second child at y 100; with a row gap of 10, rows 95 / 95 and y 105; on Hug the rows fit their content, 150 / 20) (this replaces the 2026-10-11 rule from the documentation). Percentages unchanged |
| 2026-11-03 | minor | Traditional Chinese (Taiwan) (`zh-Hant`) draft catalog: 1,975 UI strings, 641 command labels and 31 menu wordings, clean-room from the English source; zh-TW, zh-HK, zh-MO and zh-Hant read it, explicit Hans stays Simplified; Noto Sans CJK TC then Taiwan system faces; no bundled font; native review pending. Numbers unchanged |
| 2026-11-04 | patch | M4, #1970: justified Chinese, Japanese and Korean text, written without spaces between words, aligns its right edge: the room on each line but a paragraph's last goes between its characters, each a word of its own as for line breaking ("Justify aligns the first word in each line of text to the left-most edge, and the last to the right-most edge"), wherever a line could break there, so none opens before a full stop or closing bracket, nor after an opening one. Latin text widens its spaces as before; mixed text widens both. A gap is reordered with the line's runs as a space of no width would be, so one beside a Hebrew word in Chinese text, or beside a Chinese word within that Hebrew, opens beside the word, not inside it. `figcraft_text::breaks::justify_between`, beside `break_between`, says where. Evidence: `tests::justified_ideographs_spread_the_room_between_characters` (fails on main). |
| 2026-11-04 | patch | M8, #1951 (#1506 again): a path broken open by deleting a point (Delete, not Shift+Delete) is filled closed by a straight line across the gap, as an open path's fill is; joining its two ends again (the pen onto the other end, Connect, ⌘J) left the loop without the new segment, so once the points were rounded the fill and the selection outline ran straight across while the stroke followed the segment and its arcs. Joining now closes the loop through the new segment ("Click to close the path."; "You can use the Paint tool to add fills to any closed regions of a vector network."): `ops::connect` puts it into the loop whose segments are the chain between the ends, at the gap and in the loop's own order, so a hole keeps its winding whichever end was clicked first; a loop listed out of walking order is left as it was. Evidence: `vectoredit::tests::a_rounded_chamfer_made_other_ways_fills_along_its_arcs` (fails on main: the loop kept 4 of 5 segments), `a_rounded_chamfer_cut_in_the_editor_fills_along_its_arcs` (Shift+Delete, with and without a region fill), `doc::tests::a_chamfered_rectangles_rounded_fill_is_cut_at_every_point` (fill, region path and stroke agree for every loop order and segment direction of a chamfered rectangle). |
+23
View File
@@ -31,6 +31,8 @@ interaction feel not yet compared.
| Multiplayer avatars, Share button | missing | waits for multiplayer |
| Colour names in hex fields | done | every hex field (fills, strokes, text ranges, effects, layout grids, colour variables, page and prototype backgrounds, the colour picker's Hex) takes a CSS colour name as well: `red` becomes `FF0000`; case, spaces and hyphens are ignored (`Light Blue`); names are opaque and keep the colour's opacity; hex wins where both could match. The picker's CSS field takes every CSS name too (`Color::from_hex_or_name`, `color::named`) |
| Fields select their text on focus | done | Number and text values keep their vertical position when their editor gains focus (including typography spacing; `sidebar_field_text_stays_in_place_on_focus`). a click or Tab into a one-line field selects all of its text, so typing replaces the value: hex and colour fields, number fields, inline fields (a fill's hex, variable values, renames), names, search boxes and dialog fields; a drag from the press selects only what it covers; multi-line fields (descriptions, comments) keep the caret where clicked. The first click into an inline field (a fill's hex) now takes the keyboard; it took a second click. A rename (double-click, F2, File ▸ Rename, a page) selects the whole name before its field first draws, so a key typed in that same frame replaces the name; it was appended (`widgets::select_all_on_focus`, `select_all_tests`, `layers_rename_tests`) |
| Canvas page transparency | done | Transparent or translucent page backgrounds show a checkerboard using the theme’s panel and canvas colours, updated immediately when switching between light and dark. The page background alpha is applied once: a rendered image already contains the page colour, so only uncovered areas receive the fallback colour during zoom and pan. An opaque page hides the checkerboard. Theme changes update the same cached texture. The grid is a repeated four-pixel texture at a fixed screen size, separate from document exports. Tests: `canvas::background::tests` assert strip colour/alpha, gap-free non-overlapping coverage for absent, offscreen, edge-straddling and fully covering renders, checkerboard rect/UV/order, and theme updates through drawing. |
| Page background colour control | done | Uses `widgets::paint_field`, as Fill, Stroke and Selection colors do: one background, inline HEX, accessible swatch toggling the picker and fractional opacity. Typed colours preserve alpha unless 4/8 HEX digits explicitly supply it; opacity scrubs remain live and undo as one step. Tests: `field_tests::the_page_background_opacity_field_sets_the_page_colour_alpha`, `field_tests::page_background_shows_fractional_opacity_like_fill_and_keeps_it_when_opening_the_picker` and `field_tests::page_background_colour_and_opacity_fit_a_resized_sidebar` (240 and 500 pt). |
| Colour picker models | done | Hex, RGB, HSL, HSB, LCH, OKLCH and CSS; LCH and OKLCH keep lightness and hue when a colour leaves sRGB (chroma comes down); the values typed stay while the colour is theirs (a hue typed on a grey, a chroma sRGB can't hold); CSS reads `lch()` and `oklch()` with any hue unit and `none` (`figcraft_color::{Lch, Oklch}`). Values are the stored channels read as sRGB |
| Colour picker placement and dismissal | done | Opens 8 pt beside the sidebar at the opening control, lifted to fit the screen; outside presses (including the sidebar), Escape and X close it. Reopening resets a dragged position; eyedropper sampling keeps it open, and so does a drag of the open gradient's handles or stops on the canvas (#1852). |
| Colour picker image well | partial | the Image tab shows a checkered placeholder and Upload from computer until there is a file (no dialog at once); an image paint shows its asset preview; a file dropped on the open picker replaces the image, keeping the fill mode, crop, position and adjustments (`image.replace`). Layers' fills and strokes only (text ranges, styles and selection colours keep Upload). The Fill and Stroke swatches take no drop: a dragged file's position isn't known, so a drop with none goes to the open picker |
@@ -187,11 +189,32 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
| WCAG AA contrast of secondary and accent text | done | disabled and dim text at least 3:1 against panels, fields and selected rows, selected text at least 3:1 on its highlight, in both themes (`theme::tests`, #890) |
| Tested with VoiceOver, NVDA, Narrator, Orca | missing | human QA |
## Sidebar colour control audit (2026-11-09)
The Page background, Fill, Stroke and Selection colors reuse `widgets::paint_field`.
The remaining custom controls are:
| Control | Implementation | Remaining difference |
|---|---|---|
| Effects: drop/inner shadow and noise colours | `panels/right/effects.rs`, `fx_color` | Separate swatch, HEX and rounded opacity; shadow colour also supports variable binding. |
| Layout-grid colour | `panels/right/effects.rs`, `grid_settings` | Separate painted swatch, HEX and rounded opacity; the swatch is not interactive. |
| Prototype overlay background | `panels/proto_panel.rs`, `action_fields` | Separate swatch, HEX and rounded opacity; opacity scrubs apply only on release. |
| Prototype device background | `panels/proto_panel.rs`, `settings` | Swatch and HEX only; no opacity field or picker routing. |
| Prototype Set variable colour value | `panels/proto_editor.rs`, `literal_field` | Swatch and HEX only; no opacity field or picker routing. |
| Selected-text Fill | `panels/right/paints.rs`, `range_fill` | Custom combined swatch/HEX row, with picker routing but no sidebar opacity field. |
Device-colour presets and Developer Mode inspection swatches are display controls, so they
are not colour/opacity editors. Variable cells and style editing live in separate windows.
This audit records candidates for subsequent fixes; their behaviour is unchanged here.
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-11-09 | patch | Preferences ▸ Invert zoom direction (#2145): Cmd/Ctrl+wheel, and a plain mouse wheel with Scroll wheel zoom on, zoom the other way; pinch and panning unchanged. Numbers unchanged. |
| 2026-11-10 | patch | M1 review follow-up: strengthen canvas alpha, clipping/coverage, grid geometry/order and drawn-theme tests; use settled fresh sidebars at each width, check HEX visibility and opacity alignment, and assert picker toggling preserves page alpha. Production behavior unchanged. |
| 2026-11-09 | patch | M1: canvas page transparency uses a checkerboard that follows light/dark themes and applies background alpha once, including partial or missing renders. The page control also fits both sidebar width limits. Tests: `canvas::background::tests`, `field_tests::page_background_colour_and_opacity_fit_a_resized_sidebar`. Numbers unchanged |
| 2026-11-09 | patch | M1: the Page background reuses the shared sidebar paint control; fractional opacity and picker toggling covered alongside alpha-aware HEX and scrub undo. Audited remaining custom sidebar colour controls. Numbers unchanged |
| 2026-11-07 | patch | View: the arrow keys with nothing selected pan 64 px, 320 with Shift (five steps), as measured in Figma's web app at 100% zoom (62–64 px in two trials, about 320 px with Shift, about ±3 px); they panned 20 px (200 with Shift). Test: `cmd::view::start_on_home_tests::the_arrows_pan_64_px_and_320_with_shift_as_measured_in_figma` |
| 2026-11-09 | minor | Shortcuts: Shift+T picks the new Annotation tool (`tool.annotation {id?}`, Accessibility at Figma, Add an annotation): with a layer selected, or an `id`, it asks the UI for that layer's note at once; in the tool a press on a layer selects it and asks, a press on empty canvas does nothing; the Inspect tab opens with the caret in the new-note field and the engine writes nothing itself; documented shortcuts 137, bound 136 (#1840). Figma's placement, cursor and whether the tool stays selected are unmeasured (Developer Mode is paywalled in the web app); staying selected until V or Escape, like the Comment tool, is inferred, and there is no toolbar button or icon yet |
| 2026-11-11 | patch | M5, gap 4: marquee selection as measured in Figma's web app (Select layers and objects, Selection marquee): a partly covered top-level frame or section gives the layers in it the marquee touches (it was selected only when enclosed, so nothing came), an empty frame is selected on touch, a rotated shape is touched by its outline and not its box, a drag from inside a frame selects that frame only when the marquee also takes another top-level frame (then whole), and also takes the top-level layers it touches outside it, and selecting a frame whole keeps the marquee at the top level (a partly covered frame then comes whole, not its layers). Flipped: `marquee_selects_what_it_touches`, `cmd_marquee_selects_nested_layers`, `a_box_selects_like_a_marquee`. Tests: `marquee_behaviour_tests`. Counts unchanged |