Compare commits

..
Author SHA1 Message Date
bflatastic f979878d9e Merge main into fix/issue-892
# Conflicts:
#	ROADMAP.md
2026-10-20 06:09:17 +07:00
BFlat 5f0fe486db Merge pull request #900 from storytold/fix/issue-890
M1: readable disabled and selected text (#890)
2026-10-20 06:09:13 +07:00
bflatastic 64efe12e6f Merge main into fix/issue-890
# Conflicts:
#	ROADMAP.md
#	docs/ui-parity.md
2026-10-20 05:58:56 +07:00
BFlat 4bb30782ff Merge pull request #899 from storytold/fix/issue-879
M1: node.get reports layer data (pluginData) (#879)
2026-10-20 05:58:32 +07:00
bflatastic 8a46478e81 Merge main into fix/issue-879
# Conflicts:
#	ROADMAP.md
2026-10-20 05:46:30 +07:00
BFlat 11ecf3e887 Merge pull request #898 from storytold/fix/issue-894
M1: nudge while measuring; distances follow, handles hide (#894)
2026-10-20 05:46:25 +07:00
bflatastic b82369201d M3: duplicating a main component makes an instance (#892)
Duplicate and Alt-drag copied a main component as a second main. Within
its file the copy is now an instance of it (components::copy_in_file,
shared by edit.duplicate and the Alt-drag copy), as Copy then Paste
already was. A variant duplicated in its set stays a new variant.
2026-10-20 05:44:46 +07:00
bflatastic e3b20b6540 Merge main into fix/issue-894
# Conflicts:
#	ROADMAP.md
2026-10-20 05:35:21 +07:00
BFlat b17bea9195 Merge pull request #881 from yelintong/feat/grid-auto-width
Layout grids: Auto line width for columns and rows
2026-10-20 05:35:13 +07:00
BFlat 287b506bc1 Merge pull request #889 from kk376/fix/857-live-session-large-files
fix(collab-net): allow live sessions above 64 MiB and report accurate transport limits (#857)
2026-10-20 05:34:52 +07:00
bflatastic 05da6b2125 M1: node.get reports layer data (pluginData) (#879)
Layer data written with node.set {props: {pluginData}} was saved but no
command read it back. node.get (the command, the control method and MCP
get_node) and document.inspect now report pluginData as {key: value}
when a layer or page has any, so a tool keeping state in a file can
read it on its next run. Widget keys are included: the plugin rules
only stop plugins writing them, and widget.list already reports that
state.
2026-10-20 04:58:32 +07:00
bflatastic 5617c94167 M1: nudge while measuring; distances follow, handles hide (#894)
Alt+arrows were left out of the nudge keys, so with Alt held to measure
the arrows did nothing and the distances never changed. The arrows now
nudge with Alt held too (Shift: the big step); the hover measurement is
recomputed every frame, so the labels follow each nudge. While measuring
to another layer the selection's handles and size label hide; both
layers keep their outlines.
2026-10-20 04:58:31 +07:00
bflatastic 9ee0c7f572 M1: readable disabled and selected text (#890)
The dim text token (disabled buttons, placeholders, hints) was 2.1:1 on
the light theme's panels; disabled buttons also faded to 40% opacity,
and selected text was drawn in the dark green, 1.4:1 on the dark
theme's selection highlight. The dim text token is now at least 3:1
against panels, fields and selected rows in both themes while staying
dimmer than secondary text, a disabled button is grey with that text,
and selected text takes the theme's readable green. A test checks the
ratios with the colour contrast helper.
2026-10-20 04:57:20 +07:00
bflatastic 0f95ce3d5b #881: test Auto in a fixed grid's count, scroll to the field instead of a fixed point 2026-10-20 04:45:45 +07:00
bflatastic a7d0648958 #881: fixed grids keep their pixel Width and the Stretch->Left lift to 60; the stretched Auto width reuses the number field (disabled) 2026-10-20 04:45:44 +07:00
bflatastic 309271851f #881: keep fixed grids at width 0 drawing bands as before (no hairlines) 2026-10-20 04:39:14 +07:00
bflatastic f99fdea3b6 #881: leave the progress docs to the maintainer 2026-10-20 04:39:13 +07:00
bflatastic 5d32c522d2 Merge main into #881 2026-10-20 04:39:04 +07:00
bflatastic cc4799061e #889: say a join is too large in words for people (native and web), translated; keep one test 2026-10-20 04:38:28 +07:00
BFlat 5b5a4855d4 Merge pull request #895 from storytold/fix/web-fonts
M9: web release draws text again; craft-fonts pin back to the Latin fonts (#885)
2026-10-20 04:38:11 +07:00
bflatastic 89529b3d00 #889: drop the hub write-cap and core-limit changes (neither is read) 2026-10-20 04:22:26 +07:00
bflatastic ca0e689834 Merge main into #889 2026-10-20 04:22:20 +07:00
Kushagra Kumar 6369b6b7a5 test(collab-net): exercise NetLink host in large welcome regression test 2026-10-20 01:43:50 +05:30
Kushagra Kumar e8a6033199 test(collab-net): add ignored loopback test exercising welcome delivery above 64 MiB 2026-10-20 01:33:32 +05:30
Kushagra Kumar 227e182f68 fix(collab-net): report exact bytes when server message limit is not a whole unit 2026-10-20 01:23:09 +05:30
Kushagra Kumar fbb4a22e01 fix(collab-net): allow live sessions above 64 MiB and report accurate transport limits (#857)
- Configure server write loop in hub.rs with max_server_message so outgoing ServerMsg payloads above 64 MiB (e.g. document snapshots in Welcome messages) are delivered without socket termination.
- Set core max_message_bytes in local live session HostConfig to max_server_message.
- Map tungstenite Capacity errors in guest read loop to Outcome::Failed with accurate size limits rather than Outcome::Refused which falsely reported 'The host refused'.
- Add loopback tests verifying Welcome delivery above client message limits and accurate diagnostics on client capacity overrun.
2026-10-20 01:12:13 +05:30
叶林橦 cecc2865b0 Layout grids: a stretched grid's count takes no Auto
CodeRabbit: typing "Auto" in the count of a stretched grid set 0, which the setter clamped to 1. The count field only takes the word where 0 is Auto (a fixed-size grid); a stretched grid's count is a plain number.
2026-10-20 00:39:29 +08:00
叶林橦 8450d18c8e Layout grids: Auto width takes the word only where 0 is Auto
Review fixes for #881 (FredApps, CodeRabbit).

- number_field: the word for Auto (the placeholder's own text in the UI's
  language, or "auto") is taken only by a field that asks for it (`Empty::Auto`),
  not by every field whose placeholder reads "Auto". An auto layout's gap
  ("Auto" is Space between) and a prototype overlay's offset ("Auto" is no
  offset) keep their own meaning of 0; the fixed "自动" match is gone.
- layout: Auto width has no hidden 60 px band — the bands step by the gutter
  alone, so the pitch is what the panel shows. A zero-width band snaps to one
  line, not two identical edges.
- right: the width field shows "Auto" (not 0) when there is no width, and a
  stretched grid shows it dim and not editable (its bands divide the frame),
  with the margin and gutter still editable.
- tests: the field suite checks the word on a grid's width (0) and on an auto
  layout's gap (nothing); the layout suite pins the gutter pitch, the Auto
  count and a stretched grid's bands.
- docs: ROADMAP and ui-parity state the pitch and what changes for saved grids.
2026-10-20 00:13:37 +08:00
叶林橦 3e02bffbbc Layout grids: Auto line width for columns and rows
Columns and rows can now set their line width to Auto: a width of 0 (or
typing the word "Auto" / "自动" in the field) draws a one-pixel hairline
down the band's middle, like the square grid, instead of a filled band.

- grid_lines: a non-stretched grid with size <= 0 emits a hairline at each
  band's middle rather than a zero/negative-width rect.
- canvas: a degenerate (zero-width or zero-height) grid rect is painted as a
  1px line; a filled band is still painted as a rectangle.
- number_field: a field whose placeholder is "Auto" also accepts the word
  "Auto"/"自动", mapping it to 0.
- right: the grid size field shows "Auto" and the align menu no longer
  forces a 60px band on a zero width.
- props: document the Auto semantics of LayoutGrid::size.
- ROADMAP.md / docs/ui-parity.md: note the change.
- layout tests: regression test for the Auto hairline.
2026-10-19 22:51:57 +08:00
34 changed files with 538 additions and 66 deletions

No files matched your search

+4
View File
@@ -316,6 +316,10 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-20 | minor | Duplicating a main component makes an instance (#892): Duplicate and Alt-drag copied a main component as a second main; within its file the copy is now an instance of it, as Copy then Paste already was (a variant duplicated in its set stays a variant). Numbers unchanged. |
| 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged |
| 2026-10-20 | minor | Layer data reads back (#879): `node.get` (the command, the control method and MCP `get_node`) and `document.inspect` report a layer's or page's `pluginData` as `{key: value}` when it has any, so a tool that keeps state in a file with `node.set {props: {pluginData}}` can read it on its next run (`plugin_data_tests`). Numbers unchanged |
| 2026-10-20 | minor | Measuring while nudging (#894): with Alt held over another layer, the arrows nudge the selection (Shift: the big step) and the distances follow every nudge (Alt+arrows used to do nothing); while measuring, the selection's handles and size label hide and both layers keep their outlines (`measure_nudge_tests`). Numbers unchanged |
| 2026-10-20 | minor | Release fonts (#885): the web release embeds craft-fonts again (Inter), so the browser version draws text and lists fonts; it had none since releases moved to the shared workflow. The craft-fonts pin goes back to the commit with the Latin manifest (the Georgian change had moved it to one without Inter, which would have failed the next release). Numbers unchanged. |
| 2026-10-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). `cargo xtask parity` re-run: 212 / 212 menu items (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Language table re-measured from the catalogs (2,272 entries): the drafts read 95–99% (strings added since 2026-10-12 show English), Indonesian has a draft (99%; it read none), and Russian, Swedish (its command labels from #599), Turkish and Czech are listed with their counts. Per-area parity rows for the day's landings in `docs/gaps.md`, `target-app-parity.md` and the area docs. Numbers unchanged |
| 2026-10-17 | minor | Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. |
+4 -1
View File
@@ -16,7 +16,7 @@ use figcraft_engine::live::{Event, Status};
use web_time::Instant;
use crate::inbox::Inbox;
use crate::join::{GuestConfig, Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, unresolved};
use crate::join::{GuestConfig, Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, too_large, unresolved};
use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
/// A WebSocket the driver opens, writes and closes; what it receives comes back through the
@@ -114,6 +114,9 @@ impl<S: Socket> Driver<S> {
/// A binary message from the host.
pub(crate) fn message(&mut self, bytes: &[u8], now: Instant) {
let Some(c) = self.conn.as_mut() else { return };
if bytes.len() > self.cfg.limits.max_server_message {
return self.end(too_large(&self.cfg.limits), now);
}
c.last_heard = now;
let taken = match message::decode::<ServerMsg>(bytes, self.cfg.limits.max_server_message) {
// The join code's answer: join the file it opens, on a new connection.
+10 -1
View File
@@ -10,7 +10,7 @@ use std::time::{Duration, Instant};
use figcraft_collab::message::{self, ClientMsg, Presence, ServerMsg};
use figcraft_collab::{Client, Limits, Outgoing, Server};
use figcraft_doc::{Document, NodeId};
use figcraft_engine::live::{Event, Status};
use figcraft_engine::live::{Event, Status, TOO_LARGE_TO_JOIN};
use tungstenite::protocol::WebSocket;
use tungstenite::{Bytes, Message};
@@ -262,6 +262,15 @@ fn refusals_and_the_end_of_the_session_are_final() {
assert!(d.done());
assert!(matches!(statuses(&inbox.drain()).last(), Some(Status::Failed { reason }) if reason.starts_with("The host refused")));
}
// A message over the size limit: the file is too large for this app, not refused by the host.
let inbox = Inbox::new(Arc::new(|| {}));
let mut cfg = GuestConfig::new(invite(), "Ada".into());
cfg.limits.max_server_message = 2 << 20;
let mut d = Driver::start(Script::default(), cfg, inbox.clone(), t0);
d.opened(t0);
d.message(&vec![0; (2 << 20) + 1], t0);
assert!(d.done());
assert!(matches!(statuses(&inbox.drain()).last(), Some(Status::Failed { reason }) if *reason == TOO_LARGE_TO_JOIN.replace("{mb}", "2")));
}
#[test]
+2 -2
View File
@@ -26,7 +26,7 @@ use tungstenite::{Bytes, Message};
use crate::inbox::Inbox;
use crate::invite::{Endpoint, Invite};
pub use crate::join::GuestConfig;
use crate::join::{Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, unresolved};
use crate::join::{Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, too_large, unresolved};
use crate::manage;
use crate::net::{Duplex, NetLimits, config, open, shutdown};
use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
@@ -367,7 +367,7 @@ fn read_loop(mut ws: WebSocket<Duplex>, ctx: &ReaderCtx) {
Ok(Message::Close(f)) => break f.map_or_else(|| closed(0, ""), |f| closed(f.code.into(), &f.reason)),
Ok(Message::Text(_)) => break Outcome::Refused("the host sent a text message".into()),
Ok(_) => {}
Err(tungstenite::Error::Capacity(_)) => break Outcome::Refused("the host sent a message over the size limit".into()),
Err(tungstenite::Error::Capacity(_)) => break too_large(&ctx.cfg.limits),
Err(e) => break Outcome::Retry(e.to_string()),
}
};
+7 -1
View File
@@ -7,7 +7,7 @@ use std::time::Duration;
use figcraft_collab::Client;
use figcraft_collab::message::{ClientMsg, Peer, Resume, ServerMsg};
use figcraft_engine::live::{Event, Role, SessionInfo, Status};
use figcraft_engine::live::{Event, Role, SessionInfo, Status, TOO_LARGE_TO_JOIN};
use crate::inbox::Inbox;
use crate::invite::Invite;
@@ -95,6 +95,12 @@ pub(crate) fn unresolved(cfg: &GuestConfig, outcome: Outcome) -> Outcome {
})
}
/// The host sent a message over this app's size limit (the file's welcome, usually): final, and
/// not the host's doing.
pub(crate) fn too_large(limits: &NetLimits) -> Outcome {
Outcome::Failed(TOO_LARGE_TO_JOIN.replace("{mb}", &(limits.max_server_message >> 20).to_string()))
}
/// The server's answer to the join code: the invite is whole now.
pub(crate) fn resolved(cfg: &mut GuestConfig, file: String, token: String) -> Result<(), Outcome> {
if !crate::invite::valid_id(&file, 1, 64) || !crate::invite::valid_id(&token, 16, 128) {
+29 -1
View File
@@ -10,7 +10,7 @@ use std::time::{Duration, Instant};
use figcraft_collab::message::{self, ClientMsg, Hello, Presence, ServerMsg, Signal};
use figcraft_collab::{Client, Limits};
use figcraft_doc::{Document, NodeId};
use figcraft_engine::live::{Event, PEOPLE, Status};
use figcraft_engine::live::{Event, PEOPLE, Status, TOO_LARGE_TO_JOIN};
use tungstenite::protocol::WebSocket;
use tungstenite::{Bytes, Message};
@@ -642,3 +642,31 @@ fn the_host_removes_someone_and_they_cant_come_back() {
host.stop();
assert_eq!(host.connections(), 0);
}
#[test]
fn a_welcome_over_the_size_limit_says_the_file_is_too_large() {
let mut doc = Document::new("B".repeat(1 << 20));
doc.repair();
let probe = Probe::new();
let cfg = HostConfig {
addr: SocketAddr::from(([127, 0, 0, 1], 0)),
any_port_if_taken: false,
file: FILE.into(),
token: TOKEN.into(),
doc,
name: "Hana".into(),
limits: fast_limits(),
core: Limits::default(),
};
let mut host = Host::start(cfg, probe.inbox.clone()).unwrap();
let invite = Invite::new(host.addr().to_string(), FILE, TOKEN);
let mut guest_cfg = quick(GuestConfig::new(invite, "Ada".into()));
guest_cfg.limits = NetLimits { max_server_message: 1 << 20, ..fast_limits() };
let mut gp = Probe::new();
let _g = Guest::start(guest_cfg, gp.inbox.clone()).unwrap();
let too_large = TOO_LARGE_TO_JOIN.replace("{mb}", "1");
gp.wait("the file too large to join", |e| e.iter().any(|ev| matches!(ev, Event::Status(Status::Failed { reason }) if *reason == too_large)));
assert!(!welcomed(&gp.events));
host.stop();
}
+11 -4
View File
@@ -18,13 +18,19 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"edit.duplicate",
"Duplicate",
"{ids?, dx?, dy? (page offset; default: the offset of the last duplicate when ids are its copies, still selected; else top-level frames to the right with a 100 gap, everything else in place)}",
"{ids?, dx?, dy? (page offset; default: the offset of the last duplicate when ids are its copies, still selected; else top-level frames to the right with a 100 gap, everything else in place)} a main component's copy is an instance of it (a variant stays a new variant in its set)",
true,
duplicate
),
spec!("edit.copy", "Copy", "{ids?}", false, copy),
spec!("edit.cut", "Cut", "{ids?}", true, cut),
spec!("edit.paste", "Paste", "{parent?, x?, y? (page coordinates of the pasted top-left)}", true, paste),
spec!(
"edit.paste",
"Paste",
"{parent?, x?, y? (page coordinates of the pasted top-left)} a copied main component pastes as an instance of it within its file (cut ones, and pastes into other files, keep the component)",
true,
paste
),
spec!(
"edit.pasteDesignLayers",
"Paste design layers",
@@ -466,7 +472,7 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let top_frame = doc.node(parent).is_some_and(Node::is_page) && n.kind.is_frame_like();
let idx = doc.index_in_parent(i).map(|x| x + 1);
let w = n.width;
let nid = doc.duplicate_into(i, parent, idx)?;
let nid = crate::components::copy_in_file(doc, i, parent, idx)?;
let (dx, dy) = match (num(p, "dx"), num(p, "dy")) {
(Some(dx), dy) => (dx, dy.unwrap_or(0.0)),
(None, Some(dy)) => (0.0, dy),
@@ -481,7 +487,8 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
nn.transform = Affine::translate((dx, dy)) * nn.transform;
}
}
if rename {
// An instance keeps the name instances get by default.
if rename && !matches!(doc.get(nid)?.kind, NodeKind::Instance { .. }) {
let nn = doc.get_mut(nid)?;
nn.name = bump_name(&nn.name);
}
+15
View File
@@ -292,6 +292,21 @@ pub fn create_instance_at(doc: &mut Document, component: NodeId, parent: NodeId,
Ok(id)
}
/// Copy `id` within its own file under `parent` (at `index`), keeping its local transform. A file
/// has one main per component, so a main component's copy is an instance of it; a variant copied
/// into its set stays a new variant, and every other layer is deep-copied.
pub fn copy_in_file(doc: &mut Document, id: NodeId, parent: NodeId, index: Option<usize>) -> Result<NodeId, DocError> {
let n = doc.get(id)?;
let in_set = doc.node(parent).is_some_and(|p| matches!(p.kind, NodeKind::ComponentSet { .. }));
if !matches!(n.kind, NodeKind::Component { .. }) || in_set {
return doc.duplicate_into(id, parent, index);
}
let transform = n.transform;
let instance = create_instance_at(doc, id, parent, 0.0, 0.0, index)?;
doc.get_mut(instance)?.transform = transform;
Ok(instance)
}
/// Which part of the carried overrides to apply.
#[derive(Clone, Copy, PartialEq)]
enum Carry {
@@ -194,6 +194,67 @@ fn pasted_component_instances_keep_the_copied_transform_in_the_target_frame() {
healthy(&s);
}
#[test]
fn duplicating_a_main_component_makes_a_linked_instance() {
for rename in [false, true] {
let mut s = Session::new();
ex(&mut s, "pref.renameDuplicates", json!({"on": rename}));
let k = kit(&mut s);
let rect = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 300, "width": 50, "height": 50, "props": {"name": "Box"}})));
let before = s.doc().unwrap().clone();
let out = ex(&mut s, "edit.duplicate", json!({"ids": [k.card, rect]}));
let ids: Vec<NodeId> = out["ids"].as_array().unwrap().iter().filter_map(crate::params::id_of).collect();
assert_eq!(s.selection(), ids);
let copy = *ids.iter().find(|i| matches!(node(&s, **i).kind, NodeKind::Instance { .. })).expect("the main's copy is an instance");
assert_eq!(component_of(&s, copy), k.card);
assert_eq!(node(&s, copy).name, "Card", "instances keep their default name (rename={rename})");
assert_eq!(node(&s, copy).parent, Some(s.page().unwrap()));
let (b, c) = (s.doc().unwrap().world_bounds(k.card).unwrap(), s.doc().unwrap().world_bounds(copy).unwrap());
assert_eq!((c.x0, c.y0, c.width(), c.height()), (b.x1 + 100.0, b.y0, b.width(), b.height()), "a top-level frame's offset");
let other = *ids.iter().find(|i| **i != copy).unwrap();
assert!(matches!(node(&s, other).kind, NodeKind::Rectangle), "other layers are still copied");
ex(&mut s, "node.set", json!({"id": k.title, "props": {"characters": "Updated"}}));
assert_eq!(chars(&s, copy_of(&s, copy, k.title)), "Updated", "the instance follows its main");
ex(&mut s, "edit.undo", json!({}));
ex(&mut s, "edit.undo", json!({}));
assert_eq!(*s.doc().unwrap(), before, "undo restores exactly");
healthy(&s);
}
}
#[test]
fn alt_dragging_a_main_component_copies_an_instance() {
use crate::tools::{PointerEvent, PointerKind};
let mut s = Session::new();
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100, "name": "Tile"})));
let main = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
let ev = |kind, x| PointerEvent { alt: true, ..PointerEvent::at(kind, x, 50.0, 1.0) };
s.pointer(&ev(PointerKind::Down, 50.0)).unwrap();
s.pointer(&ev(PointerKind::Move, 150.0)).unwrap();
s.pointer(&ev(PointerKind::Move, 250.0)).unwrap();
s.pointer(&ev(PointerKind::Up, 250.0)).unwrap();
let [copy] = s.selection()[..] else { panic!("one copy selected") };
assert_eq!(component_of(&s, copy), main);
assert_eq!(s.doc().unwrap().world_bounds(copy).unwrap().x0, 200.0);
assert_eq!(s.doc().unwrap().world_bounds(main).unwrap().x0, 0.0, "the main stays");
healthy(&s);
}
#[test]
fn duplicating_a_variant_adds_a_variant_to_its_set() {
let mut s = Session::new();
let (set, _, hover) = button_set(&mut s);
let copy = crate::params::id_of(&ex(&mut s, "edit.duplicate", json!({"ids": [hover]}))["ids"][0]).unwrap();
assert!(matches!(node(&s, copy).kind, NodeKind::Component { .. }));
assert_eq!(node(&s, copy).parent, Some(set));
assert_eq!(kids(&s, set).len(), 3);
// A whole set duplicates as a new set of variants.
let set2 = crate::params::id_of(&ex(&mut s, "edit.duplicate", json!({"ids": [set]}))["ids"][0]).unwrap();
assert!(matches!(node(&s, set2).kind, NodeKind::ComponentSet { .. }));
assert!(kids(&s, set2).iter().all(|v| matches!(node(&s, *v).kind, NodeKind::Component { .. })));
healthy(&s);
}
#[test]
fn overridable_properties_stay_through_main_edits_and_reset() {
// (what, layer, group, override on the instance, the same property edited on the main, read,
+4
View File
@@ -165,6 +165,10 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
o.insert("operandOperation".into(), json!(op));
}
}
// Layer data written with `node.set {props: {pluginData}}` (pages too).
if !n.plugin_data.is_empty() {
o.insert("pluginData".into(), json!(n.plugin_data));
}
match &n.kind {
NodeKind::Text(t) => {
o.insert("characters".into(), json!(t.characters));
+2
View File
@@ -1775,6 +1775,8 @@ mod pixel_preview_tests;
#[cfg(test)]
mod playground_tests;
#[cfg(test)]
mod plugin_data_tests;
#[cfg(test)]
mod plugin_directory_tests;
#[cfg(test)]
mod plugin_panels_tests;
+4
View File
@@ -86,6 +86,10 @@ pub fn parse_color(s: &str) -> Option<(u8, u8, u8)> {
Some((byte(0)?, byte(2)?, byte(4)?))
}
/// Why a join failed when a message from the host is over this app's size limit (`{mb}`: the
/// limit in MB). The UI translates it.
pub const TOO_LARGE_TO_JOIN: &str = "This file is too large to join here (up to {mb} MB)";
/// The connection to a live session.
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
#[serde(rename_all = "camelCase", tag = "state")]
+30
View File
@@ -0,0 +1,30 @@
//! Layer data (`pluginData`) reads back through `node.get` and `document.inspect` (#879): what
//! `node.set` writes on a layer or a page comes back as `{key: value}`, a removed key goes, and a
//! layer without any reports none.
use crate::Session;
use crate::components::tests::{ex, idv};
use serde_json::json;
#[test]
fn node_get_reports_the_layer_data_node_set_wrote() {
let mut s = Session::new();
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100})));
let r = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})));
let page = s.ds().unwrap().page;
assert!(ex(&mut s, "node.get", json!({"id": f})).get("pluginData").is_none(), "no data, no field");
ex(&mut s, "node.set", json!({"ids": [f], "props": {"pluginData": {"com.example.sync": "{\"v\":1}", "n": 2}}}));
ex(&mut s, "node.set", json!({"ids": [page], "props": {"pluginData": {"com.example.sync": "page"}}}));
let got = ex(&mut s, "node.get", json!({"id": f}))["pluginData"].clone();
assert_eq!(got, json!({"com.example.sync": "{\"v\":1}", "n": "2"}), "values read back as the strings stored");
assert_eq!(ex(&mut s, "node.get", json!({"id": page}))["pluginData"], json!({"com.example.sync": "page"}), "pages keep data too");
assert!(ex(&mut s, "node.get", json!({"id": r})).get("pluginData").is_none(), "other layers report none");
// The document tree reports it as well (the page and its children).
let tree = crate::inspect::document(&s, 2, false);
assert_eq!(tree["pages"][0]["pluginData"], json!({"com.example.sync": "page"}));
// A null removes a key; undo brings it back.
ex(&mut s, "node.set", json!({"ids": [f], "props": {"pluginData": {"n": null}}}));
assert_eq!(ex(&mut s, "node.get", json!({"id": f}))["pluginData"], json!({"com.example.sync": "{\"v\":1}"}));
ex(&mut s, "edit.undo", json!({}));
assert_eq!(ex(&mut s, "node.get", json!({"id": f}))["pluginData"]["n"], "2");
}
+1 -1
View File
@@ -1300,7 +1300,7 @@ impl Session {
for i in &ids {
let parent = doc.get(*i)?.parent.unwrap_or(page);
let idx = doc.index_in_parent(*i).map(|x| x + 1);
copies.push(doc.duplicate_into(*i, parent, idx)?);
copies.push(crate::components::copy_in_file(doc, *i, parent, idx)?);
}
moving = copies;
*sel = moving.clone();
+1
View File
@@ -1393,6 +1393,7 @@ name = "English"
"This condition is edited as an expression" = "This condition is edited as an expression"
"This file" = "This file"
"This file has unsaved changes" = "This file has unsaved changes"
"This file is too large to join here (up to {mb} MB)" = "This file is too large to join here (up to {mb} MB)"
"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them."
"This file is on {server}, open only to you. Share it to let others in with a new code." = "This file is on {server}, open only to you. Share it to let others in with a new code."
"This frame is new on the branch." = "This frame is new on the branch."
+1
View File
@@ -1394,6 +1394,7 @@ status = "draft — needs native review"
"This condition is edited as an expression" = "Esta condición se edita como expresión"
"This file" = "Este archivo"
"This file has unsaved changes" = "Este archivo tiene cambios sin guardar"
"This file is too large to join here (up to {mb} MB)" = "Este archivo es demasiado grande para unirte aquí (hasta {mb} MB)"
"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Este archivo usa fuentes que no están instaladas en este equipo. Su texto se dibuja con una fuente de reserva hasta que las instales."
"This file is on {server}, open only to you. Share it to let others in with a new code." = "Este archivo está en {server}, abierto solo para ti. Compártelo para dejar entrar a otros con un código nuevo."
"This frame is new on the branch." = "Este marco es nuevo en la rama."
+20 -8
View File
@@ -1011,7 +1011,7 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [
width_overlay(app, painter, &doc, &ve, zoom);
} else if let Some(c) = app.session.vector_edit_box() {
let label = format!("{} × {}", fmt((c[1] - c[0]).hypot()), fmt((c[3] - c[0]).hypot()));
size_label = transform_box(app, painter, c.map(|p| app.to_screen(p.x, p.y)), accent(painter), true, 4, label);
size_label = transform_box(app, painter, c.map(|p| app.to_screen(p.x, p.y)), accent(painter), true, 4, Some(label));
}
}
// Selection.
@@ -1035,8 +1035,10 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [
// A layer smaller than its handles on screen shows just its outline (they still answer
// just outside it).
let tiny = figcraft_engine::tools::box_sides_px(&b, zoom).1 < figcraft_engine::tools::SMALL_BOX_PX;
// Measuring to another layer (Alt held) shows just the outlines: no handles or size.
let measuring = app.session.tool.overlay.measure_box.is_some();
// Locked layers (selected in the layers panel) can't be resized: no handles either.
let handles = if sel.iter().any(|i| figcraft_engine::tools::locked_in(&doc, *i)) {
let handles = if measuring || sel.iter().any(|i| figcraft_engine::tools::locked_in(&doc, *i)) {
0
} else if is_line {
2
@@ -1045,9 +1047,11 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [
} else {
4
};
size_label = transform_box(app, painter, b.map(|p| app.to_screen(p.x, p.y)), col, sel.len() > 1, handles, label);
arc_overlay(app, painter, col);
shape_overlay(app, painter, col);
size_label = transform_box(app, painter, b.map(|p| app.to_screen(p.x, p.y)), col, sel.len() > 1, handles, (!measuring).then_some(label));
if !measuring {
arc_overlay(app, painter, col);
shape_overlay(app, painter, col);
}
if app.session.tool.tool == ToolId::Scale {
// The point that stays put: the dragged pivot, else the anchor.
let pivot = scale
@@ -1185,8 +1189,16 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [
}
/// A transform box (screen corners tl, tr, br, bl): its outline when `outline`, the first `handles`
/// corner handles and, when dimensions are shown, the size `label` under it (its box returned).
fn transform_box(app: &FigApp, painter: &egui::Painter, q: [Pos2; 4], col: Color32, outline: bool, handles: usize, label: String) -> Option<Rect> {
/// corner handles and, when dimensions are shown, the size `label` (if any) under it (its box returned).
fn transform_box(
app: &FigApp,
painter: &egui::Painter,
q: [Pos2; 4],
col: Color32,
outline: bool,
handles: usize,
label: Option<String>,
) -> Option<Rect> {
if outline {
quad(painter, q, stroke(col, 1.0));
}
@@ -1195,7 +1207,7 @@ fn transform_box(app: &FigApp, painter: &egui::Painter, q: [Pos2; 4], col: Color
painter.rect_filled(r, CornerRadius::same(1), Color32::WHITE);
painter.rect_stroke(r, CornerRadius::same(1), stroke(col, 1.0), StrokeKind::Inside);
}
if app.session.prefs.show_dimensions {
if let Some(label) = label.filter(|_| app.session.prefs.show_dimensions) {
let bottom = q.iter().map(|p| p.y).fold(f32::MIN, f32::max);
let cx = q.iter().map(|p| p.x).sum::<f32>() / 4.0;
return Some(pill(painter, pos2(cx, bottom + 9.0), Align2::CENTER_TOP, label, col));
+85
View File
@@ -549,3 +549,88 @@ fn a_label_too_long_for_its_field_ends_in_an_ellipsis_before_the_value() {
assert!(field.contains_rect(*l) && field.contains_rect(*v), "both inside the field {field:?}: {l:?} {v:?}");
assert!(l.max.x < v.min.x, "the label stops before the value: {l:?} {v:?}");
}
/// The middle of the field with response `id`.
fn at(u: &Ui, id: Id) -> Pos2 {
u.ctx.read_response(id.with("field")).unwrap_or_else(|| panic!("no field {id:?}")).rect.center()
}
/// Type `text` into the field whose response is `id` and press Enter.
fn type_at(u: &mut Ui, id: Id, text: &str) {
let p = at(u, id);
click(u, p);
u.frame(vec![Event::Text(text.into())]);
key(u, Key::Enter, Modifiers::NONE);
u.frame(vec![]);
}
/// A frame with one layout grid, its settings open in the Design panel.
fn grid_ui(grid: serde_json::Value) -> (Ui, NodeId) {
let mut s = Session::new();
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 400, "height": 300})).unwrap());
s.execute("node.set", &json!({"ids": [f], "props": {"grids": [grid]}})).unwrap();
s.execute("select.set", &json!({"ids": [f]})).unwrap();
let mut u = Ui::new(s);
u.ctx.data_mut(|d| d.insert_temp(Id::new(("grid_open", 0usize)), true));
u.frame(vec![]);
(u, f)
}
/// Scroll the Design panel (over its own field `id`) until that field is on the test screen: the
/// grid settings sit below the fold.
fn scroll_to(u: &mut Ui, id: Id) {
let wheel = Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(0.0, -300.0),
phase: egui::TouchPhase::Move,
modifiers: Modifiers::NONE,
};
for _ in 0..10 {
let p = at(u, id);
if p.y < 860.0 {
break;
}
u.frame(vec![Event::PointerMoved(pos2(p.x, 500.0)), wheel.clone()]);
// Let the scroll settle.
for _ in 0..30 {
u.frame(vec![]);
}
}
}
/// The word for Auto types 0 only where 0 *is* the automatic value: a fixed layout grid's count
/// does, an auto layout's gap (Auto there is "Space between") does not. Keying it on the field's
/// placeholder said 0 everywhere, which turned the gap's mode off.
#[test]
fn typing_auto_is_zero_only_where_zero_is_the_automatic_value() {
let count_id = Id::new(("grid_field", "count", 0usize));
let (mut u, f) = grid_ui(json!({"kind": "columns", "align": "min", "count": 3, "size": 8, "gutter": 10}));
let count = |u: &Ui| u.app.session.doc().unwrap().node(f).unwrap().grids[0].count;
scroll_to(&mut u, count_id);
type_at(&mut u, count_id, "Auto");
assert_eq!(count(&u), 0, "a fixed grid's count: Auto is 0");
type_at(&mut u, count_id, "4");
assert_eq!(count(&u), 4, "and a number is that count");
// A stretched grid works its width out from the frame: the field shows Auto and takes no edit.
let (mut u, f) = grid_ui(json!({"kind": "columns", "align": "stretch", "count": 3, "size": 0}));
let size_id = Id::new(("grid_field", "size", 0usize));
scroll_to(&mut u, size_id);
type_at(&mut u, size_id, "40");
assert_eq!(u.app.session.doc().unwrap().node(f).unwrap().grids[0].size, 0.0, "a stretched grid's width isn't set");
// Nor its count: a stretched grid has at least one band, so the word commits nothing.
type_at(&mut u, count_id, "Auto");
assert_eq!(u.app.session.doc().unwrap().node(f).unwrap().grids[0].count, 3, "a stretched grid keeps its count");
// The auto layout's gap: its placeholder says Auto, but Auto is a mode, not 0.
let mut s = Session::new();
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 400, "height": 300})).unwrap());
s.execute("layout.add", &json!({"ids": [f], "direction": "vertical"})).unwrap();
s.execute("node.set", &json!({"ids": [f], "props": {"mainAlign": "spaceBetween"}})).unwrap();
s.execute("select.set", &json!({"ids": [f]})).unwrap();
let mut u = Ui::new(s);
u.frame(vec![]);
let gap = |u: &Ui| u.app.session.doc().unwrap().node(f).unwrap().layout.as_ref().unwrap().gap;
let before = gap(&u);
type_at(&mut u, Id::new("al_gap"), "Auto");
assert_eq!(gap(&u), before, "the word isn't a value where Auto is a mode");
}
+9
View File
@@ -135,6 +135,7 @@ fn dynamic_labels() -> Vec<&'static str> {
v.extend(Group::ALL.iter().map(|g| g.label()));
v.extend(GESTURES.iter().flat_map(|(_, label, mac, other)| [*label, *mac, *other]));
v.extend(DATA_LABELS);
v.push(figcraft_engine::live::TOO_LARGE_TO_JOIN);
// The starter templates' names, descriptions and kinds.
v.extend(figcraft_engine::templates::TEMPLATES.iter().flat_map(|t| [t.name, t.description, t.kind.label()]));
v
@@ -327,6 +328,14 @@ fn lookups_fall_back_to_english_and_fill_placeholders() {
assert!(matches!(lookup(es, k), Some(x) if std::ptr::eq(x, t)));
}
#[test]
fn a_live_failure_the_ui_knows_is_read_back_for_translation() {
use figcraft_engine::live::TOO_LARGE_TO_JOIN;
let reason = TOO_LARGE_TO_JOIN.replace("{mb}", "1024");
assert!(matches!(crate::live::failure_text(&reason), std::borrow::Cow::Owned(t) if t == reason));
assert!(matches!(crate::live::failure_text("Couldn't reach the server"), std::borrow::Cow::Borrowed(_)));
}
#[test]
fn system_languages_pick_the_first_one_with_a_catalog() {
let code = |pref: &str, system: Option<&str>| LANGS[resolve(pref, system)].code;
+3 -1
View File
@@ -147,6 +147,8 @@ mod live_tests;
#[cfg(test)]
mod live_text_tests;
#[cfg(test)]
mod measure_nudge_tests;
#[cfg(test)]
mod menu_aim_tests;
#[cfg(test)]
mod menu_input_tests;
@@ -1510,7 +1512,7 @@ impl FigApp {
// What happened in a live session (the network woke us for it).
if self.session.live_poll() {
if let Some(m) = self.session.message.take() {
self.toast(m);
self.toast(live::failure_text(&m));
}
self.background_done(&ctx);
}
+11 -3
View File
@@ -13,10 +13,11 @@
//! and it fades a few seconds after the last change (frames are asked for only until then).
//! Spotlight: your avatar's menu asks everyone to follow you; a banner says so and stops it.
use std::borrow::Cow;
use std::collections::BTreeMap;
use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Shape, Stroke, Ui, pos2, vec2};
use figcraft_engine::live::{Access, PEOPLE, Role, SessionId, Share, Signal, Status, Via, initials, parse_color, presence_page};
use figcraft_engine::live::{Access, PEOPLE, Role, SessionId, Share, Signal, Status, TOO_LARGE_TO_JOIN, Via, initials, parse_color, presence_page};
use serde_json::{Value, json};
use crate::FigApp;
@@ -595,7 +596,7 @@ pub fn share_popover(app: &mut FigApp, anchor: &egui::Response) {
(Status::Reconnecting { attempt }, _) => {
(trf("Connection lost, reconnecting (attempt {attempt})…", &[("attempt", attempt)]), t.text_secondary)
}
(Status::Failed { .. }, _) => (status.label(), t.danger),
(Status::Failed { reason }, _) => (failure_text(reason).into_owned(), t.danger),
_ => (tr(&status.label()).to_string(), t.text_secondary),
};
if !line.is_empty() {
@@ -695,11 +696,18 @@ pub fn join_field(app: &mut FigApp, ui: &mut Ui, typed: &mut String, id: egui::I
pub fn join_status(app: &FigApp, ui: &mut Ui, t: &Tokens, session: SessionId) {
match app.session.live.get(session).map(|l| &l.status) {
Some(Status::Connecting) => widgets::label(ui, tr("Joining…"), t.text_secondary),
Some(Status::Failed { reason }) => widgets::label(ui, reason, t.danger),
Some(Status::Failed { reason }) => widgets::label(ui, &failure_text(reason), t.danger),
_ => {}
}
}
/// Why a live session failed, in the UI language when it is a reason the UI knows (the transport's
/// English otherwise).
pub(crate) fn failure_text(reason: &str) -> Cow<'_, str> {
let mb = TOO_LARGE_TO_JOIN.split_once("{mb}").and_then(|(before, after)| reason.strip_prefix(before)?.strip_suffix(after));
mb.map_or(Cow::Borrowed(reason), |mb| Cow::Owned(trf(TOO_LARGE_TO_JOIN, &[("mb", &mb)])))
}
/// Not in a session: your name, how to share (online or on the local network), start; or join
/// one with a code. Everything else is under Advanced.
fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
+64
View File
@@ -0,0 +1,64 @@
//! Nudging while measuring (#894), driven through egui headless: with Alt held over another layer
//! the arrows still nudge the selection (Shift: the big step), the distance follows every nudge,
//! and the selection's handles and size label hide while measuring (its outline stays).
use egui::{Event, Key, Modifiers, Pos2, Shape};
use figcraft_doc::NodeId;
use figcraft_engine::Session;
use serde_json::json;
use crate::layers_drag_tests::Ui;
use crate::live_tests::flat;
use crate::present_tests::id;
fn labels(u: &Ui) -> Vec<String> {
u.app.session.tool.overlay.guides.iter().filter_map(|g| g.label.clone()).collect()
}
fn top(u: &Ui, i: NodeId) -> f64 {
u.app.session.doc().unwrap().world_bounds(i).unwrap().y0
}
/// Press and release `key` with `m` held, then a settled frame (the pointer still over the canvas).
fn press(u: &mut Ui, key: Key, m: Modifiers, at: Pos2) {
let ev = |pressed| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: m };
u.frame_with(vec![ev(true), ev(false)], m);
u.frame_with(vec![Event::PointerMoved(at)], m);
}
/// Whether the frame drew corner handles or a text `label`.
fn drew(shapes: &[Shape], label: &str) -> (bool, bool) {
let handle = shapes.iter().any(|s| matches!(s, Shape::Rect(r) if (r.rect.width() - 8.0).abs() < 0.1 && (r.rect.height() - 8.0).abs() < 0.1));
let text = shapes.iter().any(|s| matches!(s, Shape::Text(t) if t.galley.text() == label));
(handle, text)
}
#[test]
fn arrows_nudge_while_measuring_and_the_distance_follows() {
let mut s = Session::new();
let upper = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 40})).unwrap());
let lower = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 140, "width": 100, "height": 40})).unwrap());
s.execute("select.set", &json!({"ids": [lower]})).unwrap();
let mut u = Ui::new(s);
let over = u.app.to_screen(50.0, 20.0);
let (alt, shift_alt) = (Modifiers { alt: true, ..Modifiers::NONE }, Modifiers { alt: true, shift: true, ..Modifiers::NONE });
// Not measuring: the selection shows its handles and size.
let shapes = flat(&u.frame_with(vec![Event::PointerMoved(over)], Modifiers::NONE).shapes);
assert_eq!(drew(&shapes, "100 × 40"), (true, true), "handles and size label show");
u.frame_with(vec![Event::PointerMoved(over)], alt);
let shapes = flat(&u.frame_with(vec![Event::PointerMoved(over)], alt).shapes);
assert!(u.app.session.tool.overlay.measure_box.is_some(), "Alt over the other layer measures to it");
assert!(labels(&u).contains(&"100".to_string()), "the gap: {:?}", labels(&u));
assert_eq!(drew(&shapes, "100 × 40"), (false, false), "measuring hides the handles and size label");
press(&mut u, Key::ArrowUp, alt, over);
assert_eq!(top(&u, lower), 139.0, "Alt+Up nudges by the small step");
assert!(labels(&u).contains(&"99".to_string()), "the gap follows the nudge: {:?}", labels(&u));
press(&mut u, Key::ArrowUp, shift_alt, over);
assert_eq!(top(&u, lower), 129.0, "Shift+Alt+Up nudges by the big step");
assert!(labels(&u).contains(&"89".to_string()), "the gap follows the big nudge: {:?}", labels(&u));
assert_eq!(top(&u, upper), 0.0, "the measured layer stays put");
}
+14 -1
View File
@@ -926,7 +926,20 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page:
let w = ((ui.available_width() - 6.0) / 2.0).max(36.0);
for (i, (name, key)) in [("X", "proto_ov_x"), ("Y", "proto_ov_y")].into_iter().enumerate() {
let shown = offset.and_then(|o| o.get(i).copied());
let Some(e) = widgets::number_field_bindable(ui, slot.id(key), Lead::Text(name), shown, "", w, 1.0, None, tr("Auto")).0
// "Auto" is no offset (right below the layer), not 0: the word isn't taken
// as a value here.
let Some(e) = widgets::number_field_bindable(
ui,
slot.id(key),
Lead::Text(name),
shown,
"",
w,
1.0,
None,
widgets::Empty::Text(tr("Auto")),
)
.0
else {
continue;
};
+15 -4
View File
@@ -2056,8 +2056,13 @@ fn grid_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], list: &[LayoutGr
// 0 is Auto for fixed-size columns (rows); a stretched grid has at least one.
let count = (!auto_count(g)).then(|| f64::from(g.count.max(1)));
let key = format!("layoutGrids/{i}/count");
let e =
crate::variable_ui::number_field_or(app, ui, ids, &key, Id::new(("grid_field", "count", i)), count, col, tr("Auto"), Lead::None, "");
let id = Id::new(("grid_field", "count", i));
// A stretched grid has at least one band, so 0 (Auto) isn't a count it can take.
let e = if g.align == GridAlign::Stretch {
crate::variable_ui::number_field_or(app, ui, ids, &key, id, count, col, "", Lead::None, "")
} else {
crate::variable_ui::number_field_auto(app, ui, ids, &key, id, count, col, Lead::None, "")
};
app.field_run(e, "node.set", |v| {
set(&|x| x.count = if x.align == GridAlign::Stretch { v.round().clamp(1.0, 1000.0) } else { v.round().clamp(0.0, 1000.0) } as u32)
});
@@ -2090,11 +2095,17 @@ fn grid_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], list: &[LayoutGr
let span = if cols { tr("Width") } else { tr("Height") };
match g.align {
GridAlign::Stretch => {
widgets::sublabels(ui, tr("Margin"), Some(tr("Gutter")));
widgets::sublabels(ui, span, Some(tr("Margin")));
row(ui, 32.0, |ui| {
// The frame, divided by the count: shown as Auto and not editable.
ui.add_enabled_ui(false, |ui| {
let auto = widgets::Empty::Text(tr("Auto"));
widgets::number_field_bindable(ui, Id::new(("grid_field", "size", i)), Lead::Named(span), None, "", col, 1.0, None, auto)
});
field(app, ui, "offset", g.offset, &offset);
field(app, ui, "gutter", g.gutter, &gutter);
});
widgets::sublabels(ui, tr("Gutter"), None);
row(ui, 32.0, |ui| field(app, ui, "gutter", g.gutter, &gutter));
}
GridAlign::Center => {
widgets::sublabels(ui, span, Some(tr("Gutter")));
+3 -2
View File
@@ -279,7 +279,8 @@ fn key_event(app: &mut FigApp, key: Key, physical: Option<Key>, m: Modifiers, re
if app.ui.layers_focus && !m.command && !m.alt && crate::layers::key_nav(app, key, m.shift) {
return true;
}
// Arrow nudges; with nothing selected the arrows pan the view.
// Arrow nudges, Alt held too (measuring stays on while the selection moves); with nothing
// selected the arrows pan the view.
let (dx, dy) = match key {
Key::ArrowLeft => (-1.0, 0.0),
Key::ArrowRight => (1.0, 0.0),
@@ -287,7 +288,7 @@ fn key_event(app: &mut FigApp, key: Key, physical: Option<Key>, m: Modifiers, re
Key::ArrowDown => (0.0, 1.0),
_ => (0.0, 0.0),
};
if (dx, dy) != (0.0, 0.0) && !m.alt {
if (dx, dy) != (0.0, 0.0) {
if app.session.selection().is_empty() {
app.run("view.pan", json!({"dx": dx, "dy": dy, "big": m.shift}));
} else if m.command {
+40 -4
View File
@@ -14,6 +14,8 @@ pub struct Tokens {
pub border_strong: Color32,
pub text: Color32,
pub text_secondary: Color32,
/// Dim text: disabled controls, placeholders, hints. At least 3:1 against the panels, fields
/// and selected rows, yet dimmer than `text_secondary`.
pub text_tertiary: Color32,
pub icon: Color32,
pub icon_secondary: Color32,
@@ -58,11 +60,15 @@ const CANVAS_ACCENT: &str = "figcraft-canvas-accent";
/// Remember the page colour the canvas shows. Its overlays (outlines, handles, arrows) take
/// whichever green contrasts more with it: the bright one on a dark page, the darker on a light one.
pub fn set_canvas_page(ctx: &Context, page: Color32) {
let c = |x: Color32| figcraft_color::Color::new(f32::from(x.r()) / 255.0, f32::from(x.g()) / 255.0, f32::from(x.b()) / 255.0, 1.0);
let pick = if c(BRAND).contrast(c(page)) > c(ACCENT).contrast(c(page)) { BRAND } else { ACCENT };
ctx.data_mut(|d| d.insert_temp(egui::Id::new(CANVAS_ACCENT), pick));
}
/// `x` (opaque) as a document colour, for its contrast with another.
fn c(x: Color32) -> figcraft_color::Color {
figcraft_color::Color::new(f32::from(x.r()) / 255.0, f32::from(x.g()) / 255.0, f32::from(x.b()) / 255.0, 1.0)
}
/// The green for overlays drawn on the page ([`set_canvas_page`]); [`ACCENT`] before a page shows.
pub fn canvas_accent(ctx: &Context) -> Color32 {
ctx.data(|d| d.get_temp(egui::Id::new(CANVAS_ACCENT))).unwrap_or(ACCENT)
@@ -91,7 +97,7 @@ impl Tokens {
text: Color32::from_rgb(0x1E, 0x1E, 0x1E),
// 4.7:1 on fields and hovered rows (#F5F5F5), 4.6:1 on selected rows: WCAG AA.
text_secondary: Color32::from_rgb(0x6E, 0x6E, 0x6E),
text_tertiary: Color32::from_rgb(0xB3, 0xB3, 0xB3),
text_tertiary: Color32::from_rgb(0x8A, 0x8A, 0x8A),
icon: Color32::from_rgb(0x1E, 0x1E, 0x1E),
icon_secondary: Color32::from_rgb(0x75, 0x75, 0x75),
hover: Color32::from_rgb(0xF5, 0xF5, 0xF5),
@@ -132,7 +138,7 @@ impl Tokens {
border_strong: Color32::from_rgb(0x55, 0x55, 0x55),
text: Color32::from_rgb(0xFF, 0xFF, 0xFF),
text_secondary: Color32::from_rgb(0xB3, 0xB3, 0xB3),
text_tertiary: Color32::from_rgb(0x75, 0x75, 0x75),
text_tertiary: Color32::from_rgb(0x88, 0x88, 0x88),
icon: Color32::from_rgb(0xF5, 0xF5, 0xF5),
icon_secondary: Color32::from_rgb(0xB3, 0xB3, 0xB3),
hover: Color32::from_rgb(0x38, 0x38, 0x38),
@@ -216,7 +222,9 @@ fn visuals(t: Tokens) -> Visuals {
v.window_corner_radius = egui::CornerRadius::same(13);
v.menu_corner_radius = egui::CornerRadius::same(13);
v.selection.bg_fill = t.accent.gamma_multiply(0.3);
v.selection.stroke = Stroke::new(1.0, ACCENT);
// Also the colour of selected text on that highlight (and a focused text box's outline): the
// theme's readable green, not the darker one that sinks into the dark theme's highlight.
v.selection.stroke = Stroke::new(1.0, t.accent_text);
v.hyperlink_color = t.accent_text;
for w in [&mut v.widgets.inactive, &mut v.widgets.hovered, &mut v.widgets.active, &mut v.widgets.open, &mut v.widgets.noninteractive] {
w.corner_radius = egui::CornerRadius::same(5);
@@ -462,6 +470,34 @@ mod tests {
assert_eq!(under_white(COMPONENT), COMPONENT);
}
/// `fill` (premultiplied, maybe translucent) painted over the opaque `bg`.
fn over(fill: Color32, bg: Color32) -> Color32 {
let k = 1.0 - f32::from(fill.a()) / 255.0;
let mix = |f: u8, b: u8| (f32::from(f) + f32::from(b) * k).round().min(255.0) as u8;
Color32::from_rgb(mix(fill.r(), bg.r()), mix(fill.g(), bg.g()), mix(fill.b(), bg.b()))
}
#[test]
fn disabled_and_selected_text_stay_readable_in_both_themes() {
for t in [Tokens::light(), Tokens::dark()] {
let theme = if t.dark { "dark" } else { "light" };
// Disabled text (a disabled button's label, placeholders) on panels, fields and
// selected rows, still dimmer than secondary text.
for bg in [t.panel, t.field, t.hover, t.selected_row] {
let ratio = c(t.text_tertiary).contrast(c(bg));
assert!(ratio >= 3.0, "{theme}: dim text {:?} on {bg:?} is {ratio:.2}:1", t.text_tertiary);
assert!(ratio < c(t.text_secondary).contrast(c(bg)), "{theme}: dim text reads dimmer than secondary text");
}
// Selected text (renaming a page or layer, any text box) on the selection highlight.
let v = visuals(t);
for bg in [t.panel, t.field] {
let highlight = over(v.selection.bg_fill, bg);
let ratio = c(v.selection.stroke.color).contrast(c(highlight));
assert!(ratio >= 3.0, "{theme}: selected text on {highlight:?} is {ratio:.2}:1");
}
}
}
#[test]
fn ui_font_draws_every_mac_shortcut_glyph() {
figcraft_text::scan_system_fonts();
+20 -4
View File
@@ -17,7 +17,7 @@ use crate::a11y::Tip;
use crate::i18n::tr;
use crate::icons::{self, Icon};
use crate::theme::{Tokens, regular, semibold};
use crate::widgets::{self, BindEvent, Edit, FieldEdit, Lead};
use crate::widgets::{self, BindEvent, Edit, Empty, FieldEdit, Lead};
/// What picking a variable does.
#[derive(Clone)]
@@ -258,7 +258,23 @@ pub fn number_field_or(
lead: Lead,
suffix: &str,
) -> Option<FieldEdit> {
number_field_edit(app, ui, ids, field, id, value, width, empty, lead, suffix)?.settled()
number_field_edit(app, ui, ids, field, id, value, width, Empty::Text(empty), lead, suffix)?.settled()
}
/// [`number_field_or`] for a field where 0 is its automatic value (a layout grid's width or
/// count): typing the word for Auto sets 0 ([`Empty::Auto`]).
pub fn number_field_auto(
app: &mut FigApp,
ui: &mut Ui,
ids: &[NodeId],
field: &str,
id: Id,
value: Option<f64>,
width: f32,
lead: Lead,
suffix: &str,
) -> Option<FieldEdit> {
number_field_edit(app, ui, ids, field, id, value, width, Empty::Auto, lead, suffix)?.settled()
}
/// [`number_field`] over several layers: a relative edit of mixed values comes back as one for
@@ -274,7 +290,7 @@ pub fn number_field_mixed(
suffix: &str,
width: f32,
) -> Option<Edit> {
number_field_edit(app, ui, ids, field, id, value, width, tr("Mixed"), lead, suffix)
number_field_edit(app, ui, ids, field, id, value, width, Empty::Text(tr("Mixed")), lead, suffix)
}
fn number_field_edit(
@@ -285,7 +301,7 @@ fn number_field_edit(
id: Id,
value: Option<f64>,
width: f32,
empty: &str,
empty: Empty<'_>,
lead: Lead,
suffix: &str,
) -> Option<Edit> {
+52 -21
View File
@@ -37,6 +37,25 @@ impl Lead<'_> {
}
}
/// What a number field shows when it has no single value, and whether that word types 0.
#[derive(Clone, Copy)]
pub enum Empty<'a> {
/// Dim text that is only a label ("Mixed", or "Auto" where 0 means something else).
Text(&'a str),
/// Auto, in the UI's language: 0 is the field's automatic value, so the word types 0.
Auto,
}
impl<'a> Empty<'a> {
/// The text shown when the field has no value.
fn text(self) -> &'a str {
match self {
Empty::Text(s) => s,
Empty::Auto => tr("Auto"),
}
}
}
/// Result of a field edit.
pub enum FieldEdit {
/// Committed a new value (Enter, Tab or focus loss; an arrow key's step).
@@ -157,18 +176,19 @@ pub fn number_field(ui: &mut Ui, id: Id, lead: Lead, value: Option<f64>, suffix:
/// [`number_field`] over several layers: without a single value (`value` None, "Mixed") a relative
/// edit comes back as one for each layer ([`Edit::Each`]).
pub fn number_field_mixed(ui: &mut Ui, id: Id, lead: Lead, value: Option<f64>, suffix: &str, width: f32, step: f64) -> Option<Edit> {
number_field_bindable(ui, id, lead, value, suffix, width, step, None, tr("Mixed")).0
number_field_bindable(ui, id, lead, value, suffix, width, step, None, Empty::Text(tr("Mixed"))).0
}
/// [`number_field`] that shows `empty` (dim) for no value instead of "Mixed" (e.g. "Auto"): there
/// is no value to edit relative to, so `+4` is 4.
pub fn number_field_or(ui: &mut Ui, id: Id, value: Option<f64>, empty: &str, width: f32, step: f64) -> Option<FieldEdit> {
number_field_impl(ui, id, Lead::None, value, "", width, step, None, empty).0?.settled()
number_field_impl(ui, id, Lead::None, value, "", width, step, None, Empty::Text(empty)).0?.settled()
}
/// [`number_field`] that can bind a variable: `binding` None = not bindable, `Some(None)` = shows
/// a variable button on hover, `Some(Some(name))` = bound (shows the name; click to change, the
/// detach button unbinds). No value shows `empty` ("Mixed", "Auto").
/// detach button unbinds). No value shows `empty` ("Mixed", "Auto"); [`Empty::Auto`] also takes
/// the word for Auto as 0.
pub fn number_field_bindable(
ui: &mut Ui,
id: Id,
@@ -178,7 +198,7 @@ pub fn number_field_bindable(
width: f32,
step: f64,
binding: Option<Option<&str>>,
empty: &str,
empty: Empty<'_>,
) -> (Option<Edit>, Option<BindEvent>) {
number_field_impl(ui, id, lead, value, suffix, width, step, binding, empty)
}
@@ -192,7 +212,7 @@ fn number_field_impl(
width: f32,
step: f64,
binding: Option<Option<&str>>,
empty: &str,
empty: Empty<'_>,
) -> (Option<Edit>, Option<BindEvent>) {
let t = Tokens::get(ui.ctx());
let (rect, _) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover());
@@ -210,7 +230,7 @@ fn number_field_impl(
let editing_id = id.with("editing");
let mut editing: Option<String> = ui.data(|d| d.get_temp(editing_id));
let field_resp = ui.interact(edit_rect, id.with("field"), Sense::click());
a11y::number(&field_resp, lead.name(), value, suffix, empty);
a11y::number(&field_resp, lead.name(), value, suffix, empty.text());
a11y::ring_around(ui, rect, &field_resp);
// The label scrubs with the pointer; the keyboard stops at the value only.
let lead_resp = ui.interact(lead_rect, id.with("lead"), Sense::CLICK | Sense::DRAG);
@@ -259,10 +279,21 @@ fn number_field_impl(
};
// A relative edit of no single value goes to each layer's own.
let set = |v: f64| Edit::All(FieldEdit::Set(v));
let typed = |text: &str| match (parse(text, suffix == "%")?, value) {
(Typed::Value(v), _) => Some(set(v)),
(Typed::Rel(r), Some(cur)) => r.apply(cur).map(set),
(Typed::Rel(r), None) => Some(Edit::Each(r)),
let typed = |text: &str| {
// A field where 0 is its automatic value takes the word for Auto as input too: the
// placeholder's own text (so it is what the field shows, in the UI's language) or the
// English word.
if let Empty::Auto = empty {
let t = text.trim();
if t == empty.text() || t.eq_ignore_ascii_case("auto") {
return Some(set(0.0));
}
}
match (parse(text, suffix == "%")?, value) {
(Typed::Value(v), _) => Some(set(v)),
(Typed::Rel(r), Some(cur)) => r.apply(cur).map(set),
(Typed::Rel(r), None) => Some(Edit::Each(r)),
}
};
// With the keyboard on the field (Tab) and nothing typed yet, the arrows step it as it is.
if editing.is_none()
@@ -349,7 +380,7 @@ fn number_field_impl(
None => {
let s = match value {
Some(v) => format!("{}{}", fmt_num(v), suffix),
None => empty.to_string(),
None => empty.text().to_string(),
};
let col = if value.is_some() { t.text } else { t.text_secondary };
// A value too long for its room ends in "…", so a cut number never reads as another.
@@ -999,26 +1030,26 @@ pub fn radio(ui: &mut Ui, on: bool, label: &str) -> bool {
}
/// A text button: `primary` is filled with the accent colour, otherwise outlined. A disabled
/// button is dimmed and never reports clicks.
/// button is grey with dim text (still readable) and never reports clicks.
pub fn button(ui: &mut Ui, label: &str, primary: bool, enabled: bool) -> Response {
let t = Tokens::get(ui.ctx());
let galley = ui.painter().layout_no_wrap(label.to_string(), regular(11.0), t.text);
let sense = if enabled { Sense::click() } else { Sense::hover() };
let (rect, resp) = ui.allocate_exact_size(vec2(galley.size().x + 24.0, FIELD_H), sense);
a11y::button(&resp, label);
let hot = enabled && resp.hovered();
let (fill, text) = match (primary, hot) {
(true, false) => (t.accent, t.accent_on),
(true, true) => (t.accent_hover, t.accent_on),
(false, false) => (Color32::TRANSPARENT, t.text),
(false, true) => (t.hover, t.text),
let (fill, text) = match (enabled, primary, resp.hovered()) {
(false, true, _) => (t.field, t.text_tertiary),
(false, false, _) => (Color32::TRANSPARENT, t.text_tertiary),
(true, true, false) => (t.accent, t.accent_on),
(true, true, true) => (t.accent_hover, t.accent_on),
(true, false, false) => (Color32::TRANSPARENT, t.text),
(true, false, true) => (t.hover, t.text),
};
let dim = |c: Color32| if enabled { c } else { c.gamma_multiply(0.4) };
ui.painter().rect_filled(rect, CornerRadius::same(5), dim(fill));
ui.painter().rect_filled(rect, CornerRadius::same(5), fill);
if !primary {
ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.border_strong), StrokeKind::Inside);
}
ui.painter().text(rect.center(), Align2::CENTER_CENTER, label, regular(11.0), dim(text));
ui.painter().text(rect.center(), Align2::CENTER_CENTER, label, regular(11.0), text);
resp
}
+3 -1
View File
@@ -1,6 +1,6 @@
# Components, variants, styles and variables parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (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-10-20 · **Change:** minor (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
@@ -17,6 +17,7 @@ accounts).
| What an instance overrides: fills, strokes, effects, visibility, layer names, text content and every text property (resizing included), auto layout padding, gap and alignment, layout grids, export settings, prototype interactions, nested swaps and property values; the size and resizing (hug, fill, fixed, min and max) of layers inside it | done | each kept when the main changes that property, reset per group; opacity and corner radius too (documentation silent); size and resizing are kept through relayout, undo, save, `.fig` and REST import and detach, and stay with the instance on push |
| What it doesn't: position, rotation, constraints and absolute or grid placement of layers inside it, its auto layout direction and other settings, its structure | done | refused with a message, the fields greyed out; the instance itself resizes and its layers follow the main's constraints |
| Component sets / variants, add variant, switch | done | |
| A main's copy within its file is an instance of it: Duplicate, Alt-drag, Copy then Paste | done | a variant duplicated in its set stays a new variant and a whole set copies as a new set; only the copied roots become instances (a main inside a copied frame is copied as a main); Cut then Paste and pastes into other files keep the component (#892) |
| Switching variants keeps overrides where names match and both variants had the same value; text layers keep theirs | done | |
| Default variant: the top-left one (new instances, Assets thumbnail, generated code) | done | |
| Variant property order (names rewritten), duplicate combinations reported | done | drag a property in the Properties section to reorder it, one undo step (#679), or `component.editProperty {index}` |
@@ -76,6 +77,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
| Date | Change | Summary |
|---|---|---|
| 2026-10-20 | minor | A main component's copy within its file is an instance of it (#892): Duplicate (`edit.duplicate`) and Alt-drag on the canvas made a second main; they now place an instance where the copy went (same offset, the instance's default name, selected), as Copy then Paste already did. A variant duplicated in its set stays a new variant. |
| 2026-10-18 | minor | A bound colour variable's alpha applies once (#802): a `#RRGGBBAA` paint keeps its alpha in the paint opacity, and binding a variable with alpha left that opacity in place, so a 50% fill bound to a 50% variable drew at 25%. Binding (`variable.bind`, `node.set` `boundVariables`, `style.bind`) now restarts the paint's opacity at 100% and unbinding (also deleting the variable) moves the colour's alpha back into the opacity; `.fig` and REST imports take the variable's alpha back out of a bound paint's opacity, so `.fig` round trips no longer square it. Colour-picker edits of a colour variable or a plugin colour field, generated CSS strokes and the document colours list counted the paint opacity twice and now count it once (`bound_alpha_tests`). Numbers unchanged |
| 2026-10-18 | minor | Variables import (#790): a `variables` map outside `meta` (or `meta.variables` next to top-level collections) is read, and an import that finds no variables, or fewer than the collections' `variableIds` list, says so in `warnings` instead of succeeding silently |
| 2026-10-17 | minor | Variant properties reorder by dragging them in the Properties section, one undo step (#679; was "no drag in the panel yet"); a library file saved again right after it was read is read again, so a quick republish isn't missed (#652); the Hidden from publishing row no longer says the Hide when publishing item is missing (it landed 2026-10-12). Numbers unchanged |
+3 -1
View File
@@ -131,7 +131,9 @@ component/instance links, overrides, `slot` (a slot layer's property name), boun
`devStatus` (handoff status), `devChanged` (changed since it was marked ready for dev),
`annotations` (Dev Mode notes: `[{label, properties, category?}]`), `codeLinks` (a component's
links to code: `[{language, importPath, name, template}]`), `devResources` (links for developers,
its own and inherited: `[{name, url, index?, from?}]`). The auto layout props
its own and inherited: `[{name, url, index?, from?}]`), `pluginData` (layer data written with
`node.set {props: {pluginData: {key: value}}}`, pages included: `{key: value}`, values as strings;
only when there is some). The auto layout props
that `node.set` accepts are listed in [mcp.md](mcp.md#conventions-agents-must-know).
### `ui.inspect {}`
+1 -1
View File
@@ -299,7 +299,7 @@ Or in a project's `.mcp.json`:
| `create_node {type, x, y, width, height, parent, index, name, text, path, props}` | Create a frame, rectangle, ellipse, polygon, star, line, arrow, text, vector, section, component or slice; returns `{id, node}`. |
| `set_properties {ids, props}` | `node.set` on layers (geometry, fills, strokes, effects, corner radius, auto layout, sizing, constraints, masks (`isMask`, `maskType: alpha\|vector\|luminance`), handoff status (`devStatus: none\|readyForDev\|completed`; marking again clears "Changed"), a section's `sectionContentsHidden`, text…); returns the updated layers. |
| `inspect_document {depth, allPages}` | The document as JSON (layer tree, styles, selection, history). |
| `get_node {id, depth}` | One layer as JSON. |
| `get_node {id, depth}` | One layer as JSON; `pluginData` (`{key: value}`, values as strings) when the layer or page has layer data written with `set_properties {props: {pluginData}}`. |
| `select {ids}` | Replace the selection (`[]` deselects). |
| `export {ids, format, scale \| size, path?, bleed?, cropMarks?, bleedMarks?, cmyk?, contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?}` | png, jpg, svg, webp, pdf, or `fig`: the whole file as `.fig`, experimental (see Exporting `.fig` files below); `size` is `2x`, `512w` or `512h`. The image covers what the layers paint (shadows towards their offset, strokes, unclipped children, a mask group's mask; hidden layers take no room; a slice is its area with everything under it); `contentsOnly: false` also draws every other layer showing in that area, `includeBoundingBox` exports the layers' own boxes; SVG: `svgOutlineText: false` keeps `<text>`, `svgIdAttribute` writes unique ids from layer names, `svgSimplifyStroke: false` writes every stroke as an outline. The same options go in export settings (`export.add` / `export.update`). PDF: frames one page each (sized in points), anything else together on one page; print options: `bleed` (points or a length like `"3mm"`, `"0.125in"`), crop and bleed marks, CMYK colour (see Exports below), also taken by `file.export`, `file.exportLayers`, `file.exportFramesPdf` and stored per export setting by `export.add` / `export.update`; `run_command` `file.exportFramesPdf {page?, path?}` writes every top-level frame of a page. Without `path`, images come back as MCP image content, SVG as text. Layers' own export settings: `run_command` with `export.add` / `export.update {index, size?, suffix?, format?}` / `export.remove` and `file.exportLayers {ids?, dir?, assets?}` (every layer once per setting, named `<layer><suffix>.<format>`, a slash in a layer name making a folder; `assets: true` exports the Inspect tab's assets instead: icons as SVG, images as PNG). |
| `screenshot {ids?, scale?, maxSize?, window?}` | PNG of the page (with background) or of layers, scaled to fit `maxSize` (default 1600). `window: true` captures the app window in front when attached, a window id (see Windows below) that window. |
+3 -1
View File
@@ -460,4 +460,6 @@ import what you need from `figcraft`.
vectors, components or variables).
- Panels: no images, sliders or free layout inside a panel.
- Data in the document: plugins keep data on layers (pages included) with `node.set {props:
{pluginData: {key: value}}}`; there is no separate per-document store.
{pluginData: {key: value}}}` (a `null` value removes a key) and read it back with `node.get`, which
reports `pluginData: {key: value}` (values as strings) when a layer has any; there is no separate
per-document store.
+2 -1
View File
@@ -111,7 +111,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
| 15 | Dev Mode / inspect | 2 | 96% | 72% | Import lines for connected instances inside generated layer trees; an editor extension is left to the MCP server (inspect behaviour suite with colour formats, rem and icon and image assets, code connect, plugins in Dev Mode and dev resources links landed) ([dev-mode-parity](dev-mode-parity.md)) | 0–1 |
| 16 | Comments, versions, find, misc | 3 | 87% | 63% | Comments are local only; comparing two versions; branches in live sessions; text runs and other lists besides fills, strokes and effects merge whole (branches with review and merge, their own version history and per-item list merges, and a comments and version history behaviour suite from the documentation landed) | 1–3 |
| 17 | Multiplayer & sharing | 8 | 17% | 5% | Core landed (`crates/collab`: ids, change sets, server ordering, convergence tested); live sessions (preview): WebSocket transport, hosting from the desktop app, joining from the app or the browser, invite links that open the app, cursors, selections and follow, cursor chat and spotlight, per-person undo, offline resume; a self-hosted `figcraft-server` with persistence and share links to edit or view, which can be open to the public (owner tokens, limits per address, expiry, moderation). Left: accounts and teams, audio ([collaboration-parity](collaboration-parity.md)) | 42–84 |
| 18 | Plugins & widgets | 4 | 60% | 35% | Own sandboxed WASM plugins landed (#101: manifest, permissions, menu, Run last, Manage, SDK), with log byte/count limits checked before allocation and host forwarding ([logging limits](plugins.md#logging-limits)), plugin panels called back per button or changed value and per-plugin settings (ABI v2), then widgets (API 3: plugin-drawn layers on the canvas with state kept in the document, redrawn on clicks; a Counter example), panels side by side and a colour picker for plugin colour fields, then a plugin directory on `figcraft-server` (publish with the server key, browse, install after a permissions confirmation, hash-checked downloads, updates by version). Left: a public catalogue, publisher accounts and signatures, ratings, background update checks, the directory in the browser; widgets take clicks only (no text input, property menus, hover or timers) and draw frames, text, shapes and images only; Figma's plugins are JavaScript and don't run (a JS engine would be an owner decision) ([plugins.md](plugins.md)) | 8–26 |
| 18 | Plugins & widgets | 4 | 60% | 35% | Own sandboxed WASM plugins landed (#101: manifest, permissions, menu, Run last, Manage, SDK), with log byte/count limits checked before allocation and host forwarding ([logging limits](plugins.md#logging-limits)), plugin panels called back per button or changed value and per-plugin settings (ABI v2), then widgets (API 3: plugin-drawn layers on the canvas with state kept in the document, redrawn on clicks; a Counter example), panels side by side and a colour picker for plugin colour fields, then a plugin directory on `figcraft-server` (publish with the server key, browse, install after a permissions confirmation, hash-checked downloads, updates by version); layer data written with `node.set {props: {pluginData}}` reads back through `node.get` (#879). Left: a public catalogue, publisher accounts and signatures, ratings, background update checks, the directory in the browser; widgets take clicks only (no text input, property menus, hover or timers) and draw frames, text, shapes and images only; Figma's plugins are JavaScript and don't run (a JS engine would be an owner decision) ([plugins.md](plugins.md)) | 8–26 |
| 19 | Team libraries & organization | 2 | 70% | 40% | Libraries kept on `figcraft-server` under their file and enabled by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names. Left: team-wide library lists, accounts and permissions, analytics over time, pushed update notices | 4–8 |
| 20 | Figma Draw extras | 2 | 88% | 60% | Have: variable width, lasso, shape builder, noise/texture, text on a path, pattern fills, stretch and scatter brushes, pen pressure (Windows pens, browser styluses), multi-edit (several vector layers in edit mode at once). Missing: pressure from macOS/Linux tablets | 1–3 |
| 21 | AI features | 3 | 0% | 0% | First draft, rename layers, image edit/remove background, visual search, Make: needs local models (owner decision) | 30–60 |
@@ -638,6 +638,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
| Date | Change | Summary |
|---|---|---|
| 2026-10-20 | minor | Layer data reads back (#879): `node.get` (the command, the control method and MCP `get_node`) and `document.inspect` report a layer's or page's `pluginData` as `{key: value}` when it has any, so a tool that keeps state in a file with `node.set {props: {pluginData}}` can read it on its next run (`plugin_data_tests`). Numbers unchanged |
| 2026-10-17 | minor | Menu breadth re-run with `cargo xtask parity`: 212 / 212 (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Rows for the 2026-10-17 landings: import/export (area 14) and the file formats dimension name the `.fig` import's new fidelity (instance identity, overrides by key, text style precedence, grid tracks, slots, connector labels, per-point joins, text on a path, image adjustments, list start numbers, vertical trim), budgets from the file's size, the import report, a `.fig` copy from Save as and paste from another design app; the localization evidence takes the re-measured catalog counts (2,272 entries, drafts at 95–99%, Indonesian and Czech drafts, Swedish and Turkish catalogs); the Automation line adds control requests answered while the window is minimised (#720); batch #753's Selection colors, smart-selection spacing, Present tab and auto layout on empty frames are in ui-parity, prototyping-parity and auto-layout-parity. Numbers unchanged: every `.fig` and paste change is checked on synthetic files or clipboard HTML made from our own export, and real files are what gap 1's blocker and the file-exchange discounts wait for; the UI changes (menus, the Actions palette, editor fixes) are not compared side by side, so UI/UX stays at 58%; the localization grade and hours stay (Indonesian's draft is offset by the drafts' new gaps) |
| 2026-10-17 | minor | Automation roots (M8, #715, #481): `figcraft-cli mcp --automation-read-root DIR --automation-write-root DIR` (each repeatable, existing folders) and the desktop app with the same flags confine every file a command reads to the read and write roots and every file it writes to the write roots; paths are resolved (links followed, `..`, Windows `\\?\` and UNC paths, case) and compared by whole folder names, anything else is an ordinary error. The engine reaches the file system through one module (enforced by `crates/engine/clippy.toml`), and a sweep runs every command with paths outside the roots. `--attach` refuses the flags (the app confines itself); `ui.screenshot` and `ui.render` return PNG data without a `path`, so the attached MCP server writes no temporary files. Numbers unchanged. |
| 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. |
+4 -2
View File
@@ -64,7 +64,7 @@ interaction feel not yet compared.
| Nudge 1 / Shift 10 (preference), Cmd+arrows resize | done | arrows reorder auto layout children |
| Smart guides: edges, centres, equal spacing (between two layers or continuing a row's gap, labelled), spacing labels, parent, layout grid, ruler guides | done | red, `snap.rs`; thresholds not yet compared with Figma's |
| Ctrl to suspend snapping while dragging (macOS) | done | Control on macOS (Cmd keeps working), Ctrl elsewhere; moving, resizing and drawing |
| Measure (Alt hover, Cmd+Alt nested, Shift+Alt padding/gaps) | done | labels never overlap |
| Measure (Alt hover, Cmd+Alt nested, Shift+Alt padding/gaps) | done | labels never overlap; the arrows still nudge while measuring (Shift: the big step) and the distances follow; the selection's handles and size label hide while measuring (`measure_nudge_tests`, #894) |
| Pixel grid snapping, pixel preview, 2x preview | done | moves, resizes and new shapes land on whole pixels; the pixel grid shows from 400% (`view_behaviour_tests`) |
| Numeric fields: arithmetic, scrubbing by dragging the label, mixed values | done | Numeric panels use `widgets::number_field`; Typography line height, letter spacing and paragraph spacing share its leading-icon scrub logic (#373), retaining their existing unit parsing, Auto/Mixed values, variable chips and selected-character targeting (`typography_scrub_tests`). Scrubbing the label: a step a point (1 px; 1% of opacity, 1° of rotation), ten with Shift, a tenth with Alt, each stretch at the rate held while it was dragged, one undo step when let go; past the edge of the screen it goes on where the platform reports the mouse's own motion (desktop), otherwise it stops there. Arrow keys: Up/Down step a pixel field by the small nudge and Shift by the big nudge (the preferences), other fields by their own step and ten of it; they work while typing (from what is typed) and with the keyboard on the field. Typing: `+ - * /`, brackets, units (`px`, `°`, `%`, `x`, `ms`), a decimal comma; `+10`, `*2`, `/4` are relative to the value, and `50%`, `+10%`, `-10%` too in a field that isn't a percentage. Enter, Tab or a click elsewhere commits (Tab and Shift+Tab move to the next and previous control), Escape restores; a field left as it was commits nothing (no undo step, and a rounded display isn't written back). Mixed values show "Mixed": a number sets them all, a relative edit or an arrow key goes to each layer from its own value in one undo step, for X, Y, W, H, rotation, opacity, corner radius and stroke weight (elsewhere `+10` on Mixed sets 10 and `*2` does nothing) (`field_tests`). Left: scrubbing a Mixed field (nothing happens), `x2`-style suffix edits, the pointer isn't wrapped to the other side of the screen; rates not compared with Figma's |
| Transform box at small sizes | done | a press inside a layer smaller than its handles moves it, the nearest handle wins, handles answer just outside and are hidden under 16 px on screen; cursors follow each handle and the box's rotation |
@@ -165,13 +165,15 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
| Keyboard navigation: F6 regions, Tab within, focus ring | done | |
| Layers panel from the keyboard: arrows, F2 / Cmd+R rename (the row gets the keyboard back), Shift+F10 context menu; 10k-layer lists | done | revealing a layer, expanding, collapsing and arrow keys timed in `cargo xtask bench` |
| Reduced motion | done | system setting or preference |
| WCAG AA contrast of secondary and accent text | done | |
| WCAG AA contrast of secondary and accent text | done | disabled and dim text at least 3:1 against panels, fields and selected rows, selected text at least 3:1 on its highlight, in both themes (`theme::tests`, #890) |
| Tested with VoiceOver, NVDA, Narrator, Orca | missing | human QA |
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged |
| 2026-10-20 | minor | Measuring while nudging (#894): with Alt held over another layer, the arrows nudge the selection (Shift: the big step) and the distances follow every nudge (Alt+arrows used to do nothing); while measuring, the selection's handles and size label hide and both layers keep their outlines (`measure_nudge_tests`). Numbers unchanged |
| 2026-10-18 | minor | A rename (layers double-click, F2, File ▸ Rename, a page) selects the whole name in the frame its field first draws, so the first key replaces the name instead of being appended. Checked in the app on Windows over the control channel |
| 2026-10-17 | minor | Escape or Enter while the pen draws ends the path and keeps its vector in edit mode with the pen, so the next path is drawn into the same layer (unconnected); another Escape leaves edit mode (#760). Entering edit mode on a layer that already is a vector no longer records an empty undo step. Numbers unchanged |
| 2026-10-17 | minor | Text layers keep the platform IME on for every frame of an edit, not only frames with input (an idle frame turned it off and macOS dropped each composition, #747); the candidate window opens at the caret instead of the canvas corner; keys pressed while composing go to the IME |