M1: open recent, outlines for the selection, mask outlines (#115)
The last three catalogued menu items got commands: File > Open recent (recent files kept in preferences, capped, missing files greyed and dropped when picked, Clear recent), outlines for the selected layers only, and mask outlines. Menu breadth 100%.
This commit is contained in:
parent
ee82cdd138
commit
ecb3656c69
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
**Stage: alpha** · next: beta, ~28 points and ~200–365 h away (one Opus 5.5 agent; ~40–80 h wall-clock with 5 agents)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%; ready ~47%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%; ready ~47%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
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
|
||||
|
|
@ -13,7 +13,7 @@ start with [`docs/gaps.md`](docs/gaps.md).
|
|||
|
||||
| | Value | Kind |
|
||||
|---|---|---|
|
||||
| Menu/command breadth | **98.5%** (200 / 203) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) |
|
||||
| Menu/command breadth | **100%** (203 / 203) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) |
|
||||
| Feature breadth | **~79%** (core editor ~90%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) |
|
||||
| Ready for real work | **~47%** | estimated, same doc (written dimension weights and scores) |
|
||||
| Essentials user | **~53%** | estimated: core features 77.8% × 0.92 launch/stability × 0.92 discoverability × 0.8 opening files people send ([method](docs/target-app-parity.md#essentials-user)) |
|
||||
|
|
@ -127,6 +127,9 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
|||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-11** · The last three menu items got commands: File ▸ Open recent (kept in the
|
||||
preferences, missing files greyed, Clear recent), outlines for the selection and mask outlines
|
||||
(gap 21). Menu breadth 203/203.
|
||||
- **2026-10-11** · Extended variable collections (a library collection's modes and variables in a
|
||||
collection of the file, overridden per mode with reset, following library updates), variables
|
||||
import/export (the REST local variables JSON, merged by name; CSS custom properties), gradient
|
||||
|
|
@ -161,6 +164,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | The last three catalogued menu items got commands (gap 21): File ▸ Open recent (recent files kept in the preferences, at most 20, a file that's gone greyed in the menu and dropped when picked, Clear recent), View ▸ Outlines ▸ Show outlines for selection and View ▸ Mask outlines: menu breadth 98.5% → 100% (203/203); Canvas & navigation breadth 95% → 97%, 3–6 h → 2–5 h; feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
|
||||
| 2026-10-11 | minor | Extended collections, variables import/export and gradient stop and variant property bindings landed (gap 14): Styles & variables breadth 85% → 92%, ready 70% → 72%, 4–8 h → 1–2 h; feature breadth unchanged (~79%); ready for real work unchanged (~47%); full parity ~385–730 h → ~380–725 h |
|
||||
| 2026-10-11 | minor | Collaboration wording after the standalone server: `figcraft-server` keeps many files, persisted as a snapshot plus a log of what changed (compacted, checksummed, damaged files skipped and reported), restarts lose nothing confirmed and apps resume where they were; the app publishes its file to it; share links per file let people edit or only view (the server refuses a viewer's edits), are revocable and stored as hashes; TLS comes from a reverse proxy (the desktop app has none of its own yet); percentages and hours unchanged until the next re-measure |
|
||||
| 2026-10-11 | minor | Live boolean editing, Design panel arc fields, the shape builder on strokes and open paths and one-sided variable widths landed (gap 13): Shapes & vector breadth 92% → 97%, ready 66% → 68%, 8–14 h → 1–3 h; feature breadth ~78% → ~79%; ready for real work unchanged (~47%); full parity ~390–745 h → ~385–730 h |
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
//! Desktop services: native file dialogs, clipboard, URLs, persisted preferences and recents.
|
||||
//! Desktop services: native file dialogs, clipboard, URLs, persisted preferences.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
|
@ -10,7 +10,6 @@ use serde_json::{Value, json};
|
|||
|
||||
pub struct Desktop {
|
||||
clipboard: Option<arboard::Clipboard>,
|
||||
recents: Vec<String>,
|
||||
pub prefs: Option<Prefs>,
|
||||
/// The system's reduce-motion setting (read in the background at startup).
|
||||
reduce_motion: Arc<AtomicBool>,
|
||||
|
|
@ -32,16 +31,20 @@ impl Desktop {
|
|||
.and_then(|d| std::fs::read_to_string(d.join("state.json")).ok())
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or(json!({}));
|
||||
let recents = state
|
||||
.get("recents")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect())
|
||||
.unwrap_or_default();
|
||||
let prefs = state.get("prefs").and_then(|p| serde_json::from_value(p.clone()).ok());
|
||||
let prefs = state.get("prefs").and_then(|p| serde_json::from_value::<Prefs>(p.clone()).ok()).map(|mut p| {
|
||||
// Recent files lived next to the preferences before they moved into them.
|
||||
if p.recent_files.is_empty()
|
||||
&& let Some(old) = state.get("recents").and_then(Value::as_array)
|
||||
{
|
||||
p.recent_files = old.iter().filter_map(|v| v.as_str().map(str::to_string)).collect();
|
||||
}
|
||||
p.recent_files.truncate(figcraft_engine::cmd::file::MAX_RECENT);
|
||||
p
|
||||
});
|
||||
let reduce_motion = Arc::new(AtomicBool::new(false));
|
||||
let flag = reduce_motion.clone();
|
||||
std::thread::spawn(move || flag.store(system_reduce_motion(), Ordering::Relaxed));
|
||||
Self { clipboard: arboard::Clipboard::new().ok(), recents, prefs, reduce_motion }
|
||||
Self { clipboard: arboard::Clipboard::new().ok(), prefs, reduce_motion }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -86,7 +89,7 @@ fn command_says(program: &str, args: &[&str], want: &str) -> bool {
|
|||
pub fn save_state(app: &FigApp) {
|
||||
let Some(dir) = config_dir() else { return };
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let v = json!({"recents": app.services.recent_files(), "prefs": app.session.prefs});
|
||||
let v = json!({"prefs": app.session.prefs});
|
||||
let tmp = dir.join("state.json.tmp");
|
||||
if std::fs::write(&tmp, v.to_string()).is_ok() {
|
||||
let _ = std::fs::rename(&tmp, dir.join("state.json"));
|
||||
|
|
@ -147,14 +150,6 @@ impl Services for Desktop {
|
|||
fn open_url(&mut self, url: &str) {
|
||||
let _ = webbrowser::open(url);
|
||||
}
|
||||
fn recent_files(&self) -> Vec<String> {
|
||||
self.recents.clone()
|
||||
}
|
||||
fn add_recent(&mut self, path: &str) {
|
||||
self.recents.retain(|p| p != path);
|
||||
self.recents.insert(0, path.to_string());
|
||||
self.recents.truncate(20);
|
||||
}
|
||||
fn reduce_motion(&self) -> bool {
|
||||
self.reduce_motion.load(Ordering::Relaxed)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
false,
|
||||
open
|
||||
),
|
||||
spec!(
|
||||
"file.openRecent",
|
||||
"Open recent",
|
||||
"{path? | index? (into the list), clear? (forget them all)} opens a recent file; a file that no longer exists is dropped from the list. Returns the list, newest first: {files: [{path, name, exists}]} (opening returns what `file.open` does)",
|
||||
false,
|
||||
open_recent
|
||||
),
|
||||
spec!("file.save", "Save", "{path?} (programmatic saves need a path the first time)", false, save),
|
||||
spec!("file.saveAs", "Save as", "{path}", false, save_as),
|
||||
spec!(
|
||||
|
|
@ -517,15 +524,57 @@ pub(crate) fn variables_param(cmd: &str, p: &Value) -> Result<Option<Value>> {
|
|||
Ok(Some(v))
|
||||
}
|
||||
|
||||
/// How many files File ▸ Open recent keeps.
|
||||
pub const MAX_RECENT: usize = 20;
|
||||
|
||||
/// Put `path` at the top of the recent files.
|
||||
fn remember(s: &mut Session, path: &str) {
|
||||
let r = &mut s.prefs.recent_files;
|
||||
r.retain(|p| p != path);
|
||||
r.insert(0, path.to_string());
|
||||
r.truncate(MAX_RECENT);
|
||||
}
|
||||
|
||||
fn open_recent(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "file.openRecent";
|
||||
if p.get("clear").and_then(Value::as_bool) == Some(true) {
|
||||
s.prefs.recent_files.clear();
|
||||
}
|
||||
let path = match (string(p, "path"), num(p, "index")) {
|
||||
(Some(path), _) => Some(path),
|
||||
(None, Some(i)) => Some(s.prefs.recent_files.get(i.max(0.0) as usize).cloned().ok_or_else(|| bad(CMD, "no such recent file"))?),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(path) = path {
|
||||
if !std::path::Path::new(&path).exists() {
|
||||
s.prefs.recent_files.retain(|r| *r != path);
|
||||
return Err(bad(CMD, format!("{path} no longer exists")));
|
||||
}
|
||||
return open(s, &json!({"path": path}));
|
||||
}
|
||||
let files: Vec<Value> = s
|
||||
.prefs
|
||||
.recent_files
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let name = std::path::Path::new(r).file_name().map_or_else(|| r.clone(), |f| f.to_string_lossy().into_owned());
|
||||
json!({"path": r, "name": name, "exists": std::path::Path::new(r).exists()})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({"files": files}))
|
||||
}
|
||||
|
||||
fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = req_string("file.open", p, "path")?;
|
||||
let variables = variables_param("file.open", p)?;
|
||||
// Already open: switch to it.
|
||||
if let Some(i) = s.docs.iter().position(|d| d.path.as_deref() == Some(path.as_str())) {
|
||||
s.active = i;
|
||||
remember(s, &path);
|
||||
return Ok(json!({"index": i}));
|
||||
}
|
||||
let (loaded, rest) = load_path(&path, variables.as_ref())?;
|
||||
remember(s, &path);
|
||||
let native = !rest && matches!(ext(&path).as_str(), "figcraft" | "json");
|
||||
let warnings = rest || ext(&path) == "svg";
|
||||
s.docs.push(DocState::new(loaded.doc, native.then_some(path)).with_versions(loaded.versions));
|
||||
|
|
@ -577,6 +626,7 @@ fn save_to(s: &mut Session, path: String) -> Result<Value> {
|
|||
ds.path = Some(path.clone());
|
||||
ds.mark_saved();
|
||||
ds.revision += 1;
|
||||
remember(s, &path);
|
||||
Ok(json!({"path": path, "bytes": bytes.len()}))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -193,6 +193,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
spec!("view.slices", "Slices", "{on?}", false, |s, p| flag(s, p, |v| &mut v.show_slices)),
|
||||
spec!("view.comments", "Comments", "{on?}", false, |s, p| flag(s, p, |v| &mut v.comments)),
|
||||
spec!("view.outlines", "Outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.outlines)),
|
||||
spec!("view.outlinesSelection", "Outlines for selection", "{on?} outline mode for the selected layers only", false, |s, p| {
|
||||
flag(s, p, |v| &mut v.outlines_selection)
|
||||
}),
|
||||
spec!("view.maskOutlines", "Mask outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.mask_outlines)),
|
||||
spec!("view.pixelPreview", "Pixel preview", "{on?, scale? (1|2: rendered pixels per page pixel; turns it on)}", false, pixel_preview),
|
||||
spec!("view.frameOutlines", "Frame outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.frame_outlines)),
|
||||
spec!("view.snapGeometry", "Snap to geometry", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_geometry)),
|
||||
|
|
|
|||
|
|
@ -120,6 +120,10 @@ pub struct ViewState {
|
|||
pub layout_grids: bool,
|
||||
pub rulers: bool,
|
||||
pub outlines: bool,
|
||||
/// Outline mode for the selected layers only (the rest draws normally).
|
||||
pub outlines_selection: bool,
|
||||
/// Outline the masks (they don't draw themselves).
|
||||
pub mask_outlines: bool,
|
||||
pub pixel_preview: bool,
|
||||
/// Pixel preview resolution: rendered pixels per page pixel (1 or 2).
|
||||
pub pixel_scale: u8,
|
||||
|
|
@ -144,6 +148,8 @@ impl Default for ViewState {
|
|||
layout_grids: true,
|
||||
rulers: false,
|
||||
outlines: false,
|
||||
outlines_selection: false,
|
||||
mask_outlines: false,
|
||||
pixel_preview: false,
|
||||
pixel_scale: 1,
|
||||
show_slices: false,
|
||||
|
|
@ -459,6 +465,9 @@ pub struct Prefs {
|
|||
/// Installed plugins (Plugins menu), in menu order.
|
||||
#[serde(serialize_with = "plugins::persisted")]
|
||||
pub plugins: Vec<plugins::InstalledPlugin>,
|
||||
/// Recently opened or saved files, newest first (File ▸ Open recent; at most
|
||||
/// [`cmd::file::MAX_RECENT`]).
|
||||
pub recent_files: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for Prefs {
|
||||
|
|
@ -476,6 +485,7 @@ impl Default for Prefs {
|
|||
reduce_motion: false,
|
||||
invite_links: None,
|
||||
plugins: Vec::new(),
|
||||
recent_files: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1197,6 +1207,8 @@ mod mask_tests;
|
|||
#[cfg(test)]
|
||||
mod measure_tests;
|
||||
#[cfg(test)]
|
||||
mod menu_gaps_tests;
|
||||
#[cfg(test)]
|
||||
mod nested_override_tests;
|
||||
#[cfg(test)]
|
||||
mod paint_reorder_tests;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
//! File ▸ Open recent (kept in the preferences, capped, missing files dropped), outlines for the
|
||||
//! selection and mask outlines.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::cmd::file::MAX_RECENT;
|
||||
use crate::params::id_of;
|
||||
|
||||
#[test]
|
||||
fn open_recent_lists_opens_prunes_and_clears() {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-recent-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let a = dir.join("a.figcraft").to_string_lossy().to_string();
|
||||
let b = dir.join("b.figcraft").to_string_lossy().to_string();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.save", &json!({"path": a})).unwrap();
|
||||
s.execute("file.saveAs", &json!({"path": b})).unwrap();
|
||||
let list = s.execute("file.openRecent", &json!({})).unwrap();
|
||||
assert_eq!(list["files"][0]["path"], b.as_str(), "newest first");
|
||||
assert_eq!(list["files"][1]["name"], "a.figcraft");
|
||||
assert_eq!(list["files"][1]["exists"], true);
|
||||
// Opening one moves it to the top (an open file is switched to).
|
||||
s.execute("file.openRecent", &json!({"index": 1})).unwrap();
|
||||
assert_eq!(s.prefs.recent_files, vec![a.clone(), b.clone()]);
|
||||
assert!(s.execute("file.openRecent", &json!({"index": 7})).is_err());
|
||||
// A file that's gone is dropped when it's picked.
|
||||
std::fs::remove_file(&b).unwrap();
|
||||
assert!(s.execute("file.openRecent", &json!({"path": b})).is_err());
|
||||
assert_eq!(s.prefs.recent_files, vec![a.clone()]);
|
||||
s.execute("file.openRecent", &json!({"clear": true})).unwrap();
|
||||
assert!(s.prefs.recent_files.is_empty());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_files_are_capped() {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-recent-cap-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut s = Session::new();
|
||||
for i in 0..MAX_RECENT + 3 {
|
||||
let p = dir.join(format!("{i}.figcraft")).to_string_lossy().to_string();
|
||||
s.execute("file.saveAs", &json!({"path": p})).unwrap();
|
||||
}
|
||||
assert_eq!(s.prefs.recent_files.len(), MAX_RECENT);
|
||||
assert!(s.prefs.recent_files[0].ends_with(&format!("{}.figcraft", MAX_RECENT + 2)));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_toggles_flip_their_flags() {
|
||||
let mut s = Session::new();
|
||||
assert_eq!(s.execute("view.outlinesSelection", &json!({})).unwrap()["on"], true);
|
||||
assert!(s.ds().unwrap().view.outlines_selection);
|
||||
assert_eq!(s.execute("view.maskOutlines", &json!({"on": true})).unwrap()["on"], true);
|
||||
assert_eq!(s.execute("view.maskOutlines", &json!({})).unwrap()["on"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outlined_layers_draw_as_outlines_and_the_rest_normally() {
|
||||
let mut s = Session::new();
|
||||
let a = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 40, "height": 40})).unwrap()["id"]).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 60, "y": 0, "width": 40, "height": 40})).unwrap();
|
||||
let ds = s.ds().unwrap();
|
||||
let view = figcraft_render::View { origin: (0.0, 0.0), scale: 1.0, width: 120, height: 60 };
|
||||
let render =
|
||||
|outlined| figcraft_render::render_view(&ds.doc, ds.page, &view, &figcraft_render::Options { outlined, ..Default::default() }).unwrap();
|
||||
let (plain, outlined) = (render(vec![]), render(vec![a]));
|
||||
let empty = plain.pixel(110, 50).unwrap();
|
||||
assert_ne!(plain.pixel(20, 20).unwrap(), empty, "a filled rectangle");
|
||||
assert_eq!(outlined.pixel(20, 20).unwrap(), empty, "only its outline");
|
||||
assert_eq!(outlined.pixel(80, 20).unwrap(), plain.pixel(80, 20).unwrap(), "the other layer draws normally");
|
||||
}
|
||||
|
|
@ -21,6 +21,8 @@ pub struct Ctx<'a> {
|
|||
/// Device pixels per document unit (for effect radii).
|
||||
pub scale: f64,
|
||||
pub outline_mode: bool,
|
||||
/// Layers drawn in outline mode with their contents (outlines for the selection).
|
||||
pub outlined: &'a [NodeId],
|
||||
/// Nodes not to draw (e.g. text being edited draws through the editor).
|
||||
pub skip: &'a [NodeId],
|
||||
/// Approximate what's too small to see in detail (canvas previews; see [`crate::lod`]).
|
||||
|
|
@ -353,8 +355,8 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep
|
|||
if dev.intersect(view).is_zero_area() && !(dev.width() == 0.0 || dev.height() == 0.0) {
|
||||
return;
|
||||
}
|
||||
if ctx.outline_mode {
|
||||
draw_outline(ctx, pix, n, xf, depth);
|
||||
if ctx.outline_mode || ctx.outlined.contains(&id) {
|
||||
draw_outline(&Ctx { outline_mode: true, ..*ctx }, pix, n, xf, depth);
|
||||
return;
|
||||
}
|
||||
if ctx.lod && dev.width().max(dev.height()) < lod::TINY_PX && lod::splattable(ctx.doc, n, 0) {
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@ pub struct Options {
|
|||
/// Fill the background (None = transparent, e.g. export without page colour).
|
||||
pub background: Option<Color>,
|
||||
pub outline_mode: bool,
|
||||
/// Layers drawn in outline mode (with their contents) whatever `outline_mode` says.
|
||||
pub outlined: Vec<NodeId>,
|
||||
/// Nodes to leave out (an editor drawing them itself).
|
||||
pub skip: Vec<NodeId>,
|
||||
/// Draw only this layer of the page, where it is (Dev Mode's focus view).
|
||||
|
|
@ -151,7 +153,7 @@ pub fn render_view_single(doc: &Document, page: NodeId, view: &View, opts: &Opti
|
|||
pix.fill(paint::ts_color(bg));
|
||||
}
|
||||
let lod = opts.lod && !opts.outline_mode && view.scale <= lod::MAX_SCALE;
|
||||
let ctx = draw::Ctx { doc, scale: view.scale, outline_mode: opts.outline_mode, skip: &opts.skip, lod, under: None };
|
||||
let ctx = draw::Ctx { doc, scale: view.scale, outline_mode: opts.outline_mode, outlined: &opts.outlined, skip: &opts.skip, lod, under: None };
|
||||
let xf = view.transform();
|
||||
let Some(p) = doc.node(page) else { return Some(pix) };
|
||||
match opts.solo.filter(|id| *id != page && doc.page_of(*id) == Some(page)) {
|
||||
|
|
@ -167,7 +169,7 @@ pub fn render_view_single(doc: &Document, page: NodeId, view: &View, opts: &Opti
|
|||
|
||||
/// Render several nodes (in z-order) into an existing pixmap through `view`.
|
||||
pub fn render_nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap) {
|
||||
let ctx = draw::Ctx { doc, scale: view.scale, outline_mode: false, skip: &[], lod: false, under: None };
|
||||
let ctx = draw::Ctx { doc, scale: view.scale, outline_mode: false, outlined: &[], skip: &[], lod: false, under: None };
|
||||
for id in ids {
|
||||
draw::draw_node(&ctx, pix, *id, view.transform() * parent_transform(doc, *id), 0);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ fn exports_never_use_lod() {
|
|||
crate::render_nodes_into(&d, &[card], &v, &mut exact);
|
||||
assert_eq!(png.data(), exact.data());
|
||||
let mut preview = Pixmap::new(v.width, v.height).unwrap();
|
||||
let ctx = crate::draw::Ctx { doc: &d, scale, outline_mode: false, skip: &[], lod: true, under: None };
|
||||
let ctx = crate::draw::Ctx { doc: &d, scale, outline_mode: false, outlined: &[], skip: &[], lod: true, under: None };
|
||||
let parent = d.world_transform(p);
|
||||
crate::draw::draw_node(&ctx, &mut preview, card, v.transform() * parent, 0);
|
||||
assert_ne!(png.data(), preview.data(), "the preview of the same area is approximated");
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ struct Frame {
|
|||
doc: Arc<Document>,
|
||||
page: NodeId,
|
||||
outline: bool,
|
||||
outlined: Vec<NodeId>,
|
||||
lod: bool,
|
||||
skip: Vec<NodeId>,
|
||||
solo: Option<NodeId>,
|
||||
|
|
@ -134,6 +135,7 @@ impl Retained {
|
|||
if let Some(last) = self.last.take()
|
||||
&& last.page == page
|
||||
&& last.outline == opts.outline_mode
|
||||
&& last.outlined == opts.outlined
|
||||
&& last.lod == opts.lod
|
||||
&& last.skip == opts.skip
|
||||
&& last.solo == opts.solo
|
||||
|
|
@ -176,6 +178,7 @@ impl Retained {
|
|||
doc: doc.clone(),
|
||||
page,
|
||||
outline: opts.outline_mode,
|
||||
outlined: opts.outlined.clone(),
|
||||
lod: opts.lod,
|
||||
skip: opts.skip.clone(),
|
||||
solo: opts.solo,
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ struct JobKey {
|
|||
scale: i64,
|
||||
size: (u32, u32),
|
||||
outline: bool,
|
||||
outlined: Vec<NodeId>,
|
||||
skip: Vec<NodeId>,
|
||||
solo: Option<NodeId>,
|
||||
/// Text lays out anew when fonts arrive (system fonts load in the background).
|
||||
|
|
@ -271,6 +272,7 @@ fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32) {
|
|||
};
|
||||
// The edited text is drawn live (an IME composition included); the caret is an overlay.
|
||||
let doc = app.preedit_view().map(|p| p.doc.clone()).unwrap_or_else(|| ds.doc.clone());
|
||||
let outlined = if ds.view.outlines_selection { ds.selection.clone() } else { vec![] };
|
||||
let key = JobKey {
|
||||
doc: Arc::as_ptr(&doc) as usize,
|
||||
page: ds.page,
|
||||
|
|
@ -278,6 +280,7 @@ fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32) {
|
|||
scale: (scale * 10000.0) as i64,
|
||||
size: (w, h),
|
||||
outline: ds.view.outlines,
|
||||
outlined: outlined.clone(),
|
||||
skip: vec![],
|
||||
solo: ds.focused(),
|
||||
fonts: figcraft_text::layout_generation(),
|
||||
|
|
@ -290,6 +293,7 @@ fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32) {
|
|||
page: ds.page,
|
||||
view: figcraft_render::View { origin: (ox, oy), scale, width: w.min(16384), height: h.min(16384) },
|
||||
outline: ds.view.outlines,
|
||||
outlined,
|
||||
skip: vec![],
|
||||
solo: key.solo,
|
||||
};
|
||||
|
|
@ -749,6 +753,12 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [
|
|||
for id in operands.iter().filter(|o| !sel.contains(o)) {
|
||||
draw_outline(app, painter, &doc, *id, stroke(outline_colour(&doc, *id).gamma_multiply(0.6), 1.0));
|
||||
}
|
||||
// Mask outlines: a mask doesn't draw itself.
|
||||
if view.mask_outlines {
|
||||
for id in doc.subtree(page).into_iter().filter(|i| doc.node(*i).is_some_and(|n| n.is_mask && n.visible)) {
|
||||
draw_outline(app, painter, &doc, id, stroke(ACCENT, 1.0));
|
||||
}
|
||||
}
|
||||
// Drop target / insertion indicator.
|
||||
let ov = &app.session.tool.overlay;
|
||||
if let Some(d) = ov.drop_target
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
|
|||
app.ui.home = false;
|
||||
}
|
||||
y += 16.0;
|
||||
let recents = app.services.recent_files();
|
||||
let recents = app.session.prefs.recent_files.clone();
|
||||
if !recents.is_empty() {
|
||||
ui.painter().text(pos2(main.min.x, y), Align2::LEFT_TOP, "Recent files", semibold(12.0), t.text_secondary);
|
||||
y += 28.0;
|
||||
|
|
|
|||
|
|
@ -200,6 +200,7 @@ fn a_finished_background_render_wakes_the_ui() {
|
|||
page: ds.page,
|
||||
view: figcraft_render::View { origin: (0.0, 0.0), scale: 1.0, width: 120, height: 120 },
|
||||
outline: false,
|
||||
outlined: vec![],
|
||||
skip: vec![],
|
||||
solo: None,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -135,11 +135,6 @@ pub trait Services {
|
|||
None
|
||||
}
|
||||
fn open_url(&mut self, _url: &str) {}
|
||||
/// Recently opened files (persisted by the app).
|
||||
fn recent_files(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
fn add_recent(&mut self, _path: &str) {}
|
||||
/// The system asks for reduced motion (its accessibility setting).
|
||||
fn reduce_motion(&self) -> bool {
|
||||
false
|
||||
|
|
@ -557,7 +552,6 @@ impl FigApp {
|
|||
if let Some(p) = self.services.pick_save("Save as", &format!("{name}.figcraft"), &["figcraft"]) {
|
||||
let ps = p.to_string_lossy().to_string();
|
||||
if self.run("file.saveAs", json!({"path": ps})).is_some() {
|
||||
self.services.add_recent(&ps);
|
||||
self.toast(format!("Saved {}", p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default()));
|
||||
}
|
||||
}
|
||||
|
|
@ -577,7 +571,6 @@ impl FigApp {
|
|||
if let Some(p) = self.services.pick_open("Open", &["figcraft", "json", "svg", "png", "jpg", "jpeg", "webp", "gif"]) {
|
||||
let ps = p.to_string_lossy().to_string();
|
||||
if self.run("file.open", json!({"path": ps})).is_some() {
|
||||
self.services.add_recent(&ps);
|
||||
self.ui.home = false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -182,6 +182,12 @@ fn menu_level(app: &mut FigApp, ui: &mut Ui, prefix: &str) -> bool {
|
|||
continue;
|
||||
}
|
||||
last_sep = false;
|
||||
if m.id == "file.openRecent" {
|
||||
if recent_menu(app, ui, prefix, m.label, &t) {
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let enabled = available(m.id);
|
||||
if item(ui, m.label, crate::shortcuts::shortcut(m.id).unwrap_or(""), enabled, false, &t) && enabled {
|
||||
clicked = Some(m.id);
|
||||
|
|
@ -205,6 +211,35 @@ fn menu_level(app: &mut FigApp, ui: &mut Ui, prefix: &str) -> bool {
|
|||
false
|
||||
}
|
||||
|
||||
/// File ▸ Open recent: the recent files (greyed when the file is gone) and Clear recent. Returns
|
||||
/// whether an item ran.
|
||||
fn recent_menu(app: &mut FigApp, ui: &mut Ui, prefix: &str, label: &str, t: &Tokens) -> bool {
|
||||
let picked = submenu(ui, prefix, &format!("{prefix}/{label}"), label, t, |ui| {
|
||||
let recents = &app.session.prefs.recent_files;
|
||||
let mut picked = None;
|
||||
for (i, p) in recents.iter().enumerate() {
|
||||
let path = std::path::Path::new(p);
|
||||
let name = path.file_name().map_or_else(|| p.clone(), |f| f.to_string_lossy().into_owned());
|
||||
let exists = path.exists();
|
||||
if item(ui, &name, "", exists, false, t) && exists {
|
||||
picked = Some(json!({"index": i}));
|
||||
}
|
||||
}
|
||||
if !recents.is_empty() {
|
||||
separator(ui);
|
||||
}
|
||||
if item(ui, "Clear recent", "", !recents.is_empty(), false, t) && !recents.is_empty() {
|
||||
picked = Some(json!({"clear": true}));
|
||||
}
|
||||
picked
|
||||
});
|
||||
let Some(params) = picked else { return false };
|
||||
if app.run("file.openRecent", params).is_some_and(|r| r.get("index").is_some()) {
|
||||
app.ui.home = false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// A submenu row `label` in the menu at `prefix`; while it's open (hovered last), `add` draws its
|
||||
/// items beside it. `key` is unique among the menu's submenus. Returns what `add` returned.
|
||||
pub(crate) fn submenu<R>(ui: &mut Ui, prefix: &str, key: &str, label: &str, t: &Tokens, add: impl FnOnce(&mut Ui) -> Option<R>) -> Option<R> {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ pub struct Job {
|
|||
pub page: NodeId,
|
||||
pub view: View,
|
||||
pub outline: bool,
|
||||
/// Layers drawn as outlines (outlines for the selection).
|
||||
pub outlined: Vec<NodeId>,
|
||||
pub skip: Vec<NodeId>,
|
||||
/// Only this layer (the focus view).
|
||||
pub solo: Option<NodeId>,
|
||||
|
|
@ -45,7 +47,14 @@ fn run(job: &Job, retained: &mut Retained) -> Option<Done> {
|
|||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let t0 = std::time::Instant::now();
|
||||
// The canvas is a preview: what's too small to see in detail at this zoom is approximated.
|
||||
let opts = figcraft_render::Options { outline_mode: job.outline, skip: job.skip.clone(), solo: job.solo, lod: true, ..Default::default() };
|
||||
let opts = figcraft_render::Options {
|
||||
outline_mode: job.outline,
|
||||
outlined: job.outlined.clone(),
|
||||
skip: job.skip.clone(),
|
||||
solo: job.solo,
|
||||
lod: true,
|
||||
..Default::default()
|
||||
};
|
||||
let (view, pix, work) = retained.render(&job.doc, job.page, &job.view, &opts)?;
|
||||
let image = screen_image(pix, job.doc.color_profile);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
|
|
|||
16
docs/gaps.md
16
docs/gaps.md
|
|
@ -1,6 +1,6 @@
|
|||
# Where FigCraft falls short of Figma
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from
|
||||
doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The
|
||||
|
|
@ -34,7 +34,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
|||
| 18 | Display P3 on screen; one window only | Hardware | 6–12 | [hardware-parity](hardware-parity.md) |
|
||||
| 19 | AI features | AI | 30–60 | [target-app-parity](target-app-parity.md) |
|
||||
| 20 | Mobile apps (view, mirror, comment) | Platforms | 20–40 | [hardware-parity](hardware-parity.md) |
|
||||
| 21 | Three menu items without a command | Feature | 2–4 | [parity-checklist](parity-checklist.md) |
|
||||
| 21 | Menu items without a command: none left (landed 2026-10-11) | Feature | — | [parity-checklist](parity-checklist.md) |
|
||||
| 22 | Screen readers never run; browser has no accessibility tree | UI/UX | 4–8 | [ui-parity](ui-parity.md) |
|
||||
| 23 | CMYK output intent for print PDFs | File format | 2–4 | [file-format-parity](file-format-parity.md) |
|
||||
|
||||
|
|
@ -217,10 +217,13 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
|||
FigCraft's web build runs in mobile browsers but is not designed for touch.
|
||||
- **Estimate:** 20–40 h for a touch-first viewer and mirror.
|
||||
|
||||
### 21. Three menu items without a command (measured)
|
||||
- `file.openRecent`, `view.outlinesSelection`, `view.maskOutlines`
|
||||
([`parity-checklist.md`](parity-checklist.md)); the plugin pair landed in #101.
|
||||
- **Estimate:** 2–4 h for the first three.
|
||||
### 21. Menu items without a command (measured): none left
|
||||
- **Landed (2026-10-11):** `file.openRecent` (File ▸ Open recent: recent files kept in the
|
||||
preferences, at most 20, greyed when the file is gone and dropped when picked, Clear recent),
|
||||
`view.outlinesSelection` (outline mode for the selected layers only) and `view.maskOutlines`;
|
||||
the plugin pair landed in #101. Every item in the menu catalog has a command
|
||||
([`parity-checklist.md`](parity-checklist.md)).
|
||||
- **Estimate:** none left.
|
||||
|
||||
### 22. Screen readers never run; browser has no accessibility tree
|
||||
- **Evidence:** AccessKit names and keyboard navigation landed (#96), checked by tests only;
|
||||
|
|
@ -236,6 +239,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | The last three catalogued menu items got commands (gap 21): File ▸ Open recent (recent files kept in the preferences, at most 20, a file that's gone greyed in the menu and dropped when picked, Clear recent), View ▸ Outlines ▸ Show outlines for selection and View ▸ Mask outlines: menu breadth 98.5% → 100% (203/203); Canvas & navigation breadth 95% → 97%, 3–6 h → 2–5 h; feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
|
||||
| 2026-10-11 | minor | Extended collections, variables import/export and gradient stop and variant property bindings landed (gap 14): Styles & variables breadth 85% → 92%, ready 70% → 72%, 4–8 h → 1–2 h; feature breadth unchanged (~79%); ready for real work unchanged (~47%); full parity ~385–730 h → ~380–725 h |
|
||||
| 2026-10-11 | minor | Collaboration wording after the standalone server: `figcraft-server` keeps many files, persisted as a snapshot plus a log of what changed (compacted, checksummed, damaged files skipped and reported), restarts lose nothing confirmed and apps resume where they were; the app publishes its file to it; share links per file let people edit or only view (the server refuses a viewer's edits), are revocable and stored as hashes; TLS comes from a reverse proxy (the desktop app has none of its own yet); percentages and hours unchanged until the next re-measure |
|
||||
| 2026-10-11 | minor | Live boolean editing, Design panel arc fields, the shape builder on strokes and open paths and one-sided variable widths landed (gap 13): Shapes & vector breadth 92% → 97%, ready 66% → 68%, 8–14 h → 1–3 h; feature breadth ~78% → ~79%; ready for real work unchanged (~47%); full parity ~390–745 h → ~385–730 h |
|
||||
|
|
|
|||
|
|
@ -2,14 +2,10 @@
|
|||
|
||||
> **Generated:** 2026-10-11 by `cargo xtask parity`. Do not edit by hand.
|
||||
|
||||
FigCraft commands registered for the incumbent's main menu and toolbar (455 commands in the engine).
|
||||
FigCraft commands registered for the incumbent's main menu and toolbar (468 commands in the engine).
|
||||
|
||||
**200 / 203 menu items (98.5%)**
|
||||
**203 / 203 menu items (100.0%)**
|
||||
|
||||
## Missing
|
||||
|
||||
- `file.openRecent`
|
||||
- `view.outlinesSelection`
|
||||
- `view.maskOutlines`
|
||||
Every catalog item has a command.
|
||||
|
||||
Menu coverage measures breadth only; see docs/target-app-parity.md for depth and fidelity.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# FigCraft roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and
|
||||
[`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours
|
||||
|
|
@ -47,7 +47,7 @@ blockers.
|
|||
|---|---|---|---|
|
||||
| M0 Foundation: workspace, model, renderer, engine, UI shell, app, control channel | done (2026-10-09) | — | — |
|
||||
| M0.9 CLI + MCP, web build, release packaging, PDF export | done (v0.2.0 released 2026-10-10 for every platform) | — | — |
|
||||
| M1 Core editing completeness (tools, panels, menus, clipboard, files) | ~75% | Open recent, outline mode variants, keyboard box selection, interaction pass | 8–14 + pass |
|
||||
| M1 Core editing completeness (tools, panels, menus, clipboard, files) | ~77% | keyboard box selection, interaction pass (Open recent and the outline mode variants landed 2026-10-11) | 6–12 + pass |
|
||||
| M2 Layout depth | ~80% | grid `fr` rows, real-file QA | 6–12 |
|
||||
| M3 Components & styles depth | ~78% (extended collections, variables import/export, gradient stop and variant bindings landed 2026-10-11) | real libraries, component playground | 7–14 |
|
||||
| M4 Vector & text depth | ~75% (live booleans, arc fields, shape builder on strokes, one-sided widths landed 2026-10-11) | spell check, Figma Draw (brushes, pressure, text on a path, patterns), boolean hit testing on the result | 18–32 |
|
||||
|
|
@ -71,6 +71,7 @@ blockers.
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | The last three catalogued menu items got commands (gap 21): File ▸ Open recent (recent files kept in the preferences, at most 20, a file that's gone greyed in the menu and dropped when picked, Clear recent), View ▸ Outlines ▸ Show outlines for selection and View ▸ Mask outlines: menu breadth 98.5% → 100% (203/203); Canvas & navigation breadth 95% → 97%, 3–6 h → 2–5 h; feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
|
||||
| 2026-10-11 | minor | Extended collections, variables import/export and gradient stop and variant property bindings landed (gap 14): Styles & variables breadth 85% → 92%, ready 70% → 72%, 4–8 h → 1–2 h; feature breadth unchanged (~79%); ready for real work unchanged (~47%); full parity ~385–730 h → ~380–725 h |
|
||||
| 2026-10-11 | minor | Collaboration wording after the standalone server: `figcraft-server` keeps many files, persisted as a snapshot plus a log of what changed (compacted, checksummed, damaged files skipped and reported), restarts lose nothing confirmed and apps resume where they were; the app publishes its file to it; share links per file let people edit or only view (the server refuses a viewer's edits), are revocable and stored as hashes; TLS comes from a reverse proxy (the desktop app has none of its own yet); percentages and hours unchanged until the next re-measure |
|
||||
| 2026-10-11 | minor | Live boolean editing, Design panel arc fields, the shape builder on strokes and open paths and one-sided variable widths landed (gap 13): Shapes & vector breadth 92% → 97%, ready 66% → 68%, 8–14 h → 1–3 h; feature breadth ~78% → ~79%; ready for real work unchanged (~47%); full parity ~390–745 h → ~385–730 h |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# FigCraft parity with Figma
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%; ready ~47%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%; ready ~47%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS)
|
||||
|
||||
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
|
||||
|
|
@ -15,7 +15,7 @@ summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu c
|
|||
|
||||
| Number | Value | Kind |
|
||||
|---|---|---|
|
||||
| Menu/command breadth | **98.5%** (200 / 203 catalogued menu items have a live command) | **measured** (`cargo xtask parity`, re-checked from source on 2026-10-10) |
|
||||
| Menu/command breadth | **100%** (203 / 203 catalogued menu items have a live command) | **measured** (`cargo xtask parity`, 2026-10-11) |
|
||||
| Feature breadth (does each Figma Design feature exist, weighted) | **~79%** (core editor areas 1–16: ~90%) | estimated from the area table below |
|
||||
| Ready for real work | **~47%** | estimated (dimension table below: written weights × written per-dimension scores) |
|
||||
| Essentials user | **~53%** | estimated ([below](#essentials-user): core features 77.8% × discounts 0.92 × 0.92 × 0.8) |
|
||||
|
|
@ -65,7 +65,7 @@ multiplicative discounts, as the standard says.
|
|||
File, Edit, View, Object, Text, Arrange, Vector, Plugins, Preferences, Libraries, Help), written
|
||||
from public documentation and black-box observation (local notes in the gitignored
|
||||
`plan/figma/03-menu-tree.md`). It leaves out the Widgets menu and dynamic plugin lists, so the
|
||||
98.5% overstates breadth against the full menu a little.
|
||||
100% overstates breadth against the full menu a little.
|
||||
- **From source** (no build; 2026-10-10): 449 command ids registered with `spec!`, 963 `#[test]`
|
||||
functions in 177 files, ~108,800 lines of Rust in 13 crates + 3 apps + xtask (before #101–#103, which added the `plugin` and `collab` crates and ~7k lines), 140 key bindings
|
||||
and 13 gesture rows in `figcraft_engine::keymap`, 21 files in the SVG import corpus, 0
|
||||
|
|
@ -94,7 +94,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
|
|||
|
||||
| # | Area | Weight | Breadth | Ready | Remaining | Hours |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 | Canvas & navigation | 6 | 95% | 72% | Outline mode variants (`view.outlinesSelection`, `view.maskOutlines`), Display P3 on screen, QA at extreme zooms | 3–6 |
|
||||
| 1 | Canvas & navigation | 6 | 97% | 72% | Display P3 on screen, QA at extreme zooms (outlines for the selection and mask outlines landed) | 2–5 |
|
||||
| 2 | Selection & transform | 8 | 95% | 68% | Handles, measuring and snapping checked tool by tool against Figma; rotated groups and mixed selections | 6–10 |
|
||||
| 3 | Layers panel | 5 | 95% | 78% | Keyboard box selection, keyboard rename/context menu, long-list QA | 2–4 |
|
||||
| 4 | Shapes & vector | 8 | 97% | 68% | Boolean groups hit-tested by their operands rather than their result; vector QA with real files (live boolean editing, arc fields, shape builder on strokes and open paths and one-sided widths landed) | 1–3 |
|
||||
|
|
@ -109,7 +109,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
|
|||
| 13 | Prototyping | 6 | 88% | 70% | Video triggers and media actions, gamepad triggers, real prototype QA ([prototyping-parity](prototyping-parity.md)) | 5–9 |
|
||||
| 14 | Import/export | 6 | 65% | 35% | **`.fig` import** (the blocking gap), `.fig` export, HEIC/TIFF/video import, CMYK output intent ([file-format-parity](file-format-parity.md)) | 30–60 |
|
||||
| 15 | Dev Mode / inspect | 2 | 80% | 62% | Code Connect, plugins in Dev Mode, editor integrations ([dev-mode-parity](dev-mode-parity.md)) | 4–8 |
|
||||
| 16 | Comments, versions, find, misc | 3 | 80% | 58% | Comments are local only; branching; Open recent | 4–8 |
|
||||
| 16 | Comments, versions, find, misc | 3 | 80% | 58% | Comments are local only; branching | 4–8 |
|
||||
| 17 | Multiplayer & sharing | 8 | 15% | 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, per-person undo, offline resume; a self-hosted `figcraft-server` with persistence and share links to edit or view. Left: accounts and teams, cursor chat, TLS in the desktop app ([collaboration-parity](collaboration-parity.md)) | 45–90 |
|
||||
| 18 | Plugins & widgets | 4 | 35% | 20% | Own sandboxed WASM plugins landed (#101: manifest, permissions, menu, Run last, Manage, SDK, 2 examples). Left: plugin UI at run time, settings storage, distribution, widgets; Figma's plugins are JavaScript and don't run (a JS engine would be an owner decision) ([plugins.md](plugins.md)) | 25–55 |
|
||||
| 19 | Team libraries & organization | 2 | 40% | 25% | Local library files exist; no shared/cloud library, no library analytics | 10–20 |
|
||||
|
|
@ -148,7 +148,7 @@ graphic or a simple mockup. Same method, with the core features a casual user to
|
|||
|
||||
| Feature | Weight | Depth | Notes |
|
||||
|---|---|---|---|
|
||||
| Create, open, save and reopen a file; pages | 10 | 85% | home screen, `.figcraft` round trips; Open recent missing |
|
||||
| Create, open, save and reopen a file; pages | 10 | 85% | home screen, `.figcraft` round trips, Open recent |
|
||||
| Pan and zoom | 10 | 80% | wheel, pinch, Space-drag, zoom shortcuts |
|
||||
| Select, move, resize, rotate, align, group, duplicate | 15 | 75% | not yet compared tool by tool |
|
||||
| Frames with device presets | 8 | 80% | |
|
||||
|
|
@ -577,6 +577,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | The last three catalogued menu items got commands (gap 21): File ▸ Open recent (recent files kept in the preferences, at most 20, a file that's gone greyed in the menu and dropped when picked, Clear recent), View ▸ Outlines ▸ Show outlines for selection and View ▸ Mask outlines: menu breadth 98.5% → 100% (203/203); Canvas & navigation breadth 95% → 97%, 3–6 h → 2–5 h; feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
|
||||
| 2026-10-11 | minor | Extended collections, variables import/export and gradient stop and variant property bindings landed (gap 14): Styles & variables breadth 85% → 92%, ready 70% → 72%, 4–8 h → 1–2 h; feature breadth unchanged (~79%); ready for real work unchanged (~47%); full parity ~385–730 h → ~380–725 h |
|
||||
| 2026-10-11 | minor | Collaboration wording after the standalone server: `figcraft-server` keeps many files, persisted as a snapshot plus a log of what changed (compacted, checksummed, damaged files skipped and reported), restarts lose nothing confirmed and apps resume where they were; the app publishes its file to it; share links per file let people edit or only view (the server refuses a viewer's edits), are revocable and stored as hashes; TLS comes from a reverse proxy (the desktop app has none of its own yet); percentages and hours unchanged until the next re-measure |
|
||||
| 2026-10-11 | minor | Live boolean editing, Design panel arc fields, the shape builder on strokes and open paths and one-sided variable widths landed (gap 13): Shapes & vector breadth 92% → 97%, ready 66% → 68%, 8–14 h → 1–3 h; feature breadth ~78% → ~79%; ready for real work unchanged (~47%); full parity ~390–745 h → ~385–730 h |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7)
|
||||
|
||||
Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys,
|
||||
drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status:
|
||||
|
|
@ -70,7 +70,7 @@ interaction feel not yet compared.
|
|||
| Pan/zoom: wheel, pinch, Space-drag, Shift+0/1/2, Cmd+0, `+`/`-` | done | |
|
||||
| Rulers and guides (drag out, move, remove; rotated frames; frame guides) | done | |
|
||||
| Frame labels (rotated, right-to-left), size badge | done | |
|
||||
| Outline mode | partial | "Outlines" done; outline selection and mask outlines missing |
|
||||
| Outline mode | done | Outlines, outlines for the selection, mask outlines |
|
||||
| Layout grids (Ctrl+G) | done | |
|
||||
| Gradient and padding/gap handles on the canvas | done | |
|
||||
| Hover outline, selection colours, handle sizes | done | measured from UI3 screenshots |
|
||||
|
|
@ -91,5 +91,6 @@ interaction feel not yet compared.
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Outline mode done: outlines for the selection (the selected layers draw as outlines, the rest normally) and mask outlines |
|
||||
| 2026-10-11 | minor | Design panel arc fields, live boolean editing (operands on the canvas, live results, per-operand operations), shape builder on strokes and open paths, one-sided widths (Alt-drag a width handle) |
|
||||
| 2026-10-10 | major | Created from the ROADMAP's look-and-feel and interaction items, the keymap and the canvas code |
|
||||
|
|
|
|||
Loading…
Reference in New Issue