2 Commits

Author SHA1 Message Date
Marcos Paulo d934f4ca89 chore(release): 0.7.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 20:51:09 -03:00
Marcos Paulo 5b54f9a7be feat: UI kits and code links, issues from the app, design-playbook rules, one-row top bar
- Code links on main components (import plus React and egui usage templates);
  linked instances export as library code and the AI spec lists them as
  library components instead of their layers.
- apply_ui_kit builds shadcn/ui, Ant Design, React Bits and egui kits; the
  chat brief asks for the code library and builds from its components.
- report_issue drafts and a Report a PaperCraft issue form file on the
  configured repository (keychain token or tea login), de-duplicated by title.
- design_rules looks up design-playbook rules through the playbook CLI; the
  brief asks whether to follow it.
- Top bar: project name and page picker on one centred row, so the bar no
  longer overflows onto the canvas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 20:50:52 -03:00
18 changed files with 1300 additions and 82 deletions
Generated
+1 -1
View File
@@ -3128,7 +3128,7 @@ dependencies = [
[[package]]
name = "papercraft"
version = "0.7.1"
version = "0.7.2"
dependencies = [
"arboard",
"axum",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "papercraft"
version = "0.7.1"
version = "0.7.2"
edition = "2024"
rust-version = "1.95"
description = "A native infinite-canvas app for planning, working and sketching"
+9
View File
@@ -76,6 +76,15 @@ are credited in `THIRD_PARTY.md`.
* **Not carried over**: comments, design tokens, AI image generation, video export, teams;
moving layers between pages; remote image URLs (see README Known gaps).
### 2026-10-24: 0.7.2 (UI kits, code links, issues from the app, design playbook)
* Code links (Figma Code Connect style): a main component carries its library import and a React and an egui template (`set_code_link`). The React, Tailwind, inline JSX and egui exports write linked instances as that code, with overrides filled in and imports gathered once; the AI spec lists them as Library Components with a "use it, do not rebuild it" rule and stops describing their inner layers.
* UI kits (`src/ui_kits.rs`, `apply_ui_kit`): shadcn/ui, Ant Design, React Bits and egui, each with Light and Dark variables, tokens and components in its look and density, all linked. The chat brief asks for the code library and starts from its kit.
* Issues from the app (`src/issues.rs`): the agent's `report_issue` draft card (Send or Discard) and a Report a PaperCraft issue form, filed on the repository in Settings (token in the keychain, else the `tea` login), de-duplicated by title against open and closed issues.
* design-playbook in the chat: with the `playbook` CLI installed, the brief asks whether to follow the playbook and the agent looks rules up with `design_rules`, citing ids in its report.
* Top bar: project name and page picker on one centred row; the two-row title had outgrown the bar and covered the canvas.
* Evidence: `src/ui_kits.rs`, `src/issues.rs`, `src/mcp/codegen.rs` (`linked_components_export_as_library_code`), `specs/ui-kits/spec.md`.
### 2026-10-24: 0.7.1 (rename fix)
* Enter, Escape and clicking away end a rename again (projects on the home screen, library assets, versions). The fields took focus back before checking whether they had lost it.
+6
View File
@@ -114,6 +114,12 @@ Full flows: [recipes.md](references/recipes.md).
| Reuse | `create_component`, `create_instance`, `swap_instance`, `get_components`, `<x-paper-instance>` |
| Shared libraries | `publish_library`, `list_libraries`, `enable_library`, `update_library`, `disable_library` |
| Icons | `search_icons`, `<x-paper-icon name="set:name">`; CLI: `papercraft-cli icons add\|list\|search` |
| Design in a library's look, hand off its code | `apply_ui_kit` (shadcn, antd, reactbits, egui), then `<x-paper-instance>` of its components; `set_code_link` for a component of your own; `get_jsx` / `get_design_spec` then write `<Button variant="outline">` instead of divs |
| Avatars, more icons, photos, flags, QR | `<x-plugin use="avataaars\|dicebear\|iconify\|picsum\|flags\|qr" …/>` inside `write_html` (plugins the user turned on) |
| References and art | `fetch_reference_images`, `zoom_image`, `generate_image` (when the user turned image generation on) |
| Ask the user | `ask_user` (clickable answers; end your turn after it) |
| Design rules (design-playbook) | `design_rules` (`query` or `rule` id; needs the `playbook` CLI; cite ids in the report) |
| Report a PaperCraft problem | `report_issue` (a draft the user sends; synthetic repro only) |
| Check quality | `lint_design` |
| Export | `export` (PNG, SVG, PDF to files), `export_combined_pdf` |
| Point at things for the user | `select_nodes` (`zoom: true`) |
+43
View File
@@ -0,0 +1,43 @@
# UI kits and code links
## Why
The Code & AI Handoff rebuilds every layer as `<div>`s with Tailwind classes, and the AI spec
describes boxes. A team that ships with shadcn/ui, Ant Design or React Bits then re-maps each
box to its library by hand, and the design never looked like that library in the first place.
For egui the same holds: the export draws frames where the app would call `ui.button`.
## What
1. **Code links** (like Figma's Code Connect): a main component can carry a `CodeLink`: the
kit, the import line, and a usage template per target, React (`<Button variant="outline">{Label}</Button>`)
and egui (`ui.button({Label})`). `{Name}` in a template is the text of the layer named
`Name` in the instance (its override when there is one). Set with the `set_code_link` tool
or by a kit.
2. **Kits** (data, `src/ui_kits.rs`): shadcn/ui, Ant Design, React Bits (animated text,
backgrounds and a dock, on top of any styling) and egui. Each kit has its tokens (colors in
light and dark, radius, control height, font size) and a set of components: the markup that
draws it in that library's look and density, and its code link. `apply_ui_kit` builds a
"UI Kit / <name>" page with the variables, tokens and linked components, ready to place
with `<x-paper-instance>`.
3. **Handoff**: React and Tailwind exports emit a linked instance as its template (with the
imports gathered at the top) instead of its layers; the egui export does the same with the
egui template. The AI spec gets a "Library components" section (component, import, usage,
where it is used) and the rule to use them, not to rebuild them.
4. **Chat**: the brief asks which library the code will use; the agent applies that kit
before building, so screens are made of linked components from the start.
## Not now
Variant props beyond text (a variant is its own component with its own template), Vue and
Svelte templates, importing a kit from a package registry.
## Acceptance
* A linked Button instance with the override `Label: "Save"` exports in React as
`<Button variant="default">Save</Button>` with `import { Button } from "@/components/ui/button"`
once at the top, and in egui as its egui call.
* An unlinked frame exports as before.
* `apply_ui_kit({kit:"shadcn"})` makes a page with the kit's variables and its components, each
a main component with a code link.
* The AI spec of a screen with linked instances lists them with import and usage.
+200
View File
@@ -0,0 +1,200 @@
//! Issues for PaperCraft itself, filed on its repository from inside the app: by the user (a form)
//! or drafted by the chat agent when the app gets in its way (`report_issue`, sent only when the
//! user presses Send). Same rules as the `papercraft-self-report` skill: kinds `bug`, `docs`,
//! `feature` as a `[kind]` title prefix, and no new issue when an open or closed one already has
//! a matching title.
//!
//! Transport: a token saved in Settings (Gitea or GitHub REST API); without one, the `tea` CLI
//! with the user's own login for that server.
use serde_json::{Value, json};
use std::process::Command;
pub const KINDS: [&str; 3] = ["bug", "docs", "feature"];
pub const DEFAULT_REPO: &str = "https://git.marcospaulo.dev.br/masi/papercraft";
const KEY_USER: &str = "issues-token";
const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
/// Titles shorter than this are not compared: too little to call two issues the same.
const MIN_MATCH: usize = 12;
#[derive(Debug, Clone, PartialEq)]
pub struct Draft {
pub kind: String,
pub title: String,
pub body: String,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Filed {
New { number: u64, url: String },
/// An issue with a matching title exists already; nothing was filed.
Duplicate { number: u64, url: String, title: String },
}
/// `(scheme://host, owner, repo)` of a repository URL.
pub fn repo_parts(url: &str) -> Option<(String, String, String)> {
let u = reqwest::Url::parse(url.trim().trim_end_matches(".git")).ok()?;
let mut seg = u.path_segments()?.filter(|s| !s.is_empty());
let (owner, repo) = (seg.next()?.to_owned(), seg.next()?.to_owned());
Some((format!("{}://{}", u.scheme(), u.host_str()?), owner, repo))
}
pub fn token() -> Option<String> {
keyring::Entry::new("PaperCraft", KEY_USER).ok()?.get_password().ok().filter(|t| !t.trim().is_empty())
}
pub fn set_token(t: &str) -> Result<(), String> {
let e = keyring::Entry::new("PaperCraft", KEY_USER).map_err(|e| e.to_string())?;
if t.trim().is_empty() {
return match e.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => Err(e.to_string()),
};
}
e.set_password(t.trim()).map_err(|e| e.to_string())
}
/// Files the draft (or finds the issue it duplicates). Blocking: call from a worker thread.
pub fn file(repo: &str, d: &Draft) -> Result<Filed, String> {
let (host, owner, name) = repo_parts(repo).ok_or("the issue repository is not a repository URL (Settings, Updates)")?;
if !KINDS.contains(&d.kind.as_str()) {
return Err(format!("kind must be one of {}", KINDS.join(", ")));
}
if d.title.trim().is_empty() {
return Err("an issue needs a title".into());
}
let title = format!("[{}] {}", d.kind, d.title.trim());
let body = format!(
"{}\n\n---\nFiled from PaperCraft {} ({}).",
d.body.trim(),
env!("CARGO_PKG_VERSION"),
std::env::consts::OS
);
let existing = match token() {
Some(t) => rest_list(&host, &owner, &name, &t)?,
None => tea_list(&host, &owner, &name)?,
};
if let Some((number, url, t)) = existing.into_iter().find(|(_, _, t)| same_issue(t, &title)) {
return Ok(Filed::Duplicate { number, url, title: t });
}
let (number, url) = match token() {
Some(t) => rest_create(&host, &owner, &name, &t, &title, &body)?,
None => tea_create(&host, &owner, &name, &title, &body, &d.kind)?,
};
Ok(Filed::New { number, url })
}
/// Two titles describe the same issue: one contains the other, ignoring case and the `[kind]`.
fn same_issue(a: &str, b: &str) -> bool {
let norm = |t: &str| {
let t = t.trim().to_lowercase();
t.strip_prefix('[').and_then(|r| r.split_once(']')).map_or(t.as_str(), |(_, rest)| rest).trim().to_owned()
};
let (a, b) = (norm(a), norm(b));
a.len().min(b.len()) >= MIN_MATCH && (a.contains(&b) || b.contains(&a))
}
fn github(host: &str) -> bool {
host.ends_with("://github.com")
}
fn api(host: &str, owner: &str, name: &str) -> String {
if github(host) { format!("https://api.github.com/repos/{owner}/{name}/issues") } else { format!("{host}/api/v1/repos/{owner}/{name}/issues") }
}
fn rest(host: &str, token: &str, req: reqwest::RequestBuilder) -> Result<Value, String> {
let auth = if github(host) { format!("Bearer {token}") } else { format!("token {token}") };
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().map_err(|e| e.to_string())?;
rt.block_on(async {
let resp = req.header("authorization", auth).header("user-agent", "PaperCraft").header("accept", "application/json").timeout(TIMEOUT).send().await.map_err(|e| e.to_string())?;
let status = resp.status();
let v: Value = resp.json().await.unwrap_or(Value::Null);
if !status.is_success() {
return Err(format!("the issue tracker answered {status}: {}", v["message"].as_str().unwrap_or("")));
}
Ok(v)
})
}
fn rest_list(host: &str, owner: &str, name: &str, token: &str) -> Result<Vec<(u64, String, String)>, String> {
let c = reqwest::Client::new();
let url = format!("{}?state=all&limit=100&per_page=100&type=issues", api(host, owner, name));
let v = rest(host, token, c.get(url))?;
Ok(v.as_array().into_iter().flatten().filter_map(|i| Some((i["number"].as_u64()?, i["html_url"].as_str()?.to_owned(), i["title"].as_str()?.to_owned()))).collect())
}
fn rest_create(host: &str, owner: &str, name: &str, token: &str, title: &str, body: &str) -> Result<(u64, String), String> {
let c = reqwest::Client::new();
let v = rest(host, token, c.post(api(host, owner, name)).json(&json!({"title": title, "body": body})))?;
Ok((v["number"].as_u64().ok_or("the tracker returned no issue number")?, v["html_url"].as_str().unwrap_or_default().to_owned()))
}
/// The `tea` login for a server (its name in tea's config), so tea talks to the right one.
fn tea_login(host: &str) -> Option<String> {
// tea keeps its config in ~/.config/tea on every platform (and the OS config folder on some).
let cfg = [dirs::home_dir().map(|h| h.join(".config")), dirs::config_dir()]
.into_iter()
.flatten()
.find_map(|d| std::fs::read_to_string(d.join("tea").join("config.yml")).ok())?;
let mut name = None;
for line in cfg.lines().map(str::trim) {
if let Some(n) = line.strip_prefix("- name:") {
name = Some(n.trim().to_owned());
} else if let Some(u) = line.strip_prefix("url:")
&& u.trim().trim_end_matches('/') == host
{
return name;
}
}
None
}
fn tea(args: &[&str]) -> Result<String, String> {
let bin = crate::chat::path::which("tea").ok_or("no issue token in Settings and no `tea` CLI: add a token in Settings, Updates, Issues")?;
let out = Command::new(bin).args(args).env("PATH", crate::chat::path::resolved_path()).output().map_err(|e| e.to_string())?;
if !out.status.success() {
let err = String::from_utf8_lossy(&out.stderr);
return Err(format!("tea: {}", err.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("failed")));
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
fn tea_list(host: &str, owner: &str, name: &str) -> Result<Vec<(u64, String, String)>, String> {
let login = tea_login(host).ok_or_else(|| format!("tea has no login for {host}: run `tea login add`, or add a token in Settings"))?;
let repo = format!("{owner}/{name}");
let out = tea(&["issues", "ls", "--login", &login, "--repo", &repo, "--state", "all", "--limit", "200", "--output", "json", "--fields", "index,title,url"])?;
let v: Value = serde_json::from_str(&out).map_err(|e| format!("tea output: {e}"))?;
Ok(v.as_array().into_iter().flatten().filter_map(|i| Some((i["index"].as_str()?.parse().ok()?, i["url"].as_str()?.to_owned(), i["title"].as_str()?.to_owned()))).collect())
}
fn tea_create(host: &str, owner: &str, name: &str, title: &str, body: &str, kind: &str) -> Result<(u64, String), String> {
let login = tea_login(host).ok_or_else(|| format!("tea has no login for {host}"))?;
let repo = format!("{owner}/{name}");
// A label only when the repository has it; the [kind] prefix carries it otherwise.
let labels = tea(&["labels", "ls", "--login", &login, "--repo", &repo, "--output", "json"]).ok().filter(|l| l.contains(&format!("\"{kind}\"")));
let mut args = vec!["issues", "create", "--login", &login, "--repo", &repo, "--title", title, "--description", body];
if labels.is_some() {
args.extend(["--labels", kind]);
}
tea(&args)?;
// tea prints a table, not JSON: read the new issue back by its title.
tea_list(host, owner, name)?
.into_iter()
.find(|(_, _, t)| t == title)
.map(|(n, u, _)| (n, u))
.ok_or_else(|| "tea created the issue but it was not found afterwards".into())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn repository_urls_and_duplicates() {
assert_eq!(repo_parts("https://git.marcospaulo.dev.br/masi/papercraft.git"), Some(("https://git.marcospaulo.dev.br".into(), "masi".into(), "papercraft".into())));
assert_eq!(repo_parts("not a url"), None);
assert!(same_issue("[bug] write_html: border-left becomes a full border", "[docs] Write_HTML: border-left becomes a full border on frames"));
assert!(!same_issue("[bug] short", "[bug] short"), "too short to call the same");
assert!(!same_issue("[bug] rename field cannot be left", "[feature] copy layers between pages"));
}
}
+3
View File
@@ -13,6 +13,8 @@ pub mod editor;
pub mod geom;
pub mod history;
pub mod icons;
#[cfg(not(target_arch = "wasm32"))]
pub mod issues;
pub mod ink;
pub mod io;
pub mod layout;
@@ -33,6 +35,7 @@ pub mod sync;
pub mod text;
pub mod theme;
pub mod ui;
pub mod ui_kits;
#[cfg(not(target_arch = "wasm32"))]
pub mod update;
pub mod variables;
+204 -4
View File
@@ -397,9 +397,68 @@ fn instance_comp_attr(doc: &Document, n: &Node) -> String {
.unwrap_or_default()
}
/// The code link of a node: its own (a main component) or its main component's (an instance).
pub(crate) fn code_link<'a>(doc: &'a Document, n: &'a Node) -> Option<&'a crate::model::CodeLink> {
n.code.as_ref().or_else(|| n.instance.as_ref().and_then(|l| doc.get(l.main)).and_then(|m| m.code.as_ref()))
}
/// A code link template with `{Name}` replaced by the text of the layer named `Name` under `id`
/// (the instance's own text, overrides included). An unknown name stays as written.
fn fill_template(doc: &Document, id: NodeId, template: &str, escape: fn(&str) -> String) -> String {
let mut out = String::new();
let mut rest = template;
while let Some(i) = rest.find('{') {
let Some(j) = rest[i..].find('}').map(|j| i + j) else { break };
let name = &rest[i + 1..j];
let text = (!name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || " _-".contains(c)))
.then(|| doc.subtree(id).into_iter().filter_map(|d| doc.get(d)).find(|d| d.name == name).and_then(|d| d.kind.text().map(|t| t.to_string())))
.flatten();
out.push_str(&rest[..i]);
match text {
Some(t) => out.push_str(&escape(&t)),
None => out.push_str(&rest[i..=j]),
}
rest = &rest[j + 1..];
}
out.push_str(rest);
out
}
/// A linked component's JSX, filled in from the instance (None when it has no React link).
pub(crate) fn linked_react(doc: &Document, id: NodeId) -> Option<String> {
let n = doc.get(id)?;
code_link(doc, n).filter(|l| !l.react.is_empty()).map(|l| fill_template(doc, id, &l.react, jsx_text))
}
/// The distinct import lines of the linked components under `root`, in order of first use.
pub(crate) fn code_imports(doc: &Document, root: NodeId) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for id in doc.subtree(root) {
if let Some(link) = doc.get(id).and_then(|n| code_link(doc, n))
&& !link.import.is_empty()
&& !out.contains(&link.import)
{
out.push(link.import.clone());
}
}
out
}
/// Each line of a (possibly multi-line) snippet, indented.
pub(crate) fn push_indented(out: &mut String, ind: &str, snippet: &str) {
for line in snippet.lines() {
out.push_str(&format!("{ind}{line}\n"));
}
}
fn tailwind_node(doc: &Document, id: NodeId, depth: usize, is_root: bool, out: &mut String) {
let Some(n) = doc.get(id) else { return };
let ind = " ".repeat(depth);
// A linked component is written as its library usage, not rebuilt from its layers.
if let Some(link) = code_link(doc, n).filter(|l| !l.react.is_empty()) {
push_indented(out, &ind, &fill_template(doc, id, &link.react, jsx_text));
return;
}
let cls = tw_classes(doc, n, is_root);
let name = n.name.replace('"', "'");
let comp_attr = instance_comp_attr(doc, n);
@@ -467,6 +526,12 @@ fn tailwind_node(doc: &Document, id: NodeId, depth: usize, is_root: bool, out: &
/// Serialize `root` and its descendants into clean React JSX with Tailwind CSS utility classes.
pub fn to_tailwind_jsx(doc: &Document, root: NodeId) -> String {
let mut out = String::new();
for import in code_imports(doc, root) {
out.push_str(&format!("{import}\n"));
}
if !out.is_empty() {
out.push('\n');
}
tailwind_node(doc, root, 0, true, &mut out);
out
}
@@ -835,10 +900,14 @@ pub fn to_react_tsx(doc: &Document, root: NodeId) -> String {
.get(root)
.map(|n| pascal_ident(&n.name))
.unwrap_or_else(|| "Component".into());
let jsx = to_tailwind_jsx(doc, root);
for import in code_imports(doc, root) {
out.push_str(&format!("{import}\n"));
}
let mut jsx = String::new();
tailwind_node(doc, root, 0, true, &mut jsx);
let body: String = jsx.lines().map(|line| format!(" {line}\n")).collect();
out.push_str(&format!(
"export function {comp_name}() {{\n return (\n{body} );\n}}\n"
"\nexport function {comp_name}() {{\n return (\n{body} );\n}}\n"
));
out
}
@@ -1007,6 +1076,11 @@ fn egui_node(doc: &Document, id: NodeId, depth: usize, is_root: bool, out: &mut
return;
}
let ind = " ".repeat(depth);
// A linked component is the widget call it stands for.
if let Some(link) = code_link(doc, n).filter(|l| !l.egui.is_empty()) {
push_indented(out, &ind, &fill_template(doc, id, &link.egui, |t| format!("{t:?}")));
return;
}
if let Some((set, icon)) = icon_of(doc, n) {
let (hex, var) = icon_tint(doc, n);
let tint = hex
@@ -1308,6 +1382,17 @@ fn build_spec_tree(
if !is_root && in_flow {
*flow_count += 1;
}
// A linked component is one library call: no rules for the layers that draw it (the
// "MUST use the <kit> component" rule covers it).
if !is_root && let Some(link) = code_link(doc, n) {
return Some(json!({
"id": n.id.0, "name": n.name, "kind": "library",
"library": {"kit": link.kit, "import": link.import, "react": link.react, "egui": link.egui,
"usage": fill_template(doc, id, &link.react, jsx_text), "eguiUsage": fill_template(doc, id, &link.egui, |t| format!("{t:?}"))},
"bounds": {"width": n.rect.w, "height": n.rect.h},
"sizing": {"horizontal": "fixed", "vertical": "fixed", "inFlow": in_flow},
}));
}
let mut instance_of: Option<Value> = None;
if let Some(link) = &n.instance
@@ -1640,6 +1725,19 @@ pub(crate) fn to_ai_spec_with_categories(
"MUST NOT use `position: absolute` (or Tailwind `absolute`) on in-flow children ({flow_count} flow nodes under root)"
),
);
let library = library_components(doc, root);
for l in &library {
let usage = if matches!(target.trim().to_ascii_lowercase().as_str(), "egui" | "rust") { &l["egui"] } else { &l["react"] };
if let Some(u) = usage.as_str().filter(|u| !u.is_empty()) {
rules.push(format!(
"MUST use the {} component for `{}` ({} uses: `{}`), not rebuild it from its layers",
l["kit"].as_str().unwrap_or(""),
l["name"].as_str().unwrap_or(""),
l["usedBy"].as_array().map_or(0, Vec::len),
u.lines().next().unwrap_or(u)
));
}
}
let colors: Map<String, Value> = doc
.variables
@@ -1689,6 +1787,7 @@ pub(crate) fn to_ai_spec_with_categories(
},
"components": components,
"componentDefinitions": definitions,
"libraryComponents": library,
"layoutTree": layout_tree,
"enforcementRules": rules,
"annotations": annotation_comments(doc, root, categories).lines().collect::<Vec<_>>(),
@@ -1696,6 +1795,27 @@ pub(crate) fn to_ai_spec_with_categories(
}))
}
/// The linked components used under `root`: one entry per main component, with the layers
/// that use it. Nested layers of a linked component are not looked into.
fn library_components(doc: &Document, root: NodeId) -> Vec<Value> {
let mut out: Vec<Value> = Vec::new();
let mut stack = vec![root];
while let Some(id) = stack.pop() {
let Some(n) = doc.get(id) else { continue };
let Some(link) = code_link(doc, n) else {
stack.extend(n.children.iter().rev());
continue;
};
let main = n.instance.as_ref().map_or(id, |l| l.main);
let name = doc.get(main).map_or_else(|| n.name.clone(), |m| m.name.clone());
match out.iter_mut().find(|e| e["mainId"] == json!(main.0)) {
Some(e) => e["usedBy"].as_array_mut().unwrap().push(json!(id.0)),
None => out.push(json!({"name": name, "mainId": main.0, "kit": link.kit, "import": link.import, "react": link.react, "egui": link.egui, "usedBy": [id.0]})),
}
}
out
}
fn format_hierarchy_md(node: &Value, depth: usize, out: &mut String) {
let ind = " ".repeat(depth);
let id = node["id"].as_u64().unwrap_or(0);
@@ -1744,6 +1864,10 @@ fn format_hierarchy_md(node: &Value, depth: usize, out: &mut String) {
details.push(format!("line-height={}px", px_num(lh)));
}
}
if let Some(lib) = node.get("library") {
let usage = lib["usage"].as_str().filter(|u| !u.is_empty()).or(lib["eguiUsage"].as_str()).unwrap_or("");
details.push(format!("use `{}`", usage.lines().next().unwrap_or(usage)));
}
if let Some(ic) = node.get("icon") {
details.push(format!(
"icon={}:{} tint={}",
@@ -1768,6 +1892,10 @@ fn format_hierarchy_md(node: &Value, depth: usize, out: &mut String) {
}
}
fn lang_is_egui(target: &str) -> bool {
matches!(target.trim().to_ascii_lowercase().as_str(), "egui" | "rust")
}
/// Format the AI Layout & Enforcement Spec as a structured Markdown document.
pub fn to_ai_spec_markdown(doc: &Document, root: NodeId, target: &str) -> Result<String, String> {
to_ai_spec_markdown_with_categories(doc, root, target, &crate::model::annotation::default_categories())
@@ -1808,7 +1936,27 @@ pub(crate) fn to_ai_spec_markdown_with_categories(
md.push('\n');
}
md.push_str("## 3. Structured Layout Hierarchy\n\n");
// Sections are numbered as they appear: the library one only shows when there is one.
let mut section = 2;
let mut next = || {
section += 1;
section
};
if let Some(lib) = spec["libraryComponents"].as_array().filter(|l| !l.is_empty()) {
let egui = lang_is_egui(target);
md.push_str(&format!("## {}. Library Components\n\nUse these components; the layers under them are only how they look.\n\n", next()));
for l in lib {
let usage = if egui { l["egui"].as_str() } else { l["react"].as_str() }.unwrap_or("");
md.push_str(&format!("- **`{}`** ({}), used {}×", l["name"].as_str().unwrap_or(""), l["kit"].as_str().unwrap_or(""), l["usedBy"].as_array().map_or(0, Vec::len)));
if !egui && let Some(i) = l["import"].as_str().filter(|i| !i.is_empty()) {
md.push_str(&format!(": `{i}`"));
}
md.push_str(&format!("\n ```{}\n {}\n ```\n", if egui { "rust" } else { "tsx" }, usage.replace('\n', "\n ")));
}
md.push('\n');
}
md.push_str(&format!("## {}. Structured Layout Hierarchy\n\n", next()));
format_hierarchy_md(&spec["layoutTree"], 0, &mut md);
md.push('\n');
@@ -1821,7 +1969,8 @@ pub(crate) fn to_ai_spec_markdown_with_categories(
};
let ref_code = spec["referenceCode"].as_str().unwrap_or("");
md.push_str(&format!(
"## 4. Reference Implementation ({target})\n\n```{lang_tag}\n{}\n```\n",
"## {}. Reference Implementation ({target})\n\n```{lang_tag}\n{}\n```\n",
next(),
ref_code.trim_end()
));
@@ -2046,4 +2195,55 @@ mod tests {
"{md}"
);
}
#[test]
fn linked_components_export_as_library_code() {
let mut doc = Document::default();
let frame = |doc: &mut Document, name: &str| {
let id = doc.alloc_id();
let mut n = Node::new(id, NodeKind::Frame { clip: false }, Rect::new(0.0, 0.0, 90.0, 36.0));
n.name = name.into();
doc.nodes.insert(id, n);
id
};
let label = |doc: &mut Document, parent: NodeId, text: &str| {
let id = doc.alloc_id();
let mut t = Node::new(id, NodeKind::Text { text: text.into(), font_size: 14.0, auto_width: true, align: TextAlign::Left }, Rect::new(0.0, 0.0, 50.0, 20.0));
t.name = "Label".into();
t.parent = Some(parent);
doc.nodes.insert(id, t);
doc.nodes.get_mut(&parent).unwrap().children.push(id);
};
let main = frame(&mut doc, "Button / Outline");
label(&mut doc, main, "Button");
{
let m = doc.nodes.get_mut(&main).unwrap();
m.component = true;
m.code = Some(crate::model::CodeLink {
kit: "shadcn".into(),
import: r#"import { Button } from "@/components/ui/button""#.into(),
react: r#"<Button variant="outline">{Label}</Button>"#.into(),
egui: "ui.button({Label});".into(),
});
}
let screen = frame(&mut doc, "Screen");
doc.root.push(screen);
for text in ["Save", "Cancel"] {
let inst = frame(&mut doc, "Button / Outline");
label(&mut doc, inst, text);
let n = doc.nodes.get_mut(&inst).unwrap();
n.instance = Some(InstanceLink { main, overrides: Vec::new(), source: None });
n.parent = Some(screen);
doc.nodes.get_mut(&screen).unwrap().children.push(inst);
}
let tsx = to_react_tsx(&doc, screen);
assert_eq!(tsx.matches("import { Button }").count(), 1, "{tsx}");
assert!(tsx.contains(r#"<Button variant="outline">Save</Button>"#) && tsx.contains(r#"<Button variant="outline">Cancel</Button>"#), "{tsx}");
assert!(!tsx.contains("data-name=\"Label\""), "the layers under a linked component are not rebuilt: {tsx}");
let rs = to_egui(&doc, screen);
assert!(rs.contains(r#"ui.button("Save");"#), "{rs}");
let spec = to_ai_spec(&doc, screen, "react").unwrap();
assert_eq!(spec["libraryComponents"][0]["usedBy"].as_array().unwrap().len(), 2);
assert!(spec["enforcementRules"].as_array().unwrap().iter().any(|r| r.as_str().unwrap().contains("MUST use the shadcn component")));
}
}
+109 -1
View File
@@ -306,6 +306,9 @@ pub fn call(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult {
"get_fill_image" => fill_image(ed, &a),
"zoom_image" => zoom_image(host, &a),
"ask_user" => ask_user(&a),
"report_issue" => report_issue(&a),
#[cfg(not(target_arch = "wasm32"))]
"design_rules" => design_rules(&a),
#[cfg(not(target_arch = "wasm32"))]
"fetch_reference_images" => super::refs::fetch(host.project().map(|p| p.dir.clone()).as_deref(), None, &Value::Object(a.0.clone())),
#[cfg(not(target_arch = "wasm32"))]
@@ -343,6 +346,8 @@ pub fn call(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult {
"diff_frames" => diff_frames(ed, &a),
"search_icons" => search_icons(&a),
"rename_nodes" => rename_nodes(ed, &a),
"set_code_link" => set_code_link(ed, &a),
"apply_ui_kit" => apply_ui_kit(host, &a),
"delete_nodes" => delete_nodes(ed, &a),
"duplicate_nodes" => duplicate_nodes(ed, &a),
"move_nodes" => move_nodes(ed, &a),
@@ -1245,9 +1250,38 @@ fn ask_user(a: &Args) -> ToolResult {
text(json!({"shown": true, "next": "End your turn now. The answers arrive as the user's next message."}))
}
const ASK_MAX_QUESTIONS: usize = 5;
const ASK_MAX_QUESTIONS: usize = 7;
/// An issue draft: the chat shows it with Send and Discard; nothing is filed from here.
fn report_issue(a: &Args) -> ToolResult {
let kind = a.req_str("kind")?;
if !["bug", "docs", "feature"].contains(&kind) {
return Err("kind must be bug, docs or feature".into());
}
if a.req_str("title")?.is_empty() || a.req_str("body")?.is_empty() {
return Err("an issue needs a title and a body".into());
}
text(json!({"draft": true, "next": "The user sees the draft and sends or discards it. Do not repeat it in text; go on with the task."}))
}
const ASK_MAX_OPTIONS: usize = 6;
/// design-playbook rules through its CLI: `playbook search` or `playbook rule`.
#[cfg(not(target_arch = "wasm32"))]
fn design_rules(a: &Args) -> ToolResult {
let bin = crate::chat::path::which("playbook").ok_or("the design-playbook CLI (`playbook`) is not installed")?;
let args = match (a.str("rule"), a.str("query")) {
(Some(r), _) if !r.trim().is_empty() => vec!["rule", r.trim()],
(_, Some(q)) if !q.trim().is_empty() => vec!["search", "-n", "5", q.trim()],
_ => return Err("give query or rule".into()),
};
let out = std::process::Command::new(bin).args(&args).output().map_err(|e| e.to_string())?;
let found = String::from_utf8_lossy(&out.stdout).trim().to_owned();
if found.is_empty() {
return Err(format!("no playbook match: {}", String::from_utf8_lossy(&out.stderr).trim()));
}
text(json!({"rules": found}))
}
/// A region of a chat attachment at full resolution: the `.full` original kept next to the sent
/// image when it was scaled down, else the image itself. Only files in the project's chat folder.
fn zoom_image(host: &mut dyn Host, a: &Args) -> ToolResult {
@@ -1907,6 +1941,9 @@ fn jsx(host: &mut dyn Host, a: &Args) -> ToolResult {
match a.str("format") {
None | Some("") | Some("inline") | Some("inline-styles") => {
let mut out = super::codegen::annotation_comments(doc, id, &ed.design.categories());
for import in super::codegen::code_imports(doc, id) {
out.push_str(&format!("{import}\n"));
}
jsx_node(doc, id, 0, true, assets_dir.as_deref(), &mut out);
Ok(vec![Out::Text(out)])
}
@@ -1978,6 +2015,11 @@ fn jsx_node(
) {
let n = doc.get(id).unwrap();
let ind = " ".repeat(depth);
// A linked component is written as its library usage.
if let Some(jsx) = super::codegen::linked_react(doc, id) {
super::codegen::push_indented(out, &ind, &jsx);
return;
}
let mut st = css::computed(n, parent_layout(doc, n).as_ref(), n.parent.is_none());
if root {
for k in ["position", "left", "top"] {
@@ -5295,6 +5337,72 @@ fn search_icons(a: &Args) -> ToolResult {
text(v)
}
/// Link a main component (or the main of an instance) to its code in a UI library.
fn set_code_link(ed: &mut Editor, a: &Args) -> ToolResult {
let (page, id) = resolve(ed, a.req_str("nodeId")?, page_arg(ed, a)?)?;
let doc = ed.page_doc(page);
let n = doc.get(id).ok_or("no such node")?;
let main = match (&n.instance, n.component) {
(_, true) => id,
(Some(l), false) if doc.get(l.main).is_some() => l.main,
(Some(_), false) => return Err("the main component is on another page: link it there".into()),
(None, false) => return Err("set_code_link needs a main component or an instance of one (create_component first)".into()),
};
let s = |k: &str| a.str(k).unwrap_or("").to_owned();
let link = crate::model::CodeLink { kit: s("kit"), import: s("import"), react: s("react"), egui: s("egui") };
let clear = link.import.is_empty() && link.react.is_empty() && link.egui.is_empty();
let name = doc.get(main).map(|m| m.name.clone()).unwrap_or_default();
ed.with_page(page, |ed| {
ed.edit("Set code link", |tx| {
tx.node_mut(main).unwrap().code = (!clear).then_some(link);
})
});
text(json!({"component": node_key(ed, page, main), "name": name, "linked": !clear}))
}
/// The JSON a tool call answers with (its first text block).
fn call_json(host: &mut dyn Host, name: &str, args: Value) -> Result<Value, String> {
let outs = call(host, name, &args)?;
let t = outs.iter().find_map(|o| if let Out::Text(t) = o { Some(t.as_str()) } else { None }).ok_or_else(|| format!("{name} answered no text"))?;
serde_json::from_str(t).map_err(|e| format!("{name}: {e}"))
}
/// Build a "UI Kit / <name>" page from a kit (`crate::ui_kits`): its variables (Light and Dark),
/// tokens, and its components, each a main component linked to the library's code.
fn apply_ui_kit(host: &mut dyn Host, a: &Args) -> ToolResult {
let id = a.req_str("kit")?;
let Some(kit) = crate::ui_kits::kit(id) else {
let all: Vec<&str> = crate::ui_kits::KITS.iter().map(|k| k.id).collect();
return Err(format!("no kit `{id}` (kits: {})", all.join(", ")));
};
let map = |pairs: &[(&str, &str)]| pairs.iter().map(|(k, v)| (k.to_string(), json!(v))).collect::<Map<String, Value>>();
let page = call_json(host, "create_page", json!({"name": format!("UI Kit / {}", kit.name)}))?;
let pid = page["pageId"].as_str().ok_or("create_page gave no page")?.to_owned();
call_json(host, "set_variables", json!({"pageId": pid, "variables": map(kit.light), "mode": "Light"}))?;
call_json(host, "set_variables", json!({"pageId": pid, "variables": map(kit.dark), "mode": "Dark"}))?;
call_json(host, "set_tokens", json!({"pageId": pid, "tokens": map(kit.tokens)}))?;
let body: String = kit.components.iter().map(|c| c.html).collect();
let html = format!(
r#"<div layer-name="UI Kit / {}" style="display:flex;flex-wrap:wrap;gap:32px;align-items:flex-start;padding:48px;width:1200px;background:var(--{})">{body}</div>"#,
kit.name, kit.surface
);
let written = call_json(host, "write_html", json!({"pageId": pid, "targetNodeId": "root", "html": html}))?;
let board = written["createdNodeIds"][0].as_str().ok_or("write_html made no frame")?.to_owned();
let kids = call_json(host, "get_children", json!({"pageId": pid, "nodeId": board}))?;
let mut made = Vec::new();
for c in kit.components {
let Some(node) = kids["children"].as_array().into_iter().flatten().find(|k| k["name"] == c.name).and_then(|k| k["id"].as_str()) else { continue };
let comp = call_json(host, "create_component", json!({"pageId": pid, "nodeId": node}))?;
let cid = comp["id"].as_str().unwrap_or(node).to_owned();
call_json(host, "set_code_link", json!({"pageId": pid, "nodeId": cid, "kit": kit.id, "import": c.import, "react": c.react, "egui": c.egui}))?;
made.push(json!({"name": c.name, "id": cid}));
}
text(json!({
"pageId": pid, "frame": board, "kit": kit.id, "components": made,
"next": "Place these with <x-paper-instance component=\"Name\" overrides='{\"Label\":\"Text\"}'/>; the code export and the AI spec use the library components for them. Use the kit's variables (var(--primary) and so on) for everything else."
}))
}
fn rename_nodes(ed: &mut Editor, a: &Args) -> ToolResult {
let n = batch(
ed,
+58 -4
View File
@@ -58,7 +58,7 @@ fn tool(name: &str, desc: &str, props: Value, required: &[&str], read_only: bool
&& !m.contains_key("fileId")
&& !matches!(
name,
"list_resources" | "create_file" | "get_font_family_info" | "get_guide" | "open_file" | "zoom_image" | "ask_user" | "fetch_reference_images" | "generate_image"
"list_resources" | "create_file" | "get_font_family_info" | "get_guide" | "open_file" | "zoom_image" | "ask_user" | "fetch_reference_images" | "generate_image" | "report_issue" | "design_rules"
)
{
m.insert("fileId".into(), file_id());
@@ -307,7 +307,7 @@ pub fn definitions() -> Vec<Value> {
"ask_user",
"Ask the user a few questions as a card with clickable answers (in-app chat). Use it \
for the brief before a new design (platform and frame size, density, palette, type, \
references, scope) and for next steps after one. One to five questions, two to six \
references, scope) and for next steps after one. One to seven questions, two to six \
options each, the option you recommend first. A palette option lists its hex colors \
in `colors` (shown as swatches); a reference option gives its saved `image` path. The user can also type their own answer. After \
calling it, end your turn: the answers come as the user's next message.",
@@ -324,6 +324,60 @@ pub fn definitions() -> Vec<Value> {
&["questions"],
true,
),
tool(
"report_issue",
"Draft an issue about PaperCraft itself (a tool that misbehaved, docs that disagree \
with the app, a capability you had to work around). The user sees the draft and \
sends it to PaperCraft's repository or discards it. Use a minimal synthetic repro: \
never the user's own content, names or files.",
json!({
"kind": {"type": "string", "enum": ["bug", "docs", "feature"]},
"title": {"type": "string", "description": "Short, specific: the tool and what goes wrong."},
"body": {"type": "string", "description": "**Expected:** … **Actual:** … **Repro:** the smallest tool call that shows it. **Workaround:** … **Impact:** …"},
}),
&["kind", "title", "body"],
true,
),
tool(
"design_rules",
"Look up design-playbook rules (interaction, accessibility, motion, forms, tokens, \
component patterns, handoff annotations; each with a stable id like A11Y-09) through \
the `playbook` CLI. Use only when the user agreed to follow the playbook. Give \
`query` for ranked matches or `rule` for an id or prefix (\"ANN\"). Cite the ids \
you applied in your report.",
json!({
"query": {"type": "string", "description": "Words to search, e.g. \"confirm vs undo delete\"."},
"rule": {"type": "string", "description": "A rule id (\"INT-06\") or prefix (\"MOT\")."},
}),
&[],
true,
),
tool(
"apply_ui_kit",
"Start a design in a UI library's look and code: builds a \"UI Kit / <name>\" page \
with the kit's variables (Light and Dark modes), tokens and components, each a main \
component linked to the library (so the code export and the AI spec write \
<Button variant=\"outline\"> instead of rebuilding divs). Kits: shadcn (shadcn/ui), \
antd (Ant Design), reactbits (React Bits animated pieces), egui (native Rust widgets).",
json!({"kit": {"type": "string", "enum": ["shadcn", "antd", "reactbits", "egui"]}}),
&["kit"],
false,
),
tool(
"set_code_link",
"Link a main component (or an instance's main) to its code in a library, used by the \
code export and the AI spec. In templates {Name} is the text of the instance's layer \
named Name. Empty import, react and egui remove the link.",
json!({
"nodeId": node_id("A main component or one of its instances."),
"kit": {"type": "string", "description": "Library, like shadcn, antd, mui, chakra, reactbits, egui."},
"import": {"type": "string", "description": "React import line."},
"react": {"type": "string", "description": "JSX, like <Button variant=\"outline\">{Label}</Button>."},
"egui": {"type": "string", "description": "Rust on `ui`, like ui.button({Label});"},
}),
&["nodeId"],
false,
),
tool(
"generate_image",
"Make images with the Codex CLI's image generation (the user's Codex login): art the \
@@ -1018,7 +1072,7 @@ fn classify(name: &str, args: &Value) -> Option<bool> {
Some(match name {
"list_resources" | "get_basic_info" | "get_selection" | "get_node_info" | "get_children"
| "get_tree_summary" | "get_screenshot" | "get_jsx" | "get_design_spec" | "verify_code"
| "get_computed_styles" | "get_fill_image" | "zoom_image" | "ask_user" | "fetch_reference_images" | "generate_image" | "find_nodes" | "get_font_family_info"
| "get_computed_styles" | "get_fill_image" | "zoom_image" | "ask_user" | "fetch_reference_images" | "generate_image" | "report_issue" | "design_rules" | "find_nodes" | "get_font_family_info"
| "get_annotations" | "get_annotation_template" | "get_guide" | "get_feedback" | "get_variables" | "get_components" | "search_icons"
| "diff_frames" | "publish_library" | "list_libraries" => false,
"select_nodes" | "set_viewport" | "export" | "export_combined_pdf" | "export_design_system" | "open_file"
@@ -1030,7 +1084,7 @@ fn classify(name: &str, args: &Value) -> Option<bool> {
| "create_collection" | "delete_variables" | "bind_variable"
| "enable_library" | "update_library" | "disable_library"
| "set_variable_mode" | "import_design_system" | "import_components" | "create_component"
| "create_instance" | "swap_instance" | "detach_instance" | "delete_nodes"
| "create_instance" | "swap_instance" | "detach_instance" | "delete_nodes" | "apply_ui_kit" | "set_code_link"
| "duplicate_nodes" | "move_nodes" | "undo" | "redo" => true,
_ => return None,
})
+1 -1
View File
@@ -7,7 +7,7 @@ pub mod rough;
pub use document::{Document, VarState};
pub use node::{
Binding, Color, Comment, FillRule, Head, Node, NodeId, NodeKind, PathPoint, Route, Shadow,
Binding, CodeLink, Color, Comment, FillRule, Head, Node, NodeId, NodeKind, PathPoint, Route, Shadow,
StrokePoint, Style, SubPath, TextAlign,
};
pub use paint::*;
+22
View File
@@ -429,6 +429,27 @@ pub struct Node {
/// Stable key of a main component (set on `create_component`), used by instances on other pages.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub component_key: Option<String>,
/// A main component's code in a UI library, used by the code export and the AI spec
/// (`set_code_link`, `apply_ui_kit`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub code: Option<CodeLink>,
}
/// What a component is in code: its library, the import, and a usage template per target.
/// `{Name}` in a template is the text of the instance's layer named `Name`.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct CodeLink {
/// The kit or library, like `shadcn`, `antd`, `reactbits`, `egui`.
pub kit: String,
/// The React import line, like `import { Button } from "@/components/ui/button"`.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub import: String,
/// JSX for React and Tailwind exports.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub react: String,
/// Rust for the egui export (statements on `ui`).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub egui: String,
}
/// A note attached to a layer: what to change, from the user to an agent or back.
@@ -533,6 +554,7 @@ impl Node {
link: None,
bindings: BTreeMap::new(),
component_key: None,
code: None,
}
}
+145 -2
View File
@@ -52,13 +52,17 @@ const RESEARCH_LINE: &str = "[Research] Web research is on";
/// How the in-app chat differs from an agent in a terminal: the user is right here, and every
/// token is theirs (specs/chat-tokens).
#[cfg(not(target_arch = "wasm32"))]
/// Added when the design-playbook CLI is installed: the agent reaches it through `design_rules`.
const PLAYBOOK_RULE: &str = "\n\nDesign playbook: the design-playbook rules are available through design_rules. Add a seventh question to the brief: follow the playbook (recommended) or not. If yes, before building look up the rules for what you design (for example the screen's patterns, forms, states, accessibility, motion) with one or two design_rules calls, follow them, and cite the rule ids you applied in the finish report. Thresholds (contrast, target sizes, durations) come from the playbook, not memory.";
const CHAT_RULES: &str = "In-app chat: the user is watching and can answer at once.
Brief. Before the first design in a project or on an empty page, ask first, even when the request looks specific, unless the user said to go ahead:
* Call ask_user once, up to five questions, your recommended option first: platform and frame size (desktop 1440x900, laptop 1280x800, 1440p 2560x1440, tablet 768x1024, phone 390x844); density (compact, 32px controls for a desktop pointer UI; comfortable, 40px; touch, 44px and up); palette (two or three palettes that suit the request, with their hex colors, plus \"my brand colors\"); type (a pairing that suits it, or Inter only); scope (one screen to judge the look, or a flow).
* Call ask_user once, up to six questions, your recommended option first: platform and frame size (desktop 1440x900, laptop 1280x800, 1440p 2560x1440, tablet 768x1024, phone 390x844); density (compact, 32px controls for a desktop pointer UI; comfortable, 40px; touch, 44px and up); palette (two or three palettes that suit the request, with their hex colors, plus \"my brand colors\"); type (a pairing that suits it, or Inter only); scope (one screen to judge the look, or a flow); code library (shadcn/ui, Ant Design, React Bits on top of either, egui for a Rust app, or none).
* In the same message invite references: they can paste or drop screenshots and images in the chat. Then end your turn. Never restate a card in text: one short sentence at most next to it.
Build.
* A code library chosen: call apply_ui_kit with it first and build screens from its components (<x-paper-instance>), restyling its variables to the agreed palette, so the code handoff writes the library's components. A component of your own that maps to a library one: set_code_link it.
* Variables first: colors (light and dark modes) with set_variables, spacing, radius and type sizes with set_tokens, then write every style with var(--name), no literal colors or off-scale sizes. For more than one screen put them and the components on a Design System page first.
* Anything that repeats (a card, a row, a button, a slot) is a component, built once and placed as instances, with variants for its states. Once a component exists, place it inside write_html with <x-paper-instance component=\"Name\" overrides='{\"Label\":\"Text\"}'/> instead of writing its markup again: rewritten markup is most of what a turn costs.
* Sizes follow the agreed density. WCAG 2.2 AA asks 24px targets for a pointer, so compact desktop controls are fine; touch screens need 44px.
@@ -68,6 +72,7 @@ Finish. Report in at most 80 words, flat bullets: what you built, what is a comp
Research. When a message carries the [Research] line, web search is on. If a researcher agent is available, hand all reference research to it (one Agent call with what the design is and the brief): it searches and fetches on a cheaper model and returns only the kept images; then skip to asking which to follow. Otherwise, after the brief is answered (or when asked), find three to six references that show an interface, not marketing. Sources that work: nicelydone.club/apps/<app> (real screenshots of 500+ SaaS web apps: linear, notion, slack, jira...), banani.co/references/apps and screenlane.com (mobile screens), App Store and Google Play listings (app screenshots), godly.website and awwwards.com/websites (marketing sites). Dribbble, Behance, Figma Community, Mobbin, Land-book and Lapa block automated fetching; a homepage's image is marketing art. Bring them in with one fetch_reference_images call, then look at what came back: keep only images that show an interface like the one you are designing (a screen, its layout or its components). A social card, a logo, an app icon or an illustration is not a reference. If fewer than two qualify, search again once with better sources (the product name plus \"screenshot\", its App Store listing, a help-center article) before building. Say in one line each what you take from a kept one (layout, density, color, type), then ask_user which to follow with only the kept ones as options, each with its `image` path, even when the user took your defaults. Take ideas, never a brand's logo or artwork.
* When PaperCraft itself gets in the way (a tool misbehaves, its docs disagree with what it does, or you had to work around a missing capability), call report_issue once per problem with a minimal synthetic repro, never the user's content, then go on. The user decides whether it is sent.
* Less common tools (annotations, pages, files, export, libraries, design-system and component edits) may not be loaded yet: find them with tool search.
* Attached images are scaled down to send. To read small text or detail in one, call zoom_image with its attached path and a region instead of guessing.";
const SELF_FOLLOW_QUIET: Duration = Duration::from_secs(10);
@@ -256,6 +261,28 @@ pub struct SelChip {
pub image: Option<Vec<u8>>,
}
/// An issue for PaperCraft: kind (`bug`, `docs`, `feature`), title and body.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct IssueDraft {
pub kind: String,
pub title: String,
pub body: String,
}
/// Where an issue draft stands.
#[derive(Debug, Clone, PartialEq)]
pub enum IssueState {
Sending,
/// Filed as #number; `existing` when an issue with a matching title was there already.
Filed { number: u64, url: String, existing: bool },
Failed(String),
Discarded,
}
/// The key of the form's issue in `ChatState::issues`.
pub const FORM_ISSUE: &str = "form";
const ISSUE_TEMPLATE: &str = "**Expected:** \n**Actual:** \n**Repro:** \n**Workaround:** \n**Impact:** ";
/// An image waiting in the composer.
#[derive(Debug, Clone, PartialEq)]
pub struct Attachment {
@@ -317,6 +344,12 @@ pub struct ChatState {
turn_read: u64,
/// The last turn was stopped by the turn budget (`ChatPrefs::turn_budget`).
pub budget_hit: bool,
/// Issues being filed or filed, by key (a `report_issue` call's id, or `FORM_ISSUE`).
pub issues: std::collections::HashMap<String, IssueState>,
#[cfg(not(target_arch = "wasm32"))]
issue_rx: Vec<(String, std::sync::mpsc::Receiver<Result<crate::issues::Filed, String>>)>,
/// The "Report a PaperCraft issue" form, while open.
pub issue_form: Option<IssueDraft>,
pub approvals: Vec<Approval>,
/// Streamed thinking of the last turn (the "Thinking" row; not saved).
pub thinking: String,
@@ -704,11 +737,12 @@ impl ChatState {
let skill = include_str!("../../skills/papercraft/SKILL.md");
let dir = self.project.as_deref().map(|d| d.display().to_string()).unwrap_or_default();
let art = if self.prefs.image_provider() == crate::mcp::imagegen::Provider::Off { "" } else { ART_RULE };
let playbook = if crate::chat::path::which("playbook").is_some() { PLAYBOOK_RULE } else { "" };
// Plugins the user turned on, one usage line each.
let on: Vec<&str> = crate::plugins::PLUGINS.iter().filter(|p| self.prefs.plugins.iter().any(|id| id == p.id)).map(|p| p.hint).collect();
let plugins = if on.is_empty() { String::new() } else { format!("\n\nPlugins on, placed inside write_html (prefer them to drawn placeholders: avatars for people, real icons):\n- {}", on.join("\n- ")) };
format!(
"{skill}\n\n{CHAT_RULES}{art}{plugins}\n\nProject context: the open project is \"{}\" (folder {dir}). Read and edit it only through the papercraft tools.",
"{skill}\n\n{CHAT_RULES}{art}{playbook}{plugins}\n\nProject context: the open project is \"{}\" (folder {dir}). Read and edit it only through the papercraft tools.",
self.project_name
)
}
@@ -850,6 +884,39 @@ impl ChatState {
}
impl ChatState {
/// Files an issue on a worker thread; `issues[key]` follows it.
#[cfg(not(target_arch = "wasm32"))]
pub fn file_issue(&mut self, key: String, d: IssueDraft, repo: String) {
self.issues.insert(key.clone(), IssueState::Sending);
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let draft = crate::issues::Draft { kind: d.kind, title: d.title, body: d.body };
let _ = tx.send(crate::issues::file(&repo, &draft));
});
self.issue_rx.push((key, rx));
}
#[cfg(not(target_arch = "wasm32"))]
fn poll_issues(&mut self, ctx: &egui::Context) {
use crate::issues::Filed;
let issues = &mut self.issues;
self.issue_rx.retain(|(key, rx)| match rx.try_recv() {
Ok(r) => {
issues.insert(key.clone(), match r {
Ok(Filed::New { number, url }) => IssueState::Filed { number, url, existing: false },
Ok(Filed::Duplicate { number, url, .. }) => IssueState::Filed { number, url, existing: true },
Err(e) => IssueState::Failed(e),
});
false
}
Err(std::sync::mpsc::TryRecvError::Empty) => true,
Err(_) => false,
});
if !self.issue_rx.is_empty() {
ctx.request_repaint_after(REPAINT);
}
}
/// Reads the background scan's answer; starts the first scan.
pub fn poll_detect(&mut self, ctx: &egui::Context, prefs: &super::settings::ChatPrefs) {
if self.prefs != *prefs {
@@ -1202,6 +1269,63 @@ fn image_offer(ui: &mut Ui, st: &mut UiState) {
}
}
/// The "Report a PaperCraft issue" form: kind, title, description, then Send (filed on the
/// repository in Settings, Updates) and the link to what was filed.
#[cfg(not(target_arch = "wasm32"))]
fn issue_form(ctx: &egui::Context, st: &mut UiState) {
let Some(draft) = st.chat.issue_form.as_mut() else { return };
let mut close = false;
let mut send = false;
let state = st.chat.issues.get(FORM_ISSUE).cloned();
egui::Modal::new(egui::Id::new("issue-form")).show(ctx, |ui| {
ui.set_width(ISSUE_FORM_W);
ui.spacing_mut().item_spacing.y = 8.0;
ui.label(RichText::new("Report a PaperCraft issue").font(theme::semibold(theme::BODY + 3.0)));
ui.label(RichText::new(format!("Goes to {}. Leave out private content.", st.settings.chat.issue_repo)).size(theme::SECONDARY).color(theme::pal(ui).secondary));
let kinds = [("bug", "Bug"), ("docs", "Docs"), ("feature", "Feature")];
if let Some(k) = kit::segmented(ui, &kinds.map(|(_, l)| (l, None)), kinds.iter().position(|(k, _)| *k == draft.kind).unwrap_or(0)) {
draft.kind = kinds[k].0.into();
}
ui.add(egui::TextEdit::singleline(&mut draft.title).hint_text("What goes wrong, in a line").desired_width(f32::INFINITY).margin(theme::INPUT_MARGIN));
ui.add(egui::TextEdit::multiline(&mut draft.body).desired_rows(7).desired_width(f32::INFINITY).margin(theme::INPUT_MARGIN));
match &state {
Some(IssueState::Sending) => {
ui.label(RichText::new("Sending\u{2026}").color(theme::pal(ui).secondary));
}
Some(IssueState::Filed { number, url, existing }) => {
ui.hyperlink_to(if *existing { format!("Already reported as #{number}") } else { format!("Filed as #{number}") }, url);
}
Some(IssueState::Failed(e)) => {
ui.label(RichText::new(e).color(theme::pal(ui).danger));
}
_ => {}
}
ui.horizontal(|ui| {
let filed = matches!(state, Some(IssueState::Filed { .. }));
if !filed {
let ready = !draft.title.trim().is_empty() && state != Some(IssueState::Sending);
if ui.add_enabled_ui(ready, |ui| kit::button(ui, kit::ButtonKind::Primary, None, "Send").clicked()).inner {
send = true;
}
}
if kit::button(ui, kit::ButtonKind::Secondary, None, if filed { "Done" } else { "Cancel" }).clicked() {
close = true;
}
});
});
if send {
let d = draft.clone();
let repo = st.settings.chat.issue_repo.clone();
st.chat.file_issue(FORM_ISSUE.into(), d, repo);
}
if close {
st.chat.issue_form = None;
}
}
#[cfg(not(target_arch = "wasm32"))]
const ISSUE_FORM_W: f32 = 520.0;
/// What a fresh session needs from a chat that grew long: the user's recent requests and the
/// agent's last report. The canvas itself is read with the tools.
fn recap(messages: &[Message]) -> String {
@@ -1425,6 +1549,11 @@ fn column(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
let ctx = ui.ctx().clone();
let column_rect = ui.max_rect();
chat_msgs::lightbox(&ctx);
#[cfg(not(target_arch = "wasm32"))]
{
st.chat.poll_issues(&ctx);
issue_form(&ctx, st);
}
st.chat.ensure_chat();
if std::mem::take(&mut st.settings.chat_changed) {
// Paths, key or Re-scan changed in Settings: what was detected is stale.
@@ -1478,6 +1607,7 @@ fn column(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
error: st.chat.error.as_deref(),
stopped: st.chat.stopped,
budget: st.chat.budget_hit.then_some(st.settings.chat.turn_budget),
issues: &st.chat.issues,
signin: signin.as_ref(),
live_run: st.run_open.iter().find(|r| r.client.split(" \u{b7} ").next() == Some(crate::mcp::CHAT_CLIENT)),
};
@@ -1521,6 +1651,14 @@ fn apply_acts(ctx: &egui::Context, ed: &mut Editor, st: &mut UiState, acts: Acts
if let Some(id) = acts.revert {
st.pending_run_revert = Some(id);
}
#[cfg(not(target_arch = "wasm32"))]
if let Some((key, d)) = acts.send_issue {
let repo = st.settings.chat.issue_repo.clone();
st.chat.file_issue(key, d, repo);
}
if let Some(key) = acts.discard_issue {
st.chat.issues.insert(key, IssueState::Discarded);
}
if let Some(id) = acts.restore {
st.pending_restore = Some(id);
}
@@ -1634,6 +1772,11 @@ fn chat_menu(anchor: &egui::Response, st: &mut UiState) {
st.chat.start_new_chat();
ui.close();
}
if ui.add(egui::Button::new(RichText::new("Report a PaperCraft issue\u{2026}").size(theme::SECONDARY + 1.0)).frame(false)).clicked() {
st.chat.issues.remove(FORM_ISSUE);
st.chat.issue_form = Some(IssueDraft { kind: "bug".into(), title: String::new(), body: ISSUE_TEMPLATE.into() });
ui.close();
}
ui.add_space(4.0);
let current = st.chat.chat.as_ref().map(|c| c.id.clone());
let now = now_ms();
+58 -1
View File
@@ -19,6 +19,9 @@ const LIGHTBOX_MAX: u32 = 2048;
/// The tool whose calls are drawn as a question card instead of a tool card.
const ASK_TOOL: &str = "ask_user";
const REFS_TOOL: &str = "fetch_reference_images";
const ISSUE_TOOL: &str = "report_issue";
/// Lines of an issue draft's body shown on its card.
const ISSUE_PREVIEW_LINES: usize = 6;
const GEN_TOOL: &str = "generate_image";
const REF_THUMB_H: f32 = 96.0;
/// Opacity of a fetched image the agent did not keep.
@@ -62,6 +65,9 @@ pub struct Acts {
pub revert: Option<String>,
pub accept: Option<String>,
pub approve: Option<(String, bool)>,
/// File this issue draft (its key and contents).
pub send_issue: Option<(String, super::chat::IssueDraft)>,
pub discard_issue: Option<String>,
}
pub struct View<'a> {
@@ -75,6 +81,8 @@ pub struct View<'a> {
pub stopped: bool,
/// The last turn was stopped by the turn budget: the budget, in tokens read.
pub budget: Option<u64>,
/// Issue drafts being filed or filed, by `report_issue` call id.
pub issues: &'a std::collections::HashMap<String, super::chat::IssueState>,
pub signin: Option<&'a super::chat::chat_agents::SignIn>,
/// The chat's run while it is being recorded, for the live count on the step group.
pub live_run: Option<&'a Run>,
@@ -892,7 +900,8 @@ fn assistant_segment(ui: &mut Ui, md_id: Id, m: &Message, v: &View, offered: &[S
}
// A call waiting for approval is shown by its approval card instead.
let waiting = |t: &ToolCall| v.approvals.iter().any(|a| a.id == t.id);
let tools: Vec<ToolCall> = m.tools.iter().filter(|t| !is_question(t)).cloned().collect();
let issues: Vec<&ToolCall> = m.tools.iter().filter(|t| t.name == ISSUE_TOOL && t.error.is_none()).collect();
let tools: Vec<ToolCall> = m.tools.iter().filter(|t| !is_question(t) && !(t.name == ISSUE_TOOL && t.error.is_none())).cloned().collect();
let (refs, tools): (Vec<ToolCall>, Vec<ToolCall>) = tools.into_iter().partition(|t| [REFS_TOOL, GEN_TOOL].contains(&t.name.as_str()) && t.result.is_some());
// Finished calls stay folded in their group while the turn runs and only the live ones show
// as cards, so the list does not grow and shrink (and the scroll jump) on every call.
@@ -911,6 +920,54 @@ fn assistant_segment(ui: &mut Ui, md_id: Id, m: &Message, v: &View, offered: &[S
for t in &refs {
reference_gallery(ui, t, offered, acts);
}
for t in issues {
issue_card(ui, t, v.issues.get(&t.id), acts);
}
}
/// An issue the agent drafted about PaperCraft: what it says, and Send or Discard.
fn issue_card(ui: &mut Ui, t: &ToolCall, state: Option<&super::chat::IssueState>, acts: &mut Acts) {
use super::chat::IssueState;
let p = theme::pal(ui);
let s = |k: &str| t.args[k].as_str().unwrap_or_default().to_owned();
let draft = super::chat::IssueDraft { kind: s("kind"), title: s("title"), body: s("body") };
card(ui).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.spacing_mut().item_spacing.y = 6.0;
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover());
theme::paint_icon(ui.painter(), r, Icon::Note, theme::accent());
ui.label(RichText::new("Issue for PaperCraft").font(theme::semibold(theme::BODY)));
kit::chip_ink(ui, &draft.kind, None);
});
ui.label(RichText::new(&draft.title).font(theme::semibold(theme::BODY)));
ui.add(egui::Label::new(RichText::new(draft.body.lines().take(ISSUE_PREVIEW_LINES).collect::<Vec<_>>().join("\n")).size(theme::SECONDARY).color(p.secondary)).wrap());
match state {
Some(IssueState::Sending) => {
ui.horizontal(|ui| {
spinner(ui, 12.0, theme::accent());
muted(ui, "Sending\u{2026}");
});
}
Some(IssueState::Filed { number, url, existing }) => {
ui.hyperlink_to(if *existing { format!("Already reported as #{number}") } else { format!("Filed as #{number}") }, url);
}
Some(IssueState::Discarded) => muted(ui, "Discarded"),
failed => {
if let Some(IssueState::Failed(e)) = failed {
ui.label(RichText::new(e).size(theme::SECONDARY).color(p.danger));
}
ui.horizontal(|ui| {
if kit::button(ui, kit::ButtonKind::Primary, None, if failed.is_some() { "Try again" } else { "Send issue" }).clicked() {
acts.send_issue = Some((t.id.clone(), draft.clone()));
}
if kit::chip(ui, "Discard", false).clicked() {
acts.discard_issue = Some(t.id.clone());
}
});
}
}
});
}
/// An `ask_user` call that went through: drawn as a question card, not a tool card.
+12
View File
@@ -927,6 +927,18 @@ fn export_section(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
st.code_target = v.into();
}
});
// A component linked to a library (`set_code_link`, `apply_ui_kit`) is written as its code.
if let Some(link) = ed.doc.get(root_id).and_then(|n| crate::mcp::codegen::code_link(&ed.doc, n)) {
let egui = st.code_target == "egui";
let usage = if egui { &link.egui } else { &link.react };
row(ui, "Library", |ui| {
ui.label(RichText::new(if usage.is_empty() { format!("{} (no {} code)", link.kit, st.code_target) } else { link.kit.clone() }).size(theme::SECONDARY));
});
if !usage.is_empty() {
ui.add(egui::Label::new(RichText::new(usage.lines().next().unwrap_or(usage)).monospace().size(theme::SECONDARY)).truncate())
.on_hover_text(if egui { usage.clone() } else { format!("{}\n{usage}", link.import) });
}
}
ui.horizontal(|ui| {
let w = half(ui);
if ui
+39 -66
View File
@@ -524,8 +524,6 @@ pub(crate) const PANEL_GAP: f32 = 12.0;
/// Canvas width under which the top bar drops the zoom pill (it is also on the keyboard).
const TIGHT_BAR_W: f32 = 520.0;
/// Room the Design | Chat switch takes in the top bar.
const CHAT_SWITCH_W: f32 = 140.0;
/// Tallest the top bar's page menu grows before it scrolls.
const PAGE_MENU_MAX_H: f32 = 360.0;
@@ -1046,22 +1044,7 @@ fn top_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
ui.separator();
ui.add_space(6.0);
}
// The title column sits at the top of the bar while the bar centres single widgets, so
// place the home icon on the column's centre (measured last frame) instead.
let column_id = egui::Id::new("title-column-centre");
let cy = ui
.ctx()
.data(|d| d.get_temp::<f32>(column_id))
.unwrap_or(ui.max_rect().center().y);
let slot = egui::Rect::from_center_size(
egui::pos2(ui.cursor().left() + 13.0, cy),
egui::vec2(26.0, 26.0),
);
let home = ui
.scope_builder(egui::UiBuilder::new().max_rect(slot), |ui| {
theme::icon_button(ui, theme::Icon::Grid, 26.0, false)
})
.inner;
let home = theme::icon_button(ui, theme::Icon::Grid, 26.0, false);
if home.on_hover_text("All projects").clicked() {
ed.requests.push(crate::editor::AppRequest::Home);
}
@@ -1075,7 +1058,7 @@ fn top_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
} else {
st.project_name.clone()
};
let column = ui.vertical(|ui| {
ui.horizontal(|ui| {
// Leave room for the zoom pill and icons on the right; long titles truncate.
ui.set_max_width(
(ui.available_width()
@@ -1085,11 +1068,9 @@ fn top_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
300.0
} else {
420.0
}
- CHAT_SWITCH_W)
})
.max(40.0),
);
ui.spacing_mut().item_spacing.y = 0.0;
let state = if !st.save_state.is_empty() {
st.save_state.clone()
} else if ed.is_dirty() {
@@ -1097,55 +1078,47 @@ fn top_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
} else {
String::new()
};
// The text, not its row: the row is button height with the title low in it.
let top = ui
.horizontal(|ui| {
let t = ui.add(egui::Label::new(title_text(title)).truncate());
if !state.is_empty() {
kit::chip(ui, &state, state == "Saved");
}
t.rect.top()
})
.inner;
// Title and page share one row: two rows outgrow the bar and spill onto the canvas.
// The page name opens a page menu: Chat mode has no Pages panel to switch with.
let many = ed.pages.len() > 1;
let row = ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
let label = ui.add(
egui::Label::new(RichText::new(ed.page_name()).size(theme::SECONDARY).color(p.secondary))
.truncate()
.sense(egui::Sense::click()),
);
if many {
let (r, _) = ui.allocate_exact_size(egui::vec2(12.0, 12.0), egui::Sense::hover());
theme::paint_icon(ui.painter(), r, Icon::ChevronDown, p.secondary);
}
label
});
let page = row.inner;
let bottom = page.rect.bottom();
if many {
let page = page.on_hover_text("Switch page").on_hover_cursor(egui::CursorIcon::PointingHand);
egui::Popup::menu(&page).show(|ui| {
ui.set_min_width(180.0);
egui::ScrollArea::vertical().max_height(PAGE_MENU_MAX_H).show(ui, |ui| {
for i in 0..ed.pages.len() {
let name = ed.pages[i].name.clone();
if ui.selectable_label(i == ed.active_page, name).clicked() {
ed.switch_page(i);
ui.close();
}
ui.horizontal(|ui| {
ui.add(egui::Label::new(title_text(title)).truncate());
ui.label(RichText::new("/").size(theme::SECONDARY).color(p.secondary));
let page = ui
.scope(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
let label = ui.add(
egui::Label::new(RichText::new(ed.page_name()).size(theme::SECONDARY).color(p.secondary))
.truncate()
.sense(egui::Sense::click()),
);
if many {
let (r, _) = ui.allocate_exact_size(egui::vec2(12.0, 12.0), egui::Sense::hover());
theme::paint_icon(ui.painter(), r, Icon::ChevronDown, p.secondary);
}
label
})
.inner;
if many {
let page = page.on_hover_text("Switch page").on_hover_cursor(egui::CursorIcon::PointingHand);
egui::Popup::menu(&page).show(|ui| {
ui.set_min_width(180.0);
egui::ScrollArea::vertical().max_height(PAGE_MENU_MAX_H).show(ui, |ui| {
for i in 0..ed.pages.len() {
let name = ed.pages[i].name.clone();
if ui.selectable_label(i == ed.active_page, name).clicked() {
ed.switch_page(i);
ui.close();
}
}
});
});
});
}
(top + bottom) / 2.0
}
if !state.is_empty() {
kit::chip(ui, &state, state == "Saved");
}
});
});
let centre = column.inner;
if (centre - cy).abs() > 0.5 {
ui.ctx().data_mut(|d| d.insert_temp(column_id, centre));
ui.ctx().request_repaint();
}
agent_pill(ui, st, !tight);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.spacing_mut().item_spacing.x = 2.0;
+39 -1
View File
@@ -35,6 +35,7 @@ fn modal_height(ctx: &egui::Context) -> f32 {
}
const DEFAULT_MODEL: &str = "claude-opus-5-5";
const DEFAULT_EFFORT: &str = "medium";
const ISSUE_REPO: &str = "https://git.marcospaulo.dev.br/masi/papercraft";
/// Generous: a long build (eight screens) read about 5M tokens.
const DEFAULT_TURN_BUDGET: u64 = 10_000_000;
pub const TURN_BUDGETS: [(u64, &str); 5] = [(0, "No limit"), (2_000_000, "2M tokens"), (5_000_000, "5M tokens"), (10_000_000, "10M tokens"), (20_000_000, "20M tokens")];
@@ -110,6 +111,8 @@ pub struct ChatPrefs {
pub image_offer_seen: bool,
/// Plugins turned on (`plugins::PLUGINS` ids); all off by default.
pub plugins: Vec<String>,
/// Where PaperCraft's own issues go (the form and the agent's drafts).
pub issue_repo: String,
}
impl Default for ChatPrefs {
@@ -132,6 +135,7 @@ impl Default for ChatPrefs {
image_command: String::new(),
image_offer_seen: false,
plugins: Vec::new(),
issue_repo: ISSUE_REPO.into(),
}
}
}
@@ -231,6 +235,9 @@ pub struct SettingsUi {
pub chat_changed: bool,
#[cfg(not(target_arch = "wasm32"))]
chat_page: native::ChatPage,
/// A token typed on the Updates page, until saved to the keychain.
issue_token: String,
issue_note: Option<String>,
}
impl SettingsUi {
@@ -250,6 +257,8 @@ impl Default for SettingsUi {
chat_changed: false,
#[cfg(not(target_arch = "wasm32"))]
chat_page: native::ChatPage::default(),
issue_token: String::new(),
issue_note: None,
}
}
}
@@ -496,6 +505,34 @@ fn content(ui: &mut Ui, ctx: &egui::Context, st: &mut UiState) {
ui.label("Reload the page to get the latest version.");
},
);
#[cfg(not(target_arch = "wasm32"))]
{
ui.add_space(16.0);
kit::section_header(ui, "Issues", None);
ui.label(RichText::new("Where \"Report a PaperCraft issue\" and the agent's issue drafts go. Nothing is sent without your Send.").color(theme::pal(ui).secondary));
ui.add_space(6.0);
let s = &mut st.settings;
kit::setting_row(ui, "Repository", "Gitea or GitHub repository URL.", |ui| {
if ui.add(egui::TextEdit::singleline(&mut s.chat.issue_repo).desired_width(320.0).margin(theme::INPUT_MARGIN)).lost_focus() {
s.chat.save();
}
});
let saved = crate::issues::token().is_some();
let desc = if saved { "Saved in the keychain." } else { "Optional: without one, your tea login for this server is used." };
kit::setting_row(ui, "Access token", desc, |ui| {
ui.add(egui::TextEdit::singleline(&mut s.issue_token).password(true).hint_text(if saved { "•••• saved" } else { "token" }).desired_width(200.0).margin(theme::INPUT_MARGIN));
if kit::button(ui, kit::ButtonKind::Secondary, None, if s.issue_token.is_empty() && saved { "Remove" } else { "Save" }).clicked() {
s.issue_note = Some(match crate::issues::set_token(&s.issue_token) {
Ok(()) => if s.issue_token.is_empty() { "Token removed".into() } else { "Token saved".into() },
Err(e) => e,
});
s.issue_token.clear();
}
});
if let Some(n) = &st.settings.issue_note {
ui.label(RichText::new(n).size(theme::SECONDARY).color(theme::pal(ui).secondary));
}
}
}
}
}
@@ -1237,6 +1274,7 @@ mod tests {
image_command: "my-tool {prompt} {out}".into(),
image_offer_seen: true,
plugins: vec!["avataaars".into()],
issue_repo: "https://example.com/me/repo".into(),
};
assert_eq!(ChatPrefs::from_json(&prefs.to_json()), prefs);
assert_eq!(ChatPrefs::from_json(&ChatPrefs::default().to_json()), ChatPrefs::default());
@@ -1332,7 +1370,7 @@ mod tests {
open: ["agent:claude-code", "agent:qwen", "agent:mine", "ep:anthropic", "ep:ep-1"].into_iter().map(String::from).collect(),
..Default::default()
};
st.settings = SettingsUi { open: true, page: Page::Chat, chat, chat_changed: false, chat_page: page };
st.settings = SettingsUi { open: true, page: Page::Chat, chat, chat_changed: false, chat_page: page, issue_token: String::new(), issue_note: None };
let mut ed = crate::editor::Editor::default();
for _ in 0..3 {
let input = egui::RawInput {
+350
View File
@@ -0,0 +1,350 @@
//! UI kits: a library's look and its code, as data. Each kit has its tokens (colors in light and
//! dark, radius, control height, type size) and components drawn in its style and density, each
//! with a `CodeLink` so the code export and the AI spec use the library component instead of
//! rebuilding it (`apply_ui_kit` builds a page from a kit; `specs/ui-kits/spec.md`).
//!
//! In templates `{Name}` is the text of the instance's layer named `Name` (see `layer-name` in the
//! markup). Web kits also carry an egui template for the components egui has, so one design can
//! be handed off to either.
pub struct Kit {
pub id: &'static str,
pub name: &'static str,
pub about: &'static str,
pub light: &'static [(&'static str, &'static str)],
pub dark: &'static [(&'static str, &'static str)],
pub tokens: &'static [(&'static str, &'static str)],
/// The kit page's background variable.
pub surface: &'static str,
pub components: &'static [KitComponent],
}
pub struct KitComponent {
pub name: &'static str,
pub html: &'static str,
pub import: &'static str,
pub react: &'static str,
pub egui: &'static str,
}
const EGUI_BUTTON: &str = "if ui.button({Label}).clicked() {\n // TODO: action\n}";
const EGUI_INPUT: &str = "ui.add(egui::TextEdit::singleline(&mut text).hint_text({Placeholder}));";
pub const KITS: [Kit; 4] = [
Kit {
id: "shadcn",
name: "shadcn/ui",
about: "Neutral, quiet components on Radix and Tailwind (new-york style): 36px controls, 14px text.",
light: &[
("background", "#ffffff"), ("foreground", "#09090b"), ("card", "#ffffff"), ("primary", "#18181b"),
("primary-foreground", "#fafafa"), ("secondary", "#f4f4f5"), ("secondary-foreground", "#18181b"),
("muted", "#f4f4f5"), ("muted-foreground", "#71717a"), ("border", "#e4e4e7"), ("input", "#e4e4e7"),
("destructive", "#dc2626"), ("ring", "#a1a1aa"),
],
dark: &[
("background", "#09090b"), ("foreground", "#fafafa"), ("card", "#18181b"), ("primary", "#fafafa"),
("primary-foreground", "#18181b"), ("secondary", "#27272a"), ("secondary-foreground", "#fafafa"),
("muted", "#27272a"), ("muted-foreground", "#a1a1aa"), ("border", "#27272a"), ("input", "#3f3f46"),
("destructive", "#ef4444"), ("ring", "#52525b"),
],
tokens: &[("radius", "10px"), ("control-height", "36px"), ("text-sm", "14px"), ("space-4", "16px"), ("space-6", "24px")],
surface: "background",
components: &[
KitComponent {
name: "Button / Default",
html: r#"<div layer-name="Button / Default" style="display:flex;align-items:center;justify-content:center;height:36px;padding:0 16px;border-radius:8px;background:var(--primary)"><span layer-name="Label" style="font-size:14px;font-weight:500;color:var(--primary-foreground)">Button</span></div>"#,
import: r#"import { Button } from "@/components/ui/button""#,
react: "<Button>{Label}</Button>",
egui: EGUI_BUTTON,
},
KitComponent {
name: "Button / Outline",
html: r#"<div layer-name="Button / Outline" style="display:flex;align-items:center;justify-content:center;height:36px;padding:0 16px;border-radius:8px;background:var(--background);border:1px solid var(--border)"><span layer-name="Label" style="font-size:14px;font-weight:500;color:var(--foreground)">Button</span></div>"#,
import: r#"import { Button } from "@/components/ui/button""#,
react: r#"<Button variant="outline">{Label}</Button>"#,
egui: EGUI_BUTTON,
},
KitComponent {
name: "Button / Secondary",
html: r#"<div layer-name="Button / Secondary" style="display:flex;align-items:center;justify-content:center;height:36px;padding:0 16px;border-radius:8px;background:var(--secondary)"><span layer-name="Label" style="font-size:14px;font-weight:500;color:var(--secondary-foreground)">Button</span></div>"#,
import: r#"import { Button } from "@/components/ui/button""#,
react: r#"<Button variant="secondary">{Label}</Button>"#,
egui: EGUI_BUTTON,
},
KitComponent {
name: "Button / Ghost",
html: r#"<div layer-name="Button / Ghost" style="display:flex;align-items:center;justify-content:center;height:36px;padding:0 16px;border-radius:8px"><span layer-name="Label" style="font-size:14px;font-weight:500;color:var(--foreground)">Button</span></div>"#,
import: r#"import { Button } from "@/components/ui/button""#,
react: r#"<Button variant="ghost">{Label}</Button>"#,
egui: EGUI_BUTTON,
},
KitComponent {
name: "Button / Destructive",
html: r#"<div layer-name="Button / Destructive" style="display:flex;align-items:center;justify-content:center;height:36px;padding:0 16px;border-radius:8px;background:var(--destructive)"><span layer-name="Label" style="font-size:14px;font-weight:500;color:#ffffff">Delete</span></div>"#,
import: r#"import { Button } from "@/components/ui/button""#,
react: r#"<Button variant="destructive">{Label}</Button>"#,
egui: EGUI_BUTTON,
},
KitComponent {
name: "Input",
html: r#"<div layer-name="Input" style="display:flex;align-items:center;width:240px;height:36px;padding:0 12px;border-radius:8px;border:1px solid var(--input);background:var(--background)"><span layer-name="Placeholder" style="font-size:14px;color:var(--muted-foreground)">Email</span></div>"#,
import: r#"import { Input } from "@/components/ui/input""#,
react: r#"<Input placeholder="{Placeholder}" />"#,
egui: EGUI_INPUT,
},
KitComponent {
name: "Badge",
html: r#"<div layer-name="Badge" style="display:flex;align-items:center;padding:2px 8px;border-radius:6px;background:var(--primary)"><span layer-name="Label" style="font-size:12px;font-weight:500;color:var(--primary-foreground)">Badge</span></div>"#,
import: r#"import { Badge } from "@/components/ui/badge""#,
react: "<Badge>{Label}</Badge>",
egui: "ui.label(egui::RichText::new({Label}).small().strong());",
},
KitComponent {
name: "Card",
html: r#"<div layer-name="Card" style="display:flex;flex-direction:column;gap:6px;width:320px;padding:24px;border-radius:12px;border:1px solid var(--border);background:var(--card)"><span layer-name="Title" style="font-size:16px;font-weight:600;color:var(--foreground)">Card title</span><span layer-name="Description" style="font-size:14px;color:var(--muted-foreground)">A short description of what this card holds.</span></div>"#,
import: r#"import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card""#,
react: "<Card>\n <CardHeader>\n <CardTitle>{Title}</CardTitle>\n <CardDescription>{Description}</CardDescription>\n </CardHeader>\n</Card>",
egui: "egui::Frame::group(ui.style()).show(ui, |ui| {\n ui.strong({Title});\n ui.label({Description});\n});",
},
KitComponent {
name: "Switch",
html: r#"<div layer-name="Switch" style="display:flex;align-items:center;justify-content:flex-end;width:32px;height:18px;padding:2px;border-radius:9px;background:var(--primary)"><div style="width:14px;height:14px;border-radius:7px;background:var(--primary-foreground)"></div></div>"#,
import: r#"import { Switch } from "@/components/ui/switch""#,
react: "<Switch defaultChecked />",
egui: "ui.toggle_value(&mut on, \"\");",
},
KitComponent {
name: "Alert",
html: r#"<div layer-name="Alert" style="display:flex;flex-direction:column;gap:4px;width:360px;padding:12px 16px;border-radius:10px;border:1px solid var(--border);background:var(--background)"><span layer-name="Title" style="font-size:14px;font-weight:500;color:var(--foreground)">Heads up</span><span layer-name="Description" style="font-size:14px;color:var(--muted-foreground)">You can add components to your app using the CLI.</span></div>"#,
import: r#"import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert""#,
react: "<Alert>\n <AlertTitle>{Title}</AlertTitle>\n <AlertDescription>{Description}</AlertDescription>\n</Alert>",
egui: "ui.group(|ui| {\n ui.strong({Title});\n ui.label({Description});\n});",
},
],
},
Kit {
id: "antd",
name: "Ant Design",
about: "Enterprise components (antd v5): blue primary, 32px controls, 6px radius, dense tables and forms.",
light: &[
("background", "#f5f5f5"), ("container", "#ffffff"), ("text", "#1f1f1f"), ("text-secondary", "#737373"),
("primary", "#1677ff"), ("border", "#d9d9d9"), ("success", "#52c41a"), ("warning", "#faad14"),
("error", "#ff4d4f"), ("info-bg", "#e6f4ff"), ("info-border", "#91caff"),
],
dark: &[
("background", "#000000"), ("container", "#141414"), ("text", "#e6e6e6"), ("text-secondary", "#8c8c8c"),
("primary", "#1668dc"), ("border", "#424242"), ("success", "#49aa19"), ("warning", "#d89614"),
("error", "#dc4446"), ("info-bg", "#111a2c"), ("info-border", "#15325b"),
],
tokens: &[("radius", "6px"), ("control-height", "32px"), ("font-size", "14px"), ("padding", "16px"), ("padding-lg", "24px")],
surface: "background",
components: &[
KitComponent {
name: "Button / Primary",
html: r#"<div layer-name="Button / Primary" style="display:flex;align-items:center;justify-content:center;height:32px;padding:0 15px;border-radius:6px;background:var(--primary)"><span layer-name="Label" style="font-size:14px;color:#ffffff">Primary Button</span></div>"#,
import: r#"import { Button } from "antd""#,
react: r#"<Button type="primary">{Label}</Button>"#,
egui: EGUI_BUTTON,
},
KitComponent {
name: "Button / Default",
html: r#"<div layer-name="Button / Default" style="display:flex;align-items:center;justify-content:center;height:32px;padding:0 15px;border-radius:6px;background:var(--container);border:1px solid var(--border)"><span layer-name="Label" style="font-size:14px;color:var(--text)">Default Button</span></div>"#,
import: r#"import { Button } from "antd""#,
react: "<Button>{Label}</Button>",
egui: EGUI_BUTTON,
},
KitComponent {
name: "Button / Dashed",
html: r#"<div layer-name="Button / Dashed" style="display:flex;align-items:center;justify-content:center;height:32px;padding:0 15px;border-radius:6px;background:var(--container);border:1px dashed var(--border)"><span layer-name="Label" style="font-size:14px;color:var(--text)">Dashed Button</span></div>"#,
import: r#"import { Button } from "antd""#,
react: r#"<Button type="dashed">{Label}</Button>"#,
egui: EGUI_BUTTON,
},
KitComponent {
name: "Button / Danger",
html: r#"<div layer-name="Button / Danger" style="display:flex;align-items:center;justify-content:center;height:32px;padding:0 15px;border-radius:6px;background:var(--error)"><span layer-name="Label" style="font-size:14px;color:#ffffff">Delete</span></div>"#,
import: r#"import { Button } from "antd""#,
react: r#"<Button type="primary" danger>{Label}</Button>"#,
egui: EGUI_BUTTON,
},
KitComponent {
name: "Input",
html: r#"<div layer-name="Input" style="display:flex;align-items:center;width:240px;height:32px;padding:0 11px;border-radius:6px;border:1px solid var(--border);background:var(--container)"><span layer-name="Placeholder" style="font-size:14px;color:var(--text-secondary)">Basic usage</span></div>"#,
import: r#"import { Input } from "antd""#,
react: r#"<Input placeholder="{Placeholder}" />"#,
egui: EGUI_INPUT,
},
KitComponent {
name: "Tag",
html: r#"<div layer-name="Tag" style="display:flex;align-items:center;padding:0 7px;height:22px;border-radius:4px;background:var(--info-bg);border:1px solid var(--info-border)"><span layer-name="Label" style="font-size:12px;color:var(--primary)">blue</span></div>"#,
import: r#"import { Tag } from "antd""#,
react: r#"<Tag color="blue">{Label}</Tag>"#,
egui: "ui.label(egui::RichText::new({Label}).small());",
},
KitComponent {
name: "Card",
html: r#"<div layer-name="Card" style="display:flex;flex-direction:column;width:300px;border-radius:8px;border:1px solid var(--border);background:var(--container)"><div style="display:flex;align-items:center;height:56px;padding:0 24px;border-bottom:1px solid var(--border)"><span layer-name="Title" style="font-size:16px;font-weight:600;color:var(--text)">Card title</span></div><div style="padding:24px"><span layer-name="Description" style="font-size:14px;color:var(--text)">Card content</span></div></div>"#,
import: r#"import { Card } from "antd""#,
react: "<Card title=\"{Title}\">{Description}</Card>",
egui: "egui::Frame::group(ui.style()).show(ui, |ui| {\n ui.strong({Title});\n ui.separator();\n ui.label({Description});\n});",
},
KitComponent {
name: "Switch",
html: r#"<div layer-name="Switch" style="display:flex;align-items:center;justify-content:flex-end;width:44px;height:22px;padding:2px;border-radius:11px;background:var(--primary)"><div style="width:18px;height:18px;border-radius:9px;background:#ffffff"></div></div>"#,
import: r#"import { Switch } from "antd""#,
react: "<Switch defaultChecked />",
egui: "ui.toggle_value(&mut on, \"\");",
},
KitComponent {
name: "Alert",
html: r#"<div layer-name="Alert" style="display:flex;flex-direction:column;gap:4px;width:360px;padding:9px 12px;border-radius:8px;background:var(--info-bg);border:1px solid var(--info-border)"><span layer-name="Title" style="font-size:14px;color:var(--text)">Informational Notes</span><span layer-name="Description" style="font-size:14px;color:var(--text-secondary)">Additional description and information.</span></div>"#,
import: r#"import { Alert } from "antd""#,
react: "<Alert message=\"{Title}\" description=\"{Description}\" type=\"info\" showIcon />",
egui: "ui.group(|ui| {\n ui.strong({Title});\n ui.label({Description});\n});",
},
],
},
Kit {
id: "reactbits",
name: "React Bits",
about: "Animated text, backgrounds and interactive pieces (reactbits.dev) to put on top of any style: dark stages, glowing accents.",
light: &[
("stage", "#f4f1ff"), ("surface", "#ffffff"), ("text", "#110b2a"), ("text-muted", "#5b5677"),
("accent", "#5227ff"), ("accent-2", "#7cff67"), ("border", "#e2dcfa"),
],
dark: &[
("stage", "#060010"), ("surface", "#120a24"), ("text", "#ffffff"), ("text-muted", "#a59cc5"),
("accent", "#7b5cff"), ("accent-2", "#7cff67"), ("border", "#2a1f4a"),
],
tokens: &[("radius", "16px"), ("radius-pill", "999px")],
surface: "stage",
components: &[
KitComponent {
name: "Split Text",
html: r#"<div layer-name="Split Text" style="display:flex"><span layer-name="Text" style="font-size:48px;font-weight:700;color:var(--text)">Hello, you!</span></div>"#,
import: r#"import SplitText from "@/components/SplitText""#,
react: r#"<SplitText text="{Text}" className="text-5xl font-bold" delay={40} />"#,
egui: "ui.heading({Text});",
},
KitComponent {
name: "Shiny Text",
html: r#"<div layer-name="Shiny Text" style="display:flex"><span layer-name="Text" style="font-size:16px;font-weight:500;color:var(--text-muted)">Just some shiny text!</span></div>"#,
import: r#"import ShinyText from "@/components/ShinyText""#,
react: r#"<ShinyText text="{Text}" speed={3} />"#,
egui: "ui.label({Text});",
},
KitComponent {
name: "Gradient Text",
html: r#"<div layer-name="Gradient Text" style="display:flex"><span layer-name="Text" style="font-size:40px;font-weight:700;color:var(--accent)">Gradient magic</span></div>"#,
import: r#"import GradientText from "@/components/GradientText""#,
react: r##"<GradientText colors={["#40ffaa", "#4079ff", "#40ffaa"]} animationSpeed={3}>{Text}</GradientText>"##,
egui: "ui.heading({Text});",
},
KitComponent {
name: "Aurora",
html: r#"<div layer-name="Aurora" style="width:480px;height:200px;border-radius:16px;background:linear-gradient(135deg, #5227ff 0%, #7cff67 55%, #5227ff 100%)"></div>"#,
import: r#"import Aurora from "@/components/Aurora""#,
react: r##"<Aurora colorStops={["#5227FF", "#7CFF67", "#5227FF"]} amplitude={1} blend={0.5} />"##,
egui: "",
},
KitComponent {
name: "Spotlight Card",
html: r#"<div layer-name="Spotlight Card" style="display:flex;flex-direction:column;gap:8px;width:300px;padding:28px;border-radius:20px;border:1px solid var(--border);background:radial-gradient(circle at 30% 20%, #2b1a5c 0%, #120a24 70%)"><span layer-name="Title" style="font-size:18px;font-weight:600;color:#ffffff">Spotlight</span><span layer-name="Description" style="font-size:14px;color:#a59cc5">Move your pointer over the card.</span></div>"#,
import: r#"import SpotlightCard from "@/components/SpotlightCard""#,
react: "<SpotlightCard spotlightColor=\"rgba(82, 39, 255, 0.25)\">\n <h3>{Title}</h3>\n <p>{Description}</p>\n</SpotlightCard>",
egui: "egui::Frame::group(ui.style()).show(ui, |ui| {\n ui.strong({Title});\n ui.label({Description});\n});",
},
KitComponent {
name: "Dock",
html: r#"<div layer-name="Dock" style="display:flex;gap:12px;align-items:flex-end;padding:10px 14px;border-radius:20px;border:1px solid var(--border);background:var(--surface)"><div style="width:44px;height:44px;border-radius:12px;background:#2a1f4a"></div><div style="width:52px;height:52px;border-radius:14px;background:var(--accent)"></div><div style="width:44px;height:44px;border-radius:12px;background:#2a1f4a"></div><div style="width:44px;height:44px;border-radius:12px;background:#2a1f4a"></div></div>"#,
import: r#"import Dock from "@/components/Dock""#,
react: "<Dock items={items} panelHeight={68} baseItemSize={50} magnification={70} />",
egui: "",
},
KitComponent {
name: "Magnet Button",
html: r#"<div layer-name="Magnet Button" style="display:flex;align-items:center;justify-content:center;height:44px;padding:0 22px;border-radius:999px;background:var(--accent)"><span layer-name="Label" style="font-size:15px;font-weight:600;color:#ffffff">Hover me</span></div>"#,
import: r#"import Magnet from "@/components/Magnet""#,
react: "<Magnet padding={50} magnetStrength={3}>\n <button className=\"rounded-full bg-[#5227ff] px-5 py-2.5 font-semibold text-white\">{Label}</button>\n</Magnet>",
egui: EGUI_BUTTON,
},
],
},
Kit {
id: "egui",
name: "egui",
about: "Native egui widgets for Rust apps: compact (20px rows), small type, square-ish corners, the default dark visuals.",
light: &[
("panel", "#f8f8f8"), ("widget", "#e6e6e6"), ("widget-hover", "#dcdcdc"), ("text", "#3c3c3c"),
("text-strong", "#000000"), ("text-weak", "#8c8c8c"), ("selection", "#90d1ff"), ("stroke", "#bebebe"), ("field", "#ffffff"),
],
dark: &[
("panel", "#1b1b1b"), ("widget", "#3c3c3c"), ("widget-hover", "#464646"), ("text", "#b4b4b4"),
("text-strong", "#ffffff"), ("text-weak", "#8c8c8c"), ("selection", "#005c80"), ("stroke", "#3c3c3c"), ("field", "#0f0f0f"),
],
tokens: &[("radius", "3px"), ("row-height", "20px"), ("body", "13px"), ("item-spacing", "8px")],
surface: "panel",
components: &[
KitComponent {
name: "Button",
html: r#"<div layer-name="Button" style="display:flex;align-items:center;justify-content:center;height:20px;padding:0 6px;border-radius:3px;background:var(--widget)"><span layer-name="Label" style="font-size:13px;color:var(--text)">Click me</span></div>"#,
import: "",
react: "",
egui: EGUI_BUTTON,
},
KitComponent {
name: "Checkbox",
html: r#"<div layer-name="Checkbox" style="display:flex;align-items:center;gap:6px"><div style="width:14px;height:14px;border-radius:3px;background:var(--widget)"></div><span layer-name="Label" style="font-size:13px;color:var(--text)">Enabled</span></div>"#,
import: "",
react: "",
egui: "ui.checkbox(&mut checked, {Label});",
},
KitComponent {
name: "Toggle",
html: r#"<div layer-name="Toggle" style="display:flex;align-items:center;justify-content:center;height:20px;padding:0 6px;border-radius:3px;background:var(--selection)"><span layer-name="Label" style="font-size:13px;color:var(--text-strong)">Grid</span></div>"#,
import: "",
react: "",
egui: "ui.toggle_value(&mut on, {Label});",
},
KitComponent {
name: "Text Edit",
html: r#"<div layer-name="Text Edit" style="display:flex;align-items:center;width:200px;height:20px;padding:0 4px;border-radius:3px;background:var(--field);border:1px solid var(--stroke)"><span layer-name="Placeholder" style="font-size:13px;color:var(--text-weak)">Search…</span></div>"#,
import: "",
react: "",
egui: EGUI_INPUT,
},
KitComponent {
name: "Slider",
html: r#"<div layer-name="Slider" style="display:flex;align-items:center;gap:8px"><div style="display:flex;align-items:center;width:140px;height:4px;border-radius:2px;background:var(--widget)"><div style="width:84px;height:4px;border-radius:2px;background:var(--selection)"></div></div><span layer-name="Label" style="font-size:13px;color:var(--text)">Volume</span></div>"#,
import: "",
react: "",
egui: "ui.add(egui::Slider::new(&mut value, 0.0..=100.0).text({Label}));",
},
KitComponent {
name: "Combo Box",
html: r#"<div layer-name="Combo Box" style="display:flex;align-items:center;gap:8px"><div style="display:flex;align-items:center;justify-content:space-between;width:140px;height:20px;padding:0 6px;border-radius:3px;background:var(--widget)"><span layer-name="Value" style="font-size:13px;color:var(--text)">First</span><svg width="10" height="10" viewBox="0 0 10 10" style="color:var(--text)"><path d="M2 3.5l3 3 3-3" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg></div><span layer-name="Label" style="font-size:13px;color:var(--text)">Choice</span></div>"#,
import: "",
react: "",
egui: "egui::ComboBox::from_label({Label})\n .selected_text({Value})\n .show_ui(ui, |ui| {\n // ui.selectable_value(&mut choice, Choice::First, \"First\");\n });",
},
KitComponent {
name: "Heading",
html: r#"<div layer-name="Heading" style="display:flex"><span layer-name="Text" style="font-size:20px;color:var(--text-strong)">Settings</span></div>"#,
import: "",
react: "",
egui: "ui.heading({Text});",
},
KitComponent {
name: "Collapsing Header",
html: r#"<div layer-name="Collapsing Header" style="display:flex;align-items:center;gap:6px;height:20px"><svg width="10" height="10" viewBox="0 0 10 10" style="color:var(--text)"><path d="M2 3.5l3 3 3-3" stroke="currentColor" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg><span layer-name="Title" style="font-size:13px;color:var(--text-strong)">Advanced</span></div>"#,
import: "",
react: "",
egui: "ui.collapsing({Title}, |ui| {\n // body\n});",
},
],
},
];
pub fn kit(id: &str) -> Option<&'static Kit> {
KITS.iter().find(|k| k.id == id)
}