From c2a67f81e56c383be97cb64044248b5721f69f7f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 13 Oct 2026 01:42:20 +0700 Subject: [PATCH] M11: starter templates: eight templates built in code, template.list and file.newFromTemplate, help.gettingStarted --- crates/engine/src/catalog.rs | 9 +- crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/cmd/view.rs | 14 + crates/engine/src/lib.rs | 7 + crates/engine/src/templates/dashboard.rs | 242 +++++++++++ crates/engine/src/templates/design_system.rs | 219 ++++++++++ crates/engine/src/templates/glyphs.rs | 43 ++ crates/engine/src/templates/icon_set.rs | 85 ++++ crates/engine/src/templates/kit.rs | 412 +++++++++++++++++++ crates/engine/src/templates/landing.rs | 191 +++++++++ crates/engine/src/templates/mobile.rs | 195 +++++++++ crates/engine/src/templates/mod.rs | 197 +++++++++ crates/engine/src/templates/slides.rs | 131 ++++++ crates/engine/src/templates/social.rs | 58 +++ crates/engine/src/templates/wireframe.rs | 193 +++++++++ crates/engine/src/templates_tests.rs | 218 ++++++++++ crates/engine/src/tests.rs | 2 + 17 files changed, 2216 insertions(+), 1 deletion(-) create mode 100644 crates/engine/src/templates/dashboard.rs create mode 100644 crates/engine/src/templates/design_system.rs create mode 100644 crates/engine/src/templates/glyphs.rs create mode 100644 crates/engine/src/templates/icon_set.rs create mode 100644 crates/engine/src/templates/kit.rs create mode 100644 crates/engine/src/templates/landing.rs create mode 100644 crates/engine/src/templates/mobile.rs create mode 100644 crates/engine/src/templates/mod.rs create mode 100644 crates/engine/src/templates/slides.rs create mode 100644 crates/engine/src/templates/social.rs create mode 100644 crates/engine/src/templates/wireframe.rs create mode 100644 crates/engine/src/templates_tests.rs diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 3e6acaf9..66439ecb 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -21,6 +21,9 @@ pub const SEP: &str = "-"; pub static MENU: &[MenuItem] = &[ // File m!("File", "New design file", "file.new"), + // FigCraft's own: starter templates (not counted by `parity`, like the other items of its own + // listed in `OWN`). + m!("File", "New from template…", "file.newFromTemplate"), m!("File", "New window", "window.new"), m!("File", "Open…", "file.open"), m!("File", "Import from file…", "file.import"), @@ -251,6 +254,7 @@ pub static MENU: &[MenuItem] = &[ m!("Libraries", "Publish library…", "libraries.publish"), m!("Libraries", "Hide when publishing", "component.hideWhenPublishing"), // Help + m!("Help", "Getting started", "help.gettingStarted"), m!("Help", "Keyboard shortcuts", "help.shortcuts"), m!("Help", "Help page", "help.page"), m!("Help", "About FigCraft", "help.about"), @@ -280,12 +284,15 @@ pub fn items() -> impl Iterator { MENU.iter().filter(|m| m.label != SEP) } +/// Items of FigCraft's own that the parity count leaves out (as it does the `collab.` ones). +const OWN: [&str; 2] = ["file.newFromTemplate", "help.gettingStarted"]; + /// Coverage: (implemented, total, missing ids). `extra` lists ids implemented by the UI layer. pub fn parity(extra: &[&str]) -> (usize, usize, Vec<&'static str>) { let mut done = 0; let mut missing = Vec::new(); let mut total = 0; - for m in items().filter(|m| !m.id.starts_with("collab.")) { + for m in items().filter(|m| !m.id.starts_with("collab.") && !OWN.contains(&m.id)) { total += 1; if crate::find_command(m.id).is_some() || extra.contains(&m.id) { done += 1; diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index ea65cfd9..8ee661c8 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -105,6 +105,7 @@ fn registry() -> &'static Registry { list.extend(crate::live::specs()); list.extend(crate::online::specs()); list.extend(crate::invite_links::specs()); + list.extend(crate::templates::specs()); let mut index = HashMap::new(); for (i, s) in list.iter().enumerate() { if index.insert(s.id, i).is_some() { diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 09a3a889..b068544d 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -382,6 +382,20 @@ pub fn specs() -> Vec { spec!("help.shortcuts", "Keyboard shortcuts", "{}", false, |s, _| ui(s, "panel.shortcuts")), spec!("help.about", "About FigCraft", "{}", false, |s, _| ui(s, "dialog.about")), spec!("help.page", "Help page", "{}", false, |s, _| ui(s, "open.help")), + spec!( + "help.gettingStarted", + "Getting started", + "{show? (default true; false closes them)} the Getting started tips on the home screen: shows them again (the app opens the home screen), or closes them until asked for → {shown}", + false, + |s, p| { + let show = boolean(p, "show").unwrap_or(true); + s.prefs.getting_started_dismissed = !show; + if show { + s.request_ui("home.gettingStarted", json!({})); + } + Ok(json!({"shown": show})) + } + ), spec!("libraries.open", "Libraries", "{}", false, |s, _| ui(s, "panel.libraries")), spec!( "tool.set", diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 91d03bfc..44bd5d16 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -47,6 +47,7 @@ pub mod shapebuilder; pub mod snap; pub mod synthetic; pub mod synthetic_ds; +pub mod templates; pub mod textedit; pub mod textpreview; pub mod tools; @@ -585,6 +586,9 @@ pub struct Prefs { /// [`online`]), most recently opened first, at most [`online::MAX_ONLINE_FILES`]. #[serde(skip_serializing_if = "Vec::is_empty", deserialize_with = "online::read")] pub online_files: Vec, + /// The Getting started tips on the home screen were closed (Help ▸ Getting started shows them + /// again). + pub getting_started_dismissed: bool, } impl Default for Prefs { @@ -609,6 +613,7 @@ impl Default for Prefs { publish_server: live::DEFAULT_SERVER.into(), recent_files: Vec::new(), online_files: Vec::new(), + getting_started_dismissed: false, } } } @@ -1509,6 +1514,8 @@ mod styles_behaviour_tests; #[cfg(test)] mod svg_import_tests; #[cfg(test)] +mod templates_tests; +#[cfg(test)] mod tests; #[cfg(test)] mod text_behaviour_tests; diff --git a/crates/engine/src/templates/dashboard.rs b/crates/engine/src/templates/dashboard.rs new file mode 100644 index 00000000..23ed506a --- /dev/null +++ b/crates/engine/src/templates/dashboard.rs @@ -0,0 +1,242 @@ +//! Dashboard: one analytics screen of an invented booking product ("Ridgeline Analytics"): a +//! sidebar, then figures, charts and a table placed on a grid auto layout. The charts are drawn +//! from vectors, rectangles and arcs that follow their plot's size. + +use figcraft_doc::NodeId; +use serde_json::{Value, json}; + +use super::glyphs::{self, Glyph}; +use super::kit::*; +use crate::Result; + +const NAV: [(&str, Glyph); 6] = [ + ("Overview", glyphs::CHART), + ("Bookings", glyphs::CALENDAR), + ("Routes", glyphs::MOUNTAIN), + ("Guides", glyphs::USER), + ("Messages", glyphs::MAIL), + ("Settings", glyphs::SLIDERS), +]; + +/// The figures across the top: (label, value, change, whether the change is good). +const FIGURES: [(&str, &str, &str, bool); 4] = [ + ("Revenue", "€48,260", "+12.4%", true), + ("Bookings", "1,284", "+8.1%", true), + ("Average group", "6.3", "+0.4", true), + ("Cancellations", "3.2%", "+0.6%", false), +]; + +/// Revenue per week, as a share of the plot's height. +const REVENUE: [f64; 12] = [0.32, 0.38, 0.35, 0.46, 0.52, 0.48, 0.61, 0.58, 0.70, 0.66, 0.78, 0.86]; +const LAST_YEAR: [f64; 12] = [0.26, 0.30, 0.33, 0.36, 0.41, 0.44, 0.47, 0.52, 0.55, 0.57, 0.62, 0.66]; +const WEEKDAYS: [(&str, f64); 7] = [("Mon", 0.42), ("Tue", 0.36), ("Wed", 0.48), ("Thu", 0.55), ("Fri", 0.74), ("Sat", 0.96), ("Sun", 0.82)]; +/// Bookings by kind of walk: (label, share, colour). +const KINDS: [(&str, f64, &str); 3] = [("Day walks", 0.54, PRIMARY), ("Weekends", 0.31, ACCENT), ("Courses", 0.15, SEA)]; +const SEA: &str = "#6FB09B"; +const BAD: [&str; 2] = ["#B4472E", "#FBE9E4"]; + +const BOOKINGS: [[&str; 5]; 4] = [ + ["Cedar Hollow Loop", "Ilse Okafor", "14 June", "Confirmed", "€420"], + ["Lantern Ridge at dawn", "Tomas Beck", "15 June", "Confirmed", "€680"], + ["Mill Pond family walk", "Mara Lindqvist", "15 June", "Waiting", "€150"], + ["Harrow Fell weekend", "Ilse Okafor", "21 June", "Confirmed", "€1,940"], +]; + +/// A chart's plot is drawn at this size, then follows its card. +const PLOT: [f64; 2] = [800.0, 220.0]; + +pub(super) fn build(k: &mut Kit) -> Result<()> { + k.foundations()?; + k.rename_page("Dashboard")?; + let page = k.page()?; + let frame = json!({"fill": PAGE, "stroke": BORDER, "strokeWeight": 1, "strokeAlign": "outside", "clip": true, "sizingH": "fixed", "sizingV": "fixed", "width": 1440, "height": 960}); + let root = k.stack(page, "Dashboard", H, 0.0, json!(0), frame)?; + sidebar(k, root)?; + let main = k.stack(root, "Main", V, 24.0, json!(32), json!({"sizingH": "fill", "sizingV": "fill"}))?; + top_bar(k, main)?; + let grid = json!({"layout": "grid", "gridColumns": 4, "gridRows": 3, "columnTracks": ["1fr", "1fr", "1fr", "1fr"], "rowTracks": ["auto", "1fr", "1fr"], "crossGap": 20, "sizingH": "fill", "sizingV": "fill"}); + let grid = k.stack(main, "Grid", H, 20.0, json!(0), grid)?; + for (label, value, change, good) in FIGURES { + figure(k, grid, label, value, change, good)?; + } + revenue(k, grid)?; + kinds(k, grid)?; + weekdays(k, grid)?; + bookings(k, grid) +} + +fn sidebar(k: &mut Kit, root: NodeId) -> Result<()> { + let props = json!({"fill": CARD, "stroke": BORDER, "strokeSides": RIGHT_EDGE, "sizingH": "fixed", "width": 248, "sizingV": "fill", "mainAlign": "spaceBetween"}); + let bar = k.stack(root, "Sidebar", V, 32.0, json!([28, 16, 20, 16]), props)?; + let top = k.stack(bar, "Top", V, 28.0, json!(0), json!({"sizingH": "fill"}))?; + let logo = k.stack(top, "Logo", H, 8.0, json!([0, 12, 0, 12]), json!({"crossAlign": "center"}))?; + k.icon(logo, &glyphs::MOUNTAIN, ICON, PRIMARY)?; + k.text(logo, "Ridgeline Analytics", "Heading/S", INK)?; + let nav = k.stack(top, "Navigation", V, 4.0, json!(0), json!({"sizingH": "fill"}))?; + for (i, (label, glyph)) in NAV.iter().enumerate() { + let (color, style, props) = if i == 0 { (PRIMARY, "Label/M", json!({"fill": SOFT})) } else { (MUTED, "Body/M", json!({})) }; + let props = merged(json!({"sizingH": "fill", "cornerRadius": 12, "crossAlign": "center"}), props); + let item = k.stack(nav, label, H, 12.0, json!([10, 12, 10, 12]), props)?; + k.icon(item, glyph, 20.0, color)?; + k.text(item, label, style, color)?; + } + let user = k.stack(bar, "Account", H, 12.0, json!(12), json!({"fill": PAGE, "cornerRadius": 16, "sizingH": "fill", "crossAlign": "center"}))?; + let avatar = k.tile(user, 36.0, 18.0)?; + k.text(avatar, "TB", "Label/S", PRIMARY)?; + let who = k.stack(user, "Who", V, 0.0, json!(0), json!({"sizingH": "fill"}))?; + k.text(who, "Tomas Beck", "Label/M", INK)?; + k.text(who, "Operations", "Body/S", MUTED).map(drop) +} + +fn top_bar(k: &mut Kit, main: NodeId) -> Result<()> { + let bar = k.stack(main, "Top bar", H, 16.0, json!(0), json!({"sizingH": "fill", "mainAlign": "spaceBetween", "crossAlign": "center"}))?; + let title = k.stack(bar, "Title", V, 2.0, json!(0), json!({}))?; + k.text(title, "Overview", "Heading/L", INK)?; + k.text(title, "Bookings and revenue, 1 March to 31 May", "Body/M", MUTED)?; + let tools = k.stack(bar, "Tools", H, 12.0, json!(0), json!({"crossAlign": "center"}))?; + let field = + json!({"fill": CARD, "cornerRadius": 14, "stroke": BORDER, "strokeWeight": 1, "sizingH": "fixed", "width": 260, "crossAlign": "center"}); + let search = k.stack(tools, "Search", H, 10.0, json!([11, 14, 11, 14]), field)?; + k.icon(search, &glyphs::SEARCH, 20.0, MUTED)?; + k.text(search, "Search bookings", "Body/M", MUTED)?; + let bell = k.tile(tools, 44.0, 14.0)?; + k.set(bell, json!({"name": "Notifications", "fill": CARD, "stroke": BORDER, "strokeWeight": 1}))?; + k.icon(bell, &glyphs::BELL, 20.0, INK)?; + k.chip(tools, "Export", ["Label/M", ON_BRAND], PRIMARY, [12.0, 18.0], 14.0).map(drop) +} + +/// A card in the grid, `span` columns wide, holding a title row and what follows. +fn card(k: &mut Kit, grid: NodeId, name: &str, span: usize, note: &str) -> Result { + let props = json!({"fill": CARD, "cornerRadius": 20, "sizingH": "fill", "sizingV": "fill", "gridColumnSpan": span}); + let card = k.stack(grid, name, V, 16.0, json!(20), props)?; + let head = k.stack(card, "Heading", H, 8.0, json!(0), json!({"sizingH": "fill", "mainAlign": "spaceBetween", "crossAlign": "center"}))?; + k.text(head, name, "Heading/S", INK)?; + k.text(head, note, "Body/S", MUTED)?; + Ok(card) +} + +fn figure(k: &mut Kit, grid: NodeId, label: &str, value: &str, change: &str, good: bool) -> Result<()> { + let card = k.stack(grid, label, V, 8.0, json!(20), json!({"fill": CARD, "cornerRadius": 20, "sizingH": "fill"}))?; + k.text(card, label, "Body/S", MUTED)?; + let row = k.stack(card, "Value", H, 8.0, json!(0), json!({"sizingH": "fill", "mainAlign": "spaceBetween", "crossAlign": "center"}))?; + k.text(row, value, "Heading/L", INK)?; + let [color, fill] = if good { [PRIMARY, SOFT] } else { BAD }; + k.chip(row, change, ["Label/S", color], fill, [4.0, 10.0], 999.0).map(drop) +} + +/// A plot area in `card` that fills what room is left; its shapes follow its size. +fn plot(k: &mut Kit, card: NodeId, size: [f64; 2]) -> Result { + k.frame(card, "Plot", [0.0, 0.0, size[0], size[1]], json!({"fills": [], "clip": false})) +} + +/// Make `plot`'s children follow its size, then let it fill its card. +fn fit(k: &mut Kit, plot: NodeId, shapes: &[NodeId]) -> Result<()> { + for id in shapes { + k.set(*id, json!({"constraints": {"horizontal": "scale", "vertical": "scale"}}))?; + } + k.set(plot, json!({"sizingH": "fill", "sizingV": "fill"})) +} + +/// Labels under a plot, spread across its width. +fn axis(k: &mut Kit, card: NodeId, labels: &[&str]) -> Result<()> { + let row = k.stack(card, "Axis", H, 0.0, json!(0), json!({"sizingH": "fill", "mainAlign": "spaceBetween"}))?; + for label in labels { + k.text(row, label, "Label/S", MUTED)?; + } + Ok(()) +} + +/// A polyline through `values` (shares of the height) across the plot, as path data. +fn polyline(values: &[f64], [w, h]: [f64; 2]) -> String { + let step = w / (values.len().max(2) - 1) as f64; + values.iter().enumerate().map(|(i, v)| format!("{}{:.1} {:.1}", if i == 0 { "M" } else { " L" }, i as f64 * step, h - v * h)).collect() +} + +fn revenue(k: &mut Kit, grid: NodeId) -> Result<()> { + let card = card(k, grid, "Revenue", 3, "This year and last year, by week")?; + let p = plot(k, card, PLOT)?; + let [w, h] = PLOT; + let mut shapes = Vec::new(); + for i in 0..4 { + let y = h * f64::from(i) / 4.0; + shapes.push(k.line(p, "Grid line", &format!("M0 {y} H{w}"), BORDER, 1.0)?); + } + shapes.push(k.line(p, "Baseline", &format!("M0 {h} H{w}"), MUTED, 1.0)?); + let area = format!("{} V{h} H0 Z", polyline(&REVENUE, PLOT)); + shapes.push(k.shape(p, "Area", &area, json!({"fill": PRIMARY, "opacity": 0.12}))?); + let last = k.line(p, "Last year", &polyline(&LAST_YEAR, PLOT), SEA, 2.0)?; + k.set(last, json!({"dashes": [6, 6]}))?; + shapes.push(last); + shapes.push(k.line(p, "This year", &polyline(&REVENUE, PLOT), PRIMARY, 3.0)?); + fit(k, p, &shapes)?; + axis(k, card, &["1 Mar", "15 Mar", "29 Mar", "12 Apr", "26 Apr", "10 May", "24 May"]) +} + +fn kinds(k: &mut Kit, grid: NodeId) -> Result<()> { + let card = card(k, grid, "Bookings by kind", 1, "")?; + let ring = + k.stack(card, "Ring", H, 0.0, json!(0), json!({"sizingH": "fill", "sizingV": "fill", "mainAlign": "center", "crossAlign": "center"}))?; + let donut = k.frame(ring, "Donut", [0.0, 0.0, 132.0, 132.0], json!({"fills": [], "clip": false}))?; + let mut start = -90.0; + for (label, share, color) in KINDS { + let sweep = share * 360.0; + k.ellipse(donut, label, [0.0, 0.0, 132.0, 132.0], json!({"fill": color, "arcStart": start, "arcSweep": sweep - 2.0, "arcInner": 0.68}))?; + start += sweep; + } + let legend = k.stack(card, "Legend", V, 8.0, json!(0), json!({"sizingH": "fill"}))?; + for (label, share, color) in KINDS { + let row = k.stack(legend, label, H, 8.0, json!(0), json!({"sizingH": "fill", "crossAlign": "center"}))?; + k.ellipse(row, "Dot", [0.0, 0.0, 10.0, 10.0], json!({"fill": color}))?; + k.text_with(row, label, "Body/S", INK, json!({"sizingH": "fill"}))?; + k.text(row, &format!("{:.0}%", share * 100.0), "Label/S", MUTED)?; + } + Ok(()) +} + +fn weekdays(k: &mut Kit, grid: NodeId) -> Result<()> { + let card = card(k, grid, "Busiest days", 1, "Per weekday")?; + k.bar_chart(card, [230.0, 170.0], &WEEKDAYS, "Label/S") +} + +fn bookings(k: &mut Kit, grid: NodeId) -> Result<()> { + let card = card(k, grid, "Latest bookings", 3, "See all")?; + let table = k.stack(card, "Table", V, 0.0, json!(0), json!({"sizingH": "fill"}))?; + let header = ["Walk", "Guide", "Date", "Status", "Amount"]; + for (i, cells) in std::iter::once(header).chain(BOOKINGS).enumerate() { + let edge = if i < BOOKINGS.len() { json!({"stroke": BORDER, "strokeSides": BOTTOM_EDGE}) } else { json!({}) }; + let row = k.stack( + table, + if i == 0 { "Header" } else { "Row" }, + H, + 12.0, + json!([10, 0, 10, 0]), + merged(json!({"sizingH": "fill", "crossAlign": "center"}), edge), + )?; + for (column, cell) in cells.into_iter().enumerate() { + cell_in(k, row, column, cell, i == 0)?; + } + } + Ok(()) +} + +/// One table cell: the first column is the widest, the status is a pill, the amount is right-aligned. +fn cell_in(k: &mut Kit, row: NodeId, column: usize, cell: &str, header: bool) -> Result<()> { + let (style, color) = if header { + ("Label/S", MUTED) + } else if column == 0 { + ("Label/M", INK) + } else { + ("Body/S", INK) + }; + let width: Value = match column { + 0 => json!({"sizingH": "fill"}), + 4 => json!({"sizingH": "fixed", "width": 80, "textAlign": "right", "textAutoResize": "height"}), + _ => json!({"sizingH": "fixed", "width": 140, "textAutoResize": "height"}), + }; + if column == 3 && !header { + let holder = k.stack(row, "Status", H, 0.0, json!(0), json!({"sizingH": "fixed", "width": 140}))?; + let [color, fill] = if cell == "Waiting" { ["#8A5A0B", "#FCEFD9"] } else { [PRIMARY, SOFT] }; + return k.chip(holder, cell, ["Label/S", color], fill, [3.0, 10.0], 999.0).map(drop); + } + k.text_with(row, cell, style, color, width).map(drop) +} diff --git a/crates/engine/src/templates/design_system.rs b/crates/engine/src/templates/design_system.rs new file mode 100644 index 00000000..6f47712c --- /dev/null +++ b/crates/engine/src/templates/design_system.rs @@ -0,0 +1,219 @@ +//! Design system starter: colour and text styles, a Theme variable collection with Light and Dark +//! modes and a Sizes collection, shown on a Foundations page; and a Components page with button and +//! input component sets and a card, all bound to the variables, used in one form shown in both +//! modes. + +use figcraft_doc::NodeId; +use serde_json::{Value, json}; + +use super::kit::*; +use crate::Result; + +const THEME: &str = "Theme"; +const MODES: [&str; 2] = ["Light", "Dark"]; + +/// Theme colours: (name, Light, Dark). +const THEME_COLORS: [(&str, &str, &str); 9] = [ + ("Surface", "#FFFFFF", "#121816"), + ("Surface raised", "#F5F7F6", "#1C2421"), + ("Text", "#14201C", "#F2F5F4"), + ("Text muted", "#5C6B66", "#9FADA8"), + ("Primary", "#1F6F5C", "#58C2A6"), + ("Primary hover", "#185848", "#7AD3BA"), + ("On primary", "#FFFFFF", "#0C1A16"), + ("Border", "#D8DFDC", "#34403B"), + ("Danger", "#B4472E", "#F08A72"), +]; + +/// Sizes: (name, value). +const SIZES: [(&str, f64); 7] = + [("Radius/S", 6.0), ("Radius/M", 12.0), ("Radius/L", 24.0), ("Space/S", 8.0), ("Space/M", 16.0), ("Space/L", 24.0), ("Space/XL", 40.0)]; + +/// Text in a text style whose colour follows theme variable `var`. +fn themed(k: &mut Kit, parent: NodeId, chars: &str, style: &str, var: &str, props: Value) -> Result { + let id = k.text_with(parent, chars, style, "#14201C", props)?; + k.bound(id, &[("fill", var)]) +} + +/// A surface whose fill, border and corner radius follow the variables. +fn surface(k: &mut Kit, kind: &str, parent: NodeId, name: &str, dir: &str, [gap, pad]: [f64; 2], fill: &str, props: Value) -> Result { + let base = json!({"fill": "#FFFFFF", "stroke": "#D8DFDC", "strokeWeight": 1}); + let id = k.stack_of(kind, parent, name, dir, gap, json!(pad), merged(base, props))?; + k.bound(id, &[("fill", fill), ("stroke", "Border"), ("cornerRadius", "Radius/L")]) +} + +pub(super) fn build(k: &mut Kit) -> Result<()> { + k.foundations()?; + let colors = THEME_COLORS.iter().map(|(name, light, dark)| (*name, "color", vec![json!(light), json!(dark)])).collect(); + k.variables(THEME, &MODES, colors)?; + k.variables("Sizes", &["Default"], SIZES.iter().map(|(name, v)| (*name, "number", vec![json!(v)])).collect())?; + k.rename_page("Foundations")?; + let page = k.page()?; + for (i, mode) in MODES.into_iter().enumerate() { + colours(k, page, mode, i as f64 * 600.0)?; + } + type_scale(k, page, 1200.0)?; + sizes_board(k, page, 1800.0)?; + let page = k.add_page("Components")?; + let button = buttons(k, page)?; + let input = inputs(k, page)?; + card(k, page, button)?; + for (i, mode) in MODES.into_iter().enumerate() { + example(k, page, mode, 1120.0 + i as f64 * 480.0, button, input)?; + } + Ok(()) +} + +/// A board on the canvas: a titled panel that follows the theme. +fn board(k: &mut Kit, page: NodeId, title: &str, note: &str, x: f64, width: f64) -> Result { + let board = surface(k, "frame", page, title, V, [24.0, 32.0], "Surface", json!({"sizingH": "fixed", "width": width, "x": x}))?; + let head = k.stack(board, "Heading", V, 4.0, json!(0), json!({"sizingH": "fill"}))?; + themed(k, head, title, "Heading/M", "Text", json!({}))?; + themed(k, head, note, "Body/S", "Text muted", json!({}))?; + Ok(board) +} + +fn colours(k: &mut Kit, page: NodeId, mode: &str, x: f64) -> Result<()> { + let board = board(k, page, &format!("Colours · {mode}"), "The Theme collection's colour variables in this mode", x, 520.0)?; + k.mode(board, THEME, mode)?; + let grid = k.stack(board, "Swatches", H, 16.0, json!(0), json!({"sizingH": "fill", "wrap": true, "crossGap": 20}))?; + for (name, ..) in THEME_COLORS { + let swatch = k.stack(grid, name, V, 8.0, json!(0), json!({}))?; + let chip = k.rect( + swatch, + "Colour", + [0.0, 0.0, 102.0, 64.0], + json!({"fill": "#FFFFFF", "stroke": "#D8DFDC", "strokeWeight": 1, "cornerRadius": 12}), + )?; + k.bound(chip, &[("fill", name), ("stroke", "Border"), ("cornerRadius", "Radius/M")])?; + themed(k, swatch, name, "Label/S", "Text", json!({}))?; + } + Ok(()) +} + +fn type_scale(k: &mut Kit, page: NodeId, x: f64) -> Result<()> { + let board = board(k, page, "Type", "The text styles, largest first", x, 520.0)?; + for (name, size, weight, line) in TYPE { + let row = k.stack(board, name, V, 2.0, json!(0), json!({"sizingH": "fill"}))?; + themed(k, row, &format!("{name} · {weight} {size} / {line}"), "Label/S", "Text muted", json!({}))?; + themed(k, row, "Ridgeline at first light", name, "Text", json!({"sizingH": "fill", "textAutoResize": "height"}))?; + } + Ok(()) +} + +fn sizes_board(k: &mut Kit, page: NodeId, x: f64) -> Result<()> { + let board = board(k, page, "Sizes", "Number variables for corner radius and spacing", x, 400.0)?; + let radii = k.stack(board, "Radius", H, 16.0, json!(0), json!({"sizingH": "fill"}))?; + let spaces = k.stack(board, "Space", V, 12.0, json!(0), json!({"sizingH": "fill"}))?; + for (name, value) in SIZES { + if name.starts_with("Radius") { + let item = k.stack(radii, name, V, 8.0, json!(0), json!({}))?; + let square = k.rect(item, "Corner", [0.0, 0.0, 72.0, 72.0], json!({"fill": "#F5F7F6", "stroke": "#D8DFDC", "strokeWeight": 1}))?; + k.bound(square, &[("fill", "Surface raised"), ("stroke", "Border"), ("cornerRadius", name)])?; + themed(k, item, &format!("{name} · {value}"), "Label/S", "Text", json!({}))?; + } else { + let item = k.stack(spaces, name, H, 12.0, json!(0), json!({"crossAlign": "center"}))?; + let bar = k.rect(item, "Space", [0.0, 0.0, value, 16.0], json!({"fill": "#1F6F5C", "cornerRadius": 3}))?; + k.bound(bar, &[("fill", "Primary"), ("width", name)])?; + themed(k, item, &format!("{name} · {value}"), "Label/S", "Text", json!({}))?; + } + } + Ok(()) +} + +/// The button component set: Type × State, with a Label text property. +fn buttons(k: &mut Kit, page: NodeId) -> Result { + let mut variants = Vec::new(); + let mut labels = Vec::new(); + for (row, kind) in ["Primary", "Secondary", "Ghost"].into_iter().enumerate() { + for (column, state) in ["Default", "Hover", "Disabled"].into_iter().enumerate() { + let hover = state == "Hover"; + // (fill, label colour, outlined) + let (fill, ink, outlined) = match kind { + "Primary" => (Some(if hover { "Primary hover" } else { "Primary" }), "On primary", false), + "Secondary" => (Some(if hover { "Surface raised" } else { "Surface" }), "Text", true), + _ => (hover.then_some("Surface raised"), "Primary", false), + }; + let mut props = json!({"mainAlign": "center", "crossAlign": "center", "x": column as f64 * 150.0, "y": row as f64 * 68.0}); + if fill.is_some() { + props = merged(props, json!({"fill": "#1F6F5C"})); + } + if outlined { + props = merged(props, json!({"stroke": "#D8DFDC", "strokeWeight": 1})); + } + if state == "Disabled" { + props = merged(props, json!({"opacity": 0.4})); + } + let c = k.stack_of("component", page, &format!("Type={kind}, State={state}"), H, 8.0, json!([11, 20, 11, 20]), props)?; + k.bound(c, &[("cornerRadius", "Radius/M")])?; + if let Some(fill) = fill { + k.bound(c, &[("fill", fill)])?; + } + if outlined { + k.bound(c, &[("stroke", "Border")])?; + } + labels.push(themed(k, c, "Button", "Label/M", ink, json!({}))?); + variants.push(c); + } + } + let set = k.variants("Button", &variants, json!({}))?; + k.text_prop(set, "Label", "Button", &labels)?; + Ok(set) +} + +/// The input component set: State, with Label and Value text properties. +fn inputs(k: &mut Kit, page: NodeId) -> Result { + let mut variants = Vec::new(); + let (mut labels, mut values) = (Vec::new(), Vec::new()); + for (i, state) in ["Default", "Focus", "Error"].into_iter().enumerate() { + let (edge, weight, help, help_ink) = match state { + "Focus" => ("Primary", 2, "Helper text", "Text muted"), + "Error" => ("Danger", 1, "This field is required", "Danger"), + _ => ("Border", 1, "Helper text", "Text muted"), + }; + let props = json!({"sizingH": "fixed", "width": 300, "x": i as f64 * 340.0, "y": 300}); + let c = k.stack_of("component", page, &format!("State={state}"), V, 6.0, json!(0), props)?; + let label = themed(k, c, "Label", "Label/S", "Text", json!({}))?; + let field = json!({"fill": "#FFFFFF", "stroke": "#D8DFDC", "strokeWeight": weight, "sizingH": "fill", "crossAlign": "center"}); + let field = k.stack(c, "Field", H, 8.0, json!([11, 14, 11, 14]), field)?; + k.bound(field, &[("fill", "Surface"), ("stroke", edge), ("cornerRadius", "Radius/M")])?; + let value = themed(k, field, "Value", "Body/M", "Text", json!({"sizingH": "fill"}))?; + themed(k, c, help, "Body/S", help_ink, json!({}))?; + labels.push(label); + values.push(value); + variants.push(c); + } + let set = k.variants("Input", &variants, json!({}))?; + k.text_prop(set, "Label", "Label", &labels)?; + k.text_prop(set, "Value", "Value", &values)?; + Ok(set) +} + +/// A card component holding a button instance, with Title and Body text properties. +fn card(k: &mut Kit, page: NodeId, button: NodeId) -> Result { + let c = surface(k, "component", page, "Card", V, [16.0, 24.0], "Surface raised", json!({"sizingH": "fixed", "width": 320, "y": 480}))?; + let words = k.stack(c, "Words", V, 6.0, json!(0), json!({"sizingH": "fill"}))?; + let wrap = json!({"sizingH": "fill", "textAutoResize": "height"}); + let title = themed(k, words, "Card title", "Heading/S", "Text", wrap.clone())?; + let body = themed(k, words, "A short line that says what this card is about.", "Body/M", "Text muted", wrap)?; + k.instance(button, c, json!({"Label": "Open"}), json!({}))?; + k.text_prop(c, "Title", "Card title", &[title])?; + k.text_prop(c, "Body", "A short line that says what this card is about.", &[body])?; + Ok(c) +} + +/// The components in use: a sign-in form, shown in one mode of the theme. +fn example(k: &mut Kit, page: NodeId, mode: &str, x: f64, button: NodeId, input: NodeId) -> Result<()> { + let form = + surface(k, "frame", page, &format!("Example · {mode}"), V, [20.0, 40.0], "Surface", json!({"sizingH": "fixed", "width": 400, "x": x}))?; + k.mode(form, THEME, mode)?; + let words = k.stack(form, "Words", V, 6.0, json!(0), json!({"sizingH": "fill"}))?; + themed(k, words, "Welcome back", "Heading/M", "Text", json!({}))?; + themed(k, words, "Sign in to plan your next walk.", "Body/M", "Text muted", json!({}))?; + let fill = json!({"sizingH": "fill"}); + k.instance(input, form, json!({"Label": "Email", "Value": "mara@ridgeline.example"}), fill.clone())?; + k.instance(input, form, json!({"State": "Focus", "Label": "Password", "Value": "••••••••••"}), fill.clone())?; + let actions = k.stack(form, "Actions", V, 8.0, json!([8, 0, 0, 0]), fill.clone())?; + k.instance(button, actions, json!({"Label": "Sign in"}), fill.clone())?; + k.instance(button, actions, json!({"Type": "Ghost", "Label": "Create an account"}), fill).map(drop) +} diff --git a/crates/engine/src/templates/glyphs.rs b/crates/engine/src/templates/glyphs.rs new file mode 100644 index 00000000..16bb6f16 --- /dev/null +++ b/crates/engine/src/templates/glyphs.rs @@ -0,0 +1,43 @@ +//! Original icons for the starter templates: each is one outline drawn for FigCraft from straight +//! lines, arcs and simple curves on a 24 px grid (2 px margin, 2 px round strokes). + +/// An icon: its name and its outline as SVG path data on the 24 px grid. +pub(super) struct Glyph { + pub name: &'static str, + pub path: &'static str, +} + +macro_rules! glyphs { + ($($id:ident $name:literal $path:literal)*) => { + $(pub(super) const $id: Glyph = Glyph { name: $name, path: $path };)* + /// Every icon, in the order the icon set lays them out. + pub(super) const ALL: &[Glyph] = &[$($id),*]; + }; +} + +glyphs! { + HOME "Home" "M4 11 L12 4 L20 11 V20 H4 Z M10 20 V14 H14 V20" + SEARCH "Search" "M5 11 a6 6 0 1 0 12 0 a6 6 0 1 0 -12 0 M15.5 15.5 L20 20" + USER "User" "M8 8 a4 4 0 1 0 8 0 a4 4 0 1 0 -8 0 M4 20 C4 16.5 7.5 14.5 12 14.5 C16.5 14.5 20 16.5 20 20" + BELL "Bell" "M6 17 V11 C6 7.5 8.5 5 12 5 C15.5 5 18 7.5 18 11 V17 M4 17 H20 M10.5 20 H13.5" + HEART "Heart" "M12 20 L4.5 12.5 C2.5 10.5 2.5 7.5 4.5 5.8 C6.5 4 9.5 4.5 12 7.5 C14.5 4.5 17.5 4 19.5 5.8 C21.5 7.5 21.5 10.5 19.5 12.5 Z" + STAR "Star" "M12 3.5 L14.6 9 L20.5 9.8 L16.2 14 L17.3 20 L12 17.1 L6.7 20 L7.8 14 L3.5 9.8 L9.4 9 Z" + PIN "Pin" "M12 21 C9 18 5 14 5 10 C5 6 8 3 12 3 C16 3 19 6 19 10 C19 14 15 18 12 21 Z M9.5 10 a2.5 2.5 0 1 0 5 0 a2.5 2.5 0 1 0 -5 0" + MOUNTAIN "Mountain" "M3 19 L9 7 L13 14 L16 10 L21 19 Z" + CLOCK "Clock" "M4 12 a8 8 0 1 0 16 0 a8 8 0 1 0 -16 0 M12 7.5 V12 L15 14" + CALENDAR "Calendar" "M4 6 H20 V20 H4 Z M4 10.5 H20 M8 4 V8 M16 4 V8" + MAIL "Mail" "M3 6 H21 V18 H3 Z M3 7 L12 13.5 L21 7" + CHART "Chart" "M4 20 H20 M7 20 V12 M12 20 V6 M17 20 V10" + LAYERS "Layers" "M12 4 L20 8.5 L12 13 L4 8.5 Z M4 12.5 L12 17 L20 12.5 M4 16.5 L12 21 L20 16.5" + SLIDERS "Sliders" "M4 7 H10 M14 7 H20 M10 7 a2 2 0 1 0 4 0 a2 2 0 1 0 -4 0 M4 17 H8 M12 17 H20 M8 17 a2 2 0 1 0 4 0 a2 2 0 1 0 -4 0" + BOOKMARK "Bookmark" "M7 4 H17 V20 L12 16 L7 20 Z" + DOWNLOAD "Download" "M12 4 V15 M7 10.5 L12 15.5 L17 10.5 M5 20 H19" + PLUS "Plus" "M12 5 V19 M5 12 H19" + CHECK "Check" "M5 12.5 L10 17.5 L19 7" + CLOSE "Close" "M6 6 L18 18 M18 6 L6 18" + MENU "Menu" "M4 7 H20 M4 12 H20 M4 17 H20" + ARROW_LEFT "Arrow left" "M19 12 H5 M11 6 L5 12 L11 18" + ARROW_RIGHT "Arrow right" "M5 12 H19 M13 6 L19 12 L13 18" + CHEVRON_RIGHT "Chevron right" "M9 6 L15 12 L9 18" + CHEVRON_DOWN "Chevron down" "M6 9 L12 15 L18 9" +} diff --git a/crates/engine/src/templates/icon_set.rs b/crates/engine/src/templates/icon_set.rs new file mode 100644 index 00000000..2d6671d4 --- /dev/null +++ b/crates/engine/src/templates/icon_set.rs @@ -0,0 +1,85 @@ +//! Icon set starter: FigCraft's own template icons as components on a 24 px grid (2 px round +//! strokes in one colour style), an anatomy board that shows the grid, the margin and the keyline +//! shapes at twenty times the size, and a preview of one icon at four sizes. + +use figcraft_doc::NodeId; +use serde_json::json; + +use super::glyphs; +use super::kit::*; +use crate::Result; + +const STROKE: &str = "Icon/Stroke"; +/// The anatomy board draws one grid unit this large. +const UNIT: f64 = 20.0; +const GUIDE: &str = "#E2574C"; +/// The width of the icon boards: six 88 px cells, their gaps, the padding and a little slack. +const LIBRARY: f64 = 6.0 * 88.0 + 5.0 * 16.0 + 72.0; + +pub(super) fn build(k: &mut Kit) -> Result<()> { + k.foundations()?; + k.colors(&[(STROKE, "#14201C")])?; + k.rename_page("Icons")?; + let page = k.page()?; + anatomy(k, page)?; + // The icons and their preview sit in a column beside the anatomy board. + let column = k.stack(page, "Library", V, 32.0, json!(0), json!({"x": ICON * UNIT + 64.0 + 80.0}))?; + let home = icons(k, column)?; + sizes(k, column, home) +} + +/// A titled white panel. +fn board(k: &mut Kit, parent: NodeId, title: &str, note: &str, width: f64) -> Result { + let props = json!({"fill": CARD, "stroke": BORDER, "strokeWeight": 1, "cornerRadius": 24, "sizingH": "fixed", "width": width}); + let board = k.stack(parent, title, V, 24.0, json!(32), props)?; + let head = k.stack(board, "Heading", V, 4.0, json!(0), json!({"sizingH": "fill"}))?; + k.text(head, title, "Heading/M", INK)?; + k.para(head, note, "Body/S", MUTED)?; + Ok(board) +} + +/// The grid an icon is drawn on, enlarged: every unit, the 2 px margin, and the circle and square +/// keylines that keep icons the same optical size, around one icon. +fn anatomy(k: &mut Kit, page: NodeId) -> Result<()> { + let side = ICON * UNIT; + let board = board(k, page, "Anatomy", "24 px grid · 2 px margin · circle and square keylines · 2 px round strokes", side + 64.0)?; + let grid = k.frame(board, "Grid", [0.0, 0.0, side, side], json!({"fill": PAGE, "clip": true}))?; + let lines: String = (1..24).map(|i| f64::from(i) * UNIT).map(|at| format!("M{at} 0 V{side} M0 {at} H{side} ")).collect(); + k.line(grid, "Units", lines.trim_end(), BORDER, 1.0)?; + let dashed = json!({"fills": [], "stroke": GUIDE, "strokeWeight": 1.5, "dashes": [6, 6]}); + let (margin, live) = (2.0 * UNIT, side - 4.0 * UNIT); + k.rect(grid, "Margin", [margin, margin, live, live], dashed.clone())?; + k.ellipse(grid, "Circle keyline", [margin, margin, live, live], dashed.clone())?; + k.rect(grid, "Square keyline", [3.0 * UNIT, 3.0 * UNIT, side - 6.0 * UNIT, side - 6.0 * UNIT], merged(dashed, json!({"cornerRadius": UNIT})))?; + // The enlarged icon's stroke is enlarged with it. + let (_, stroke) = k.icon_of("frame", grid, &glyphs::HOME, [0.0, 0.0], side, STROKE)?; + k.set(stroke, json!({"strokeWeight": 2.0 * UNIT, "opacity": 0.9})) +} + +/// Every icon as a component with its name under it. Returns the first one. +fn icons(k: &mut Kit, parent: NodeId) -> Result { + let board = board(k, parent, "Icons", "Components: drag one from the Assets panel, or copy one and redraw it on the grid", LIBRARY)?; + let grid = k.stack(board, "Set", H, 16.0, json!(0), json!({"sizingH": "fill", "wrap": true, "crossGap": 24}))?; + let mut first = None; + for glyph in glyphs::ALL { + let cell = json!({"sizingH": "fixed", "width": 88, "crossAlign": "center"}); + let cell = k.stack(grid, glyph.name, V, 10.0, json!(0), cell)?; + let (icon, _) = k.icon_of("component", cell, glyph, [0.0, 0.0], ICON, STROKE)?; + k.set(icon, json!({"name": format!("Icon/{}", glyph.name)}))?; + k.text(cell, glyph.name, "Label/S", MUTED)?; + first.get_or_insert(icon); + } + first.ok_or_else(|| crate::EngineError::Other("the icon set is empty".into())) +} + +/// One icon at four sizes: instances scale, and their strokes keep their weight. +fn sizes(k: &mut Kit, parent: NodeId, icon: NodeId) -> Result<()> { + let board = board(k, parent, "Sizes", "Instances at 16, 24, 32 and 48 px", LIBRARY)?; + let row = k.stack(board, "Row", H, 40.0, json!(0), json!({"crossAlign": "max"}))?; + for size in [16.0, 24.0, 32.0, 48.0] { + let cell = k.stack(row, &format!("{size} px"), V, 10.0, json!(0), json!({"crossAlign": "center"}))?; + k.instance(icon, cell, json!({}), json!({"width": size, "height": size}))?; + k.text(cell, &format!("{size}"), "Label/S", MUTED)?; + } + Ok(()) +} diff --git a/crates/engine/src/templates/kit.rs b/crates/engine/src/templates/kit.rs new file mode 100644 index 00000000..4ad5b11a --- /dev/null +++ b/crates/engine/src/templates/kit.rs @@ -0,0 +1,412 @@ +//! The builder the starter templates share: a throwaway session whose document is built by +//! running commands, the way a user (or a script) would make it. + +use std::collections::BTreeMap; +use std::sync::Arc; + +use figcraft_color::Color; +use figcraft_doc::{Document, NodeId, NodeKind}; +use serde_json::{Map, Value, json}; + +use super::glyphs::Glyph; +use crate::{EngineError, Result, Session}; + +pub(super) const V: &str = "vertical"; +pub(super) const H: &str = "horizontal"; + +/// The side of the grid icons are drawn on. +pub(super) const ICON: f64 = 24.0; + +/// `over`'s keys on top of `base`'s (both objects; anything else gives `base`). +pub(super) fn merged(mut base: Value, over: Value) -> Value { + if let (Some(b), Value::Object(o)) = (base.as_object_mut(), over) { + b.extend(o); + } + base +} + +pub(super) struct Kit { + s: Session, + /// Style ids by name ("Brand/Primary"). + styles: BTreeMap, +} + +impl Kit { + pub fn new(name: &str) -> Self { + Self { s: Session::with_document(Document::new(name)), styles: BTreeMap::new() } + } + + /// The document, with every page on `background` and the first page shown. + pub fn finish(mut self, background: Color) -> Result { + let ds = self.s.docs.pop().ok_or(EngineError::NoDocument)?; + let mut doc = Arc::try_unwrap(ds.doc).unwrap_or_else(|d| (*d).clone()); + for page in doc.pages.clone() { + if let Some(NodeKind::Page { background: b, .. }) = doc.node_mut(page).map(|n| &mut n.kind) { + *b = background; + } + } + doc.current_page = doc.first_page(); + // Nothing here is an edit waiting to be normalised. + doc.take_dirty(); + Ok(doc) + } + + pub fn run(&mut self, cmd: &str, params: Value) -> Result { + self.s.execute(cmd, ¶ms) + } + + /// The id a command answered with. + fn made(&mut self, cmd: &str, params: Value) -> Result { + let v = self.run(cmd, params)?; + v.get("id").and_then(crate::params::id_of).ok_or_else(|| EngineError::Other(format!("`{cmd}` made no layer"))) + } + + /// The page being built on. + pub fn page(&self) -> Result { + self.s.page() + } + + /// A new page, which becomes the one being built on. + pub fn add_page(&mut self, name: &str) -> Result { + self.run("page.add", json!({"name": name}))?; + self.page() + } + + /// Go back to `page` (flows are added to the page shown). + pub fn go(&mut self, page: NodeId) -> Result<()> { + self.run("page.select", json!({"id": page.0})).map(drop) + } + + pub fn rename_page(&mut self, name: &str) -> Result<()> { + self.run("page.rename", json!({"name": name})).map(drop) + } + + /// `props` with style names in `fill`, `stroke` and `textStyle` turned into links to those + /// styles (anything else, like "#RRGGBB", stays a plain value). + fn resolved(&self, props: Value) -> Value { + let Value::Object(map) = props else { return props }; + let mut out = Map::new(); + for (k, v) in map { + let style = v.as_str().and_then(|name| self.styles.get(name)); + match (k.as_str(), style) { + ("fill", Some(id)) => out.insert("fillStyle".into(), json!(id)), + ("stroke", Some(id)) => out.insert("strokeStyle".into(), json!(id)), + ("textStyle", Some(id)) => out.insert(k, json!(id)), + _ => out.insert(k, v), + }; + } + Value::Object(out) + } + + fn create(&mut self, kind: &str, parent: NodeId, extra: Value, props: Value) -> Result { + let base = json!({"type": kind, "parent": parent.0, "select": false, "props": self.resolved(props)}); + self.made("node.create", merged(base, extra)) + } + + /// A layer of `kind` (frame, component, rectangle, ellipse…) at `[x, y, width, height]`. + pub fn node(&mut self, kind: &str, parent: NodeId, name: &str, [x, y, w, h]: [f64; 4], props: Value) -> Result { + self.create(kind, parent, json!({"name": name, "x": x, "y": y, "width": w, "height": h}), props) + } + + pub fn frame(&mut self, parent: NodeId, name: &str, at: [f64; 4], props: Value) -> Result { + self.node("frame", parent, name, at, props) + } + + pub fn rect(&mut self, parent: NodeId, name: &str, at: [f64; 4], props: Value) -> Result { + self.node("rectangle", parent, name, at, props) + } + + pub fn ellipse(&mut self, parent: NodeId, name: &str, at: [f64; 4], props: Value) -> Result { + self.node("ellipse", parent, name, at, props) + } + + /// An auto layout frame of `kind` (frame or component) that hugs its content, without a fill, + /// unless `props` say otherwise. + pub fn stack_of(&mut self, kind: &str, parent: NodeId, name: &str, dir: &str, gap: f64, pad: Value, props: Value) -> Result { + let mut base = json!({"clip": false, "layout": dir, "gap": gap, "padding": pad, "sizingH": "hug", "sizingV": "hug"}); + if props.get("fill").is_none() && props.get("fills").is_none() { + base = merged(base, json!({"fills": []})); + } + self.create(kind, parent, json!({"name": name}), merged(base, props)) + } + + pub fn stack(&mut self, parent: NodeId, name: &str, dir: &str, gap: f64, pad: Value, props: Value) -> Result { + self.stack_of("frame", parent, name, dir, gap, pad, props) + } + + /// One line of text in text style `style`, coloured `color` (a paint style's name or a colour). + pub fn text(&mut self, parent: NodeId, chars: &str, style: &str, color: &str) -> Result { + self.text_with(parent, chars, style, color, json!({})) + } + + pub fn text_with(&mut self, parent: NodeId, chars: &str, style: &str, color: &str, props: Value) -> Result { + self.create("text", parent, json!({"text": chars}), merged(json!({"textStyle": style, "fill": color}), props)) + } + + /// Text set by hand rather than in a text style: `size` px in font style `weight`. + pub fn sized_text(&mut self, parent: NodeId, chars: &str, [size, line]: [f64; 2], weight: &str, color: &str, props: Value) -> Result { + let base = json!({"fontSize": size, "fontStyle": weight, "lineHeight": line, "fill": color}); + self.create("text", parent, json!({"text": chars}), merged(base, props)) + } + + /// Text that fills its auto layout parent's width and wraps. + pub fn para(&mut self, parent: NodeId, chars: &str, style: &str, color: &str) -> Result { + self.text_with(parent, chars, style, color, json!({"sizingH": "fill", "textAutoResize": "height"})) + } + + /// A stroked outline from SVG path data, where the path puts it in `parent`. + pub fn line(&mut self, parent: NodeId, name: &str, path: &str, color: &str, weight: f64) -> Result { + let props = json!({"fills": [], "stroke": color, "strokeWeight": weight, "strokeCap": "round", "strokeJoin": "round"}); + self.create("vector", parent, json!({"name": name, "path": path}), props) + } + + /// A filled shape from SVG path data. + pub fn shape(&mut self, parent: NodeId, name: &str, path: &str, props: Value) -> Result { + self.create("vector", parent, json!({"name": name, "path": path}), props) + } + + /// An icon: a square frame of `size` holding `glyph` stroked in `color`. + pub fn icon(&mut self, parent: NodeId, glyph: &Glyph, size: f64, color: &str) -> Result { + self.icon_of("frame", parent, glyph, [0.0, 0.0], size, color).map(|(frame, _)| frame) + } + + /// [`Kit::icon`] as a frame or component at a place of its own: the icon and its outline. + pub fn icon_of(&mut self, kind: &str, parent: NodeId, glyph: &Glyph, [x, y]: [f64; 2], size: f64, color: &str) -> Result<(NodeId, NodeId)> { + let frame = self.node(kind, parent, glyph.name, [x, y, ICON, ICON], json!({"fills": [], "clip": false}))?; + let stroke = self.line(frame, "Glyph", glyph.path, color, 2.0)?; + // The glyph follows the frame's size (and an instance's); its stroke keeps its weight. + self.set(stroke, json!({"constraints": {"horizontal": "scale", "vertical": "scale"}}))?; + if size != ICON { + self.set(frame, json!({"width": size, "height": size}))?; + } + Ok((frame, stroke)) + } + + pub fn set(&mut self, id: NodeId, props: Value) -> Result<()> { + let props = self.resolved(props); + self.run("node.set", json!({"ids": [id.0], "props": props})).map(drop) + } + + /// Paint styles: (name, colour). + pub fn colors(&mut self, list: &[(&str, &str)]) -> Result<()> { + for (name, hex) in list { + let v = self.run("style.create", json!({"type": "paint", "name": name, "value": [hex]}))?; + self.keep_style(name, &v); + } + Ok(()) + } + + /// Text styles: (name, size, font style, line height in pixels). + pub fn type_styles(&mut self, list: &[(&str, f64, &str, f64)]) -> Result<()> { + for (name, size, weight, line) in list { + let value = json!({"fontSize": size, "fontStyle": weight, "lineHeight": line}); + let v = self.run("style.create", json!({"type": "text", "name": name, "value": value}))?; + self.keep_style(name, &v); + } + Ok(()) + } + + fn keep_style(&mut self, name: &str, made: &Value) { + if let Some(id) = made.get("id").and_then(Value::as_str) { + self.styles.insert(name.to_string(), id.to_string()); + } + } + + /// A variable collection with `modes`, and its variables: (name, type, one value per mode). + pub fn variables(&mut self, collection: &str, modes: &[&str], vars: Vec<(&str, &str, Vec)>) -> Result<()> { + self.run("variable.createCollection", json!({"name": collection, "modes": modes}))?; + for (name, kind, values) in vars { + let values: Map = modes.iter().map(|m| m.to_string()).zip(values).collect(); + self.run("variable.create", json!({"collection": collection, "name": name, "type": kind, "values": values}))?; + } + Ok(()) + } + + /// Bind fields of layer `id` to variables: (field, variable name). Gives the layer back. + pub fn bound(&mut self, id: NodeId, binds: &[(&str, &str)]) -> Result { + for (field, variable) in binds { + self.run("variable.bind", json!({"ids": [id.0], "field": field, "variable": variable}))?; + } + Ok(id) + } + + /// Show `frame` in `mode` of variable collection `collection`. + pub fn mode(&mut self, frame: NodeId, collection: &str, mode: &str) -> Result<()> { + self.run("variable.setMode", json!({"ids": [frame.0], "collection": collection, "mode": mode})).map(drop) + } + + /// Combine components (named "Property=Value, …") into a component set called `name`. + pub fn variants(&mut self, name: &str, ids: &[NodeId], props: Value) -> Result { + let set = self.made("component.combineVariants", json!({"ids": ids.iter().map(|i| i.0).collect::>()}))?; + self.set(set, merged(json!({"name": name}), props))?; + Ok(set) + } + + /// A text property of `component` (or of a set) shown by `layers` (one per variant). + pub fn text_prop(&mut self, component: NodeId, name: &str, default: &str, layers: &[NodeId]) -> Result<()> { + for (i, layer) in layers.iter().enumerate() { + if i == 0 { + self.run("component.addProperty", json!({"id": component.0, "name": name, "type": "text", "default": default, "layer": layer.0}))?; + } else { + self.run("component.bindProperty", json!({"layer": layer.0, "property": name, "field": "characters"}))?; + } + } + Ok(()) + } + + /// An instance of `component` in `parent` with component `properties` and layer `props`. + pub fn instance(&mut self, component: NodeId, parent: NodeId, properties: Value, props: Value) -> Result { + let id = self.made("instance.create", json!({"component": component.0, "parent": parent.0, "x": 0, "y": 0}))?; + if properties.as_object().is_some_and(|o| !o.is_empty()) { + self.run("instance.setProperties", json!({"ids": [id.0], "properties": properties}))?; + } + if props.as_object().is_some_and(|o| !o.is_empty()) { + self.set(id, props)?; + } + Ok(id) + } + + /// Clicking `from` goes to frame `to` in the prototype. + pub fn link(&mut self, from: NodeId, to: NodeId) -> Result<()> { + let params = json!({"id": from.0, "action": {"type": "navigate", "dest": to.0}, "transition": {"type": "dissolve"}, "durationMs": 200}); + self.run("proto.interaction.add", params).map(drop) + } + + /// The prototype starts at frame `start`. + pub fn flow(&mut self, start: NodeId, name: &str) -> Result<()> { + self.run("proto.flow.add", json!({"start": start.0, "name": name})).map(drop) + } +} + +/// The colours every template starts from, as paint styles. +const COLORS: &[(&str, &str)] = &[ + ("Brand/Primary", "#1F6F5C"), + ("Brand/Soft", "#DDEFE8"), + ("Brand/Accent", "#F2A541"), + ("Text/Primary", "#14201C"), + ("Text/Secondary", "#5C6B66"), + ("Text/On brand", "#FFFFFF"), + ("Surface/Page", "#F5F7F6"), + ("Surface/Card", "#FFFFFF"), + ("Surface/Sunken", "#E9EEEC"), + ("Border/Default", "#D8DFDC"), +]; + +/// The text styles every template starts from: (name, size, font style, line height). +pub(super) const TYPE: &[(&str, f64, &str, f64)] = &[ + ("Display", 48.0, "Bold", 56.0), + ("Heading/L", 30.0, "Bold", 38.0), + ("Heading/M", 22.0, "Semi Bold", 30.0), + ("Heading/S", 17.0, "Semi Bold", 24.0), + ("Body/L", 18.0, "Regular", 28.0), + ("Body/M", 15.0, "Regular", 22.0), + ("Body/S", 13.0, "Regular", 18.0), + ("Label/M", 14.0, "Medium", 20.0), + ("Label/S", 12.0, "Medium", 16.0), +]; + +pub(super) const PRIMARY: &str = "Brand/Primary"; +pub(super) const SOFT: &str = "Brand/Soft"; +pub(super) const ACCENT: &str = "Brand/Accent"; +/// A darker green than the brand colour, for full-bleed backgrounds (not a style). +pub(super) const DEEP: &str = "#174F41"; +pub(super) const INK: &str = "Text/Primary"; +pub(super) const MUTED: &str = "Text/Secondary"; +pub(super) const ON_BRAND: &str = "Text/On brand"; +pub(super) const PAGE: &str = "Surface/Page"; +pub(super) const CARD: &str = "Surface/Card"; +pub(super) const SUNKEN: &str = "Surface/Sunken"; +pub(super) const BORDER: &str = "Border/Default"; + +/// `strokeSides` for a hairline on one edge only: [top, right, bottom, left] weights. +pub(super) const TOP_EDGE: [f64; 4] = [1.0, 0.0, 0.0, 0.0]; +pub(super) const BOTTOM_EDGE: [f64; 4] = [0.0, 0.0, 1.0, 0.0]; +pub(super) const RIGHT_EDGE: [f64; 4] = [0.0, 1.0, 0.0, 0.0]; + +/// A soft shadow under cards. +pub(super) fn shadow() -> Value { + json!([{"type": "dropShadow", "color": "#14201C14", "offset": [0, 4], "radius": 16}]) +} + +/// A top-to-bottom gradient fill. +pub(super) fn gradient(top: &str, bottom: &str) -> Value { + let stop = |position: f64, hex: &str| json!({"position": position, "color": Color::parse_css(hex).unwrap_or(Color::BLACK)}); + json!([{"type": "gradient", "kind": "linear", "stops": [stop(0.0, top), stop(1.0, bottom)], "from": [0.5, 0.0], "to": [0.5, 1.0], "width": [-0.5, 0.0]}]) +} + +impl Kit { + /// The shared colour and text styles. + pub fn foundations(&mut self) -> Result<()> { + self.colors(COLORS)?; + self.type_styles(TYPE) + } + + /// A picture drawn from shapes (a sun over two ridges at dawn), `w` × `h`; `props` then size + /// or round it, and the shapes follow. + pub fn scene(&mut self, parent: NodeId, name: &str, [w, h]: [f64; 2], props: Value) -> Result { + let frame = self.frame(parent, name, [0.0, 0.0, w, h], json!({"fills": gradient("#BFE3D6", "#F8ECD4"), "clip": true}))?; + let follow = json!({"horizontal": "scale", "vertical": "scale"}); + let sun = w.min(h) * 0.22; + self.ellipse(frame, "Sun", [w * 0.68, h * 0.14, sun, sun], json!({"fill": ACCENT, "constraints": follow}))?; + let ridge = |ys: [f64; 6]| { + let [a, b, c, d, e, f] = ys.map(|y| y * h); + format!("M0 {a} L{} {b} L{} {c} L{} {d} L{} {e} L{w} {f} V{h} H0 Z", w * 0.2, w * 0.38, w * 0.58, w * 0.8) + }; + for (name, color, ys) in + [("Far ridge", "#6FB09B", [0.62, 0.40, 0.56, 0.32, 0.58, 0.46]), ("Near ridge", PRIMARY, [0.84, 0.64, 0.78, 0.58, 0.82, 0.70])] + { + self.shape(frame, name, &ridge(ys), json!({"fill": color, "constraints": follow}))?; + } + self.set(frame, props)?; + Ok(frame) + } + + /// A pill or tile holding one line of text: (text style, text colour) on `fill`. + pub fn chip(&mut self, parent: NodeId, label: &str, [style, color]: [&str; 2], fill: &str, pad: [f64; 2], radius: f64) -> Result { + let props = json!({"fill": fill, "cornerRadius": radius, "mainAlign": "center", "crossAlign": "center"}); + let chip = self.stack(parent, label, H, 8.0, json!([pad[0], pad[1], pad[0], pad[1]]), props)?; + self.text(chip, label, style, color)?; + Ok(chip) + } + + /// A soft square (or, with half its side as `radius`, a circle) that centres what goes in it: + /// an icon, initials. + pub fn tile(&mut self, parent: NodeId, side: f64, radius: f64) -> Result { + let props = json!({"fill": SOFT, "cornerRadius": radius, "sizingH": "fixed", "sizingV": "fixed", "width": side, "height": side, "mainAlign": "center", "crossAlign": "center"}); + self.stack(parent, "Tile", H, 0.0, json!(0), props) + } + + /// A bar chart in `card`: a plot drawn `w` × `h` with a baseline and one bar per (name, share of + /// the height), the tallest in the accent colour, and the names under it in `label_style`. The + /// shapes follow the plot, which fills the room `card` has. + pub fn bar_chart(&mut self, card: NodeId, [w, h]: [f64; 2], bars: &[(&str, f64)], label_style: &str) -> Result<()> { + let plot = self.frame(card, "Plot", [0.0, 0.0, w, h], json!({"fills": [], "clip": false}))?; + let follow = json!({"horizontal": "scale", "vertical": "scale"}); + let slot = w / bars.len().max(1) as f64; + let top = bars.iter().map(|(_, share)| *share).fold(0.0, f64::max); + let base = self.line(plot, "Baseline", &format!("M0 {h} H{w}"), BORDER, 1.0)?; + self.set(base, json!({"constraints": follow}))?; + for (i, (name, share)) in bars.iter().enumerate() { + let (bar, tall) = (slot * 0.56, share * h); + let at = [i as f64 * slot + (slot - bar) / 2.0, h - tall, bar, tall]; + let fill = if *share >= top { ACCENT } else { PRIMARY }; + let radius = (bar / 6.0).round(); + self.rect(plot, name, at, json!({"fill": fill, "cornerRadius": [radius, radius, 0, 0], "constraints": follow}))?; + } + self.set(plot, json!({"sizingH": "fill", "sizingV": "fill"}))?; + // Each name sits under its bar: the row's cells are as wide as the bars' slots. + let axis = self.stack(card, "Axis", H, 0.0, json!(0), json!({"sizingH": "fill"}))?; + for (name, _) in bars { + self.text_with(axis, name, label_style, MUTED, json!({"sizingH": "fill", "textAlign": "center"}))?; + } + Ok(()) + } + + /// A tile with a figure and what it counts. + pub fn stat(&mut self, parent: NodeId, value: &str, label: &str, value_style: &str) -> Result { + let tile = self.stack(parent, label, V, 2.0, json!(16), json!({"fill": CARD, "cornerRadius": 16, "sizingH": "fill"}))?; + self.text(tile, value, value_style, INK)?; + self.text(tile, label, "Body/S", MUTED)?; + Ok(tile) + } +} diff --git a/crates/engine/src/templates/landing.rs b/crates/engine/src/templates/landing.rs new file mode 100644 index 00000000..7edaef03 --- /dev/null +++ b/crates/engine/src/templates/landing.rs @@ -0,0 +1,191 @@ +//! Landing page: the page of an invented product ("Ridgeline Studio") as one auto layout frame per +//! width. Sections fill the frame and hold a container with a maximum width, so the desktop frame +//! can be resized; the phone frame is the same page built with its rows stacked. + +use figcraft_doc::NodeId; +use serde_json::{Value, json}; + +use super::glyphs::{self, Glyph}; +use super::kit::*; +use crate::Result; + +/// The widest the content gets. +const CONTAINER: f64 = 1120.0; + +const FEATURES: [(&str, Glyph, &str); 3] = [ + ("Routes everyone can edit", glyphs::LAYERS, "Draw a route once and let the whole club adjust it. Every change is kept, so nothing is lost."), + ("Ready without signal", glyphs::DOWNLOAD, "Maps, notes and meeting points are saved to each phone before you leave the car park."), + ("Plans that fit the day", glyphs::CLOCK, "Sunrise, daylight and pace are worked out for you, with a turnaround time you can trust."), +]; + +const FIGURES: [(&str, &str); 4] = + [("4,200", "Clubs planning together"), ("38k", "Routes shared"), ("96%", "Walks finished on time"), ("12", "Languages")]; + +/// One width of the page. +struct Layout { + width: f64, + /// Rows become columns. + narrow: bool, +} + +impl Layout { + fn row(&self) -> &'static str { + if self.narrow { V } else { H } + } + + /// Room above and below a section. + fn pad(&self, wide: f64) -> f64 { + if self.narrow { wide / 2.0 } else { wide } + } + + /// How a row that became a column lines its content up. + fn cross(&self) -> &'static str { + if self.narrow { "min" } else { "center" } + } +} + +pub(super) fn build(k: &mut Kit) -> Result<()> { + k.foundations()?; + k.type_styles(&[("Display/XL", 64.0, "Bold", 72.0)])?; + k.rename_page("Landing page")?; + let page = k.page()?; + page_at(k, page, "Desktop", 0.0, &Layout { width: 1440.0, narrow: false })?; + page_at(k, page, "Phone", 1540.0, &Layout { width: 390.0, narrow: true }) +} + +fn page_at(k: &mut Kit, page: NodeId, name: &str, x: f64, l: &Layout) -> Result<()> { + let props = json!({"fill": CARD, "stroke": BORDER, "strokeWeight": 1, "strokeAlign": "outside", "clip": true, "sizingH": "fixed", "width": l.width, "x": x}); + let root = k.stack(page, name, V, 0.0, json!(0), props)?; + nav(k, root, l)?; + hero(k, root, l)?; + features(k, root, l)?; + figures(k, root, l)?; + quote(k, root, l)?; + call_to_action(k, root, l)?; + footer(k, root, l) +} + +/// A full-width band (with room above and below) holding a centred container that lays its +/// content out in `dir`, `gap` apart. +fn section(k: &mut Kit, root: NodeId, name: &str, l: &Layout, dir: &str, [top, bottom, gap]: [f64; 3], props: Value) -> Result { + let side = if l.narrow { 20.0 } else { 40.0 }; + let band = merged(json!({"sizingH": "fill", "crossAlign": "center"}), props); + let band = k.stack(root, name, V, 0.0, json!([l.pad(top), side, l.pad(bottom), side]), band)?; + k.stack(band, "Container", dir, gap, json!(0), json!({"sizingH": "fill", "maxWidth": CONTAINER})) +} + +fn logo(k: &mut Kit, parent: NodeId) -> Result { + let logo = k.stack(parent, "Logo", H, 8.0, json!(0), json!({"crossAlign": "center"}))?; + k.icon(logo, &glyphs::MOUNTAIN, ICON, PRIMARY)?; + k.text(logo, "Ridgeline Studio", "Heading/S", INK)?; + Ok(logo) +} + +fn button(k: &mut Kit, parent: NodeId, label: &str, primary: bool) -> Result { + let (color, fill) = if primary { (ON_BRAND, PRIMARY) } else { (INK, SUNKEN) }; + k.chip(parent, label, ["Label/M", color], fill, [14.0, 22.0], 14.0) +} + +fn nav(k: &mut Kit, root: NodeId, l: &Layout) -> Result<()> { + let bar = section(k, root, "Navigation", l, H, [20.0, 20.0, 24.0], json!({"stroke": BORDER, "strokeSides": BOTTOM_EDGE}))?; + k.set(bar, json!({"mainAlign": "spaceBetween", "crossAlign": "center"}))?; + logo(k, bar)?; + if l.narrow { + k.icon(bar, &glyphs::MENU, ICON, INK)?; + return Ok(()); + } + let links = k.stack(bar, "Links", H, 36.0, json!(0), json!({}))?; + for link in ["Product", "Pricing", "Stories", "Help"] { + k.text(links, link, "Label/M", MUTED)?; + } + let actions = k.stack(bar, "Actions", H, 20.0, json!(0), json!({"crossAlign": "center"}))?; + k.text(actions, "Sign in", "Label/M", INK)?; + k.chip(actions, "Start free", ["Label/M", ON_BRAND], PRIMARY, [10.0, 18.0], 12.0).map(drop) +} + +fn hero(k: &mut Kit, root: NodeId, l: &Layout) -> Result<()> { + let hero = section(k, root, "Hero", l, l.row(), [88.0, 88.0, if l.narrow { 32.0 } else { 72.0 }], json!({}))?; + k.set(hero, json!({"crossAlign": "center"}))?; + let words = k.stack(hero, "Words", V, 24.0, json!(0), json!({"sizingH": "fill"}))?; + k.chip(words, "New · Shared trail maps", ["Label/S", PRIMARY], SOFT, [6.0, 12.0], 999.0)?; + let text = k.stack(words, "Headline", V, 16.0, json!(0), json!({"sizingH": "fill"}))?; + k.para(text, "Plan every walk together", if l.narrow { "Display" } else { "Display/XL" }, INK)?; + k.para( + text, + "Ridgeline Studio keeps your club's routes, maps and meeting points in one place, so the only thing left to decide is who brings the flask.", + "Body/L", + MUTED, + )?; + let actions = k.stack(words, "Actions", H, 12.0, json!(0), json!({}))?; + button(k, actions, "Start free", true)?; + button(k, actions, "Watch the tour", false)?; + k.text(words, "Free for clubs of up to ten people", "Body/S", MUTED)?; + let picture = if l.narrow { json!({"cornerRadius": 24, "sizingH": "fill"}) } else { json!({"cornerRadius": 32}) }; + k.scene(hero, "Picture", if l.narrow { [350.0, 260.0] } else { [520.0, 420.0] }, picture).map(drop) +} + +/// A section's centred title and the line under it. +fn heading(k: &mut Kit, parent: NodeId, title: &str, line: &str) -> Result<()> { + let head = k.stack(parent, "Heading", V, 12.0, json!(0), json!({"sizingH": "fill", "maxWidth": 640, "crossAlign": "center"}))?; + let center = json!({"sizingH": "fill", "textAutoResize": "height", "textAlign": "center"}); + k.text_with(head, title, "Heading/L", INK, center.clone())?; + k.text_with(head, line, "Body/L", MUTED, center).map(drop) +} + +fn features(k: &mut Kit, root: NodeId, l: &Layout) -> Result<()> { + let features = section(k, root, "Features", l, V, [88.0, 88.0, 48.0], json!({"fill": PAGE}))?; + k.set(features, json!({"crossAlign": "center"}))?; + heading(k, features, "Everything the day needs", "Three things walking clubs told us they were tired of doing by hand.")?; + let cards = k.stack(features, "Cards", l.row(), 24.0, json!(0), json!({"sizingH": "fill"}))?; + for (title, glyph, line) in &FEATURES { + let card = k.stack(cards, title, V, 16.0, json!(28), json!({"fill": CARD, "cornerRadius": 24, "sizingH": "fill", "effects": shadow()}))?; + let tile = k.tile(card, 48.0, 14.0)?; + k.icon(tile, glyph, ICON, PRIMARY)?; + let words = k.stack(card, "Words", V, 8.0, json!(0), json!({"sizingH": "fill"}))?; + k.para(words, title, "Heading/S", INK)?; + k.para(words, line, "Body/M", MUTED)?; + } + Ok(()) +} + +fn figures(k: &mut Kit, root: NodeId, l: &Layout) -> Result<()> { + let band = section(k, root, "Figures", l, l.row(), [64.0, 64.0, 32.0], json!({"fill": PRIMARY}))?; + for (value, label) in FIGURES { + let figure = k.stack(band, label, V, 4.0, json!(0), json!({"sizingH": "fill", "crossAlign": "center"}))?; + k.text(figure, value, "Display", ON_BRAND)?; + k.text_with(figure, label, "Body/M", ON_BRAND, json!({"opacity": 0.8}))?; + } + Ok(()) +} + +fn quote(k: &mut Kit, root: NodeId, l: &Layout) -> Result<()> { + let quote = section(k, root, "Quote", l, V, [88.0, 88.0, 28.0], json!({}))?; + k.set(quote, json!({"crossAlign": "center"}))?; + let words = json!({"sizingH": "fill", "maxWidth": 760, "textAutoResize": "height", "textAlign": "center"}); + let said = "“We used to plan in three group chats and a spreadsheet. Now the route is simply there, and everyone has it.”"; + k.text_with(quote, said, "Heading/M", INK, words)?; + let person = k.stack(quote, "Person", H, 12.0, json!(0), json!({"crossAlign": "center"}))?; + let avatar = k.tile(person, 44.0, 22.0)?; + k.text(avatar, "IO", "Label/M", PRIMARY)?; + let who = k.stack(person, "Who", V, 0.0, json!(0), json!({}))?; + k.text(who, "Ilse Okafor", "Label/M", INK)?; + k.text(who, "Walk leader, Harrow Fell Ramblers", "Body/S", MUTED).map(drop) +} + +fn call_to_action(k: &mut Kit, root: NodeId, l: &Layout) -> Result<()> { + let outer = section(k, root, "Call to action", l, V, [0.0, 88.0, 0.0], json!({}))?; + let pad = if l.narrow { 28.0 } else { 56.0 }; + let props = json!({"fill": SOFT, "cornerRadius": 28, "sizingH": "fill", "mainAlign": "spaceBetween", "crossAlign": l.cross()}); + let card = k.stack(outer, "Card", l.row(), 24.0, json!(pad), props)?; + let words = k.stack(card, "Words", V, 8.0, json!(0), json!({"sizingH": "fill"}))?; + k.para(words, "Take the next walk off the group chat", "Heading/L", INK)?; + k.para(words, "Set up your club in a couple of minutes. No card needed.", "Body/L", MUTED)?; + button(k, card, "Start free", true).map(drop) +} + +fn footer(k: &mut Kit, root: NodeId, l: &Layout) -> Result<()> { + let footer = section(k, root, "Footer", l, l.row(), [32.0, 32.0, 12.0], json!({"stroke": BORDER, "strokeSides": TOP_EDGE}))?; + k.set(footer, json!({"mainAlign": "spaceBetween", "crossAlign": l.cross()}))?; + logo(k, footer)?; + k.text(footer, "© 2026 Ridgeline Studio, an invented company", "Body/S", MUTED).map(drop) +} diff --git a/crates/engine/src/templates/mobile.rs b/crates/engine/src/templates/mobile.rs new file mode 100644 index 00000000..863d3725 --- /dev/null +++ b/crates/engine/src/templates/mobile.rs @@ -0,0 +1,195 @@ +//! Mobile app: four screens of an invented trail app ("Ridgeline Trails") built with auto layout +//! from three components (a button, a trail card, a tab bar with two variants), linked into a +//! prototype flow. + +use figcraft_doc::NodeId; +use serde_json::json; + +use super::glyphs::{self, Glyph}; +use super::kit::*; +use crate::Result; + +const W: f64 = 390.0; +const SCREEN_H: f64 = 844.0; +/// Space between screens on the canvas. +const GAP: f64 = 80.0; + +/// The trails on the Explore screen: (name, distance and difficulty). +const TRAILS: [(&str, &str); 3] = + [("Cedar Hollow Loop", "8.4 km · Moderate"), ("Lantern Ridge", "12.1 km · Hard"), ("Mill Pond Walk", "3.2 km · Easy")]; + +const TABS: [(&str, Glyph); 4] = [("Explore", glyphs::MOUNTAIN), ("Saved", glyphs::BOOKMARK), ("Activity", glyphs::CHART), ("Profile", glyphs::USER)]; + +struct Parts { + button: NodeId, + card: NodeId, + tabs: NodeId, +} + +pub(super) fn build(k: &mut Kit) -> Result<()> { + k.foundations()?; + k.rename_page("Screens")?; + let screens = k.page()?; + let page = k.add_page("Components")?; + let parts = Parts { button: button(k, page)?, card: trail_card(k, page)?, tabs: tab_bar(k, page)? }; + k.go(screens)?; + let (welcome, start) = welcome(k, screens, &parts)?; + let (explore, first_card, explore_tabs) = explore(k, screens, &parts)?; + let (detail, back) = detail(k, screens, &parts)?; + let (profile, profile_tabs) = profile(k, screens, &parts)?; + for (from, to) in [(start, explore), (first_card, detail), (back, explore), (explore_tabs, profile), (profile_tabs, explore)] { + k.link(from, to)?; + } + k.flow(welcome, "Onboarding") +} + +fn button(k: &mut Kit, page: NodeId) -> Result { + let props = json!({"fill": PRIMARY, "cornerRadius": 16, "mainAlign": "center", "crossAlign": "center"}); + let c = k.stack_of("component", page, "Button", H, 8.0, json!([16, 24, 16, 24]), props)?; + let label = k.text(c, "Button", "Label/M", ON_BRAND)?; + k.text_prop(c, "Label", "Button", &[label])?; + Ok(c) +} + +fn trail_card(k: &mut Kit, page: NodeId) -> Result { + let props = + json!({"fill": CARD, "cornerRadius": 20, "crossAlign": "center", "effects": shadow(), "sizingH": "fixed", "width": W - 48.0, "y": 120}); + let c = k.stack_of("component", page, "Trail card", H, 14.0, json!(12), props)?; + k.scene(c, "Picture", [72.0, 72.0], json!({"cornerRadius": 14}))?; + let words = k.stack(c, "Words", V, 2.0, json!(0), json!({"sizingH": "fill"}))?; + let title = k.text(words, "Trail name", "Heading/S", INK)?; + let meta = k.text(words, "Distance · Difficulty", "Body/S", MUTED)?; + k.icon(c, &glyphs::CHEVRON_RIGHT, 20.0, MUTED)?; + k.text_prop(c, "Name", "Trail name", &[title])?; + k.text_prop(c, "Details", "Distance · Difficulty", &[meta])?; + Ok(c) +} + +/// The tab bar as a component set: one variant per tab the screens show as current. +fn tab_bar(k: &mut Kit, page: NodeId) -> Result { + let mut variants = Vec::new(); + for (i, current) in ["Explore", "Profile"].into_iter().enumerate() { + let props = json!({"fill": CARD, "stroke": BORDER, "strokeSides": TOP_EDGE, "sizingH": "fixed", "width": W, "y": 260.0 + i as f64 * 110.0}); + let bar = k.stack_of("component", page, &format!("Current={current}"), H, 0.0, json!([10, 12, 26, 12]), props)?; + for (label, glyph) in &TABS { + let color = if *label == current { PRIMARY } else { MUTED }; + let tab = k.stack(bar, label, V, 4.0, json!(0), json!({"sizingH": "fill", "crossAlign": "center"}))?; + k.icon(tab, glyph, ICON, color)?; + k.text(tab, label, "Label/S", color)?; + } + variants.push(bar); + } + k.variants("Tab bar", &variants, json!({})) +} + +/// A screen: a phone-sized frame that stacks its content. +fn screen(k: &mut Kit, page: NodeId, name: &str, index: usize) -> Result { + let props = json!({"fill": PAGE, "stroke": BORDER, "strokeWeight": 1, "strokeAlign": "outside", "clip": true, "sizingH": "fixed", "sizingV": "fixed", "width": W, "height": SCREEN_H, "x": index as f64 * (W + GAP)}); + k.stack(page, name, V, 0.0, json!(0), props) +} + +/// The part of a screen above the tab bar or its last button: it takes the room that is left. +fn body(k: &mut Kit, screen: NodeId, gap: f64, top: f64) -> Result { + k.stack(screen, "Content", V, gap, json!([top, 24, 24, 24]), json!({"sizingH": "fill", "sizingV": "fill"})) +} + +fn welcome(k: &mut Kit, page: NodeId, parts: &Parts) -> Result<(NodeId, NodeId)> { + let s = screen(k, page, "Welcome", 0)?; + k.scene(s, "Picture", [W, 440.0], json!({"sizingH": "fill"}))?; + let content = body(k, s, 0.0, 32.0)?; + k.set(content, json!({"mainAlign": "spaceBetween"}))?; + let words = k.stack(content, "Words", V, 12.0, json!(0), json!({"sizingH": "fill"}))?; + k.text(words, "RIDGELINE TRAILS", "Label/S", PRIMARY)?; + k.para(words, "Find your next trail", "Heading/L", INK)?; + k.para(words, "Maps, distances and notes for quiet walks and long climbs, saved for when there is no signal.", "Body/M", MUTED)?; + let actions = k.stack(content, "Actions", V, 16.0, json!(0), json!({"sizingH": "fill", "crossAlign": "center"}))?; + let start = k.instance(parts.button, actions, json!({"Label": "Get started"}), json!({"sizingH": "fill"}))?; + k.text(actions, "I already have an account", "Label/M", PRIMARY)?; + Ok((s, start)) +} + +fn avatar(k: &mut Kit, parent: NodeId, size: f64, style: &str) -> Result { + let a = k.tile(parent, size, size / 2.0)?; + k.text(a, "ML", style, PRIMARY)?; + Ok(a) +} + +fn explore(k: &mut Kit, page: NodeId, parts: &Parts) -> Result<(NodeId, NodeId, NodeId)> { + let s = screen(k, page, "Explore", 1)?; + let content = body(k, s, 20.0, 56.0)?; + let header = k.stack(content, "Header", H, 12.0, json!(0), json!({"sizingH": "fill", "mainAlign": "spaceBetween", "crossAlign": "center"}))?; + let hello = k.stack(header, "Greeting", V, 2.0, json!(0), json!({}))?; + k.text(hello, "Good morning", "Body/S", MUTED)?; + k.text(hello, "Mara Lindqvist", "Heading/M", INK)?; + avatar(k, header, 44.0, "Label/M")?; + let search_props = json!({"fill": CARD, "cornerRadius": 16, "stroke": BORDER, "strokeWeight": 1, "sizingH": "fill", "crossAlign": "center"}); + let search = k.stack(content, "Search", H, 10.0, json!([14, 16, 14, 16]), search_props)?; + k.icon(search, &glyphs::SEARCH, 20.0, MUTED)?; + k.text(search, "Search trails and places", "Body/M", MUTED)?; + let filters = k.stack(content, "Filters", H, 8.0, json!(0), json!({}))?; + for (i, label) in ["Nearby", "Easy", "Lakes", "Summits"].into_iter().enumerate() { + let (color, fill) = if i == 0 { (ON_BRAND, PRIMARY) } else { (INK, SUNKEN) }; + k.chip(filters, label, ["Label/M", color], fill, [8.0, 16.0], 999.0)?; + } + let title = + k.stack(content, "Section title", H, 8.0, json!(0), json!({"sizingH": "fill", "mainAlign": "spaceBetween", "crossAlign": "center"}))?; + k.text(title, "Popular this week", "Heading/S", INK)?; + k.text(title, "See all", "Label/M", PRIMARY)?; + let list = k.stack(content, "Trails", V, 12.0, json!(0), json!({"sizingH": "fill"}))?; + let mut first = None; + for (name, details) in TRAILS { + let card = k.instance(parts.card, list, json!({"Name": name, "Details": details}), json!({"sizingH": "fill"}))?; + first.get_or_insert(card); + } + let tabs = k.instance(parts.tabs, s, json!({"Current": "Explore"}), json!({"sizingH": "fill"}))?; + Ok((s, first.unwrap_or(list), tabs)) +} + +fn detail(k: &mut Kit, page: NodeId, parts: &Parts) -> Result<(NodeId, NodeId)> { + let s = screen(k, page, "Trail", 2)?; + k.scene(s, "Picture", [W, 300.0], json!({"sizingH": "fill"}))?; + let back = k.tile(s, 44.0, 22.0)?; + k.set(back, json!({"name": "Back", "fill": CARD, "absolute": true, "x": 20, "y": 52}))?; + k.icon(back, &glyphs::ARROW_LEFT, 20.0, INK)?; + let content = body(k, s, 20.0, 24.0)?; + k.set(content, json!({"mainAlign": "spaceBetween"}))?; + let about = k.stack(content, "About", V, 16.0, json!(0), json!({"sizingH": "fill"}))?; + let heading = k.stack(about, "Heading", V, 6.0, json!(0), json!({"sizingH": "fill"}))?; + let [(name, _), ..] = TRAILS; + k.para(heading, name, "Heading/L", INK)?; + let place = k.stack(heading, "Place", H, 6.0, json!(0), json!({"crossAlign": "center"}))?; + k.icon(place, &glyphs::PIN, 16.0, MUTED)?; + k.text(place, "Northfork Valley", "Body/M", MUTED)?; + let stats = k.stack(about, "Figures", H, 10.0, json!(0), json!({"sizingH": "fill"}))?; + for (value, label) in [("8.4 km", "Distance"), ("310 m", "Climb"), ("2 h 40", "Time")] { + k.stat(stats, value, label, "Heading/S")?; + } + k.text(about, "About this trail", "Heading/S", INK)?; + k.para(about, "A shaded loop through old cedars that opens onto the ridge at the halfway point. Bring water: the spring near the trailhead dries up in late summer.", "Body/M", MUTED)?; + k.instance(parts.button, content, json!({"Label": "Start hike"}), json!({"sizingH": "fill"}))?; + Ok((s, back)) +} + +fn profile(k: &mut Kit, page: NodeId, parts: &Parts) -> Result<(NodeId, NodeId)> { + let s = screen(k, page, "Profile", 3)?; + let content = body(k, s, 24.0, 72.0)?; + let who = k.stack(content, "Who", V, 6.0, json!(0), json!({"sizingH": "fill", "crossAlign": "center"}))?; + avatar(k, who, 88.0, "Heading/M")?; + k.text(who, "Mara Lindqvist", "Heading/M", INK)?; + k.text(who, "Hiking since 2021 · Northfork", "Body/M", MUTED)?; + let stats = k.stack(content, "Figures", H, 10.0, json!(0), json!({"sizingH": "fill"}))?; + for (value, label) in [("128 km", "This year"), ("23", "Hikes"), ("9", "Summits")] { + k.stat(stats, value, label, "Heading/S")?; + } + let menu = k.stack(content, "Menu", V, 0.0, json!([4, 16, 4, 16]), json!({"fill": CARD, "cornerRadius": 20, "sizingH": "fill"}))?; + for (label, glyph) in + [("Saved trails", glyphs::BOOKMARK), ("Offline maps", glyphs::DOWNLOAD), ("Notifications", glyphs::BELL), ("Settings", glyphs::SLIDERS)] + { + let row = k.stack(menu, label, H, 12.0, json!([14, 0, 14, 0]), json!({"sizingH": "fill", "crossAlign": "center"}))?; + k.icon(row, &glyph, 20.0, PRIMARY)?; + k.text_with(row, label, "Body/M", INK, json!({"sizingH": "fill"}))?; + k.icon(row, &glyphs::CHEVRON_RIGHT, 16.0, MUTED)?; + } + let tabs = k.instance(parts.tabs, s, json!({"Current": "Profile"}), json!({"sizingH": "fill"}))?; + Ok((s, tabs)) +} diff --git a/crates/engine/src/templates/mod.rs b/crates/engine/src/templates/mod.rs new file mode 100644 index 00000000..33f2c530 --- /dev/null +++ b/crates/engine/src/templates/mod.rs @@ -0,0 +1,197 @@ +//! Starter templates: documents to begin from, built in code by running commands (no template +//! files ship with the app). Everything in them is original and synthetic: the Ridgeline names, +//! people and figures are invented, the pictures are drawn from shapes and the icons are FigCraft's +//! own ([`glyphs`]). +//! +//! Each template is real, editable content: shared colour and text styles, auto layout, +//! components with properties and instances of them, variables with modes, constraints and +//! prototype links. Building one is deterministic: the same document every time. + +mod dashboard; +mod design_system; +mod glyphs; +mod icon_set; +mod kit; +mod landing; +mod mobile; +mod slides; +mod social; +mod wireframe; + +use figcraft_color::Color; +use figcraft_doc::Document; +use serde_json::{Value, json}; + +use crate::cmd::CommandSpec; +use crate::params::string; +use crate::{DocState, Result, Session, bad, spec}; + +/// What a template is for (the home screen filters by it). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Kind { + App, + Website, + Kit, + Media, +} + +impl Kind { + pub const ALL: [Kind; 4] = [Kind::App, Kind::Website, Kind::Kit, Kind::Media]; + + pub fn id(self) -> &'static str { + match self { + Kind::App => "app", + Kind::Website => "website", + Kind::Kit => "kit", + Kind::Media => "media", + } + } + + pub fn label(self) -> &'static str { + match self { + Kind::App => "Apps", + Kind::Website => "Websites", + Kind::Kit => "Kits", + Kind::Media => "Social and slides", + } + } +} + +/// A starter template. +pub struct Template { + pub id: &'static str, + pub name: &'static str, + /// One line about what is in it. + pub description: &'static str, + pub kind: Kind, + /// The size of its frames, as a tag ("390 × 844"). + pub size: &'static str, + make: fn(&mut kit::Kit) -> Result<()>, +} + +pub static TEMPLATES: &[Template] = &[ + Template { + id: "mobile-app", + name: "Mobile app", + description: "Four screens of a trail app with components and a prototype flow", + kind: Kind::App, + size: "390 × 844", + make: mobile::build, + }, + Template { + id: "landing-page", + name: "Landing page", + description: "A responsive product page: hero, features, figures, a quote and a footer", + kind: Kind::Website, + size: "1440 wide", + make: landing::build, + }, + Template { + id: "dashboard", + name: "Dashboard", + description: "An analytics screen on a grid: figures, charts drawn from vectors and a table", + kind: Kind::App, + size: "1440 × 960", + make: dashboard::build, + }, + Template { + id: "design-system", + name: "Design system starter", + description: "Colour and text styles, Light and Dark variables, buttons, inputs and cards", + kind: Kind::Kit, + size: "2 pages", + make: design_system::build, + }, + Template { + id: "wireframe-kit", + name: "Wireframe kit", + description: "Greyscale components and two example screens to sketch a flow with", + kind: Kind::Kit, + size: "390 and 1280 wide", + make: wireframe::build, + }, + Template { + id: "social-posts", + name: "Social post sizes", + description: "One announcement in five common sizes: square, portrait, story, link and banner", + kind: Kind::Media, + size: "5 sizes", + make: social::build, + }, + Template { + id: "slides", + name: "Presentation slides", + description: "A title, an agenda, a big figure, a chart and a closing slide", + kind: Kind::Media, + size: "1920 × 1080", + make: slides::build, + }, + Template { + id: "icon-set", + name: "Icon set starter", + description: "Icon components on a 24 px grid with keylines and a size preview", + kind: Kind::Kit, + size: "24 × 24", + make: icon_set::build, + }, +]; + +pub fn find(id: &str) -> Option<&'static Template> { + TEMPLATES.iter().find(|t| t.id == id) +} + +impl Template { + /// A new document made from the template, its pages on `background`. + pub fn build(&self, background: Color) -> Result { + let mut kit = kit::Kit::new(self.name); + (self.make)(&mut kit)?; + kit.finish(background) + } + + fn json(&self) -> Value { + json!({"id": self.id, "name": self.name, "description": self.description, "kind": self.kind.id(), "size": self.size}) + } +} + +fn list() -> Value { + Value::Array(TEMPLATES.iter().map(Template::json).collect()) +} + +/// The longest document name `file.newFromTemplate` takes (chars). +const MAX_NAME: usize = 200; + +fn new_from_template(s: &mut Session, p: &Value) -> Result { + let cmd = "file.newFromTemplate"; + let Some(id) = p.get("template") else { + // Asked from a menu: the app shows the templates to pick from. + s.request_ui("home.templates", json!({})); + return Ok(json!({"templates": list()})); + }; + let template = id.as_str().and_then(find).ok_or_else(|| bad(cmd, "`template` must be an id from `template.list`"))?; + let mut doc = template.build(s.new_page_background())?; + if let Some(name) = string(p, "name").map(|n| n.trim().chars().take(MAX_NAME).collect::()).filter(|n| !n.is_empty()) { + doc.name = name; + } + s.docs.push(DocState::new(doc, None)); + s.active = s.docs.len() - 1; + Ok(json!({"index": s.active, "template": template.id})) +} + +pub fn specs() -> Vec { + vec![ + spec!( + "template.list", + "List templates", + "{} the starter templates: [{id, name, description, kind (app|website|kit|media), size}]", + false, + |_, _| Ok(list()) + ), + spec!( + "file.newFromTemplate", + "New from template", + "{template (an id from `template.list`), name? (the document's name; default the template's)} opens a new, unsaved document built from the template → {index, template}; without `template`, returns {templates} (and the app shows them to pick from)", + false, + new_from_template + ), + ] +} diff --git a/crates/engine/src/templates/slides.rs b/crates/engine/src/templates/slides.rs new file mode 100644 index 00000000..322b3407 --- /dev/null +++ b/crates/engine/src/templates/slides.rs @@ -0,0 +1,131 @@ +//! Presentation slides: five 1920 × 1080 slides of an invented season review (title, agenda, a big +//! figure, a chart, closing) with their own text styles and a footer component whose page number +//! is a property. + +use figcraft_doc::NodeId; +use serde_json::{Value, json}; + +use super::glyphs; +use super::kit::*; +use crate::Result; + +const W: f64 = 1920.0; +const SLIDE_H: f64 = 1080.0; +const GAP: f64 = 160.0; + +const AGENDA: [&str; 4] = ["The season in numbers", "What walkers asked for", "What we are building next", "Questions"]; +const WEEKS: [(&str, f64); 10] = + [("W1", 0.30), ("W2", 0.36), ("W3", 0.44), ("W4", 0.41), ("W5", 0.55), ("W6", 0.63), ("W7", 0.72), ("W8", 0.95), ("W9", 0.84), ("W10", 0.77)]; + +pub(super) fn build(k: &mut Kit) -> Result<()> { + k.foundations()?; + k.type_styles(&[ + ("Slide/Figure", 260.0, "Bold", 260.0), + ("Slide/Title", 120.0, "Bold", 128.0), + ("Slide/Heading", 72.0, "Bold", 84.0), + ("Slide/Body", 40.0, "Regular", 58.0), + ("Slide/Label", 26.0, "Medium", 36.0), + ])?; + k.rename_page("Slides")?; + let page = k.page()?; + let parts = k.add_page("Components")?; + let footer = footer(k, parts)?; + title(k, page)?; + agenda(k, page, footer)?; + figure(k, page, footer)?; + chart(k, page, footer)?; + closing(k, page) +} + +/// The footer of the inner slides, with the page number as a text property. +fn footer(k: &mut Kit, page: NodeId) -> Result { + let props = json!({"sizingH": "fixed", "width": W - 240.0, "mainAlign": "spaceBetween", "crossAlign": "center", "stroke": BORDER, "strokeSides": TOP_EDGE}); + let c = k.stack_of("component", page, "Footer", H, 16.0, json!([28, 0, 0, 0]), props)?; + let logo = k.stack(c, "Logo", H, 12.0, json!(0), json!({"crossAlign": "center"}))?; + k.icon(logo, &glyphs::MOUNTAIN, 32.0, PRIMARY)?; + k.text(logo, "Ridgeline Studio · Season review 2026", "Slide/Label", MUTED)?; + let number = k.text(c, "1", "Slide/Label", MUTED)?; + k.text_prop(c, "Page", "1", &[number])?; + Ok(c) +} + +/// Slide `index` (from 0): a 1920 × 1080 frame in a three-column grid on the canvas. Its content +/// goes in the returned frame; inner slides get the footer under it. +fn slide(k: &mut Kit, page: NodeId, name: &str, index: usize, dark: bool, footer: Option) -> Result { + let (x, y) = ((index % 3) as f64 * (W + GAP), (index / 3) as f64 * (SLIDE_H + GAP)); + let fill = if dark { DEEP } else { CARD }; + let frame = json!({"fill": fill, "stroke": BORDER, "strokeWeight": 1, "strokeAlign": "outside", "clip": true, "sizingH": "fixed", "sizingV": "fixed", "width": W, "height": SLIDE_H, "x": x, "y": y}); + let root = k.stack(page, name, V, 40.0, json!([120, 120, 72, 120]), frame)?; + let content = k.stack(root, "Content", V, 48.0, json!(0), json!({"sizingH": "fill", "sizingV": "fill", "mainAlign": "center"}))?; + if let Some(footer) = footer { + k.instance(footer, root, json!({"Page": (index + 1).to_string()}), json!({"sizingH": "fill"}))?; + } + Ok(content) +} + +fn wrap() -> Value { + json!({"sizingH": "fill", "textAutoResize": "height"}) +} + +fn title(k: &mut Kit, page: NodeId) -> Result<()> { + let content = slide(k, page, "1 Title", 0, true, None)?; + k.set(content, json!({"layout": H, "gap": 96, "crossAlign": "center"}))?; + let words = k.stack(content, "Words", V, 32.0, json!(0), json!({"sizingH": "fill"}))?; + k.text(words, "SEASON REVIEW · 2026", "Slide/Label", ACCENT)?; + k.text_with(words, "A summer on the ridge", "Slide/Title", ON_BRAND, wrap())?; + k.text_with(words, "What 4,200 clubs walked, and what we learned from them", "Slide/Body", ON_BRAND, merged(wrap(), json!({"opacity": 0.8})))?; + k.scene(content, "Picture", [640.0, 720.0], json!({"cornerRadius": 48})).map(drop) +} + +fn agenda(k: &mut Kit, page: NodeId, footer: NodeId) -> Result<()> { + let content = slide(k, page, "2 Agenda", 1, false, Some(footer))?; + k.text(content, "Today", "Slide/Heading", INK)?; + let list = k.stack(content, "List", V, 0.0, json!(0), json!({"sizingH": "fill"}))?; + for (i, item) in AGENDA.into_iter().enumerate() { + let row = json!({"sizingH": "fill", "crossAlign": "center", "stroke": BORDER, "strokeSides": BOTTOM_EDGE}); + let row = k.stack(list, item, H, 48.0, json!([28, 0, 28, 0]), row)?; + k.text(row, &format!("{:02}", i + 1), "Slide/Body", PRIMARY)?; + k.text_with(row, item, "Slide/Body", INK, json!({"sizingH": "fill"}))?; + } + Ok(()) +} + +fn figure(k: &mut Kit, page: NodeId, footer: NodeId) -> Result<()> { + let content = slide(k, page, "3 Figure", 2, false, Some(footer))?; + k.set(content, json!({"gap": 8}))?; + k.text(content, "38,000", "Slide/Figure", PRIMARY)?; + k.text_with( + content, + "routes shared between June and September: three times as many as last summer", + "Slide/Body", + MUTED, + merged(wrap(), json!({"maxWidth": 1100})), + ) + .map(drop) +} + +fn chart(k: &mut Kit, page: NodeId, footer: NodeId) -> Result<()> { + let content = slide(k, page, "4 Chart", 3, false, Some(footer))?; + k.set(content, json!({"layout": H, "gap": 96, "crossAlign": "center"}))?; + let words = k.stack(content, "Words", V, 32.0, json!(0), json!({"sizingH": "fixed", "width": 620}))?; + k.text_with(words, "Walks peaked in week eight", "Slide/Heading", INK, wrap())?; + k.text_with(words, "The long weekend filled every guided walk. Weeks nine and ten held most of it.", "Slide/Body", MUTED, wrap())?; + let card = k.stack( + content, + "Walks per week", + V, + 24.0, + json!(48), + json!({"fill": PAGE, "cornerRadius": 40, "sizingH": "fill", "sizingV": "fixed", "height": 640}), + )?; + k.text(card, "Walks per week", "Slide/Label", INK)?; + k.bar_chart(card, [900.0, 440.0], &WEEKS, "Slide/Label") +} + +fn closing(k: &mut Kit, page: NodeId) -> Result<()> { + let content = slide(k, page, "5 Closing", 4, true, None)?; + k.set(content, json!({"gap": 24}))?; + k.icon(content, &glyphs::MOUNTAIN, 96.0, ACCENT)?; + k.text(content, "Thank you", "Slide/Title", ON_BRAND)?; + k.text_with(content, "Questions and routes to hello@ridgeline.example", "Slide/Body", ON_BRAND, json!({"opacity": 0.8})).map(drop) +} diff --git a/crates/engine/src/templates/social.rs b/crates/engine/src/templates/social.rs new file mode 100644 index 00000000..61525260 --- /dev/null +++ b/crates/engine/src/templates/social.rs @@ -0,0 +1,58 @@ +//! Social post sizes: one invented announcement ("Summer trail series") laid out in five common +//! sizes by the same code. Wide sizes put the words beside the picture, tall ones above it; type +//! and spacing scale with the frame. + +use figcraft_doc::NodeId; +use serde_json::json; + +use super::glyphs; +use super::kit::*; +use crate::Result; + +/// (name, width, height, x, y) on the canvas. +const SIZES: [(&str, f64, f64, f64, f64); 5] = [ + ("Square", 1080.0, 1080.0, 0.0, 0.0), + ("Portrait", 1080.0, 1350.0, 1200.0, 0.0), + ("Story", 1080.0, 1920.0, 2400.0, 0.0), + ("Link preview", 1200.0, 630.0, 3600.0, 0.0), + ("Banner", 1500.0, 500.0, 3600.0, 750.0), +]; + +pub(super) fn build(k: &mut Kit) -> Result<()> { + k.foundations()?; + k.rename_page("Posts")?; + let page = k.page()?; + for (name, w, h, x, y) in SIZES { + post(k, page, &format!("{name} · {w} × {h}"), [w, h], [x, y])?; + } + Ok(()) +} + +fn post(k: &mut Kit, page: NodeId, name: &str, [w, h]: [f64; 2], [x, y]: [f64; 2]) -> Result<()> { + let wide = w > h * 1.5; + // Type and spacing follow the shorter side. + let u = (w.min(h) / 18.0).round(); + let frame = json!({"fill": DEEP, "clip": true, "sizingH": "fixed", "sizingV": "fixed", "width": w, "height": h, "x": x, "y": y}); + let root = k.stack(page, name, if wide { H } else { V }, 0.0, json!(0), frame)?; + let words = k.stack(root, "Words", V, u, json!(u * 1.2), json!({"sizingH": "fill", "sizingV": "fill", "mainAlign": "spaceBetween"}))?; + let logo = k.stack(words, "Logo", H, u * 0.3, json!(0), json!({"crossAlign": "center"}))?; + k.icon(logo, &glyphs::MOUNTAIN, (u * 0.9).round(), ACCENT)?; + k.sized_text(logo, "Ridgeline Trails", [u * 0.62, u * 0.9], "Semi Bold", ON_BRAND, json!({}))?; + let message = k.stack(words, "Message", V, u * 0.5, json!(0), json!({"sizingH": "fill"}))?; + k.sized_text(message, "SUMMER TRAIL SERIES", [u * 0.5, u * 0.7], "Semi Bold", ACCENT, json!({"letterSpacing": "8%"}))?; + let wrap = json!({"sizingH": "fill", "textAutoResize": "height"}); + let headline = u * 1.8; + k.sized_text(message, "Six Saturdays.\nSix summits.", [headline, headline * 1.08], "Bold", ON_BRAND, wrap.clone())?; + k.sized_text(message, "Guided walks for every pace, starting 14 June.", [u * 0.6, u * 0.9], "Regular", "#CFE6DE", wrap)?; + let action = k.stack(words, "Action", H, u * 0.6, json!(0), json!({"crossAlign": "center"}))?; + let button = k.stack(action, "Button", H, 0.0, json!([u * 0.4, u * 0.8, u * 0.4, u * 0.8]), json!({"fill": ACCENT, "cornerRadius": 999}))?; + k.sized_text(button, "Join the first walk", [u * 0.55, u * 0.8], "Semi Bold", INK, json!({}))?; + k.sized_text(action, "ridgeline.example", [u * 0.5, u * 0.8], "Medium", "#CFE6DE", json!({}))?; + // The picture takes the rest: the right-hand side of a wide post, the bottom of a tall one. + let (size, fill) = if wide { + ([w * 0.4, h], json!({"sizingV": "fill"})) + } else { + ([w, (h * if h > w { 0.36 } else { 0.3 }).round()], json!({"sizingH": "fill"})) + }; + k.scene(root, "Picture", size, fill).map(drop) +} diff --git a/crates/engine/src/templates/wireframe.rs b/crates/engine/src/templates/wireframe.rs new file mode 100644 index 00000000..68426c07 --- /dev/null +++ b/crates/engine/src/templates/wireframe.rs @@ -0,0 +1,193 @@ +//! Wireframe kit: greyscale components (buttons, an input, an image placeholder, a paragraph, an +//! avatar, a list item, a card, a navigation bar and a tab bar) and two screens sketched with +//! instances of them, a phone and a desktop one. + +use figcraft_doc::NodeId; +use serde_json::{Value, json}; + +use super::glyphs; +use super::kit::*; +use crate::Result; + +const WIRE_INK: &str = "Wire/Ink"; +const MID: &str = "Wire/Mid"; +const LINE: &str = "Wire/Line"; +const FILL: &str = "Wire/Fill"; +const PAPER: &str = "Wire/Paper"; + +struct Parts { + button: NodeId, + input: NodeId, + image: NodeId, + paragraph: NodeId, + avatar: NodeId, + item: NodeId, + card: NodeId, + nav: NodeId, + tabs: NodeId, +} + +pub(super) fn build(k: &mut Kit) -> Result<()> { + k.colors(&[(WIRE_INK, "#2B2B2B"), (MID, "#8A8A8A"), (LINE, "#C4C4C4"), (FILL, "#EBEBEB"), (PAPER, "#FFFFFF")])?; + k.type_styles(TYPE)?; + k.rename_page("Wireframes")?; + let screens = k.page()?; + let page = k.add_page("Components")?; + let (button, input, image, paragraph, avatar) = (buttons(k, page)?, input(k, page)?, image(k, page)?, paragraph(k, page)?, avatar(k, page)?); + let (item, card) = (list_item(k, page, avatar)?, card(k, page, [image, paragraph, button])?); + let parts = Parts { button, input, image, paragraph, avatar, item, card, nav: nav(k, page, button)?, tabs: tabs(k, page)? }; + phone(k, screens, &parts)?; + desktop(k, screens, &parts) +} + +fn fill() -> Value { + json!({"sizingH": "fill"}) +} + +/// A component that stacks its content at (`x`, `y`) on the components page. +fn part(k: &mut Kit, page: NodeId, name: &str, dir: &str, [gap, x, y]: [f64; 3], pad: Value, props: Value) -> Result { + k.stack_of("component", page, name, dir, gap, pad, merged(json!({"x": x, "y": y}), props)) +} + +/// A grey bar standing in for a line of text. +fn bar(k: &mut Kit, parent: NodeId, props: Value) -> Result { + k.rect(parent, "Line", [0.0, 0.0, 120.0, 8.0], merged(json!({"fill": LINE, "cornerRadius": 4}), props)) +} + +fn buttons(k: &mut Kit, page: NodeId) -> Result { + let mut variants = Vec::new(); + let mut labels = Vec::new(); + for (i, kind) in ["Primary", "Secondary"].into_iter().enumerate() { + let (fill, ink) = if i == 0 { (WIRE_INK, PAPER) } else { (PAPER, WIRE_INK) }; + let props = json!({"fill": fill, "stroke": WIRE_INK, "strokeWeight": 1, "cornerRadius": 6, "mainAlign": "center", "crossAlign": "center"}); + let c = part(k, page, &format!("Kind={kind}"), H, [8.0, i as f64 * 140.0, 0.0], json!([10, 18, 10, 18]), props)?; + labels.push(k.text(c, "Button", "Label/M", ink)?); + variants.push(c); + } + let set = k.variants("Button", &variants, json!({}))?; + k.text_prop(set, "Label", "Button", &labels)?; + Ok(set) +} + +fn input(k: &mut Kit, page: NodeId) -> Result { + let c = part(k, page, "Input", V, [6.0, 0.0, 120.0], json!(0), json!({"sizingH": "fixed", "width": 260}))?; + let label = k.text(c, "Label", "Label/S", WIRE_INK)?; + let field = json!({"fill": PAPER, "stroke": MID, "strokeWeight": 1, "cornerRadius": 6, "sizingH": "fill"}); + let field = k.stack(c, "Field", H, 8.0, json!([10, 12, 10, 12]), field)?; + let hint = k.text(field, "Placeholder", "Body/M", MID)?; + k.text_prop(c, "Label", "Label", &[label])?; + k.text_prop(c, "Placeholder", "Placeholder", &[hint])?; + Ok(c) +} + +/// An image placeholder: a grey box crossed corner to corner, whatever its size. +fn image(k: &mut Kit, page: NodeId) -> Result { + let c = k.node("component", page, "Image", [0.0, 220.0, 240.0, 160.0], json!({"fill": FILL, "stroke": LINE, "strokeWeight": 1, "clip": true}))?; + for path in ["M0 0 L240 160", "M240 0 L0 160"] { + let cross = k.line(c, "Cross", path, LINE, 1.0)?; + k.set(cross, json!({"constraints": {"horizontal": "scale", "vertical": "scale"}}))?; + } + Ok(c) +} + +fn paragraph(k: &mut Kit, page: NodeId) -> Result { + let c = part(k, page, "Paragraph", V, [8.0, 300.0, 120.0], json!(0), json!({"sizingH": "fixed", "width": 240}))?; + bar(k, c, fill())?; + bar(k, c, fill())?; + bar(k, c, json!({"width": 150}))?; + Ok(c) +} + +fn avatar(k: &mut Kit, page: NodeId) -> Result { + let c = k.node("component", page, "Avatar", [300.0, 0.0, 40.0, 40.0], json!({"fills": [], "clip": false}))?; + let scale = json!({"horizontal": "scale", "vertical": "scale"}); + k.ellipse(c, "Circle", [0.0, 0.0, 40.0, 40.0], json!({"fill": FILL, "stroke": MID, "strokeWeight": 1, "constraints": scale}))?; + Ok(c) +} + +fn list_item(k: &mut Kit, page: NodeId, avatar: NodeId) -> Result { + let props = json!({"sizingH": "fixed", "width": 342, "crossAlign": "center", "stroke": LINE, "strokeSides": BOTTOM_EDGE}); + let c = part(k, page, "List item", H, [12.0, 0.0, 420.0], json!([12, 0, 12, 0]), props)?; + k.instance(avatar, c, json!({}), json!({}))?; + let words = k.stack(c, "Words", V, 8.0, json!(0), fill())?; + let title = k.text(words, "List item", "Label/M", WIRE_INK)?; + bar(k, words, json!({"width": 160}))?; + k.icon(c, &glyphs::CHEVRON_RIGHT, 20.0, MID)?; + k.text_prop(c, "Title", "List item", &[title])?; + Ok(c) +} + +fn card(k: &mut Kit, page: NodeId, [image, paragraph, button]: [NodeId; 3]) -> Result { + let props = json!({"fill": PAPER, "stroke": LINE, "strokeWeight": 1, "cornerRadius": 8, "sizingH": "fixed", "width": 280}); + let c = part(k, page, "Card", V, [14.0, 400.0, 220.0], json!(14), props)?; + k.instance(image, c, json!({}), json!({"sizingH": "fill", "height": 132}))?; + let title = k.text(c, "Card title", "Heading/S", WIRE_INK)?; + k.instance(paragraph, c, json!({}), fill())?; + k.instance(button, c, json!({"Kind": "Secondary", "Label": "Action"}), json!({}))?; + k.text_prop(c, "Title", "Card title", &[title])?; + Ok(c) +} + +fn nav(k: &mut Kit, page: NodeId, button: NodeId) -> Result { + let props = json!({"fill": PAPER, "sizingH": "fixed", "width": 1280, "mainAlign": "spaceBetween", "crossAlign": "center", "stroke": LINE, "strokeSides": BOTTOM_EDGE}); + let c = part(k, page, "Navigation bar", H, [24.0, 0.0, 620.0], json!([16, 40, 16, 40]), props)?; + k.rect(c, "Logo", [0.0, 0.0, 36.0, 36.0], json!({"fill": WIRE_INK, "cornerRadius": 8}))?; + let links = k.stack(c, "Links", H, 36.0, json!(0), json!({}))?; + for _ in 0..4 { + k.text(links, "Link", "Label/M", MID)?; + } + k.instance(button, c, json!({"Label": "Action"}), json!({}))?; + Ok(c) +} + +fn tabs(k: &mut Kit, page: NodeId) -> Result { + let props = json!({"fill": PAPER, "sizingH": "fixed", "width": 390, "stroke": LINE, "strokeSides": TOP_EDGE}); + let c = part(k, page, "Tab bar", H, [0.0, 0.0, 510.0], json!([10, 12, 26, 12]), props)?; + for i in 0..4 { + let tab = k.stack(c, "Tab", V, 6.0, json!(0), json!({"sizingH": "fill", "crossAlign": "center"}))?; + let ink = if i == 0 { WIRE_INK } else { LINE }; + k.rect(tab, "Icon", [0.0, 0.0, 24.0, 24.0], json!({"fill": ink, "cornerRadius": 6}))?; + k.text(tab, "Tab", "Label/S", if i == 0 { WIRE_INK } else { MID })?; + } + Ok(c) +} + +/// A screen: a white frame of a fixed size that stacks its content. +fn screen(k: &mut Kit, page: NodeId, name: &str, [x, w, h]: [f64; 3]) -> Result { + let props = json!({"fill": PAPER, "stroke": LINE, "strokeWeight": 1, "strokeAlign": "outside", "clip": true, "sizingH": "fixed", "sizingV": "fixed", "width": w, "height": h, "x": x}); + k.stack(page, name, V, 0.0, json!(0), props) +} + +fn phone(k: &mut Kit, page: NodeId, p: &Parts) -> Result<()> { + let s = screen(k, page, "Phone · List", [0.0, 390.0, 844.0])?; + let body = k.stack(s, "Content", V, 20.0, json!([56, 24, 24, 24]), json!({"sizingH": "fill", "sizingV": "fill"}))?; + let header = k.stack(body, "Header", H, 12.0, json!(0), json!({"sizingH": "fill", "mainAlign": "spaceBetween", "crossAlign": "center"}))?; + k.text(header, "Screen title", "Heading/M", WIRE_INK)?; + k.instance(p.avatar, header, json!({}), json!({}))?; + k.instance(p.input, body, json!({"Label": "Search", "Placeholder": "Type to search"}), fill())?; + let list = k.stack(body, "List", V, 0.0, json!(0), fill())?; + for title in ["First item", "Second item", "Third item"] { + k.instance(p.item, list, json!({"Title": title}), fill())?; + } + k.instance(p.card, body, json!({"Title": "Featured"}), fill())?; + k.instance(p.tabs, s, json!({}), fill()).map(drop) +} + +fn desktop(k: &mut Kit, page: NodeId, p: &Parts) -> Result<()> { + let s = screen(k, page, "Desktop · Home", [470.0, 1280.0, 900.0])?; + k.instance(p.nav, s, json!({}), fill())?; + let body = k.stack(s, "Content", V, 48.0, json!([56, 80, 56, 80]), json!({"sizingH": "fill", "sizingV": "fill"}))?; + let hero = k.stack(body, "Hero", H, 64.0, json!(0), json!({"sizingH": "fill", "crossAlign": "center"}))?; + let words = k.stack(hero, "Words", V, 24.0, json!(0), fill())?; + k.text_with(words, "Headline goes here", "Display", WIRE_INK, json!({"sizingH": "fill", "textAutoResize": "height"}))?; + k.instance(p.paragraph, words, json!({}), fill())?; + let actions = k.stack(words, "Actions", H, 12.0, json!(0), json!({}))?; + k.instance(p.button, actions, json!({"Label": "Primary action"}), json!({}))?; + k.instance(p.button, actions, json!({"Kind": "Secondary", "Label": "Secondary"}), json!({}))?; + k.instance(p.image, hero, json!({}), json!({"width": 520, "height": 300}))?; + let cards = k.stack(body, "Cards", H, 24.0, json!(0), fill())?; + for title in ["First card", "Second card", "Third card", "Fourth card"] { + k.instance(p.card, cards, json!({"Title": title}), fill())?; + } + Ok(()) +} diff --git a/crates/engine/src/templates_tests.rs b/crates/engine/src/templates_tests.rs new file mode 100644 index 00000000..756db8b6 --- /dev/null +++ b/crates/engine/src/templates_tests.rs @@ -0,0 +1,218 @@ +//! Starter templates: each builds, the same every time, into a valid document that saves, reopens +//! and exports; `template.list` and `file.newFromTemplate` open them (and survive junk); and +//! building stays fast. + +use figcraft_color::Color; +use figcraft_doc::{Document, NodeKind}; +use serde_json::{Value, json}; + +use crate::templates::{Kind, TEMPLATES, find}; +use crate::{EngineError, Session}; + +fn built(id: &str) -> Document { + // Other tests index the system fonts while these run; text measured before and after would + // differ, so they are indexed first (a machine without fonts measures with the built-in ones). + static FONTS: std::sync::Once = std::sync::Once::new(); + FONTS.call_once(figcraft_text::scan_system_fonts); + find(id).unwrap().build(Color::DEFAULT_PAGE).unwrap_or_else(|e| panic!("{id}: {e}")) +} + +fn json_of(doc: &Document) -> String { + figcraft_format::to_json(doc).unwrap().to_string() +} + +fn count(doc: &Document, f: impl Fn(&NodeKind) -> bool) -> usize { + doc.ids().filter(|id| doc.node(*id).is_some_and(|n| f(&n.kind))).count() +} + +#[test] +fn every_template_builds_a_valid_document_the_same_every_time() { + assert_eq!(TEMPLATES.len(), 8); + for t in TEMPLATES { + let doc = built(t.id); + assert_eq!(doc.validate(), Vec::::new(), "{}", t.id); + assert_eq!(doc.name, t.name); + assert_eq!(doc.current_page, doc.first_page(), "{}: opens on its first page", t.id); + assert!(doc.ids().count() > 40, "{}: has content", t.id); + assert_eq!(json_of(&doc), json_of(&built(t.id)), "{}: two builds differ", t.id); + // Real content: shared styles on its layers, and auto layout. + assert!(doc.styles.len() >= 6, "{}", t.id); + assert!(doc.ids().any(|id| doc.node(id).is_some_and(|n| n.styles.fill.is_some() || n.styles.text.is_some())), "{}", t.id); + assert!(doc.ids().any(|id| doc.node(id).is_some_and(|n| n.layout.is_some())), "{}", t.id); + // Nothing waits to be normalised: opening it changes nothing. + let mut s = Session::with_document(doc.clone()); + s.execute("select.none", &json!({})).unwrap(); + assert_eq!(json_of(s.doc().unwrap()), json_of(&doc), "{}", t.id); + } +} + +#[test] +fn ids_are_unique_and_every_kind_has_templates() { + let mut ids: Vec<&str> = TEMPLATES.iter().map(|t| t.id).collect(); + ids.sort_unstable(); + ids.dedup(); + assert_eq!(ids.len(), TEMPLATES.len()); + for kind in Kind::ALL { + assert!(TEMPLATES.iter().any(|t| t.kind == kind), "{kind:?}"); + } + for t in TEMPLATES { + assert!(!t.name.is_empty() && !t.description.is_empty() && !t.description.ends_with('.') && !t.size.is_empty(), "{}", t.id); + } +} + +#[test] +fn templates_use_the_features_they_show() { + let instances = |d: &Document| count(d, |k| matches!(k, NodeKind::Instance { .. })); + let components = |d: &Document| count(d, |k| matches!(k, NodeKind::Component { .. })); + let sets = |d: &Document| count(d, |k| matches!(k, NodeKind::ComponentSet { .. })); + let app = built("mobile-app"); + assert!(components(&app) >= 4 && sets(&app) == 1 && instances(&app) >= 7); + assert!(matches!(&app.node(app.first_page().unwrap()).unwrap().kind, NodeKind::Page { flows, .. } if flows.len() == 1), "a prototype flow"); + assert!(app.ids().filter(|id| app.node(*id).is_some_and(|n| !n.interactions.is_empty())).count() >= 5, "prototype links"); + let ds = built("design-system"); + assert_eq!(ds.variables.collections.len(), 2); + assert!(ds.variables.collections.iter().any(|c| c.modes.iter().map(|m| m.name.as_str()).eq(["Light", "Dark"]))); + assert!(ds.variables.variables.len() >= 16); + assert!(sets(&ds) == 2 && components(&ds) >= 13 && instances(&ds) >= 9); + assert!(ds.ids().filter(|id| ds.node(*id).is_some_and(|n| !n.bound.is_empty())).count() > 50, "layers bound to variables"); + assert_eq!(ds.ids().filter(|id| ds.node(*id).is_some_and(|n| !n.var_modes.is_empty())).count(), 4, "frames in Light and in Dark"); + let grid = built("dashboard"); + assert!(grid.ids().any(|id| grid.node(id).is_some_and(|n| n.layout.as_ref().is_some_and(|l| l.mode == figcraft_doc::LayoutMode::Grid)))); + assert!(count(&grid, |k| matches!(k, NodeKind::Vector { .. })) >= 8, "charts drawn from vectors"); + let icons = built("icon-set"); + assert_eq!(components(&icons), 24); + assert_eq!(instances(&icons), 4); + assert!(components(&built("wireframe-kit")) >= 9); + assert_eq!(built("social-posts").node(built("social-posts").first_page().unwrap()).unwrap().children.len(), 5); + assert_eq!(built("slides").node(built("slides").first_page().unwrap()).unwrap().children.len(), 5); + assert_eq!(built("landing-page").node(built("landing-page").first_page().unwrap()).unwrap().children.len(), 2, "desktop and phone"); +} + +#[test] +fn templates_save_and_reopen_losslessly() { + for t in TEMPLATES { + let doc = built(t.id); + let bytes = figcraft_format::save_bytes(&doc, None).unwrap(); + let (back, notes) = figcraft_format::load_bytes(&bytes).unwrap(); + assert_eq!(notes, Vec::::new(), "{}: nothing to repair", t.id); + assert_eq!(json_of(&back), json_of(&doc), "{}", t.id); + } +} + +#[test] +fn templates_export_to_png_svg_and_pdf() { + for t in TEMPLATES { + let mut s = Session::empty(); + s.execute("file.newFromTemplate", &json!({"template": t.id})).unwrap(); + let page = s.page().unwrap(); + let frames = s.doc().unwrap().node(page).unwrap().children.clone(); + // One frame keeps the largest templates quick in debug builds. + let first = frames.first().map(|f| f.0).unwrap(); + for format in ["png", "svg", "pdf"] { + let out = s + .execute("file.export", &json!({"ids": [first], "format": format, "scale": 0.25})) + .unwrap_or_else(|e| panic!("{} as {format}: {e}", t.id)); + assert!(out["data"].as_str().is_some_and(|d| d.len() > 100), "{} as {format}", t.id); + } + let pdf = s.execute("file.exportFramesPdf", &json!({})).unwrap(); + assert!(pdf["data"].as_str().is_some_and(|d| d.len() > 100), "{}", t.id); + } +} + +#[test] +fn template_list_describes_every_template() { + let mut s = Session::empty(); + let list = s.execute("template.list", &json!({})).unwrap(); + let list = list.as_array().unwrap(); + assert_eq!(list.len(), TEMPLATES.len()); + for (item, t) in list.iter().zip(TEMPLATES) { + assert_eq!(item, &json!({"id": t.id, "name": t.name, "description": t.description, "kind": t.kind.id(), "size": t.size})); + } + assert!(s.docs.is_empty() && s.ui_requests.is_empty(), "listing opens nothing"); +} + +#[test] +fn new_from_template_opens_a_new_unsaved_document_and_leaves_the_others_alone() { + let mut s = Session::new(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + let before = json_of(s.doc().unwrap()); + let r = s.execute("file.newFromTemplate", &json!({"template": "dashboard"})).unwrap(); + assert_eq!(r, json!({"index": 1, "template": "dashboard"})); + assert_eq!((s.docs.len(), s.active), (2, 1)); + let ds = s.ds().unwrap(); + assert_eq!(ds.doc.name, "Dashboard"); + assert!(ds.path.is_none() && !ds.is_dirty() && ds.history.undo.is_empty(), "a new file: no path, nothing to save or undo yet"); + assert_eq!(json_of(&s.docs[0].doc), before, "the open document is untouched"); + // It is a document like any other: editable, and the edit is undoable. + let root = s.doc().unwrap().node(s.page().unwrap()).unwrap().children[0]; + s.execute("node.set", &json!({"ids": [root.0], "props": {"name": "Mine"}})).unwrap(); + assert!(s.ds().unwrap().is_dirty()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().node(root).unwrap().name, "Dashboard"); + // A second one is a separate document, named as asked. + let r = s.execute("file.newFromTemplate", &json!({"template": "dashboard", "name": " Q3 numbers "})).unwrap(); + assert_eq!(r["index"], 2); + assert_eq!(s.doc().unwrap().name, "Q3 numbers"); + // The page takes the interface's theme, like a new file. + s.set_ui_dark(true); + s.execute("file.newFromTemplate", &json!({"template": "icon-set"})).unwrap(); + let doc = s.doc().unwrap(); + assert!(matches!(doc.node(s.page().unwrap()).unwrap().kind, NodeKind::Page { background, .. } if background == Color::DARK_PAGE)); +} + +#[test] +fn without_a_template_the_app_is_asked_to_show_them() { + let mut s = Session::empty(); + let r = s.execute("file.newFromTemplate", &json!({})).unwrap(); + assert_eq!(r["templates"].as_array().unwrap().len(), TEMPLATES.len()); + assert!(s.docs.is_empty()); + assert_eq!(s.ui_requests.iter().map(|r| r.kind.as_str()).collect::>(), ["home.templates"]); +} + +#[test] +fn junk_never_panics_or_opens_anything() { + let junk = [ + json!({"template": "nope"}), + json!({"template": ""}), + json!({"template": 7}), + json!({"template": null}), + json!({"template": ["mobile-app"]}), + json!({"template": {"id": "mobile-app"}}), + json!({"template": "mobile-app\u{0}"}), + json!({"template": "MOBILE-APP"}), + json!({"template": "x".repeat(100_000)}), + ]; + for j in &junk { + let mut s = Session::new(); + match s.execute("file.newFromTemplate", j) { + Err(EngineError::BadParams { .. }) => {} + other => panic!("{j}: {other:?}"), + } + assert_eq!(s.docs.len(), 1, "{j}"); + } + // Junk beside a good id: the name falls back to the template's, or is cut to a sane length. + let mut s = Session::empty(); + for name in [json!(5), json!(null), json!(""), json!(" "), json!({"a": 1}), json!("é".repeat(5000))] { + s.execute("file.newFromTemplate", &json!({"template": "icon-set", "name": name})).unwrap(); + assert!((1..=200).contains(&s.doc().unwrap().name.chars().count())); + } + for j in [Value::Null, json!([1]), json!("x"), json!(3)] { + let _ = s.execute("file.newFromTemplate", &j); + let _ = s.execute("template.list", &j); + } +} + +/// Building is fast enough to do on a click: each template well under a second even in an +/// unoptimised build on a busy machine (a release build takes a few milliseconds). +#[test] +fn building_stays_within_its_time_budget() { + // Indexing the fonts (in `built`) is not part of the budget. + built("icon-set"); + for t in TEMPLATES { + let start = std::time::Instant::now(); + let doc = built(t.id); + let took = start.elapsed(); + println!("{}: {} layers in {:.1} ms", t.id, doc.ids().count(), took.as_secs_f64() * 1000.0); + assert!(took < std::time::Duration::from_secs(3), "{} took {took:?}", t.id); + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 31c92ed7..980c816f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -258,6 +258,8 @@ fn every_command_survives_junk_params() { json!({"variations": {"wght": 1e308, "": "x", "slnt": null}, "props": {"fontVariations": [1], "variationSettings": {"wdth": "-1e9"}}, "text": 7, "cursor": -3}), json!({"style": 3, "props": {"listStyle": 5, "indentation": 1e308, "listSpacing": "NaN", "hangingList": "yes", "start": -5, "end": 1e12, "textDecorationThickness": {"unit": "pixels", "value": "x"}, "textDecorationOffset": -1e308, "textDecorationColor": [1]}}), + // Templates and the Getting started tips as junk. + json!({"template": {"id": 1}, "name": [1e308], "show": "yes"}), // Links (dev resources) and indexes as junk. json!({"id": 2, "url": "javascript:alert(1)", "index": 1e308, "name": {"x": 1}}), json!({"id": 2, "url": "https://\u{0}x", "index": -1, "name": ""}),