mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-29 04:46:50 -06:00
M11: starter templates: eight templates built in code, template.list and file.newFromTemplate, help.gettingStarted
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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, ¶ms)
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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": ""}),
|
||||
|
||||
Reference in New Issue
Block a user