Merge main into #2121

This commit is contained in:
bflatastic committed 2026-11-12 11:50:33 +07:00
commit fac3541c4d
6 files changed
+350 -16

No files matched your search

+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(
+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-08 | minor | M8: measured in Figma on 2026-11-08, vector edit mode's Point section: X and Y show the value the selected points share, or Mixed when they differ, and a typed value sets every selected point's own coordinate in the layer being edited, points of other edited layers left as they are (it moved the group's top-left before); a relative edit (`+10`, `*2`, `/2`) typed into a Mixed field applies to each point's own value (`vector.edit.position` takes a number or `{add}`, `{mul}`, `{div}` per axis); the W and H row and other edited layers' points are 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 |