mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-13 22:02:38 +00:00
M10: CSS code export writes several fills as one layered background, top first, as Figma does (#2271)
inspect.code wrote one `background` per visible fill, so only the last survived. A non-text layer now gets one `background` with every visible fill as a layer, top fill first (a document's fills run bottom to top), joined with ", ". A solid above another fill is `linear-gradient(0deg, c, c)` since CSS allows a plain colour only as the last layer; the bottom solid stays a plain colour (and keeps its variable token); gradients, images and patterns keep their layers. A single fill is unchanged. Text keeps one `color` per fill. Tailwind emits a layered value as one `[background:...]` class. Native targets are unchanged. Tests: several_fills_are_one_layered_background_top_first, one_solid_fill_stays_a_plain_colour, several_fills_are_one_layered_background_class. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Model-Effort: Opus 5.5 drove; Fable 5.2 queued; Opus 5.5 briefed; Sonnet 5.5 measured and wrote at medium
This commit is contained in:
1 parent
0d6ce1124c
commit
9467d2355b
5 files changed
+117
-12
No files matched your search
@@ -506,6 +506,44 @@ mod tests {
|
||||
assert!(!css.contains("#000000 0%"), "{css}");
|
||||
}
|
||||
|
||||
/// The measured frame: white at the bottom, a 20% gradient above it, 50% black on top.
|
||||
fn layered_frame(s: &mut Session) -> figcraft_doc::NodeId {
|
||||
let f = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 120})).unwrap()["id"]).unwrap();
|
||||
let mut g = figcraft_color::Paint::gradient(figcraft_color::Gradient::default_for(figcraft_color::GradientKind::Linear, Color::BLACK));
|
||||
g.opacity = 0.2;
|
||||
let mut top = figcraft_color::Paint::solid(Color::BLACK);
|
||||
top.opacity = 0.5;
|
||||
s.doc_mut().unwrap().get_mut(f).unwrap().fills = vec![figcraft_color::Paint::solid(Color::WHITE), g, top];
|
||||
f
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn several_fills_are_one_layered_background_top_first() {
|
||||
let mut s = Session::new();
|
||||
let f = layered_frame(&mut s);
|
||||
let css = code_for(s.doc().unwrap(), f, CodeLang::Css);
|
||||
let bg: Vec<&str> = css.lines().filter(|l| l.starts_with("background:")).collect();
|
||||
assert_eq!(bg.len(), 1, "{css}");
|
||||
let v = bg[0];
|
||||
assert!(
|
||||
v.starts_with(
|
||||
"background: linear-gradient(0deg, rgba(0, 0, 0, 0.50), rgba(0, 0, 0, 0.50)), linear-gradient(180deg, rgba(0, 0, 0, 0.20) 0%, "
|
||||
),
|
||||
"{v}"
|
||||
);
|
||||
assert!(v.ends_with(", #FFFFFF;"), "{v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_solid_fill_stays_a_plain_colour() {
|
||||
let mut s = Session::new();
|
||||
let f = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap()["id"]).unwrap();
|
||||
s.doc_mut().unwrap().get_mut(f).unwrap().fills = vec![figcraft_color::Paint::solid(Color::WHITE)];
|
||||
let css = code_for(s.doc().unwrap(), f, CodeLang::Css);
|
||||
assert!(css.contains("background: #FFFFFF;\n"), "{css}");
|
||||
assert!(!css.contains("gradient"), "{css}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swiftui_and_compose_for_an_auto_layout_card() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -160,17 +160,48 @@ pub(crate) fn css_decls(doc: &Document, n: &Node, t: &mut Tokens, hug: (bool, bo
|
||||
put("transform", format!("rotate({}deg)", num(-rot)));
|
||||
put("transform-origin", "top left".into());
|
||||
}
|
||||
for (i, p) in n.fills.iter().enumerate().filter(|(_, p)| p.visible) {
|
||||
match &p.kind {
|
||||
PaintKind::Solid { color } => {
|
||||
let key = if matches!(n.kind, NodeKind::Text(_)) { "color" } else { "background" };
|
||||
let c = color.with_alpha(color.a * p.opacity).to_css();
|
||||
// A variable binds the first paint's colour.
|
||||
put(key, if i == 0 { tok(doc, n, &["fill"], c, t) } else { c });
|
||||
if matches!(n.kind, NodeKind::Text(_)) {
|
||||
// Text: a solid is the ink (`color`), one per fill, so the last (top) one wins.
|
||||
for (i, p) in n.fills.iter().enumerate().filter(|(_, p)| p.visible) {
|
||||
match &p.kind {
|
||||
PaintKind::Solid { color } => {
|
||||
let c = color.with_alpha(color.a * p.opacity).to_css();
|
||||
// A variable binds the first paint's colour.
|
||||
put("color", if i == 0 { tok(doc, n, &["fill"], c, t) } else { c });
|
||||
}
|
||||
PaintKind::Gradient(g) => put("background", css_gradient(g, n.width, n.height, p.opacity)),
|
||||
PaintKind::Image(_) => put("background", "url(image.png) center / cover".into()),
|
||||
PaintKind::Pattern(_) => put("background", "url(pattern.png) repeat".into()),
|
||||
}
|
||||
PaintKind::Gradient(g) => put("background", css_gradient(g, n.width, n.height, p.opacity)),
|
||||
PaintKind::Image(_) => put("background", "url(image.png) center / cover".into()),
|
||||
PaintKind::Pattern(_) => put("background", "url(pattern.png) repeat".into()),
|
||||
}
|
||||
} else {
|
||||
// One `background` with every visible fill as a layer, top first (a document's fills run
|
||||
// bottom to top, as the renderer paints them). CSS allows a plain colour only as the last
|
||||
// layer, so a solid above another fill becomes a one-colour gradient.
|
||||
let visible: Vec<(usize, &figcraft_color::Paint)> = n.fills.iter().enumerate().filter(|(_, p)| p.visible).collect();
|
||||
let layers: Vec<String> = visible
|
||||
.iter()
|
||||
.enumerate()
|
||||
.rev()
|
||||
.map(|(pos, (i, p))| match &p.kind {
|
||||
PaintKind::Solid { color } => {
|
||||
let c = color.with_alpha(color.a * p.opacity).to_css();
|
||||
if pos > 0 {
|
||||
format!("linear-gradient(0deg, {c}, {c})")
|
||||
} else if *i == 0 {
|
||||
// A variable binds the first paint's colour.
|
||||
tok(doc, n, &["fill"], c, t)
|
||||
} else {
|
||||
c
|
||||
}
|
||||
}
|
||||
PaintKind::Gradient(g) => css_gradient(g, n.width, n.height, p.opacity),
|
||||
PaintKind::Image(_) => "url(image.png) center / cover".into(),
|
||||
PaintKind::Pattern(_) => "url(pattern.png) repeat".into(),
|
||||
})
|
||||
.collect();
|
||||
if !layers.is_empty() {
|
||||
put("background", layers.join(", "));
|
||||
}
|
||||
}
|
||||
for (key, value) in css_borders(doc, n, t) {
|
||||
|
||||
@@ -105,6 +105,23 @@ fn arb(v: &str) -> String {
|
||||
format!("[{}]", v.trim().replace('_', "\\_").replace(' ', "_"))
|
||||
}
|
||||
|
||||
/// Whether `v` has a comma outside any parentheses (a multi-layer `background`).
|
||||
fn layered(v: &str) -> bool {
|
||||
let mut depth = 0usize;
|
||||
v.chars().any(|c| match c {
|
||||
'(' => {
|
||||
depth += 1;
|
||||
false
|
||||
}
|
||||
')' => {
|
||||
depth = depth.saturating_sub(1);
|
||||
false
|
||||
}
|
||||
',' => depth == 0,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
@@ -270,6 +287,8 @@ fn decl(key: &str, value: &str) -> Vec<(String, String)> {
|
||||
Ok(o) if ((o * 100.0).round() - o * 100.0).abs() < 1e-9 => one(format!("opacity-{}", num(o * 100.0))),
|
||||
_ => one(util("opacity", "", v, "")),
|
||||
},
|
||||
// Several fills are one layered `background`: commas between the layers, outside any call.
|
||||
("background", v) if layered(v) => one(format!("[background:{}]", arb(v).trim_matches(['[', ']']))),
|
||||
("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(['[', ']']))),
|
||||
|
||||
@@ -353,3 +353,19 @@ fn font_family_with_quote_and_bracket_stays_a_valid_class_token() {
|
||||
assert!(after.starts_with('>') && !attr.contains('\\'), "{out}");
|
||||
assert!(!inner.is_empty() && !inner.contains(['\\', '"', ']']), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn several_fills_are_one_layered_background_class() {
|
||||
let mut s = Session::new();
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 120})));
|
||||
let mut g =
|
||||
figcraft_color::Paint::gradient(figcraft_color::Gradient::default_for(figcraft_color::GradientKind::Linear, figcraft_color::Color::BLACK));
|
||||
g.opacity = 0.2;
|
||||
let mut top = figcraft_color::Paint::solid(figcraft_color::Color::BLACK);
|
||||
top.opacity = 0.5;
|
||||
s.doc_mut().unwrap().get_mut(f).unwrap().fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::WHITE), g, top];
|
||||
let out = code(&mut s, f, json!({}));
|
||||
let class = out.split(['"', ' ']).find(|c| c.starts_with("[background:")).unwrap_or_default();
|
||||
assert!(class.starts_with("[background:linear-gradient(0deg,_rgba(0,_0,_0,_0.50),_rgba(0,_0,_0,_0.50)),_linear-gradient(180deg"), "{out}");
|
||||
assert!(class.ends_with(",_#FFFFFF]"), "{out}");
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# Dev Mode parity
|
||||
|
||||
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-11-09 · **Change:** patch (the Annotation tool (Shift+T, `tool.annotation {id?}`): with a layer selected (or an `id`) it asks the UI for that layer's note at once, and in the tool a press on a layer selects it and asks for its note, a press on empty canvas does nothing; the Inspect tab then opens (never toggles away) with the caret in that layer's new-note field, and the engine never writes the note itself (#1840); the handoff mode reads Developer Mode in the interface, its ids and saved fields unchanged; class names transliterate non-ASCII layer names (#1235); measurements kept on the canvas; a read-only layers panel in Dev Mode; earlier: import lines in generated layer trees; compare with any saved version; Dev Mode behaviour suite: an inspect-only canvas, Changed in the ready list, statuses only on frames and sections; plugin panels from the Inspect tab) · **Target:** Figma Design Dev Mode (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-11-12 · **Change:** patch (CSS code export writes several fills as one layered `background`, top first, as the incumbent does (#2271): a solid above another fill becomes a one-colour gradient, the bottom solid stays a plain colour, Tailwind gets one `[background:...]` class; the Annotation tool (Shift+T, `tool.annotation {id?}`): with a layer selected (or an `id`) it asks the UI for that layer's note at once, and in the tool a press on a layer selects it and asks for its note, a press on empty canvas does nothing; the Inspect tab then opens (never toggles away) with the caret in that layer's new-note field, and the engine never writes the note itself (#1840); the handoff mode reads Developer Mode in the interface, its ids and saved fields unchanged; class names transliterate non-ASCII layer names (#1235); measurements kept on the canvas; a read-only layers panel in Dev Mode; earlier: import lines in generated layer trees; compare with any saved version; Dev Mode behaviour suite: an inspect-only canvas, Changed in the ready list, statuses only on frames and sections; plugin panels from the Inspect tab) · **Target:** Figma Design Dev Mode (desktop app 126.10.7)
|
||||
|
||||
**Breadth ~98%, ready ~78% (estimated).** The inspect surface is broad, components connect
|
||||
to the code components of a repository (with their imports, also inside generated layer trees),
|
||||
@@ -13,7 +13,7 @@ repository, third-party integrations. Remaining: 1–2 h.
|
||||
| 2026-11-07 | patch | Naming: the handoff mode is called Developer Mode in the interface, its shortcut row, its toasts, the control channel's refusal, the board refusal, the measurement parameter doc, the `.fig` export note, the CLI help and every catalog (24 languages, translations awaiting native review), since Figma holds a US registration for Dev Mode (7574348); command ids (`view.devMode`, `devMode.*`), preference keys and saved-document fields are unchanged, and the Actions palette still finds the command by the old name |
|
||||
| Dev Mode toggle (Shift+D), Inspect panel | done | the canvas only inspects: presses select, pan and comment but never move, resize, draw or edit text and vectors; menus and shortcuts refuse design edits with a note (Dev Mode's annotations, statuses, dev resources, measurements, code connections and comments still work); a drawing tool chosen in the toolbar goes back to the design. The layers panel only selects and expands (no renaming, reordering, hiding or locking; a double click says why), and the Pages list only opens pages (no adding, renaming, reordering, duplicating or deleting). Files dropped on the canvas aren't imported (a dropped document still opens as a file of its own), and the Assets panel places, swaps and applies nothing, doesn't change Hide when publishing, and doesn't accept library updates or add, remove or swap libraries; each says why |
|
||||
| Properties with their styles and variables; redlines on hover | done | positions from the frame around the layer (groups aren't frames), rotation, radius (CSS corner order), opacity, blend; colours in hex, RGB, CSS, HSL or HSB (`inspect.properties {colorFormat}`, the Inspect tab's Color format) |
|
||||
| Code: CSS, iOS (SwiftUI), Android (Compose), Flutter | done | plus HTML, React, Vue and Leptos (ours; Leptos: `#[component]` functions with `#[prop]` defaults, variant enums and `view!` markup; Flutter is ours too: a `StatelessWidget` for a component, a widget tree for a layer); components take properties as parameters, instances are calls; CSS in px or rem (`inspect.code {unit, rootFontSize}`), with rotation, blend modes, gradient directions and kinds (linear, radial, conic), text alignment, case and decoration; class names from layer names transliterate non-ASCII scripts into readable ASCII (로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber`; symbols-only names take the node's kind, `.text`), and the 40-character cut lands on a word boundary (#1235) |
|
||||
| Code: CSS, iOS (SwiftUI), Android (Compose), Flutter | done | plus HTML, React, Vue and Leptos (ours; Leptos: `#[component]` functions with `#[prop]` defaults, variant enums and `view!` markup; Flutter is ours too: a `StatelessWidget` for a component, a widget tree for a layer); components take properties as parameters, instances are calls; CSS in px or rem (`inspect.code {unit, rootFontSize}`), with rotation, blend modes, several fills as one layered `background` (top first), gradient directions and kinds (linear, radial, conic), text alignment, case and decoration; class names from layer names transliterate non-ASCII scripts into readable ASCII (로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber`; symbols-only names take the node's kind, `.text`), and the 40-character cut lands on a word boundary (#1235) |
|
||||
| Assets list with export | done | layers with export settings, icons (small, vector shapes only: SVG) and images (PNG); the icon rule is ours |
|
||||
| Annotations: pinned properties, categories, coloured callouts, filter | done | the Annotation tool (Shift+T, `tool.annotation`) picks a layer by a click or from the selection and opens its note field in the Inspect tab. Figma's placement, cursor and whether the tool stays selected are unmeasured (Developer Mode is paywalled in the web app); staying selected until V or Escape, like the Comment tool, is inferred, and there is no toolbar button or icon yet |
|
||||
| Ready for dev list, statuses, "Changed" after edits | done | the list filters Ready for dev, Changed, Completed or any, and badges changed ones (`inspect.readyForDev {status: changed}`, `changed` per item); only sections and frames at the top of a page or section take a status (others are refused) |
|
||||
@@ -29,6 +29,7 @@ repository, third-party integrations. Remaining: 1–2 h.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-12 | patch | CSS code export (#2271): a layer with several fills wrote one `background` per fill, so only the last survived. It now writes one `background` with every visible fill as a layer, top fill first, comma-separated: a solid above another fill is `linear-gradient(0deg, c, c)` (CSS allows a plain colour only as the last layer), the bottom solid stays a plain colour (with its variable), images and patterns keep their `url(...)` layers. Tailwind output carries the layered value as one `[background:...]` class. Text keeps one `color` per fill. Native targets unchanged. Percentages and hours unchanged |
|
||||
| 2026-11-09 | patch | The Annotation tool (Shift+T, `tool.annotation {id?}`): selects the layer pressed on (or the selection, or `id`) and asks the UI for its note, which the Inspect tab takes in its new-note field; the engine writes nothing itself, and Developer Mode lets the tool through its gates (#1840). Figma's placement, cursor and whether the tool stays selected are unmeasured (Developer Mode is paywalled in the web app); staying selected until V or Escape, like the Comment tool, is inferred, and there is no toolbar button or icon yet |
|
||||
| 2026-10-30 | minor | Gap 16: `figcraft-cli connect publish <file.figcraft> [--mappings figcraft.connect.json] [--dry-run]` publishes a code repository's mappings to a design file (as the incumbent's Code Connect CLI publishes a repository's mappings): it links each entry's component or set by name, warns about and skips links that don't fit, saves the file in place through the engine's save (or not, with `--dry-run`) and fails when nothing links. Dev Mode breadth (~98%) and ready (~78%) unchanged at this rounding; hours unchanged |
|
||||
| 2026-10-26 | minor | Class names from layer names transliterate non-ASCII scripts (#1235): `class_name` runs names through `deunicode` (new dependency: pure Rust tables, zero deps, BSD-3-Clause), so Korean, Cyrillic, Latin-diacritic and CJK names become readable ASCII classes in HTML/CSS/React/Leptos output, component roots and `data-*` variant attributes (`rogeuin-hwamyeon`, `knopka`, `uber`, `an-niu`; before, every one of them came out `layer-`); symbols-only names take the node's kind (`.text`, `.frame`) instead of `layer-`, and the 40-character cut lands on a word boundary. ASCII-only names are unchanged. `html_codegen_tests` updated (its diacritics expectation improves from `layer-1-n-code-name` to `layer-1-unicode-name`) and extended. Percentages and hours unchanged |
|
||||
|
||||
Reference in new issue
Block a user