Merge main into #1209

This commit is contained in:
bflatastic
2026-10-26 09:19:57 +07:00
33 changed files with 1514 additions and 296 deletions
+3 -2
View File
@@ -6,8 +6,9 @@ CRAFT_FONTS_DIR = { value = "../craft-fonts", relative = true }
# The Windows UI Automation adapter can be asked about a node that disappeared in the
# same frame. On MinGW, converting that HRESULT through windows-result's thread COM
# error-info path has crashed in GetErrorInfo. Keep the HRESULT without that extra
# COM lookup; this leaves the AccessKit tree and screen-reader support enabled.
[target.x86_64-pc-windows-gnu]
# COM lookup; this leaves the AccessKit tree and screen-reader support enabled. Every Windows GNU
# target (x86_64, i686, the gnullvm ones) gets it, as the check in apps/figcraft/src/main.rs asks.
[target.'cfg(all(windows, target_env = "gnu"))']
rustflags = ["--cfg=windows_slim_errors"]
[alias]
Generated
+11 -11
View File
@@ -253,7 +253,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"parking_lot",
"percent-encoding",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
"wl-clipboard-rs",
"x11rb",
]
@@ -907,7 +907,7 @@ dependencies = [
[[package]]
name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184"
[[package]]
name = "craft-update"
@@ -937,7 +937,7 @@ dependencies = [
[[package]]
name = "craft-window"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=25da1314d443171b4adeaad2cd9454b273f35c6f#25da1314d443171b4adeaad2cd9454b273f35c6f"
source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184"
dependencies = [
"wayland-client",
]
@@ -1437,7 +1437,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -2666,7 +2666,7 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "krilla"
version = "0.8.2"
source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184"
dependencies = [
"base64 0.22.1",
"bumpalo",
@@ -3353,7 +3353,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -4025,7 +4025,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -4082,7 +4082,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -4633,10 +4633,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"getrandom 0.3.4",
"once_cell",
"rustix 1.1.5",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -5749,7 +5749,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
+5 -5
View File
@@ -32,14 +32,14 @@ figcraft-mcp = { path = "crates/mcp" }
figcraft-collab-net = { path = "crates/collab-net" }
figcraft-plugin = { path = "crates/plugin" }
# craft-libs crates all pin one rev (craftrules standards/shared-libraries.md): bump them together.
# Which paths stored in a document may be followed (craft-libs).
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" }
# Who draws the window frame on Linux — the compositor or the app — so every Craft app shows the
# same native titlebar per desktop (craft-libs). FigCraft only reads the probe result and maps it
# to its own `TitleBar`, so it does not enable craft-window's optional `egui` helpers (which would
# pull a second egui family version). The rev is the last merged one; the released 0.37 pin is
# supplied by the migration's reviewed craft-libs revision.
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f" }
# pull a second egui family version).
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] }
# foundation
@@ -154,4 +154,4 @@ winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457
# craft-libs' vendored krilla 0.8.2: decoded PNGs are capped at 1 GiB and reserved fallibly, and a
# panic while drawing unwinds without aborting.
krilla = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" }
krilla = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" }
+1 -5
View File
@@ -1,10 +1,5 @@
# FigCraft Roadmap
M9 / M7.4 server memory follow-up: read-only disconnects retain their durable client-id journal
without rebuilding the image-bearing snapshot. Snapshot encoding appends into its header buffer,
avoiding a second full payload copy while preserving the existing disk format. See
[`docs/server-memory-retention.md`](docs/server-memory-retention.md).
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
@@ -341,6 +336,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. |
| 2026-10-24 | minor | Server memory (#1134): when the last person leaves a file nobody edited, figcraft-server keeps the client ids it issued in the log instead of rewriting the snapshot (compaction still follows `compact_after`/`compact_mb` and edits), and a snapshot is encoded straight into its file buffer, without a second copy of the body; the file format is unchanged. Numbers unchanged |
| 2026-10-24 | minor | Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 97% → 98% breadth, 76% → 78% ready, 2–4 h → 1–2 h; headline numbers unchanged at this rounding |
| 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Boards row added outside the weighted numbers; headline numbers unchanged at this rounding |
| 2026-10-15 | minor | Auto-save (#305, #296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only, the browser has no files to save to) saves each open file that has a path 3 s after its last change, so steady editing doesn't save every few seconds, and never during a gesture, a live text preview or while a version is shown. The file is encoded on a thread of its own and written as of that moment (edits made meanwhile stay unsaved); a failed save says why in a toast and is tried again a minute later, and version history checkpoints keep coming with it on. Numbers unchanged |
+1 -1
View File
@@ -135,7 +135,7 @@ pub fn encode_snapshot(generation: u64, state: &SessionState) -> Result<Vec<u8>,
out.extend_from_slice(&0u32.to_le_bytes());
let mut out = message::encode_into(state, out).map_err(|e| e.to_string())?;
let sum = crc(&out[SNAP_HEADER..]);
out[13..SNAP_HEADER].copy_from_slice(&sum.to_le_bytes());
out[SNAP_HEADER - 4..SNAP_HEADER].copy_from_slice(&sum.to_le_bytes());
Ok(out)
}
+3 -4
View File
@@ -129,12 +129,11 @@ fn two_files_are_hosted_and_joined_independently() {
// Each joined file opens in front when its welcome arrives, and the two joins race: the file
// whose welcome came last is in front.
assert_eq!(guest.ds().unwrap().uid, guest.docs[2].uid, "the file that arrived last is in front");
guest.execute("doc.switch", &json!({"index": guest.docs.iter().position(|d| d.uid == gb).unwrap()})).unwrap();
// An edit in A reaches the guest's copy of A only, while B is in front at the guest.
// Concurrent joins may complete in either order. Put B in front explicitly so the
// following assertions exercise A's background session independent of network timing.
// Concurrent joins may complete in either order. Put B in front explicitly so the following
// assertions exercise A's background session independent of network timing.
guest.execute("doc.switch", &json!({"index": guest.docs.iter().position(|d| d.uid == gb).unwrap()})).unwrap();
assert_eq!(guest.ds().unwrap().uid, gb, "B is in front while A receives edits");
// An edit in A reaches the guest's copy of A only, while B is in front at the guest.
let in_a = rect(&mut host);
host.execute("doc.switch", &json!({"index": 1})).unwrap();
let in_b = rect(&mut host);
+6
View File
@@ -495,9 +495,15 @@ pub fn scale_node(doc: &mut Document, id: NodeId, k: f64) -> Result<()> {
t.paragraph_spacing *= k;
t.paragraph_indent *= k;
t.list_spacing *= k;
if let Some(tp) = &mut t.path {
tp.scale(k, k);
}
}
if let NodeKind::Vector { network } = &mut n.kind {
network.scale(k, k);
for v in &mut network.vertices {
v.radius *= k;
}
}
n.width *= k;
n.height *= k;
+18 -8
View File
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"variable.export",
"Export variables",
"{collections? [ids or names; default every collection of this file, not library copies], format? (json (default): the local variables JSON that variable.import reads | css: custom properties, the default mode on :root and each other mode on [data-<collection>=\"<mode>\"]; numbers in px unless layers or scopes use them as opacity or font weight), path? (write it there)} → {text}, or {path} when written",
"{collections? [ids or names; default every collection of this file, not library copies], format? (json (default): the local variables JSON that variable.import reads | css: custom properties, the default mode on :root and each other mode on [data-<collection>=\"<mode>\"]; numbers in px unless layers or scopes use them as opacity or font weight | tailwind: a Tailwind CSS v4 stylesheet, the default mode as an @theme block named the way inspect.code's tailwind utilities read them: --color-*, --radius-*, --text-*, --spacing-*, --font-*, unique per file), path? (write it there)} → {text}, or {path} when written",
false,
export
),
@@ -46,8 +46,9 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
let text = match string(p, "format").as_deref().unwrap_or("json") {
"json" => serde_json::to_string_pretty(&figcraft_format::rest::export_variables(&doc.variables, &colls))
.map_err(|e| EngineError::Other(e.to_string()))?,
"css" => css(doc, &colls),
_ => return Err(bad(cmd, "format: json|css")),
"css" => css(doc, &colls, false),
"tailwind" => css(doc, &colls, true),
_ => return Err(bad(cmd, "format: json|css|tailwind")),
};
match string(p, "path") {
Some(path) => {
@@ -66,16 +67,25 @@ fn css_safe(text: &str) -> String {
/// Each collection's variables as custom properties (named as generated code names them): the
/// default mode's values on `:root`, every other mode's on `[data-<collection>="<mode>"]`;
/// aliases are `var(--name)`.
fn css(doc: &Document, colls: &[&Collection]) -> String {
fn css(doc: &Document, colls: &[&Collection], tailwind: bool) -> String {
let vars = &doc.variables;
// Tailwind theme names, unique per file and the same ones page code reads.
let theme = crate::tailwind::Theme::new(doc);
let name = |v: &Variable| if tailwind { theme.name(v) } else { css_var_of(v) };
// Lengths need a unit where CSS reads them (`border-radius: var(--radius)`); opacity and
// font weight don't.
let roles = crate::token_roles::Roles::new(doc);
let mut out = String::new();
let mut out = if tailwind { "@import \"tailwindcss\";\n\n".to_string() } else { String::new() };
for c in colls {
let attr = css_var_name(&c.name);
for (i, m) in c.modes.iter().enumerate() {
let sel = if i == 0 { ":root".to_string() } else { format!("[data-{}=\"{}\"]", attr.trim_start_matches('-'), css_safe(&m.name)) };
let sel = match i {
// `static`: Tailwind otherwise drops theme variables no utility uses, and pages also
// read them through `var()` and `(--name)` utilities.
0 if tailwind => "@theme static".to_string(),
0 => ":root".to_string(),
_ => format!("[data-{}=\"{}\"]", attr.trim_start_matches('-'), css_safe(&m.name)),
};
out.push_str(&format!("/* {} · {} */\n{sel} {{\n", css_safe(&c.name), css_safe(&m.name)));
for v in vars.variables.iter().filter(|v| v.collection == c.id) {
let value = match v.values.get(&m.id) {
@@ -85,12 +95,12 @@ fn css(doc: &Document, colls: &[&Collection]) -> String {
Some(VariableValue::String(t)) => format!("\"{}\"", css_safe(t)),
Some(VariableValue::Boolean(b)) => b.to_string(),
Some(VariableValue::Alias(a)) => match vars.get(a) {
Some(t) => format!("var({})", css_var_of(t)),
Some(t) => format!("var({})", name(t)),
None => continue,
},
None => continue,
};
out.push_str(&format!(" {}: {value};\n", css_var_of(v)));
out.push_str(&format!(" {}: {value};\n", name(v)));
}
out.push_str("}\n");
}
+75 -13
View File
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
vec![spec!(
"inspect.code",
"Code for layer",
"{id?, language?: css|html|react|leptos|swiftui|compose (default css; html, react and leptos are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead), unit?: px|rem (CSS, HTML, React and Leptos; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}",
"{id?, language?: css|html|react|leptos|tailwind|swiftui|compose (default css; html, react and leptos are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead; tailwind is React with Tailwind CSS v4 utilities for pages: fluid auto layout boxes, instances inlined with their overrides, bound variables as theme utilities), mobile? (tailwind: a mobile frame with the same layer names; the code is mobile first with the desktop layer's classes behind lg:), unit?: px|rem (CSS, HTML, React and Leptos; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}",
false,
|s, p| {
let cmd = "inspect.code";
@@ -40,7 +40,14 @@ pub fn specs() -> Vec<CommandSpec> {
return Err(bad(cmd, "no such layer"));
}
let connected = crate::code_connect::link(doc, id, lang).is_some();
let code = code_for(doc, id, lang);
let mobile = match get(p, "mobile") {
None | Some(serde_json::Value::Null) => None,
Some(v) => Some(id_of(v).filter(|m| doc.contains(*m)).ok_or_else(|| bad(cmd, "mobile: the id of a mobile frame"))?),
};
let code = match (lang, mobile) {
(CodeLang::Tailwind, Some(m)) => crate::tailwind::code(doc, id, Some(m)),
_ => code_for(doc, id, lang),
};
let code = match rem {
Some(root) if lang.is_web() => px_to_rem(&code, root),
_ => code,
@@ -61,18 +68,22 @@ pub enum CodeLang {
React,
/// A Leptos component (`view!` markup) with its stylesheet.
Leptos,
/// A React function component styled with Tailwind CSS v4 utilities (no stylesheet).
Tailwind,
SwiftUi,
Compose,
}
impl CodeLang {
pub const ALL: [CodeLang; 6] = [CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::Leptos, CodeLang::SwiftUi, CodeLang::Compose];
pub const ALL: [CodeLang; 7] =
[CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::Leptos, CodeLang::Tailwind, CodeLang::SwiftUi, CodeLang::Compose];
pub fn id(self) -> &'static str {
match self {
CodeLang::Css => "css",
CodeLang::Html => "html",
CodeLang::React => "react",
CodeLang::Leptos => "leptos",
CodeLang::Tailwind => "tailwind",
CodeLang::SwiftUi => "swiftui",
CodeLang::Compose => "compose",
}
@@ -83,11 +94,13 @@ impl CodeLang {
CodeLang::Html => "HTML + CSS",
CodeLang::React => "React",
CodeLang::Leptos => "Leptos",
CodeLang::Tailwind => "React + Tailwind",
CodeLang::SwiftUi => "SwiftUI",
CodeLang::Compose => "Compose",
}
}
/// Languages whose lengths are CSS pixels (they can be shown in rem).
/// Languages whose lengths are CSS pixels that can be shown in rem (not Tailwind: its lengths
/// are utility classes).
pub fn is_web(self) -> bool {
matches!(self, CodeLang::Css | CodeLang::Html | CodeLang::React | CodeLang::Leptos)
}
@@ -199,6 +212,7 @@ pub fn code_for(doc: &Document, id: NodeId, lang: CodeLang) -> String {
match lang {
CodeLang::Css => css(doc, id),
CodeLang::Html => html(doc, id),
CodeLang::Tailwind => crate::tailwind::code(doc, id, None),
CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose => crate::component_codegen::code(doc, id, lang),
}
}
@@ -321,25 +335,32 @@ pub(crate) fn indent(depth: usize) -> String {
/// The CSS custom properties generated code uses: name → fallback value, in first-use order.
#[derive(Default)]
pub(crate) struct Tokens(pub Vec<(String, String)>);
pub(crate) struct Tokens {
vars: Vec<(String, String)>,
/// Name variables as Tailwind theme variables (`--color-accent`), not by their CSS name.
pub tailwind: bool,
/// The file's theme names (computed on first use).
theme: Option<crate::tailwind::Theme>,
}
impl Tokens {
/// `var(--name, value)`, remembering the variable.
fn var(&mut self, variable: &Variable, value: String) -> String {
let name = css_var_of(variable);
fn var(&mut self, doc: &Document, variable: &Variable, value: String) -> String {
let name =
if self.tailwind { self.theme.get_or_insert_with(|| crate::tailwind::Theme::new(doc)).name(variable) } else { css_var_of(variable) };
let out = format!("var({name}, {value})");
if !self.0.iter().any(|(n, _)| *n == name) {
self.0.push((name, value));
if !self.vars.iter().any(|(n, _)| *n == name) {
self.vars.push((name, value));
}
out
}
/// A `:root` rule declaring the variables (empty without any).
pub fn root_rule(&self) -> String {
if self.0.is_empty() {
if self.vars.is_empty() {
return String::new();
}
let decls: String = self.0.iter().map(|(n, v)| format!(" {n}: {v};\n")).collect();
let decls: String = self.vars.iter().map(|(n, v)| format!(" {n}: {v};\n")).collect();
format!(":root {{\n{decls}}}\n")
}
}
@@ -370,7 +391,7 @@ pub fn css_var_of(v: &Variable) -> String {
/// `value`, or a reference to the variable bound to the first of `fields` that has one.
fn tok(doc: &Document, n: &Node, fields: &[&str], value: String, t: &mut Tokens) -> String {
match fields.iter().find_map(|f| crate::devinspect::bound_variable(doc, n, f)) {
Some(v) => t.var(v, value),
Some(v) => t.var(doc, v, value),
None => value,
}
}
@@ -664,6 +685,14 @@ pub(crate) struct Markup<'c, 'a> {
/// Each layer's path in the tree (names with sibling counts), class and declarations, so
/// variants can be compared layer by layer.
pub rules: Vec<(String, String, String)>,
/// Instances as their own (overridden) layers instead of component calls, except those
/// connected to code (page code).
pub inline: bool,
/// Auto layout frames and text as fluid boxes (`width: 100%` up to their width) for pages.
pub fluid: bool,
/// With `fluid`, other layers at least this share of the root's width are fluid too (a
/// divider, a banner image); smaller ones (icons) keep their size. Default: none.
pub fluid_leaves: f64,
}
impl<'c, 'a> Markup<'c, 'a> {
@@ -678,6 +707,9 @@ impl<'c, 'a> Markup<'c, 'a> {
root_class: None,
root_attrs: String::new(),
rules: Vec::new(),
inline: false,
fluid: false,
fluid_leaves: f64::INFINITY,
}
}
@@ -759,7 +791,10 @@ impl<'c, 'a> Markup<'c, 'a> {
}
}
// Nested instances are calls (component code), in a wrapper that places them when needed.
if self.code() && !root && matches!(n.kind, NodeKind::Instance { .. }) {
// Page code inlines instances, except those connected to code (codeConnect.set): their
// snippet, e.g. a design-system `<Button>`.
let connected = self.inline && crate::code_connect::link(doc, n.id, CodeLang::React).is_some();
if self.code() && (!self.inline || connected) && !root && matches!(n.kind, NodeKind::Instance { .. }) {
let at = depth + usize::from(!decls.is_empty());
let run = if self.lang == CodeLang::Leptos { ".run()" } else { "" };
let inner = match self.ctx.param(n, "mainComponent") {
@@ -779,6 +814,11 @@ impl<'c, 'a> Markup<'c, 'a> {
let hugs = n.layout.is_some() || matches!(n.kind, NodeKind::Text(_));
let hug = (hugs && n.child_layout.sizing_h == Sizing::Hug, hugs && n.child_layout.sizing_v == Sizing::Hug);
let mut own = css_decls(doc, n, &mut self.tokens, hug);
let page = self.ctx.doc.node(self.ctx.root).map_or(f64::INFINITY, |r| r.width);
if self.fluid && (n.layout.is_some() || matches!(n.kind, NodeKind::Text(_)) || (!root && n.width >= page * self.fluid_leaves)) {
// Full width, or sized by the flex row (`flex: 1`): no cap needed.
own = fluid(&own, n.width >= page - 0.5 || decls.contains("flex: 1 1 0;"), n.layout.is_some());
}
if n.layout.is_none() && !n.children.is_empty() && !root && in_flow {
own.push_str("position: relative;\n");
}
@@ -845,6 +885,28 @@ impl<'c, 'a> Markup<'c, 'a> {
}
}
/// `decls` for a box that follows the page: a fixed width becomes `width: 100%` capped at that
/// width (or just the full width for boxes as wide as the page), and a frame's fixed height a
/// minimum, so content can wrap and grow.
// ponytail: width-based heuristic; fixed-size leaves (icons, images, shapes) keep their pixels.
fn fluid(decls: &str, full: bool, frame: bool) -> String {
let mut out = String::with_capacity(decls.len() + 32);
for line in decls.lines() {
if let Some(w) = line.strip_prefix("width: ") {
out.push_str("width: 100%;\n");
if !full {
out.push_str(&format!("max-width: {w}\n"));
}
} else if let Some(h) = line.strip_prefix("height: ").filter(|_| frame) {
out.push_str(&format!("min-height: {h}\n"));
} else {
out.push_str(line);
out.push('\n');
}
}
out
}
// ------------------------------------------------------------------ SwiftUI
fn swift_color(c: Color) -> String {
+12
View File
@@ -203,6 +203,18 @@ fn text_on_a_path_left_empty_puts_the_path_back_without_an_undo_step() {
}
}
#[test]
fn scaling_text_scales_its_path() {
let mut s = Session::new();
let t = id(s.execute("text.create", &json!({"text": "Around", "x": 0, "y": 0})).unwrap());
let v = id(s.execute("vector.create", &json!({"path": "M0 0 C50 -50 150 -50 200 0", "x": 40, "y": 100})).unwrap());
s.execute("text.onPath", &json!({"ids": [t, v]})).unwrap();
let w0 = figcraft_geom::Shape::bounding_box(&text_path(&s, t).unwrap().path()).width();
s.execute("transform.scale", &json!({"ids": [t], "factor": 2.0})).unwrap();
let w1 = figcraft_geom::Shape::bounding_box(&text_path(&s, t).unwrap().path()).width();
assert!((w1 - w0 * 2.0).abs() < 1.0, "path width {w0} -> {w1}");
}
#[test]
fn svg_text_paths_import_as_text_on_a_path() {
// SVG text is parsed with Inter standing in for every family (CI has no fonts).
+3
View File
@@ -61,6 +61,7 @@ pub mod snap;
pub mod style_vars;
pub mod synthetic;
pub mod synthetic_ds;
pub mod tailwind;
pub mod templates;
pub mod textedit;
pub mod textfit;
@@ -1863,6 +1864,8 @@ mod styles_behaviour_tests;
#[cfg(test)]
mod svg_import_tests;
#[cfg(test)]
mod tailwind_tests;
#[cfg(test)]
mod templates_tests;
#[cfg(test)]
mod tests;
+14
View File
@@ -240,3 +240,17 @@ fn the_scale_tool_only_acts_on_handles() {
assert_rect(bounds(&s, r), [10.0, 10.0, 110.0, 60.0]);
assert!(near(s.doc().unwrap().node(r).unwrap().width, 100.0));
}
#[test]
fn scale_node_scales_vector_point_corner_radius() {
let mut s = Session::new();
let v = id_of(&ex(&mut s, "vector.create", json!({"points": [[0, 0], [100, 0], [100, 100], [0, 100]], "closed": true}))["id"]).unwrap();
ex(&mut s, "node.set", json!({"id": v, "props": {"cornerRadius": 10}}));
ex(&mut s, "transform.scale", json!({"ids": [v], "factor": 2}));
let d = s.doc().unwrap();
let NodeKind::Vector { network } = &d.node(v).unwrap().kind else { panic!("not a vector") };
assert!(!network.vertices.is_empty());
for vx in &network.vertices {
assert_eq!(vx.radius, 20.0, "corner radius scaled with the layer");
}
}
+519
View File
@@ -0,0 +1,519 @@
//! React + Tailwind CSS v4 for a layer tree, ready to drop into a web project: every layer's look
//! as utility classes instead of a stylesheet, bound variables as theme utilities (`bg-accent`,
//! `rounded-card`) that `variable.export {format: "tailwind"}` declares in an `@theme` block, auto
//! layout frames and text that grow with the page (`w-full max-w-[…]`) instead of fixed pixel
//! boxes, and instances inlined with their overrides. Given a mobile frame too, the code is mobile
//! first: the mobile frame's classes, and the desktop frame's where they differ behind `lg:`.
use std::collections::{BTreeMap, HashMap};
use figcraft_doc::{Document, NodeId, NodeKind, Variable, VariableId, VariableType};
use crate::codegen::{CodeLang, Ctx, Markup, css_var_name, css_var_of, num};
use crate::component_codegen::component_type_name;
use crate::token_roles::{NumberRole, Roles};
/// The breakpoint desktop classes sit behind in responsive code.
// ponytail: one breakpoint (lg = 1024px); a tablet frame would need a second pass and `md:`.
const BREAKPOINT: &str = "lg";
/// The roles and Tailwind theme names of a file's variables, computed once per export so code
/// and the `@theme` block always agree.
#[derive(Default)]
pub struct Theme {
roles: Roles,
names: HashMap<VariableId, String>,
}
impl Theme {
pub fn new(doc: &Document) -> Self {
let vars = &doc.variables.variables;
let roles = Roles::new(doc);
let mut t = Theme { roles, names: HashMap::new() };
// Short names ("accent") where they are unique, the full path ("palette-accent") where two
// variables would share one, and a numeric suffix as a last resort.
let short: Vec<String> = vars.iter().map(|v| t.candidate(v, false)).collect();
// Linear in the number of variables: counts for clashes, a set of names taken and the next
// suffix per base, so a file with very many variables can't make this quadratic.
let mut count: HashMap<&str, usize> = HashMap::new();
for n in &short {
*count.entry(n.as_str()).or_default() += 1;
}
let mut taken: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut next: HashMap<String, usize> = HashMap::new();
for (v, s) in vars.iter().zip(&short) {
let base = if count.get(s.as_str()).copied().unwrap_or(0) > 1 { t.candidate(v, true) } else { s.clone() };
let mut name = base.clone();
if taken.contains(&name) {
let k = next.entry(base.clone()).or_insert(1);
loop {
*k += 1;
name = format!("{base}-{k}");
if !taken.contains(&name) {
break;
}
}
}
taken.insert(name.clone());
t.names.insert(v.id.clone(), name);
}
t
}
/// What a number variable measures; a number nothing tells about is a length.
pub fn role(&self, v: &Variable) -> NumberRole {
self.roles.role(v)
}
/// The theme variable for `v`, unique in the file (see [`Theme::new`]).
pub fn name(&self, v: &Variable) -> String {
self.names.get(&v.id).cloned().unwrap_or_else(|| self.candidate(v, false))
}
/// `--color-*` for colours, `--radius-*`, `--text-*`, `--spacing-*` for numbers by role,
/// `--font-*` for font families, others their CSS name; from the last path segment, or the
/// whole path (`full`). A name already in the namespace isn't prefixed twice.
fn candidate(&self, v: &Variable, full: bool) -> String {
let ns = match v.kind {
VariableType::Color => Some("color"),
VariableType::Number => match self.role(v) {
NumberRole::Radius => Some("radius"),
NumberRole::FontSize => Some("text"),
NumberRole::Spacing => Some("spacing"),
NumberRole::Length | NumberRole::Unitless => None,
},
VariableType::String if v.scopes.as_ref().is_some_and(|s| s.iter().any(|s| s == "FONT_FAMILY")) => Some("font"),
_ => None,
};
let Some(ns) = ns else {
return if full { css_var_name(&v.name) } else { css_var_of(v) };
};
let source = if full { v.name.as_str() } else { v.name.rsplit('/').next().unwrap_or(&v.name) };
let slug = css_var_name(source);
let slug = slug.trim_start_matches('-');
let slug = [format!("{ns}s-"), format!("{ns}-")].iter().find_map(|p| slug.strip_prefix(p.as_str())).unwrap_or(slug);
// A variable named after its namespace ("radius", "colors") is the namespace's base value.
let slug = if slug == ns || slug == format!("{ns}s") { "base" } else { slug };
format!("--{ns}-{slug}")
}
}
// ------------------------------------------------------------------ declarations → classes
/// An arbitrary value: spaces become underscores (Tailwind's escape), underscores are escaped.
fn arb(v: &str) -> String {
format!("[{}]", v.trim().replace('_', "\\_").replace(' ', "_"))
}
/// The custom property a `var(--name, fallback)` value reads, when that one call is the whole
/// value (not `var(--a, 4px) 8px`, four corners of which only the first is a variable).
fn var_name(v: &str) -> Option<&str> {
let inner = v.trim().strip_prefix("var(")?;
let mut depth = 1usize;
let close = inner.char_indices().find_map(|(i, c)| {
match c {
'(' => depth += 1,
')' => depth -= 1,
_ => {}
}
(depth == 0).then_some(i)
})?;
if close + 1 != inner.len() {
return None;
}
Some(inner.split([',', ')']).next()?.trim())
}
/// `util` with `value`: a theme utility when the value reads a variable of the namespace
/// (`bg-accent`), else the variable in parentheses (`bg-(--x)`, with a type hint for `text-`
/// sizes), else an arbitrary value.
fn util(util: &str, ns: &str, value: &str, hint: &str) -> String {
match var_name(value) {
Some(name) => match name.strip_prefix(&format!("--{ns}-")) {
Some(key) if !ns.is_empty() => format!("{util}-{key}"),
_ => format!("{util}-({hint}{name})"),
},
None => format!("{util}-{}", arb(value)),
}
}
/// A length on the spacing scale (4px steps) when it fits one, else arbitrary.
fn spacing(prefix: &str, value: &str) -> String {
if var_name(value).is_some() {
return util(prefix, "spacing", value, "");
}
match value.strip_suffix("px").and_then(|n| n.parse::<f64>().ok()) {
Some(0.0) => format!("{prefix}-0"),
Some(px) if px > 0.0 && px <= 384.0 && (px / 4.0).fract() == 0.0 => format!("{prefix}-{}", num(px / 4.0)),
Some(px) if px < 0.0 && (px / 4.0).fract() == 0.0 => format!("-{prefix}-{}", num(-px / 4.0)),
_ => format!("{prefix}-{}", arb(value)),
}
}
/// The values of a `padding` shorthand (top right bottom left), as utilities.
fn padding(value: &str) -> Vec<(String, String)> {
let mut parts = Vec::new();
let mut depth = 0usize;
let mut cur = String::new();
for c in value.chars() {
match c {
'(' => depth += 1,
')' => depth = depth.saturating_sub(1),
' ' if depth == 0 => {
parts.push(std::mem::take(&mut cur));
continue;
}
_ => {}
}
cur.push(c);
}
parts.push(cur);
let [t, r, b, l] = match parts.as_slice() {
[a] => [a, a, a, a],
[t, r, b, l] => [t, r, b, l],
_ => return vec![("padding".into(), format!("p-{}", arb(value)))],
};
let zero = |v: &str| v == "0px" || v == "0";
let mut out = Vec::new();
if t == r && r == b && b == l {
if !zero(t) {
out.push(("padding".into(), spacing("p", t)));
}
} else if t == b && l == r {
for (k, p, v) in [("padding-y", "py", t), ("padding-x", "px", l)] {
if !zero(v) {
out.push((k.into(), spacing(p, v)));
}
}
} else {
for (k, p, v) in [("padding-top", "pt", t), ("padding-right", "pr", r), ("padding-bottom", "pb", b), ("padding-left", "pl", l)] {
if !zero(v) {
out.push((k.into(), spacing(p, v)));
}
}
}
out
}
fn font_weight(v: &str) -> String {
let named = match v.trim() {
"100" => "thin",
"200" => "extralight",
"300" => "light",
"400" => "normal",
"500" => "medium",
"600" => "semibold",
"700" => "bold",
"800" => "extrabold",
"900" => "black",
_ => return format!("font-{}", arb(v)),
};
format!("font-{named}")
}
/// A border shorthand (`1px solid c`) on `side` ("" or "t", "r", "b", "l") as width + colour.
fn border(side: &str, value: &str) -> Vec<(String, String)> {
// `<width> solid <colour>`, where the width may be `var(--w, 2px)`: split at the style.
let (width, color) = value.split_once(" solid ").unwrap_or((value, ""));
let (width, color) = (width.trim(), color.trim());
let b = if side.is_empty() { "border".to_string() } else { format!("border-{side}") };
let key = format!("border{}", if side.is_empty() { String::new() } else { format!("-{side}") });
let w = match width {
"1px" => b.clone(),
"0px" => format!("{b}-0"),
w => match var_name(w) {
// `border-(--w)` would read as a colour; the hint makes it a width.
Some(name) => format!("{b}-(length:{name})"),
None => format!("{b}-{}", arb(w)),
},
};
let mut out = vec![(format!("{key}-width"), w)];
if !color.is_empty() {
out.push(("border-color".into(), util("border", "color", color, "")));
}
out
}
/// One CSS declaration as utilities, keyed by the property they set (so responsive code can tell
/// which mobile class a desktop class replaces).
fn decl(key: &str, value: &str) -> Vec<(String, String)> {
let one = |c: String| vec![(key.to_string(), c)];
let kw = |c: &str| vec![(key.to_string(), c.to_string())];
match (key, value) {
("display", "flex") => kw("flex"),
("display", "block") => kw("block"),
("flex-direction", "column") => kw("flex-col"),
("flex-direction", _) => kw("flex-row"),
("flex-wrap", "wrap") => kw("flex-wrap"),
// Defaults flexbox already has.
("justify-content", "flex-start") | ("gap", "0px") => Vec::new(),
("justify-content", v) => one(format!("justify-{}", v.trim_start_matches("flex-").trim_start_matches("space-"))),
("align-items", v) => one(format!("items-{}", v.trim_start_matches("flex-"))),
("align-content", v) => one(format!("content-{}", v.trim_start_matches("flex-").trim_start_matches("space-"))),
("align-self", "stretch") => kw("self-stretch"),
("flex", "1 1 0") => kw("flex-1"),
("position", v) => one(v.to_string()),
("left" | "top" | "right" | "bottom", v) => one(spacing(key, v)),
("width", "100%") => kw("w-full"),
("width", v) => one(spacing("w", v)),
("height", v) => one(spacing("h", v)),
("max-width", v) => one(format!("max-w-{}", arb(v))),
("min-height", v) => one(format!("min-h-{}", arb(v))),
("margin-inline", "auto") => kw("mx-auto"),
("gap", v) => one(spacing("gap", v)),
("padding", v) => padding(v),
("border-radius", "50%") => kw("rounded-full"),
("border-radius", v) if v.strip_suffix("px").and_then(|n| n.parse::<f64>().ok()).is_some_and(|r| r >= 999.0) => kw("rounded-full"),
("border-radius", v) if v.contains(' ') && var_name(v).is_none() => one(format!("rounded-{}", arb(v))),
("border-radius", v) => one(util("rounded", "radius", v, "")),
("opacity", v) => match v.parse::<f64>() {
Ok(o) if ((o * 100.0).round() - o * 100.0).abs() < 1e-9 => one(format!("opacity-{}", num(o * 100.0))),
_ => one(util("opacity", "", v, "")),
},
("background", v) if v.contains("gradient(") => one(format!("bg-{}", arb(v))),
// An image shorthand (`url(…) center / cover`) is a whole `background`, not a bg colour.
("background", v) if v.starts_with("url(") => one(format!("[background:{}]", arb(v).trim_matches(['[', ']']))),
("background", v) => one(util("bg", "color", v, "color:")),
("color", v) => one(util("text", "color", v, "color:")),
("border", v) => border("", v),
("border-top", v) => border("t", v),
("border-right", v) => border("r", v),
("border-bottom", v) => border("b", v),
("border-left", v) => border("l", v),
("box-shadow", v) => one(format!("shadow-{}", arb(v))),
("filter", v) => one(match v.strip_prefix("blur(").and_then(|r| r.strip_suffix(')')) {
Some(r) => format!("blur-{}", arb(r)),
None => format!("[filter:{}]", arb(v).trim_matches(['[', ']'])),
}),
("backdrop-filter", v) => one(match v.strip_prefix("blur(").and_then(|r| r.strip_suffix(')')) {
Some(r) => format!("backdrop-blur-{}", arb(r)),
None => format!("[backdrop-filter:{}]", arb(v).trim_matches(['[', ']'])),
}),
("mix-blend-mode", v) => one(format!("mix-blend-{v}")),
("transform", v) => match v.strip_prefix("rotate(").and_then(|r| r.strip_suffix(')')) {
Some(r) => one(format!("rotate-{}", arb(r))),
None => one(format!("[transform:{}]", arb(v).trim_matches(['[', ']']))),
},
("transform-origin", "top left" | "0 0") => kw("origin-top-left"),
// `font-(--x)` would read as a weight; the hint makes an un-namespaced variable a family.
("font-family", v) if var_name(v).is_some() => one(util("font", "font", v, "family-name:")),
("font-family", v) => one(format!("font-{}", arb(&format!("'{}'", v.trim_matches('"'))))),
("font-size", v) => one(util("text", "text", v, "length:")),
("font-weight", v) => one(font_weight(v)),
("font-style", "italic") => kw("italic"),
("line-height", v) => match v.strip_suffix('%').and_then(|p| p.parse::<f64>().ok()) {
Some(p) => one(format!("leading-{}", arb(&num(p / 100.0)))),
None => one(util("leading", "", v, "")),
},
("letter-spacing", v) => one(util("tracking", "", v, "")),
("text-align", v) => one(format!("text-{v}")),
("text-transform", "uppercase") => kw("uppercase"),
("text-transform", "lowercase") => kw("lowercase"),
("text-transform", "capitalize") => kw("capitalize"),
("text-decoration", "underline") => kw("underline"),
("text-decoration", "line-through") => kw("line-through"),
_ => one(format!("[{key}:{}]", arb(value).trim_matches(['[', ']']))),
}
}
/// A rule's declarations (`key: value;` lines) as (property, utility) pairs in order.
pub fn classes(decls: &str) -> Vec<(String, String)> {
decls
.lines()
.filter_map(|l| {
let (k, v) = l.trim().trim_end_matches(';').split_once(':')?;
Some(decl(k.trim(), v.trim()))
})
.flatten()
.collect()
}
/// The utility that undoes a mobile class at the breakpoint when desktop sets nothing for it.
fn reset(key: &str) -> Option<String> {
// Border widths by side: `border-t-width` → `border-t-0`.
if let Some(side) = key.strip_prefix("border").and_then(|k| k.strip_suffix("-width")) {
return Some(format!("border{side}-0"));
}
Some(String::from(match key {
"display" => "block",
"flex-direction" => "flex-row",
"flex-wrap" => "flex-nowrap",
"flex" => "flex-initial",
"align-self" => "self-auto",
"width" => "w-auto",
"height" => "h-auto",
"max-width" => "max-w-none",
"min-height" => "min-h-0",
"text-align" => "text-left",
"justify-content" => "justify-start",
"gap" => "gap-0",
"position" => "static",
"text-transform" => "normal-case",
"opacity" => "opacity-100",
"border-radius" => "rounded-none",
"box-shadow" => "shadow-none",
"font-style" => "not-italic",
// The whole background: a mobile image or gradient would survive a colour-only reset.
"background" => "bg-none bg-transparent",
"border-color" => "border-current",
"letter-spacing" => "tracking-normal",
"line-height" => "leading-normal",
"text-decoration" => "no-underline",
"align-items" => "items-stretch",
"filter" => "filter-none",
"backdrop-filter" => "backdrop-filter-none",
"transform" => "rotate-0",
"left" | "top" | "right" | "bottom" => return Some(format!("{key}-auto")),
"mix-blend-mode" => "mix-blend-normal",
_ => return None,
}))
}
/// The sides (top, right, bottom, left) a padding utility's property sets.
fn padding_sides(key: &str) -> [bool; 4] {
match key {
"padding" => [true; 4],
"padding-x" => [false, true, false, true],
"padding-y" => [true, false, true, false],
"padding-top" => [true, false, false, false],
"padding-right" => [false, true, false, false],
"padding-bottom" => [false, false, true, false],
"padding-left" => [false, false, false, true],
_ => [false; 4],
}
}
/// Utilities zeroing `sides` (as few as cover them: `py-0`, `px-0`, else per side).
fn zero_padding(sides: [bool; 4]) -> Vec<&'static str> {
let [t, r, b, l] = sides;
let mut out = Vec::new();
match (t, b) {
(true, true) => out.push("py-0"),
(true, false) => out.push("pt-0"),
(false, true) => out.push("pb-0"),
_ => {}
}
match (r, l) {
(true, true) => out.push("px-0"),
(true, false) => out.push("pr-0"),
(false, true) => out.push("pl-0"),
_ => {}
}
out
}
/// Mobile classes plus, behind the breakpoint, the desktop ones that differ (and resets for what
/// desktop leaves unset). A layer missing on mobile is hidden there.
pub(crate) fn merge(mobile: Option<&[(String, String)]>, desktop: &[(String, String)]) -> Vec<String> {
let hidden = [("display".to_string(), "hidden".to_string())];
// A mobile frame's widths are the phone's, not caps: below the breakpoint boxes fill the page.
let fluid: Vec<(String, String)>;
let mobile = match mobile {
Some(m) => {
fluid = m.iter().filter(|(k, _)| k != "max-width").cloned().collect();
fluid.as_slice()
}
None => &hidden,
};
let mut out: Vec<String> = mobile.iter().map(|(_, c)| c.clone()).collect();
let bp = |c: &str| format!("{BREAKPOINT}:{c}");
for (_, c) in desktop {
if !mobile.iter().any(|(_, m)| m == c) {
out.push(bp(c));
}
}
for (k, _) in mobile.iter().filter(|(k, _)| !k.starts_with("padding")) {
if !desktop.iter().any(|(d, _)| d == k)
&& let Some(r) = reset(k)
{
out.extend(r.split_whitespace().map(bp));
}
}
// Padding utilities overlap (p, px/py, pt…): compare side by side, and zero on desktop the
// sides mobile pads and desktop doesn't.
let cover = |list: &[(String, String)]| {
list.iter().fold([false; 4], |mut acc, (k, _)| {
for (a, s) in acc.iter_mut().zip(padding_sides(k)) {
*a |= s;
}
acc
})
};
let (m, d) = (cover(mobile), cover(desktop));
let missing = [m[0] && !d[0], m[1] && !d[1], m[2] && !d[2], m[3] && !d[3]];
out.extend(zero_padding(missing).into_iter().map(bp));
out.dedup();
out
}
/// The markup walk for `id` in Tailwind mode: (markup, rules by path).
fn walk(doc: &Document, id: NodeId) -> (String, Vec<(String, String, String)>) {
let ctx = Ctx::new(doc, id);
let mut m = Markup::new(&ctx, CodeLang::React);
m.inline = true;
m.fluid = true;
m.fluid_leaves = 0.5;
m.tokens.tailwind = true;
m.node(id, None, 2, "");
(m.markup, m.rules)
}
/// React + Tailwind for `id` (with `mobile`, mobile first with desktop classes behind `lg:`).
pub fn code(doc: &Document, id: NodeId, mobile: Option<NodeId>) -> String {
let Some(n) = doc.node(id) else { return String::new() };
let (mut markup, rules) = walk(doc, id);
let small: Option<BTreeMap<String, Vec<(String, String)>>> =
mobile.filter(|m| doc.contains(*m) && *m != id).map(|m| walk(doc, m).1.into_iter().map(|(p, _, d)| (p, classes(&d))).collect());
// ponytail: layers only on mobile are listed, not merged in; their place in the tree is ambiguous.
let only_mobile: Vec<String> = small
.iter()
.flat_map(BTreeMap::keys)
.filter(|p| !rules.iter().any(|(d, _, _)| d == *p))
.map(|p| p.trim_start_matches('/').split('/').map(|seg| seg.rsplit_once('#').map_or(seg, |(n, _)| n)).collect::<Vec<_>>().join(" › "))
.take(20)
.collect();
// Longest class names first, so "card" never rewrites inside "card-2"'s attribute.
let mut rules = rules;
rules.sort_by_key(|(_, c, _)| std::cmp::Reverse(c.len()));
let paths: Vec<String> = rules.iter().map(|(p, _, _)| p.clone()).collect();
for (path, class, decls) in &rules {
let own = classes(decls);
// Layer names may contain "/": the parent is the longest other path this one extends.
let parent =
paths.iter().filter(|p| p.len() < path.len() && path.starts_with(&format!("{p}/"))).max_by_key(|p| p.len()).map_or("", String::as_str);
let list = match &small {
// Inside a layer hidden on mobile: desktop's classes as they are.
Some(m) if !path.is_empty() && !m.contains_key(parent) => own.into_iter().map(|(_, c)| c).collect(),
Some(m) => merge(m.get(path).map(Vec::as_slice), &own),
None => own.into_iter().map(|(_, c)| c).collect(),
};
markup = markup.replace(&format!("className=\"{class}\""), &format!("className=\"{}\"", list.join(" ")));
}
// Imports of the instances written as their connected code component, once each.
let mut imports: Vec<String> = Vec::new();
for i in doc.subtree(id).into_iter().take(20_000) {
if i != id
&& doc.node(i).is_some_and(|k| matches!(k.kind, NodeKind::Instance { .. }))
&& let Some(l) = crate::code_connect::link(doc, i, CodeLang::React)
{
let line = crate::code_connect::import_line(l, CodeLang::React);
if !line.is_empty() && !imports.contains(&line) {
imports.push(line);
}
}
}
let imports = if imports.is_empty() { String::new() } else { format!("{}\n\n", imports.join("\n")) };
let name = component_type_name(&n.name);
let mut mode = if small.is_some() { format!(", mobile first; desktop from {BREAKPOINT}") } else { String::new() };
if !only_mobile.is_empty() {
mode.push_str(&format!(". Only in the mobile frame, add by hand with {BREAKPOINT}:hidden: {}", only_mobile.join(", ")));
}
let header = comment_safe(&format!("{} as React + Tailwind CSS v4{mode}. Theme: variable.export {{format: \"tailwind\"}}.", n.name));
format!("// {header}\n{imports}export function {name}() {{\n return (\n{markup} );\n}}\n")
}
/// `text` for one `//` comment line: every JS line terminator (control characters, U+2028,
/// U+2029) becomes a space, so layer names in it can't end the comment and inject code.
fn comment_safe(text: &str) -> String {
text.chars().take(2_000).map(|c| if c.is_control() || matches!(c, '\u{2028}' | '\u{2029}') { ' ' } else { c }).collect()
}
+342
View File
@@ -0,0 +1,342 @@
//! React + Tailwind code for pages: utilities instead of a stylesheet, theme utilities for bound
//! variables, fluid boxes, inlined instances, per-side borders, mobile-first responsive merges and
//! the Tailwind theme export.
use crate::Session;
use crate::components::tests::{ex, idv};
use crate::tailwind::{classes, merge};
use figcraft_doc::NodeId;
use serde_json::json;
fn code(s: &mut Session, id: NodeId, extra: serde_json::Value) -> String {
let mut p = json!({"id": id, "language": "tailwind"});
if let (Some(p), Some(e)) = (p.as_object_mut(), extra.as_object()) {
p.extend(e.clone());
}
ex(s, "inspect.code", p)["code"].as_str().unwrap_or_default().to_string()
}
/// A 1440 "Page" (vertical auto layout, bg bound to Brand/Accent) holding a 960 "Hero" text and a
/// horizontal "Row" of two "Card" frames that fill it. Returns (page, row).
fn page(s: &mut Session, name: &str, width: f64, row_dir: &str) -> (NodeId, NodeId) {
let p = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": width, "height": 900, "name": name})));
ex(s, "layout.add", json!({"ids": [p], "direction": "vertical"}));
ex(s, "node.set", json!({"id": p, "props": {"gap": 24, "padding": 32}}));
let t = idv(&ex(s, "text.create", json!({"text": "Seu site em 7 dias", "x": 0, "y": 0, "parent": p, "props": {"name": "Hero"}})));
ex(s, "node.set", json!({"id": t, "props": {"width": 960, "textAutoResize": "height"}}));
let row = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 800, "height": 200, "parent": p, "name": "Row"})));
ex(s, "layout.add", json!({"ids": [row], "direction": row_dir}));
for _ in 0..2 {
let c = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100, "parent": row, "name": "Card"})));
ex(s, "node.set", json!({"id": c, "props": {"sizingH": "fill"}}));
}
(p, row)
}
fn brand(s: &mut Session) {
ex(s, "variable.createCollection", json!({"name": "Brand"}));
ex(s, "variable.create", json!({"collection": "Brand", "name": "Brand/Accent", "type": "color", "value": "#3DDC97"}));
ex(s, "variable.create", json!({"collection": "Brand", "name": "card", "type": "number", "value": 12}));
ex(s, "variable.create", json!({"collection": "Brand", "name": "fade", "type": "number", "value": 0.5}));
}
#[test]
fn utilities_replace_the_stylesheet_and_variables_become_theme_utilities() {
let mut s = Session::new();
brand(&mut s);
let (p, row) = page(&mut s, "Landing", 1440.0, "horizontal");
ex(&mut s, "variable.bind", json!({"ids": [p], "field": "fill", "variable": "Brand/Accent"}));
ex(&mut s, "node.set", json!({"id": row, "props": {"cornerRadius": 12}}));
ex(&mut s, "variable.bind", json!({"ids": [row], "field": "cornerRadius", "variable": "card"}));
let out = code(&mut s, p, json!({}));
assert!(out.contains("export function Landing() {"), "{out}");
assert!(!out.contains("import") && !out.contains("<style") && !out.contains(".css"), "no stylesheet: {out}");
for want in ["flex flex-col", "gap-6", "p-8", "bg-accent", "rounded-card", "flex-1", "w-full"] {
assert!(out.contains(want), "{want} in {out}");
}
// The page is as wide as itself: just full width; the 960 text is capped, not fixed.
assert!(out.contains("max-w-[960px]") && !out.contains("w-[1440px]") && !out.contains("max-w-[1440px]"), "{out}");
}
#[test]
fn per_side_strokes_are_borders_on_those_sides() {
let mut s = Session::new();
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 60, "name": "Nav"})));
ex(&mut s, "node.set", json!({"id": f, "props": {"strokes": ["#262A31"], "strokeWeight": 1, "strokeSides": [0, 0, 1, 0]}}));
let css = ex(&mut s, "inspect.code", json!({"id": f, "language": "css"}))["code"].as_str().unwrap_or_default().to_string();
assert!(css.contains("border-bottom: 1px solid") && !css.contains("border:"), "{css}");
let tw = code(&mut s, f, json!({}));
assert!(tw.contains("border-b ") && (tw.contains("border-[#262a31]") || tw.contains("border-[#262A31]")), "{tw}");
// Without per-side weights it is one border.
ex(&mut s, "node.set", json!({"id": f, "props": {"strokeSides": null, "strokeWeight": 2}}));
let css = ex(&mut s, "inspect.code", json!({"id": f, "language": "css"}))["code"].as_str().unwrap_or_default().to_string();
assert!(css.contains("border: 2px solid"), "{css}");
}
#[test]
fn instances_are_inlined_with_their_overrides() {
let mut s = Session::new();
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Benefit"})));
ex(&mut s, "layout.add", json!({"ids": [f], "direction": "vertical"}));
ex(&mut s, "text.create", json!({"text": "Original", "x": 0, "y": 0, "parent": f, "props": {"name": "Title"}}));
let comp = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
let holder = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 400, "width": 600, "height": 200, "name": "Benefits"})));
ex(&mut s, "layout.add", json!({"ids": [holder], "direction": "horizontal"}));
let inst = idv(&ex(&mut s, "instance.create", json!({"component": comp, "x": 0, "y": 0, "parent": holder})));
let doc = s.doc().unwrap();
let title = doc.node(inst).unwrap().children[0];
ex(&mut s, "text.set", json!({"id": title, "characters": "Agenda sozinha"}));
let out = code(&mut s, holder, json!({}));
assert!(out.contains("Agenda sozinha") && !out.contains("<Benefit"), "inlined with override: {out}");
}
#[test]
fn a_mobile_frame_makes_the_code_mobile_first() {
let mut s = Session::new();
let (desk, _) = page(&mut s, "Landing", 1440.0, "horizontal");
let (mob, _) = page(&mut s, "Landing mobile", 390.0, "vertical");
// A layer only on desktop is hidden on mobile.
ex(&mut s, "text.create", json!({"text": "Só no desktop", "x": 0, "y": 0, "parent": desk, "props": {"name": "Aside"}}));
let out = code(&mut s, desk, json!({"mobile": mob}));
assert!(out.contains("mobile first"), "{out}");
assert!(out.contains("flex flex-col") && out.contains("lg:flex-row"), "row stacks on mobile: {out}");
assert!(out.contains("hidden") && out.contains("lg:block"), "desktop-only layer: {out}");
assert!(s.execute("inspect.code", &json!({"id": desk, "language": "tailwind", "mobile": 999_999})).is_err(), "a missing mobile frame is refused");
}
#[test]
fn declarations_map_to_idiomatic_utilities() {
let got: Vec<String> = classes(
"display: flex;\njustify-content: space-between;\nalign-items: center;\npadding: 8px 16px 8px 16px;\nfont-weight: 600;\nfont-size: 18px;\nline-height: 120%;\nopacity: 0.5;\ncolor: var(--color-muted, #9aa3ae);\nbox-shadow: 0px 4px 12px 0px rgba(0, 0, 0, 0.25);\nwhite-space: nowrap;\n",
)
.into_iter()
.map(|(_, c)| c)
.collect();
let want = [
"flex",
"justify-between",
"items-center",
"py-2",
"px-4",
"font-semibold",
"text-[18px]",
"leading-[1.2]",
"opacity-50",
"text-muted",
"shadow-[0px_4px_12px_0px_rgba(0,_0,_0,_0.25)]",
"[white-space:nowrap]",
];
assert_eq!(got, want);
}
#[test]
fn variables_export_with_units_and_as_a_tailwind_theme() {
let mut s = Session::new();
brand(&mut s);
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100})));
ex(&mut s, "node.set", json!({"id": f, "props": {"cornerRadius": 12, "opacity": 0.5}}));
ex(&mut s, "variable.bind", json!({"ids": [f], "field": "cornerRadius", "variable": "card"}));
ex(&mut s, "variable.bind", json!({"ids": [f], "field": "opacity", "variable": "fade"}));
let css = ex(&mut s, "variable.export", json!({"format": "css"}))["text"].as_str().unwrap_or_default().to_string();
assert!(css.contains("--card: 12px;") && css.contains("--fade: 0.5;"), "{css}");
let tw = ex(&mut s, "variable.export", json!({"format": "tailwind"}))["text"].as_str().unwrap_or_default().to_string();
for want in ["@import \"tailwindcss\";", "@theme static {", "--color-accent: #3ddc97;", "--radius-card: 12px;", "--fade: 0.5;"] {
assert!(tw.to_lowercase().contains(&want.to_lowercase()), "{want} in {tw}");
}
}
#[test]
fn desktop_padding_on_other_sides_is_not_reset_away() {
let mut s = Session::new();
let (desk, _) = page(&mut s, "Landing", 1440.0, "horizontal");
let (mob, _) = page(&mut s, "Landing mobile", 390.0, "vertical");
ex(&mut s, "node.set", json!({"id": desk, "props": {"paddingTop": 24, "paddingBottom": 24, "paddingLeft": 64, "paddingRight": 64}}));
ex(&mut s, "node.set", json!({"id": mob, "props": {"padding": 24}}));
let out = code(&mut s, desk, json!({"mobile": mob}));
assert!(out.contains("p-6 ") && out.contains("lg:py-6 lg:px-16"), "{out}");
assert!(!out.contains("lg:p-0"), "a reset would undo lg:px-16: {out}");
}
#[test]
fn a_layer_name_cant_break_out_of_the_header_comment() {
let mut s = Session::new();
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100, "name": "A\rB\u{2028}alert(1)\u{2029}C\nD"})));
let out = code(&mut s, f, json!({}));
let header = out.lines().next().unwrap_or_default();
assert!(header.starts_with("// ") && header.contains("alert(1)"), "{out}");
assert!(!out.contains(['\r', '\u{2028}', '\u{2029}']), "{out:?}");
let name = out.lines().nth(1).and_then(|l| l.strip_prefix("export function ")).and_then(|l| l.strip_suffix("() {")).unwrap_or_default();
assert!(!name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'), "an identifier: {name:?}");
}
#[test]
fn slashed_layer_names_and_phone_widths_merge_right() {
let mut s = Session::new();
let mut frames = Vec::new();
for (name, width, dir, sizing) in [("Landing", 1440.0, "horizontal", "hug"), ("Landing mobile", 390.0, "vertical", "fill")] {
let p = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": width, "height": 600, "name": name})));
ex(&mut s, "layout.add", json!({"ids": [p], "direction": "vertical"}));
let ctas = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 342, "height": 120, "parent": p, "name": "CTAs"})));
ex(&mut s, "layout.add", json!({"ids": [ctas], "direction": dir}));
let b = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 48, "parent": ctas, "name": "Button/Primary"})));
ex(&mut s, "layout.add", json!({"ids": [b], "direction": "horizontal"}));
ex(&mut s, "node.set", json!({"id": b, "props": {"sizingH": sizing}}));
ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": width * 0.8, "height": 1, "parent": p, "props": {"name": "Divider"}}));
frames.push(p);
}
// A layer only the mobile frame has.
ex(&mut s, "text.create", json!({"text": "7 dias", "x": 0, "y": 0, "parent": frames[1], "props": {"name": "Tag"}}));
let out = code(&mut s, frames[0], json!({"mobile": frames[1]}));
let button =
out.lines().find(|l| l.contains("lg:") && l.contains("rounded") || l.contains("self-stretch") && l.contains("flex-row")).unwrap_or_default();
assert!(button.contains("self-stretch") && button.contains("lg:self-auto"), "the slashed name keeps its mobile classes: {out}");
assert!(!out.contains("max-w-[342px] ") || out.contains("lg:max-w-[342px]"), "no phone-width cap below lg: {out}");
assert!(out.contains("Only in the mobile frame") && out.contains("Tag"), "{out}");
let divider = out.lines().filter(|l| l.contains("<div") && (l.contains("h-[1px]") || l.contains("h-px"))).count();
assert!(out.contains("w-full") && divider <= 2, "{out}");
}
#[test]
fn a_variable_named_after_its_namespace_is_its_base() {
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "Brand"}));
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "radius", "type": "number", "value": 12}));
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100})));
ex(&mut s, "node.set", json!({"id": f, "props": {"cornerRadius": 12}}));
ex(&mut s, "variable.bind", json!({"ids": [f], "field": "cornerRadius", "variable": "radius"}));
assert!(code(&mut s, f, json!({})).contains("rounded-base"));
let tw = ex(&mut s, "variable.export", json!({"format": "tailwind"}))["text"].as_str().unwrap_or_default().to_string();
assert!(tw.contains("--radius-base: 12px;") && !tw.contains("--radius-radius"), "{tw}");
}
#[test]
fn mobile_only_layer_names_cant_break_out_of_the_header_comment() {
let mut s = Session::new();
let (desk, _) = page(&mut s, "Landing", 1440.0, "horizontal");
let (mob, _) = page(&mut s, "Landing mobile", 390.0, "vertical");
ex(&mut s, "text.create", json!({"text": "x", "x": 0, "y": 0, "parent": mob, "props": {"name": "Tag\nalert(1)\u{2028}y"}}));
let out = code(&mut s, desk, json!({"mobile": mob}));
let header = out.lines().next().unwrap_or_default();
assert!(header.starts_with("// ") && header.contains("alert(1)"), "listed inside the comment: {out}");
assert!(out.lines().nth(1).is_some_and(|l| l.starts_with("export function ")), "{out}");
assert!(!out.contains(['\r', '\u{2028}', '\u{2029}']), "{out:?}");
}
#[test]
fn page_code_leaves_resets_to_tailwind_preflight() {
let mut s = Session::new();
let (p, _) = page(&mut s, "Landing", 1440.0, "horizontal");
let out = code(&mut s, p, json!({}));
for leak in ["box-sizing", "[margin:0]", "text-decoration", "cursor"] {
assert!(!out.contains(leak), "{leak} in {out}");
}
}
#[test]
fn instances_connected_to_code_are_their_snippet_with_one_import() {
let mut s = Session::new();
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 160, "height": 44, "name": "Button"})));
ex(&mut s, "layout.add", json!({"ids": [f], "direction": "horizontal"}));
let label = idv(&ex(&mut s, "text.create", json!({"text": "Botão", "x": 0, "y": 0, "parent": f, "props": {"name": "Label"}})));
let comp = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
ex(&mut s, "component.addProperty", json!({"id": comp, "name": "Label", "type": "text", "layer": label}));
ex(
&mut s,
"codeConnect.set",
json!({"id": comp, "language": "react", "importPath": "@/components/ui/button", "name": "Button", "template": "<Button size=\"lg\">{Label}</Button>"}),
);
let p = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 300, "width": 1440, "height": 400, "name": "Landing"})));
ex(&mut s, "layout.add", json!({"ids": [p], "direction": "vertical"}));
for text in ["Agendar diagnóstico", "Ver exemplos"] {
let i = idv(&ex(&mut s, "instance.create", json!({"component": comp, "x": 0, "y": 0, "parent": p})));
ex(&mut s, "instance.setProperties", json!({"id": i, "properties": {"Label": text}}));
}
let out = code(&mut s, p, json!({}));
assert_eq!(out.matches("import { Button } from \"@/components/ui/button\";").count(), 1, "{out}");
assert!(out.contains("<Button size=\"lg\">") && out.contains("Agendar diagnóstico") && out.contains("Ver exemplos"), "{out}");
assert!(!out.contains(">Botão<"), "the component's own layers aren't inlined: {out}");
// Unconnected instances are still inlined.
ex(&mut s, "codeConnect.remove", json!({"id": comp, "language": "react"}));
let out = code(&mut s, p, json!({}));
assert!(!out.contains("import ") && out.contains(">Agendar diagnóstico<"), "{out}");
}
#[test]
fn review_same_named_variables_keep_distinct_theme_names() {
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "Brand"}));
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "Palette/Accent", "type": "color", "value": "#3DDC97"}));
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "Semantic/Accent", "type": "color", "value": "#7C5CFF"}));
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "Muted", "type": "color", "value": "#9AA3AE"}));
let p = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 200, "name": "Page"})));
ex(&mut s, "layout.add", json!({"ids": [p], "direction": "vertical"}));
let a = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 50, "parent": p, "name": "A"})));
let b = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 50, "parent": p, "name": "B"})));
ex(&mut s, "variable.bind", json!({"ids": [a], "field": "fill", "variable": "Palette/Accent"}));
ex(&mut s, "variable.bind", json!({"ids": [b], "field": "fill", "variable": "Semantic/Accent"}));
let out = code(&mut s, p, json!({}));
assert!(out.contains("bg-palette-accent") && out.contains("bg-semantic-accent"), "{out}");
let tw = ex(&mut s, "variable.export", json!({"format": "tailwind"}))["text"].as_str().unwrap_or_default().to_lowercase();
assert!(tw.contains("--color-palette-accent: #3ddc97;") && tw.contains("--color-semantic-accent: #7c5cff;"), "{tw}");
assert!(tw.contains("--color-muted:"), "unique short names stay short: {tw}");
}
#[test]
fn review_an_alias_target_takes_its_readers_role() {
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "Brand"}));
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "half", "type": "number", "value": 0.5}));
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "fade", "type": "number", "value": 1}));
ex(&mut s, "variable.set", json!({"variable": "fade", "value": {"alias": "half"}}));
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100})));
ex(&mut s, "variable.bind", json!({"ids": [f], "field": "opacity", "variable": "fade"}));
let css = ex(&mut s, "variable.export", json!({"format": "css"}))["text"].as_str().unwrap_or_default().to_string();
assert!(css.contains("--half: 0.5;") && !css.contains("0.5px"), "{css}");
}
#[test]
fn review_borders_images_and_fonts_from_variables_are_valid_utilities() {
let got: Vec<String> = classes(
"border: var(--stroke, 2px) solid #000000;\nborder-bottom: var(--hair, 1px) solid var(--color-border, #262a31);\nbackground: url(image.png) center / cover;\nfont-family: var(--brand-font, \"Inter\");\n",
)
.into_iter()
.map(|(_, c)| c)
.collect();
assert_eq!(
got,
[
"border-(length:--stroke)",
"border-[#000000]",
"border-b-(length:--hair)",
"border-border",
"[background:url(image.png)_center_/_cover]",
"font-(family-name:--brand-font)"
]
);
}
#[test]
fn review_desktop_defaults_reset_mobile_values_and_padding_per_side() {
let mut s = Session::new();
let (desk, _) = page(&mut s, "Landing", 1440.0, "horizontal");
let (mob, _) = page(&mut s, "Landing mobile", 390.0, "vertical");
ex(&mut s, "node.set", json!({"id": mob, "props": {"opacity": 0.5, "padding": 24}}));
ex(&mut s, "node.set", json!({"id": desk, "props": {"paddingTop": 0, "paddingBottom": 0, "paddingLeft": 64, "paddingRight": 64}}));
let out = code(&mut s, desk, json!({"mobile": mob}));
let root = out.lines().nth(3).unwrap_or_default();
assert!(root.contains("opacity-50") && root.contains("lg:opacity-100"), "{root}");
assert!(root.contains("p-6") && root.contains("lg:px-16") && root.contains("lg:py-0"), "{root}");
}
#[test]
fn review_desktop_without_fill_clears_a_mobile_image_or_gradient() {
let got = merge(Some(&[("background".to_string(), "[background:url(image.png)_center_/_cover]".to_string())]), &[]);
assert!(got.contains(&"lg:bg-none".to_string()) && got.contains(&"lg:bg-transparent".to_string()), "{got:?}");
}
#[test]
fn per_corner_radii_with_a_variable_first_keep_every_corner() {
let got: Vec<String> =
classes("border-radius: var(--radius-a, 4px) 8px 8px 8px;\nborder-radius: var(--radius-card, 4px);\n").into_iter().map(|(_, c)| c).collect();
assert_eq!(got, ["rounded-[var(--radius-a,_4px)_8px_8px_8px]", "rounded-card"]);
}
+11 -14
View File
@@ -746,7 +746,7 @@ fn get_node(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolResult, S
}
}
if used_wire + MARKER_RESERVE <= budget {
return Ok(ToolResult::json(&with_marker(value, depth, used, wire, used_wire, max_bytes, true, None)));
return Ok(ToolResult::json(&with_marker(value, Cut { asked: depth, used, asked_wire: wire, used_wire }, max_bytes, true, None)));
}
}
@@ -771,10 +771,7 @@ fn get_node(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolResult, S
let over = wire > budget || next.is_some();
let mut out = with_marker(
value,
depth,
used,
wire,
used_wire,
Cut { asked: depth, used, asked_wire: wire, used_wire },
max_bytes,
over,
Some(json!({
@@ -816,19 +813,19 @@ fn children_window(value: &mut Value, offset: usize, budget: usize) -> bool {
true
}
/// `value` with the `truncated` object saying how it was cut. `over`: the subtree asked for passed
/// the limit (else the window is only the `offset` asked for). `children`: the window, when the
/// children were cut as well as (or instead of) the depth.
fn with_marker(
mut value: Value,
/// How a subtree was cut: the depth asked for and the depth reported, with their sizes on the wire.
struct Cut {
asked: u64,
used: u64,
asked_wire: usize,
used_wire: usize,
max_bytes: usize,
over: bool,
children: Option<Value>,
) -> Value {
}
/// `value` with the `truncated` object saying how it was cut. `over`: the subtree asked for passed
/// the limit (else the window is only the `offset` asked for). `children`: the window, when the
/// children were cut as well as (or instead of) the depth.
fn with_marker(mut value: Value, cut: Cut, max_bytes: usize, over: bool, children: Option<Value>) -> Value {
let Cut { asked, used, asked_wire, used_wire } = cut;
if let Some(obj) = value.as_object_mut() {
let mut marker = json!({
"requestedDepth": asked,
+98 -27
View File
@@ -292,7 +292,9 @@ pub fn stroke_center(path: &BezPath, width: f64, props: &StrokeProps) -> BezPath
.with_miter_limit(props.miter_limit.clamp(1.0, 100.0))
.with_start_cap(kcap(props.cap_start))
.with_end_cap(kcap(props.cap_end));
if let Some(pat) = width::dash_pattern(props) {
// Each subpath restarts the pattern, so it can add up to one more pattern's worth of dashes.
let subpaths = path.elements().iter().filter(|e| matches!(e, PathEl::MoveTo(_))).count() as f64;
if let Some(pat) = width::dash_pattern(props).filter(|p| width::dashes_fit(path.perimeter(1.0) + subpaths * p.iter().sum::<f64>(), p)) {
style = style.with_dashes(0.0, pat);
}
kurbo::stroke(path.iter(), &style, &kurbo::StrokeOpts::default(), 0.05)
@@ -359,36 +361,35 @@ pub fn stroke_outline_of(path: &BezPath, props: &StrokeProps, closed_shape: bool
/// End point and outward direction of an open path at its start or end.
fn path_end(path: &BezPath, at_end: bool) -> Option<(Point, Vec2)> {
let segs: Vec<PathSeg> = path.segments().collect();
let seg = if at_end { segs.last()? } else { segs.first()? };
use kurbo::ParamCurve;
use kurbo::ParamCurveDeriv;
let (p, d) = if at_end {
let d = match seg {
PathSeg::Line(l) => l.p1 - l.p0,
PathSeg::Quad(q) => q.deriv().eval(1.0).to_vec2(),
PathSeg::Cubic(c) => {
let d = c.deriv().eval(1.0).to_vec2();
if d.hypot() < 1e-9 { c.p3 - c.p2 } else { d }
}
let dir_of = |seg: &PathSeg| -> Option<(Point, Vec2)> {
let (p, d) = if at_end {
let d = match seg {
PathSeg::Line(l) => l.p1 - l.p0,
PathSeg::Quad(q) => q.deriv().eval(1.0).to_vec2(),
PathSeg::Cubic(c) => {
let d = c.deriv().eval(1.0).to_vec2();
if d.hypot() < 1e-9 { c.p3 - c.p2 } else { d }
}
};
(seg.end(), d)
} else {
let d = match seg {
PathSeg::Line(l) => l.p0 - l.p1,
PathSeg::Quad(q) => -q.deriv().eval(0.0).to_vec2(),
PathSeg::Cubic(c) => {
let d = -c.deriv().eval(0.0).to_vec2();
if d.hypot() < 1e-9 { c.p0 - c.p1 } else { d }
}
};
(seg.start(), d)
};
(seg.end(), d)
} else {
let d = match seg {
PathSeg::Line(l) => l.p0 - l.p1,
PathSeg::Quad(q) => -q.deriv().eval(0.0).to_vec2(),
PathSeg::Cubic(c) => {
let d = -c.deriv().eval(0.0).to_vec2();
if d.hypot() < 1e-9 { c.p0 - c.p1 } else { d }
}
};
(seg.start(), d)
let len = d.hypot();
(len >= 1e-12).then(|| (p, d / len))
};
let len = d.hypot();
if len < 1e-12 {
return None;
}
Some((p, d / len))
// Skip zero-length segments (repeated vertices) and use the nearest one with a direction.
if at_end { path.segments().collect::<Vec<_>>().iter().rev().find_map(dir_of) } else { path.segments().find_map(|s| dir_of(&s)) }
}
/// The filled arrowhead shape for `cap` at one end of an open path.
@@ -693,6 +694,29 @@ mod tests {
assert_ne!(explicit, layer_only);
}
#[test]
fn layer_end_arrow_is_drawn_when_a_closed_run_follows_the_open_one() {
let verts = [(0.0, 0.0), (50.0, 0.0), (0.0, 100.0), (50.0, 100.0), (25.0, 150.0)];
let net = VectorNetwork {
vertices: verts
.iter()
.enumerate()
.map(|(i, &(x, y))| figcraft_doc::Vertex { cap: (i == 0).then_some(StrokeCap::Round), ..figcraft_doc::Vertex::new(x, y) })
.collect(),
segments: vec![
figcraft_doc::Segment::line(0, 1),
figcraft_doc::Segment::line(2, 3),
figcraft_doc::Segment::line(3, 4),
figcraft_doc::Segment::line(4, 2),
],
..Default::default()
};
let path = BezPath::new();
let plain = StrokeProps { weight: 2.0, ..Default::default() };
let arrow = StrokeProps { cap_end: StrokeCap::TriangleArrow, ..plain.clone() };
assert_ne!(stroke_outline_of(&path, &arrow, false, Some(&net)), stroke_outline_of(&path, &plain, false, Some(&net)));
}
#[test]
fn equal_joins_do_not_break_closed_stroke_runs() {
let path = sq(0.0, 0.0, 20.0);
@@ -719,6 +743,35 @@ mod tests {
assert!((s.area().abs() - 100.0).abs() < 2.0, "{}", s.area());
}
#[test]
fn a_dash_pattern_too_fine_for_its_path_strokes_solid() {
// ~500k dashes on this line (#1060): drawn solid, as a variable width stroke is past
// `MAX_DASHES`, rather than one outline per dash.
let mut line = BezPath::new();
line.move_to((0.0, 0.0));
line.line_to((10_000.0, 0.0));
let props = StrokeProps { weight: 2.0, dashes: vec![0.01, 0.01], align: StrokeAlign::Center, ..Default::default() };
let s = stroke_center(&line, 2.0, &props);
assert!(s.elements().len() < 100, "{} elements", s.elements().len());
assert!((s.area().abs() - 20_000.0).abs() < 10.0, "{}", s.area());
}
#[test]
fn each_subpath_restarting_the_dash_pattern_counts_toward_the_limit() {
// The pattern restarts at every subpath: on 10,000 short lines its 199 tiny dashes come
// round each time (~1M dashes), though the length alone suggests a few hundred.
let mut path = BezPath::new();
for i in 0..10_000 {
path.move_to((0.0, f64::from(i) * 4.0));
path.line_to((2.0, f64::from(i) * 4.0));
}
let mut dashes = vec![0.01; 199];
dashes.push(10_000.0);
let props = StrokeProps { weight: 1.0, dashes, align: StrokeAlign::Center, ..Default::default() };
let s = stroke_center(&path, 1.0, &props);
assert!(s.elements().len() < 100_000, "{} elements", s.elements().len());
}
#[test]
fn inside_and_outside_strokes_respect_an_even_odd_hole() {
// A ring: both loops run the same way, so only the even-odd rule leaves the hole open.
@@ -742,4 +795,22 @@ mod tests {
let s = stroke_outline_of(&ring, &inside, true, Some(&nz));
assert!(s.winding(material) != 0 && s.winding(hole) != 0);
}
#[test]
fn arrowhead_skips_zero_length_end_segments() {
let mut p = BezPath::new();
p.move_to((0.0, 0.0));
p.line_to((100.0, 0.0));
assert!(arrowhead(&p, StrokeCap::TriangleArrow, 2.0, true).is_some());
p.line_to((100.0, 0.0));
let (tip, dir) = path_end(&p, true).expect("end direction");
assert_eq!((tip, dir), (Point::new(100.0, 0.0), Vec2::new(1.0, 0.0)));
assert!(arrowhead(&p, StrokeCap::TriangleArrow, 2.0, true).is_some());
let mut q = BezPath::new();
q.move_to((0.0, 0.0));
q.line_to((0.0, 0.0));
q.line_to((100.0, 0.0));
let (tip, dir) = path_end(&q, false).expect("start direction");
assert_eq!((tip, dir), (Point::new(0.0, 0.0), Vec2::new(-1.0, 0.0)));
}
}
+2 -2
View File
@@ -86,8 +86,8 @@ impl Styled {
/// and last end (as without point styles).
pub(crate) fn arrowheads(&self, weight: f64, props: &StrokeProps) -> BezPath {
let mut out = BezPath::new();
let last = self.runs.len().saturating_sub(1);
for (k, run) in self.runs.iter().enumerate().filter(|(_, r)| !r.closed) {
let last = self.runs.iter().filter(|r| !r.closed).count().saturating_sub(1);
for (k, run) in self.runs.iter().filter(|r| !r.closed).enumerate() {
let start = self.cap_at(run.start, if k == 0 { props.cap_start } else { StrokeCap::None });
let end = self.cap_at(run.end, if k == last { props.cap_end } else { StrokeCap::None });
for (at_end, cap) in [(false, start), (true, end)] {
+8 -2
View File
@@ -17,7 +17,7 @@ pub use figcraft_geom::measure::{Polyline, polylines};
/// Every subpath gets at least this many width samples, so the width varies smoothly along long
/// straight runs.
const SAMPLES_PER_SUBPATH: f64 = 128.0;
/// The most dashes a dashed variable width stroke makes.
/// The most dashes a dashed stroke makes ([`dashes_fit`]).
const MAX_DASHES: usize = 20_000;
/// The nearest point of the stroke path to `p`: (subpath index, t by length, distance).
@@ -242,6 +242,12 @@ pub fn dash_pattern(props: &StrokeProps) -> Option<Vec<f64>> {
Some(if dashes.len() % 2 == 1 { [dashes.clone(), dashes].concat() } else { dashes })
}
/// Whether `pattern` (from [`dash_pattern`]) makes at most [`MAX_DASHES`] dashes along `len`;
/// past that a stroke draws solid instead of one outline per dash (#1060).
pub(crate) fn dashes_fit(len: f64, pattern: &[f64]) -> bool {
len / pattern.iter().sum::<f64>() * (pattern.len() as f64) < MAX_DASHES as f64
}
/// Resample a polyline so no step is longer than `max_step`; returns the points and the length at
/// each, or `None` past the sample budget.
fn resample(line: &Polyline, max_step: f64, budget: &mut usize) -> Option<(Vec<Point>, Vec<f64>)> {
@@ -294,7 +300,7 @@ pub fn variable_outline(path: &BezPath, width: f64, props: &StrokeProps, profile
})
.collect();
match &pattern {
Some(pat) if len / pat.iter().sum::<f64>() * (pat.len() as f64) < MAX_DASHES as f64 => {
Some(pat) if dashes_fit(len, pat) => {
for (p, h) in dashes(&pts, &hw, pat) {
outline_piece(&mut out, &p, &h, false, style);
}
+20
View File
@@ -253,3 +253,23 @@ fn stroke_only_text_operands_use_glyphs_instead_of_the_text_box() {
text.characters = " ".into();
assert!(outline(&d, b).elements().is_empty(), "spaces have no stroked glyphs");
}
#[test]
fn rounded_side_borders_follow_their_corners_in_booleans() {
use figcraft_doc::StrokeAlign;
use figcraft_geom::Point;
let mut d = Document::new("rounded borders");
let page = d.first_page().unwrap();
let b = d.create(page, NodeKind::BooleanOp { op: BoolOp::Union }, "B", 0.0, 0.0, 100.0, 100.0).unwrap();
let id = d.create(b, NodeKind::Rectangle, "operand", 0.0, 0.0, 100.0, 100.0).unwrap();
let n = d.get_mut(id).unwrap();
n.fills.clear();
n.strokes = vec![Paint::solid(Color::BLACK)];
n.stroke.align = StrokeAlign::Inside;
n.stroke.sides = Some([8.0; 4]);
n.corners.radii = [30.0; 4];
let p = outline(&d, b);
assert_ne!(p.winding(Point::new(50.0, 4.0)), 0, "top border");
assert_eq!(p.winding(Point::new(3.0, 3.0)), 0, "the rounded corner leaves the square's tip out");
assert_eq!(p.winding(Point::new(50.0, 50.0)), 0, "hollow inside");
}
+26 -21
View File
@@ -156,19 +156,26 @@ pub(crate) fn hash_path(p: &BezPath, h: &mut impl Hasher) {
}
}
/// Per-side strokes on rectangles and frames (a ring under non-zero winding too), if `n` has them.
fn side_strokes(n: &Node) -> Option<Arc<BezPath>> {
let props = &n.stroke;
let sides = props.sides?;
if !matches!(n.kind, NodeKind::Rectangle | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. }) {
return None;
}
let c = &n.corners;
Some(Arc::new(if c.uniform() == Some(0.0) {
figcraft_pathops::rect_side_strokes(n.width, n.height, sides, props.align)
} else {
figcraft_pathops::rounded_side_strokes(n.width, n.height, c.radii, c.smoothing, sides, props.align)
}))
}
/// The stroke area of a node in local coordinates.
pub fn stroke_area(n: &Node, path: &BezPath, closed: bool) -> Arc<BezPath> {
let props = &n.stroke;
// Per-side strokes on rectangles/frames.
if let Some(sides) = props.sides
&& matches!(n.kind, NodeKind::Rectangle | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. })
{
let c = &n.corners;
return Arc::new(if c.uniform() == Some(0.0) {
figcraft_pathops::rect_side_strokes(n.width, n.height, sides, props.align)
} else {
figcraft_pathops::rounded_side_strokes(n.width, n.height, c.radii, c.smoothing, sides, props.align)
});
if let Some(p) = side_strokes(n) {
return p;
}
// Rect/ellipse inside/outside strokes without booleans: an even-odd ring of two outlines.
if props.dashes.is_empty() && props.profile().is_none() && props.brush.is_none() && closed && props.align != StrokeAlign::Center {
@@ -212,15 +219,15 @@ pub fn stroke_area(n: &Node, path: &BezPath, closed: bool) -> Arc<BezPath> {
cached_stroke_outline(n, path, closed)
}
/// A non-zero stroke outline, shared by painting and boolean operands.
/// The stroke a boolean operand contributes: like [`stroke_area`], but never an even-odd ring
/// (boolean folding uses non-zero winding).
fn operand_stroke(n: &Node, path: &BezPath, closed: bool) -> Arc<BezPath> {
side_strokes(n).unwrap_or_else(|| cached_stroke_outline(n, path, closed))
}
/// A non-zero stroke outline (cached), shared by painting and boolean operands.
fn cached_stroke_outline(n: &Node, path: &BezPath, closed: bool) -> Arc<BezPath> {
let props = &n.stroke;
if let Some(sides) = props.sides
&& matches!(n.kind, NodeKind::Rectangle | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. })
&& n.corners.uniform() == Some(0.0)
{
return Arc::new(figcraft_pathops::rect_side_strokes(n.width, n.height, sides, props.align));
}
let mut h = std::collections::hash_map::DefaultHasher::new();
hash_path(path, &mut h);
(props.weight.to_bits(), props.align as u8, props.cap_start as u8, props.cap_end as u8, props.join as u8, props.miter_limit.to_bits(), closed)
@@ -334,7 +341,7 @@ fn subtree_outline_at(doc: &Document, n: &Node, depth: usize, stroke_only: bool)
if n.fills.iter().any(|f| f.visible) {
out.extend(p.elements().iter().copied());
} else if stroke_only && n.has_visible_stroke() {
out.extend(cached_stroke_outline(n, &p, true).elements().iter().copied());
out.extend(operand_stroke(n, &p, true).elements().iter().copied());
}
}
for c in n.children.iter().filter_map(|c| doc.node(*c)) {
@@ -352,9 +359,7 @@ fn subtree_outline_at(doc: &Document, n: &Node, depth: usize, stroke_only: bool)
let (path, _) = node_outline_at(doc, n, depth)?;
let path = if matches!(n.kind, NodeKind::Text(_)) { path } else { figcraft_doc::geometry::stroke_geometry(n).unwrap_or(path) };
let closed = figcraft_doc::geometry::is_closed(n) || matches!(n.kind, NodeKind::BooleanOp { .. });
// Boolean folding uses non-zero winding; the canvas stroke cache also contains
// even-odd rectangle rings, so use the winding-correct stroke outline here.
Some(((*cached_stroke_outline(n, &path, closed)).clone(), FillRule::Winding))
Some(((*operand_stroke(n, &path, closed)).clone(), FillRule::Winding))
}
_ => node_outline_at(doc, n, depth),
}
+18 -4
View File
@@ -48,9 +48,11 @@ fn an_image_within_the_limits_decodes() {
}
#[test]
fn an_image_wider_or_taller_than_the_limit_is_refused() {
assert!(images::decode_within(&png(30, 20), ColorProfile::Srgb, 29, u64::MAX).is_none());
assert!(images::decode_within(&png(20, 30), ColorProfile::Srgb, 29, u64::MAX).is_none());
fn an_image_wider_or_taller_than_the_limit_is_scaled_down_to_fit() {
let (p, _) = images::decode_within(&png(30, 20), ColorProfile::Srgb, 15, u64::MAX).unwrap();
assert_eq!((p.width(), p.height()), (15, 10));
let (p, _) = images::decode_within(&png(20, 30), ColorProfile::Srgb, 15, u64::MAX).unwrap();
assert_eq!((p.width(), p.height()), (10, 15));
}
#[test]
@@ -63,10 +65,22 @@ fn a_huge_image_is_refused_from_its_header_alone() {
// 16 000 × 16 000 is within the side limit; its pixels (1 GB) aren't within a 1 MB budget, and
// the file has none to decode: only the header is read.
assert!(images::decode_within(&png_header_only(16_000, 16_000), ColorProfile::Srgb, 16_384, 1 << 20).is_none());
// Past the side limit, the shipped decode refuses it the same way.
// Past the side limit, the shipped decode reads its header and finds no pixels to scale down.
assert!(images::decode(&png_header_only(20_000, 10)).is_none());
}
#[test]
fn an_image_past_the_side_limit_is_drawn_downscaled() {
// #1336: one side over 16 384 px drew nothing.
let p = images::decode(&png(16_385, 4)).unwrap();
assert_eq!(p.width(), 16_384);
assert!(p.height() >= 1 && p.height() <= 4);
assert!(p.pixels().iter().all(|c| c.alpha() == 255));
let p = images::decode(&png(4, 18_078)).unwrap();
assert_eq!(p.height(), 16_384);
assert!(p.width() >= 1 && p.width() <= 4);
}
fn key(name: &str) -> images::Key {
(name.to_string(), [0; 7], ColorProfile::Srgb)
}
+11 -8
View File
@@ -15,7 +15,7 @@ use tiny_skia::Pixmap;
pub(crate) type Key = (String, [i32; 7], ColorProfile);
/// The largest image side decoded.
/// The largest side of a decoded image: larger images are scaled down to fit.
const MAX_SIDE: u32 = 16_384;
/// The most bytes one image's decoding may allocate: the image crate's own default limit (a
/// larger image is refused from its header).
@@ -83,26 +83,25 @@ fn fkey(f: &ImageFilters) -> [i32; 7] {
[q(f.exposure), q(f.contrast), q(f.saturation), q(f.temperature), q(f.tint), q(f.highlights), q(f.shadows)]
}
/// Decode encoded image bytes into a premultiplied sRGB pixmap (max 16k per side).
/// Decode encoded image bytes into a premultiplied sRGB pixmap (scaled down to 16k per side).
pub fn decode(bytes: &[u8]) -> Option<Pixmap> {
decode_in(bytes, ColorProfile::Srgb).map(|(p, _)| p)
}
/// Decode encoded image bytes into a premultiplied pixmap (max 16k per side): converted from the
/// Decode encoded image bytes into a premultiplied pixmap (scaled down to 16k per side): converted from the
/// image's own profile into `target` (`false`), or as sRGB when it has none (`true`: the sRGB →
/// `target` conversion is still to do).
fn decode_in(bytes: &[u8], target: ColorProfile) -> Option<(Pixmap, bool)> {
decode_within(bytes, target, MAX_SIDE, MAX_DECODED_BYTES)
}
/// [`decode_in`] for images at most `max_side` wide and tall whose decoded pixels take at most
/// `max_bytes`; larger ones are refused from their header, before any pixels are decoded.
/// [`decode_in`] for images whose decoded pixels take at most `max_bytes` (larger ones are refused
/// from their header, before any pixels are decoded), scaled down to at most `max_side` wide and
/// tall.
pub(crate) fn decode_within(bytes: &[u8], target: ColorProfile, max_side: u32, max_bytes: u64) -> Option<(Pixmap, bool)> {
use image::ImageDecoder;
let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
let mut limits = image::Limits::default();
limits.max_image_width = Some(max_side);
limits.max_image_height = Some(max_side);
limits.max_alloc = Some(max_bytes);
reader.limits(limits);
let mut dec = reader.into_decoder().ok()?;
@@ -110,7 +109,11 @@ pub(crate) fn decode_within(bytes: &[u8], target: ColorProfile, max_side: u32, m
return None;
}
let source = crate::image_profile::Source::read(bytes, &mut dec);
let img = image::DynamicImage::from_decoder(dec).ok()?;
let mut img = image::DynamicImage::from_decoder(dec).ok()?;
if img.width() > max_side || img.height() > max_side {
// `thumbnail` keeps the aspect ratio and works in integers (no float copy of the pixels).
img = img.thumbnail(max_side, max_side);
}
match source.convert(&img, target) {
Some(rgba) => Some((premultiplied(&rgba)?, false)),
None => Some((premultiplied(&img.to_rgba8())?, true)),
+13 -13
View File
@@ -21,7 +21,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "ამ ფაილის კოლექცია ამ რეჟიმებითა და ცვლადებით: შეცვალეთ მათი მნიშვნელობები თითოეული რეჟიმისთვის და დაამატეთ საკუთარი ცვლადები"
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "ამ სახელით .figcraft ფაილი უკვე არსებობს. ჩანაცვლების დასადასტურებლად აირჩიეთ მისი სრული სახელი."
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "ცალკე შრე თავის ჩარჩოსთან სწორდება: მონიშნეთ მეტი შრე ან ჩარჩოში მყოფი შრე"
"A responsive product page: hero, features, figures, a quote and a footer" = "პროდუქტის ადაპტური გვერდი: მთავარი ბლოკი, შესაძლებლობები, ციფრები, ციტატა და ქვედა ზოლი"
"A responsive product page: hero, features, figures, a quote and a footer" = "პროდუქტის ადაპტური გვერდი ყველა ძირითადი ბლოკით"
"A spring plays until it settles: its mass, stiffness and damping set how long" = "ზამბარა მოძრაობს, სანამ არ დამშვიდდება: მისი მასა, სიხისტე და მილევა განსაზღვრავს ხანგრძლივობას"
"A title, an agenda, a big figure, a chart and a closing slide" = "სათაური, დღის წესრიგი, დიდი ციფრი, დიაგრამა და დასკვნითი სლაიდი"
"A to Z" = "ა-დან ჰ-მდე"
@@ -104,7 +104,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Alpha" = "ალფა"
"Alphabetical" = "ანბანის მიხედვით"
"Alt+drag" = "Alt+გადათრევა"
"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "ანალიტიკის ეკრანი ბადეზე: ციფრები, ვექტორებით დახატული დიაგრამები და ცხრილი"
"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "ანალიტიკა ბადეზე: ციფრები, დიაგრამები და ცხრილი"
"An invite link that opens FigCraft and joins" = "მოსაწვევი ბმული, რომელიც ხსნის FigCraft-ს და უერთდება სესიას"
"Anchor" = "საყრდენი"
"Android" = "Android"
@@ -215,7 +215,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Clear from this list" = "ამ სიიდან ამოღება"
"Code or invite link" = "კოდი ან მოსაწვევი ბმული"
"Color profile: {name}" = "ფერის პროფილი: {name}"
"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "ფერისა და ტექსტის სტილები, ღია და მუქი ცვლადები, ღილაკები, შეყვანის ველები და ბარათები"
"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "სტილები, ღია და მუქი ცვლადები, ღილაკები და ბარათები"
"Connector" = "შემაერთებელი"
"Convert to frame" = "ჩარჩოდ გარდაქმნა"
"Convert to section" = "სექციად გარდაქმნა"
@@ -480,7 +480,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Expand" = "გაშლა"
"Export" = "ექსპორტირება"
"Export all" = "ყველაფრის ექსპორტირება"
"Export frames and layers as PNG, JPG, SVG or PDF" = "ჩარჩოებისა და შრეების ექსპორტირება PNG, JPG, SVG ან PDF ფორმატში"
"Export frames and layers as PNG, JPG, SVG or PDF" = "ჩარჩოები და შრეები PNG, JPG, SVG ან PDF ფორმატში"
"Export page as PNG" = "გვერდის PNG ფაილად ექსპორტირება"
"Export settings" = "ექსპორტის პარამეტრები"
"Export variables" = "ცვლადების ექსპორტირება"
@@ -553,7 +553,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Font size" = "შრიფტის ზომა"
"Font style" = "შრიფტის სტილი"
"Forget on this computer" = "ამ კომპიუტერზე დავიწყება"
"Four screens of a trail app with components and a prototype flow" = "ლაშქრობის აპის ოთხი ეკრანი კომპონენტებითა და პროტოტიპის ნაკადით"
"Four screens of a trail app with components and a prototype flow" = "ლაშქრობის აპის ოთხი ეკრანი და პროტოტიპის ნაკადი"
"Fractions" = "წილადები"
"Frame" = "ჩარჩო"
"Frame selection" = "მონიშნულის ჩარჩოში მოქცევა"
@@ -576,7 +576,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Gradient" = "გრადიენტი"
"Gray" = "ნაცრისფერი"
"Green" = "მწვანე"
"Greyscale components and two example screens to sketch a flow with" = "ნაცრისფერი კომპონენტები და ორი სანიმუშო ეკრანი ნაკადის ჩანახატისთვის"
"Greyscale components and two example screens to sketch a flow with" = "ნაცრისფერი კომპონენტები ნაკადის ჩანახატისთვის"
"Grid" = "ბადე"
"Grid ({size})" = "ბადე ({size})"
"Grid styles" = "ბადის სტილები"
@@ -623,7 +623,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Hue" = "ფერის ტონი"
"Hug" = "შიგთავსზე მორგება"
"Hug contents" = "შიგთავსზე მორგება"
"Icon components on a 24 px grid with keylines and a size preview" = "ხატულების კომპონენტები 24 px ბადეზე საორიენტაციო ხაზებითა და ზომების გადახედვით"
"Icon components on a 24 px grid with keylines and a size preview" = "ხატულები 24 px ბადეზე, საორიენტაციო ხაზებით"
"Icon set starter" = "ხატულების ნაკრების საწყისი"
"If it keeps happening, please report it at {link} with this message." = "თუ ეს განმეორდება, გთხოვთ, ეს შეტყობინება გამოგვიგზავნეთ აქ: {link}"
"If" = "თუ"
@@ -745,7 +745,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Link" = "ბმული"
"Link copied" = "ბმული დაკოპირდა"
"Link docs, code or tickets for developers." = "მიაბით დოკუმენტაცია, კოდი ან ტიკეტები დეველოპერებისთვის."
"Link frames on the Prototype tab, then present the flow" = "დააკავშირეთ ჩარჩოები „პროტოტიპის“ ჩანართზე, შემდეგ წარადგინეთ ნაკადი"
"Link frames on the Prototype tab, then present the flow" = "დააკავშირეთ ჩარჩოები და წარადგინეთ ნაკადი"
"Link this component to its code component: instances then show your real snippet with their values." = "დააკავშირეთ ეს კომპონენტი მის კოდის კომპონენტთან: ინსტანსები მაშინ თქვენს რეალურ ფრაგმენტს აჩვენებს მათი მნიშვნელობებით."
"List spacing" = "სიის დაშორება"
"List style" = "სიის სტილი"
@@ -921,7 +921,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"On click" = "დაწკაპუნებით"
"On drag" = "გადათრევისას"
"On this page" = "ამ გვერდზე"
"One announcement in five common sizes: square, portrait, story, link and banner" = "ერთი განცხადება ხუთ ხშირად გამოყენებულ ზომაში: კვადრატი, ვერტიკალური, სთორი, ბმული და ბანერი"
"One announcement in five common sizes: square, portrait, story, link and banner" = "ერთი განცხადება ხუთ პოპულარულ ზომაში"
"Online" = "ონლაინ"
"Online files" = "ონლაინ ფაილები"
"Online sharing is off: files are shared on the local network only." = "ონლაინ გაზიარება გამორთულია: ფაილები მხოლოდ ლოკალურ ქსელში ზიარდება."
@@ -1003,7 +1003,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Pick color" = "ფერის არჩევა"
"Pick color (I)" = "ფერის აღება (I)"
"Pick one font family first" = "ჯერ აირჩიეთ ერთი შრიფტის ოჯახი"
"Pick the Frame tool, then drag on the canvas or choose a device size" = "აირჩიეთ ჩარჩოს ხელსაწყო, შემდეგ გადაათრიეთ ტილოზე ან აირჩიეთ მოწყობილობის ზომა"
"Pick the Frame tool, then drag on the canvas or choose a device size" = "ჩარჩოს ხელსაწყოთი გადაათრიეთ ან აირჩიეთ ზომა"
"Pin a property" = "თვისების მიმაგრება"
"Pinch" = "მოჭყლეტა"
"Pixel grid" = "პიქსელური ბადე"
@@ -1182,7 +1182,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Save files automatically" = "ფაილების ავტომატურად შენახვა"
"Search templates" = "შაბლონების ძიება"
"See all templates" = "ყველა შაბლონის ნახვა"
"Select layers and they space themselves as their content changes" = "მონიშნეთ შრეები და შიგთავსის ცვლილებისას ისინი ავტომატურად დაშორდება ერთმანეთს"
"Select layers and they space themselves as their content changes" = "შრეები შიგთავსის ცვლილებისას თავად დაშორდება"
"SHA-256: {sha}…" = "SHA-256: {sha}…"
"Same as layer" = "შრის მსგავსად"
"Saturation" = "გაჯერება"
@@ -1330,7 +1330,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Start on path" = "დასაწყისი ტრაექტორიაზე"
"Start point" = "საწყისი წერტილი"
"Start sharing" = "გაზიარების დაწყება"
"Start sharing with the Share button, then pass on the code it gives" = "გაზიარება დაიწყეთ ღილაკით „გაზიარება“, შემდეგ გადაეცით მის მიერ მოცემული კოდი"
"Start sharing with the Share button, then pass on the code it gives" = "დააჭირეთ „გაზიარებას“ და გადაეცით მიღებული კოდი"
"Starting to share…" = "გაზიარების დაწყება…"
"Status" = "სტატუსი"
"Sticky" = "მიმაგრებული"
@@ -1451,7 +1451,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Try another search or filter." = "სცადეთ სხვა ძიება ან ფილტრი."
"Try another search, or browse templates." = "სცადეთ სხვა ძიება ან დაათვალიერეთ შაბლონები."
"Try another word, or look at every kind." = "სცადეთ სხვა სიტყვა ან ნახეთ ყველა სახეობა."
"Turn a layer into a component, reuse it, and change every copy at once" = "შრე აქციეთ კომპონენტად, გამოიყენეთ ხელახლა და შეცვალეთ ყველა ასლი ერთდროულად"
"Turn a layer into a component, reuse it, and change every copy at once" = "შრე აქციეთ კომპონენტად და შეცვალეთ ყველა ასლი"
"Two digits" = "ორი ციფრი"
"Type" = "ტიპი"
"Type settings" = "ტექსტის პარამეტრები"
@@ -131,6 +131,37 @@ fn a_copy_command_invoked_for_a_synthetic_click_still_keeps_the_clipboard() {
assert_eq!(w.writes(), (1, 0));
}
/// Asserts a real `ui.menu.invoke` of Copy link is fenced from the system clipboard, while the
/// same menu item invoked by the user writes the selected layer's link.
#[test]
fn a_synthetic_ui_menu_invoke_copy_link_keeps_the_clipboard_then_a_user_copy_writes_the_link() {
let mut w = Win::new();
let selected = *w.app.session.selection().first().expect("a selected layer");
let link = format!("figcraft://node/{}", selected.0);
let (tx, rx) = std::sync::mpsc::channel();
w.app.control_queue.push(ControlRequest {
id: json!(1),
method: "ui.menu.invoke".into(),
params: json!({"id": "edit.copyLink"}),
reply: tx,
state: Default::default(),
});
w.frame(vec![]);
let reply = rx.try_recv().expect("the menu invocation is acknowledged");
assert_eq!(reply.id, json!(1));
assert!(reply.result.is_ok(), "the menu item ran: {:?}", reply.result);
assert_eq!(w.writes(), (0, 0), "a synthetic menu invocation never replaces the user's clipboard");
assert_eq!(w.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Link copied"));
assert!(!w.app.control_synthetic, "the fence ends with the frame");
crate::menus::invoke(&mut w.app, "edit.copyLink");
assert_eq!(w.writes(), (1, 0), "the user's invocation writes exactly one text clipboard entry");
let recorded = w.recorded.lock().unwrap();
assert_eq!(recorded.text, vec![link.clone()]);
assert_eq!(recorded.last_text.as_deref(), Some(link.as_str()));
}
/// Return the characters of a text node in the test window.
fn edit_text(w: &Win, id: NodeId) -> String {
match &w.app.session.doc().unwrap().node(id).unwrap().kind {
+3 -1
View File
@@ -241,8 +241,10 @@ pub fn invoke(app: &mut FigApp, id: &str) {
app.toast(tr("Copied as text"));
}
"edit.copyLink" => {
// Only a selected layer has a link, and the copy goes through the control-channel fence
// (`clipboard_set_text`): a synthetic gesture never replaces the user's clipboard.
if let Some(id) = app.session.selection().first() {
app.services.clipboard_set_text(&format!("figcraft://node/{}", id.0));
app.clipboard_set_text(&format!("figcraft://node/{}", id.0));
app.toast(tr("Link copied"));
}
}
+7
View File
@@ -2198,6 +2198,9 @@ fn player(p: &mut Presenter, ui: &mut Ui, rect: Rect, now: f64, chrome: Chrome)
p.restart(p.start, now);
}
if p.doc.as_ref().is_none_or(|d| !d.contains(p.current)) {
// Controller buttons pressed with no frame to show are dropped, as they are while a link
// notice shows.
p.pads.clear();
if !chrome.pane {
return Some(BarAction::Close);
}
@@ -2228,6 +2231,8 @@ fn player(p: &mut Presenter, ui: &mut Ui, rect: Rect, now: f64, chrome: Chrome)
// the prototype takes neither keys nor the pointer (Escape there cancels the notice).
let keys = chrome.keys && p.link_notice.is_none() && (!chrome.pane || over);
input(p, &ctx, &l, over && p.link_notice.is_none(), keys, chrome.pane, now);
} else {
p.pads.clear();
}
let Some(l) = layout(p, area, chrome) else { return action };
p.shown = Some((p.current, l.place, l.clip.intersect(area)));
@@ -2506,7 +2511,9 @@ fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_area: bool, ke
)
});
let keys = if keys_on { keys } else { Vec::new() };
// Controller buttons pressed while a link notice shows are dropped, as keys are.
let pads = std::mem::take(&mut p.pads);
let pads = if p.link_notice.is_none() { pads } else { Vec::new() };
// A transition a drag started follows the pointer while it stays down, then plays on from there.
if let Some(a) = p.anim.as_mut()
&& let Some((from, dir, len)) = a.follow
+28
View File
@@ -162,6 +162,34 @@ fn the_presenter_keys_wait_while_the_notice_shows() {
assert_eq!(notice(&u), "https://example.com/a");
}
#[test]
fn the_prototype_takes_no_controller_buttons_while_the_notice_shows() {
let mut s = Session::new();
let a = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 400, "name": "A"})));
let b = id(&ex(&mut s, "frame.create", json!({"x": 400, "y": 0, "width": 300, "height": 400, "name": "B"})));
let layer = id(&ex(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 30, "parent": a})));
let link = json!({"type": "openUrl", "url": "https://example.com/a"});
ex(&mut s, "proto.interaction.add", json!({"id": layer, "trigger": {"type": "afterDelay", "ms": 100}, "action": link}));
let to_b = json!({"type": "navigate", "dest": b});
ex(&mut s, "proto.interaction.add", json!({"id": a, "trigger": {"type": "keyPress", "key": "Gamepad A"}, "action": to_b}));
let mut u = Harness::new(s);
u.app.services = Box::new(Opened::default());
u.ctx.enable_accesskit();
u.run("proto.present", json!({"start": a}));
u.frames(40, Vec::new);
assert_eq!(notice(&u), "https://example.com/a");
let ctx = u.ctx.clone();
crate::control::handle(&mut u.app, &ctx, "ui.input", &json!({"events": [{"gamepad": "A"}]})).unwrap();
u.frames(2, Vec::new);
assert_eq!(u.app.inspect()["presenter"]["current"], json!(a), "the Gamepad A trigger did not run");
assert_eq!(notice(&u), "https://example.com/a");
click(&mut u, "Cancel");
assert!(notice(&u).is_null());
crate::control::handle(&mut u.app, &ctx, "ui.input", &json!({"events": [{"gamepad": "A"}]})).unwrap();
u.frames(2, Vec::new);
assert_eq!(u.app.inspect()["presenter"]["current"], json!(b), "the button plays once the notice closes");
}
#[test]
fn dont_show_this_again_is_saved_with_the_preferences() {
let (mut u, seen) = presented("https://example.com/a", false);
+20
View File
@@ -137,6 +137,26 @@ fn preview_pane_opens_follows_edits_and_the_selection() {
assert!(u.app.ui.preview.is_none());
}
/// A controller button pressed while the pane has no frame to show is dropped, and does not play
/// once the frame comes back.
#[test]
fn controller_buttons_pressed_with_no_frame_shown_are_dropped() {
let (mut u, a, b, _) = setup();
let to_b = json!({"type": "navigate", "dest": b});
u.run("proto.interaction.add", json!({"id": a, "trigger": {"type": "keyPress", "key": "Gamepad A"}, "action": to_b}));
u.run("proto.preview", json!({"start": a}));
u.run("edit.delete", json!({"ids": [a]}));
let ctx = u.ctx.clone();
crate::control::handle(&mut u.app, &ctx, "ui.input", &json!({"events": [{"gamepad": "A"}]})).unwrap();
u.frames(2, Vec::new);
u.run("edit.undo", json!({}));
u.frames(2, Vec::new);
assert_eq!(u.preview()["current"], json!(a), "the button pressed with no frame shown did not play");
crate::control::handle(&mut u.app, &ctx, "ui.input", &json!({"events": [{"gamepad": "A"}]})).unwrap();
u.frames(2, Vec::new);
assert_eq!(u.preview()["current"], json!(b), "a button pressed with the frame shown plays");
}
/// Asserts the preview's device viewport and scroll range, wheel and drag scrolling, the
/// end clamp with spring-back, and that navigation resets scroll to the top.
#[test]
+184 -128
View File
@@ -715,13 +715,22 @@ fn text_binding(ui: &mut Ui, style: &str, field: &str, kind: VariableType, rect:
}
}
/// A text style's typography in the editor: a sample, then font, style, size, line height and
/// letter spacing; a change redefines the style (every linked char follows). Each field takes a
/// variable: the font and style menus start with Apply variable, the other fields show a variable
/// button on hover, and a bound field shows its variable instead of its value.
fn text_properties(ui: &mut Ui, doc: &Document, style: &Style, def: &TextStyle, out: &mut Frame) {
let id = style.id.as_str();
let bound = |field: &str| style.bound.get(field).map(|v| doc.variables.get(v).map_or_else(|| v.clone(), |x| x.name.clone()));
/// The text style's variable name for `field`, or None when the field is not bound.
fn bound_name(doc: &Document, style: &Style, field: &str) -> Option<String> {
style.bound.get(field).map(|v| doc.variables.get(v).map_or_else(|| v.clone(), |x| x.name.clone()))
}
/// Redefines the text style `def` through `f`; the edit is queued only when it changes the style.
fn set_text(def: &TextStyle, id: &str, out: &mut Frame, f: impl FnOnce(&mut TextStyle)) {
let mut d = def.clone();
f(&mut d);
if d != *def {
out.act = Some(Act::SetText(id.to_string(), d));
}
}
/// The sample of the text style, with its font, style and metrics beside it.
fn text_sample_header(ui: &mut Ui, def: &TextStyle, out: &mut Frame) {
let t = Tokens::get(ui.ctx());
let (r, _) = ui.allocate_exact_size(vec2(W, 40.0), Sense::hover());
ui.painter().rect_filled(r, CornerRadius::same(5), Color32::from_white_alpha(14));
@@ -730,133 +739,180 @@ fn text_properties(ui: &mut Ui, doc: &Document, style: &Style, def: &TextStyle,
let g = ui.painter().layout(summary, regular(10.5), t.menu_dim, W - 80.0);
ui.painter().galley(pos2(r.min.x + 72.0, r.center().y - g.size().y / 2.0), g, t.menu_dim);
ui.add_space(6.0);
let set = |out: &mut Frame, f: &dyn Fn(&mut TextStyle)| {
let mut d = def.clone();
f(&mut d);
if d != *def {
out.act = Some(Act::SetText(id.to_string(), d));
}
/// The font field: a variable chip when bound, else a dropdown whose menu is returned.
fn font_family_field(ui: &mut Ui, doc: &Document, style: &Style, def: &TextStyle, out: &mut Frame) -> Option<Response> {
let id = style.id.as_str();
match bound_name(doc, style, "fontFamily") {
Some(name) => {
let (r, _) = ui.allocate_exact_size(vec2(W, widgets::FIELD_H), Sense::hover());
text_binding(ui, id, "fontFamily", VariableType::String, r, &name, widgets::Lead::Named(tr("Font")), out);
None
}
};
let half = (W - 8.0) / 2.0;
// The dropdowns' menus open outside the dark scope (they're regular popups).
let (family_resp, style_resp) = crate::theme::on_dark(ui, |ui| {
ui.spacing_mut().item_spacing = vec2(8.0, 6.0);
let family_resp = match bound("fontFamily") {
Some(name) => {
let (r, _) = ui.allocate_exact_size(vec2(W, widgets::FIELD_H), Sense::hover());
text_binding(ui, id, "fontFamily", VariableType::String, r, &name, widgets::Lead::Named(tr("Font")), out);
None
}
None => Some(widgets::dropdown(ui, &def.font_family, W, None).on_hover_text(tr("Font"))),
};
let mut style_resp = None;
ui.horizontal(|ui| {
match bound("fontStyle") {
Some(name) => {
let (r, _) = ui.allocate_exact_size(vec2(half, widgets::FIELD_H), Sense::hover());
text_binding(ui, id, "fontStyle", VariableType::String, r, &name, widgets::Lead::Named(tr("Font style")), out);
}
None => style_resp = Some(widgets::dropdown(ui, &def.font_style, half, None).on_hover_text(tr("Font style"))),
}
let size = bound("fontSize");
let (edit, ev) = widgets::number_field_bindable(
ui,
Id::unique(("ts_size", id)),
widgets::Lead::Text(tr("Size")),
Some(def.font_size),
"",
half,
1.0,
Some(size.as_deref()),
widgets::Empty::Text(tr("Mixed")),
);
if let Some(widgets::FieldEdit::Set(v) | widgets::FieldEdit::Scrub(v, true)) = edit.and_then(|e| e.settled()) {
set(out, &|d| d.font_size = v.clamp(1.0, 5000.0));
}
match ev {
Some(widgets::BindEvent::Open(at)) => crate::variable_ui::open_style(ui, id, "fontSize", VariableType::Number, at),
Some(widgets::BindEvent::Detach) => out.act = Some(Act::Unbind(id.to_string(), "fontSize".to_string())),
None => {}
}
});
ui.horizontal(|ui| {
let area = Rect::from_min_size(ui.cursor().min, vec2(half, widgets::FIELD_H));
match bound("lineHeight") {
Some(name) => {
let (r, _) = ui.allocate_exact_size(area.size(), Sense::hover());
text_binding(ui, id, "lineHeight", VariableType::Number, r, &name, widgets::Lead::Named(tr("Line height")), out);
}
None => {
if let Some(v) = widgets::text_field(ui, Id::unique(("ts_lh", id)), &def.line_height.label(), half, tr("Line height"))
&& let Some(lh) =
crate::typography::parse_line_height(&v, Some(def.line_height)).and_then(|v| crate::typography::line_height_of(&v))
{
set(out, &|d| d.line_height = lh);
}
if crate::variable_ui::variable_button(ui, area, Id::unique(("ts_bind", id, "lineHeight"))) {
crate::variable_ui::open_style(ui, id, "lineHeight", VariableType::Number, area.left_bottom());
}
}
}
let area = Rect::from_min_size(ui.cursor().min, vec2(half, widgets::FIELD_H));
match bound("letterSpacing") {
Some(name) => {
let (r, _) = ui.allocate_exact_size(area.size(), Sense::hover());
text_binding(ui, id, "letterSpacing", VariableType::Number, r, &name, widgets::Lead::Named(tr("Letter spacing")), out);
}
None => {
if let Some(v) = widgets::text_field(ui, Id::unique(("ts_ls", id)), &def.letter_spacing.label(), half, tr("Letter spacing"))
&& let Some(ls) = crate::typography::parse_letter_spacing(&v, Some(def.letter_spacing))
.and_then(|v| crate::typography::letter_spacing_of(&v))
{
set(out, &|d| d.letter_spacing = ls);
}
if crate::variable_ui::variable_button(ui, area, Id::unique(("ts_bind", id, "letterSpacing"))) {
crate::variable_ui::open_style(ui, id, "letterSpacing", VariableType::Number, area.left_bottom());
}
}
}
});
(family_resp, style_resp)
});
if let Some(family_resp) = family_resp {
crate::right::menu_popup(&family_resp).width(260.0).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
if crate::right::menu_item(ui, tr("Apply variable"), "") {
crate::variable_ui::open_style(ui, id, "fontFamily", VariableType::String, family_resp.rect.left_bottom());
ui.close();
return;
}
ui.separator();
if let Some(f) = crate::typography::font_picker(ui, Some(&def.font_family), family_resp.clicked()).picked {
set(out, &|d| {
d.font_family = f.clone();
let styles = figcraft_text::family_styles(&f);
if !styles.is_empty() && !styles.contains(&d.font_style) {
d.set_font_style(styles.iter().find(|s| s.eq_ignore_ascii_case("Regular")).or(styles.first()).cloned().unwrap_or_default());
}
});
ui.close();
}
});
None => Some(widgets::dropdown(ui, &def.font_family, W, None).on_hover_text(tr("Font"))),
}
if let Some(sr) = style_resp {
crate::right::menu_popup(&sr).width(200.0).show(|ui| {
if crate::right::menu_item(ui, tr("Apply variable"), "") {
crate::variable_ui::open_style(ui, id, "fontStyle", VariableType::String, sr.rect.left_bottom());
ui.close();
return;
}
/// The font style field (a dropdown, whose menu is returned, or a variable chip) and the size field, in one row.
fn font_style_size_row(ui: &mut Ui, doc: &Document, style: &Style, def: &TextStyle, half: f32, out: &mut Frame) -> Option<Response> {
let id = style.id.as_str();
let mut style_resp = None;
ui.horizontal(|ui| {
match bound_name(doc, style, "fontStyle") {
Some(name) => {
let (r, _) = ui.allocate_exact_size(vec2(half, widgets::FIELD_H), Sense::hover());
text_binding(ui, id, "fontStyle", VariableType::String, r, &name, widgets::Lead::Named(tr("Font style")), out);
}
ui.separator();
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
for s in figcraft_text::family_styles(&def.font_family) {
let mark = if def.font_style == s { "✓ " } else { " " };
if crate::right::menu_item(ui, &format!("{mark}{s}"), "") {
set(out, &|d| d.set_font_style(s.clone()));
ui.close();
}
None => style_resp = Some(widgets::dropdown(ui, &def.font_style, half, None).on_hover_text(tr("Font style"))),
}
font_size_field(ui, doc, style, def, half, out);
});
style_resp
}
/// The font size field, which also takes a variable.
fn font_size_field(ui: &mut Ui, doc: &Document, style: &Style, def: &TextStyle, half: f32, out: &mut Frame) {
let id = style.id.as_str();
let size = bound_name(doc, style, "fontSize");
let (edit, ev) = widgets::number_field_bindable(
ui,
Id::unique(("ts_size", id)),
widgets::Lead::Text(tr("Size")),
Some(def.font_size),
"",
half,
1.0,
Some(size.as_deref()),
widgets::Empty::Text(tr("Mixed")),
);
if let Some(widgets::FieldEdit::Set(v) | widgets::FieldEdit::Scrub(v, true)) = edit.and_then(|e| e.settled()) {
set_text(def, id, out, |d| d.font_size = v.clamp(1.0, 5000.0));
}
match ev {
Some(widgets::BindEvent::Open(at)) => crate::variable_ui::open_style(ui, id, "fontSize", VariableType::Number, at),
Some(widgets::BindEvent::Detach) => out.act = Some(Act::Unbind(id.to_string(), "fontSize".to_string())),
None => {}
}
}
/// The line height field: a variable chip when bound, else a text field with a variable button beside it.
fn line_height_field(ui: &mut Ui, doc: &Document, style: &Style, def: &TextStyle, half: f32, area: Rect, out: &mut Frame) {
let id = style.id.as_str();
match bound_name(doc, style, "lineHeight") {
Some(name) => {
let (r, _) = ui.allocate_exact_size(area.size(), Sense::hover());
text_binding(ui, id, "lineHeight", VariableType::Number, r, &name, widgets::Lead::Named(tr("Line height")), out);
}
None => {
if let Some(v) = widgets::text_field(ui, Id::unique(("ts_lh", id)), &def.line_height.label(), half, tr("Line height"))
&& let Some(lh) = crate::typography::parse_line_height(&v, Some(def.line_height)).and_then(|v| crate::typography::line_height_of(&v))
{
set_text(def, id, out, |d| d.line_height = lh);
}
if crate::variable_ui::variable_button(ui, area, Id::unique(("ts_bind", id, "lineHeight"))) {
crate::variable_ui::open_style(ui, id, "lineHeight", VariableType::Number, area.left_bottom());
}
}
}
}
/// The letter spacing field: a variable chip when bound, else a text field with a variable button beside it.
fn letter_spacing_field(ui: &mut Ui, doc: &Document, style: &Style, def: &TextStyle, half: f32, area: Rect, out: &mut Frame) {
let id = style.id.as_str();
match bound_name(doc, style, "letterSpacing") {
Some(name) => {
let (r, _) = ui.allocate_exact_size(area.size(), Sense::hover());
text_binding(ui, id, "letterSpacing", VariableType::Number, r, &name, widgets::Lead::Named(tr("Letter spacing")), out);
}
None => {
if let Some(v) = widgets::text_field(ui, Id::unique(("ts_ls", id)), &def.letter_spacing.label(), half, tr("Letter spacing"))
&& let Some(ls) =
crate::typography::parse_letter_spacing(&v, Some(def.letter_spacing)).and_then(|v| crate::typography::letter_spacing_of(&v))
{
set_text(def, id, out, |d| d.letter_spacing = ls);
}
if crate::variable_ui::variable_button(ui, area, Id::unique(("ts_bind", id, "letterSpacing"))) {
crate::variable_ui::open_style(ui, id, "letterSpacing", VariableType::Number, area.left_bottom());
}
}
}
}
/// The fields of a text style, in rows: font, then style and size, then line height and letter
/// spacing. Returns the font and style dropdowns, whose menus are opened after the fields.
fn text_property_fields(ui: &mut Ui, doc: &Document, style: &Style, def: &TextStyle, out: &mut Frame) -> (Option<Response>, Option<Response>) {
let half = (W - 8.0) / 2.0;
ui.spacing_mut().item_spacing = vec2(8.0, 6.0);
let family_resp = font_family_field(ui, doc, style, def, out);
let style_resp = font_style_size_row(ui, doc, style, def, half, out);
ui.horizontal(|ui| {
let area = Rect::from_min_size(ui.cursor().min, vec2(half, widgets::FIELD_H));
line_height_field(ui, doc, style, def, half, area, out);
let area = Rect::from_min_size(ui.cursor().min, vec2(half, widgets::FIELD_H));
letter_spacing_field(ui, doc, style, def, half, area, out);
});
(family_resp, style_resp)
}
/// The font menu: Apply variable, then the installed fonts. Picking a font keeps its style when the
/// family has one, else falls back to Regular or the first style.
fn font_family_menu(id: &str, def: &TextStyle, family_resp: &Response, out: &mut Frame) {
crate::right::menu_popup(family_resp).width(260.0).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
if crate::right::menu_item(ui, tr("Apply variable"), "") {
crate::variable_ui::open_style(ui, id, "fontFamily", VariableType::String, family_resp.rect.left_bottom());
ui.close();
return;
}
ui.separator();
if let Some(f) = crate::typography::font_picker(ui, Some(&def.font_family), family_resp.clicked()).picked {
set_text(def, id, out, |d| {
d.font_family = f.clone();
let styles = figcraft_text::family_styles(&f);
if !styles.is_empty() && !styles.contains(&d.font_style) {
d.set_font_style(styles.iter().find(|s| s.eq_ignore_ascii_case("Regular")).or(styles.first()).cloned().unwrap_or_default());
}
});
ui.close();
}
});
}
/// The font style menu: Apply variable, then the styles of the current font, the current one checked.
fn font_style_menu(id: &str, def: &TextStyle, style_resp: &Response, out: &mut Frame) {
crate::right::menu_popup(style_resp).width(200.0).show(|ui| {
if crate::right::menu_item(ui, tr("Apply variable"), "") {
crate::variable_ui::open_style(ui, id, "fontStyle", VariableType::String, style_resp.rect.left_bottom());
ui.close();
return;
}
ui.separator();
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
for s in figcraft_text::family_styles(&def.font_family) {
let mark = if def.font_style == s { "✓ " } else { " " };
if crate::right::menu_item(ui, &format!("{mark}{s}"), "") {
set_text(def, id, out, |d| d.set_font_style(s.clone()));
ui.close();
}
}
});
});
}
/// A text style's typography in the editor: a sample, then font, style, size, line height and
/// letter spacing; a change redefines the style (every linked char follows). Each field takes a
/// variable: the font and style menus start with Apply variable, the other fields show a variable
/// button on hover, and a bound field shows its variable instead of its value.
fn text_properties(ui: &mut Ui, doc: &Document, style: &Style, def: &TextStyle, out: &mut Frame) {
let id = style.id.as_str();
text_sample_header(ui, def, out);
// The dropdowns' menus open outside the dark scope (they're regular popups).
let (family_resp, style_resp) = crate::theme::on_dark(ui, |ui| text_property_fields(ui, doc, style, def, out));
if let Some(family_resp) = family_resp {
font_family_menu(id, def, &family_resp, out);
}
if let Some(sr) = style_resp {
font_style_menu(id, def, &sr, out);
}
}
+3 -2
View File
@@ -1,6 +1,6 @@
# File format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7)
Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of
`/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's
@@ -49,7 +49,7 @@ export limit, not a native document nesting limit.
| Format | Figma | FigCraft | Notes | Hours |
|---|---|---|---|---|
| PNG | yes, scales/suffixes | yes | export settings per layer, presets (`2x`, `512w`, `512h`), preview, "Export N layers"; contents only (off: every layer showing in the area) and include bounding box (the layer's own box); slashes in layer names export into folders. The exported area is what the layer paints: shadows towards their offset, outside and centre strokes, unclipped children, a mask group's mask, nothing for hidden layers; slices export everything under them, except that with contents only on a slice inside a frame or group exports only that frame or group; sides round up to whole pixels | — |
| PNG | yes, scales/suffixes | yes | export settings per layer, presets (`2x`, `512w`, `512h`), preview, "Export N layers"; ignore overlapping layers (on by default; off: every layer showing in the area) and include bounding box (the layer's own box; the Export section offers it for text layers, and `file.export` and `export.update` take it for any layer); slashes in layer names export into folders. The exported area is what the layer paints: shadows towards their offset, outside and centre strokes, unclipped children, a mask group's mask, nothing for hidden layers; slices export everything under them, except that with ignore overlapping layers on a slice inside a frame or group exports only that frame or group; sides round up to whole pixels | — |
| JPG | yes | yes | white where transparent; fixed quality (90) | — |
| SVG | yes (outline text option, ids) | yes | alpha/luminance masks kept; noise, texture, progressive and background blur, glass embedded as images; outline text (off: `<text>` with one `<tspan>` per line, one style per layer), unique id attributes from layer names, explicit depth-limit refusal, nested frames clipped by their parent's clip (#327), simplified strokes (stroke attributes for centred strokes and for inside and outside strokes on boxes and ellipses) | — |
| PDF | yes | yes | one page per frame, real selectable text in subset fonts, links, vectors and gradients as vectors, effects as images; bleed, crop/bleed marks, page boxes, naive CMYK; checked by rendering the PDF back against PNG exports; explicit depth-limit refusal | CMYK output intent 2–4 |
@@ -165,6 +165,7 @@ each.
| Date | Change | Summary |
|---|---|---|
| 2026-10-25 | patch | PNG: the export settings were listed as "contents only" and "include bounding box (the layer's own box)"; the row now names ignore overlapping layers, on by default, and says that the Export section offers include bounding box for text layers (#1307), while `file.export` and `export.update` take it for any layer. Numbers unchanged |
| 2026-10-24 | patch | Fonts: the missing-font row said "warnings only (REST import)"; it now names the Missing fonts window (#752), `text.missingFonts` and the `missing font` lines of `figcraft-cli info`. Replacing a missing font is still missing. Numbers unchanged |
| 2026-10-24 | minor | Slices and contents only (Figma's Ignore overlapping layers): with it on, a slice inside a frame or group exports only that frame or group and the layers in it, and its Export preview shows the same; with it off, or for a slice on the page or in a section, the slice exports everything in its area as before |
| 2026-10-24 | minor | Boards (M12): `.figcraft` saves the file kind (`kind: "board"`, absent for design files) and each board object's `board` metadata; the `.jam` row points at boards |
+16
View File
@@ -1408,6 +1408,22 @@ permissions: without it nothing is downloaded); `plugin.directory.publish {path
### Dev Mode
- **Pages as React + Tailwind:** `inspect.code {id, language: "tailwind"}` writes a layer tree as a
React function component styled with Tailwind CSS v4 utilities and no stylesheet. Auto layout
becomes `flex`/`flex-col`/`gap-*`/`p-*` on the 4px spacing scale, fixed-width auto layout frames
and text become fluid (`w-full max-w-[960px]`; as wide as the frame: `w-full`) and a frame's
fixed height a minimum, per-side strokes become `border-b` and the like, and instances are
inlined with their overrides (component code is `language: react`), except instances whose component
is connected to code (`codeConnect.set`/`codeConnect.import`, React): those are their snippet, e.g.
`<Button variant="outline">{"See examples"}</Button>`, with each import once at the top. A design
system mapped to shadcn/ui this way exports pages that use the project's own components. Bound variables become theme
utilities (`bg-accent`, `rounded-card`, `text-muted`) that `variable.export {format: "tailwind"}`
declares: `@import "tailwindcss";` and an `@theme static` block (every token is emitted, also those only read through `var()`) named by namespace (`--color-*`,
`--radius-*`, `--text-*`, `--spacing-*`, `--font-*`; a number's namespace and unit come from what
layers bind it to), other modes on `[data-<collection>="<mode>"]`. With `mobile: <frame id>` (the
same layer names, mobile layout) the code is mobile first: the mobile frame's classes, the
desktop frame's that differ behind `lg:`, and layers only on desktop `hidden` plus `lg:flex`
(auto layout frames) or `lg:block` (other layers).
- **Component code:** `inspect.code {id, language: react|leptos|swiftui|compose}` on a component,
a component set or a variant writes the component as a reusable unit: React (TSX) with a props
interface, Leptos with a `#[component]` function (`#[prop]` defaults, `view!` markup), SwiftUI
-23
View File
@@ -1,23 +0,0 @@
# Server memory follow-up
Joining a persisted session issues a client id and makes its journal dirty, even if nobody edits
the document. Previously, the last client leaving caused a full snapshot rewrite for that id.
For image-heavy documents this creates large temporary allocations and unnecessary disk writes;
an allocator can retain the freed memory after the room closes.
On idle, the server now flushes the journal and only compacts when the document sequence is newer
than the stored snapshot, or when flushing failed and a snapshot must be attempted as a fallback.
Issued ids and refusals remain durable in the journal. Existing size/count compaction thresholds
remain in effect, and edited documents still compact when the last participant leaves.
Snapshot encoding also appends the versioned postcard body directly to the snapshot header's
buffer, then fills the checksum. It no longer holds a separate complete encoded body alongside
the finished snapshot. The bytes, generation, checksum and file format are unchanged.
Regression coverage uses synthetic documents: six read-only sessions across store reopenings
must preserve increasing client ids without rewriting the snapshot; exact legacy framing and
checksum must match; the binary message body must be unchanged when appended to a prefix.
Existing crash recovery, damaged-input and edit compaction tests continue to apply.
This reduces unnecessary temporary allocations. It does not promise that the operating system
immediately reclaims every freed allocation, or that all large-document memory costs are solved.
+2 -2
View File
@@ -336,8 +336,8 @@ the address it came from and when it was last opened (for expiry).
beside it, flushed, then renamed over the old one. The log is only ever appended to, and synced
before the server tells anyone about the change.
- **Compaction** writes the file's state as the next snapshot and starts an empty log, after
`compact_after` entries or `compact_mb` of log, when the last person leaves, and when the server
stops. A log left over from a compaction cut short is replayed over the snapshot, which already
`compact_after` entries or `compact_mb` of log, when the last person leaves a file that was
edited (the ids of people who only joined stay in the log), and when the server stops. A log left over from a compaction cut short is replayed over the snapshot, which already
holds it all, so nothing changes. If a damaged generation field made a current log look like such
a leftover, its edits the snapshot lacks are recovered the same way and the damage is reported.
- **Damage is skipped and reported, never deleted.** At startup a folder whose description, links