M11: starter templates: eight templates built in code, template.list and file.newFromTemplate, help.gettingStarted

This commit is contained in:
bflatastic
2026-10-13 03:01:19 +07:00
committed by BFlat
parent 31815f1d13
commit c2a67f81e5
17 changed files with 2216 additions and 1 deletions
+8 -1
View File
@@ -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<Item = &'static MenuItem> {
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;
+1
View File
@@ -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() {
+14
View File
@@ -382,6 +382,20 @@ pub fn specs() -> Vec<CommandSpec> {
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",
+7
View File
@@ -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<online::OnlineFile>,
/// 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;
+242
View File
@@ -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<NodeId> {
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<NodeId> {
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)
}
@@ -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<NodeId> {
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<NodeId> {
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<NodeId> {
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<NodeId> {
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<NodeId> {
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<NodeId> {
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)
}
+43
View File
@@ -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"
}
+85
View File
@@ -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<NodeId> {
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<NodeId> {
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(())
}
+412
View File
@@ -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<String, String>,
}
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<Document> {
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<Value> {
self.s.execute(cmd, &params)
}
/// The id a command answered with.
fn made(&mut self, cmd: &str, params: Value) -> Result<NodeId> {
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<NodeId> {
self.s.page()
}
/// A new page, which becomes the one being built on.
pub fn add_page(&mut self, name: &str) -> Result<NodeId> {
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<NodeId> {
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<NodeId> {
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<NodeId> {
self.node("frame", parent, name, at, props)
}
pub fn rect(&mut self, parent: NodeId, name: &str, at: [f64; 4], props: Value) -> Result<NodeId> {
self.node("rectangle", parent, name, at, props)
}
pub fn ellipse(&mut self, parent: NodeId, name: &str, at: [f64; 4], props: Value) -> Result<NodeId> {
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<NodeId> {
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<NodeId> {
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<NodeId> {
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<NodeId> {
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<NodeId> {
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<NodeId> {
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<NodeId> {
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<NodeId> {
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<NodeId> {
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<Value>)>) -> Result<()> {
self.run("variable.createCollection", json!({"name": collection, "modes": modes}))?;
for (name, kind, values) in vars {
let values: Map<String, Value> = 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<NodeId> {
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<NodeId> {
let set = self.made("component.combineVariants", json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))?;
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<NodeId> {
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<NodeId> {
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<NodeId> {
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<NodeId> {
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<NodeId> {
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)
}
}
+191
View File
@@ -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<NodeId> {
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<NodeId> {
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<NodeId> {
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)
}
+195
View File
@@ -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<NodeId> {
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<NodeId> {
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<NodeId> {
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<NodeId> {
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<NodeId> {
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<NodeId> {
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))
}
+197
View File
@@ -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<Document> {
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<Value> {
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::<String>()).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<CommandSpec> {
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
),
]
}
+131
View File
@@ -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<NodeId> {
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<NodeId>) -> Result<NodeId> {
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)
}
+58
View File
@@ -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)
}
+193
View File
@@ -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<NodeId> {
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<NodeId> {
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<NodeId> {
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<NodeId> {
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<NodeId> {
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<NodeId> {
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<NodeId> {
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<NodeId> {
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<NodeId> {
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<NodeId> {
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<NodeId> {
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<NodeId> {
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(())
}
+218
View File
@@ -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::<String>::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::<String>::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::<Vec<_>>(), ["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);
}
}
+2
View File
@@ -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": ""}),