Merge upstream/main into feat/variables-multi-select-2002

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Trevor BrownandClaude Fable 5.1 committed 2026-11-05 16:41:32 -05:00
commit 527307178e
21 files changed
+587 -46

No files matched your search

+3 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (the Local variables window selects several variables (click, Shift+click for a range, Cmd/Ctrl+click to toggle, Escape to clear) and delete, move to a group and scope act on all of them in one undo step (#2002); web host bridge: `?host=parent` or `data-host="page"` hands a page the app's files, documented in the hosting guide; a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; 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 Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (the Local variables window selects several variables (click, Shift+click for a range, Cmd/Ctrl+click to toggle, Escape to clear) and delete, move to a group and scope act on all of them in one undo step (#2002); glass bends with a thick-lens profile and splits its colours over 8 steps; glass effects take variables and keep their frost within 0–100; web host bridge: `?host=parent` or `data-host="page"` hands a page the app's files, documented in the hosting guide; a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; 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 Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makLine truncated
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@@ -359,6 +359,8 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-11-02 | minor | M3, #1885: glass bends the backdrop with the reference tool's thick-lens profile: most a 1-unit bevel in from the edge, falling off as the 3.5th power of the way out of the curve to nothing `depth` further in, and a fifth as much on the bevel itself (it fell off as the square of the way in from the very edge). Dispersion splits the channels over 8 steps with a triangular window, red moving furthest and blue least, as far apart as the reference tool's (at 100% red and blue move 0.16 of the bend apart; it was three samples, blue furthest), and `Glass::max_bend` bounds red's furthest step, so the backdrop read around a pane covers every sample. Tests: `glass::tests::the_bend_follows_the_thick_lens_profile`, `glass::tests::dispersion_splits_the_channels_along_the_bend`. Numbers unchanged |
| 2026-11-02 | minor | M3, #1885: glass effects take variables (light angle, intensity, refraction, depth, dispersion, frost and splay, on layers and in effect styles, and from the Design panel's glass fields), and a glass's frost stays within 0–100, the reference tool's range: a larger frost from an older file draws at 100, and the field, a bound variable and scaling stop there. Numbers unchanged |
| 2026-10-31 | minor | M12: as the help describes, holding Cmd/Ctrl while moving a connector's end places it on any part of an object instead of snapping to a side. It keeps that spot as a fraction of the object's box, so it follows the object when it moves or resizes, and without the key an end snaps as before. `board.connector {fromAt, toAt}` and `board.reattach {at}` take it; saved only when set. Numbers unchanged |
| 2026-10-31 | patch | M11: a trackpad or touch tap sends its press and release in one frame, so a plugin panel's field committed in the frame its button was clicked and the value was lost: the Counter's Set count… kept the old count. The plugin now gets both, the field's value first. The `window.figcraft` docs name `screenshot`'s `data`. Numbers unchanged |
| 2026-10-31 | patch | M4: Dash and Custom strokes have a Dash cap (None, Round, Square) in the advanced stroke settings and `node.set {dashCap}`, on both ends of every dash on the canvas and in PNG, PDF and SVG. Unset, dashes keep taking the end point caps, so older files look the same; the saved file writes it only when set. Numbers unchanged |
+41
View File
@@ -49,6 +49,47 @@ fn glass_values_are_clamped_and_reach_their_backdrop() {
assert_eq!(hidden.backdrop_radius(), 0.0);
}
#[test]
fn glass_frost_stays_within_the_reference_range() {
// A file from before the 0–100 range draws its frost at 100, and reaches no further.
let g = Glass { frost: 150.0, ..Glass::default() };
assert_eq!(g.sanitized().frost, Glass::MAX_FROST);
let e = Effect { kind: EffectKind::Glass(g), visible: true };
let capped = Effect { kind: EffectKind::Glass(Glass { frost: Glass::MAX_FROST, ..Glass::default() }), visible: true };
assert_eq!(e.backdrop_radius(), capped.backdrop_radius());
}
#[test]
fn glass_fields_take_bound_numbers_as_the_panel_shows_them() {
let mut g = Glass::default();
for (field, x) in [
("lightAngle", 30.0),
("lightIntensity", 25.0),
("refraction", 40.0),
("depth", 12.0),
("dispersion", 10.0),
("radius", 8.0),
("splay", 50.0),
] {
assert!(g.set_bound(field, x), "{field}");
}
assert_eq!((g.light_angle, g.light_intensity, g.refraction, g.depth, g.dispersion, g.frost, g.splay), (30.0, 0.25, 0.4, 12.0, 0.1, 8.0, 0.5));
// Out of range: clamped to the field's range.
for (field, x) in [("lightAngle", 270.0), ("lightIntensity", 250.0), ("depth", 0.0), ("radius", 150.0), ("splay", -5.0)] {
assert!(g.set_bound(field, x), "{field}");
}
assert_eq!((g.light_angle, g.light_intensity, g.depth, g.frost, g.splay), (180.0, 1.0, 1.0, Glass::MAX_FROST, 0.0));
// A field glass doesn't have, or a value that isn't a number, changes nothing.
let before = g.clone();
assert!(!g.set_bound("spread", 3.0));
assert!(!g.set_bound("splay", f64::NAN));
assert!(!g.set_bound("depth", f64::INFINITY));
assert_eq!(g, before);
for field in Glass::BOUND_FIELDS {
assert!(Glass::default().set_bound(field, 1.0), "{field}");
}
}
#[test]
fn effect_colours() {
let mut e = Effect::noise();
+40 -4
View File
@@ -660,7 +660,7 @@ pub struct Glass {
pub depth: f64,
/// Colour splitting along the curved edge (0–1).
pub dispersion: f64,
/// Blur of the backdrop (a blur radius, document units).
/// Blur of the backdrop (a blur radius, document units, 0–[`Self::MAX_FROST`]).
pub frost: f64,
/// How widely the light spreads over the glass (0–1).
pub splay: f64,
@@ -673,6 +673,34 @@ impl Default for Glass {
}
impl Glass {
/// The most frost a glass takes, as the reference tool's Frost field (0–100).
pub const MAX_FROST: f64 = 100.0;
/// The fields a variable binds on a glass effect (`effects/<i>/<field>`), see [`Self::set_bound`].
pub const BOUND_FIELDS: [&'static str; 7] = ["lightAngle", "lightIntensity", "refraction", "depth", "dispersion", "radius", "splay"];
/// Sets `field` (one of [`Self::BOUND_FIELDS`]) from a bound number variable, read as the
/// Design panel's field shows it: intensity, refraction, dispersion and splay in percent, the
/// frost as `radius`, each clamped to the field's range. False for a field glass doesn't have
/// or a value that isn't finite (nothing changes).
pub fn set_bound(&mut self, field: &str, x: f64) -> bool {
if !x.is_finite() {
return false;
}
let pct = (x / 100.0).clamp(0.0, 1.0);
match field {
"lightAngle" => self.light_angle = x.clamp(-180.0, 180.0),
"lightIntensity" => self.light_intensity = pct,
"refraction" => self.refraction = pct,
"depth" => self.depth = x.clamp(1.0, 1000.0),
"dispersion" => self.dispersion = pct,
"radius" => self.frost = x.clamp(0.0, Self::MAX_FROST),
"splay" => self.splay = pct,
_ => return false,
}
true
}
/// The values clamped to their ranges (non-finite ones at their defaults).
pub fn sanitized(&self) -> Glass {
let d = Glass::default();
@@ -683,15 +711,23 @@ impl Glass {
refraction: ok(self.refraction, d.refraction, 0.0, 1.0),
depth: ok(self.depth, d.depth, 1.0, 1000.0),
dispersion: ok(self.dispersion, d.dispersion, 0.0, 1.0),
frost: ok(self.frost, d.frost, 0.0, 1000.0),
frost: ok(self.frost, d.frost, 0.0, Self::MAX_FROST),
splay: ok(self.splay, d.splay, 0.0, 1.0),
}
}
/// Furthest the curved edge moves the backdrop (document units).
/// How far `dispersion` splits the colour channels, as a share of the bend: at full dispersion
/// red moves up to this much further and blue as much less. Matched to the reference tool:
/// across a black-to-white edge in the bend, red and blue part about 4.7 px at 100% and 2.4 px
/// at 50% (at 2×, depth 20, refraction 80%).
pub const DISPERSION_SPREAD: f64 = 0.16;
/// Furthest the curved edge moves the backdrop (document units): at most `depth` × `refraction`
/// (all of it a bevel in from the edge), and red's dispersion steps up to
/// [`Self::DISPERSION_SPREAD`] × `dispersion` times that again.
pub fn max_bend(&self) -> f64 {
let g = self.sanitized();
g.refraction * g.depth * (1.0 + g.dispersion * 0.25)
g.refraction * g.depth * (1.0 + Self::DISPERSION_SPREAD * g.dispersion)
}
}
+1
View File
@@ -33,6 +33,7 @@ pub fn field_type(kind: &StyleKind, key: &str) -> Option<VariableType> {
(EffectKind::DropShadow(_) | EffectKind::InnerShadow(_), "color") => Some(Color),
(EffectKind::DropShadow(_) | EffectKind::InnerShadow(_), "radius" | "spread" | "offsetX" | "offsetY") => Some(Number),
(EffectKind::LayerBlur(_) | EffectKind::BackgroundBlur(_), "radius" | "startRadius") => Some(Number),
(EffectKind::Glass(_), f) if figcraft_doc::Glass::BOUND_FIELDS.contains(&f) => Some(Number),
_ => None,
},
(StyleKind::Grid { grids }, Some((BoundList::Grids, i, "size" | "count" | "gutter" | "offset"))) => grids.get(i).map(|_| Number),
+35
View File
@@ -98,6 +98,41 @@ fn effect_moves_and_removals_reject_junk() {
assert_eq!(s.doc().unwrap().node(r).unwrap().effects.len(), 1);
}
fn glass(s: &Session, r: NodeId) -> figcraft_doc::Glass {
match s.doc().unwrap().node(r).unwrap().effects.first().map(|e| &e.kind) {
Some(EffectKind::Glass(g)) => g.clone(),
k => panic!("{k:?}"),
}
}
#[test]
fn glass_fields_take_number_variables() {
let (mut s, r) = setup();
ex(&mut s, "effect.add", json!({"ids": [r], "type": "glass"}));
// Percent fields read the variable as the Design panel shows them; `radius` is the frost.
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "effects/0/splay", "variable": "large"}));
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "effects/0/radius", "variable": "small"}));
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "effects/0/depth", "variable": "large"}));
let g = glass(&s, r);
assert_eq!((g.splay, g.frost, g.depth), (0.3, 2.0, 30.0));
// A new value follows, clamped to the field's range (frost stops at 100).
ex(&mut s, "variable.set", json!({"variable": "small", "value": 150}));
ex(&mut s, "variable.set", json!({"variable": "large", "value": 75}));
let g = glass(&s, r);
assert_eq!((g.splay, g.frost, g.depth), (0.75, figcraft_doc::Glass::MAX_FROST, 75.0));
// A colour variable doesn't fit a glass field.
assert!(s.execute("variable.bind", &json!({"ids": [r], "field": "effects/0/refraction", "variable": "brand"})).is_err());
// An effect style binds them too, and every layer using it follows.
let fx = ex_value(&mut s, "style.create", json!({"type": "effect", "name": "Pane", "from": r}))["id"].as_str().unwrap().to_string();
ex(&mut s, "style.bind", json!({"style": fx, "field": "effects/0/refraction", "variable": "large"}));
assert_eq!(glass(&s, r).refraction, 0.75);
assert!(s.execute("style.bind", &json!({"style": fx, "field": "effects/0/spread", "variable": "large"})).is_err(), "glass has no spread");
}
fn ex_value(s: &mut Session, cmd: &str, p: serde_json::Value) -> serde_json::Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn fill_hex(s: &Session, r: NodeId) -> Vec<String> {
s.doc().unwrap().node(r).unwrap().fills.iter().filter_map(|p| p.solid_color()).map(|c| c.to_hex()).collect()
}
+2 -1
View File
@@ -465,7 +465,8 @@ pub fn scale_node(doc: &mut Document, id: NodeId, k: f64) -> Result<()> {
}
EffectKind::Glass(g) => {
g.depth *= k;
g.frost *= k;
// From the frost it draws with (an older file's past 100 draws at 100).
g.frost = (g.sanitized().frost * k).min(figcraft_doc::Glass::MAX_FROST);
}
}
}
+5 -2
View File
@@ -53,7 +53,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"style.bind",
"Bind variable in style",
"{style, field, variable? (id or name; none: unbind)} bind a variable to a field of the style's value: a paint style's `fills/<i>/color` (or `fill`: its first paint) and `fills/<i>/stops/<j>/color`, an effect style's `effects/<i>/<color|radius|spread|offsetX|offsetY|startRadius>`, a grid style's `layoutGrids/<i>/<size|count|gutter|offset>`, a text style's `fontSize|lineHeight|letterSpacing` (numbers, px) and `fontFamily|fontStyle` (strings). Every layer using the style shows the field resolved in its own modes; the style keeps it resolved in the default modes",
"{style, field, variable? (id or name; none: unbind)} bind a variable to a field of the style's value: a paint style's `fills/<i>/color` (or `fill`: its first paint) and `fills/<i>/stops/<j>/color`, an effect style's `effects/<i>/<color|radius|spread|offsetX|offsetY|startRadius>` (glass: `lightAngle|lightIntensity|refraction|depth|dispersion|radius|splay`), a grid style's `layoutGrids/<i>/<size|count|gutter|offset>`, a text style's `fontSize|lineHeight|letterSpacing` (numbers, px) and `fontFamily|fontStyle` (strings). Every layer using the style shows the field resolved in its own modes; the style keeps it resolved in the default modes",
true,
bind_style
),
@@ -196,7 +196,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"variable.bind",
"Bind variable",
"{ids?, field (fill|stroke (first paint)|fills/<i>/color|strokes/<i>/color|fills/<i>/stops/<j>/color (gradient stops)|effects/<i>/<offsetX|offsetY|radius|startRadius|spread|color>|layoutGrids/<i>/<size|count|gutter|offset>|componentProperties/<name> (instances: boolean/text properties; variant properties take string variables naming a value)|width|height|opacity (percent)|cornerRadius|gap|paddingHorizontal|characters|visible|fontSize…; list indices bottom first), variable (null unbinds)}",
"{ids?, field (fill|stroke (first paint)|fills/<i>/color|strokes/<i>/color|fills/<i>/stops/<j>/color (gradient stops)|effects/<i>/<offsetX|offsetY|radius|startRadius|spread|color|lightAngle|lightIntensity|refraction|depth|dispersion|splay> (glass: `radius` is its frost; intensity, refraction, dispersion and splay in percent)|layoutGrids/<i>/<size|count|gutter|offset>|componentProperties/<name> (instances: boolean/text properties; variant properties take string variables naming a value)|width|height|opacity (percent)|cornerRadius|gap|paddingHorizontal|characters|visible|fontSize…; list indices bottom first), variable (null unbinds)}",
true,
bind
),
@@ -1304,6 +1304,9 @@ pub(crate) fn set_effect_field(effects: &mut [Effect], i: usize, prop: &str, val
(EffectKind::DropShadow(s) | EffectKind::InnerShadow(s), "color", _, VariableValue::Color(c)) => s.color = *c,
(EffectKind::LayerBlur(b) | EffectKind::BackgroundBlur(b), "radius", Some(x), _) => b.radius = x.max(0.0),
(EffectKind::LayerBlur(b) | EffectKind::BackgroundBlur(b), "startRadius", Some(x), _) => b.start_radius = x.max(0.0),
(EffectKind::Glass(g), field, Some(x), _) => {
g.set_bound(field, x);
}
_ => {}
}
}
+1 -1
View File
@@ -475,7 +475,7 @@ pub fn properties_with(doc: &Document, id: NodeId, fmt: ColorFormat) -> Vec<Sect
pct(g.refraction),
num(g.depth),
pct(g.dispersion),
num(g.frost),
num(g.sanitized().frost),
pct(g.splay)
),
),
+26
View File
@@ -95,6 +95,32 @@ fn scaling_scales_grain_and_glass() {
assert_eq!((g.refraction, nz.density), (d.refraction, 0.5));
}
#[test]
fn scaling_a_frost_from_an_older_file_starts_from_what_it_draws() {
// A frost of 150, saved before the 0–100 range, draws at 100: halved, it draws at 50.
let mut s = Session::new();
let r = rect(&mut s);
s.execute("effect.add", &json!({"ids": [r], "type": "glass"})).unwrap();
s.execute("effect.set", &json!({"ids": [r], "index": 0, "effect": {"type": "glass", "frost": 150}})).unwrap();
s.execute("transform.scale", &json!({"ids": [r], "factor": 0.5})).unwrap();
let EffectKind::Glass(g) = &s.doc().unwrap().node(r).unwrap().effects[0].kind else { panic!() };
assert_eq!(g.frost, 50.0);
}
#[test]
fn inspect_shows_a_frost_from_an_older_file_as_it_draws() {
// A frost of 150, saved before the 0–100 range, draws at 100: Dev Mode says 100 as CSS does.
let mut s = Session::new();
let r = rect(&mut s);
s.execute("effect.add", &json!({"ids": [r], "type": "glass"})).unwrap();
s.execute("effect.set", &json!({"ids": [r], "index": 0, "effect": {"type": "glass", "frost": 150}})).unwrap();
let doc = s.doc().unwrap();
let sections = crate::devinspect::properties(doc, r);
let glass = &sections.iter().find(|s| s.title == "Effects").unwrap().rows[0];
assert!(glass.value.contains(" frost 100 "), "{}", glass.value);
assert!(code_for(doc, r, CodeLang::Css).contains("backdrop-filter: blur(50px);"));
}
#[test]
fn inspect_and_css_show_the_new_effects() {
let mut s = Session::new();
+1 -1
View File
@@ -234,7 +234,7 @@ impl<'d> Exporter<'d> {
}
EffectKind::Glass(g) => {
self.warn("glass effects keep only their frost");
f.extend([("type", e("GLASS")), ("radius", fl(g.frost))]);
f.extend([("type", e("GLASS")), ("radius", fl(g.sanitized().frost))]);
}
}
o(f)
+19
View File
@@ -1243,6 +1243,25 @@ fn local_samples_round_trip() {
}
}
#[test]
fn a_glass_frost_is_written_as_it_draws() {
// A frost saved before the 0–100 range (or not a number) is written at what it draws: 100, or
// the default.
for (frost, written) in [(12.0, 12.0), (500.0, Glass::MAX_FROST), (f64::NAN, Glass::default().frost)] {
let mut doc = Document::new("glass");
let page = doc.first_page().unwrap();
let r = doc.create(page, NodeKind::Rectangle, "Pane", 0.0, 0.0, 24.0, 24.0).unwrap();
doc.get_mut(r).unwrap().effects = vec![Effect { kind: EffectKind::Glass(Glass { frost, ..Glass::default() }), visible: true }];
let (bytes, _) = export(&doc, None).unwrap();
let archive = Archive::open(&bytes).unwrap();
let (schema, message) = decode_canvas(&archive.canvas).unwrap();
let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap();
let pane = msg.get("nodeChanges").unwrap().list().iter().find(|nc| nc.get("name").and_then(Kv::str) == Some("Pane")).unwrap();
let radius = pane.get("effects").unwrap().list()[0].get("radius").and_then(Kv::f64).unwrap();
assert_eq!(radius, written, "frost {frost}");
}
}
#[test]
fn a_vector_drawable_export_setting_is_written_as_svg_with_a_warning() {
let mut doc = Document::new("vd");
+175 -17
View File
@@ -1,9 +1,12 @@
//! The glass effect: the backdrop seen through a pane whose edges curve down like a lens.
//!
//! Within `depth` of the outline the surface curves: the backdrop there is bent towards the inside
//! (strongest at the very edge, `refraction` × `depth` far), each colour channel by a slightly
//! different amount (`dispersion`, a rainbow fringe), and the rim catches the light: brightest
//! where it faces `light_angle`, half as bright on the opposite side, spread wider by `splay`.
//! Within `depth` of the outline the surface curves like a thick lens: the backdrop there is bent
//! towards the inside, most a [`BEVEL`] in from the edge (`refraction` × `depth` far), falling off
//! as the 3.5th power of the way out of the curve and gone `depth` further in, with a fifth as
//! much on the bevel itself ([`bend_at`]). Each colour channel bends by a different amount
//! (`dispersion`, a rainbow fringe: red furthest, blue least, each smeared along the bend), and the
//! rim catches the light: brightest where it faces `light_angle`, half as bright on the opposite
//! side, spread wider by `splay`.
//! `frost` blurs the backdrop first. Distances to the outline come from an exact Euclidean
//! distance transform of the shape's coverage.
//!
@@ -162,6 +165,70 @@ fn smooth_normals(dist: &[f32], w: usize, h: usize, r: usize) -> Vec<(f32, f32)>
.collect()
}
/// The bevel along a glass's edge (document units): where the bend eases off before the edge.
pub(crate) const BEVEL: f64 = 1.0;
/// How sharply the bend falls off going in: the 3.5th power of the way out of the curve.
const PROFILE_EXP: f32 = 3.5;
/// The furthest the backdrop moves, as a share of `depth`.
const MAX_BEND: f32 = 0.999;
/// The bend on the bevel itself, as a share of [`MAX_BEND`] × `depth` × `refraction`.
const RIM_BEND: f32 = 0.2;
/// Steps of the dispersion window: each channel is sampled this many times along the bend, weighted
/// by a triangle (the two end steps weigh nothing and are skipped).
const DISPERSION_STEPS: usize = 8;
/// `x` from `a` to `b` as 0 to 1, clamped (a span under 1e-5 counts as 1e-5).
fn lin(a: f32, b: f32, x: f32) -> f32 {
((x - a) / (b - a).max(1e-5)).clamp(0.0, 1.0)
}
/// How far the backdrop moves at `x` pixels in from the edge, for a glass `depth` and [`BEVEL`]
/// `bevel` deep (pixels) and `refraction` (0–1): a thick lens's profile, the most a bevel in from
/// the edge, falling off as the 3.5th power of the way out of the curve to nothing `depth` further
/// in, and a fifth as much on the bevel itself, eased between the two over its outer 70%.
pub(crate) fn bend_at(x: f32, depth: f32, bevel: f32, refraction: f32) -> f32 {
// Past the curve (and the bevel's easing) nothing bends: the flat inside of the pane.
if x >= depth.max(1e-4) + bevel {
return 0.0;
}
let sd = -x;
let t = lin(-depth - bevel, -bevel, sd);
let most = MAX_BEND * depth;
let core = (t.powf(PROFILE_EXP) * depth * refraction).clamp(-most, most);
let rim = refraction * RIM_BEND * most;
let u = lin(-bevel - 1e-4, -0.3 * bevel, sd);
core + (rim - core) * u
}
/// The backdrop seen at pixel coordinates (`x`, `y`) moved by (`dx`, `dy`), its channels split by
/// `k` (dispersion, 0–1): red moves up to `k` further, blue up to `k` less, each smeared along the
/// move by a triangular window of [`DISPERSION_STEPS`]. One sample when `k` is too small to show.
fn dispersed(frosted: &Pixmap, x: f32, y: f32, dx: f32, dy: f32, k: f32) -> [f32; 4] {
let at = |s: f32| sample(frosted, x + dx * s, y + dy * s);
if k < 0.001 {
let s = at(1.0);
return [s[0].min(s[3]), s[1].min(s[3]), s[2].min(s[3]), s[3]];
}
let last = (DISPERSION_STEPS - 1) as f32;
let (mut acc, mut sum) = ([0.0f32; 4], 0.0f32);
for i in 1..DISPERSION_STEPS - 1 {
let t = i as f32 / last - 0.5;
let w = 1.0 - (2.0 * t).abs();
let (r, g, b) = (at(1.0 + (t + 0.5) * k), at(1.0 + t * k), at(1.0 + (t - 0.5) * k));
acc[0] += r[0] * w;
acc[1] += g[1] * w;
acc[2] += b[2] * w;
acc[3] += r[3].max(g[3]).max(b[3]) * w;
sum += w;
}
let a = acc[3] / sum;
[(acc[0] / sum).min(a), (acc[1] / sum).min(a), (acc[2] / sum).min(a), a]
}
/// Bilinear sample of premultiplied `p` at pixel coordinates `(x, y)` (centres at +0.5), each
/// channel 0..=255; transparent outside.
fn sample(p: &Pixmap, x: f32, y: f32) -> [f32; 4] {
@@ -411,20 +478,20 @@ pub fn glass(g: &Glass, frame: EffectFrame<'_>) -> Option<Pixmap> {
let EffectFrame { shape, back, pad, size: (w, h), xf, scale } = frame;
let g = g.sanitized();
let depth = (g.depth * scale).clamp(1.0, 1024.0);
let bevel = (BEVEL * scale).min(1024.0);
let band = (depth * (0.15 + 0.35 * g.splay)).max(1.0);
let reach = light_reach(band, g.light_intensity);
let r = (depth * 0.1).clamp(1.0, 8.0) as usize;
// Coverage with a margin, so wherever the layer is cut every distance the pixels read is
// exact: the bend's (within `depth`), the light's (within `reach`) and those the normals are
// smoothed from (2r + 1 further).
let m = depth.max(reach).ceil() as u32 + 4 * r as u32 + 4;
// exact: the bend's (within `depth` + `bevel`), the light's (within `reach`) and those the
// normals are smoothed from (2r + 1 further).
let m = (depth + bevel).max(reach).ceil() as u32 + 4 * r as u32 + 4;
let (fields, shift) = shape_fields(shape, xf, w, h, m, r)?;
let mut frosted = back.clone();
fx::blur(&mut frosted, g.frost / 2.0 * scale);
let ang = g.light_angle.to_radians();
let light = (ang.sin() as f32, -ang.cos() as f32);
let bend_max = (g.refraction * depth) as f32;
let fringe = (g.dispersion * 0.25) as f32;
let (refraction, dispersion, bevel) = (g.refraction as f32, (g.dispersion * Glass::DISPERSION_SPREAD) as f32, bevel as f32);
let power = (6.0 - 5.0 * g.splay) as f32;
let (band, reach) = (band as f32, reach as f32);
// The falloff, lowered by its value at `reach` and scaled back to 1 at the edge, so the light
@@ -441,19 +508,15 @@ pub fn glass(g: &Glass, frame: EffectFrame<'_>) -> Option<Pixmap> {
if c <= 0.0 {
continue;
}
let t = (d / depth).min(1.0);
let bend = bend_max * (1.0 - t) * (1.0 - t);
let bend = bend_at(d, depth, bevel, refraction);
let (bx, by) = (x as f32 + pad as f32 + 0.5, y as f32 + pad as f32 + 0.5);
let base = if bend == 0.0 {
// Nothing bends (past `depth`, or no refraction): the three channels' samples are
// at the same point, so one sample.
// Nothing bends (past `depth` + `bevel`, or no refraction): the channels' samples
// are at the same point, so one sample.
let s = sample(&frosted, bx, by);
[s[0].min(s[3]), s[1].min(s[3]), s[2].min(s[3]), s[3]]
} else {
let at = |k: f32| sample(&frosted, bx + inward.0 * bend * k, by + inward.1 * bend * k);
let (rs, gs, bs) = (at(1.0 - fringe), at(1.0), at(1.0 + fringe));
let a = rs[3].max(gs[3]).max(bs[3]);
[rs[0].min(a), gs[1].min(a), bs[2].min(a), a]
dispersed(&frosted, bx, by, inward.0 * bend, inward.1 * bend, dispersion)
};
// The rim catches the light where it faces it, and half as much opposite. It fades out
// by `reach` and is none past it, wherever the layer is cut.
@@ -479,6 +542,101 @@ pub fn glass(g: &Glass, frame: EffectFrame<'_>) -> Option<Pixmap> {
mod tests {
use super::*;
/// The bend at depth 20, refraction 0.8 and a 1-unit bevel, x units in from the edge: the
/// reference profile's values.
#[test]
fn the_bend_follows_the_thick_lens_profile() {
let table = [
(0.10, 3.1968),
(0.25, 3.1968),
(0.50, 6.8543),
(0.75, 11.4263),
(1.00, 15.9982),
(2.0, 13.3707),
(4.0, 9.0591),
(6.0, 5.8457),
(8.0, 3.5426),
(10.0, 1.9742),
(16.0, 0.1250),
(20.0, 0.0004),
(21.0, 0.0),
(40.0, 0.0),
];
for (x, want) in table {
let got = bend_at(x, 20.0, 1.0, 0.8);
assert!((got - want).abs() < 1e-3, "x {x}: {got} vs {want}");
}
// No refraction, no bend; never more than 0.999 × depth, never outwards.
assert_eq!(bend_at(0.5, 20.0, 1.0, 0.0), 0.0);
for i in 0..400 {
let x = i as f32 * 0.1;
let b = bend_at(x, 20.0, 1.0, 1.0);
assert!((0.0..=0.999 * 20.0).contains(&b), "x {x}: {b}");
}
// A depth thinner than the bevel (zoomed far in) still gives numbers.
assert!(bend_at(0.5, 1.0, 8.0, 1.0).is_finite());
// `Glass::max_bend`, which sizes the backdrop read around a pane, bounds the whole bend.
for refraction in [0.3, 0.8, 1.0] {
let most = (0..800).map(|i| bend_at(i as f32 * 0.05, 20.0, 1.0, refraction as f32)).fold(0.0f32, f32::max);
let bound = Glass { refraction, depth: 20.0, dispersion: 0.0, ..Glass::default() }.max_bend();
assert!(f64::from(most) <= bound + 1e-4, "refraction {refraction}: {most} past {bound}");
}
}
/// Dispersion off is one sample at the moved point; on, the channels split along the move, red
/// further and blue less, and a flat backdrop stays flat.
#[test]
fn dispersion_splits_the_channels_along_the_bend() {
// A 40 × 1 ramp: red, green and blue all rise with x.
let mut p = Pixmap::new(40, 1).unwrap();
for (i, px) in p.data_mut().chunks_exact_mut(4).enumerate() {
let v = (i * 6) as u8;
px.copy_from_slice(&[v, v, v, 255]);
}
let one = sample(&p, 10.5 + 8.0, 0.5);
let off = dispersed(&p, 10.5, 0.5, 8.0, 0.0, 0.0);
assert_eq!(off, [one[0], one[1], one[2], one[3]]);
let on = dispersed(&p, 10.5, 0.5, 8.0, 0.0, 1.0);
assert!(on[0] > on[1] && on[1] > on[2], "{on:?}");
assert!((on[1] - one[1]).abs() < 1e-3, "green stays centred on the move: {on:?} vs {one:?}");
let mut flat = Pixmap::new(40, 1).unwrap();
flat.fill(tiny_skia::Color::from_rgba8(90, 120, 200, 255));
let f = dispersed(&flat, 10.5, 0.5, 8.0, 0.0, 1.0);
assert!(f.iter().zip([90.0, 120.0, 200.0, 255.0]).all(|(a, b)| (a - b).abs() < 1e-3), "{f:?}");
// One white pixel on black: only the weighted steps that reach it pick it up, red and green
// from opposite ends of their windows (the same amount), blue none.
let mut impulse = Pixmap::new(40, 1).unwrap();
impulse.fill(tiny_skia::Color::BLACK);
impulse.data_mut()[20 * 4..21 * 4].copy_from_slice(&[255, 255, 255, 255]);
let spread = dispersed(&impulse, 10.5, 0.5, 8.0, 0.0, 1.0);
assert!((spread[0] - 33.392857).abs() < 1e-3 && (spread[1] - 33.392857).abs() < 1e-3, "{spread:?}");
assert_eq!(spread[2], 0.0);
}
/// At full dispersion red's furthest step stays within `Glass::max_bend` of the pixel, all
/// along the bend, so the backdrop read around a pane covers it: one white pixel further out
/// than that is never seen.
#[test]
fn full_dispersion_reads_no_further_than_max_bend() {
let bound = Glass { refraction: 1.0, depth: 20.0, dispersion: 1.0, ..Glass::default() }.max_bend() as f32;
let k = Glass::DISPERSION_SPREAD as f32;
let mut furthest = 0usize;
for i in 0..100 {
let bend = bend_at(i as f32 * 0.25, 20.0, 1.0, 1.0);
for at in 0..40 {
let mut impulse = Pixmap::new(40, 1).unwrap();
impulse.fill(tiny_skia::Color::BLACK);
impulse.data_mut()[at * 4..at * 4 + 4].copy_from_slice(&[255, 255, 255, 255]);
let s = dispersed(&impulse, 0.5, 0.5, bend, 0.0, k);
if s[0].max(s[1]).max(s[2]) > 0.0 {
furthest = furthest.max(at);
}
}
}
// A sample `o` pixels along picks up the pixel `at` while |o − at| < 1.
assert!(furthest > 0 && (furthest as f32) < bound + 1.0, "pixel {furthest} seen, past {bound}");
}
#[test]
fn distance_of_a_square() {
let mut p = Pixmap::new(21, 21).unwrap();
+32
View File
@@ -185,6 +185,33 @@ fn drag_id() -> Id {
Id::unique("figcraft_handle_drag")
}
/// Whether a gradient handle or stop on the canvas is being dragged, the press that starts it
/// included: those are the open colour picker's own controls, so the picker stays through the drag
/// instead of taking the press for one outside it (#1852).
pub fn gradient_dragging(ctx: &egui::Context) -> bool {
matches!(ctx.data(|d| d.get_temp::<Drag>(drag_id())), Some(Drag::Gradient { .. }))
}
/// Whether the document has a gesture open: a handle drag edits only inside its own.
fn gesture_open(app: &FigApp) -> bool {
app.session.ds().is_ok_and(|d| d.interaction.is_some())
}
/// A handle drag whose button went up wherever the pointer was (off the canvas, over a panel, the
/// colour picker or a ruler, outside the window) ends as a release on the handle would: the edit
/// commits now, instead of waiting, uncommitted, to move the handle to wherever the pointer next
/// presses. So does one whose gesture Escape or Undo closed under the held button: it stops there
/// rather than editing on with no gesture open, an undo step a frame. True when a drag ended.
pub fn end_released_drag(app: &mut FigApp, ctx: &egui::Context) -> bool {
let stashed = ctx.data(|d| d.get_temp::<Drag>(drag_id())).is_some();
if !stashed || (ctx.input(|i| i.pointer.primary_down()) && gesture_open(app)) {
return false;
}
let _ = app.session.commit_interaction();
ctx.data_mut(|d| d.remove::<Drag>(drag_id()));
true
}
/// The gradient being edited (single node, picker open on a gradient paint).
fn gradient_target(app: &FigApp) -> Option<(NodeId, bool, usize, figcraft_color::Gradient)> {
let Some(PickerTarget::Paint { fills, index, ids }) = &app.ui.picker else { return None };
@@ -516,6 +543,11 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Pos2) -> bool {
});
let dragging: Option<Drag> = ctx.data(|d| d.get_temp(drag_id()));
if let Some(drag) = dragging {
// Escape or Undo closed the gesture under the held button: the drag is over.
if !gesture_open(app) {
ctx.data_mut(|d| d.remove::<Drag>(drag_id()));
return true;
}
if down {
apply(app, &drag, pos, shift);
ctx.set_cursor_icon(egui::CursorIcon::Grabbing);
+8
View File
@@ -183,6 +183,8 @@ pub(super) fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> boo
if !ctx.input(|i| i.pointer.button_down(egui::PointerButton::Middle) || i.pointer.primary_down()) {
app.canvas.middle_pan = None;
}
// So is a handle drag.
crate::handles::end_released_drag(app, &ctx);
return true;
};
// A press on the canvas gives the keyboard back to it (arrows nudge again).
@@ -230,6 +232,12 @@ pub(super) fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> boo
}
return true;
}
// A handle drag whose button went up ends first, wherever on the canvas the pointer is: over a
// ruler or a comment pin, which take the pointer below, or over something drawn above the
// canvas (the colour picker, a card). The handle had the press, so it has the release too.
if crate::handles::end_released_drag(app, &ctx) {
return false;
}
if !app.canvas.dragging && (resp.hovered() || app.ui.comments.dragging()) && crate::comments::input(app, &ctx, pos) {
return false;
}
@@ -215,3 +215,160 @@ fn a_press_in_the_gradient_variable_picker_keeps_the_colour_picker_open() {
assert!(u.app.ui.picker.is_some(), "the child variable picker is part of the interaction");
u.button(at, false);
}
#[test]
fn dragging_a_gradient_handle_on_the_canvas_keeps_the_picker_open() {
// #1852: the handles belong to the picker, so the press that starts a drag is not a press
// outside it: the picker and the handles stay, the drag is one undo step, and a press elsewhere
// on the layer still closes it.
let mut s = Session::new();
let a = NodeId(s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 200, "height": 200})).unwrap()["id"].as_u64().unwrap());
s.execute("fill.set", &json!({"id": a, "paint": {"type": "linearGradient", "stops": ["#ff0000", "#0000ff"]}})).unwrap();
s.execute("select.set", &json!({"ids": [a]})).unwrap();
let mut u = Ui::new(s);
let target = PickerTarget::Paint { fills: true, index: 0, ids: vec![a] };
u.app.open_picker(target.clone(), None);
for _ in 0..5 {
u.frame(vec![]);
}
assert_eq!(u.app.ui.picker, Some(target.clone()));
let depth = u.app.session.undo_depth().0;
let from_handle = |u: &Ui| match &u.app.session.doc().unwrap().node(a).unwrap().fills[0].kind {
figcraft_color::PaintKind::Gradient(g) => g.from,
_ => panic!("the fill is a gradient"),
};
assert_eq!(from_handle(&u), (0.5, 0.0), "a linear gradient starts at the top middle");
// Drag the start handle from the top middle of the layer to its left middle.
let from = u.app.to_screen(200.0, 100.0);
let to = u.app.to_screen(100.0, 200.0);
u.button(from, true);
assert_eq!(u.app.ui.picker, Some(target.clone()), "the press on the handle keeps the picker open");
for _ in 0..2 {
u.frame(vec![egui::Event::PointerMoved(to)]);
}
assert_eq!(u.app.ui.picker, Some(target.clone()), "and so does the drag");
let (x, y) = from_handle(&u);
assert!(x.abs() < 0.02 && (y - 0.5).abs() < 0.02, "the handle followed the pointer: ({x}, {y})");
u.button(to, false);
u.frame(vec![]);
assert_eq!(u.app.ui.picker, Some(target.clone()), "released, the picker is still open");
assert_eq!(u.app.session.undo_depth().0, depth + 1, "the drag is one undo step");
// A press on the layer away from its handles is still a press outside the picker.
let body = u.app.to_screen(250.0, 250.0);
u.button(body, true);
u.button(body, false);
u.frame(vec![]);
assert!(u.app.ui.picker.is_none(), "a plain canvas press closes it");
}
#[test]
fn a_gradient_drag_released_off_the_canvas_ends_there() {
// A handle drag whose button goes up over the sidebar or over the picker itself ends there:
// the edit commits, the picker stays, and the next press on the canvas is a plain press (it
// closes the picker) rather than a move of the handle to wherever it landed.
for over in ["sidebar", "picker"] {
let mut s = Session::new();
let a = NodeId(s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 200, "height": 200})).unwrap()["id"].as_u64().unwrap());
s.execute("fill.set", &json!({"id": a, "paint": {"type": "linearGradient", "stops": ["#ff0000", "#0000ff"]}})).unwrap();
s.execute("select.set", &json!({"ids": [a]})).unwrap();
let mut u = Ui::new(s);
let target = PickerTarget::Paint { fills: true, index: 0, ids: vec![a] };
u.app.open_picker(target.clone(), None);
for _ in 0..5 {
u.frame(vec![]);
}
let depth = u.app.session.undo_depth().0;
let from_handle = |u: &Ui| match &u.app.session.doc().unwrap().node(a).unwrap().fills[0].kind {
figcraft_color::PaintKind::Gradient(g) => g.from,
_ => panic!("the fill is a gradient"),
};
u.button(u.app.to_screen(200.0, 100.0), true);
u.frame(vec![egui::Event::PointerMoved(u.app.to_screen(100.0, 200.0))]);
assert!(crate::handles::gradient_dragging(&u.ctx), "{over}: the handle is being dragged");
let spot = match over {
"sidebar" => u.app.ui.right_panel_rect.unwrap().center(),
_ => u.app.ui.picker_popover.as_ref().unwrap().rect.center(),
};
if over == "picker" {
assert!(u.app.canvas.rect.contains(spot), "the picker lies over the canvas");
} else {
assert!(!u.app.canvas.rect.contains(spot), "the sidebar is off the canvas");
}
u.button(spot, false);
u.frame(vec![]);
assert!(!crate::handles::gradient_dragging(&u.ctx), "{over}: the release ended the drag");
assert_eq!(u.app.ui.picker, Some(target.clone()), "{over}: the picker is still open");
assert_eq!(u.app.session.undo_depth().0, depth + 1, "{over}: the drag is one undo step");
let moved = from_handle(&u);
assert!(moved.0.abs() < 0.02 && (moved.1 - 0.5).abs() < 0.02, "{over}: the handle stayed where it was dragged: {moved:?}");
// The next press on the layer is a plain one: the handle stays put and the picker closes.
let body = u.app.to_screen(250.0, 250.0);
u.button(body, true);
u.frame(vec![]);
assert_eq!(from_handle(&u), moved, "{over}: a later press doesn't move the handle");
assert!(u.app.ui.picker.is_none(), "{over}: a later press closes the picker as any outside press does");
u.button(body, false);
}
}
/// A rectangle with a linear gradient fill, selected, its picker open and the canvas settled; a
/// second layer drawn after it leaves an undo step that isn't the gradient's. Rulers on or off.
fn gradient_ui(rulers: bool) -> Ui {
let mut s = Session::new();
if rulers {
s.execute("view.rulers", &json!({"on": true})).unwrap();
}
let a = NodeId(s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 200, "height": 200})).unwrap()["id"].as_u64().unwrap());
s.execute("fill.set", &json!({"id": a, "paint": {"type": "linearGradient", "stops": ["#ff0000", "#0000ff"]}})).unwrap();
s.execute("shape.rectangle", &json!({"x": 400, "y": 100, "width": 20, "height": 20})).unwrap();
s.execute("select.set", &json!({"ids": [a]})).unwrap();
let mut u = Ui::new(s);
u.app.open_picker(PickerTarget::Paint { fills: true, index: 0, ids: vec![a] }, None);
for _ in 0..5 {
u.frame(vec![]);
}
u
}
#[test]
fn a_gradient_drag_released_over_a_ruler_ends_there() {
// The rulers take the pointer on the canvas before the handles do: the release still ends the
// drag, as one undo step, instead of leaving it waiting for the next press.
let mut u = gradient_ui(true);
let depth = u.app.session.undo_depth().0;
u.button(u.app.to_screen(200.0, 100.0), true);
u.frame(vec![egui::Event::PointerMoved(u.app.to_screen(100.0, 200.0))]);
assert!(crate::handles::gradient_dragging(&u.ctx), "the handle is being dragged");
let r = u.app.canvas.rect;
let ruler = egui::Pos2::new(r.min.x + 5.0, r.center().y);
assert!(!u.app.ui.picker_popover.as_ref().unwrap().rect.contains(ruler), "the ruler is not under the picker");
u.button(ruler, false);
assert!(!crate::handles::gradient_dragging(&u.ctx), "the release over the ruler ended the drag");
assert_eq!(u.app.session.undo_depth().0, depth + 1, "the drag is one undo step");
}
#[test]
fn a_gradient_drag_whose_gesture_closes_under_the_held_button_stops_editing() {
// Escape (the picker closes and commits) or Undo ends the gesture while the button is still
// down: moving on edits nothing more, rather than adding an undo step every frame.
for close in ["escape", "undo"] {
let mut u = gradient_ui(false);
u.button(u.app.to_screen(200.0, 100.0), true);
u.frame(vec![egui::Event::PointerMoved(u.app.to_screen(100.0, 200.0))]);
assert!(crate::handles::gradient_dragging(&u.ctx), "{close}: the handle is being dragged");
if close == "escape" {
crate::colorpicker::close(&mut u.app);
} else {
assert!(u.app.session.undo().unwrap(), "{close}: there is a step to undo");
}
let depth = u.app.session.undo_depth().0;
let doc = u.app.session.doc().unwrap().clone();
for k in 1..=4 {
u.frame(vec![egui::Event::PointerMoved(u.app.to_screen(100.0 + 20.0 * f64::from(k), 200.0))]);
}
assert!(!crate::handles::gradient_dragging(&u.ctx), "{close}: the drag is over");
assert_eq!(u.app.session.undo_depth().0, depth, "{close}: no undo step a frame");
assert!(*u.app.session.doc().unwrap() == doc, "{close}: the gradient stays where the gesture left it");
u.button(u.app.to_screen(180.0, 200.0), false);
}
}
+2 -2
View File
@@ -667,11 +667,11 @@ fn the_glass_light_intensity_field_has_a_label() {
u.ctx.data_mut(|d| d.insert_temp(Id::unique(("fx_open", 0usize)), true));
u.frame(vec![]);
let shapes = u.frame_output(vec![]).shapes;
let lead = u.ctx.read_response(Id::unique(("fx_gi", 0usize)).with("lead")).expect("the intensity field is shown").rect;
let lead = u.ctx.read_response(Id::unique(("fx", "lightIntensity", 0usize)).with("lead")).expect("the intensity field is shown").rect;
let labels: Vec<(String, bool)> =
painted_texts(&shapes).into_iter().filter(|(_, _, at)| lead.contains(at.center())).map(|(text, cut, _)| (text, cut)).collect();
assert_eq!(labels, [("Intensity".to_string(), false)], "labelled, and not cut short");
let field = u.ctx.read_response(Id::unique(("fx_gi", 0usize)).with("field")).expect("its value").rect;
let field = u.ctx.read_response(Id::unique(("fx", "lightIntensity", 0usize)).with("field")).expect("its value").rect;
assert_eq!(field.max.x - lead.min.x, GLASS_FIELD_W, "the width the next test measures against");
}
+26 -10
View File
@@ -108,7 +108,22 @@ pub(crate) fn effect_open(ctx: &egui::Context, i: usize) -> bool {
/// A number field of effect `i` that can be bound to a variable (`effects/<i>/<prop>`).
fn fx_field(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, prop: &str, lead: Lead, value: f64, width: f32) -> Option<FieldEdit> {
crate::variable_ui::number_field(app, ui, ids, &format!("effects/{i}/{prop}"), Id::unique(("fx", prop, i)), lead, Some(value), "", width)
fx_field_in(app, ui, ids, i, prop, lead, Some(value), "", width)
}
/// [`fx_field`] showing `value` as given (a glass percentage) with `suffix` after it.
fn fx_field_in(
app: &mut FigApp,
ui: &mut Ui,
ids: &[NodeId],
i: usize,
prop: &str,
lead: Lead,
value: Option<f64>,
suffix: &str,
width: f32,
) -> Option<FieldEdit> {
crate::variable_ui::number_field(app, ui, ids, &format!("effects/{i}/{prop}"), Id::unique(("fx", prop, i)), lead, value, suffix, width)
}
/// Draw type-specific effect fields for shadows, blurs, noise, texture and glass, using live
@@ -290,29 +305,30 @@ fn effect_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &
}
})
};
// Each field takes a variable (`effects/<i>/<field>`), read as the field shows it:
// percentages for the 0–1 values, and the frost as `radius` (see `Glass::set_bound`).
let pct = |v: f64| Some((v * 100.0).round());
let unit = |v: f64| (v / 100.0).clamp(0.0, 1.0);
row(ui, 32.0, |ui| {
let ed =
widgets::number_field(ui, Id::unique(("fx_gl", i)), Lead::Text(tr("Light|glass")), Some(g.light_angle.round()), "°", half, 1.0);
let ed = fx_field_in(app, ui, ids, i, "lightAngle", Lead::Text(tr("Light|glass")), Some(g.light_angle.round()), "°", half);
edit(app, ed, &|v| with(&|gl| gl.light_angle = v.clamp(-180.0, 180.0)));
let ed = widgets::number_field(ui, Id::unique(("fx_gi", i)), Lead::Text(tr("Intensity")), pct(g.light_intensity), "%", half, 1.0);
let ed = fx_field_in(app, ui, ids, i, "lightIntensity", Lead::Text(tr("Intensity")), pct(g.light_intensity), "%", half);
edit(app, ed, &|v| with(&|gl| gl.light_intensity = unit(v)));
});
row(ui, 32.0, |ui| {
let ed = widgets::number_field(ui, Id::unique(("fx_gr", i)), Lead::Text(tr("Refraction")), pct(g.refraction), "%", half, 1.0);
let ed = fx_field_in(app, ui, ids, i, "refraction", Lead::Text(tr("Refraction")), pct(g.refraction), "%", half);
edit(app, ed, &|v| with(&|gl| gl.refraction = unit(v)));
let ed = widgets::number_field(ui, Id::unique(("fx_gd", i)), Lead::Text(tr("Depth")), Some(g.depth), "", half, 1.0);
let ed = fx_field_in(app, ui, ids, i, "depth", Lead::Text(tr("Depth")), Some(g.depth), "", half);
edit(app, ed, &|v| with(&|gl| gl.depth = v.clamp(1.0, 1000.0)));
});
row(ui, 32.0, |ui| {
let ed = widgets::number_field(ui, Id::unique(("fx_gs", i)), Lead::Text(tr("Dispersion")), pct(g.dispersion), "%", half, 1.0);
let ed = fx_field_in(app, ui, ids, i, "dispersion", Lead::Text(tr("Dispersion")), pct(g.dispersion), "%", half);
edit(app, ed, &|v| with(&|gl| gl.dispersion = unit(v)));
let ed = widgets::number_field(ui, Id::unique(("fx_gf", i)), Lead::Text(tr("Frost")), Some(g.frost), "", half, 1.0);
edit(app, ed, &|v| with(&|gl| gl.frost = v.clamp(0.0, 1000.0)));
let ed = fx_field_in(app, ui, ids, i, "radius", Lead::Text(tr("Frost")), Some(g.sanitized().frost), "", half);
edit(app, ed, &|v| with(&|gl| gl.frost = v.clamp(0.0, Glass::MAX_FROST)));
});
row(ui, 32.0, |ui| {
let ed = widgets::number_field(ui, Id::unique(("fx_gp", i)), Lead::Text(tr("Splay")), pct(g.splay), "%", half, 1.0);
let ed = fx_field_in(app, ui, ids, i, "splay", Lead::Text(tr("Splay")), pct(g.splay), "%", half);
edit(app, ed, &|v| with(&|gl| gl.splay = unit(v)));
});
}
+4 -1
View File
@@ -977,7 +977,10 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
// Sampling a colour from the canvas — the eyedropper this picker's own button started, or the
// colour it just sampled — is the press, not a press outside: neither may close the picker.
let picking = app.session.tool.pick_only || app.session.tool.picked.is_some();
pop = pop.close_on_outside(!picking);
// Nor may a press on the open gradient's handles or stops on the canvas: they are this picker's
// controls, and the canvas took the press before the picker drew (#1852).
let own = picking || crate::handles::gradient_dragging(ctx);
pop = pop.close_on_outside(!own);
// Only the opening control is held out: any other sidebar or canvas press dismisses it.
let mut held: Vec<Rect> = app.ui.picker_button.into_iter().collect();
held.extend(crate::variable_ui::picker_rect(ctx));
+3 -2
View File
@@ -1,6 +1,6 @@
# Components, variants, styles and variables parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (the Local variables window selects several variables (click, Shift+click for a range, Cmd/Ctrl+click to toggle, Escape to clear) and delete, move to a group and scope act on all of them in one undo step (#2002); Delete on a layer inside an instance hides it as an override instead of being refused, as the help center says: "Figma will only toggle the layer's visibility instead of removing it"; library analytics list how many layers and files use each style and variable; SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; a typed or stored variable, collection or mode id names only the object with that id (#1613); colour variables can be calculated from other colours per mode — `{alpha}` replaces a colour's alpha, `{mix}` blends two — and follow them (#1280); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (the Local variables window selects several variables (click, Shift+click for a range, Cmd/Ctrl+click to toggle, Escape to clear) and delete, move to a group and scope act on all of them in one undo step (#2002); glass effects take variables; Delete on a layer inside an instance hides it as an override instead of being refused, as the help center says: "Figma will only toggle the layer's visibility instead of removing it"; library analytics list how many layers and files use each style and variable; SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; a typed or stored variable, collection or mode id names only the object with that id (#1613); colour variables can be calculated from other colours per mode — `{alpha}` replaces a colour's alpha, `{mix}` blends two — and follow them (#1280); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and
variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300
@@ -67,7 +67,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
| Modes: add, duplicate, rename, delete, set as default / reorder | done | layers on Auto follow a new default; explicit modes stay |
| Deleting a bound variable or its collection | done | layers keep their last value and are unbound; aliases to it take its values and colour calculations reading it keep the colour they show |
| Code syntax per platform (Web, iOS, Android) | done | generated CSS uses the Web name, SwiftUI the iOS one and Compose the Android one (a variable without one keeps its value there); in the variables JSON; ArkUI and Flutter have no platform of their own and write values |
| Bindings: fills, strokes, effects, opacity, padding, gaps, sizes, radius, font family/style, font size/weight, line height, letter spacing, paragraph spacing/indent, text, visibility, layout grids, instance properties | done | type-checked per field; number variables fill text content; the Design panel's font family and font style menus start with Apply variable (string variables), and a bound one shows its variable in place of the menu; a colour variable bound to a solid paint brings its whole colour, alpha included, once (the paint's opacity restarts at 100% on binding, an opacity set while bound multiplies on top; unbinding moves the alpha back into the opacity) |
| Bindings: fills, strokes, effects, opacity, padding, gaps, sizes, radius, font family/style, font size/weight, line height, letter spacing, paragraph spacing/indent, text, visibility, layout grids, instance properties | done | type-checked per field; glass fields too (light angle, intensity, refraction, depth, dispersion, frost and splay, percentages as the Design panel shows them); number variables fill text content; the Design panel's font family and font style menus start with Apply variable (string variables), and a bound one shows its variable in place of the menu; a colour variable bound to a solid paint brings its whole colour, alpha included, once (the paint's opacity restarts at 100% on binding, an opacity set while bound multiplies on top; unbinding moves the alpha back into the opacity) |
| Explicit modes per frame, library collections' modes | done | stored variable and collection references resolve by exact id in export, library copies and swaps, imported aliases, alias deletion and cycle checks, Dev Inspect, the library mode picker and prototype playback; a collection or variable named like another's id no longer stands in for it there |
| Gradient stops and variant properties bound to variables | done | a stop's binding follows it when earlier stops are removed |
| Extended collections | done | this file's collections (Extend collection on a collection's row; following its edits at once, chains included) and library collections, including a library's own extended collections (chains through libraries); overrides per mode with reset; follow library updates, variables new to the library collection being an update of their own; modes inherited. Checked with synthetic libraries only ([gaps](gaps.md) #14) |
@@ -79,6 +79,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
| Date | Change | Summary |
|---|---|---|
| 2026-11-05 | minor | M3, #2002: the Local variables window selects several variables, as the Layers panel selects layers. A click selects one row, Shift+click the range from the anchor through the rows shown, Cmd/Ctrl+click toggles a row, Escape or a click on the empty space below the rows clears; selected rows use the sidebar's selected-row colour. Right-clicking a row that is part of a selection of several offers Delete, Move to group and Scope (all supported properties, hidden from pickers, or only one scope) for the whole selection, each sent as one `command.batch`, so one undo step; no engine command changed. Renaming and value edits stay single-row. Tests: `variables_select_tests` (headless UI). Grade unchanged |
| 2026-11-02 | minor | M3, #1885: glass effects take variables, as the reference tool's do: its light angle, intensity, refraction, depth, dispersion, frost and splay bind to number variables on a layer (`variable.bind`, `effects/<i>/<lightAngle\|lightIntensity\|refraction\|depth\|dispersion\|radius\|splay>`, `radius` being the frost) and in an effect style (`style.bind`), and the Design panel's glass fields take a variable like the shadow and blur fields. A variable reads as the field shows it: intensity, refraction, dispersion and splay in percent. Tests: `binding_lists_tests::glass_fields_take_number_variables`, `effects_tests::glass_fields_take_bound_numbers_as_the_panel_shows_them`. Grade unchanged. |
| 2026-11-01 | patch | M3: Delete on a layer inside an instance hides it (an override) instead of being refused ("You can't delete a layer or object from an instance. If you try, Figma will only toggle the layer's visibility instead of removing it", Guide to auto layout); `edit.delete` reports those ids under `hidden`, the structure and the main component stay as they were, one undo step; deleting the instance itself still removes it. Found by a documentation audit of the auto layout page. Test: `components_behaviour_tests::deleting_a_layer_inside_an_instance_hides_it_instead` (the refusal row for it is gone from `what_instances_cannot_override_is_refused`). Numbers unchanged |
| 2026-10-31 | minor | M3, gap 10: library analytics cover styles and variables. A file reports how many of its layers use each of a library's styles and variables, and Show usage (`library.usage`) lists them beside the components, by a Type choice, with their layers and files. Layers inside instances count for their component. Insertions, detachments and modes aren't counted for them yet. Numbers unchanged. Tests: `library_assets_tests`. |
| 2026-10-31 | minor | M6: as the help center describes code syntax, Dev Mode's SwiftUI and Compose code now writes a bound variable's iOS or Android code syntax in place of its value, as CSS already wrote the Web one: fills, strokes and their weight, sizes, corner radius, gaps, padding, opacity, the font family and size and the characters. A variable with no code syntax for the platform keeps the value, since a name made up here wouldn't exist in the code it lands in. ArkUI and Flutter have no code syntax platform and are unchanged. Test: `variables_behaviour_tests::swiftui_and_compose_name_variables_by_their_code_syntax`. Numbers unchanged. Grade unchanged |
+2 -1
View File
@@ -1,6 +1,6 @@
# FigCraft parity with Figma
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (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 suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hostingLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (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 suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and Line 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
@@ -648,6 +648,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
| Date | Change | Summary |
|---|---|---|
| 2026-11-02 | minor | M3, #1885: glass bends the backdrop with the reference tool's thick-lens profile (most a 1-unit bevel in from the edge, falling off as the 3.5th power of the way out of the curve to nothing `depth` further in) and splits its colours over 8 steps, red and blue 0.16 of the bend apart at 100% dispersion. Measured against the reference tool on synthetic files at 2×: the black band a pane pulls to its edge ends within 3 px of the reference tool's, and red and blue part 2.27 px at 50% dispersion and 4.66 px at 100% (2.38 and 4.69 there). Tests: `glass::tests::the_bend_follows_the_thick_lens_profile`, `glass::tests::dispersion_splits_the_channels_along_the_bend`. Grade unchanged. |
| 2026-11-01 | patch | M4: bulleted lists draw the same bullet at every level of indentation ("Figma bullets will keep the same styling at all levels of indentation", Create bulleted and numbered lists); the deeper levels drew a white bullet and a small square, pinned as silent before. Numbered levels (1., a., i.) are unchanged. Found by a documentation audit of the text rules. Test: `tail_tests::list_numbering_by_level` (the bullets row). Numbers unchanged |
| 2026-11-01 | patch | M4: typing `1)` followed by a space at the start of a plain paragraph starts a numbered list, as `1.` already did ("Enter one of the creation characters below followed by a Space: 1. 1)", Create bulleted and numbered lists). Found by a documentation audit of the text rules. Test: `text_tail_tests::typing_a_creation_character_and_a_space_starts_a_list`. Numbers unchanged |
| 2026-11-01 | patch | M4: a link made with `text.link` (Cmd/Ctrl+K, Shift+Cmd/Ctrl+U) underlines the linked text unless it has a decoration of its own ("Links are styled with an underline by default. Use the keyboard shortcut to remove the underline", Add links to text); Underline (Cmd/Ctrl+U) takes the underline off and the link stays. Found by a documentation audit of the text rules. Test: `text_behaviour_tests::a_link_is_underlined_by_default_and_underline_takes_it_off`. Numbers unchanged |
+4 -3
View File
@@ -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 testingLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** patch (a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); 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 transLine truncated
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:
@@ -30,7 +30,7 @@ interaction feel not yet compared.
| 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`) |
| 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. |
| 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 |
| Colour picker on a new fill or stroke | done | + opens the picker on the new paint when the selection shares one paint list; on a mixed selection + adds a paint to each layer and no picker opens (#404) |
| Blend mode menus: the layer's (Design panel) and each paint's (colour picker) | done | Pass through and Normal are distinct (Normal isolates a group's or frame's children); paints have no pass-through; plus darker drawn exactly on the canvas |
@@ -113,7 +113,7 @@ interaction feel not yet compared.
| Masks: what a mask hides doesn't take clicks; a mask group's box is the mask's | done | |
| Layout grids (Ctrl+G) | done | columns, rows and grid; stretch, left, centre, right; Auto count; several per frame; styles; not shown or snapped on a rotated frame; a nested frame's grids snap but only top-level frames draw them |
| Gamut warnings for Display P3 files | done | colour picker note; View ▸ Soft-proof sRGB stripes what sRGB can't show (the canvas itself is sRGB) |
| Gradient and padding/gap handles on the canvas | done | |
| Gradient and padding/gap handles on the canvas | done | the gradient's handles and stops show while its picker is open, and dragging one keeps the picker open (#1852) |
| Hover outline, selection colours, handle sizes | done | measured from UI3 screenshots; the outline follows the layer's geometry, rounded corners (each radius and the smoothing) included, whatever the stroke alignment (#1506) |
| Cursors per tool and modifier | done | the engine names a cursor for every tool, modifier and hover target (`tool.overlay.cursor`, tabled in `cursor_tests`) and the UI shows the system's or draws its own at the pointer (`cursors.rs`, `cursors_tests`; original glyphs from lines and the code-drawn icons). Arrow for Move and Scale; two-headed arrows on the handles that follow the handle and the box's rotation; outside a corner a curved rotate arrow turned to that corner, turning with the pointer while rotating; crosshair for the frame, section, slice, shape, pencil and Text tools; the I-beam over a text with the Text tool and over the text being edited; the pen as a cross with what a click does beside it (nothing: a new point; a ring: closes, connects to or goes on from a point; a plus: adds a point on a segment, also Alt in vector edit mode; a curve: bends, the Bend tool or Cmd/Ctrl); open and closed hand for the Hand tool, Space and the middle button; zoom in and out (Alt) while Z is held; eyedropper; comment pin; the copy cursor with Alt over a selected layer and while Alt-dragging; not-allowed while a drag resizes, rotates or moves a layer inside an instance (it is undone on release) and over a layers row that takes no drop. Ruler guides and gradient, crop and padding handles keep their own cursors (the tool's used to replace them). `ui.inspect` reports `cursor`. Left: no cursor for removing a point with the pen (the pen doesn't remove points); the drawn cursors follow the pointer a frame behind the system's; not compared with Figma's side by side |
| Multiplayer cursors, cursor chat, spotlight | partial | in live sessions: named cursors, chat bubbles that fade, `/` to chat, spotlight from your avatar's menu with a banner; no audio or reactions |
@@ -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-04 | patch | M1, #1852: a press on one of the open gradient's handles or stops on the canvas is the colour picker's own, not a press outside it (Figma keeps the picker and the handles up while a handle or stop is dragged; the stops are made to be dragged: "You can click and drag a stop to change its placement on the gradient", Use gradients as a fill or stroke). The canvas took the press and began the drag, then the picker, drawn later in the same frame, saw a press outside its card and closed, committing the drag's interaction at once; the handles vanished with it and the drag went on without an undo step of its own. Now the picker stays while a gradient handle or stop drags, and the drag is one undo step; a press elsewhere on the canvas still closes it. A handle drag whose button goes up off the canvas (over a panel or the picker) ends there too, instead of waiting, uncommitted, to move the handle to the next press on the canvas. Tests: `colorpicker_selection_tests::dragging_a_gradient_handle_on_the_canvas_keeps_the_picker_open`, `colorpicker_selection_tests::a_gradient_drag_released_off_the_canvas_ends_there`. |
| 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` |