feat: rebrand to PaperCraft; variable modes, lint_design, Design tab, icon, docs and skills
Product, crate and binaries are now PaperCraft/papercraft (papercraft-cli, papercraft-mcp);
projects, icon sets and theme in the old paper-rs locations are still read and .paper-rs files
still open. set_variables {mode} and set_variable_mode switch whole themes; lint_design checks
contrast, type scale, 4px grid, touch targets, layer names and hard-coded colors; get_variables
exports css/json tokens; the sidebar gets a Design tab for variables, modes and components.
New app icon, README, docs, ROADMAP and agent skills.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@@ -0,0 +1,22 @@
|
||||
# Attribution
|
||||
|
||||
Bundled assets and where they come from. Ported code and dependency notices are in
|
||||
[THIRD_PARTY.md](THIRD_PARTY.md).
|
||||
|
||||
| Path | Title | Author | Source | License |
|
||||
|---|---|---|---|---|
|
||||
| `assets/fonts/Inter-*.ttf`, `assets/fonts/OFL.txt` | Inter 4.1 (Regular, Medium, SemiBold, Bold, Italic, Bold Italic) | The Inter Project Authors | https://rsms.me/inter/ | SIL Open Font License 1.1 |
|
||||
| `assets/app-icon/*` | PaperCraft app icon | PaperCraft contributors (original artwork; colors from the Catppuccin palette) | this repository | MIT OR Apache-2.0 |
|
||||
|
||||
## Palettes
|
||||
|
||||
The Catppuccin Latte, Frappé, Macchiato and Mocha themes use the colors of the
|
||||
[Catppuccin](https://github.com/catppuccin/catppuccin) palette (MIT, Copyright (c) 2021 Catppuccin),
|
||||
taken from the `craft-ui` crate of [craft-libs](https://github.com/storytold/craft-libs).
|
||||
|
||||
## Icon sets (not bundled)
|
||||
|
||||
`papercraft-cli icons add <set>` downloads open icon sets from the npm registry into the user's data
|
||||
folder, each with its own license file: Lucide (ISC), Tabler (MIT), Phosphor
|
||||
(MIT, https://github.com/phosphor-icons/core), Heroicons (MIT) and Material Symbols (Apache-2.0).
|
||||
They are not part of this repository or of the release builds.
|
||||
@@ -2520,7 +2520,7 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paper-rs"
|
||||
name = "papercraft"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[package]
|
||||
name = "paper-rs"
|
||||
name = "papercraft"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.95"
|
||||
@@ -7,20 +7,20 @@ description = "A native infinite-canvas app for planning, working and sketching"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
[lib]
|
||||
name = "paper_rs"
|
||||
name = "papercraft"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "paper-rs"
|
||||
name = "papercraft"
|
||||
path = "src/main.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "paper-rs-mcp"
|
||||
path = "src/bin/paper-rs-mcp.rs"
|
||||
name = "papercraft-mcp"
|
||||
path = "src/bin/papercraft-mcp.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "paper-rs-cli"
|
||||
path = "src/bin/paper-rs-cli.rs"
|
||||
name = "papercraft-cli"
|
||||
path = "src/bin/papercraft-cli.rs"
|
||||
|
||||
[dependencies]
|
||||
egui = "0.36"
|
||||
|
||||
@@ -1,262 +1,280 @@
|
||||
# paper-rs
|
||||
<h1 align="center">PaperCraft</h1>
|
||||
|
||||
A native Rust infinite canvas for planning, working and sketching. It is modeled on the
|
||||
behavior of [Paper](https://paper.design) and built clean-room on `wgpu` + `winit` + `egui`.
|
||||
<p align="center">
|
||||
<b>Design and whiteboarding on an infinite canvas; a native, local-first, agent-driven design tool, built in pure Rust.</b>
|
||||
</p>
|
||||
|
||||

|
||||
<p align="center">
|
||||
Frames, auto layout, components, variables and icons, plus a hand-drawn whiteboard mode,<br>
|
||||
in a native app written entirely in Rust. Open source, offline, and drivable by AI agents.
|
||||
</p>
|
||||
|
||||
A local-first design and whiteboard tool. Projects are folders of pages; each page is an
|
||||
infinite canvas with frames, groups, components and instances, auto layout and constraints,
|
||||
vector paths and booleans, gradients and image fills, rich text, smart guides, and
|
||||
pressure-sensitive ink — plus a whiteboard mode: hand-drawn (rough.js-style) shapes, arrows that
|
||||
bind to shapes and re-route, elbow connectors, sticky notes, a laser pointer and a shape
|
||||
library. Everything autosaves, and exports to PNG, SVG and PDF; Excalidraw files and SVG import.
|
||||
<p align="center">
|
||||
<img alt="Status: early alpha" src="https://img.shields.io/badge/status-early%20alpha-d69e2e?style=flat-square">
|
||||
<img alt="100% Rust" src="https://img.shields.io/badge/100%25-Rust-b7410e?style=flat-square&logo=rust">
|
||||
<img alt="Native on egui and wgpu" src="https://img.shields.io/badge/egui%20%C2%B7%20wgpu-native-2f7bf5?style=flat-square">
|
||||
<img alt="MCP server and CLI for agents" src="https://img.shields.io/badge/agents-MCP%20%2B%20CLI-555555?style=flat-square">
|
||||
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%2F%20Apache--2.0-3a3a3a?style=flat-square">
|
||||
</p>
|
||||
|
||||

|
||||
<br>
|
||||
|
||||
> **Clean-room note.** `analysis/PAPER_ARCHITECTURE.md` documents how Paper Desktop 0.5.15 is
|
||||
> put together, from a licensed install. The Electron package turned out to be a thin shell
|
||||
> around the hosted web editor. paper-rs re-implements *behavior* only. No Paper code,
|
||||
> shaders, icons, fonts or assets are used or committed. The UI and canvas text use
|
||||
> [Inter](https://rsms.me/inter/) 4.1 (SIL OFL 1.1), bundled in `assets/fonts/`.
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/app_demo.png" alt="PaperCraft with a launch plan: a Week plan frame with sticky notes and a progress bar, a Sketches frame with hand-drawn shapes, the Layers panel and the Design inspector" width="100%">
|
||||
<br>
|
||||
<sub>A planning board: frames, sticky notes, a progress bar, hand-drawn sketches, the Layers panel and the Design inspector.</sub>
|
||||
</p>
|
||||
|
||||
## Reverse-engineered origin
|
||||
<p align="center">
|
||||
<a href="#features">Features</a> ·
|
||||
<a href="#drive-it-with-agents">Agents</a> ·
|
||||
<a href="#design-systems-variables-components-icons">Design systems</a> ·
|
||||
<a href="#get-started">Get started</a> ·
|
||||
<a href="#keyboard-shortcuts">Shortcuts</a> ·
|
||||
<a href="#status">Status</a> ·
|
||||
<a href="#downloads">Downloads</a> ·
|
||||
<a href="#license-and-credits">License</a>
|
||||
</p>
|
||||
|
||||
paper-rs is an independent, clean-room-style reimplementation. It was reverse engineered from
|
||||
[Paper](https://paper.design) by studying the behavior of the Paper desktop app with
|
||||
[REA](https://github.com/morluto/rea) and Claude Code. It is not affiliated with or endorsed by
|
||||
Paper. No original Paper code or assets are included in this repository.
|
||||
<br>
|
||||
|
||||
## Projects
|
||||
<table>
|
||||
<tr>
|
||||
<td width="25%" valign="top">
|
||||
<h3>Local-first</h3>
|
||||
Projects are plain folders of JSON in <code>~/Documents/papercraft</code>. Autosave with atomic writes, no account, no cloud.
|
||||
</td>
|
||||
<td width="25%" valign="top">
|
||||
<h3>Native and fast</h3>
|
||||
egui on wgpu (Metal, Vulkan, DX12). No Electron, no web view. Cached tessellation: a frame with 10k visible nodes draws in about 3 ms.
|
||||
</td>
|
||||
<td width="25%" valign="top">
|
||||
<h3>Design and whiteboard</h3>
|
||||
Auto layout, components and variables next to hand-drawn shapes, bound arrows, sticky notes and a laser pointer, on the same canvas.
|
||||
</td>
|
||||
<td width="25%" valign="top">
|
||||
<h3>Agent-ready</h3>
|
||||
40 MCP tools and a shell CLI. Agents write HTML into layers, screenshot the result and lint it, one undo step per call.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
paper-rs opens on a **home screen** of your projects (recents grid with thumbnails, New, Import,
|
||||
and Rename / Duplicate / Move to Trash from each card's ⋯ menu or right-click; restore from
|
||||
Trash). Each project is a plain folder in `~/Documents/paper-rs/<name>/` holding `project.json`
|
||||
and one JSON file per page in `pages/`. Work is autosaved about a second after each change with
|
||||
crash-safe atomic writes; files are versioned and older versions migrate on load. Set
|
||||
`PAPER_RS_HOME` to use another folder.
|
||||
<br>
|
||||
|
||||
A project has any number of **pages** (Pages list at the top of the left sidebar: + to add,
|
||||
double-click to rename, right-click to duplicate or delete, drag to reorder). Each page is its
|
||||
own infinite canvas with its own undo history and view. Copy and paste works between pages and
|
||||
between projects.
|
||||
## Features
|
||||
|
||||
## Build and run
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/screenshots/milestone3_figma_core.png" alt="A design with frames, auto layout, a component and its instances, and vector shapes" width="100%">
|
||||
<br>
|
||||
<h3>Design tools</h3>
|
||||
Frames with clipping, groups, components with instances and per-property overrides, auto layout (direction, gap, padding, hug, fill, wrap, space-between), constraints, the Pen with Bézier editing, boolean operations, gradients, image fills, strokes, per-corner radii, opacity and blend modes, rich text, smart guides and measurements, align and distribute.
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/screenshots/milestone4_whiteboard.png" alt="A whiteboard with hand-drawn shapes, bound arrows, an elbow connector and sticky notes" width="100%">
|
||||
<br>
|
||||
<h3>A whiteboard that sketches</h3>
|
||||
Hand-drawn (rough.js-style) rectangles, ellipses, diamonds and lines, arrows that bind to shapes and re-route when they move, elbow connectors, sticky notes, freehand ink with pressure, a laser pointer and a shape library.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/screenshots/milestone5_inspector.png" alt="The inspector with position, size, constraints, auto layout, fills, strokes and export sections" width="100%">
|
||||
<br>
|
||||
<h3>Inspector, layers and a palette</h3>
|
||||
A sectioned inspector, a layers tree you can drag to reorder or re-parent, 100+ keyboard shortcuts, and a command palette over every action, page and layer (⌘K).
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/screenshots/milestone6_svg_import.png" alt="An imported SVG on the canvas" width="100%">
|
||||
<br>
|
||||
<h3>Import and export</h3>
|
||||
Export a selection, each frame, a page or the whole project to PNG (1 to 4x), SVG or vector PDF. Import <code>.excalidraw</code> scenes and libraries, SVG and images; paste between pages, projects and apps.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
You need Rust 1.95 or newer (`rust-version` in `Cargo.toml`; the latest stable is recommended). Install it with [rustup](https://rustup.rs).
|
||||
### Themes
|
||||
|
||||
```sh
|
||||
cargo build --release
|
||||
./target/release/paper-rs # projects home screen
|
||||
./target/release/paper-rs --demo # sample planning board (not saved)
|
||||
./target/release/paper-rs ~/Documents/paper-rs/Plan # open a project folder
|
||||
./target/release/paper-rs board.paper-rs # open a paper-rs 0.1 document
|
||||
```
|
||||
System, Light, Dark and the four Catppuccin flavours: Latte, Frappé, Macchiato and Mocha. Pick one in **View › Theme** or search "theme" in the ⌘K palette.
|
||||
|
||||
### macOS
|
||||
### Projects and pages
|
||||
|
||||
```sh
|
||||
xcode-select --install # command line tools (linker)
|
||||
curl https://sh.rustup.rs -sSf | sh
|
||||
cargo build --release && ./target/release/paper-rs --demo
|
||||
```
|
||||
PaperCraft opens on a **home screen** of your projects (recents grid with thumbnails; New, Import, and Rename, Duplicate or Move to Trash from each card's ⋯ menu or right-click; restore from Trash). A project is a folder in `~/Documents/papercraft/<name>/` with `project.json` and one JSON file per page in `pages/`. Work autosaves about a second after each change with crash-safe atomic writes; files are versioned and older versions migrate on load. Set `PAPERCRAFT_HOME` to use another folder. If you used the app under its earlier name, `~/Documents/paper-rs` is still read when it is the only one that exists.
|
||||
|
||||
It uses Metal through wgpu. Shortcuts use ⌘, and trackpad pinch zooms. The macOS target is
|
||||
type-checked in CI-style (`cargo check --target aarch64-apple-darwin`) but has not been run on
|
||||
Mac hardware yet (see Known gaps).
|
||||
Each project has any number of **pages** (the Pages list at the top of the left sidebar: + to add, double-click to rename, right-click to duplicate or delete, drag to reorder). Each page is its own infinite canvas with its own undo history and view. Copy and paste works between pages and projects.
|
||||
|
||||
### Linux
|
||||
### File formats
|
||||
|
||||
```sh
|
||||
# Debian/Ubuntu runtime libraries (usually already present on desktops)
|
||||
sudo apt install libxkbcommon-x11-0 libxcb-xkb1 libvulkan1 mesa-vulkan-drivers
|
||||
cargo build --release && ./target/release/paper-rs --demo
|
||||
```
|
||||
| Format | Use |
|
||||
|---|---|
|
||||
| Project folder (`project.json` + `pages/*.json`) | The native format. Plain, versioned JSON. |
|
||||
| `.papercraft` | A single-file document (`papercraft FILE.papercraft`, not autosaved). `--import` turns it into a project. |
|
||||
| `.paper-rs` | The earlier single-file extension. Old files still open. |
|
||||
| PNG, SVG, PDF | Export: selection, frames, page or all pages. PDF is vector, with gradients, images and blend modes. |
|
||||
| `.excalidraw`, `.excalidrawlib`, SVG, images | Import (drag and drop, ⌘O, or `--import`). |
|
||||
|
||||
It runs on X11 and Wayland through Vulkan, or GL as a fallback. Shortcuts use Ctrl. File
|
||||
dialogs use the XDG desktop portal.
|
||||
|
||||
### Headless (servers, CI)
|
||||
|
||||
No display is needed. wgpu renders offscreen, through Mesa lavapipe when there is no GPU:
|
||||
|
||||
```sh
|
||||
paper-rs --screenshot ui.png --demo --size 1440x900 --scale 2 # full UI
|
||||
paper-rs --screenshot home.png --home # projects home screen
|
||||
paper-rs --export-png out.png PROJECT_DIR --page 0 --scale 2 [--transparent]
|
||||
paper-rs --export-svg out.svg PROJECT_DIR
|
||||
paper-rs --export-pdf out.pdf PROJECT_DIR --frames # one PDF page per frame
|
||||
paper-rs --export out_dir PROJECT_DIR --format png --scope frames # a folder of PNGs
|
||||
paper-rs --import board.excalidraw # new project, prints its folder
|
||||
```
|
||||
|
||||
## AI agents (MCP)
|
||||
## Drive it with agents
|
||||
|
||||

|
||||
|
||||
paper-rs serves the [Model Context Protocol](https://modelcontextprotocol.io) while it runs, so
|
||||
agents such as Claude Code and Codex can read and design on the open canvas, the way Paper's
|
||||
own MCP server works (behaviour notes: `analysis/paper-mcp.md`). Every tool call runs through
|
||||
the editor like a user edit: one undo step per call, and the window repaints immediately.
|
||||
PaperCraft serves the [Model Context Protocol](https://modelcontextprotocol.io) while it runs, so agents such as Claude Code and Codex can read and design on the open canvas. Every tool call runs through the editor like a user edit: one undo step per call, and the window repaints immediately. Tool names and arguments follow other design-tool MCP servers where there is an equivalent, so prompts written for them carry over.
|
||||
|
||||
* **Streamable HTTP** (built into the app): `http://127.0.0.1:29980/mcp`. Paper uses 29979, so
|
||||
both can run. Loopback only; requests from browsers (with an `Origin` header) and foreign
|
||||
`Host` headers are rejected. `PAPER_RS_MCP_PORT=NNNN` changes the port; `PAPER_RS_MCP=0`
|
||||
turns the server off.
|
||||
* **stdio**: `paper-rs-mcp` (built next to `paper-rs`) relays stdio to the running app. It
|
||||
lists the tools even when the app is closed, and tells the agent to start paper-rs if a
|
||||
call can't reach it.
|
||||
<table>
|
||||
<tr>
|
||||
<td width="33%" valign="top">
|
||||
<img src="docs/screenshots/agent_signin.png" alt="A dark sign-in screen built from a component, an icon and variables" width="100%">
|
||||
<br><sub>A sign-in screen: variables, an icon and a component instance.</sub>
|
||||
</td>
|
||||
<td width="67%" valign="top">
|
||||
<img src="docs/screenshots/agent_dashboard.png" alt="A dashboard with a sidebar, three stat cards and a bar chart" width="100%">
|
||||
<br><sub>A dashboard written in one <code>write_html</code> call, then checked with <code>lint_design</code>.</sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" valign="top">
|
||||
<img src="docs/screenshots/agent_landing.png" alt="A landing page with a hero and three feature cards" width="100%">
|
||||
<br><sub>A landing page: hero, call to action and feature cards, all colors from variables.</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
* **Streamable HTTP** (built into the app): `http://127.0.0.1:29980/mcp`. Loopback only; requests from browsers (with an `Origin` header) and foreign `Host` headers are rejected. `PAPERCRAFT_MCP_PORT=NNNN` changes the port; `PAPERCRAFT_MCP=0` turns the server off.
|
||||
* **stdio**: `papercraft-mcp` (built next to `papercraft`) relays stdio to the running app. It lists the tools even when the app is closed, and tells the agent to start PaperCraft if a call can't reach it.
|
||||
* **Shell**: `papercraft-cli` calls the same tools, so an agent with a terminal needs no schemas in its context.
|
||||
|
||||
### Claude Code
|
||||
|
||||
```sh
|
||||
# HTTP (the app must be running)
|
||||
claude mcp add --transport http --scope user paper-rs http://127.0.0.1:29980/mcp
|
||||
claude mcp add --transport http --scope user papercraft http://127.0.0.1:29980/mcp
|
||||
# or stdio through the bridge
|
||||
claude mcp add --scope user paper-rs -- /path/to/paper-rs/target/release/paper-rs-mcp
|
||||
claude mcp add --scope user papercraft -- /path/to/papercraft-mcp
|
||||
```
|
||||
|
||||
### Shell (no MCP client needed)
|
||||
### Design tab
|
||||
|
||||
`paper-rs-cli` calls the same tools from a shell, so an agent with a terminal needs no schemas in
|
||||
its context. The app must be running with a project open.
|
||||
|
||||
```sh
|
||||
paper-rs-cli list # tool names, one line each (works offline)
|
||||
paper-rs-cli schema write_html # one tool's full schema
|
||||
paper-rs-cli call get_basic_info
|
||||
paper-rs-cli call create_artboard '{"name":"Hero","styles":{"width":"800px","height":"400px"}}'
|
||||
echo '{"nodeId":"12"}' | paper-rs-cli call get_screenshot - --out hero.png
|
||||
```
|
||||
|
||||
Text goes to stdout, images are saved to `--out` (default `<tool>.png`) and the path is printed,
|
||||
and the exit status is 1 on a tool error or when the app isn't running.
|
||||
|
||||
### Design systems: variables, components, icons
|
||||
|
||||
Agents (and `paper-rs-cli`) get the pieces of a design system, so files aren't a pile of hex codes
|
||||
and copies:
|
||||
|
||||
```sh
|
||||
paper-rs-cli call set_variables '{"variables":{"surface":"#313244","accent":"#cba6f7"}}'
|
||||
paper-rs-cli call write_html '{"targetNodeId":"root","mode":"insert-children","html":"<div style=\"background:var(--surface)\">…</div>"}'
|
||||
paper-rs-cli call create_component '{"nodeId":"<frame>","name":"Button / Primary"}'
|
||||
paper-rs-cli call create_instance '{"componentId":"<id>","parentId":"<frame>"}' # then set_text_content on its layers
|
||||
paper-rs-cli icons add phosphor lucide # open icon sets from npm, with their licences
|
||||
paper-rs-cli icons search arrow left
|
||||
# in write_html: <x-paper-icon name="phosphor:gear" size="20" color="var(--accent)"/>
|
||||
```
|
||||
|
||||
* **Variables** are named colors per project (`get_variables`, `set_variables`). A fill or text
|
||||
colour written as `var(--name)` stays linked; `set_variables` moves every linked fill on every
|
||||
page in one undoable step, and the link is saved in the file. Strokes and gradients keep fixed
|
||||
colours; fill-based icon sets (Phosphor, Material) follow variables, stroke sets (Lucide) don't.
|
||||
* **Components** (`create_component`, `create_instance`, `get_components`, `<x-paper-instance>`):
|
||||
edit the main component and every instance follows, except what an instance overrode (text,
|
||||
fills, size, ... are marked automatically when set through `set_text_content` / `update_styles`).
|
||||
Components and their instances live on one page.
|
||||
* **Icons** live in `<data dir>/paper-rs/icons/<set>/` (override with `PAPER_RS_ICONS`). Sets: Lucide
|
||||
(ISC), Tabler (MIT), Phosphor (MIT), Heroicons (MIT), Material Symbols (Apache-2.0); each keeps its
|
||||
licence file. `examples/taskflow.py` builds an 8-screen app with all three.
|
||||
The sidebar's **Design** tab (next to Layers and Library) lists the project's color variables as
|
||||
editable swatches, switches variable modes (for example the four Catppuccin flavours), and lists the
|
||||
page's components with Go to and Insert buttons. Editing a variable recolours every fill that follows it.
|
||||
|
||||
### Codex
|
||||
|
||||
```sh
|
||||
# HTTP
|
||||
codex mcp add paper-rs --url http://127.0.0.1:29980/mcp
|
||||
codex mcp add papercraft --url http://127.0.0.1:29980/mcp
|
||||
# or stdio
|
||||
codex mcp add paper-rs -- /path/to/paper-rs/target/release/paper-rs-mcp
|
||||
codex mcp add papercraft -- /path/to/papercraft-mcp
|
||||
```
|
||||
|
||||
which writes this to `~/.codex/config.toml` (either form works):
|
||||
|
||||
```toml
|
||||
[mcp_servers.paper-rs]
|
||||
[mcp_servers.papercraft]
|
||||
url = "http://127.0.0.1:29980/mcp"
|
||||
# command = "/path/to/paper-rs/target/release/paper-rs-mcp"
|
||||
# command = "/path/to/papercraft-mcp"
|
||||
```
|
||||
|
||||
Then ask the agent something like *"In paper-rs, create a 390×844 mobile artboard and design a
|
||||
sign-in screen"*, or *"draw a flowchart of our deploy process on a new page, hand-drawn
|
||||
style"*.
|
||||
Then ask the agent something like *"In PaperCraft, create a 390×844 mobile artboard and design a sign-in screen"*, or *"draw a flowchart of our deploy process on a new page, hand-drawn style"*. The [`papercraft` and `papercraft-design` agent skills](skills) teach an agent the workflow (build, screenshot, lint, fix) and design rules; [docs/agents.md](docs/agents.md) has the full guide.
|
||||
|
||||
### Shell (no MCP client needed)
|
||||
|
||||
The app must be running with a project open.
|
||||
|
||||
```sh
|
||||
papercraft-cli list # tool names, one line each (works offline)
|
||||
papercraft-cli schema write_html # one tool's full schema
|
||||
papercraft-cli call get_basic_info
|
||||
papercraft-cli call create_artboard '{"name":"Hero","styles":{"width":"800px","height":"400px"}}'
|
||||
echo '{"nodeId":"12"}' | papercraft-cli call get_screenshot - --out hero.png
|
||||
```
|
||||
|
||||
Text goes to stdout, images are saved to `--out` (default `<tool>.png`) and the path is printed, and the exit status is 1 on a tool error or when the app isn't running.
|
||||
|
||||
### Tools
|
||||
|
||||
Names and arguments follow Paper's MCP server where paper-rs has an equivalent, so prompts
|
||||
written for Paper work here too.
|
||||
|
||||
| Group | Tools |
|
||||
|---|---|
|
||||
| Projects & pages | `list_resources`, `open_file`, `create_file`, `create_page`, `rename_pages` |
|
||||
| Read | `get_basic_info`, `get_selection`, `get_node_info`, `get_children`, `get_tree_summary`, `get_computed_styles`, `get_jsx`, `find_nodes`, `get_fill_image`, `get_font_family_info`, `get_guide` |
|
||||
| See | `get_screenshot` (PNG image content of a node or the page), `export` (PNG/SVG/PDF files), `export_combined_pdf` |
|
||||
| Write | `create_artboard`, `write_html`, `update_styles`, `set_text_content`, `rename_nodes`, `duplicate_nodes`, `move_nodes`, `delete_nodes`, `finish_working_on_nodes` (saves) |
|
||||
| paper-rs extras | `create_shape` (rectangles, ellipses, diamonds, stars, stickies, text, lines, arrows bound to shapes), `select_nodes`, `undo`, `redo` |
|
||||
| Whiteboard and session | `create_shape` (rectangles, ellipses, diamonds, stars, stickies, text, lines, arrows bound to shapes), `select_nodes`, `undo`, `redo` |
|
||||
| Design system | `get_variables`, `set_variables`, `set_variable_mode`, `get_components`, `create_component`, `create_instance`, `search_icons`, `lint_design` |
|
||||
|
||||
`write_html` takes HTML with inline styles: flexbox (`display:flex`, direction, gap, padding,
|
||||
alignment, `flex-grow`, wrap) and block stacking map onto auto layout with CSS's stretch rules;
|
||||
text gets size, weight, italic, color, line height, letter spacing, alignment and rich-text
|
||||
runs (`<b>`, `<i>`, `<span style>`); boxes get backgrounds (colors, gradients, images),
|
||||
borders, radii, opacity and blend modes; `<img>` takes local paths or `data:` URIs and `<svg>`
|
||||
is imported as vectors. `update_styles` takes the same CSS as JSON, plus the hand-drawn keys
|
||||
`sketch`, `roughness`, `fillStyle`, `strokeStyle`. Node ids look like `3fa9c1:14` (page and
|
||||
node); `pageId` lets an agent work on another page without switching the user's view.
|
||||
`write_html` takes HTML with inline styles: flexbox (`display:flex`, direction, gap, padding, alignment, `flex-grow`, wrap) and block stacking map onto auto layout with CSS's stretch rules; text gets size, weight, italic, color, line height, letter spacing, alignment and rich-text runs (`<b>`, `<i>`, `<span style>`); boxes get backgrounds (colors, gradients, images), borders, radii, opacity and blend modes; `<img>` takes local paths or `data:` URIs and `<svg>` is imported as vectors. `update_styles` takes the same CSS as JSON, plus the hand-drawn keys `sketch`, `roughness`, `fillStyle`, `strokeStyle`. Node ids look like `3fa9c1:14` (page and node); `pageId` lets an agent work on another page without switching the user's view. The full reference is in [skills/papercraft/references/tools.md](skills/papercraft/references/tools.md).
|
||||
|
||||
## Tests
|
||||
## Design systems: variables, components, icons
|
||||
|
||||
Agents (and `papercraft-cli`) get the pieces of a design system, so files aren't a pile of hex codes and copies:
|
||||
|
||||
```sh
|
||||
cargo test # ~140 tests: unit, scenario, headless GPU, CLI
|
||||
PAPER_RS_REQUIRE_GPU=1 cargo test # fail (instead of skip) when no GPU adapter exists
|
||||
papercraft-cli call set_variables '{"variables":{"surface":"#313244","accent":"#cba6f7"}}'
|
||||
papercraft-cli call write_html '{"targetNodeId":"root","mode":"insert-children","html":"<div style=\"background:var(--surface)\">…</div>"}'
|
||||
papercraft-cli call create_component '{"nodeId":"<frame>","name":"Button / Primary"}'
|
||||
papercraft-cli call create_instance '{"componentId":"<id>","parentId":"<frame>"}' # then set_text_content on its layers
|
||||
papercraft-cli call lint_design '{"nodeId":"<frame>"}' # contrast, grid, type scale, names
|
||||
papercraft-cli icons add phosphor lucide # open icon sets from npm, with their licences
|
||||
papercraft-cli icons search arrow left
|
||||
# in write_html: <x-paper-icon name="phosphor:gear" size="20" color="var(--accent)"/>
|
||||
```
|
||||
|
||||
* **Variables** are named colors per project (`get_variables`, `set_variables`). A fill or text color written as `var(--name)` stays linked; `set_variables` moves every linked fill on every page in one undoable step, and the link is saved in the file. `set_variables` with a `mode` stores one set of values per mode (Light, Dark) and `set_variable_mode` switches the whole project. Strokes and gradients keep fixed colors; fill-based icon sets (Phosphor, Material) follow variables, stroke sets (Lucide) don't.
|
||||
* **Components** (`create_component`, `create_instance`, `get_components`, `<x-paper-instance>`): edit the main component and every instance follows, except what an instance overrode (text, fills, size, ... are marked automatically when set through `set_text_content` / `update_styles`). Components and their instances live on one page.
|
||||
* **Icons** live in `<data dir>/papercraft/icons/<set>/` (override with `PAPERCRAFT_ICONS`). Sets: Lucide (ISC), Tabler (MIT), Phosphor (MIT), Heroicons (MIT), Material Symbols (Apache-2.0); each keeps its licence file. `examples/taskflow.py` builds an 8-screen app with all three.
|
||||
* **Lint** (`lint_design`) checks WCAG contrast, spacing on a 4px grid, the type scale, text under 12px, touch targets under 44px, default layer names and hard-coded colors that should be variables, and says how to fix each.
|
||||
|
||||
More in [docs/design-system.md](docs/design-system.md).
|
||||
|
||||
## Get started
|
||||
|
||||
### Download
|
||||
|
||||
The macOS app (`papercraft-<version>-macos-<arch>.dmg`, containing PaperCraft.app) and the command-line tools (`papercraft-cli-<version>-macos-<arch>.zip`, with `papercraft-cli` and `papercraft-mcp`) are attached to each release of [masi/papercraft](https://git.marcospaulo.dev.br/masi/papercraft/releases). Builds are ad-hoc signed and not notarized, so on another Mac open the app with right-click › Open the first time.
|
||||
|
||||
### Build from source
|
||||
|
||||
You need Rust 1.95 or newer (`rust-version` in `Cargo.toml`). Install it with [rustup](https://rustup.rs).
|
||||
|
||||
```sh
|
||||
cargo build --release
|
||||
./target/release/papercraft # projects home screen
|
||||
./target/release/papercraft --demo # sample planning board (not saved)
|
||||
./target/release/papercraft ~/Documents/papercraft/Plan # open a project folder
|
||||
./target/release/papercraft board.papercraft # open a document
|
||||
packaging/macos/package.sh # PaperCraft.app on a DMG, plus the CLI zip (macOS)
|
||||
```
|
||||
|
||||
**macOS:** `xcode-select --install` for the linker, then the commands above. It uses Metal through wgpu. Shortcuts use ⌘, and trackpad pinch zooms.
|
||||
|
||||
**Linux:** install the runtime libraries (`sudo apt install libxkbcommon-x11-0 libxcb-xkb1 libvulkan1 mesa-vulkan-drivers` on Debian/Ubuntu), then build. It runs on X11 and Wayland through Vulkan, or GL as a fallback. Shortcuts use Ctrl. File dialogs use the XDG desktop portal.
|
||||
|
||||
### Headless (servers, CI)
|
||||
|
||||
No display is needed. wgpu renders offscreen, through Mesa lavapipe when there is no GPU:
|
||||
|
||||
```sh
|
||||
papercraft --screenshot ui.png --demo --size 1440x900 --scale 2 # full UI
|
||||
papercraft --screenshot home.png --home # projects home screen
|
||||
papercraft --export-png out.png PROJECT_DIR --page 0 --scale 2 [--transparent]
|
||||
papercraft --export-svg out.svg PROJECT_DIR
|
||||
papercraft --export-pdf out.pdf PROJECT_DIR --frames # one PDF page per frame
|
||||
papercraft --export out_dir PROJECT_DIR --format png --scope frames # a folder of PNGs
|
||||
papercraft --import board.excalidraw # new project, prints its folder
|
||||
```
|
||||
|
||||
### Tests
|
||||
|
||||
```sh
|
||||
cargo test # unit, scenario, headless GPU and CLI tests
|
||||
PAPERCRAFT_REQUIRE_GPU=1 cargo test # fail (instead of skip) when no GPU adapter exists
|
||||
cargo test --release --test perf -- --nocapture # 10k-element frame timings
|
||||
```
|
||||
|
||||
* **Unit tests** cover geometry, vector paths, the rough.js port (compared with real
|
||||
rough.js 4.6.4 output), auto layout and constraints, booleans, rich text spans, the elbow
|
||||
router, formats and migrations, atomic writes, the project store, PDF structure, Excalidraw
|
||||
and SVG import, the palette's fuzzy matching and the shortcut table.
|
||||
* **Scenario tests** drive the editor with synthetic pointer and keyboard events:
|
||||
`src/editor/tests.rs` (core editing), `tests/projects.rs` (projects, autosave, pages),
|
||||
`tests/figma.rs` (groups, components, auto layout, constraints, pen, booleans, rich text,
|
||||
guides), `tests/excalidraw.rs` (hand-drawn style, arrows, quick-connect, stickies, laser,
|
||||
library), `tests/workflow.rs` (layers drag, palette, shortcuts), `tests/io.rs` (export,
|
||||
import, clipboard, CLI), `tests/mcp.rs` (every MCP tool on a headless session, then an rmcp
|
||||
client over HTTP and through the `paper-rs-mcp` stdio bridge).
|
||||
* **Headless GPU tests** render through wgpu, assert pixels and write the screenshots below.
|
||||
One rasterizes the SVG export with resvg and compares it with the GPU render; one renders
|
||||
the PDF export with pdftoppm when installed.
|
||||
The scenario tests drive the editor with synthetic pointer and keyboard events; `tests/mcp.rs` exercises every MCP tool on a headless session, then through an rmcp client over HTTP and through the `papercraft-mcp` stdio bridge. The SVG export is rasterized with resvg and compared with the GPU render.
|
||||
|
||||
| Milestone | Screenshot(s) in `docs/screenshots/` |
|
||||
|---|---|
|
||||
| 1. Local projects | `milestone1_home.png`, `milestone1_trash.png` |
|
||||
| 2. Pages | `milestone2_pages.png` |
|
||||
| 3. Figma core | `milestone3_figma_core.png`, `milestone3_canvas.png` |
|
||||
| 4. Excalidraw core | `milestone4_whiteboard.png` |
|
||||
| 5. Inspector, palette, shortcuts | `milestone5_inspector.png`, `milestone5_palette.png`, `milestone5_shortcuts.png` |
|
||||
| 6. Import / export | `milestone6_pdf_page.png` (PDF rasterized by pdftoppm), `milestone6_excalidraw_import.png`, `milestone6_svg_import.png` |
|
||||
| 7. MCP server | `milestone7_mcp.png` (a screen written through `write_html`, captured with `get_screenshot`), `milestone7_mcp_live_xvfb.png` (the live app after Claude Code drew into it over the stdio bridge) |
|
||||
| paper-rs 0.1 | `m1_*` … `m6_*`, `app_demo.png`, `x11_window_xvfb.png` |
|
||||
## Keyboard shortcuts
|
||||
|
||||
## Using it
|
||||
|
||||

|
||||
|
||||
The tools sit in the floating bar at the bottom of the canvas. Select a layer to edit it in
|
||||
the **Design** panel on the right. The **Layers** panel on the left shows the tree: click to
|
||||
select, Shift-click to add, double-click to rename, and use 👁 / 🔒 to hide or lock. The
|
||||
**Up/Down** buttons reorder.
|
||||
|
||||
* Draw a frame around existing shapes and it adopts them. Shapes drawn inside a frame become
|
||||
its children, and dragging a layer into or out of a frame re-parents it.
|
||||
* Top-level frames that contain content act as canvas regions. Drag from their body to
|
||||
marquee-select inside them, and drag the frame by its name label.
|
||||
* The pencil keeps a constant on-screen width at any zoom. With nothing selected, set width,
|
||||
smoothing and color in the Design panel.
|
||||
* Hold ⌘/Ctrl while dragging to suspend snapping.
|
||||
|
||||
### Keyboard shortcuts
|
||||
|
||||
⌘ on macOS, Ctrl on Linux and Windows. Press `?` or ⌘/ in the app for the full grouped list
|
||||
(100+ bindings), or ⌘K to search every command.
|
||||
⌘ on macOS, Ctrl on Linux and Windows. Press `?` or ⌘/ in the app for the full grouped list (100+ bindings), or ⌘K to search every command.
|
||||
|
||||
| Keys | Action | Keys | Action |
|
||||
|---|---|---|---|
|
||||
@@ -278,82 +296,25 @@ select, Shift-click to add, double-click to rename, and use 👁 / 🔒 to hide
|
||||
| ⌘= / ⌘- / ⇧0 | Zoom in / out / 100% | ⇧1 / ⇧2 | Zoom to fit / selection |
|
||||
| PageUp / PageDown | Previous / next page | ⌘\\ | Show / hide UI |
|
||||
|
||||
## Feature parity: Paper / Figma / Excalidraw vs paper-rs
|
||||
Draw a frame around existing shapes and it adopts them; shapes drawn inside a frame become its children, and dragging a layer into or out of a frame re-parents it. Hold ⌘/Ctrl while dragging to suspend snapping.
|
||||
|
||||
Columns 2–4 say whether each product has the feature (✓) and where the behavior was learned:
|
||||
Paper from its desktop shell and public docs (`analysis/PAPER_ARCHITECTURE.md`), Figma from
|
||||
public docs (`analysis/FIGMA_NOTES.md`), Excalidraw from its MIT source
|
||||
(`analysis/EXCALIDRAW_NOTES.md`). The last column is paper-rs: ✅ done, ⚠️ partial (see
|
||||
Known gaps), ⏳ planned in a later milestone, ❌ not planned.
|
||||
## Status
|
||||
|
||||
| Feature | Paper | Figma | Excalidraw | paper-rs |
|
||||
|---|---|---|---|---|
|
||||
| Local projects, recents grid, trash | (cloud dashboard) | ✓ (cloud) | (single file) | ✅ Folders in `~/Documents/paper-rs`, thumbnails, rename/duplicate/trash/restore |
|
||||
| Autosave, crash-safe, versioned files | ✓ (cloud) | ✓ (cloud) | ✓ (browser storage) | ✅ 1 s autosave, atomic writes, migration chain (page v1→v3) |
|
||||
| Pages, infinite canvas per page | ✓ | ✓ | – | ✅ Per-page history and camera, reorder/duplicate/delete |
|
||||
| Frames with clipping | ✓ | ✓ | ✓ | ✅ ⚠️ clip is rectangular (corner radius not clipped) |
|
||||
| Groups | ✓ | ✓ | ✓ | ✅ Bounds follow children, click selects group, double-click enters, scale on resize |
|
||||
| Components, instances, overrides | – | ✓ | (library only) | ✅ Live sync, per-property overrides, reset, detach, go to main |
|
||||
| Auto layout (direction, gap, padding, hug/fill/fixed, wrap) | ✓ (flexbox) | ✓ | – | ✅ Drag-reorder in flow, space-between, absolute children |
|
||||
| Constraints | – | ✓ | – | ✅ Left/right/stretch/center/scale per axis, ⌘ to ignore |
|
||||
| Pen tool, vector point editing | ✓ | ✓ | – | ✅ Corner/smooth points, handles (⌥ breaks), close, delete, toggle |
|
||||
| Boolean ops (union/subtract/intersect/exclude), flatten | (roadmap) | ✓ | – | ✅ ⚠️ destructive (result is a path, curves flattened) |
|
||||
| Gradients (linear, radial) | ✓ | ✓ | – | ✅ Multi-stop, angle, radius; ⚠️ no on-canvas gradient handles |
|
||||
| Image fills | ✓ | ✓ | ✓ (image element) | ✅ Fill/fit/stretch/tile, place image (⌘⇧K), drag-and-drop, stored in project |
|
||||
| Strokes: width, align, dash, caps, joins | ✓ | ✓ | ✓ | ✅ |
|
||||
| Corner radius (per corner), polygons, stars | ✓ | ✓ | – | ✅ |
|
||||
| Opacity, blend modes | ✓ | ✓ | ✓ (opacity) | ✅ ⚠️ canvas renders 5 modes exactly, all 16 exact in SVG |
|
||||
| Rich text (per-range bold/italic/size/colour) | ✓ | ✓ | – | ✅ Plus underline, strikethrough, line height, letter spacing |
|
||||
| Align / distribute | ✓ | ✓ | ✓ | ✅ ⌥A/D/W/S/H/V, ⌥⇧H/V |
|
||||
| Smart guides, spacing, measurement | ✓ | ✓ | – | ✅ Edge/center snap, equal spacing, ⌥-hover distances |
|
||||
| Hand-drawn (rough) style per element | – | – | ✓ | ✅ rough.js 4.6.4 port, verified against rough.js output; sloppiness, fill styles |
|
||||
| Bound arrows, elbow arrows, quick shapes, sticky notes | – | (FigJam) | ✓ | ✅ Orbit binding that follows shapes, straight/curved/elbow, 5 heads, diamond, quick-connect, stickies |
|
||||
| Laser pointer, shape library | – | (FigJam) | ✓ | ✅ Fading laser trail; shared library with starter items |
|
||||
| Inspector, layers drag-reorder, command palette (⌘K) | ✓ | ✓ | ✓ | ✅ Sectioned inspector, tree drag above/below/into, fuzzy palette over actions, pages, layers, library |
|
||||
| Export PNG / SVG / PDF of selection, frame, page | ✓ | ✓ | ✓ (PNG/SVG) | ✅ Selection, each frame, page or all pages; vector PDF with gradients, images, blend modes; copy as PNG/SVG |
|
||||
| Import .excalidraw / SVG, copy-paste across projects | – | ✓ (SVG) | ✓ | ✅ .excalidraw scenes, .excalidrawlib libraries, SVG (paths, gradients, images, text), system clipboard between pages, projects and apps |
|
||||
| Freehand ink with pressure | – | (pencil) | ✓ | ✅ Smoothing, pressure, eraser |
|
||||
| MCP server for AI agents | ✓ | ✓ (Dev Mode) | ✓ (community) | ✅ Streamable HTTP in the app + stdio bridge; Paper-compatible tool names, `write_html`, screenshots, whiteboard shapes |
|
||||
| Multiplayer, cloud sync, plugins | ✓ | ✓ | ✓ | ❌ local-first by design |
|
||||
> [!IMPORTANT]
|
||||
> **PaperCraft is in early alpha.** It is usable for planning, sketching and building screens, but it is not a replacement for a mature design tool. [ROADMAP.md](ROADMAP.md) lists what is missing, in priority order; [PROGRESS.md](PROGRESS.md) is the milestone history.
|
||||
|
||||
## Known gaps
|
||||
Known limits:
|
||||
|
||||
* **Paper's document model is HTML/CSS.** paper-rs uses geometric nodes with flexbox-like auto
|
||||
layout on frames; there are no variables, CSS export or code generation.
|
||||
* **Rendering approximations.** Frame clipping is a rectangle (scissor), so rounded frames
|
||||
don't clip their corners. Group opacity multiplies into children instead of compositing the
|
||||
group. Overlay, color dodge/burn, hard/soft light, difference, exclusion, hue, saturation,
|
||||
color and luminosity blend as Normal on the canvas (they are exact in SVG export).
|
||||
* **Booleans are destructive**: the result is a new vector path with flattened curves.
|
||||
Gradients are edited in the inspector only.
|
||||
* **Not run on macOS hardware yet.** The macOS build type-checks and uses only portable APIs
|
||||
(Metal via wgpu, winit, rfd), but it has not been launched on a Mac. On Linux, the real
|
||||
window was exercised under Xvfb; the screenshot is `x11_window_xvfb.png`.
|
||||
* **Pen pressure.** winit 0.30 does not expose tablet pressure for pen events that arrive as
|
||||
mouse input, which is common with macOS and Wacom tablets. Pressure comes from touch/pen
|
||||
`force` when the platform reports it. Otherwise it is simulated from stroke velocity.
|
||||
* **Text** uses Inter (regular, bold, italic, bold italic) with no shaping, so there are no
|
||||
ligatures, complex scripts or RTL, and no font picker. IME preedit is not displayed
|
||||
(committed text works).
|
||||
* **No rotation.** Nodes are axis-aligned boxes; rotated imports are baked into paths.
|
||||
* **Undo granularity.** Document renames from the Design panel are not undoable, and the
|
||||
selection is not part of history.
|
||||
* **Performance.** Nodes are tessellated once per zoom octave and cached; unchanged frames
|
||||
reuse the previous item list and GPU buffers, so panning uploads nothing. With 10k visible
|
||||
nodes a cached frame takes ~3 ms (release, `tests/perf.rs`); the first frame at a new zoom
|
||||
octave takes ~140 ms on a CPU. Not tuned for 100k+.
|
||||
* **PDF**: text is outlined (not selectable); gradient stop transparency uses the first
|
||||
stop's alpha; tiled image fills are drawn as repeated images.
|
||||
* **Import**: Excalidraw rotation is baked into paths (text rotation is dropped), bound text
|
||||
inside shapes stays a separate text layer (except sticky notes), and fonts map to Inter.
|
||||
SVG patterns, filters, masks and clip paths are not imported.
|
||||
* **MCP**: one project is open at a time, so `fileId` must name the open project (call
|
||||
`open_file` to switch; the user's window switches too). No design tokens, comments or AI
|
||||
image generation (Paper has them). Moving or duplicating layers between pages isn't
|
||||
supported. Remote `http(s)` images aren't fetched; pass a local path or a `data:` URI.
|
||||
CSS margin, grid, shadows and transforms are reported back as ignored styles.
|
||||
* **Linux file dialogs** need an XDG desktop portal. Without one, Save/Open dialogs return
|
||||
nothing. The headless CLI is unaffected.
|
||||
* **Text** is Inter only (regular, bold, italic, bold italic) with no shaping: no ligatures, complex scripts or RTL, and no font picker. IME preedit is not displayed (committed text works).
|
||||
* **Rendering approximations.** Frame clipping is a rectangle, so rounded frames don't clip their corners. Group opacity multiplies into children. Overlay, dodge, burn, light, difference, exclusion and the HSL blend modes draw as Normal on the canvas (they are exact in SVG export).
|
||||
* **Booleans are destructive**: the result is a new vector path with flattened curves.
|
||||
* **Variables are colors only** and apply to fills and text color; strokes and gradients keep fixed colors.
|
||||
* **`write_html`** ignores margin, grid, tables, shadows and transforms (it reports them as `ignoredStyles`). Remote `http(s)` images aren't fetched; pass a local path or a `data:` URI.
|
||||
* **MCP**: one project is open at a time, so `fileId` must name the open project. Moving or duplicating layers between pages isn't supported.
|
||||
* **Pen pressure.** winit 0.30 does not expose tablet pressure for pen events that arrive as mouse input, which is common with macOS and Wacom tablets. Otherwise it is simulated from stroke velocity.
|
||||
* **Linux file dialogs** need an XDG desktop portal. The headless CLI is unaffected.
|
||||
* **PDF** text is outlined (not selectable).
|
||||
|
||||
## Project layout
|
||||
|
||||
@@ -365,24 +326,38 @@ src/history.rs transactions, undo/redo
|
||||
src/layout.rs auto layout, constraints, group bounds
|
||||
src/ink.rs stroke smoothing, pressure, outline
|
||||
src/text.rs rich text layout and glyph outlines
|
||||
src/assets.rs image registry (content-hashed)
|
||||
src/project/ project store, manifest/page formats, migrations
|
||||
src/session.rs open project, autosave, thumbnails
|
||||
src/editor/ tools, structure (groups/components), vector ops, pages, text editing
|
||||
src/render/ draw ops → cached meshes → wgpu; headless capture
|
||||
src/io/ PNG, SVG, PDF, export scopes, Excalidraw + SVG import, clipboard,
|
||||
atomic writes
|
||||
src/connector.rs arrow bindings, elbow router, arrowheads
|
||||
src/library.rs shared shape library
|
||||
src/ui/ home screen, pages, layers, inspector
|
||||
src/app.rs winit application
|
||||
src/mcp/ MCP server (rmcp): tools, HTML/CSS mapping, HTTP service
|
||||
src/bin/paper-rs-mcp.rs stdio bridge to the running app
|
||||
analysis/ reverse-engineering findings and design (raw dumps are gitignored)
|
||||
docs/screenshots/ headless renders produced by `cargo test`
|
||||
src/io/ PNG, SVG, PDF, export scopes, Excalidraw + SVG import, clipboard
|
||||
src/mcp/ MCP server (rmcp): tools, HTML/CSS mapping, lint, HTTP service
|
||||
src/ui/ home screen, pages, layers, inspector, palette
|
||||
src/bin/ papercraft-cli (shell access) and papercraft-mcp (stdio bridge)
|
||||
skills/ agent skills: papercraft, papercraft-design
|
||||
analysis/ behaviour studies and design notes
|
||||
docs/ guides and headless screenshots produced by `cargo test`
|
||||
```
|
||||
|
||||
## License
|
||||
## Downloads
|
||||
|
||||
MIT OR Apache-2.0 for the code in this repository. Inter (Regular, Medium, SemiBold) is
|
||||
© The Inter Project Authors under the SIL Open Font License 1.1; see `assets/fonts/OFL.txt`.
|
||||
| Build | File | Notes |
|
||||
|---|---|---|
|
||||
| macOS app | `papercraft-<ver>-macos-<arch>.dmg` | PaperCraft.app, drag to Applications. Ad-hoc signed, not notarized |
|
||||
| Command-line tools | `papercraft-cli-<ver>-macos-<arch>.zip` | `papercraft-cli` and `papercraft-mcp` |
|
||||
|
||||
Releases: https://git.marcospaulo.dev.br/masi/papercraft/releases. Other platforms: build from source.
|
||||
|
||||
## License and credits
|
||||
|
||||
PaperCraft is dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE), at your option.
|
||||
|
||||
Bundled fonts, icons and other assets keep their own open licenses; each one is listed with its author, source and license in [ATTRIBUTION.md](ATTRIBUTION.md). Inter (Regular, Medium, SemiBold, Bold and italics) is © The Inter Project Authors under the SIL Open Font License 1.1 (`assets/fonts/OFL.txt`). A few algorithms are ported from MIT-licensed projects (rough.js, hachure-fill, Excalidraw); they are listed with their notices in [THIRD_PARTY.md](THIRD_PARTY.md).
|
||||
|
||||
The app icon is original artwork ([`assets/app-icon/`](assets/app-icon/README.md)).
|
||||
|
||||
### Reverse-engineered origin
|
||||
|
||||
PaperCraft is an independent, clean-room-style reimplementation. It was reverse engineered from [Paper](https://paper.design) by studying the behavior of the Paper desktop app (`analysis/PAPER_ARCHITECTURE.md`) with [REA](https://github.com/morluto/rea) and Claude Code; Figma and Excalidraw were studied from their public docs and, for Excalidraw, its MIT source (`analysis/`). Only *behavior* was studied: no Paper or Figma code, shaders, icons, fonts or assets are used or committed. PaperCraft is not affiliated with, sponsored by or endorsed by Paper or Figma; those names are used only to describe the workflows it is compatible with.
|
||||
|
||||
<sub>Photoshop, Illustrator and Adobe are trademarks of Adobe Inc.; Figma is a trademark of Figma, Inc.; Paper is a product of its respective owners. PaperCraft is an independent, open-source project.</sub>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# Roadmap
|
||||
|
||||
PaperCraft is an early alpha: a local-first infinite-canvas design app that agents can drive. This
|
||||
file lists what is still missing, in priority order, with an honest status. Done items are checked
|
||||
against `src/mcp/tools.rs` and the UI code (2026-10-12). Milestone history is in
|
||||
[PROGRESS.md](PROGRESS.md); known rendering and import limits are in the README.
|
||||
|
||||
Status: **done** (works today), **partial** (some of it works), **todo**.
|
||||
|
||||
## 1. Design systems (the current focus)
|
||||
|
||||
Agents can already build a design system through the tools; people cannot yet manage one in the UI.
|
||||
|
||||
| # | Item | Status | Notes |
|
||||
|---|---|---|---|
|
||||
| 1 | Inspector UI for variables | todo | Variables exist only through `get_variables` / `set_variables`. No panel to list, rename, edit or bind them. |
|
||||
| 2 | Inspector UI for components | todo | Create component, detach and go-to-main are shortcuts (⌘⌥K / ⌘⌥B); there is no Components panel or asset browser. |
|
||||
| 3 | Design lint | **done** | `lint_design`: WCAG contrast, 4px grid, type scale, tiny text, 44px targets, default names, hard-coded colors. |
|
||||
| 4 | Lint auto-fix | todo | `lint_design` suggests a fix per issue; nothing applies it. Safe candidates: snap spacing, rename layers, relink colors to variables. |
|
||||
| 5 | Lint in the UI | todo | Show issues on the canvas or in a panel, click to select. |
|
||||
| 6 | Variable modes | partial | Done through tools: `set_variables {mode}` stores values per mode and `set_variable_mode` switches the whole project. Not yet in the UI, and not per frame or page. |
|
||||
| 7 | Number variables (spacing, radius, font size) | todo | Variables are colors only, and only for fills and text color. |
|
||||
| 8 | Stroke and gradient variables | todo | Strokes and gradients keep fixed colors. |
|
||||
| 9 | Text styles | todo | Named type styles (Heading / L, Body / M) that update every text layer using them. |
|
||||
| 10 | Component variants and properties | todo | Variants are separate components named `Button / Primary`. No variant sets, boolean or text properties. |
|
||||
| 11 | Swap instance, detach by tool | partial | Detach is a shortcut in the UI. No `swap_instance` or `detach_instance` tool. |
|
||||
| 12 | Cross-page and shared libraries | todo | Components and instances must live on one page. |
|
||||
| 13 | Export design tokens | partial | `get_variables` with `format: "css"` or `"json"` returns tokens. No Tailwind or Style Dictionary output, no file export from the UI. |
|
||||
| 14 | Apply a design system to existing work | todo | Map hard-coded colors and sizes onto variables and components in one pass. |
|
||||
|
||||
## 2. Agent workflow
|
||||
|
||||
| # | Item | Status | Notes |
|
||||
|---|---|---|---|
|
||||
| 15 | MCP and CLI tool surface | **done** | 40 tools, one undo step per call, `papercraft-cli list / schema / call`. |
|
||||
| 16 | Agent skills | **done** | `skills/papercraft` and `skills/papercraft-design`. |
|
||||
| 17 | Accessibility annotations | todo | Reading order, roles and alt text on layers, checked by the linter. |
|
||||
| 18 | Code export from frames | todo | `get_jsx` returns inline-style JSX only. No React / HTML+CSS with variables, no Tailwind (the `tailwind` format is accepted and ignored). |
|
||||
| 19 | Plugin and skill marketplace | todo | Skills are copied by hand today. |
|
||||
| 20 | Screenshot diffing | todo | Compare two renders of a frame to confirm a change did what was asked. |
|
||||
| 21 | Moving nodes between pages | todo | `move_nodes` stays on one page. |
|
||||
| 22 | Remote images in `write_html` | todo | Only local paths and `data:` URIs. |
|
||||
|
||||
## 3. Design features
|
||||
|
||||
| # | Item | Status | Notes |
|
||||
|---|---|---|---|
|
||||
| 23 | Prototyping links and flows | todo | No click-through between frames. |
|
||||
| 24 | Responsive constraints and breakpoint preview | partial | Constraints (left, right, stretch, center, scale) exist in the editor; there is no breakpoint preview or per-size variants. |
|
||||
| 25 | Shadows, blur and effects | todo | CSS `box-shadow` and filters are reported as ignored. |
|
||||
| 26 | Margin and grid layout | todo | Flexbox only. |
|
||||
| 27 | Font choice | todo | Inter only. |
|
||||
| 28 | Rotation | todo | Nodes are axis-aligned. |
|
||||
| 29 | Overlay, dodge, burn, light blend modes on the canvas | todo | Exact in SVG and PDF export, approximated as Normal on the canvas. |
|
||||
| 30 | Rounded frame clipping | todo | Clipping is a rectangle. |
|
||||
| 31 | Non-destructive booleans | todo | The result is a flattened path. |
|
||||
|
||||
## 4. Files and collaboration
|
||||
|
||||
| # | Item | Status | Notes |
|
||||
|---|---|---|---|
|
||||
| 32 | Version history | todo | Undo per page and crash-safe autosave only; no named versions or restore. |
|
||||
| 33 | Figma import | todo | SVG and Excalidraw import exist; `.fig` does not. |
|
||||
| 34 | Multiplayer and cloud sync | todo | Local-first by design. If added, it must stay optional. |
|
||||
| 35 | Release builds for Windows and Linux | todo | The code targets them (wgpu, winit); packaging today is the macOS DMG and the `papercraft-cli` zip. |
|
||||
|
||||
## 5. Quality
|
||||
|
||||
| # | Item | Status | Notes |
|
||||
|---|---|---|---|
|
||||
| 36 | Pen pressure on macOS tablets | todo | winit 0.30 does not expose pressure for mouse-like pen events. |
|
||||
| 37 | Text shaping, ligatures, RTL | todo | No shaping. IME preedit is not shown. |
|
||||
| 38 | Performance beyond 10k visible nodes | todo | Cached frames take about 3 ms at 10k nodes; not tuned for 100k. |
|
||||
@@ -1,6 +1,6 @@
|
||||
# Third-party code and assets
|
||||
|
||||
paper-rs is MIT OR Apache-2.0. It ports a few algorithms from MIT-licensed projects (listed
|
||||
papercraft is MIT OR Apache-2.0. It ports a few algorithms from MIT-licensed projects (listed
|
||||
below with their copyright notices and license text) and bundles the Inter font (SIL OFL 1.1,
|
||||
`assets/fonts/OFL.txt`). Figma and Paper are proprietary: only their *behavior* was studied
|
||||
(`analysis/FIGMA_NOTES.md`, `analysis/PAPER_ARCHITECTURE.md`), and no code, shaders, icons or
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# PaperCraft app icon
|
||||
|
||||
An original design: a sheet of paper with a folded corner inside a selection frame with handles, and a pen nib drawing on it. Catppuccin palette: Crust/Mantle gradient tile, Text `#cdd6f4` sheet, Mauve `#cba6f7` fold and nib, Blue `#89b4fa` frame.
|
||||
`papercraft.svg` is the source (macOS tile with Apple's margin, 824/1024 of the canvas); regenerate the PNGs with `resvg papercraft.svg papercraft-1024.png -w 1024`, then `iconutil` for the `.icns`. The `.ico` is the tile cropped edge to edge.
|
||||
License: original artwork, MIT OR Apache-2.0 like the rest of the repository.
|
||||
|
Before Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 361 KiB |
@@ -0,0 +1,28 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
|
||||
<title>PaperCraft app icon</title>
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#313244"/><stop offset="1" stop-color="#181825"/></linearGradient>
|
||||
<linearGradient id="nib" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#d9bcfa"/><stop offset="1" stop-color="#b48ef0"/></linearGradient>
|
||||
<clipPath id="tile"><path d="M924.0,512.0 L923.9,608.0 L923.8,638.6 L923.5,660.9 L923.1,679.0 L922.6,694.5 L922.0,708.2 L921.2,720.5 L920.4,731.8 L919.4,742.2 L918.3,751.9 L917.1,761.0 L915.8,769.6 L914.4,777.6 L912.8,785.3 L911.2,792.6 L909.4,799.5 L907.5,806.1 L905.4,812.4 L903.3,818.5 L901.0,824.2 L898.5,829.8 L896.0,835.1 L893.3,840.2 L890.5,845.1 L887.6,849.8 L884.5,854.3 L881.2,858.7 L877.9,862.8 L874.3,866.8 L870.7,870.7 L866.8,874.3 L862.8,877.9 L858.7,881.2 L854.3,884.5 L849.8,887.6 L845.1,890.5 L840.2,893.3 L835.1,896.0 L829.8,898.5 L824.2,901.0 L818.5,903.3 L812.4,905.4 L806.1,907.5 L799.5,909.4 L792.6,911.2 L785.3,912.8 L777.6,914.4 L769.6,915.8 L761.0,917.1 L751.9,918.3 L742.2,919.4 L731.8,920.4 L720.5,921.2 L708.2,922.0 L694.5,922.6 L679.0,923.1 L660.9,923.5 L638.6,923.8 L608.0,923.9 L512.0,924.0 L416.0,923.9 L385.4,923.8 L363.1,923.5 L345.0,923.1 L329.5,922.6 L315.8,922.0 L303.5,921.2 L292.2,920.4 L281.8,919.4 L272.1,918.3 L263.0,917.1 L254.4,915.8 L246.4,914.4 L238.7,912.8 L231.4,911.2 L224.5,909.4 L217.9,907.5 L211.6,905.4 L205.5,903.3 L199.8,901.0 L194.2,898.5 L188.9,896.0 L183.8,893.3 L178.9,890.5 L174.2,887.6 L169.7,884.5 L165.3,881.2 L161.2,877.9 L157.2,874.3 L153.3,870.7 L149.7,866.8 L146.1,862.8 L142.8,858.7 L139.5,854.3 L136.4,849.8 L133.5,845.1 L130.7,840.2 L128.0,835.1 L125.5,829.8 L123.0,824.2 L120.7,818.5 L118.6,812.4 L116.5,806.1 L114.6,799.5 L112.8,792.6 L111.2,785.3 L109.6,777.6 L108.2,769.6 L106.9,761.0 L105.7,751.9 L104.6,742.2 L103.6,731.8 L102.8,720.5 L102.0,708.2 L101.4,694.5 L100.9,679.0 L100.5,660.9 L100.2,638.6 L100.1,608.0 L100.0,512.0 L100.1,416.0 L100.2,385.4 L100.5,363.1 L100.9,345.0 L101.4,329.5 L102.0,315.8 L102.8,303.5 L103.6,292.2 L104.6,281.8 L105.7,272.1 L106.9,263.0 L108.2,254.4 L109.6,246.4 L111.2,238.7 L112.8,231.4 L114.6,224.5 L116.5,217.9 L118.6,211.6 L120.7,205.5 L123.0,199.8 L125.5,194.2 L128.0,188.9 L130.7,183.8 L133.5,178.9 L136.4,174.2 L139.5,169.7 L142.8,165.3 L146.1,161.2 L149.7,157.2 L153.3,153.3 L157.2,149.7 L161.2,146.1 L165.3,142.8 L169.7,139.5 L174.2,136.4 L178.9,133.5 L183.8,130.7 L188.9,128.0 L194.2,125.5 L199.8,123.0 L205.5,120.7 L211.6,118.6 L217.9,116.5 L224.5,114.6 L231.4,112.8 L238.7,111.2 L246.4,109.6 L254.4,108.2 L263.0,106.9 L272.1,105.7 L281.8,104.6 L292.2,103.6 L303.5,102.8 L315.8,102.0 L329.5,101.4 L345.0,100.9 L363.1,100.5 L385.4,100.2 L416.0,100.1 L512.0,100.0 L608.0,100.1 L638.6,100.2 L660.9,100.5 L679.0,100.9 L694.5,101.4 L708.2,102.0 L720.5,102.8 L731.8,103.6 L742.2,104.6 L751.9,105.7 L761.0,106.9 L769.6,108.2 L777.6,109.6 L785.3,111.2 L792.6,112.8 L799.5,114.6 L806.1,116.5 L812.4,118.6 L818.5,120.7 L824.2,123.0 L829.8,125.5 L835.1,128.0 L840.2,130.7 L845.1,133.5 L849.8,136.4 L854.3,139.5 L858.7,142.8 L862.8,146.1 L866.8,149.7 L870.7,153.3 L874.3,157.2 L877.9,161.2 L881.2,165.3 L884.5,169.7 L887.6,174.2 L890.5,178.9 L893.3,183.8 L896.0,188.9 L898.5,194.2 L901.0,199.8 L903.3,205.5 L905.4,211.6 L907.5,217.9 L909.4,224.5 L911.2,231.4 L912.8,238.7 L914.4,246.4 L915.8,254.4 L917.1,263.0 L918.3,272.1 L919.4,281.8 L920.4,292.2 L921.2,303.5 L922.0,315.8 L922.6,329.5 L923.1,345.0 L923.5,363.1 L923.8,385.4 L923.9,416.0Z"/></clipPath>
|
||||
<filter id="sh" x="-20%" y="-20%" width="140%" height="150%"><feDropShadow dx="0" dy="14" stdDeviation="16" flood-color="#000" flood-opacity=".4"/></filter>
|
||||
</defs>
|
||||
<path d="M924.0,512.0 L923.9,608.0 L923.8,638.6 L923.5,660.9 L923.1,679.0 L922.6,694.5 L922.0,708.2 L921.2,720.5 L920.4,731.8 L919.4,742.2 L918.3,751.9 L917.1,761.0 L915.8,769.6 L914.4,777.6 L912.8,785.3 L911.2,792.6 L909.4,799.5 L907.5,806.1 L905.4,812.4 L903.3,818.5 L901.0,824.2 L898.5,829.8 L896.0,835.1 L893.3,840.2 L890.5,845.1 L887.6,849.8 L884.5,854.3 L881.2,858.7 L877.9,862.8 L874.3,866.8 L870.7,870.7 L866.8,874.3 L862.8,877.9 L858.7,881.2 L854.3,884.5 L849.8,887.6 L845.1,890.5 L840.2,893.3 L835.1,896.0 L829.8,898.5 L824.2,901.0 L818.5,903.3 L812.4,905.4 L806.1,907.5 L799.5,909.4 L792.6,911.2 L785.3,912.8 L777.6,914.4 L769.6,915.8 L761.0,917.1 L751.9,918.3 L742.2,919.4 L731.8,920.4 L720.5,921.2 L708.2,922.0 L694.5,922.6 L679.0,923.1 L660.9,923.5 L638.6,923.8 L608.0,923.9 L512.0,924.0 L416.0,923.9 L385.4,923.8 L363.1,923.5 L345.0,923.1 L329.5,922.6 L315.8,922.0 L303.5,921.2 L292.2,920.4 L281.8,919.4 L272.1,918.3 L263.0,917.1 L254.4,915.8 L246.4,914.4 L238.7,912.8 L231.4,911.2 L224.5,909.4 L217.9,907.5 L211.6,905.4 L205.5,903.3 L199.8,901.0 L194.2,898.5 L188.9,896.0 L183.8,893.3 L178.9,890.5 L174.2,887.6 L169.7,884.5 L165.3,881.2 L161.2,877.9 L157.2,874.3 L153.3,870.7 L149.7,866.8 L146.1,862.8 L142.8,858.7 L139.5,854.3 L136.4,849.8 L133.5,845.1 L130.7,840.2 L128.0,835.1 L125.5,829.8 L123.0,824.2 L120.7,818.5 L118.6,812.4 L116.5,806.1 L114.6,799.5 L112.8,792.6 L111.2,785.3 L109.6,777.6 L108.2,769.6 L106.9,761.0 L105.7,751.9 L104.6,742.2 L103.6,731.8 L102.8,720.5 L102.0,708.2 L101.4,694.5 L100.9,679.0 L100.5,660.9 L100.2,638.6 L100.1,608.0 L100.0,512.0 L100.1,416.0 L100.2,385.4 L100.5,363.1 L100.9,345.0 L101.4,329.5 L102.0,315.8 L102.8,303.5 L103.6,292.2 L104.6,281.8 L105.7,272.1 L106.9,263.0 L108.2,254.4 L109.6,246.4 L111.2,238.7 L112.8,231.4 L114.6,224.5 L116.5,217.9 L118.6,211.6 L120.7,205.5 L123.0,199.8 L125.5,194.2 L128.0,188.9 L130.7,183.8 L133.5,178.9 L136.4,174.2 L139.5,169.7 L142.8,165.3 L146.1,161.2 L149.7,157.2 L153.3,153.3 L157.2,149.7 L161.2,146.1 L165.3,142.8 L169.7,139.5 L174.2,136.4 L178.9,133.5 L183.8,130.7 L188.9,128.0 L194.2,125.5 L199.8,123.0 L205.5,120.7 L211.6,118.6 L217.9,116.5 L224.5,114.6 L231.4,112.8 L238.7,111.2 L246.4,109.6 L254.4,108.2 L263.0,106.9 L272.1,105.7 L281.8,104.6 L292.2,103.6 L303.5,102.8 L315.8,102.0 L329.5,101.4 L345.0,100.9 L363.1,100.5 L385.4,100.2 L416.0,100.1 L512.0,100.0 L608.0,100.1 L638.6,100.2 L660.9,100.5 L679.0,100.9 L694.5,101.4 L708.2,102.0 L720.5,102.8 L731.8,103.6 L742.2,104.6 L751.9,105.7 L761.0,106.9 L769.6,108.2 L777.6,109.6 L785.3,111.2 L792.6,112.8 L799.5,114.6 L806.1,116.5 L812.4,118.6 L818.5,120.7 L824.2,123.0 L829.8,125.5 L835.1,128.0 L840.2,130.7 L845.1,133.5 L849.8,136.4 L854.3,139.5 L858.7,142.8 L862.8,146.1 L866.8,149.7 L870.7,153.3 L874.3,157.2 L877.9,161.2 L881.2,165.3 L884.5,169.7 L887.6,174.2 L890.5,178.9 L893.3,183.8 L896.0,188.9 L898.5,194.2 L901.0,199.8 L903.3,205.5 L905.4,211.6 L907.5,217.9 L909.4,224.5 L911.2,231.4 L912.8,238.7 L914.4,246.4 L915.8,254.4 L917.1,263.0 L918.3,272.1 L919.4,281.8 L920.4,292.2 L921.2,303.5 L922.0,315.8 L922.6,329.5 L923.1,345.0 L923.5,363.1 L923.8,385.4 L923.9,416.0Z" fill="url(#bg)"/>
|
||||
<g clip-path="url(#tile)">
|
||||
<!-- selection frame -->
|
||||
<rect x="282" y="212" width="460" height="600" fill="none" stroke="#89b4fa" stroke-width="22"/>
|
||||
<!-- sheet -->
|
||||
<g filter="url(#sh)">
|
||||
<path d="M322 252H600L702 354V772H322Z" fill="#cdd6f4"/>
|
||||
</g>
|
||||
<path d="M600 252L702 354H600Z" fill="#cba6f7"/>
|
||||
<rect x="382" y="440" width="260" height="30" rx="15" fill="#9399b2" opacity=".6"/>
|
||||
<rect x="382" y="512" width="260" height="30" rx="15" fill="#9399b2" opacity=".6"/>
|
||||
<rect x="254" y="184" width="56" height="56" rx="6" fill="#1e1e2e" stroke="#89b4fa" stroke-width="22"/><rect x="714" y="184" width="56" height="56" rx="6" fill="#1e1e2e" stroke="#89b4fa" stroke-width="22"/><rect x="254" y="784" width="56" height="56" rx="6" fill="#1e1e2e" stroke="#89b4fa" stroke-width="22"/><rect x="714" y="784" width="56" height="56" rx="6" fill="#1e1e2e" stroke="#89b4fa" stroke-width="22"/>
|
||||
<!-- pen nib -->
|
||||
<g transform="translate(470 580) rotate(135) scale(1.0)" filter="url(#sh)">
|
||||
<path d="M0 0L-52 -120Q-70 -170 -44 -214H44Q70 -170 52 -120Z" fill="url(#nib)" stroke="#1e1e2e" stroke-width="22" stroke-linejoin="round"/>
|
||||
<path d="M0 0V-104" stroke="#1e1e2e" stroke-width="12" stroke-linecap="round"/>
|
||||
<circle cx="0" cy="-132" r="17" fill="#1e1e2e"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.1 KiB |
@@ -0,0 +1,86 @@
|
||||
# Driving PaperCraft with agents
|
||||
|
||||
PaperCraft exposes one set of tools (40 at the time of writing) three ways. They all run the same code inside the app, so an agent sees what you see and every call is one undo step.
|
||||
|
||||
| Way | Use it when | Needs the app running |
|
||||
|---|---|---|
|
||||
| MCP over HTTP, `http://127.0.0.1:29980/mcp` | the agent client speaks MCP (Claude Code, Codex, pi) | yes |
|
||||
| MCP over stdio, `papercraft-mcp` | the client only starts stdio servers | for calls; `tools/list` works offline |
|
||||
| Shell, `papercraft-cli` | the agent has a terminal and you want no tool schemas in its context | for `call`; `list`, `schema` and `icons` work offline |
|
||||
|
||||
## Install
|
||||
|
||||
1. Get the app and the tools: the DMG and the `papercraft-cli` zip from the [releases](https://git.marcospaulo.dev.br/masi/papercraft/releases), or `cargo build --release` (binaries `papercraft`, `papercraft-cli`, `papercraft-mcp` in `target/release/`).
|
||||
2. Put `papercraft-cli` and `papercraft-mcp` on your `PATH` (for example in `~/.local/bin`).
|
||||
3. Start PaperCraft and open or create a project. The server starts with the app. `PAPERCRAFT_MCP_PORT` changes the port (the CLI and the bridge read it too); `PAPERCRAFT_MCP=0` turns the server off.
|
||||
|
||||
Check the connection:
|
||||
|
||||
```sh
|
||||
papercraft-cli call get_basic_info # project, page, artboards
|
||||
```
|
||||
|
||||
Exit status 1 with "isn't running" means the app is closed or the port differs.
|
||||
|
||||
## Register with an agent
|
||||
|
||||
Claude Code:
|
||||
|
||||
```sh
|
||||
claude mcp add --transport http --scope user papercraft http://127.0.0.1:29980/mcp
|
||||
# or through the stdio bridge
|
||||
claude mcp add --scope user papercraft -- /path/to/papercraft-mcp
|
||||
```
|
||||
|
||||
Codex:
|
||||
|
||||
```sh
|
||||
codex mcp add papercraft --url http://127.0.0.1:29980/mcp
|
||||
codex mcp add papercraft -- /path/to/papercraft-mcp # stdio alternative
|
||||
```
|
||||
|
||||
pi (and other agents without built-in MCP): use the shell. Tell the agent to run `papercraft-cli list`, then `papercraft-cli schema <tool>` for the tools it needs, and install the skills below.
|
||||
|
||||
Any other MCP client: point it at the HTTP URL, or run `papercraft-mcp` as a stdio server.
|
||||
|
||||
## Install the skills
|
||||
|
||||
Two agent skills in [`skills/`](../skills) teach the workflow and the design rules:
|
||||
|
||||
* `papercraft`: how to drive the tools (connection check, build, screenshot, lint, fix loop, naming, layout recipes, known limits).
|
||||
* `papercraft-design`: how to make the result look good (a direction first, 4/8pt spacing, a type scale, contrast, states, components and tokens).
|
||||
|
||||
Link them where your agent looks for skills, for example `ln -s "$PWD/skills/papercraft" ~/.agents/skills/papercraft` (same for `papercraft-design`).
|
||||
|
||||
## First prompts
|
||||
|
||||
* "In PaperCraft, create a 390×844 mobile artboard and design a sign-in screen. Use variables for every color, build the button as a component, and run `lint_design` before you finish."
|
||||
* "Add a dark mode to this project: define Light and Dark values for every variable and switch with `set_variable_mode`."
|
||||
* "Draw a flowchart of our deploy process on a new page, hand-drawn style."
|
||||
* "Lint this page and fix every warning."
|
||||
|
||||
## A session by hand
|
||||
|
||||
```sh
|
||||
export PATH="$PWD/target/release:$PATH"
|
||||
papercraft-cli call get_basic_info
|
||||
papercraft-cli call set_variables '{"variables":{"bg":"#1e1e2e","text":"#cdd6f4","accent":"#cba6f7"}}'
|
||||
papercraft-cli call create_artboard '{"name":"Hello","styles":{"width":"390px","height":"844px","backgroundColor":"var(--bg)","padding":"24px"}}'
|
||||
# -> {"id":"abc123:1",...}
|
||||
echo '{"targetNodeId":"abc123:1","mode":"insert-children","html":"<div layer-name=\"Title\" style=\"font-size:32px;font-weight:700;color:var(--text)\">Hello</div>"}' | papercraft-cli call write_html -
|
||||
papercraft-cli call lint_design '{"nodeId":"abc123:1"}'
|
||||
papercraft-cli call get_screenshot '{"nodeId":"abc123:1"}' --out hello.png
|
||||
papercraft-cli call finish_working_on_nodes '{"nodeIds":["abc123:1"]}'
|
||||
```
|
||||
|
||||
Pass JSON inline, or `-` to read it from stdin (best for HTML, which is full of quotes; `jq -Rs` can wrap a file into the right JSON). Text results go to stdout. Images are saved to `--out` (default `<tool>.png`) and the path is printed.
|
||||
|
||||
## Safety
|
||||
|
||||
The server listens on loopback only, rejects requests that carry an `Origin` header (browsers) and foreign `Host` headers. Anything on your machine that can reach `127.0.0.1:29980` can edit the open project, so do not forward the port. Undo (⌘Z, or the `undo` tool) reverts any agent edit.
|
||||
|
||||
## Reference
|
||||
|
||||
* [Tool reference](../skills/papercraft/references/tools.md), generated from `papercraft-cli schema`.
|
||||
* [Design systems](design-system.md): variables, components, icons, lint.
|
||||
* `papercraft-cli --help`-style usage: run `papercraft-cli` with no arguments.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Design systems in PaperCraft
|
||||
|
||||
A PaperCraft project can hold a small design system: color variables, components with instances, and icon sets. Today these are built and managed through the tools (MCP or `papercraft-cli`); the editor shows the results but has no panel for them yet (see [ROADMAP.md](../ROADMAP.md)).
|
||||
|
||||
## Variables
|
||||
|
||||
Named colors per project. Define them once, use them as `var(--name)`:
|
||||
|
||||
```sh
|
||||
papercraft-cli call set_variables '{"variables":{"surface":"#313244","accent":"#cba6f7","on-accent":"#11111b"}}'
|
||||
```
|
||||
|
||||
* Use `var(--name)` in `write_html`, `update_styles` and `create_artboard` for `background`, `backgroundColor` and `color`. The fill stays linked and the link is saved in the file.
|
||||
* Calling `set_variables` again with a new value moves every linked fill, on every page, in one undo step.
|
||||
* **Modes.** `set_variables {"mode":"Dark", ...}` stores one set of values for a mode; define another mode the same way. The first mode you define is active. `set_variable_mode {"mode":"Light"}` switches the whole project.
|
||||
* `get_variables` lists the variables and modes. With `"format":"css"` it returns a `:root { --name: #hex }` block, with `"json"` a design-tokens object (`{"accent":{"$type":"color","$value":"#cba6f7"}}`).
|
||||
* **Limits.** Colors only. Fills and text color follow variables; strokes and gradients get the resolved color at write time and do not follow later changes. No spacing, radius or font-size variables yet.
|
||||
|
||||
Naming that works: roles, not hues (`surface`, `text-muted`, `accent`, `on-accent`, `danger`), so a mode can change the value without changing the meaning.
|
||||
|
||||
## Components and instances
|
||||
|
||||
```sh
|
||||
papercraft-cli call create_component '{"nodeId":"<frame>","name":"Button / Primary"}'
|
||||
papercraft-cli call create_instance '{"componentId":"<id>","parentId":"<frame>","name":"Button / Sign in"}'
|
||||
papercraft-cli call set_text_content '{"updates":[{"nodeId":"<label layer id from create_instance>","textContent":"Sign in"}]}'
|
||||
```
|
||||
|
||||
* Build the main component as a frame (usually its own artboard), then convert it. Name it `Component / Variant`.
|
||||
* `create_instance` returns the instance id and its layers, so you can set text and styles on them. Changes made through `set_text_content` or `update_styles` on an instance are recorded as overrides; everything else follows the main component.
|
||||
* In `write_html`, `<x-paper-instance component-id="<id>" layer-name="Button / Save"/>` places an instance inline.
|
||||
* `get_components` lists main components and how many instances each has.
|
||||
* **Limits.** Components and their instances must be on the same page. No variants or properties: model variants as separate components (`Button / Primary`, `Button / Secondary`). In the editor, detach with ⌘⌥B; there is no detach or swap tool.
|
||||
|
||||
## Icons
|
||||
|
||||
`papercraft-cli icons add phosphor lucide` downloads sets from the npm registry into the icons folder (`<data dir>/papercraft/icons/<set>/`, or `$PAPERCRAFT_ICONS`), each with its license file. Sets: Lucide (ISC), Tabler (MIT), Phosphor (MIT), Heroicons (MIT), Material Symbols (Apache-2.0). Find names with `papercraft-cli icons search arrow left` (offline) or the `search_icons` tool, then place one in `write_html`:
|
||||
|
||||
```html
|
||||
<x-paper-icon name="phosphor:gear" size="20" color="var(--accent)"/>
|
||||
```
|
||||
|
||||
Fill-based sets (Phosphor, Material) follow variables; Lucide is stroke-based and keeps a fixed color.
|
||||
|
||||
## Lint
|
||||
|
||||
`lint_design` checks a subtree or the page and returns issues with a rule, a severity and a fix:
|
||||
|
||||
| Rule | Severity | Checks |
|
||||
|---|---|---|
|
||||
| `contrast` | error | WCAG 4.5:1 for text, 3:1 for large text (24px+, or 18.66px+ bold), against the nearest opaque fill behind it |
|
||||
| `text-size` | warning | text under 12px |
|
||||
| `type-scale` | info | font size not on the scale (default 12, 14, 16, 20, 24, 32, 40, 48, 64; pass `scale` to change) |
|
||||
| `spacing-grid` | warning | gap or padding not a multiple of 4 |
|
||||
| `touch-target` | warning | a frame named `Button…`, `Tab /…` or `Tab Bar /…` under 44px tall |
|
||||
| `layer-name` | warning | default names (Frame, Rectangle, Group...) on layers with children |
|
||||
| `hard-coded-color` | warning, info | a literal color that equals a variable (warning), or a literal used repeatedly when variables exist (info) |
|
||||
|
||||
Instances are skipped (their main component is linted). The rules are conventions, not law: pass `scale` to match your own type scale and ignore `info` items you disagree with. Lint does not judge taste; always look at `get_screenshot` too.
|
||||
|
||||
## Worked example
|
||||
|
||||
[`examples/taskflow.py`](../examples/taskflow.py) builds an 8-screen app: a Catppuccin theme as variables (the only place hex appears), components for buttons, fields, chips, task rows, toggles and tab bars, Phosphor icons, and a design-system page of swatches. Run it against the running app after `papercraft-cli icons add phosphor`.
|
||||
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 23 KiB |
@@ -1,18 +1,30 @@
|
||||
"""TaskFlow v2: variables (no hex in the file), components with instances, phosphor icons.
|
||||
Run against the running app (needs `paper-rs-cli icons add phosphor`): python3 examples/taskflow.py"""
|
||||
Run against the running app (needs `papercraft-cli icons add phosphor`): python3 examples/taskflow.py"""
|
||||
import json, os, subprocess, sys
|
||||
|
||||
CLI = os.environ.get("PAPER_RS_CLI", "paper-rs-cli")
|
||||
ENV = {**os.environ, "PAPER_RS_MCP_PORT": os.environ.get("PAPER_RS_MCP_PORT", "29980")}
|
||||
CLI = os.environ.get("PAPERCRAFT_CLI", "papercraft-cli")
|
||||
ENV = {**os.environ, "PAPERCRAFT_MCP_PORT": os.environ.get("PAPERCRAFT_MCP_PORT", "29980")}
|
||||
|
||||
# The only place colors are written as hex: the theme (Catppuccin Mocha). Everything else is var(--x).
|
||||
THEME = {
|
||||
"base": "#1e1e2e", "mantle": "#181825", "crust": "#11111b",
|
||||
"surface": "#313244", "surface-2": "#45475a", "surface-3": "#585b70",
|
||||
"text": "#cdd6f4", "subtext": "#a6adc8", "muted": "#6c7086",
|
||||
"accent": "#cba6f7", "on-accent": "#11111b",
|
||||
"success": "#a6e3a1", "danger": "#f38ba8", "warning": "#fab387", "info": "#89b4fa",
|
||||
# The only place colors are written as hex: the theme, one value set per Catppuccin flavour ("mode").
|
||||
# Everything else in the file is var(--x), so `papercraft-cli call set_variable_mode '{"mode":"Latte"}'`
|
||||
# turns the whole design light.
|
||||
def flavour(base, mantle, crust, s0, s1, s2, text, sub, muted, mauve, on_accent, green, red, peach, blue):
|
||||
return {"base": base, "mantle": mantle, "crust": crust, "surface": s0, "surface-2": s1, "surface-3": s2,
|
||||
"text": text, "subtext": sub, "muted": muted, "accent": mauve, "on-accent": on_accent,
|
||||
"success": green, "danger": red, "warning": peach, "info": blue}
|
||||
|
||||
|
||||
MODES = { # the first mode is the active one
|
||||
"Mocha": flavour("#1e1e2e", "#181825", "#11111b", "#313244", "#45475a", "#585b70", "#cdd6f4", "#a6adc8", "#6c7086",
|
||||
"#cba6f7", "#11111b", "#a6e3a1", "#f38ba8", "#fab387", "#89b4fa"),
|
||||
"Macchiato": flavour("#24273a", "#1e2030", "#181926", "#363a4f", "#494d64", "#5b6078", "#cad3f5", "#a5adcb", "#6e738d",
|
||||
"#c6a0f6", "#181926", "#a6da95", "#ed8796", "#f5a97f", "#8aadf4"),
|
||||
"Frappé": flavour("#303446", "#292c3c", "#232634", "#414559", "#51576d", "#626880", "#c6d0f5", "#a5adce", "#737994",
|
||||
"#ca9ee6", "#232634", "#a6d189", "#e78284", "#ef9f76", "#8caaee"),
|
||||
"Latte": flavour("#eff1f5", "#e6e9ef", "#dce0e8", "#ccd0da", "#bcc0cc", "#acb0be", "#4c4f69", "#6c6f85", "#9ca0b0",
|
||||
"#8839ef", "#eff1f5", "#40a02b", "#d20f39", "#fe640b", "#1e66f5"),
|
||||
}
|
||||
THEME = MODES["Mocha"]
|
||||
V = {k: f"var(--{k})" for k in THEME}
|
||||
|
||||
|
||||
@@ -116,7 +128,8 @@ class Project:
|
||||
info = call("open_file", {"fileId": self.file})
|
||||
self.first_page = info["pages"][0]["id"]
|
||||
self.used_first = False
|
||||
call("set_variables", {"fileId": self.file, "variables": THEME})
|
||||
for mode, values in MODES.items():
|
||||
call("set_variables", {"fileId": self.file, "mode": mode, "variables": values})
|
||||
|
||||
def page(self, name):
|
||||
if not self.used_first:
|
||||
@@ -230,7 +243,7 @@ def build():
|
||||
pg.inst("Chip / Default", filters, {"Label": "Done"}, name="Chip / Done")
|
||||
lst = pg.add(content, col("Task List", "", gap="10px"))[0]
|
||||
for title, meta, done, color in [("Write the spec", "Work · 9:00", True, "success"), ("Review pull requests", "Work · 10:30", True, "success"),
|
||||
("Ship paper-rs-cli", "Side project · 14:00", False, "danger"),
|
||||
("Ship papercraft-cli", "Side project · 14:00", False, "danger"),
|
||||
("Reply to emails", "Inbox · 16:00", False, "warning"), ("Plan next sprint", "Work · 17:30", False, "info")]:
|
||||
pg.inst("Task / Done" if done else "Task / Open", lst, {"Title": title, "Meta": meta},
|
||||
{"Priority": {"backgroundColor": V[color]}}, name=f"Task / {title}")
|
||||
@@ -258,7 +271,7 @@ def build():
|
||||
det = pg.add(ab, col("Detail",
|
||||
row("Nav", row("Back", icon("caret-left", 18, V["accent"]) + text("Label", "Back", 15, V["accent"], 600), gap="2px") +
|
||||
text("Edit", "Edit", 15, V["accent"], 600), justify_content="space-between", width="342px") +
|
||||
text("Title", "Ship paper-rs-cli", 28, V["text"], 800) +
|
||||
text("Title", "Ship papercraft-cli", 28, V["text"], 800) +
|
||||
col("Meta", "".join(row(f"Meta / {k}", text("Key", k, 14, V["subtext"]) + text("Value", v, 14, c, 600),
|
||||
justify_content="space-between", padding="14px") for k, v, c in
|
||||
[("Due", "Today, 14:00", V["text"]), ("Project", "Side project", V["text"]), ("Priority", "High", V["danger"])]),
|
||||
@@ -309,7 +322,10 @@ def build():
|
||||
gap="16px", padding="12px 24px", width="390px"))[0]
|
||||
fl = next(k["id"] for k in pg.c("get_children", nodeId=cont)["children"] if k["name"] == "Flavours")
|
||||
for n in ("Latte", "Frappé", "Macchiato", "Mocha"):
|
||||
pg.inst("Flavour / Selected" if n == "Mocha" else "Flavour / Default", fl, {"Name": f"Catppuccin {n}"}, name=f"Flavour / {n}")
|
||||
look = MODES[n] # each swatch shows its own flavour, so these two colors are literal on purpose
|
||||
pg.inst("Flavour / Selected" if n == "Mocha" else "Flavour / Default", fl, {"Name": f"Catppuccin {n}"},
|
||||
{"Base": {"backgroundColor": look["base"]}, "Accent": {"backgroundColor": look["accent"]}},
|
||||
name=f"Flavour / {n}")
|
||||
|
||||
# ------------------------------------------------------------------- Design system
|
||||
pg = pr.page("Design system")
|
||||
|
||||
@@ -6,13 +6,13 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>paper-rs</string>
|
||||
<string>PaperCraft</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>paper-rs</string>
|
||||
<string>PaperCraft</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>paper-rs</string>
|
||||
<string>PaperCraft</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>dev.paper-rs.app</string>
|
||||
<string>dev.papercraft.app</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
@@ -22,7 +22,7 @@
|
||||
<key>CFBundleVersion</key>
|
||||
<string>@SHORT_VERSION@</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>paper-rs</string>
|
||||
<string>papercraft</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.graphics-design</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
@@ -32,6 +32,6 @@
|
||||
<key>NSSupportsAutomaticGraphicsSwitching</key>
|
||||
<true/>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>Copyright © the paper-rs authors. MIT OR Apache-2.0.</string>
|
||||
<string>Copyright © the PaperCraft authors. MIT OR Apache-2.0.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build and sign (ad-hoc by default) the macOS artifacts into dist/release:
|
||||
# paper-rs-<version>-macos-<arch>.dmg paper-rs.app on a drag-to-Applications DMG
|
||||
# paper-rs-cli-<version>-macos-<arch>.zip paper-rs-cli (shell access for agents) and paper-rs-mcp (stdio bridge)
|
||||
# papercraft-<version>-macos-<arch>.dmg PaperCraft.app on a drag-to-Applications DMG
|
||||
# papercraft-cli-<version>-macos-<arch>.zip papercraft-cli (shell access for agents) and papercraft-mcp (stdio bridge)
|
||||
# Usage: packaging/macos/package.sh [--arch universal|aarch64|x86_64] [--skip-build]
|
||||
# Env: MACOS_SIGN_IDENTITY (default "-" = ad-hoc, not notarized; Gatekeeper rejects it on other Macs).
|
||||
set -euo pipefail
|
||||
@@ -29,9 +29,9 @@ export MACOSX_DEPLOYMENT_TARGET=11.0 # keep in sync with LSMinimumSystemVersio
|
||||
IDENTITY="${MACOS_SIGN_IDENTITY:--}"
|
||||
SHORT_VERSION="${VERSION%%-*}"
|
||||
WORK="$CARGO_TARGET_DIR/macos-package"
|
||||
APP="$WORK/paper-rs.app"
|
||||
DMG="$DIST/paper-rs-$VERSION-macos-$ARCH.dmg"
|
||||
MCP_ZIP="$DIST/paper-rs-cli-$VERSION-macos-$ARCH.zip"
|
||||
APP="$WORK/PaperCraft.app"
|
||||
DMG="$DIST/papercraft-$VERSION-macos-$ARCH.dmg"
|
||||
MCP_ZIP="$DIST/papercraft-cli-$VERSION-macos-$ARCH.zip"
|
||||
[ "$IDENTITY" = "-" ] && echo "warning: ad-hoc signing; artifacts are not notarized" >&2
|
||||
|
||||
if [ "$SKIP_BUILD" = 0 ]; then
|
||||
@@ -39,7 +39,7 @@ if [ "$SKIP_BUILD" = 0 ]; then
|
||||
(cd "$ROOT" && cargo build --release --locked --bins "${args[@]}")
|
||||
fi
|
||||
rm -rf "$WORK"; mkdir -p "$WORK/bin" "$DIST"
|
||||
for bin in paper-rs paper-rs-mcp paper-rs-cli; do
|
||||
for bin in papercraft papercraft-mcp papercraft-cli; do
|
||||
inputs=(); for t in "${TARGETS[@]}"; do inputs+=("$CARGO_TARGET_DIR/$t/release/$bin"); done
|
||||
lipo -create -output "$WORK/bin/$bin" "${inputs[@]}"
|
||||
done
|
||||
@@ -51,30 +51,30 @@ sign() {
|
||||
|
||||
echo "==> assembling $APP"
|
||||
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
|
||||
cp "$WORK/bin/paper-rs" "$APP/Contents/MacOS/paper-rs"
|
||||
cp "$ROOT/assets/app-icon/paper-rs.icns" "$APP/Contents/Resources/paper-rs.icns"
|
||||
cp "$WORK/bin/papercraft" "$APP/Contents/MacOS/PaperCraft"
|
||||
cp "$ROOT/assets/app-icon/papercraft.icns" "$APP/Contents/Resources/papercraft.icns"
|
||||
cp "$ROOT"/LICENSE-* "$APP/Contents/Resources/"
|
||||
sed -e "s/@VERSION@/$VERSION/g" -e "s/@SHORT_VERSION@/$SHORT_VERSION/g" "$HERE/Info.plist.in" >"$APP/Contents/Info.plist"
|
||||
plutil -lint "$APP/Contents/Info.plist"
|
||||
printf 'APPL????' >"$APP/Contents/PkgInfo"
|
||||
sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP/Contents/MacOS/paper-rs"
|
||||
sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP/Contents/MacOS/PaperCraft"
|
||||
sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP"
|
||||
codesign --verify --strict --deep --verbose=2 "$APP"
|
||||
|
||||
echo "==> building $DMG"
|
||||
STAGE="$WORK/dmg"; mkdir -p "$STAGE"
|
||||
ditto "$APP" "$STAGE/paper-rs.app"
|
||||
ditto "$APP" "$STAGE/PaperCraft.app"
|
||||
ln -s /Applications "$STAGE/Applications"
|
||||
rm -f "$DMG" "$WORK/raw.dmg"
|
||||
# makehybrid + convert needs no mounted device (create -srcfolder is flaky on CI and in sandboxes).
|
||||
hdiutil makehybrid -hfs -hfs-volume-name "paper-rs" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE"
|
||||
hdiutil makehybrid -hfs -hfs-volume-name "PaperCraft" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE"
|
||||
hdiutil convert "$WORK/raw.dmg" -format UDZO -imagekey zlib-level=9 -o "$DMG"
|
||||
rm -f "$WORK/raw.dmg"
|
||||
sign "$DMG"; codesign --verify --strict --verbose=2 "$DMG"
|
||||
|
||||
echo "==> building $MCP_ZIP"
|
||||
MCP_DIR="$WORK/paper-rs-cli-$VERSION-macos-$ARCH"; mkdir -p "$MCP_DIR"
|
||||
cp "$WORK/bin/paper-rs-mcp" "$WORK/bin/paper-rs-cli" "$ROOT/README.md" "$ROOT"/LICENSE-* "$MCP_DIR/"
|
||||
sign --options runtime "$MCP_DIR/paper-rs-mcp" "$MCP_DIR/paper-rs-cli"
|
||||
MCP_DIR="$WORK/papercraft-cli-$VERSION-macos-$ARCH"; mkdir -p "$MCP_DIR"
|
||||
cp "$WORK/bin/papercraft-mcp" "$WORK/bin/papercraft-cli" "$ROOT/README.md" "$ROOT"/LICENSE-* "$MCP_DIR/"
|
||||
sign --options runtime "$MCP_DIR/papercraft-mcp" "$MCP_DIR/papercraft-cli"
|
||||
rm -f "$MCP_ZIP"; ditto -c -k --keepParent "$MCP_DIR" "$MCP_ZIP"
|
||||
echo "==> done"; ls -lh "$DMG" "$MCP_ZIP"
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
---
|
||||
name: papercraft-design
|
||||
description: Design-quality rules for work made in PaperCraft (or any flexbox-based design tool): commit to an aesthetic direction, tokens before pixels, a 4/8pt spacing scale, a type scale, contrast and touch-target minimums, visual hierarchy, consistent radii, components, and every screen state. Use when designing or reviewing screens, landing pages or dashboards in PaperCraft, when a design looks generic, or before finishing a design. Use together with the papercraft skill, which has the tool mechanics.
|
||||
---
|
||||
|
||||
# papercraft-design
|
||||
|
||||
Rules for making designs that look intentional, adapted to what PaperCraft can do: flexbox layout, Inter as the only font, color variables, components, `lint_design`. Mechanics (tools, loop, limits) are in the `papercraft` skill. Sources: Figma's agent skills (variables, auto layout, components, design-system audits, spacing snapped to a scale), Anthropic's frontend-design guidance (commit to a direction, avoid generic AI aesthetics) and common UI practice (4/8pt grid, WCAG contrast, 44px targets).
|
||||
|
||||
## 0. Before you draw anything: commit to a direction
|
||||
|
||||
Write down, in a few lines, before the first tool call:
|
||||
|
||||
* **Purpose and audience.** What is this screen for, who uses it, what is the one thing they must do?
|
||||
* **Tone.** Pick one and mean it: calm and quiet, dense and technical, playful, editorial, luxe, brutalist, soft and friendly. "Clean and modern" is not a direction.
|
||||
* **The memorable thing.** One idea a person would remember: a huge number, a bold color block, an unusual rhythm of whitespace, a strong grid.
|
||||
* **Palette.** One dominant neutral family, one accent used sparingly, one semantic set (success, warning, danger). Define them as variables (section 2). A confident accent on a restrained neutral beats five evenly weighted colors.
|
||||
|
||||
Avoid the default AI look: purple-to-blue gradient on white, a row of three identical rounded cards with an icon and two lines, centered everything, generic stock phrases ("Seamless. Powerful. Effortless."), emoji as icons, drop shadows everywhere. If your draft would pass for any SaaS template, change one structural thing (layout, scale contrast, color block) until it would not.
|
||||
|
||||
**Inter is the only font.** You cannot pair typefaces, so hierarchy has to come from size, weight, color and space. Use real scale contrast (a 40 or 64px headline against 16px body), bold only for what matters, and `text-muted` for secondary text. Tight negative letter-spacing on large headlines and uppercase 12px labels with positive letter-spacing give Inter more character. Do not fake a second font.
|
||||
|
||||
## 1. Spacing: a 4/8pt scale
|
||||
|
||||
Allowed spacing values: **4, 8, 12, 16, 24, 32, 48, 64** (and 96 for page sections). Everything is a multiple of 4: `gap`, every side of `padding`, sizes of boxes and icons.
|
||||
|
||||
* Related things are close, unrelated things are far. Inside a group: 4 to 12. Between groups: 24 to 48. Between page sections: 64 to 96. If everything has the same gap, nothing reads as a group.
|
||||
* Padding of a container is at least its inner gap. Card padding 16 (mobile) to 24 (desktop); screen padding 16 or 24.
|
||||
* Use `gap` on the parent instead of per-child spacing (there is no margin).
|
||||
* Icons 16, 20, 24 (32 for feature icons). Avatars 32, 40, 48.
|
||||
* `lint_design` warns `spacing-grid` on any gap or padding off the 4px grid. Fix by snapping to the nearest allowed value.
|
||||
|
||||
## 2. Tokens first (variables)
|
||||
|
||||
* All colors are variables (`var(--name)`), named by role: `bg`, `surface`, `border`, `text`, `text-muted`, `accent`, `on-accent`, `success`, `warning`, `danger`. Two to three surface levels at most (`bg` < `surface` < `surface-2`).
|
||||
* Define at least two modes if the product may be dark or light (`set_variables` with `mode`), and check both: lint contrast in each mode after `set_variable_mode`.
|
||||
* Semantic colors carry meaning only: red means error or destructive, green means success. Do not use them decoratively.
|
||||
* If a color you need is not a variable, add the variable, then use it. `lint_design` reports hard-coded colors that equal a variable.
|
||||
* Radii, spacing and type sizes cannot be variables yet, so keep them on the lists in this skill and define them once on the design-system page.
|
||||
|
||||
## 3. Type scale
|
||||
|
||||
Sizes: **12, 14, 16, 20, 24, 32, 40, 48, 64.** `lint_design` flags anything else as `type-scale` and under 12px as `text-size`.
|
||||
|
||||
| Use | Size / weight | Line height |
|
||||
|---|---|---|
|
||||
| Display, hero | 48 to 64 / 700 to 800 | 1.1 to 1.2 (set `line-height` in px) |
|
||||
| Page title | 32 to 40 / 700 | 1.2 |
|
||||
| Section title | 20 to 24 / 600 | 1.3 |
|
||||
| Body | 16 / 400 (mobile never below 16) | 1.4 to 1.6 (24px) |
|
||||
| Secondary, captions, table text | 14 / 400 | 20px |
|
||||
| Labels, overlines, footnotes | 12 / 600, uppercase with letter-spacing 1px | 16px |
|
||||
|
||||
* One or two weights per screen (400 and 600/700). Do not use a different size for every element: three to five sizes per screen.
|
||||
* Line lengths: 45 to 75 characters for paragraphs (set a `width`, around 560 to 720px on desktop).
|
||||
* Left-align body text. Center only short headings and hero blocks.
|
||||
* Numbers that matter (stats) get size, not just weight: 32 to 48.
|
||||
|
||||
## 4. Color and contrast
|
||||
|
||||
* Text contrast **4.5:1** for normal text, **3:1** for large text (24px, or 18.66px bold) and for UI components (borders of inputs, icons that carry meaning). `lint_design` reports `contrast` errors; fix the color, not the check.
|
||||
* `text-muted` must still pass 4.5:1 on its background. Placeholder text too.
|
||||
* Never rely on color alone: pair error red with an icon or text.
|
||||
* Accent is for the primary action, the active state and one or two highlights. If more than about 10% of the screen is accent, it stops meaning anything.
|
||||
* Dark themes: raise surfaces by lightening (`bg` < `surface` < `surface-2`), not by shadows (PaperCraft has none).
|
||||
|
||||
## 5. Hierarchy and composition
|
||||
|
||||
* **One primary action per screen.** One filled accent button; others are secondary (outline or `surface`) or text links.
|
||||
* Squint test: blur the screenshot mentally. You should see three levels: the thing that matters, the supporting content, the chrome. If everything has equal weight, enlarge one thing and quiet the rest.
|
||||
* Align to a few strong edges. Every element should line up with something. Prefer a left edge and consistent column widths over centering everything.
|
||||
* Group with containers (surface + padding + radius) or with space, not both everywhere. Not everything needs a card.
|
||||
* Use whitespace as a material. Generous outer padding makes a screen feel designed; cramped content feels like a form dump.
|
||||
* Consistent radii: pick one family and stay in it, for example 8 (inputs, small), 12 (buttons), 16 (cards), 999 (pills, avatars). Nested corners: inner radius = outer radius minus padding.
|
||||
* Borders: 1px `border` color, used consistently on inputs and dividers.
|
||||
* Icons: one set per design, one size per context, `text-muted` unless active. Text labels beat icon-only buttons.
|
||||
|
||||
## 6. Touch targets and density
|
||||
|
||||
* Interactive targets are **at least 44px** tall and wide on touch (mobile, tablet). Make them 44 with padding (12 + 20 + 12), not just a bigger font. On dense desktop UIs 32 to 40 is acceptable; say so, and keep spacing between targets at 8 or more.
|
||||
* `lint_design` checks frames named `Button…`, `Tab /…` and `Tab Bar /…` for height under 44, so **name interactive layers that way**.
|
||||
* Mobile: body 16px or larger, side padding 16 to 24, bottom navigation within thumb reach, safe areas (status bar 44, home indicator 34 at the bottom).
|
||||
|
||||
## 7. Components and states
|
||||
|
||||
* Anything that appears twice is a component, built once, named `Component / Variant`, placed with `create_instance`. Change the main component, not the copies.
|
||||
* Components first, screens second: build the button, field, chip, row, card, tab bar; then compose screens from instances; override only text.
|
||||
* **Design every state, not only the happy path.** For each interactive component make a variant per state: default, hover, pressed, focused, disabled, selected, error. For each screen: empty, loading, error, partial, full, and long text (names that wrap, big numbers).
|
||||
* Empty states explain what goes here and offer the next action.
|
||||
* Disabled controls stay legible (contrast of text still 3:1 or better) but look inert.
|
||||
* Error: message under the field in `danger`, with an icon or text, not color alone.
|
||||
* Loading: skeleton blocks in `surface-2` in the shape of the content.
|
||||
* Variants are separate components in PaperCraft (`Button / Primary`, `Button / Disabled`); keep the names parallel so they sort together.
|
||||
* Keep a design-system page (swatches from the variables, the type scale, every component with its states). See the recipe in the `papercraft` skill.
|
||||
|
||||
## 8. Naming
|
||||
|
||||
* Layer names are real: `Hero`, `Stat Card / Revenue`, `Nav / Settings`, `Icon / gear`. No `Frame 12`. `lint_design` warns on default names.
|
||||
* The name says what it is, not how it looks (`Primary Action`, not `Purple Box`).
|
||||
|
||||
## 9. Finish checklist
|
||||
|
||||
Run it before `finish_working_on_nodes`:
|
||||
|
||||
1. Direction written down; the screen has one memorable idea and one primary action.
|
||||
2. Every color is a variable; both modes checked if there are two.
|
||||
3. Spacing only 4, 8, 12, 16, 24, 32, 48, 64, 96; type only from the scale; radii from one family.
|
||||
4. `lint_design` has zero errors and zero warnings, and the `info` items were considered.
|
||||
5. Screenshot at scale 2 looked at: hierarchy, alignment, wrapping, clipping, empty areas, contrast by eye.
|
||||
6. Interactive things at least 44px on touch and named `Button…` / `Tab /…`.
|
||||
7. Repeated parts are components; states exist for every interactive component and for empty, loading and error screens.
|
||||
8. Layers named; design-system page current.
|
||||
@@ -0,0 +1,112 @@
|
||||
---
|
||||
name: papercraft
|
||||
description: Drive the PaperCraft design app (native infinite-canvas design tool) through its MCP tools or the `papercraft-cli` shell. Use when asked to design screens, landing pages, dashboards or whiteboards in PaperCraft, to edit an open PaperCraft project, to build or change its variables, components and icons, or to lint and screenshot a design. Covers the build, screenshot, lint, fix loop, naming, layout recipes, tool cheat sheet, limits and troubleshooting. Pair with the papercraft-design skill for visual quality.
|
||||
---
|
||||
|
||||
# papercraft
|
||||
|
||||
PaperCraft is a native Rust design app. You edit the project that is open in the running app: every tool call is one undo step and the window repaints live. Same tools over MCP (`http://127.0.0.1:29980/mcp` or the `papercraft-mcp` stdio bridge) and over the shell (`papercraft-cli`). Look and taste live in the `papercraft-design` skill; this one is the mechanics.
|
||||
|
||||
References: [tools.md](references/tools.md) (every tool and argument), [html-subset.md](references/html-subset.md) (what `write_html` accepts), [recipes.md](references/recipes.md) (mobile flow, landing page, dashboard, design-system page).
|
||||
|
||||
## 1. Connect and orient
|
||||
|
||||
```sh
|
||||
papercraft-cli call get_basic_info # fails with exit 1 if the app is not running
|
||||
```
|
||||
|
||||
* Not running: ask the user to start PaperCraft and open a project (or `create_file` then `open_file`). `PAPERCRAFT_MCP_PORT` must match if the port was changed. Do not try to launch the GUI yourself unless asked.
|
||||
* Read `get_basic_info` (pages, artboards, sizes, fonts), then `get_selection` (what the user is looking at) and `get_variables` and `get_components`. Reuse what exists before creating anything.
|
||||
* `pageId` defaults to the page the user is viewing, which can change between calls: pass it explicitly. Node ids look like `3fa9c1:14`; never show them to the user.
|
||||
* With MCP tools you do not need the CLI; with a shell, `papercraft-cli list` and `papercraft-cli schema <tool>` give the schemas on demand. Pass big JSON on stdin: `... | papercraft-cli call write_html -`.
|
||||
|
||||
## 2. The loop: build, look, lint, fix
|
||||
|
||||
1. **Tokens first.** `set_variables` once (roles: `bg`, `surface`, `border`, `text`, `text-muted`, `accent`, `on-accent`, `danger`). Add a second mode for dark or light. No hex in the design afterwards.
|
||||
2. **Components first.** Anything that appears twice (button, field, chip, row, tab bar) is built once as an artboard, `create_component`, then placed with `create_instance`.
|
||||
3. **Build in small steps.** `create_artboard`, then one `write_html` per visual group (header, card, row) so the user sees progress. Name every layer with `layer-name`.
|
||||
4. **Look.** `get_screenshot` of the artboard after each group or two. Scale 2 to read small text. Read it as a designer: hierarchy, alignment, spacing rhythm, clipping, wrapping.
|
||||
5. **Lint.** `lint_design` on the artboard. Fix every error and warning; consider the `info` items. Run it again until errors and warnings are zero.
|
||||
6. **Fix with the smallest edit.** `update_styles` and `set_text_content` on the existing nodes, not a rewrite. Rewrite a group with `write_html` mode `replace` only when its structure is wrong.
|
||||
7. **Finish.** `finish_working_on_nodes` (saves). Tell the user what you made in a sentence or two, with artboard names, not ids.
|
||||
|
||||
Lint passes are necessary, not sufficient: contrast and grids say nothing about composition. The screenshot is the real test.
|
||||
|
||||
## 3. Naming
|
||||
|
||||
* Layers: `Noun` or `Component / Variant` (`Button / Primary`, `Tab / Today`, `Card / Task`, `Icon / gear`). Slashes group the layers panel.
|
||||
* Screens (artboards): the screen's name (`Sign in`, `Task detail`), not `Frame 12`. Add a size suffix only when you have several breakpoints (`Landing / 1440`).
|
||||
* Variables: roles, not hues (`surface`, `text-muted`, `on-accent`), kebab-case.
|
||||
* Lint flags default names (`Frame`, `Rectangle`, `Group`) on layers that have children.
|
||||
|
||||
## 4. Layout recipes (flexbox is the only layout)
|
||||
|
||||
```html
|
||||
<!-- row, vertically centered, 12px gap -->
|
||||
<div layer-name="Row" style="display:flex;align-items:center;gap:12px">…</div>
|
||||
<!-- column with 16px rhythm and 24px padding -->
|
||||
<div layer-name="Stack" style="display:flex;flex-direction:column;gap:16px;padding:24px">…</div>
|
||||
<!-- label left, value right -->
|
||||
<div layer-name="Meta Row" style="display:flex;justify-content:space-between;align-items:center">…</div>
|
||||
<!-- fill the remaining width or height -->
|
||||
<div layer-name="Main" style="flex-grow:1">…</div>
|
||||
<!-- pill button, 44px tall: 12 + 20 line height + 12 -->
|
||||
<div layer-name="Button / Primary" style="display:flex;justify-content:center;padding:12px 24px;background:var(--accent);border-radius:12px"><div layer-name="Label" style="font-size:16px;font-weight:600;line-height:20px;color:var(--on-accent)">Save</div></div>
|
||||
<!-- icon + label -->
|
||||
<div layer-name="Nav / Home" style="display:flex;align-items:center;gap:12px"><x-paper-icon name="phosphor:house" size="20" color="var(--text-muted)"/><div layer-name="Label" style="font-size:16px;color:var(--text-muted)">Home</div></div>
|
||||
```
|
||||
|
||||
Artboards: mobile 390×844, tablet 768×1024, desktop 1440×900. Create with `height:"fit-content"` when the content decides the height. Replace margin with `gap` and `padding`.
|
||||
|
||||
Full flows: [recipes.md](references/recipes.md).
|
||||
|
||||
## 5. Tools cheat sheet
|
||||
|
||||
| I want to | Tool |
|
||||
|---|---|
|
||||
| Know the project, pages, artboards | `get_basic_info`, `get_tree_summary`, `get_children` |
|
||||
| Know what is selected | `get_selection` |
|
||||
| Inspect one node | `get_node_info`, `get_computed_styles`, `get_jsx` |
|
||||
| Find nodes by text or style | `find_nodes` |
|
||||
| See it | `get_screenshot` (`nodeId`, `scale` 1 or 2) |
|
||||
| New screen | `create_artboard` (`width` and `height` required) |
|
||||
| Add content | `write_html` (`insert-children` or `replace`) |
|
||||
| Change styles or text | `update_styles`, `set_text_content`, `rename_nodes` |
|
||||
| Whiteboard shapes, sticky notes, arrows | `create_shape` |
|
||||
| Copy, move, delete | `duplicate_nodes`, `move_nodes`, `delete_nodes` |
|
||||
| Tokens | `get_variables` (`format` css or json), `set_variables` (`mode`), `set_variable_mode` |
|
||||
| Reuse | `create_component`, `create_instance`, `get_components`, `<x-paper-instance>` |
|
||||
| Icons | `search_icons`, `<x-paper-icon name="set:name">`; CLI: `papercraft-cli icons add\|list\|search` |
|
||||
| Check quality | `lint_design` |
|
||||
| Export | `export` (PNG, SVG, PDF to files), `export_combined_pdf` |
|
||||
| Point at things for the user | `select_nodes` (`zoom: true`) |
|
||||
| Take back | `undo`, `redo` |
|
||||
| Save | `finish_working_on_nodes` |
|
||||
|
||||
## 6. Known limits (design around them)
|
||||
|
||||
* **Font is Inter only**, regular and bold plus italic. No other families, no weights between. Build hierarchy from size, weight, color and space.
|
||||
* **Variables are colors only**, for fills and text color. Strokes and gradients keep fixed colors. Spacing, radius and type sizes are plain numbers: keep them on the scale by discipline and `lint_design`.
|
||||
* **No margin, grid or tables; no shadows, filters or transforms** (reported under `ignoredStyles`). Fake elevation with a border or a lighter surface.
|
||||
* **No rotation.** Nodes are axis-aligned.
|
||||
* **Components and their instances live on one page.** Put shared components on the page where they are used, or duplicate the component per page.
|
||||
* **No variants or properties**: one component per variant (`Button / Primary`, `Button / Secondary`). No swap or detach tool.
|
||||
* **Remote images are not fetched.** Use a local path or `data:` URI.
|
||||
* **One project open at a time**; moving layers between pages is not supported.
|
||||
* **Text boxes with fixed widths can wrap earlier than in a browser.** Check headlines in the screenshot and widen.
|
||||
* **Instances are skipped by lint**; lint the main component.
|
||||
|
||||
## 7. Troubleshooting
|
||||
|
||||
| Symptom | Cause and fix |
|
||||
|---|---|
|
||||
| `papercraft-cli` exits 1, "isn't running" | The app is closed, no project is open, or the port differs. Start it and open a project; check `PAPERCRAFT_MCP_PORT`. |
|
||||
| Call fails with an unknown node id | The id is from another page or was deleted. Re-read with `get_children`; pass `pageId`. |
|
||||
| `write_html` reports `ignoredStyles` | You used a CSS property that has no equivalent (`margin`, `box-shadow`, `transform`...). Replace it, see [html-subset.md](references/html-subset.md). |
|
||||
| Content is cut off | The artboard height is fixed. `update_styles` with `height: "fit-content"` or a larger number. |
|
||||
| A color did not change with the theme | It was written as hex, or it is a stroke or gradient. Use `var(--name)` on fills and text; strokes need `update_styles` after a mode switch. |
|
||||
| `create_instance` fails | The component is on another page. Instances must be on the component's page. |
|
||||
| Icon does not appear | The set is not installed: `papercraft-cli icons add phosphor`, then `papercraft-cli icons search <words>` for the exact `set:name`. |
|
||||
| Layer panel is a pile of `Frame` | You skipped `layer-name`. Fix with `rename_nodes` (batch) before finishing. |
|
||||
| Text looks tiny in the screenshot | Screenshots default to scale 1. Use `scale: 2`. |
|
||||
| The user's view jumped | You switched pages. Always pass `pageId` instead of opening other pages. |
|
||||
@@ -0,0 +1,56 @@
|
||||
# What `write_html` supports and ignores
|
||||
|
||||
`write_html` parses an HTML fragment with inline `style` attributes into layers. It is a forgiving parser (unquoted attributes, void tags, entities such as `·` and `→`, mismatched closing tags), not a browser: there is no cascade, no `<style>`, no classes, no scripts. Write each element's styles inline. Anything it cannot map comes back in the result as `ignoredStyles`: read that list after every call.
|
||||
|
||||
## Call shape
|
||||
|
||||
```json
|
||||
{"targetNodeId":"<artboard or frame id, or \"root\">","mode":"insert-children","html":"<div ...>...</div>"}
|
||||
```
|
||||
|
||||
`insert-children` appends inside the target. `replace` swaps the target for the new HTML. The result lists `createdNodeIds` (top-level layers created), so write one visual group per call and keep the ids.
|
||||
|
||||
## Elements
|
||||
|
||||
| Markup | Becomes |
|
||||
|---|---|
|
||||
| `<div>`, `<section>`, `<p>`... with child elements | a frame (auto layout when `display:flex`; block elements stack vertically) |
|
||||
| an element containing only text | a text layer |
|
||||
| `<b>`, `<i>`, `<u>`, `<s>`, `<span style="...">` inside text | rich-text runs (bold, italic, underline, strike, size, color) |
|
||||
| `<img src>` | an image fill. `src` is an absolute path, a `file://` URL, `paper-asset:///abs/path` or a `data:` URI. `http(s)` is not fetched |
|
||||
| `<svg>...</svg>` | vector layers (paths, gradients, strokes) |
|
||||
| `<x-paper-icon name="set:name" size="20" color="var(--x)"/>` | an icon from an installed set (`phosphor:gear`) |
|
||||
| `<x-paper-instance component-id="ID"/>` | an instance of a component |
|
||||
| `<x-paper-clone node-id="ID" style="..."/>` | a deep copy of an existing node |
|
||||
| `layer-name="..."` on any of the above | the layer's name (names are truncated to 50 characters) |
|
||||
|
||||
## Layout
|
||||
|
||||
Supported: `display:flex` (and `inline-flex`), `flex-direction`, `flex-wrap`, `gap`, `row-gap`, `column-gap`, `padding` (shorthand, per side, `padding-inline`, `padding-block`), `align-items`, `justify-content` (including `space-between`), `align-self`, `flex-grow` / `flex: 1`, `width`, `height` (px, %, `fit-content`), `min-`/`max-` sizes (accepted), `overflow:hidden` (clips), `position:absolute` with `left`/`top`/`right`/`bottom`, `display:none`, `visibility`.
|
||||
|
||||
Not supported (check `ignoredStyles` to be sure): `margin` (use `gap` and `padding`), `display:grid`, tables, `float`, `calc()`, viewport units, `transform` and rotation.
|
||||
|
||||
Tips:
|
||||
|
||||
* Artboards are `display:flex; flex-direction:column`. Children of a column stretch to the full width unless they have a width or `align-items` says otherwise.
|
||||
* To center something, put it in a flex parent with `align-items:center; justify-content:center`.
|
||||
* To make a row fill the remaining space, give the child `flex-grow:1`.
|
||||
* Use `height:"fit-content"` on artboards that should grow with content; fixed heights clip.
|
||||
|
||||
## Text
|
||||
|
||||
Supported: `font-size` (px), `font-weight` (600 and above renders bold; otherwise regular), `font-style:italic`, `color`, `line-height` (px or unitless), `letter-spacing`, `text-align`, `text-decoration`. The font is always **Inter**; `font-family` is ignored, and there are only regular and bold weights (plus italic), so hierarchy comes from size, weight, color and space.
|
||||
|
||||
## Boxes
|
||||
|
||||
Supported: `background` and `background-color` (hex, `rgb()`, `hsl()`, `var(--name)`), gradients (`linear-gradient`, `radial-gradient`), `background-image: url()`, `background-size` (`contain`, `100% 100%`), `border` (width, style, color; one stroke per node), `border-radius` (shorthand and per corner; `999px` makes a pill), `opacity`, `mix-blend-mode`. Strokes and gradients keep their resolved colors; they do not follow variables later.
|
||||
|
||||
Not supported (reported as ignored): `box-shadow`, `filter`, `backdrop-filter`, `text-shadow`, `outline-offset`, CSS animations and transitions.
|
||||
|
||||
## Colors and variables
|
||||
|
||||
Write colors as `var(--name)` for `background`, `background-color` and `color` and the layer stays linked to the variable. Define the variable with `set_variables` first. Hex is fine for one-off colors in an image or an illustration, but `lint_design` will flag a hex that equals a variable.
|
||||
|
||||
## `update_styles`
|
||||
|
||||
Takes the same properties as camelCase JSON (`{"backgroundColor":"var(--accent)","paddingTop":16}`; numbers become px for lengths). Extra keys for the whiteboard: `sketch`, `roughness`, `fillStyle`, `strokeStyle`, `seed`. Setting `left` / `top` on an artboard moves it.
|
||||
@@ -0,0 +1,155 @@
|
||||
# Recipes
|
||||
|
||||
Every snippet here was run against PaperCraft and passes `lint_design` with no errors or warnings. They assume the variables from recipe 0 and `jq` on the PATH. Wrap HTML files into a `write_html` call with:
|
||||
|
||||
```sh
|
||||
jq -Rs --arg t "$ARTBOARD_ID" '{targetNodeId:$t,mode:"insert-children",html:.}' file.html | papercraft-cli call write_html -
|
||||
```
|
||||
|
||||
## 0. Tokens first (do this once per project)
|
||||
|
||||
Two modes, so one call can flip the whole design between dark and light. Roles, not hues.
|
||||
|
||||
```sh
|
||||
papercraft-cli call set_variables '{"mode":"Dark","variables":{"bg":"#1e1e2e","surface":"#313244","border":"#45475a","text":"#cdd6f4","text-muted":"#a6adc8","accent":"#cba6f7","on-accent":"#11111b","danger":"#f38ba8"}}'
|
||||
papercraft-cli call set_variables '{"mode":"Light","variables":{"bg":"#eff1f5","surface":"#e6e9ef","border":"#bcc0cc","text":"#4c4f69","text-muted":"#5c5f77","accent":"#8839ef","on-accent":"#eff1f5","danger":"#d20f39"}}'
|
||||
papercraft-cli call set_variable_mode '{"mode":"Dark"}'
|
||||
```
|
||||
|
||||
The first mode you define stays active until you switch. Check contrast in both modes with `lint_design` after each switch.
|
||||
|
||||
## 1. Mobile app flow
|
||||
|
||||
Build the parts once as components, then compose screens from instances.
|
||||
|
||||
```sh
|
||||
# a. The button: the artboard IS the component (44px tall: 12 + 20 line + 12)
|
||||
BTN=$(papercraft-cli call create_artboard '{"name":"Button / Primary","styles":{"width":"fit-content","height":"fit-content","display":"flex","backgroundColor":"var(--accent)","borderRadius":"12px","padding":"12px 24px","justifyContent":"center","alignItems":"center"}}' | jq -r .id)
|
||||
echo '{"targetNodeId":"'$BTN'","mode":"insert-children","html":"<div layer-name=\"Label\" style=\"font-size:16px;font-weight:600;line-height:20px;color:var(--on-accent)\">Button</div>"}' | papercraft-cli call write_html -
|
||||
papercraft-cli call create_component '{"nodeId":"'$BTN'"}'
|
||||
|
||||
# b. The screen: 390x844, content centered, 24px padding
|
||||
SCREEN=$(papercraft-cli call create_artboard '{"name":"Sign in","styles":{"width":"390px","height":"844px","backgroundColor":"var(--bg)","padding":"24px","gap":"16px","justifyContent":"center"}}' | jq -r .id)
|
||||
cat > /tmp/signin.html <<'HTML'
|
||||
<div layer-name="Header" style="display:flex;flex-direction:column;gap:8px">
|
||||
<div layer-name="Title" style="font-size:32px;font-weight:700;color:var(--text)">Welcome back</div>
|
||||
<div layer-name="Subtitle" style="font-size:16px;line-height:24px;color:var(--text-muted)">Sign in to sync your tasks.</div>
|
||||
</div>
|
||||
<div layer-name="Field / Email" style="display:flex;align-items:center;gap:12px;padding:12px 16px;background:var(--surface);border:1px solid var(--border);border-radius:12px">
|
||||
<x-paper-icon name="phosphor:envelope-simple" size="20" color="var(--text-muted)"/>
|
||||
<div layer-name="Value" style="font-size:16px;color:var(--text)">marcos@example.com</div>
|
||||
</div>
|
||||
<div layer-name="Actions" style="display:flex;flex-direction:column;gap:12px"></div>
|
||||
HTML
|
||||
jq -Rs --arg t "$SCREEN" '{targetNodeId:$t,mode:"insert-children",html:.}' /tmp/signin.html | papercraft-cli call write_html -
|
||||
|
||||
# c. Place the instance in Actions (find its id with get_children), then set its text and width
|
||||
ACTIONS=$(papercraft-cli call get_children '{"nodeId":"'$SCREEN'"}' | jq -r '.children[]|select(.name=="Actions").id')
|
||||
INST=$(papercraft-cli call create_instance '{"componentId":"'$BTN'","parentId":"'$ACTIONS'","name":"Button / Sign in"}')
|
||||
LABEL=$(echo "$INST" | jq -r '.textLayers[0].id'); ID=$(echo "$INST" | jq -r .id)
|
||||
papercraft-cli call set_text_content '{"updates":[{"nodeId":"'$LABEL'","textContent":"Sign in"}]}'
|
||||
papercraft-cli call update_styles '{"updates":[{"nodeIds":["'$ID'"],"styles":{"width":"342px"}}]}'
|
||||
papercraft-cli call lint_design '{"nodeId":"'$SCREEN'"}'
|
||||
papercraft-cli call get_screenshot '{"nodeId":"'$SCREEN'"}' --out signin.png
|
||||
```
|
||||
|
||||
For the next screens, create each artboard at 390x844, reuse the components by `create_instance`, and give every screen a status bar and a tab bar component so they stay identical. Name screens as nouns (`Sign in`, `Today`, `Task detail`). Put each flow's screens left to right: a new artboard goes to an empty spot, or set `left` and `top` in `styles`. `examples/taskflow.py` in the PaperCraft repository is a complete 8-screen reference.
|
||||
|
||||
## 2. Landing page (1440 wide, height grows with content)
|
||||
|
||||
Create the artboard with `height:"fit-content"` so it never clips, then add one section per call.
|
||||
|
||||
```sh
|
||||
AB=$(papercraft-cli call create_artboard '{"name":"Landing","styles":{"width":"1440px","height":"fit-content","backgroundColor":"var(--bg)","display":"flex","flexDirection":"column","paddingBottom":"96px"}}' | jq -r .id)
|
||||
jq -Rs --arg t "$AB" '{targetNodeId:$t,mode:"insert-children",html:.}' landing.html | papercraft-cli call write_html -
|
||||
```
|
||||
|
||||
`landing.html` (nav, hero with one primary action, three feature cards):
|
||||
|
||||
```html
|
||||
<div layer-name="Nav" style="display:flex;justify-content:space-between;align-items:center;padding:24px 64px">
|
||||
<div layer-name="Logo" style="font-size:20px;font-weight:700;color:var(--text)">Acme</div>
|
||||
<div layer-name="Links" style="display:flex;align-items:center;gap:32px">
|
||||
<div layer-name="Link / Product" style="font-size:16px;color:var(--text-muted)">Product</div>
|
||||
<div layer-name="Link / Pricing" style="font-size:16px;color:var(--text-muted)">Pricing</div>
|
||||
<div layer-name="Button / Nav" style="display:flex;padding:12px 24px;background:var(--accent);border-radius:12px"><div layer-name="Label" style="font-size:16px;font-weight:600;line-height:20px;color:var(--on-accent)">Sign up</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div layer-name="Hero" style="display:flex;flex-direction:column;align-items:center;gap:24px;padding:96px 64px">
|
||||
<div layer-name="Eyebrow" style="font-size:14px;font-weight:600;letter-spacing:1px;color:var(--accent)">NOW IN BETA</div>
|
||||
<div layer-name="Headline" style="font-size:64px;font-weight:800;line-height:72px;letter-spacing:-2px;text-align:center;color:var(--text);width:960px">Plan the day. Finish what matters.</div>
|
||||
<div layer-name="Subhead" style="font-size:20px;line-height:32px;text-align:center;color:var(--text-muted);width:640px">One calm list for everything on your plate, synced across your devices.</div>
|
||||
<div layer-name="Actions" style="display:flex;gap:16px">
|
||||
<div layer-name="Button / Primary" style="display:flex;padding:16px 32px;background:var(--accent);border-radius:12px"><div layer-name="Label" style="font-size:16px;font-weight:600;line-height:20px;color:var(--on-accent)">Get started</div></div>
|
||||
<div layer-name="Button / Secondary" style="display:flex;padding:16px 32px;border:1px solid var(--border);border-radius:12px"><div layer-name="Label" style="font-size:16px;font-weight:600;line-height:20px;color:var(--text)">See how it works</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div layer-name="Features" style="display:flex;gap:24px;padding:0 64px">
|
||||
<div layer-name="Feature Card / Capture" style="display:flex;flex-direction:column;gap:16px;padding:32px;background:var(--surface);border-radius:16px;flex-grow:1"><x-paper-icon name="phosphor:lightning" size="32" color="var(--accent)"/><div layer-name="Title" style="font-size:20px;font-weight:600;color:var(--text)">Capture fast</div><div layer-name="Body" style="font-size:16px;line-height:24px;color:var(--text-muted)">Type a task and move on.</div></div>
|
||||
<div layer-name="Feature Card / Focus" style="display:flex;flex-direction:column;gap:16px;padding:32px;background:var(--surface);border-radius:16px;flex-grow:1"><x-paper-icon name="phosphor:target" size="32" color="var(--accent)"/><div layer-name="Title" style="font-size:20px;font-weight:600;color:var(--text)">Stay focused</div><div layer-name="Body" style="font-size:16px;line-height:24px;color:var(--text-muted)">See only what is due today.</div></div>
|
||||
<div layer-name="Feature Card / Sync" style="display:flex;flex-direction:column;gap:16px;padding:32px;background:var(--surface);border-radius:16px;flex-grow:1"><x-paper-icon name="phosphor:arrows-clockwise" size="32" color="var(--accent)"/><div layer-name="Title" style="font-size:20px;font-weight:600;color:var(--text)">Always in sync</div><div layer-name="Body" style="font-size:16px;line-height:24px;color:var(--text-muted)">Phone, tablet and desktop agree.</div></div>
|
||||
</div>
|
||||
```
|
||||
|
||||
Check the headline in the screenshot: text boxes with a fixed `width` can wrap earlier than in a browser, so give long headlines generous widths (960px above) and verify.
|
||||
|
||||
## 3. Dashboard (sidebar, stat cards, chart)
|
||||
|
||||
A row artboard (`flexDirection:"row"`) with a fixed 240px sidebar and a `flex-grow:1` main area.
|
||||
|
||||
```sh
|
||||
AB=$(papercraft-cli call create_artboard '{"name":"Dashboard","styles":{"width":"1440px","height":"900px","backgroundColor":"var(--bg)","display":"flex","flexDirection":"row"}}' | jq -r .id)
|
||||
jq -Rs --arg t "$AB" '{targetNodeId:$t,mode:"insert-children",html:.}' dashboard.html | papercraft-cli call write_html -
|
||||
```
|
||||
|
||||
`dashboard.html`:
|
||||
|
||||
```html
|
||||
<div layer-name="Sidebar" style="display:flex;flex-direction:column;gap:8px;padding:24px 16px;width:240px;background:var(--surface)">
|
||||
<div layer-name="Brand" style="font-size:20px;font-weight:700;color:var(--text);padding:8px 12px">Acme</div>
|
||||
<div layer-name="Nav / Overview" style="display:flex;align-items:center;gap:12px;padding:12px;border-radius:12px;background:var(--accent)"><x-paper-icon name="phosphor:squares-four" size="20" color="var(--on-accent)"/><div layer-name="Label" style="font-size:16px;font-weight:600;color:var(--on-accent)">Overview</div></div>
|
||||
<div layer-name="Nav / Customers" style="display:flex;align-items:center;gap:12px;padding:12px;border-radius:12px"><x-paper-icon name="phosphor:users" size="20" color="var(--text-muted)"/><div layer-name="Label" style="font-size:16px;color:var(--text-muted)">Customers</div></div>
|
||||
<div layer-name="Nav / Reports" style="display:flex;align-items:center;gap:12px;padding:12px;border-radius:12px"><x-paper-icon name="phosphor:chart-line-up" size="20" color="var(--text-muted)"/><div layer-name="Label" style="font-size:16px;color:var(--text-muted)">Reports</div></div>
|
||||
</div>
|
||||
<div layer-name="Main" style="display:flex;flex-direction:column;gap:24px;padding:32px;flex-grow:1">
|
||||
<div layer-name="Title" style="font-size:32px;font-weight:700;color:var(--text)">Overview</div>
|
||||
<div layer-name="Stat Row" style="display:flex;gap:16px">
|
||||
<div layer-name="Stat Card / Revenue" style="display:flex;flex-direction:column;gap:8px;padding:24px;background:var(--surface);border-radius:16px;flex-grow:1"><div layer-name="Label" style="font-size:14px;color:var(--text-muted)">Revenue</div><div layer-name="Value" style="font-size:32px;font-weight:700;color:var(--text)">$48,200</div></div>
|
||||
<div layer-name="Stat Card / Orders" style="display:flex;flex-direction:column;gap:8px;padding:24px;background:var(--surface);border-radius:16px;flex-grow:1"><div layer-name="Label" style="font-size:14px;color:var(--text-muted)">Orders</div><div layer-name="Value" style="font-size:32px;font-weight:700;color:var(--text)">1,204</div></div>
|
||||
<div layer-name="Stat Card / Refunds" style="display:flex;flex-direction:column;gap:8px;padding:24px;background:var(--surface);border-radius:16px;flex-grow:1"><div layer-name="Label" style="font-size:14px;color:var(--text-muted)">Refunds</div><div layer-name="Value" style="font-size:32px;font-weight:700;color:var(--danger)">18</div></div>
|
||||
</div>
|
||||
<div layer-name="Chart Card" style="display:flex;flex-direction:column;gap:16px;padding:24px;background:var(--surface);border-radius:16px;flex-grow:1">
|
||||
<div layer-name="Heading" style="font-size:20px;font-weight:600;color:var(--text)">Revenue, Monday to Thursday</div>
|
||||
<div layer-name="Bars" style="display:flex;align-items:flex-end;gap:16px;flex-grow:1">
|
||||
<div layer-name="Bar / Mon" style="width:48px;height:96px;background:var(--accent);border-radius:8px"></div>
|
||||
<div layer-name="Bar / Tue" style="width:48px;height:144px;background:var(--accent);border-radius:8px"></div>
|
||||
<div layer-name="Bar / Wed" style="width:48px;height:120px;background:var(--accent);border-radius:8px"></div>
|
||||
<div layer-name="Bar / Thu" style="width:48px;height:208px;background:var(--accent);border-radius:8px"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
Charts are just boxes: size the bars from the data (`height = value / max * plot height`). Add axis labels at 12px. Always label what the bars mean; color alone is not information.
|
||||
|
||||
## 4. Design-system page
|
||||
|
||||
Generate swatches from the project's own variables and a type-scale specimen, so the page cannot drift from the tokens:
|
||||
|
||||
```sh
|
||||
AB=$(papercraft-cli call create_artboard '{"name":"Design system","styles":{"width":"720px","height":"fit-content","backgroundColor":"var(--bg)","display":"flex","flexDirection":"column","padding":"32px","gap":"32px"}}' | jq -r .id)
|
||||
|
||||
# Colors: one swatch per variable, built from the project's own variables
|
||||
SWATCHES=$(papercraft-cli call get_variables | jq -r '.variables | keys[]' | while read -r n; do
|
||||
printf '<div layer-name="Swatch / %s" style="display:flex;align-items:center;gap:12px;width:208px"><div layer-name="Chip" style="width:40px;height:40px;border-radius:8px;background:var(--%s);border:1px solid var(--border)"></div><div layer-name="Name" style="font-size:14px;color:var(--text)">--%s</div></div>' "$n" "$n" "$n"
|
||||
done)
|
||||
jq -n --arg t "$AB" --arg s "$SWATCHES" '{targetNodeId:$t,mode:"insert-children",html:("<div layer-name=\"Colors\" style=\"display:flex;flex-direction:column;gap:16px\"><div layer-name=\"Heading\" style=\"font-size:24px;font-weight:700;color:var(--text)\">Colors</div><div layer-name=\"Swatches\" style=\"display:flex;flex-wrap:wrap;gap:16px\">"+$s+"</div></div>")}' | papercraft-cli call write_html -
|
||||
|
||||
# Type scale: one row per size
|
||||
ROWS=""; for s in 12 14 16 20 24 32 40; do
|
||||
ROWS+="<div layer-name=\"Type / ${s}\" style=\"display:flex;align-items:baseline;gap:16px\"><div layer-name=\"Size\" style=\"font-size:12px;color:var(--text-muted);width:48px\">${s}px</div><div layer-name=\"Sample\" style=\"font-size:${s}px;color:var(--text)\">The quick brown fox</div></div>"
|
||||
done
|
||||
jq -n --arg t "$AB" --arg r "$ROWS" '{targetNodeId:$t,mode:"insert-children",html:("<div layer-name=\"Type scale\" style=\"display:flex;flex-direction:column;gap:12px\"><div layer-name=\"Heading\" style=\"font-size:24px;font-weight:700;color:var(--text)\">Type scale</div>"+$r+"</div>")}' | papercraft-cli call write_html -
|
||||
```
|
||||
|
||||
Add component specimens by placing instances of every component on the same page, in a row per component with each state (default, hover, disabled, selected, error) next to it. Keep this page when you finish: it is the contract for later edits.
|
||||
@@ -0,0 +1,438 @@
|
||||
# Tool reference
|
||||
|
||||
Generated from `papercraft-cli schema <tool>`; run that command for the authoritative, current text. `fileId` and `pageId` are accepted by almost every tool: omit `fileId` to use the open project, pass `pageId` to work on a page without switching the user's view. Node ids are opaque strings such as `3fa9c1:14`; never show them to the user.
|
||||
|
||||
|
||||
## Projects and pages
|
||||
|
||||
|
||||
### `list_resources` (read-only)
|
||||
|
||||
Lists the user's papercraft projects (local folders), most recently modified first. Each item has id, name, path, page count, updatedAt, and `open` for the project open in the app.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `limit` | integer | | Maximum projects to return. Defaults to 50. |
|
||||
|
||||
|
||||
### `open_file`
|
||||
|
||||
Opens a project in the app (saving the current one) and returns a get_basic_info response. Optional pageId switches the user to that page.
|
||||
|
||||
|
||||
### `create_file`
|
||||
|
||||
Creates a new empty project and returns its id. Call open_file with the id to work in it.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `name` | string | | Optional project name. |
|
||||
|
||||
|
||||
### `create_page`
|
||||
|
||||
Creates a new page at the end of the page list and returns its id, without switching the user's page. Use the returned pageId in later calls.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `name` | string | | Optional page name. Defaults to "Page N". |
|
||||
|
||||
|
||||
### `rename_pages`
|
||||
|
||||
Rename one or more pages. Does not switch the user's page.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `updates` | array of object | yes | Page renames. |
|
||||
|
||||
|
||||
## Read
|
||||
|
||||
|
||||
### `get_basic_info` (read-only)
|
||||
|
||||
Essential context about the open project: project name, the page, node count, artboards (top-level frames) with ids, sizes and world positions, other top-level layers, fonts, and `pages` with the active one marked isActive.
|
||||
- worldX/worldY are world positions; x/y are relative to the parent.
|
||||
- Pass pageId to inspect another page without switching the user's view.
|
||||
|
||||
|
||||
### `get_selection` (read-only)
|
||||
|
||||
The selected layers on the page the user is viewing: ids, names, types, sizes, positions and the artboard each belongs to.
|
||||
|
||||
|
||||
### `get_node_info` (read-only)
|
||||
|
||||
Details of one node: type, name, size, visibility, lock state, parent, children ids, text content, x/y (relative to parent) and worldX/worldY. Errors if it doesn't exist.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `nodeId` | string | yes | The node to inspect. |
|
||||
|
||||
|
||||
### `get_children` (read-only)
|
||||
|
||||
Direct children of a node (or of the page root when nodeId is "root"), with ids, names, types, child counts and positions.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `nodeId` | string | yes | The parent node, or "root". |
|
||||
|
||||
|
||||
### `get_tree_summary` (read-only)
|
||||
|
||||
Compact indented text tree of a subtree: type, name, id and size per node. Much cheaper than get_jsx for orientation. nodeId "root" summarizes the whole page.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `depth` | number | | Maximum depth (default 3, max 10). |
|
||||
| `nodeId` | string | yes | Root of the summary, or "root". |
|
||||
|
||||
|
||||
### `get_jsx` (read-only)
|
||||
|
||||
JSX for a node and its descendants with inline styles (frames become flex divs, text becomes spans, vectors become inline SVG). Use it to export a design to code.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `format` | inline-styles/tailwind | | Only inline styles are produced; "tailwind" is accepted for compatibility. |
|
||||
| `nodeId` | string | yes | The node to convert. |
|
||||
|
||||
|
||||
### `get_computed_styles` (read-only)
|
||||
|
||||
CSS styles (camelCase) of one or more nodes, as {nodeId: styles}.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `nodeIds` | array of string | yes | Nodes to read. |
|
||||
|
||||
|
||||
### `find_nodes` (read-only)
|
||||
|
||||
Find nodes by text content and/or computed style. Searches the whole project by default; pass pageId for one page or nodeId for a subtree. `textValue` matches text case-insensitively with * wildcards anchored to the whole value. `filters` are {styleName, styleValue} pairs (AND), values may use * wildcards; colors match by value.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `filters` | array of object | | |
|
||||
| `nodeId` | string | | Optional subtree root; takes priority over pageId. |
|
||||
| `textValue` | string | | |
|
||||
|
||||
|
||||
### `get_fill_image` (read-only)
|
||||
|
||||
The image used by a node's image fill, as image content.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `nodeId` | string | yes | A node with an image fill. |
|
||||
|
||||
|
||||
### `get_font_family_info` (read-only)
|
||||
|
||||
Whether font families are available and their weights/styles. papercraft renders text with Inter (400, 700, normal and italic); other families fall back to Inter.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `familyNames` | array of string | yes | |
|
||||
|
||||
|
||||
### `get_guide` (read-only)
|
||||
|
||||
Read a guide. Topics: "papercraft-mcp-instructions" (read first), "html" (what write_html supports), "whiteboard" (shapes, arrows, stickies, hand-drawn style).
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `topic` | string | yes | |
|
||||
|
||||
|
||||
## See and export
|
||||
|
||||
|
||||
### `get_screenshot` (read-only)
|
||||
|
||||
Render a node (or the whole page when nodeId is omitted or "root") to a PNG and return it as an image. Scale 1 (default) is enough for layout checks; use 2 to read small text. Large images are scaled down to fit API limits.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `nodeId` | string | | The node to capture; omit for the whole page. |
|
||||
| `scale` | number | | Render scale: 1 (default) or 2. |
|
||||
|
||||
|
||||
### `export`
|
||||
|
||||
Export nodes to files. `nodes` maps node ids to export settings ([{format: png|svg|pdf, scale: "2x" | "512w" | "512h" | "720p"}]; [] = 2x PNG). Files go to `directory` (default: the Downloads folder). Returns the paths written.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `directory` | string | | Output folder (absolute path). |
|
||||
| `nodes` | object | yes | |
|
||||
|
||||
|
||||
### `export_combined_pdf`
|
||||
|
||||
Export several nodes into one PDF, one page per node, ordered top-to-bottom then left-to-right. Returns the file path.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `nodeIds` | array of string | yes | Nodes to put on the pages. |
|
||||
| `path` | string | | Output file (default: Downloads/<project>.pdf). |
|
||||
|
||||
|
||||
## Write
|
||||
|
||||
|
||||
### `create_artboard`
|
||||
|
||||
Creates an artboard (top-level frame) and returns its id; then add content with write_html({mode: "insert-children"}). `styles` are camelCase CSS; width and height are required (e.g. "1440px"). Artboards default to display:flex, flexDirection:column and a white background, and are placed in an empty spot unless left/top are given. Default sizes: desktop 1440×900, tablet 768×1024, mobile 390×844.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `name` | string | yes | Layer name. |
|
||||
| `styles` | object | yes | CSS styles, e.g. {"width": "390px", "height": "844px", "backgroundColor": "#f5f5f5", "padding": "24px", "gap": "16px"} |
|
||||
|
||||
|
||||
### `write_html`
|
||||
|
||||
Parse HTML with inline styles into layers. Write incrementally: each call should add one visual group (a header, one list row, a card) so the user sees progress.
|
||||
- mode "insert-children" appends the HTML inside targetNodeId ("root" = the page); "replace" replaces targetNodeId with it.
|
||||
- Inline styles only. Flexbox (display:flex, flex-direction, gap, padding, align-items, justify-content, flex-grow, flex-wrap) is the layout model; block elements stack vertically. position:absolute with left/top is supported. No margin, grid or tables.
|
||||
- Text: font-size, font-weight (≥600 = bold), font-style, color, line-height, letter-spacing, text-align, text-decoration; <b>/<i>/<u>/<s>/<span style> make rich text. Font family is always Inter.
|
||||
- Boxes: background(-color/-image: colors, linear/radial gradients, url()), border, border-radius, opacity, overflow:hidden, mix-blend-mode, width/height (px, %, fit-content).
|
||||
- <img src> takes paper-asset:///abs/path, file:// URLs, absolute paths or data: URIs. <svg> markup is imported as vector layers.
|
||||
- <x-paper-icon name="set:name" size="20" color="var(--accent)"/> places an installed icon (search_icons); <x-paper-instance component-id="ID" layer-name="..."/> places a component instance; colors may be var(--name) of set_variables.
|
||||
- layer-name="..." names layers; <x-paper-clone node-id="ID" style="..."/> clones an existing node.
|
||||
Returns the ids of the created top-level layers and the styles that were ignored.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `html` | string | yes | HTML to parse into layers. |
|
||||
| `mode` | insert-children/replace | yes | |
|
||||
| `targetNodeId` | string | yes | Target node id, or "root" for the page. |
|
||||
|
||||
|
||||
### `update_styles`
|
||||
|
||||
Update styles on one or more nodes (camelCase CSS, like React.CSSProperties). Setting left/top on an artboard moves it. Hand-drawn keys: sketch, roughness, fillStyle, strokeStyle, seed. Styles with no effect are returned under ignoredStyles.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `updates` | array of object | yes | Each item applies styles to nodeIds. |
|
||||
|
||||
|
||||
### `set_text_content`
|
||||
|
||||
Set the text of one or more Text or Sticky nodes (batch).
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `updates` | array of object | yes | |
|
||||
|
||||
|
||||
### `rename_nodes`
|
||||
|
||||
Rename layers (batch). Names are truncated to 50 characters.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `updates` | array of object | yes | |
|
||||
|
||||
|
||||
### `delete_nodes`
|
||||
|
||||
Delete nodes and their descendants. Verify parents with get_node_info first when unsure.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `nodeIds` | array of string | yes | Nodes to delete. |
|
||||
|
||||
|
||||
### `duplicate_nodes`
|
||||
|
||||
Deep-clone nodes. Each copy goes under parentId when given, else next to its source; duplicated artboards are placed in an empty spot. Returns new ids and a descendantIdMap from every original descendant id to its copy.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `nodes` | array of object | yes | |
|
||||
|
||||
|
||||
### `move_nodes`
|
||||
|
||||
Move nodes keeping their ids. Each move is {nodeId, before: siblingId}, {nodeId, after: siblingId} or {nodeId, parentId, index?} (parentId "root" = the page root). Moves apply in order. In auto-layout parents this changes the visual order; in free parents it changes stacking and keeps the world position.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `moves` | array of object | yes | |
|
||||
|
||||
|
||||
### `create_shape`
|
||||
|
||||
Create a whiteboard element: rectangle, ellipse, diamond, triangle, polygon, star, line, arrow, sticky (sticky note with text) or text. Coordinates are world units, or relative to parentId when given. Lines and arrows take `points` ([[x,y],...], at least two) and arrows may bind to layers with startNodeId/endNodeId (the arrow then follows them). `styles` takes the same CSS as update_styles plus the hand-drawn keys sketch, roughness (0 architect, 1 artist, 2 cartoonist), fillStyle (hachure, cross-hatch, solid, zigzag, dots), strokeStyle (solid, dashed, dotted). Arrows also take route (straight, curved, elbow) and startHead/endHead (none, arrow, triangle, dot, bar, diamond).
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `endHead` | string | | |
|
||||
| `endNodeId` | string | | |
|
||||
| `height` | number | | |
|
||||
| `name` | string | | |
|
||||
| `parentId` | string | | Frame to place the shape in. |
|
||||
| `points` | array of array | | |
|
||||
| `route` | straight/curved/elbow | | |
|
||||
| `sides` | integer | | Polygon sides / star points. |
|
||||
| `startHead` | string | | |
|
||||
| `startNodeId` | string | | |
|
||||
| `styles` | object | | CSS and hand-drawn styles. |
|
||||
| `text` | string | | Text for sticky and text. |
|
||||
| `type` | rectangle/ellipse/diamond/triangle/polygon/star/line/arrow/sticky/text | yes | |
|
||||
| `width` | number | | |
|
||||
| `x` | number | | |
|
||||
| `y` | number | | |
|
||||
|
||||
|
||||
## Design system
|
||||
|
||||
|
||||
### `get_variables` (read-only)
|
||||
|
||||
Color variables of the page's project (name -> hex). Fills that follow a variable were written with `var(--name)`; set_variables moves all of them at once. `format` css returns a `:root { --name: #hex }` block, json a design-tokens object.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `format` | css/json | | |
|
||||
|
||||
|
||||
### `set_variables`
|
||||
|
||||
Define or change color variables (names like `surface`, `accent-text`), on every page. Use them as `var(--name)` in write_html / update_styles / create_artboard colors (backgroundColor, background, color) instead of hex literals, so a theme change is one call. Fills already following a changed variable are updated. Variables apply to fills and text color; strokes and gradients keep fixed colors.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `mode` | string | | Store the values for this mode (e.g. "Light", "Dark"); they apply when it is the active mode. The first mode you define becomes active. |
|
||||
| `variables` | object | yes | {"accent": "#cba6f7", "surface": "#313244"} |
|
||||
|
||||
|
||||
### `set_variable_mode`
|
||||
|
||||
Switch the active variable mode (defined with set_variables {mode}): every variable takes that mode's value and all fills following them change, on every page. One call turns a whole design from light to dark.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `mode` | string | yes | |
|
||||
|
||||
|
||||
### `get_components` (read-only)
|
||||
|
||||
Main components on a page (id, name, size) with how many instances each has.
|
||||
|
||||
|
||||
### `create_component`
|
||||
|
||||
Turn a frame or artboard into a main component. Build reusable parts (button, row, chip, tab) once as components, then place them with create_instance or <x-paper-instance>. Components and instances live on one page.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `name` | string | | Optional new layer name. |
|
||||
| `nodeId` | string | yes | The frame to convert. |
|
||||
|
||||
|
||||
### `create_instance`
|
||||
|
||||
Place an instance of a component. Edit text and styles on the instance's descendants with set_text_content / update_styles (they become overrides); edits to the main component flow to every instance. Returns the instance id and its text layers.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `componentId` | string | yes | |
|
||||
| `index` | number | | Position among the parent's children. |
|
||||
| `name` | string | | |
|
||||
| `parentId` | string | | Parent frame; omit or "root" for the page. |
|
||||
| `x` | number | | |
|
||||
| `y` | number | | |
|
||||
|
||||
|
||||
### `search_icons` (read-only)
|
||||
|
||||
Search installed open icon sets (Lucide, Tabler, Phosphor, Heroicons, Material). Returns `set:name` references for <x-paper-icon name="lucide:check" size="20" color="var(--text)"/> in write_html. Without a query it lists installed and installable sets; install with `papercraft-cli icons add lucide`.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `limit` | number | | Default 20. |
|
||||
| `query` | string | | |
|
||||
| `set` | string | | |
|
||||
|
||||
|
||||
### `lint_design` (read-only)
|
||||
|
||||
Check a page or subtree for common design mistakes and get a fix for each: text contrast (WCAG 4.5:1, 3:1 for large text), text under 12px, font sizes off the type scale, gap/padding off the 4px grid, buttons and tabs under 44px tall, default layer names (Frame, Rectangle), and hard-coded colors that equal a variable. Run it before finish_working_on_nodes and fix errors and warnings; `info` items are suggestions.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `nodeId` | string | | Subtree to check; omit for the whole page. |
|
||||
| `scale` | array of number | | Allowed font sizes. Default 12, 14, 16, 20, 24, 32, 40, 48, 64. |
|
||||
|
||||
|
||||
## Session
|
||||
|
||||
|
||||
### `select_nodes`
|
||||
|
||||
Select nodes on the page the user is viewing so they can see what you mean; zoom: true also frames them in the viewport. An empty list clears the selection.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `nodeIds` | array of string | yes | Nodes to select. |
|
||||
| `zoom` | boolean | | |
|
||||
|
||||
|
||||
### `undo`
|
||||
|
||||
Undo the last change on a page (the user's or an agent's).
|
||||
|
||||
|
||||
### `redo`
|
||||
|
||||
Redo the last undone change on a page.
|
||||
|
||||
|
||||
### `finish_working_on_nodes`
|
||||
|
||||
Call when done creating or editing. Saves the project.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `nodeIds` | array of string | | Optional nodes you worked on. |
|
||||
|
||||
@@ -142,7 +142,7 @@ impl App {
|
||||
self.ed.status = msg.clone();
|
||||
rfd::MessageDialog::new()
|
||||
.set_level(rfd::MessageLevel::Error)
|
||||
.set_title("paper-rs")
|
||||
.set_title("PaperCraft")
|
||||
.set_description(msg)
|
||||
.show();
|
||||
}
|
||||
@@ -153,7 +153,7 @@ impl App {
|
||||
return self.save_project(true);
|
||||
}
|
||||
let Some(p) = rfd::FileDialog::new()
|
||||
.add_filter("paper-rs document", &[file::EXTENSION])
|
||||
.add_filter("PaperCraft document", &[file::EXTENSION])
|
||||
.set_file_name(format!("{}.{}", self.ed.doc.name, file::EXTENSION))
|
||||
.save_file()
|
||||
else {
|
||||
@@ -267,8 +267,14 @@ impl App {
|
||||
/// Import a file into a new project and open it.
|
||||
fn import_dialog(&mut self) {
|
||||
let Some(p) = rfd::FileDialog::new()
|
||||
.add_filter("Supported files", &[file::EXTENSION, "excalidraw", "svg"])
|
||||
.add_filter("paper-rs 0.1 document", &[file::EXTENSION])
|
||||
.add_filter(
|
||||
"Supported files",
|
||||
&[file::EXTENSION, file::LEGACY_EXTENSION, "excalidraw", "svg"],
|
||||
)
|
||||
.add_filter(
|
||||
"PaperCraft document",
|
||||
&[file::EXTENSION, file::LEGACY_EXTENSION],
|
||||
)
|
||||
.add_filter("Excalidraw scene", &["excalidraw"])
|
||||
.add_filter("SVG", &["svg"])
|
||||
.pick_file()
|
||||
@@ -402,7 +408,7 @@ impl App {
|
||||
}
|
||||
|
||||
/// Bring a file into the current page: SVG, Excalidraw scene/library, image, or a
|
||||
/// paper-rs page. Without an open project, a new project is created from it.
|
||||
/// papercraft page. Without an open project, a new project is created from it.
|
||||
fn import_into_page(&mut self, p: &std::path::Path, at: Option<V2>) {
|
||||
let ext = p
|
||||
.extension()
|
||||
@@ -464,7 +470,7 @@ impl App {
|
||||
self.import_file(p);
|
||||
return;
|
||||
}
|
||||
// A paper-rs page or project: add its pages to this project.
|
||||
// A papercraft page or project: add its pages to this project.
|
||||
crate::io::import::import_as_pages(p).map(|(_, pages)| {
|
||||
let n = pages.len();
|
||||
for pg in pages {
|
||||
@@ -559,6 +565,7 @@ impl App {
|
||||
"gif",
|
||||
"webp",
|
||||
file::EXTENSION,
|
||||
file::LEGACY_EXTENSION,
|
||||
],
|
||||
)
|
||||
.pick_file()
|
||||
@@ -670,16 +677,16 @@ impl App {
|
||||
g.queue.present(frame);
|
||||
|
||||
let title = if self.st.home.is_some() {
|
||||
"paper-rs".to_string()
|
||||
"PaperCraft".to_string()
|
||||
} else if self.session.project.is_some() {
|
||||
format!(
|
||||
"{} — {} — paper-rs",
|
||||
"{} — {} — PaperCraft",
|
||||
self.st.project_name,
|
||||
self.ed.page_name()
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{}{} — paper-rs",
|
||||
"{}{} — PaperCraft",
|
||||
self.ed.doc.name,
|
||||
if self.ed.is_dirty() { " •" } else { "" }
|
||||
)
|
||||
@@ -828,12 +835,12 @@ impl ApplicationHandler<UserEvent> for App {
|
||||
return;
|
||||
}
|
||||
let attrs = Window::default_attributes()
|
||||
.with_title("paper-rs")
|
||||
.with_title("PaperCraft")
|
||||
.with_inner_size(winit::dpi::LogicalSize::new(1440.0, 900.0))
|
||||
.with_min_inner_size(winit::dpi::LogicalSize::new(720.0, 480.0));
|
||||
// PAPER_RS_OPAQUE=1 turns off vibrancy (and window transparency) on macOS.
|
||||
// PAPERCRAFT_OPAQUE=1 turns off vibrancy (and window transparency) on macOS.
|
||||
#[cfg(target_os = "macos")]
|
||||
let translucent = std::env::var_os("PAPER_RS_OPAQUE").is_none();
|
||||
let translucent = std::env::var_os("PAPERCRAFT_OPAQUE").is_none();
|
||||
#[cfg(target_os = "macos")]
|
||||
let attrs = {
|
||||
use winit::platform::macos::WindowAttributesExtMacOS;
|
||||
@@ -867,7 +874,7 @@ impl ApplicationHandler<UserEvent> for App {
|
||||
let adapter_info = adapter.get_info();
|
||||
log::info!("adapter: {adapter_info:?}");
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
label: Some("paper-rs"),
|
||||
label: Some("PaperCraft"),
|
||||
required_limits: wgpu::Limits::downlevel_defaults().using_resolution(adapter.limits()),
|
||||
..Default::default()
|
||||
}))
|
||||
@@ -1174,7 +1181,9 @@ impl ApplicationHandler<UserEvent> for App {
|
||||
{
|
||||
let at = self.ed.camera.screen_to_world(self.ed.pointer);
|
||||
self.import_into_page(&p, Some(at));
|
||||
} else if p.extension().is_some_and(|e| e == file::EXTENSION)
|
||||
} else if p
|
||||
.extension()
|
||||
.is_some_and(|e| e == file::EXTENSION || e == file::LEGACY_EXTENSION)
|
||||
&& self.confirm_discard()
|
||||
{
|
||||
match file::load(&p) {
|
||||
@@ -1379,14 +1388,14 @@ impl crate::mcp::exec::Host for App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the MCP server unless `PAPER_RS_MCP=0`. Calls wake the event loop through `proxy`.
|
||||
/// Start the MCP server unless `PAPERCRAFT_MCP=0`. Calls wake the event loop through `proxy`.
|
||||
fn start_mcp(
|
||||
proxy: winit::event_loop::EventLoopProxy<UserEvent>,
|
||||
) -> Option<(
|
||||
std::sync::mpsc::Receiver<crate::mcp::Call>,
|
||||
crate::mcp::server::Running,
|
||||
)> {
|
||||
if std::env::var("PAPER_RS_MCP").is_ok_and(|v| matches!(v.as_str(), "0" | "off" | "false")) {
|
||||
if std::env::var("PAPERCRAFT_MCP").is_ok_and(|v| matches!(v.as_str(), "0" | "off" | "false")) {
|
||||
return None;
|
||||
}
|
||||
let (tx, rx) = std::sync::mpsc::channel::<crate::mcp::Call>();
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
//! `paper-rs-cli`: drive the running paper-rs app from a shell, with no MCP client or tool
|
||||
//! `papercraft-cli`: drive the running papercraft app from a shell, with no MCP client or tool
|
||||
//! schemas in an agent's context. Same tools as the MCP server, over the same endpoint
|
||||
//! (`http://127.0.0.1:29980/mcp`, or `$PAPER_RS_MCP_PORT`).
|
||||
//! (`http://127.0.0.1:29980/mcp`, or `$PAPERCRAFT_MCP_PORT`).
|
||||
|
||||
use base64::Engine;
|
||||
use paper_rs::icons;
|
||||
use paper_rs::mcp::{self, server};
|
||||
use papercraft::icons;
|
||||
use papercraft::mcp::{self, server};
|
||||
use rmcp::ServiceExt;
|
||||
use rmcp::model::CallToolRequestParams;
|
||||
use rmcp::transport::StreamableHttpClientTransport;
|
||||
@@ -13,21 +13,21 @@ use std::io::Read;
|
||||
use std::process::ExitCode;
|
||||
|
||||
const USAGE: &str = "\
|
||||
paper-rs-cli: call the running paper-rs app's tools from a shell
|
||||
papercraft-cli: call the running papercraft app's tools from a shell
|
||||
|
||||
USAGE:
|
||||
paper-rs-cli list tool names and one-line descriptions (works offline)
|
||||
paper-rs-cli schema <tool> a tool's full description and JSON input schema
|
||||
paper-rs-cli call <tool> [JSON|-] run a tool; JSON arguments inline, or `-` for stdin
|
||||
papercraft-cli list tool names and one-line descriptions (works offline)
|
||||
papercraft-cli schema <tool> a tool's full description and JSON input schema
|
||||
papercraft-cli call <tool> [JSON|-] run a tool; JSON arguments inline, or `-` for stdin
|
||||
[--out FILE] where to save an image result (default: <tool>.png)
|
||||
paper-rs-cli icons list installed and installable open icon sets
|
||||
paper-rs-cli icons add <set>... download sets (lucide tabler phosphor heroicons material)
|
||||
paper-rs-cli icons search <words> [--set S] find `set:name` references (works offline)
|
||||
paper-rs-cli --url print the endpoint it talks to
|
||||
papercraft-cli icons list installed and installable open icon sets
|
||||
papercraft-cli icons add <set>... download sets (lucide tabler phosphor heroicons material)
|
||||
papercraft-cli icons search <words> [--set S] find `set:name` references (works offline)
|
||||
papercraft-cli --url print the endpoint it talks to
|
||||
|
||||
Icons are placed with <x-paper-icon name='lucide:check' size='20' color='var(--text)'/> in
|
||||
write_html. Sets are fetched from the npm registry with curl and tar into the icons dir
|
||||
($PAPER_RS_ICONS, else the user data dir) together with their licence file.
|
||||
($PAPERCRAFT_ICONS, else the user data dir) together with their licence file.
|
||||
|
||||
Text results go to stdout; image results are saved to a file and the path is printed.
|
||||
Exit status is 1 when the tool reports an error or the app isn't running.
|
||||
@@ -35,7 +35,7 @@ Start the app and open a project first: it serves the tools while open.
|
||||
";
|
||||
|
||||
fn fail(msg: impl std::fmt::Display) -> ExitCode {
|
||||
eprintln!("paper-rs-cli: {msg}");
|
||||
eprintln!("papercraft-cli: {msg}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ fn add_set(id: &str) -> Result<usize, String> {
|
||||
.as_str()
|
||||
.ok_or("the registry reply has no tarball")?
|
||||
.to_string();
|
||||
let tmp = std::env::temp_dir().join(format!("paper-rs-icons-{id}-{}", std::process::id()));
|
||||
let tmp = std::env::temp_dir().join(format!("papercraft-icons-{id}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
std::fs::create_dir_all(&tmp).map_err(|e| e.to_string())?;
|
||||
let archive = tmp.join("pkg.tgz");
|
||||
@@ -154,7 +154,7 @@ fn icons_cmd(args: &[String]) -> ExitCode {
|
||||
match add_set(id) {
|
||||
Ok(n) => println!("{id}: {n} icons in {}", root.join(id).display()),
|
||||
Err(e) => {
|
||||
eprintln!("paper-rs-cli: {id}: {e}");
|
||||
eprintln!("papercraft-cli: {id}: {e}");
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
@@ -186,7 +186,7 @@ fn icons_cmd(args: &[String]) -> ExitCode {
|
||||
|
||||
fn schema(name: &str) -> ExitCode {
|
||||
let Some(t) = server::tool_list().into_iter().find(|t| t.name == name) else {
|
||||
return fail(format!("no tool named {name} (see `paper-rs-cli list`)"));
|
||||
return fail(format!("no tool named {name} (see `papercraft-cli list`)"));
|
||||
};
|
||||
println!(
|
||||
"{}",
|
||||
@@ -201,7 +201,7 @@ async fn call(name: String, args: Value, out: Option<String>) -> Result<bool, St
|
||||
let client = ()
|
||||
.serve(StreamableHttpClientTransport::from_uri(url.clone()))
|
||||
.await
|
||||
.map_err(|e| format!("cannot reach paper-rs at {url}: {e}. Is the app running?"))?;
|
||||
.map_err(|e| format!("cannot reach papercraft at {url}: {e}. Is the app running?"))?;
|
||||
let mut p = CallToolRequestParams::new(name.clone());
|
||||
match args {
|
||||
Value::Object(m) => p.arguments = Some(m),
|
||||
@@ -1,11 +1,11 @@
|
||||
//! `paper-rs-mcp`: MCP over stdio for clients that can't speak HTTP, relayed to the running
|
||||
//! paper-rs app (`http://127.0.0.1:29980/mcp`, or `$PAPER_RS_MCP_PORT`).
|
||||
//! `papercraft-mcp`: MCP over stdio for clients that can't speak HTTP, relayed to the running
|
||||
//! papercraft app (`http://127.0.0.1:29980/mcp`, or `$PAPERCRAFT_MCP_PORT`).
|
||||
//!
|
||||
//! The tool list is answered locally, so the agent sees every tool even before the app is
|
||||
//! started; calls are forwarded to the app and report a clear error while it isn't running.
|
||||
//! The connection is opened lazily and re-opened after the app restarts.
|
||||
|
||||
use paper_rs::mcp::{self, server};
|
||||
use papercraft::mcp::{self, server};
|
||||
use rmcp::model::{
|
||||
CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ListToolsResult,
|
||||
PaginatedRequestParams, ResultType, ServerConfig,
|
||||
@@ -17,14 +17,14 @@ use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
const USAGE: &str = "\
|
||||
paper-rs-mcp — stdio MCP bridge to the running paper-rs app
|
||||
papercraft-mcp — stdio MCP bridge to the running papercraft app
|
||||
|
||||
USAGE:
|
||||
paper-rs-mcp serve MCP on stdin/stdout (what agent harnesses run)
|
||||
paper-rs-mcp --url print the HTTP endpoint it relays to
|
||||
paper-rs-mcp --help
|
||||
papercraft-mcp serve MCP on stdin/stdout (what agent harnesses run)
|
||||
papercraft-mcp --url print the HTTP endpoint it relays to
|
||||
papercraft-mcp --help
|
||||
|
||||
The app serves MCP itself at http://127.0.0.1:29980/mcp; set PAPER_RS_MCP_PORT to change
|
||||
The app serves MCP itself at http://127.0.0.1:29980/mcp; set PAPERCRAFT_MCP_PORT to change
|
||||
the port (in both the app's and the bridge's environment).
|
||||
";
|
||||
|
||||
@@ -42,7 +42,7 @@ impl Bridge {
|
||||
let client = ()
|
||||
.serve(transport)
|
||||
.await
|
||||
.map_err(|e| format!("cannot reach paper-rs at {}: {e}", self.url))?;
|
||||
.map_err(|e| format!("cannot reach papercraft at {}: {e}", self.url))?;
|
||||
*self.client.lock().await = Some(client);
|
||||
Ok(())
|
||||
}
|
||||
@@ -60,7 +60,7 @@ impl Bridge {
|
||||
match res {
|
||||
Ok(r) => return Ok(r),
|
||||
Err(e) if attempt == 0 => {
|
||||
log::info!("paper-rs-mcp: reconnecting after {e}");
|
||||
log::info!("papercraft-mcp: reconnecting after {e}");
|
||||
*self.client.lock().await = None;
|
||||
}
|
||||
Err(e) => return Err(e.to_string()),
|
||||
@@ -98,8 +98,8 @@ impl ServerHandler for Bridge {
|
||||
r
|
||||
}
|
||||
Err(e) => CallToolResult::error(vec![ContentBlock::text(format!(
|
||||
"{e}\nThe paper-rs app is not running or its MCP server is off. Ask the user to \
|
||||
start paper-rs (it serves MCP while open), then try again."
|
||||
"{e}\nThe papercraft app is not running or its MCP server is off. Ask the user to \
|
||||
start papercraft (it serves MCP while open), then try again."
|
||||
))]),
|
||||
};
|
||||
Ok(r.into())
|
||||
@@ -128,7 +128,7 @@ fn main() -> std::process::ExitCode {
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
eprintln!("paper-rs-mcp: {e}");
|
||||
eprintln!("papercraft-mcp: {e}");
|
||||
return std::process::ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
@@ -147,7 +147,7 @@ fn main() -> std::process::ExitCode {
|
||||
match r {
|
||||
Ok(()) => std::process::ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("paper-rs-mcp: {e}");
|
||||
eprintln!("papercraft-mcp: {e}");
|
||||
std::process::ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,8 @@
|
||||
//! and pushes a [`Change`] that can be applied in either direction.
|
||||
|
||||
use crate::geom::V2;
|
||||
use crate::model::Color;
|
||||
use crate::model::{Document, Node, NodeId};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use crate::model::{Color, Document, Node, NodeId, VarState};
|
||||
use std::collections::HashMap;
|
||||
|
||||
const MAX_DEPTH: usize = 500;
|
||||
|
||||
@@ -18,14 +17,12 @@ struct Entry {
|
||||
after: Option<Node>,
|
||||
}
|
||||
|
||||
type Variables = BTreeMap<String, Color>;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Change {
|
||||
pub label: String,
|
||||
entries: Vec<Entry>,
|
||||
root: Option<(Vec<NodeId>, Vec<NodeId>)>,
|
||||
variables: Option<(Variables, Variables)>,
|
||||
variables: Option<(VarState, VarState)>,
|
||||
next_id: (u64, u64),
|
||||
coalesce_key: Option<String>,
|
||||
}
|
||||
@@ -51,11 +48,7 @@ impl Change {
|
||||
};
|
||||
}
|
||||
if let Some((before, after)) = &self.variables {
|
||||
doc.variables = if forward {
|
||||
after.clone()
|
||||
} else {
|
||||
before.clone()
|
||||
};
|
||||
doc.restore_vars(if forward { after } else { before });
|
||||
}
|
||||
doc.next_id = if forward {
|
||||
self.next_id.1
|
||||
@@ -72,7 +65,7 @@ pub struct Recording {
|
||||
before: HashMap<NodeId, Option<Node>>,
|
||||
order: Vec<NodeId>,
|
||||
root_before: Option<Vec<NodeId>>,
|
||||
variables_before: Option<Variables>,
|
||||
variables_before: Option<VarState>,
|
||||
next_id_before: u64,
|
||||
}
|
||||
|
||||
@@ -130,10 +123,66 @@ impl<'a> Tx<'a> {
|
||||
/// Define or change a document variable and move every fill that follows it. Returns the
|
||||
/// number of fills updated.
|
||||
pub fn set_variable(&mut self, name: &str, color: Color) -> usize {
|
||||
if self.rec.variables_before.is_none() {
|
||||
self.rec.variables_before = Some(self.doc.variables.clone());
|
||||
}
|
||||
self.snapshot_variables();
|
||||
self.doc.variables.insert(name.to_string(), color);
|
||||
if let Some(mode) = self.doc.mode.clone() {
|
||||
self.doc
|
||||
.modes
|
||||
.entry(mode)
|
||||
.or_default()
|
||||
.insert(name.to_string(), color);
|
||||
}
|
||||
self.apply_variable(name, color)
|
||||
}
|
||||
|
||||
fn snapshot_variables(&mut self) {
|
||||
if self.rec.variables_before.is_none() {
|
||||
self.rec.variables_before = Some(self.doc.var_state());
|
||||
}
|
||||
}
|
||||
|
||||
/// Store `name = color` for `mode`; when that mode is the active one (or none is active
|
||||
/// yet) the live value and every fill following it change too. Returns the fills updated.
|
||||
pub fn set_variable_in_mode(&mut self, mode: &str, name: &str, color: Color) -> usize {
|
||||
self.snapshot_variables();
|
||||
if self.doc.mode.is_none() {
|
||||
self.doc.mode = Some(mode.to_string());
|
||||
}
|
||||
self.doc
|
||||
.modes
|
||||
.entry(mode.to_string())
|
||||
.or_default()
|
||||
.insert(name.to_string(), color);
|
||||
if self.doc.mode.as_deref() == Some(mode) {
|
||||
self.doc.variables.insert(name.to_string(), color);
|
||||
self.apply_variable(name, color)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Switch the active mode: every variable takes its value for `mode` (variables a mode does
|
||||
/// not define keep their current value) and the fills following them move. Returns the
|
||||
/// fills updated.
|
||||
pub fn set_mode(&mut self, mode: &str) -> usize {
|
||||
self.snapshot_variables();
|
||||
let values: Vec<(String, Color)> = self
|
||||
.doc
|
||||
.modes
|
||||
.get(mode)
|
||||
.map(|m| m.iter().map(|(k, v)| (k.clone(), *v)).collect())
|
||||
.unwrap_or_default();
|
||||
self.doc.mode = Some(mode.to_string());
|
||||
let mut fills = 0;
|
||||
for (name, c) in values {
|
||||
self.doc.variables.insert(name.clone(), c);
|
||||
fills += self.apply_variable(&name, c);
|
||||
}
|
||||
fills
|
||||
}
|
||||
|
||||
/// Move every fill following `name` to `color`.
|
||||
fn apply_variable(&mut self, name: &str, color: Color) -> usize {
|
||||
let users: Vec<NodeId> = self
|
||||
.doc
|
||||
.nodes
|
||||
@@ -270,8 +319,8 @@ impl History {
|
||||
.map(|b| (b, doc.root.clone()));
|
||||
let variables = rec
|
||||
.variables_before
|
||||
.filter(|b| *b != doc.variables)
|
||||
.map(|b| (b, doc.variables.clone()));
|
||||
.filter(|b| *b != doc.var_state())
|
||||
.map(|b| (b, doc.var_state()));
|
||||
if entries.is_empty() && root.is_none() && variables.is_none() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Open icon libraries on disk: `<icons dir>/<set>/<name>.svg`.
|
||||
//!
|
||||
//! The app never bundles or downloads icons: `paper-rs-cli icons add <set>` fetches a set from
|
||||
//! the npm registry into the icons dir (`$PAPER_RS_ICONS`, else `<data dir>/paper-rs/icons`), and
|
||||
//! The app never bundles or downloads icons: `papercraft-cli icons add <set>` fetches a set from
|
||||
//! the npm registry into the icons dir (`$PAPERCRAFT_ICONS`, else `<data dir>/papercraft/icons`), and
|
||||
//! `write_html` places them with `<x-paper-icon name="lucide:check">`. Each set keeps the
|
||||
//! licence file of the package it came from next to the icons.
|
||||
|
||||
@@ -60,9 +60,18 @@ pub fn spec(id: &str) -> Option<&'static SetSpec> {
|
||||
|
||||
/// Where the sets live.
|
||||
pub fn dir() -> PathBuf {
|
||||
std::env::var_os("PAPER_RS_ICONS")
|
||||
std::env::var_os("PAPERCRAFT_ICONS")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| dirs::data_dir().map(|d| d.join("paper-rs").join("icons")))
|
||||
.or_else(|| {
|
||||
let data = dirs::data_dir()?;
|
||||
let new = data.join("papercraft").join("icons");
|
||||
let legacy = data.join("paper-rs").join("icons");
|
||||
Some(if legacy.is_dir() && !new.exists() {
|
||||
legacy
|
||||
} else {
|
||||
new
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| PathBuf::from("icons"))
|
||||
}
|
||||
|
||||
@@ -152,7 +161,7 @@ pub fn load(root: &Path, reference: &str) -> Result<String, String> {
|
||||
}
|
||||
if installed(root).is_empty() {
|
||||
return Err(
|
||||
"no icon sets installed; run `paper-rs-cli icons add lucide` (see `paper-rs-cli icons list`)"
|
||||
"no icon sets installed; run `papercraft-cli icons add lucide` (see `papercraft-cli icons list`)"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
@@ -173,7 +182,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn rejects_path_tricks_and_ranks_exact_names_first() {
|
||||
let root = std::env::temp_dir().join(format!("paper-rs-icons-{:016x}", fastrand::u64(..)));
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("papercraft-icons-{:016x}", fastrand::u64(..)));
|
||||
let set = root.join("t");
|
||||
std::fs::create_dir_all(&set).unwrap();
|
||||
for n in ["check", "check-circle", "arrow-check", "x"] {
|
||||
|
||||
@@ -67,7 +67,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn replaces_file_and_leaves_no_temp() {
|
||||
let dir = std::env::temp_dir().join(format!("paper-rs-atomic-{}", fastrand::u64(..)));
|
||||
let dir = std::env::temp_dir().join(format!("papercraft-atomic-{}", fastrand::u64(..)));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let p = dir.join("a.json");
|
||||
write(&p, b"one").unwrap();
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! Field meanings, defaults and conversions follow Excalidraw's `restore.ts` and element
|
||||
//! types (Copyright (c) 2020 Excalidraw, MIT; see THIRD_PARTY.md and
|
||||
//! `analysis/EXCALIDRAW_NOTES.md` §1, §6, §8). Rotated shapes are baked into vector paths
|
||||
//! (paper-rs has no rotation); groups become group layers; frames clip their children.
|
||||
//! (papercraft has no rotation); groups become group layers; frames clip their children.
|
||||
|
||||
use crate::geom::{Rect, V2, v2};
|
||||
use crate::history::{Recording, Tx};
|
||||
|
||||
@@ -8,7 +8,9 @@ use std::path::Path;
|
||||
|
||||
pub const FORMAT: &str = "paper-rs";
|
||||
pub const VERSION: u32 = 1;
|
||||
pub const EXTENSION: &str = "paper-rs";
|
||||
pub const EXTENSION: &str = "papercraft";
|
||||
/// The extension before the rename; still opened.
|
||||
pub const LEGACY_EXTENSION: &str = "paper-rs";
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct FileV1 {
|
||||
@@ -82,7 +84,7 @@ pub fn from_str(s: &str) -> Result<(Document, Option<Camera>), FileError> {
|
||||
|
||||
/// Write atomically (temp file + rename) so a crash never leaves a truncated document.
|
||||
pub fn save(path: &Path, doc: &Document, camera: Option<&Camera>) -> Result<(), FileError> {
|
||||
let tmp = path.with_extension("paper-rs.tmp");
|
||||
let tmp = path.with_extension("papercraft.tmp");
|
||||
std::fs::write(&tmp, to_string(doc, camera)).map_err(FileError::Io)?;
|
||||
std::fs::rename(&tmp, path).map_err(FileError::Io)
|
||||
}
|
||||
@@ -133,7 +135,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn save_and_load_file() {
|
||||
let dir = std::env::temp_dir().join(format!("paper-rs-test-{}", std::process::id()));
|
||||
let dir = std::env::temp_dir().join(format!("papercraft-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("doc.paper-rs");
|
||||
let ed = demo::showcase();
|
||||
|
||||
@@ -531,7 +531,7 @@ pub fn export(pages: &[PdfPage]) -> Vec<u8> {
|
||||
}
|
||||
let _ = write!(
|
||||
x,
|
||||
"trailer\n<< /Size {} /Root {catalog} 0 R /Info << /Producer (paper-rs) >> >>\nstartxref\n{xref}\n%%EOF\n",
|
||||
"trailer\n<< /Size {} /Root {catalog} 0 R /Info << /Producer (papercraft) >> >>\nstartxref\n{xref}\n%%EOF\n",
|
||||
objs.objs.len() + 1
|
||||
);
|
||||
out.extend_from_slice(x.as_bytes());
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! paper-rs: a native infinite canvas for planning, working and sketching.
|
||||
//! papercraft: a native infinite canvas for planning, working and sketching.
|
||||
|
||||
pub mod app;
|
||||
pub mod assets;
|
||||
|
||||
@@ -258,7 +258,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn save_load_roundtrip_and_starter() {
|
||||
let dir = std::env::temp_dir().join(format!("paper-rs-lib-{:016x}", fastrand::u64(..)));
|
||||
let dir = std::env::temp_dir().join(format!("papercraft-lib-{:016x}", fastrand::u64(..)));
|
||||
let path = dir.join(FILE);
|
||||
let lib = Library::load(&path);
|
||||
assert!(lib.items.len() >= 5, "starter shapes on first use");
|
||||
|
||||
@@ -242,7 +242,7 @@ pub fn install_menu() -> NativeMenu {
|
||||
idx
|
||||
}
|
||||
|
||||
let quit = MenuItem::new("Quit paper-rs", true, acc(cmd, Code::KeyQ));
|
||||
let quit = MenuItem::new("Quit PaperCraft", true, acc(cmd, Code::KeyQ));
|
||||
cmds.insert(quit.id().clone(), MenuCmd::Quit);
|
||||
let _ = (A::Undo, Run(A::Undo));
|
||||
let snapping = CheckMenuItem::new("Snapping", true, true, acc(cmd, Code::Quote));
|
||||
@@ -268,10 +268,10 @@ pub fn install_menu() -> NativeMenu {
|
||||
|
||||
let sep = PredefinedMenuItem::separator;
|
||||
let app_menu = Submenu::with_items(
|
||||
"paper-rs",
|
||||
"PaperCraft",
|
||||
true,
|
||||
&[
|
||||
&PredefinedMenuItem::about(Some("About paper-rs"), None),
|
||||
&PredefinedMenuItem::about(Some("About PaperCraft"), None),
|
||||
&sep(),
|
||||
&PredefinedMenuItem::services(None),
|
||||
&sep(),
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
use paper_rs::app::Start;
|
||||
use paper_rs::editor::Editor;
|
||||
use paper_rs::io::{export, file, import, png, svg};
|
||||
use paper_rs::model::Color;
|
||||
use paper_rs::project::{MANIFEST, Store};
|
||||
use paper_rs::render::{frame, gpu::Headless};
|
||||
use paper_rs::session::Session;
|
||||
use paper_rs::ui::UiState;
|
||||
use paper_rs::ui::home::HomeView;
|
||||
use papercraft::app::Start;
|
||||
use papercraft::editor::Editor;
|
||||
use papercraft::io::{export, file, import, png, svg};
|
||||
use papercraft::model::Color;
|
||||
use papercraft::project::{MANIFEST, Store};
|
||||
use papercraft::render::{frame, gpu::Headless};
|
||||
use papercraft::session::Session;
|
||||
use papercraft::ui::UiState;
|
||||
use papercraft::ui::home::HomeView;
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
|
||||
const USAGE: &str = "\
|
||||
paper-rs — infinite canvas for planning, working and sketching
|
||||
papercraft — infinite canvas for planning, working and sketching
|
||||
|
||||
USAGE:
|
||||
paper-rs open the projects home screen
|
||||
paper-rs PROJECT_DIR open a project folder
|
||||
paper-rs FILE.paper-rs open a paper-rs 0.1 document (not autosaved)
|
||||
paper-rs --demo open the app with a sample board
|
||||
paper-rs --screenshot OUT.png [--home | PROJECT|FILE|--demo] [--size WxH] [--scale S]
|
||||
papercraft open the projects home screen
|
||||
papercraft PROJECT_DIR open a project folder
|
||||
papercraft FILE.papercraft open a PaperCraft document (not autosaved)
|
||||
papercraft --demo open the app with a sample board
|
||||
papercraft --screenshot OUT.png [--home | PROJECT|FILE|--demo] [--size WxH] [--scale S]
|
||||
render the full UI headlessly to a PNG
|
||||
paper-rs --export-png OUT.png PROJECT|FILE [--page N] [--scale S] [--transparent]
|
||||
paper-rs --export-svg OUT.svg PROJECT|FILE [--page N]
|
||||
paper-rs --export-pdf OUT.pdf PROJECT|FILE [--page N] [--frames | --all-pages]
|
||||
paper-rs --export OUT PROJECT|FILE --format png|svg|pdf [--scope page|frames|all]
|
||||
papercraft --export-png OUT.png PROJECT|FILE [--page N] [--scale S] [--transparent]
|
||||
papercraft --export-svg OUT.svg PROJECT|FILE [--page N]
|
||||
papercraft --export-pdf OUT.pdf PROJECT|FILE [--page N] [--frames | --all-pages]
|
||||
papercraft --export OUT PROJECT|FILE --format png|svg|pdf [--scope page|frames|all]
|
||||
[--page N] [--scale S] [--transparent]
|
||||
(frames/all with PNG/SVG write a folder)
|
||||
paper-rs --import FILE create a project from .excalidraw, .svg or
|
||||
.paper-rs and print its folder
|
||||
paper-rs --help
|
||||
papercraft --import FILE create a project from .excalidraw, .svg or
|
||||
.papercraft and print its folder
|
||||
papercraft --help
|
||||
|
||||
Projects live in ~/Documents/paper-rs (override with PAPER_RS_HOME).
|
||||
Projects live in ~/Documents/papercraft (override with PAPERCRAFT_HOME).
|
||||
";
|
||||
|
||||
fn is_project(p: &std::path::Path) -> bool {
|
||||
@@ -39,7 +39,7 @@ fn is_project(p: &std::path::Path) -> bool {
|
||||
|
||||
fn load_editor(path: Option<&str>, demo: bool) -> Result<Editor, String> {
|
||||
if demo {
|
||||
return Ok(paper_rs::demo::showcase());
|
||||
return Ok(papercraft::demo::showcase());
|
||||
}
|
||||
let Some(p) = path else {
|
||||
return Ok(Editor::default());
|
||||
@@ -128,7 +128,7 @@ fn run(args: Vec<String>) -> Result<(), String> {
|
||||
let mut st = UiState::default();
|
||||
if flag("--home") {
|
||||
let store = Store::open(Store::default_root()).map_err(|e| e.to_string())?;
|
||||
st.home = Some(HomeView::load(&store, paper_rs::project::now_ms()));
|
||||
st.home = Some(HomeView::load(&store, papercraft::project::now_ms()));
|
||||
}
|
||||
let (w, h) = match value("--size") {
|
||||
Some(s) => {
|
||||
@@ -153,9 +153,9 @@ fn run(args: Vec<String>) -> Result<(), String> {
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
let page_doc = |input: Option<&str>| -> Result<paper_rs::model::Document, String> {
|
||||
let page_doc = |input: Option<&str>| -> Result<papercraft::model::Document, String> {
|
||||
if flag("--demo") {
|
||||
return Ok(paper_rs::demo::showcase().doc);
|
||||
return Ok(papercraft::demo::showcase().doc);
|
||||
}
|
||||
let path = input.ok_or("missing input file")?;
|
||||
let pages = import::load_any(path.as_ref())?;
|
||||
@@ -181,11 +181,11 @@ fn run(args: Vec<String>) -> Result<(), String> {
|
||||
}
|
||||
let export_with =
|
||||
|out: &str, format: export::Format, scope: export::Scope| -> Result<(), String> {
|
||||
let pages: Vec<paper_rs::project::PageData> = if flag("--demo") {
|
||||
vec![paper_rs::project::PageData {
|
||||
let pages: Vec<papercraft::project::PageData> = if flag("--demo") {
|
||||
vec![papercraft::project::PageData {
|
||||
id: "demo".into(),
|
||||
name: "Launch plan".into(),
|
||||
doc: paper_rs::demo::showcase().doc,
|
||||
doc: papercraft::demo::showcase().doc,
|
||||
camera: None,
|
||||
}]
|
||||
} else {
|
||||
@@ -194,7 +194,7 @@ fn run(args: Vec<String>) -> Result<(), String> {
|
||||
if page >= pages.len() {
|
||||
return Err(format!("no page {page}"));
|
||||
}
|
||||
let refs: Vec<(&str, &paper_rs::model::Document)> =
|
||||
let refs: Vec<(&str, &papercraft::model::Document)> =
|
||||
pages.iter().map(|p| (p.name.as_str(), &p.doc)).collect();
|
||||
let spec = export::Spec {
|
||||
format,
|
||||
@@ -248,19 +248,19 @@ fn run(args: Vec<String>) -> Result<(), String> {
|
||||
let pages = match ext.as_str() {
|
||||
"excalidraw" | "json" => {
|
||||
let t = std::fs::read_to_string(&p).map_err(|e| e.to_string())?;
|
||||
vec![paper_rs::project::PageData {
|
||||
id: paper_rs::project::new_id(),
|
||||
vec![papercraft::project::PageData {
|
||||
id: papercraft::project::new_id(),
|
||||
name: "Page 1".into(),
|
||||
doc: paper_rs::io::excalidraw::import_scene(&t)?,
|
||||
doc: papercraft::io::excalidraw::import_scene(&t)?,
|
||||
camera: None,
|
||||
}]
|
||||
}
|
||||
"svg" => {
|
||||
let t = std::fs::read_to_string(&p).map_err(|e| e.to_string())?;
|
||||
vec![paper_rs::project::PageData {
|
||||
id: paper_rs::project::new_id(),
|
||||
vec![papercraft::project::PageData {
|
||||
id: papercraft::project::new_id(),
|
||||
name: "Page 1".into(),
|
||||
doc: paper_rs::io::svg_import::import_page(&t, &name)?,
|
||||
doc: papercraft::io::svg_import::import_page(&t, &name)?,
|
||||
camera: None,
|
||||
}]
|
||||
}
|
||||
@@ -270,9 +270,9 @@ fn run(args: Vec<String>) -> Result<(), String> {
|
||||
.create_with_pages(&name, pages)
|
||||
.map_err(|e| e.to_string())?;
|
||||
for pg in proj.load_pages().map_err(|e| e.to_string())? {
|
||||
paper_rs::assets::save_to_dir(
|
||||
&proj.dir.join(paper_rs::project::ASSETS_DIR),
|
||||
paper_rs::assets::used_by(&pg.doc),
|
||||
papercraft::assets::save_to_dir(
|
||||
&proj.dir.join(papercraft::project::ASSETS_DIR),
|
||||
papercraft::assets::used_by(&pg.doc),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
@@ -285,5 +285,5 @@ fn run(args: Vec<String>) -> Result<(), String> {
|
||||
Some(p) if is_project(p.as_ref()) => Start::Project(PathBuf::from(p)),
|
||||
Some(p) => Start::Scratch(Box::new(load_editor(Some(p), false)?)),
|
||||
};
|
||||
paper_rs::app::run(start)
|
||||
papercraft::app::run(start)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! Agents describe styles the way Paper's MCP server expects them: React-style camelCase CSS
|
||||
//! property maps (`{"backgroundColor": "#fff", "padding": "20px"}`) or inline `style="..."`
|
||||
//! attributes. This module maps the subset that has a paper-rs equivalent onto `Style`,
|
||||
//! attributes. This module maps the subset that has a papercraft equivalent onto `Style`,
|
||||
//! `TextStyle`, `AutoLayout` and `LayoutChild`, reports the rest as ignored, and turns nodes
|
||||
//! back into CSS for `get_computed_styles` / `get_jsx`.
|
||||
|
||||
@@ -501,7 +501,7 @@ pub struct Context {
|
||||
/// What `apply` did besides changing the node.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Applied {
|
||||
/// Properties with no effect in paper-rs (reported back to the agent).
|
||||
/// Properties with no effect in papercraft (reported back to the agent).
|
||||
pub ignored: Vec<String>,
|
||||
/// Image URL to resolve and use as the top fill (resolved by the caller).
|
||||
pub image: Option<(String, ImageMode)>,
|
||||
@@ -1131,7 +1131,7 @@ fn apply_resolved(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> A
|
||||
}
|
||||
None => false,
|
||||
},
|
||||
// Excalidraw-style hand-drawn rendering (paper-rs extensions).
|
||||
// Excalidraw-style hand-drawn rendering (papercraft extensions).
|
||||
"sketch" | "handDrawn" => {
|
||||
if matches!(lower.as_str(), "true" | "1" | "on" | "yes") {
|
||||
rough_mut(n);
|
||||
|
||||
@@ -41,7 +41,7 @@ pub trait Host {
|
||||
}
|
||||
|
||||
/// A host without a window: an editor plus a project session. Used by tests and anything
|
||||
/// that wants to drive paper-rs headlessly.
|
||||
/// that wants to drive papercraft headlessly.
|
||||
pub struct SessionHost {
|
||||
pub ed: Editor,
|
||||
pub session: Session,
|
||||
@@ -280,7 +280,9 @@ pub fn call(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult {
|
||||
"update_styles" => update_styles(ed, &a),
|
||||
"set_text_content" => set_text_content(ed, &a),
|
||||
"get_variables" => get_variables(ed, &a),
|
||||
"lint_design" => lint_design(ed, &a),
|
||||
"set_variables" => set_variables(ed, &a),
|
||||
"set_variable_mode" => set_variable_mode(ed, &a),
|
||||
"get_components" => get_components(ed, &a),
|
||||
"create_component" => create_component(ed, &a),
|
||||
"create_instance" => create_instance(ed, &a),
|
||||
@@ -332,7 +334,7 @@ fn find_project(store: &Store, key: &str) -> Result<PathBuf, String> {
|
||||
fn check_file(host: &mut dyn Host, a: &Args) -> Result<(), String> {
|
||||
if !host.canvas_open() {
|
||||
return Err(
|
||||
"No project is open in paper-rs (the home screen is showing). Call list_resources \
|
||||
"No project is open in papercraft (the home screen is showing). Call list_resources \
|
||||
and open_file, or create_file then open_file."
|
||||
.into(),
|
||||
);
|
||||
@@ -420,9 +422,9 @@ fn create_file(host: &mut dyn Host, a: &Args) -> ToolResult {
|
||||
}
|
||||
|
||||
fn create_page(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
let vars = ed.page_doc(ed.active_page).variables.clone();
|
||||
let vars = ed.page_doc(ed.active_page).var_state();
|
||||
let i = ed.add_page_in_background(a.str("name"));
|
||||
ed.with_page(i, |ed| ed.doc.variables = vars);
|
||||
ed.with_page(i, |ed| ed.doc.restore_vars(&vars));
|
||||
text(json!({"pageId": ed.pages[i].id, "name": ed.pages[i].name}))
|
||||
}
|
||||
|
||||
@@ -869,14 +871,14 @@ fn font_info(a: &Args) -> ToolResult {
|
||||
fn guide(a: &Args) -> ToolResult {
|
||||
let t = a.req_str("topic")?;
|
||||
let s = match t {
|
||||
"paper-rs-mcp-instructions" | "paper-mcp-instructions" | "instructions" => {
|
||||
"papercraft-mcp-instructions" | "paper-mcp-instructions" | "instructions" => {
|
||||
tools::GUIDE_INSTRUCTIONS
|
||||
}
|
||||
"html" | "write_html" => tools::GUIDE_HTML,
|
||||
"whiteboard" | "excalidraw" => tools::GUIDE_WHITEBOARD,
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"unknown topic `{t}`; topics: paper-rs-mcp-instructions, html, whiteboard"
|
||||
"unknown topic `{t}`; topics: papercraft-mcp-instructions, html, whiteboard"
|
||||
));
|
||||
}
|
||||
};
|
||||
@@ -1136,7 +1138,7 @@ fn export_pdf(host: &mut dyn Host, a: &Args) -> ToolResult {
|
||||
None => {
|
||||
let dir = out_dir(a);
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
unique_path(&dir, project_name.as_deref().unwrap_or("paper-rs"), "pdf")
|
||||
unique_path(&dir, project_name.as_deref().unwrap_or("papercraft"), "pdf")
|
||||
}
|
||||
};
|
||||
let spec = Spec {
|
||||
@@ -2674,13 +2676,38 @@ fn set_text_content(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
|
||||
fn get_variables(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
let page = page_arg(ed, a)?;
|
||||
let vars: Map<String, Value> = ed
|
||||
.page_doc(page)
|
||||
let doc = ed.page_doc(page);
|
||||
match a.str("format") {
|
||||
Some("css") => {
|
||||
let body: String = doc
|
||||
.variables
|
||||
.iter()
|
||||
.map(|(k, c)| format!(" --{k}: {};\n", c.to_hex()))
|
||||
.collect();
|
||||
return Ok(vec![Out::Text(format!(":root {{\n{body}}}\n"))]);
|
||||
}
|
||||
Some("json") => {
|
||||
let tokens: Map<String, Value> = doc
|
||||
.variables
|
||||
.iter()
|
||||
.map(|(k, c)| (k.clone(), json!({"$type": "color", "$value": c.to_hex()})))
|
||||
.collect();
|
||||
return text(Value::Object(tokens));
|
||||
}
|
||||
Some(f) => return Err(format!("unknown format `{f}` (css or json)")),
|
||||
None => {}
|
||||
}
|
||||
let vars: Map<String, Value> = doc
|
||||
.variables
|
||||
.iter()
|
||||
.map(|(k, c)| (k.clone(), json!(c.to_hex())))
|
||||
.collect();
|
||||
text(json!({"variables": vars, "use": "var(--name) in color styles"}))
|
||||
let mut v = json!({"variables": vars, "use": "var(--name) in color styles"});
|
||||
if !doc.modes.is_empty() {
|
||||
v["mode"] = json!(doc.mode);
|
||||
v["modes"] = json!(doc.modes.keys().collect::<Vec<_>>());
|
||||
}
|
||||
text(v)
|
||||
}
|
||||
|
||||
fn set_variables(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
@@ -2707,13 +2734,17 @@ fn set_variables(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
.ok_or_else(|| format!("variable `{name}`: `{v}` is not a color"))?;
|
||||
parsed.push((name, c));
|
||||
}
|
||||
let mode = a.str("mode").filter(|m| !m.is_empty());
|
||||
let mut updated: Map<String, Value> = Map::new();
|
||||
for page in 0..ed.pages.len() {
|
||||
let counts = ed.with_page(page, |ed| {
|
||||
ed.edit("Set variables", |tx| {
|
||||
parsed
|
||||
.iter()
|
||||
.map(|(n, c)| tx.set_variable(n, *c))
|
||||
.map(|(n, c)| match mode {
|
||||
Some(m) => tx.set_variable_in_mode(m, n, *c),
|
||||
None => tx.set_variable(n, *c),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
});
|
||||
@@ -2725,8 +2756,62 @@ fn set_variables(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
text(json!({"fillsUpdated": updated, "pages": ed.pages.len()}))
|
||||
}
|
||||
|
||||
fn lint_design(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
let def = page_arg(ed, a)?;
|
||||
let (page, roots) = match a.str("nodeId").filter(|n| !n.is_empty() && *n != "root") {
|
||||
Some(n) => {
|
||||
let (p, id) = resolve(ed, n, def)?;
|
||||
(p, vec![id])
|
||||
}
|
||||
None => (def, ed.page_doc(def).root.clone()),
|
||||
};
|
||||
let scale: Vec<f64> = match a.get("scale").and_then(Value::as_array) {
|
||||
Some(v) => v.iter().filter_map(Value::as_f64).collect(),
|
||||
None => super::lint::DEFAULT_SCALE.to_vec(),
|
||||
};
|
||||
let doc = ed.page_doc(page);
|
||||
let issues = super::lint::lint(doc, &roots, &scale);
|
||||
let count = |sev: &str| issues.iter().filter(|i| i.severity == sev).count();
|
||||
let mut by_rule: Map<String, Value> = Map::new();
|
||||
for i in &issues {
|
||||
let e = by_rule.entry(i.rule.to_string()).or_insert(json!(0));
|
||||
*e = json!(e.as_u64().unwrap_or(0) + 1);
|
||||
}
|
||||
let list: Vec<Value> = issues
|
||||
.iter()
|
||||
.take(200)
|
||||
.map(|i| {
|
||||
json!({"rule": i.rule, "severity": i.severity, "node": node_key(ed, page, i.node),
|
||||
"name": doc.get(i.node).map(|n| n.name.clone()), "message": i.message, "fix": i.fix})
|
||||
})
|
||||
.collect();
|
||||
text(json!({
|
||||
"errors": count("error"), "warnings": count("warning"), "info": count("info"),
|
||||
"byRule": by_rule, "issues": list,
|
||||
"truncated": issues.len() > 200,
|
||||
}))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------ components
|
||||
|
||||
fn set_variable_mode(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
let mode = a.req_str("mode")?.to_string();
|
||||
if !ed.page_doc(ed.active_page).modes.contains_key(&mode) {
|
||||
let known: Vec<String> = ed.page_doc(ed.active_page).modes.keys().cloned().collect();
|
||||
return Err(format!(
|
||||
"no mode `{mode}`; define it with set_variables {{mode}} first (modes: {})",
|
||||
known.join(", ")
|
||||
));
|
||||
}
|
||||
let mut fills = 0;
|
||||
for page in 0..ed.pages.len() {
|
||||
fills += ed.with_page(page, |ed| {
|
||||
ed.edit("Set variable mode", |tx| tx.set_mode(&mode))
|
||||
});
|
||||
}
|
||||
text(json!({"mode": mode, "fillsUpdated": fills}))
|
||||
}
|
||||
|
||||
fn get_components(ed: &mut Editor, a: &Args) -> ToolResult {
|
||||
let page = page_arg(ed, a)?;
|
||||
let doc = ed.page_doc(page);
|
||||
@@ -2861,7 +2946,7 @@ fn search_icons(a: &Args) -> ToolResult {
|
||||
.map(|s| json!({"set": s.id, "license": s.license, "about": s.about}))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
v["install"] = json!("paper-rs-cli icons add <set>");
|
||||
v["install"] = json!("papercraft-cli icons add <set>");
|
||||
}
|
||||
}
|
||||
text(v)
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
//! `lint_design`: checks a design for the mistakes agents make most, with a concrete fix for each.
|
||||
//!
|
||||
//! The rules are conventions, not law: WCAG 2.x contrast, a 4px spacing grid, a type scale, 44px
|
||||
//! hit areas for buttons and tabs, real layer names, and colors that should be variables. Each
|
||||
//! issue says which node, what is wrong, and what to change.
|
||||
|
||||
use crate::model::{Color, Document, Node, NodeId, Paint};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Issue {
|
||||
pub rule: &'static str,
|
||||
pub severity: &'static str,
|
||||
pub node: NodeId,
|
||||
pub message: String,
|
||||
pub fix: String,
|
||||
}
|
||||
|
||||
/// Sizes the type-scale rule accepts by default.
|
||||
pub const DEFAULT_SCALE: [f64; 9] = [12.0, 14.0, 16.0, 20.0, 24.0, 32.0, 40.0, 48.0, 64.0];
|
||||
|
||||
/// WCAG relative luminance of an sRGB color.
|
||||
fn luminance(c: Color) -> f64 {
|
||||
let lin = |v: u8| {
|
||||
let v = v as f64 / 255.0;
|
||||
if v <= 0.03928 {
|
||||
v / 12.92
|
||||
} else {
|
||||
((v + 0.055) / 1.055).powf(2.4)
|
||||
}
|
||||
};
|
||||
0.2126 * lin(c.r) + 0.7152 * lin(c.g) + 0.0722 * lin(c.b)
|
||||
}
|
||||
|
||||
/// WCAG contrast ratio, 1..=21.
|
||||
pub fn contrast(a: Color, b: Color) -> f64 {
|
||||
let (la, lb) = (luminance(a), luminance(b));
|
||||
(la.max(lb) + 0.05) / (la.min(lb) + 0.05)
|
||||
}
|
||||
|
||||
/// `over` composited on an opaque `base`.
|
||||
fn blend(base: Color, over: Color) -> Color {
|
||||
let a = over.a as f64 / 255.0;
|
||||
let mix = |b: u8, o: u8| (b as f64 * (1.0 - a) + o as f64 * a).round() as u8;
|
||||
Color::rgb(
|
||||
mix(base.r, over.r),
|
||||
mix(base.g, over.g),
|
||||
mix(base.b, over.b),
|
||||
)
|
||||
}
|
||||
|
||||
fn solid(n: &Node) -> Option<(Color, Option<&str>)> {
|
||||
match n.style.fills.last() {
|
||||
Some(Paint::Solid { color, var }) if color.a > 0 => Some((*color, var.as_deref())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The color text sits on: the nearest opaque fill up the tree (translucent ones blend over
|
||||
/// what is behind them); white when nothing is filled.
|
||||
fn backdrop(doc: &Document, id: NodeId) -> Color {
|
||||
let mut layers: Vec<Color> = Vec::new();
|
||||
let mut cur = doc.get(id).and_then(|n| n.parent);
|
||||
while let Some(p) = cur {
|
||||
let Some(n) = doc.get(p) else { break };
|
||||
if n.visible
|
||||
&& let Some((c, _)) = solid(n)
|
||||
{
|
||||
layers.push(c);
|
||||
if c.a == 255 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
cur = n.parent;
|
||||
}
|
||||
layers
|
||||
.into_iter()
|
||||
.rev()
|
||||
.fold(Color::rgb(255, 255, 255), blend)
|
||||
}
|
||||
|
||||
fn is_default_name(name: &str) -> bool {
|
||||
let base = name.trim_end_matches(|c: char| c.is_ascii_digit() || c == ' ');
|
||||
matches!(
|
||||
base,
|
||||
"Frame"
|
||||
| "Rectangle"
|
||||
| "Ellipse"
|
||||
| "Group"
|
||||
| "Path"
|
||||
| "Line"
|
||||
| "Arrow"
|
||||
| "Layer"
|
||||
| "Vector"
|
||||
| "Polygon"
|
||||
| "Star"
|
||||
| "Diamond"
|
||||
| "Triangle"
|
||||
| "Image"
|
||||
| "Icon"
|
||||
) && name.len() < 14
|
||||
}
|
||||
|
||||
fn off_grid(v: f64) -> bool {
|
||||
v > 0.0 && (v / 4.0 - (v / 4.0).round()).abs() > 1e-6
|
||||
}
|
||||
|
||||
/// Lint the subtrees under `roots` (instances are skipped: their component is linted instead).
|
||||
pub fn lint(doc: &Document, roots: &[NodeId], scale: &[f64]) -> Vec<Issue> {
|
||||
let mut out: Vec<Issue> = Vec::new();
|
||||
let mut ids: Vec<NodeId> = Vec::new();
|
||||
for r in roots {
|
||||
ids.extend(doc.subtree(*r));
|
||||
}
|
||||
let vars: BTreeMap<String, Color> = doc.variables.clone();
|
||||
// Colors used literally, with the first node using each, for the variables report.
|
||||
let mut literal: BTreeMap<String, (usize, NodeId)> = BTreeMap::new();
|
||||
for id in ids {
|
||||
let Some(n) = doc.get(id) else { continue };
|
||||
if !n.visible || n.instance.is_some() {
|
||||
continue;
|
||||
}
|
||||
let mut add = |rule: &'static str, severity: &'static str, message: String, fix: String| {
|
||||
out.push(Issue {
|
||||
rule,
|
||||
severity,
|
||||
node: id,
|
||||
message,
|
||||
fix,
|
||||
});
|
||||
};
|
||||
// ---- text
|
||||
if let (Some(size), Some(_)) = (n.kind.font_size(), n.kind.text()) {
|
||||
if let Some((fg, _)) = solid(n) {
|
||||
let bg = backdrop(doc, id);
|
||||
let fg = blend(bg, fg);
|
||||
let ratio = contrast(fg, bg);
|
||||
let large = size >= 24.0 || (size >= 18.66 && n.text_style.bold);
|
||||
let need = if large { 3.0 } else { 4.5 };
|
||||
if ratio < need {
|
||||
add(
|
||||
"contrast",
|
||||
"error",
|
||||
format!(
|
||||
"text {} on {} is {ratio:.1}:1 (needs {need}:1)",
|
||||
fg.to_hex(),
|
||||
bg.to_hex()
|
||||
),
|
||||
"use a lighter or darker text color, or change the background".into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
if size < 12.0 {
|
||||
add(
|
||||
"text-size",
|
||||
"warning",
|
||||
format!("{size}px text is hard to read"),
|
||||
"use 12px or more (14px+ for body copy)".into(),
|
||||
);
|
||||
} else if !scale.is_empty() && !scale.iter().any(|s| (s - size).abs() < 0.01) {
|
||||
let near = scale
|
||||
.iter()
|
||||
.min_by(|a, b| (*a - size).abs().total_cmp(&(*b - size).abs()))
|
||||
.copied()
|
||||
.unwrap_or(size);
|
||||
add(
|
||||
"type-scale",
|
||||
"info",
|
||||
format!("{size}px is not on the type scale"),
|
||||
format!("use {near}px (scale: {})", join(scale)),
|
||||
);
|
||||
}
|
||||
}
|
||||
// ---- spacing on the 4px grid
|
||||
if let Some(l) = &n.layout {
|
||||
let mut bad: Vec<String> = Vec::new();
|
||||
if off_grid(l.gap) {
|
||||
bad.push(format!("gap {}", l.gap));
|
||||
}
|
||||
for (side, p) in ["top", "right", "bottom", "left"].iter().zip(l.padding) {
|
||||
if off_grid(p) {
|
||||
bad.push(format!("padding-{side} {p}"));
|
||||
}
|
||||
}
|
||||
if !bad.is_empty() {
|
||||
add(
|
||||
"spacing-grid",
|
||||
"warning",
|
||||
format!("off the 4px grid: {}", bad.join(", ")),
|
||||
"use multiples of 4 (4, 8, 12, 16, 24, 32, 48)".into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
// ---- hit areas
|
||||
let lower = n.name.to_lowercase();
|
||||
let tap = lower.starts_with("button")
|
||||
|| lower.starts_with("tab /")
|
||||
|| lower.starts_with("tab bar /");
|
||||
if tap && n.is_frame() && n.rect.h < 44.0 && n.rect.h > 0.0 && !lower.contains("icon") {
|
||||
add(
|
||||
"touch-target",
|
||||
"warning",
|
||||
format!("`{}` is {:.0}px tall", n.name, n.rect.h),
|
||||
"make interactive targets at least 44px tall (padding, not just text size)".into(),
|
||||
);
|
||||
}
|
||||
// ---- names
|
||||
if !n.component
|
||||
&& n.kind.text().is_none()
|
||||
&& is_default_name(&n.name)
|
||||
&& !doc.children(Some(id)).is_empty()
|
||||
{
|
||||
add(
|
||||
"layer-name",
|
||||
"warning",
|
||||
format!("`{}` is a default name", n.name),
|
||||
"name it for what it is (layer-name=\"Task Row\"), `Component / Variant` for parts"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
// ---- colors that should be variables
|
||||
if let Some((c, var)) = solid(n)
|
||||
&& var.is_none()
|
||||
&& c.a == 255
|
||||
{
|
||||
if let Some((name, _)) = vars.iter().find(|(_, v)| **v == c) {
|
||||
add(
|
||||
"hard-coded-color",
|
||||
"warning",
|
||||
format!("{} is the value of --{name}", c.to_hex()),
|
||||
format!("write var(--{name}) so a theme change reaches it"),
|
||||
);
|
||||
} else if !vars.is_empty() {
|
||||
let e = literal.entry(c.to_hex()).or_insert((0, id));
|
||||
e.0 += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (hex, (count, first)) in literal {
|
||||
out.push(Issue {
|
||||
rule: "hard-coded-color",
|
||||
severity: "info",
|
||||
node: first,
|
||||
message: format!("{hex} is used {count}× and is not a variable"),
|
||||
fix: "add it with set_variables, or use an existing variable".into(),
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn join(v: &[f64]) -> String {
|
||||
v.iter()
|
||||
.map(|s| format!("{s}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn contrast_matches_wcag_reference_values() {
|
||||
assert!((contrast(Color::rgb(0, 0, 0), Color::rgb(255, 255, 255)) - 21.0).abs() < 0.01);
|
||||
assert!(
|
||||
(contrast(Color::rgb(119, 119, 119), Color::rgb(255, 255, 255)) - 4.48).abs() < 0.05
|
||||
);
|
||||
assert!(off_grid(13.0) && !off_grid(12.0) && !off_grid(0.0));
|
||||
assert!(is_default_name("Frame 12") && !is_default_name("Task Row"));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
//! MCP server: AI agents read and edit the canvas of the running app.
|
||||
//!
|
||||
//! Modeled on Paper's MCP server (behaviour notes in `analysis/paper-mcp.md`): the app serves
|
||||
//! MCP over streamable HTTP on loopback, and `paper-rs-mcp` bridges stdio clients to it. Tool
|
||||
//! MCP over streamable HTTP on loopback, and `papercraft-mcp` bridges stdio clients to it. Tool
|
||||
//! calls are handed to the thread that owns the editor, so every change goes through the
|
||||
//! normal transaction / undo path and repaints the window.
|
||||
//!
|
||||
@@ -13,16 +13,17 @@
|
||||
pub mod css;
|
||||
pub mod exec;
|
||||
pub mod html;
|
||||
pub mod lint;
|
||||
pub mod server;
|
||||
pub mod tools;
|
||||
|
||||
/// Paper uses 29979; paper-rs listens next door so both apps can run side by side.
|
||||
/// Paper uses 29979; papercraft listens next door so both apps can run side by side.
|
||||
pub const DEFAULT_PORT: u16 = 29_980;
|
||||
pub const ENDPOINT: &str = "/mcp";
|
||||
|
||||
/// The port to serve on / connect to: `$PAPER_RS_MCP_PORT`, else [`DEFAULT_PORT`].
|
||||
/// The port to serve on / connect to: `$PAPERCRAFT_MCP_PORT`, else [`DEFAULT_PORT`].
|
||||
pub fn port() -> u16 {
|
||||
std::env::var("PAPER_RS_MCP_PORT")
|
||||
std::env::var("PAPERCRAFT_MCP_PORT")
|
||||
.ok()
|
||||
.and_then(|p| p.trim().parse().ok())
|
||||
.unwrap_or(DEFAULT_PORT)
|
||||
|
||||
@@ -36,7 +36,7 @@ pub fn tool_list() -> Vec<Tool> {
|
||||
/// Server identity, capabilities and instructions.
|
||||
pub fn info() -> ServerConfig {
|
||||
ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
|
||||
.with_server_info(Implementation::new("paper-rs", env!("CARGO_PKG_VERSION")))
|
||||
.with_server_info(Implementation::new("PaperCraft", env!("CARGO_PKG_VERSION")))
|
||||
.with_instructions(tools::INSTRUCTIONS)
|
||||
}
|
||||
|
||||
@@ -94,12 +94,12 @@ impl ServerHandler for PaperServer {
|
||||
reply,
|
||||
};
|
||||
let r = if !(self.dispatch)(call) {
|
||||
Err("paper-rs is shutting down".to_string())
|
||||
Err("papercraft is shutting down".to_string())
|
||||
} else {
|
||||
match tokio::time::timeout(CALL_TIMEOUT, rx).await {
|
||||
Ok(Ok(r)) => r,
|
||||
Ok(Err(_)) => Err("paper-rs dropped the call (is it closing?)".into()),
|
||||
Err(_) => Err("paper-rs did not answer in time (is a modal dialog open?)".into()),
|
||||
Ok(Err(_)) => Err("papercraft dropped the call (is it closing?)".into()),
|
||||
Err(_) => Err("papercraft did not answer in time (is a modal dialog open?)".into()),
|
||||
}
|
||||
};
|
||||
Ok(to_result(r).into())
|
||||
@@ -187,7 +187,7 @@ pub fn spawn(port: u16, dispatch: Dispatch) -> Result<Running, String> {
|
||||
let listener = std::net::TcpListener::bind(("127.0.0.1", port)).map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::AddrInUse {
|
||||
format!(
|
||||
"port {port} is in use (another paper-rs instance?); set PAPER_RS_MCP_PORT to use another"
|
||||
"port {port} is in use (another papercraft instance?); set PAPERCRAFT_MCP_PORT to use another"
|
||||
)
|
||||
} else {
|
||||
format!("cannot listen on 127.0.0.1:{port}: {e}")
|
||||
@@ -199,12 +199,12 @@ pub fn spawn(port: u16, dispatch: Dispatch) -> Result<Running, String> {
|
||||
let thread_ct = ct.clone();
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.thread_name("paper-rs-mcp")
|
||||
.thread_name("papercraft-mcp")
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("paper-rs-mcp-http".into())
|
||||
.name("papercraft-mcp-http".into())
|
||||
.spawn(move || {
|
||||
rt.block_on(async move {
|
||||
let listener = match tokio::net::TcpListener::from_std(listener) {
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
//! Tool catalogue: names, descriptions and input schemas.
|
||||
//!
|
||||
//! Names and argument shapes follow Paper's MCP server where paper-rs has an equivalent
|
||||
//! (`analysis/paper-mcp.md` §2), so agent prompts written for Paper carry over. paper-rs
|
||||
//! Names and argument shapes follow Paper's MCP server where papercraft has an equivalent
|
||||
//! (`analysis/paper-mcp.md` §2), so agent prompts written for Paper carry over. papercraft
|
||||
//! additions: `create_shape` (whiteboard shapes and arrows), `select_nodes`, `undo`, `redo`.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub const INSTRUCTIONS: &str = "\
|
||||
paper-rs is a local design and whiteboard app with an infinite canvas. These tools read and \
|
||||
papercraft is a local design and whiteboard app with an infinite canvas. These tools read and \
|
||||
edit the project open in the running app; every change is one undo step and shows up live.
|
||||
|
||||
- Start with get_basic_info (pages, artboards, sizes) and get_selection (what the user is \
|
||||
looking at). Use list_resources / open_file / create_file to pick a project.
|
||||
- For details read get_guide({ topic: \"paper-rs-mcp-instructions\" }) once per session.
|
||||
- For details read get_guide({ topic: \"papercraft-mcp-instructions\" }) once per session.
|
||||
- pageId defaults to the page the user is viewing; pass it to work on another page without \
|
||||
switching the user's view. Node ids are opaque strings; never show them to the user.
|
||||
- Build UI with create_artboard, then write_html into it, one visual group per call (a header, \
|
||||
@@ -86,7 +86,7 @@ pub fn definitions() -> Vec<Value> {
|
||||
// ------------------------------------------------------------ projects and pages
|
||||
tool(
|
||||
"list_resources",
|
||||
"Lists the user's paper-rs projects (local folders), most recently modified first. \
|
||||
"Lists the user's papercraft projects (local folders), most recently modified first. \
|
||||
Each item has id, name, path, page count, updatedAt, and `open` for the project open \
|
||||
in the app.",
|
||||
json!({"limit": {"type": "integer", "description": "Maximum projects to return. Defaults to 50."}}),
|
||||
@@ -234,7 +234,7 @@ pub fn definitions() -> Vec<Value> {
|
||||
),
|
||||
tool(
|
||||
"get_font_family_info",
|
||||
"Whether font families are available and their weights/styles. paper-rs renders text \
|
||||
"Whether font families are available and their weights/styles. papercraft renders text \
|
||||
with Inter (400, 700, normal and italic); other families fall back to Inter.",
|
||||
json!({"familyNames": {"type": "array", "items": {"type": "string"}}}),
|
||||
&["familyNames"],
|
||||
@@ -242,7 +242,7 @@ pub fn definitions() -> Vec<Value> {
|
||||
),
|
||||
tool(
|
||||
"get_guide",
|
||||
"Read a guide. Topics: \"paper-rs-mcp-instructions\" (read first), \"html\" (what \
|
||||
"Read a guide. Topics: \"papercraft-mcp-instructions\" (read first), \"html\" (what \
|
||||
write_html supports), \"whiteboard\" (shapes, arrows, stickies, hand-drawn style).",
|
||||
json!({"topic": {"type": "string"}}),
|
||||
&["topic"],
|
||||
@@ -401,11 +401,27 @@ pub fn definitions() -> Vec<Value> {
|
||||
&["updates"],
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"lint_design",
|
||||
"Check a page or subtree for common design mistakes and get a fix for each: text \
|
||||
contrast (WCAG 4.5:1, 3:1 for large text), text under 12px, font sizes off the type \
|
||||
scale, gap/padding off the 4px grid, buttons and tabs under 44px tall, default layer \
|
||||
names (Frame, Rectangle), and hard-coded colors that equal a variable. Run it before \
|
||||
finish_working_on_nodes and fix errors and warnings; `info` items are suggestions.",
|
||||
json!({"nodeId": {"type": "string", "description": "Subtree to check; omit for the whole page."},
|
||||
"scale": {"type": "array", "items": {"type": "number"},
|
||||
"description": "Allowed font sizes. Default 12, 14, 16, 20, 24, 32, 40, 48, 64."},
|
||||
"pageId": page_id()}),
|
||||
&[],
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"get_variables",
|
||||
"Color variables of the page's project (name -> hex). Fills that follow a variable \
|
||||
were written with `var(--name)`; set_variables moves all of them at once.",
|
||||
json!({"pageId": page_id()}),
|
||||
were written with `var(--name)`; set_variables moves all of them at once. \
|
||||
`format` css returns a `:root { --name: #hex }` block, json a design-tokens object.",
|
||||
json!({"pageId": page_id(),
|
||||
"format": {"type": "string", "enum": ["css", "json"]}}),
|
||||
&[],
|
||||
true,
|
||||
),
|
||||
@@ -418,10 +434,20 @@ pub fn definitions() -> Vec<Value> {
|
||||
Variables apply to fills and text color; strokes and gradients keep fixed colors.",
|
||||
json!({"variables": {"type": "object", "propertyNames": {"type": "string"},
|
||||
"additionalProperties": {"type": "string"},
|
||||
"description": "{\"accent\": \"#cba6f7\", \"surface\": \"#313244\"}"}}),
|
||||
"description": "{\"accent\": \"#cba6f7\", \"surface\": \"#313244\"}"},
|
||||
"mode": {"type": "string", "description": "Store the values for this mode (e.g. \"Light\", \"Dark\"); they apply when it is the active mode. The first mode you define becomes active."}}),
|
||||
&["variables"],
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"set_variable_mode",
|
||||
"Switch the active variable mode (defined with set_variables {mode}): every variable \
|
||||
takes that mode's value and all fills following them change, on every page. One call \
|
||||
turns a whole design from light to dark.",
|
||||
json!({"mode": {"type": "string"}}),
|
||||
&["mode"],
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"get_components",
|
||||
"Main components on a page (id, name, size) with how many instances each has.",
|
||||
@@ -459,7 +485,7 @@ pub fn definitions() -> Vec<Value> {
|
||||
"Search installed open icon sets (Lucide, Tabler, Phosphor, Heroicons, Material). \
|
||||
Returns `set:name` references for <x-paper-icon name=\"lucide:check\" size=\"20\" \
|
||||
color=\"var(--text)\"/> in write_html. Without a query it lists installed and \
|
||||
installable sets; install with `paper-rs-cli icons add lucide`.",
|
||||
installable sets; install with `papercraft-cli icons add lucide`.",
|
||||
json!({"query": {"type": "string"}, "set": {"type": "string"},
|
||||
"limit": {"type": "number", "description": "Default 20."}}),
|
||||
&[],
|
||||
@@ -536,7 +562,7 @@ pub fn definitions() -> Vec<Value> {
|
||||
}
|
||||
|
||||
pub const GUIDE_INSTRUCTIONS: &str = "\
|
||||
# Designing in paper-rs over MCP
|
||||
# Designing in papercraft over MCP
|
||||
|
||||
1. Orient: get_basic_info → pages, artboards, sizes; get_selection → what the user points at.
|
||||
2. Plan a brief before a new design: palette (3–5 colors), type scale (e.g. 12/14/16/20/28/40),
|
||||
|
||||
@@ -15,6 +15,12 @@ pub struct Document {
|
||||
/// Named colors that fills can follow (`var(--name)` in CSS). Empty for most documents.
|
||||
#[serde(default)]
|
||||
pub variables: BTreeMap<String, Color>,
|
||||
/// Values of the variables per mode (e.g. "Light", "Dark"); `variables` holds the active one.
|
||||
#[serde(default)]
|
||||
pub modes: BTreeMap<String, BTreeMap<String, Color>>,
|
||||
/// The active mode, if modes are in use.
|
||||
#[serde(default)]
|
||||
pub mode: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for Document {
|
||||
@@ -25,11 +31,35 @@ impl Default for Document {
|
||||
root: Vec::new(),
|
||||
next_id: 1,
|
||||
variables: BTreeMap::new(),
|
||||
modes: BTreeMap::new(),
|
||||
mode: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything about variables that undo has to restore together.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct VarState {
|
||||
pub values: BTreeMap<String, Color>,
|
||||
pub modes: BTreeMap<String, BTreeMap<String, Color>>,
|
||||
pub mode: Option<String>,
|
||||
}
|
||||
|
||||
impl Document {
|
||||
pub fn var_state(&self) -> VarState {
|
||||
VarState {
|
||||
values: self.variables.clone(),
|
||||
modes: self.modes.clone(),
|
||||
mode: self.mode.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn restore_vars(&mut self, s: &VarState) {
|
||||
self.variables = s.values.clone();
|
||||
self.modes = s.modes.clone();
|
||||
self.mode = s.mode.clone();
|
||||
}
|
||||
|
||||
pub fn get(&self, id: NodeId) -> Option<&Node> {
|
||||
self.nodes.get(&id)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ pub mod node;
|
||||
pub mod paint;
|
||||
pub mod rough;
|
||||
|
||||
pub use document::Document;
|
||||
pub use document::{Document, VarState};
|
||||
pub use node::{
|
||||
Binding, Color, FillRule, Head, Node, NodeId, NodeKind, PathPoint, Route, StrokePoint, Style,
|
||||
SubPath, TextAlign,
|
||||
|
||||
@@ -13,7 +13,7 @@ use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
pub const PROJECT_FORMAT: &str = "paper-rs-project";
|
||||
pub const PAGE_FORMAT: &str = "paper-rs-page";
|
||||
/// The single-file format of paper-rs 0.1 (`.paper-rs`), version 1. It migrates to a page.
|
||||
/// The single-file format of papercraft 0.1 (`.paper-rs`), version 1. It migrates to a page.
|
||||
pub const LEGACY_FORMAT: &str = "paper-rs";
|
||||
|
||||
pub const PROJECT_VERSION: u32 = 1;
|
||||
@@ -34,7 +34,7 @@ impl std::fmt::Display for FormatError {
|
||||
match self {
|
||||
FormatError::Io(e) => write!(f, "I/O error: {e}"),
|
||||
FormatError::Json(e) => write!(f, "malformed file: {e}"),
|
||||
FormatError::WrongFormat(s) => write!(f, "not a paper-rs file (format {s:?})"),
|
||||
FormatError::WrongFormat(s) => write!(f, "not a papercraft file (format {s:?})"),
|
||||
FormatError::TooNew { version, supported } => write!(
|
||||
f,
|
||||
"file version {version} is newer than this app supports ({supported})"
|
||||
@@ -128,6 +128,10 @@ struct PageFile {
|
||||
nodes: Vec<Node>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
variables: BTreeMap<String, Color>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
modes: BTreeMap<String, BTreeMap<String, Color>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
mode: Option<String>,
|
||||
}
|
||||
|
||||
/// Page migrations, indexed by source version: `PAGE_STEPS[0]` turns v1 into v2.
|
||||
@@ -177,6 +181,8 @@ pub fn page_to_json(p: &PageData) -> String {
|
||||
root: p.doc.root.clone(),
|
||||
nodes,
|
||||
variables: p.doc.variables.clone(),
|
||||
modes: p.doc.modes.clone(),
|
||||
mode: p.doc.mode.clone(),
|
||||
};
|
||||
serde_json::to_string_pretty(&f).expect("page serializes")
|
||||
}
|
||||
@@ -194,6 +200,8 @@ pub fn page_from_json(s: &str) -> Result<PageData, FormatError> {
|
||||
root: f.root,
|
||||
next_id: f.next_id.max(max_id + 1),
|
||||
variables: f.variables,
|
||||
modes: f.modes,
|
||||
mode: f.mode,
|
||||
};
|
||||
doc.validate().map_err(FormatError::Invalid)?;
|
||||
Ok(PageData {
|
||||
|
||||
@@ -73,15 +73,22 @@ pub fn folder_name(name: &str) -> String {
|
||||
}
|
||||
|
||||
impl Store {
|
||||
/// `$PAPER_RS_HOME`, else `~/Documents/paper-rs`.
|
||||
/// `$PAPERCRAFT_HOME`, else `~/Documents/papercraft` (`~/Documents/paper-rs` when only that exists).
|
||||
pub fn default_root() -> PathBuf {
|
||||
if let Some(p) = std::env::var_os("PAPER_RS_HOME") {
|
||||
if let Some(p) = std::env::var_os("PAPERCRAFT_HOME") {
|
||||
return PathBuf::from(p);
|
||||
}
|
||||
dirs::document_dir()
|
||||
let docs = dirs::document_dir()
|
||||
.or_else(dirs::home_dir)
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join("paper-rs")
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
// Projects made before the rename (paper-rs) stay where they are.
|
||||
let legacy = docs.join("paper-rs");
|
||||
let root = docs.join("papercraft");
|
||||
if legacy.is_dir() && !root.exists() {
|
||||
legacy
|
||||
} else {
|
||||
root
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open(root: impl Into<PathBuf>) -> std::io::Result<Store> {
|
||||
@@ -399,7 +406,8 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
pub fn temp_store() -> Store {
|
||||
let root = std::env::temp_dir().join(format!("paper-rs-store-{:016x}", fastrand::u64(..)));
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("papercraft-store-{:016x}", fastrand::u64(..)));
|
||||
Store::open(root).unwrap()
|
||||
}
|
||||
|
||||
|
||||
@@ -742,7 +742,7 @@ impl Headless {
|
||||
.map_err(|e| format!("no GPU adapter available: {e}"))?;
|
||||
let adapter_info = adapter.get_info();
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
label: Some("paper-rs headless"),
|
||||
label: Some("papercraft headless"),
|
||||
required_limits: wgpu::Limits::downlevel_defaults().using_resolution(adapter.limits()),
|
||||
..Default::default()
|
||||
}))
|
||||
|
||||
@@ -198,7 +198,7 @@ mod tests {
|
||||
|
||||
fn store() -> Store {
|
||||
Store::open(
|
||||
std::env::temp_dir().join(format!("paper-rs-session-{:016x}", fastrand::u64(..))),
|
||||
std::env::temp_dir().join(format!("papercraft-session-{:016x}", fastrand::u64(..))),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
@@ -352,7 +352,14 @@ impl Choice {
|
||||
|
||||
/// Persisted in the config dir; a missing or unreadable file means `System`.
|
||||
fn path() -> Option<std::path::PathBuf> {
|
||||
dirs::config_dir().map(|d| d.join("paper-rs").join("theme"))
|
||||
let dir = dirs::config_dir()?;
|
||||
let new = dir.join("papercraft").join("theme");
|
||||
let legacy = dir.join("paper-rs").join("theme");
|
||||
Some(if legacy.is_file() && !new.exists() {
|
||||
legacy
|
||||
} else {
|
||||
new
|
||||
})
|
||||
}
|
||||
|
||||
pub fn load() -> Choice {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
//! The Design tab: the project's color variables (edit one and every fill following it moves),
|
||||
//! variable modes (themes), and the page's components.
|
||||
|
||||
use super::UiState;
|
||||
use crate::editor::Editor;
|
||||
use crate::model::{Color, NodeId};
|
||||
use crate::theme;
|
||||
use egui::{RichText, Ui};
|
||||
|
||||
fn c32(c: Color) -> egui::Color32 {
|
||||
egui::Color32::from_rgba_unmultiplied(c.r, c.g, c.b, c.a)
|
||||
}
|
||||
|
||||
/// Run `f` as one undoable edit on every page, so variables stay in step across the project.
|
||||
fn on_every_page(ed: &mut Editor, label: &str, f: impl Fn(&mut crate::history::Tx)) {
|
||||
for page in 0..ed.pages.len() {
|
||||
ed.with_page(page, |ed| ed.edit(label, |tx| f(tx)));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn panel(ui: &mut Ui, ed: &mut Editor, _st: &mut UiState) {
|
||||
let p = theme::pal(ui);
|
||||
let heading = |ui: &mut Ui, t: &str| {
|
||||
ui.add_space(6.0);
|
||||
ui.label(
|
||||
RichText::new(t.to_uppercase())
|
||||
.font(theme::semibold(theme::SECONDARY - 1.0))
|
||||
.color(p.secondary),
|
||||
);
|
||||
ui.add_space(2.0);
|
||||
};
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("design")
|
||||
.auto_shrink(false)
|
||||
.show(ui, |ui| {
|
||||
// ---- modes
|
||||
if !ed.doc.modes.is_empty() {
|
||||
heading(ui, "Mode");
|
||||
let modes: Vec<String> = ed.doc.modes.keys().cloned().collect();
|
||||
let active = ed.doc.mode.clone();
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for m in &modes {
|
||||
let on = active.as_deref() == Some(m);
|
||||
if ui.selectable_label(on, m).clicked() && !on {
|
||||
let m = m.clone();
|
||||
on_every_page(ed, "Set variable mode", |tx| {
|
||||
tx.set_mode(&m);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// ---- variables
|
||||
heading(ui, "Variables");
|
||||
if ed.doc.variables.is_empty() {
|
||||
ui.label(
|
||||
RichText::new("No variables yet. Agents add them with\nset_variables; fills use var(--name).")
|
||||
.size(theme::SECONDARY)
|
||||
.color(p.secondary),
|
||||
);
|
||||
}
|
||||
let vars: Vec<(String, Color)> = ed
|
||||
.doc
|
||||
.variables
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), *v))
|
||||
.collect();
|
||||
for (name, color) in vars {
|
||||
ui.horizontal(|ui| {
|
||||
let mut c = c32(color);
|
||||
if ui.color_edit_button_srgba(&mut c).changed() {
|
||||
let new = Color::rgba(c.r(), c.g(), c.b(), c.a());
|
||||
if new != color {
|
||||
let (n, mode) = (name.clone(), ed.doc.mode.clone());
|
||||
on_every_page(ed, "Change variable", |tx| {
|
||||
match &mode {
|
||||
Some(m) => tx.set_variable_in_mode(m, &n, new),
|
||||
None => tx.set_variable(&n, new),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
ui.label(RichText::new(format!("--{name}")).monospace().size(theme::SECONDARY + 1.0));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.label(RichText::new(color.to_hex()).monospace().weak());
|
||||
});
|
||||
});
|
||||
}
|
||||
// ---- components
|
||||
heading(ui, "Components");
|
||||
let mut comps: Vec<(NodeId, String, usize)> = ed
|
||||
.doc
|
||||
.nodes
|
||||
.values()
|
||||
.filter(|n| n.component)
|
||||
.map(|n| {
|
||||
let uses = ed
|
||||
.doc
|
||||
.nodes
|
||||
.values()
|
||||
.filter(|i| i.id != n.id && i.instance.as_ref().is_some_and(|l| l.main == n.id))
|
||||
.count();
|
||||
(n.id, n.name.clone(), uses)
|
||||
})
|
||||
.collect();
|
||||
comps.sort_by_key(|c| c.0);
|
||||
if comps.is_empty() {
|
||||
ui.label(
|
||||
RichText::new("Select a frame and press ⌘⌥K,\nor use create_component.")
|
||||
.size(theme::SECONDARY)
|
||||
.color(p.secondary),
|
||||
);
|
||||
}
|
||||
for (id, name, uses) in comps {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(&name).font(theme::medium(theme::BODY)));
|
||||
ui.label(RichText::new(format!("{uses}")).weak().size(theme::SECONDARY));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.small_button("Insert").on_hover_text("Place an instance in view").clicked() {
|
||||
let at = ed.camera.screen_to_world(ed.viewport * 0.5);
|
||||
ed.create_instance(id, at);
|
||||
}
|
||||
if ui.small_button("Go to").on_hover_text("Select the main component").clicked() {
|
||||
ed.selection = vec![id];
|
||||
ed.run(crate::editor::shortcuts::Action::ZoomSelection);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -104,7 +104,7 @@ pub fn draw(ui: &mut Ui, hv: &mut HomeView, native_chrome: bool, translucent: bo
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
ui.label(
|
||||
RichText::new("paper-rs")
|
||||
RichText::new("PaperCraft")
|
||||
.font(theme::semibold(theme::SECONDARY))
|
||||
.color(p.secondary),
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! egui chrome: menu bar, layers panel, design (properties) panel, floating toolbar, and the
|
||||
//! projects home screen.
|
||||
|
||||
pub mod design;
|
||||
pub mod home;
|
||||
pub mod inspector;
|
||||
pub mod library;
|
||||
@@ -37,6 +38,8 @@ pub struct UiState {
|
||||
pub library: library::LibraryUi,
|
||||
/// Left sidebar shows the library instead of the layers.
|
||||
pub show_library: bool,
|
||||
/// ...or the design tab (variables, modes, components).
|
||||
pub show_design: bool,
|
||||
pub palette: palette::PaletteUi,
|
||||
/// All panels hidden (⌘\\): just the canvas.
|
||||
pub hide_ui: bool,
|
||||
@@ -95,6 +98,7 @@ pub fn draw(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) -> egui::Rect {
|
||||
if let Some(n) = ed.doc.get(id) {
|
||||
st.renaming = Some((id, n.name.clone()));
|
||||
st.show_library = false;
|
||||
st.show_design = false;
|
||||
st.hide_ui = false;
|
||||
}
|
||||
}
|
||||
@@ -286,8 +290,13 @@ fn layers(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
ui.add_space(6.0);
|
||||
let p = theme::pal(ui);
|
||||
ui.horizontal(|ui| {
|
||||
for (lib, label) in [(false, "Layers"), (true, "Library")] {
|
||||
let on = st.show_library == lib;
|
||||
for (tab, label) in [(0, "Layers"), (1, "Library"), (2, "Design")] {
|
||||
let current = if st.show_design {
|
||||
2
|
||||
} else {
|
||||
st.show_library as i32
|
||||
};
|
||||
let on = current == tab;
|
||||
let r = ui.add(
|
||||
egui::Label::new(
|
||||
RichText::new(label)
|
||||
@@ -297,10 +306,11 @@ fn layers(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
.sense(egui::Sense::click()),
|
||||
);
|
||||
if r.clicked() {
|
||||
st.show_library = lib;
|
||||
st.show_library = tab == 1;
|
||||
st.show_design = tab == 2;
|
||||
}
|
||||
}
|
||||
if st.show_library {
|
||||
if st.show_library || st.show_design {
|
||||
return;
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
@@ -327,6 +337,10 @@ fn layers(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
library::panel(ui, ed, st);
|
||||
return;
|
||||
}
|
||||
if st.show_design {
|
||||
design::panel(ui, ed, st);
|
||||
return;
|
||||
}
|
||||
egui::ScrollArea::vertical()
|
||||
.auto_shrink(false)
|
||||
.show(ui, |ui| {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
//! Helpers shared by the integration tests.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use paper_rs::editor::{Button, Editor, InputEvent, Key, Modifiers};
|
||||
use paper_rs::geom::{Camera, Rect, V2, v2};
|
||||
use paper_rs::model::Color;
|
||||
use paper_rs::render::gpu::Headless;
|
||||
use paper_rs::render::scene;
|
||||
use papercraft::editor::{Button, Editor, InputEvent, Key, Modifiers};
|
||||
use papercraft::geom::{Camera, Rect, V2, v2};
|
||||
use papercraft::model::Color;
|
||||
use papercraft::render::gpu::Headless;
|
||||
use papercraft::render::scene;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
@@ -23,7 +23,7 @@ pub fn gpu() -> Option<&'static Headless> {
|
||||
}
|
||||
Err(e) => {
|
||||
assert!(
|
||||
std::env::var_os("PAPER_RS_REQUIRE_GPU").is_none(),
|
||||
std::env::var_os("PAPERCRAFT_REQUIRE_GPU").is_none(),
|
||||
"GPU required: {e}"
|
||||
);
|
||||
eprintln!("skipping GPU test: {e}");
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
//! Design-system tools: color variables, components with instance overrides, and how both
|
||||
//! survive undo and a save.
|
||||
|
||||
use paper_rs::mcp::{Out, exec};
|
||||
use paper_rs::model::{Color, NodeId, Paint};
|
||||
use paper_rs::project::Store;
|
||||
use papercraft::mcp::{Out, exec};
|
||||
use papercraft::model::{Color, NodeId, Paint};
|
||||
use papercraft::project::Store;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn host() -> exec::SessionHost {
|
||||
let store =
|
||||
Store::open(std::env::temp_dir().join(format!("paper-rs-ds-{:016x}", fastrand::u64(..))))
|
||||
Store::open(std::env::temp_dir().join(format!("papercraft-ds-{:016x}", fastrand::u64(..))))
|
||||
.unwrap();
|
||||
let mut h = exec::SessionHost::new(store);
|
||||
h.session.create(&mut h.ed, "Design system").unwrap();
|
||||
@@ -109,7 +109,7 @@ fn variables_link_fills_and_move_them_with_undo() {
|
||||
|
||||
#[test]
|
||||
fn variables_survive_a_save() {
|
||||
use paper_rs::project::format::{PageData, page_from_json, page_to_json};
|
||||
use papercraft::project::format::{PageData, page_from_json, page_to_json};
|
||||
let mut h = host();
|
||||
call(
|
||||
&mut h,
|
||||
@@ -247,3 +247,134 @@ fn instances_follow_the_component_except_what_was_overridden() {
|
||||
assert_eq!(h.ed.doc.get(node(&ok)).unwrap().name, "Button / OK");
|
||||
assert!(h.ed.doc.get(node(&ok)).unwrap().instance.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lint_finds_the_usual_mistakes_and_clears_a_clean_design() {
|
||||
let mut h = host();
|
||||
call(
|
||||
&mut h,
|
||||
"set_variables",
|
||||
json!({"variables": {"accent": "#cba6f7", "text": "#111111", "bg": "#ffffff"}}),
|
||||
);
|
||||
let ab = call(
|
||||
&mut h,
|
||||
"create_artboard",
|
||||
json!({"name": "Screen", "styles": {"width": "300px", "height": "300px", "backgroundColor": "var(--bg)"}}),
|
||||
);
|
||||
call(
|
||||
&mut h,
|
||||
"write_html",
|
||||
json!({"targetNodeId": ab["id"], "mode": "insert-children", "html": concat!(
|
||||
"<div style=\"display:flex;flex-direction:column;gap:13px;padding:10px\">",
|
||||
"<div layer-name=\"Faint\" style=\"color:#cccccc;font-size:14px\">low contrast</div>",
|
||||
"<div layer-name=\"Tiny\" style=\"color:#111111;font-size:9px\">tiny</div>",
|
||||
"<div layer-name=\"Odd\" style=\"color:#111111;font-size:15px\">off scale</div>",
|
||||
"<div layer-name=\"Button / Save\" style=\"display:flex;padding:8px;background:#cba6f7\"><div layer-name=\"Label\" style=\"color:#111111;font-size:14px\">Save</div></div>",
|
||||
"</div>")}),
|
||||
);
|
||||
let r = call(&mut h, "lint_design", json!({}));
|
||||
let rules: Vec<&str> = r["issues"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|i| i["rule"].as_str().unwrap())
|
||||
.collect();
|
||||
for want in [
|
||||
"contrast",
|
||||
"text-size",
|
||||
"type-scale",
|
||||
"spacing-grid",
|
||||
"touch-target",
|
||||
"layer-name",
|
||||
"hard-coded-color",
|
||||
] {
|
||||
assert!(rules.contains(&want), "missing {want}: {r}");
|
||||
}
|
||||
assert_eq!(r["errors"], 1, "{r}");
|
||||
// A tidy design: variables, scale sizes, grid spacing, named layers, tall button.
|
||||
let mut h = host();
|
||||
call(
|
||||
&mut h,
|
||||
"set_variables",
|
||||
json!({"variables": {"accent": "#cba6f7", "text": "#111111", "bg": "#ffffff"}}),
|
||||
);
|
||||
let ab = call(
|
||||
&mut h,
|
||||
"create_artboard",
|
||||
json!({"name": "Screen", "styles": {"width": "300px", "height": "300px", "backgroundColor": "var(--bg)"}}),
|
||||
);
|
||||
call(
|
||||
&mut h,
|
||||
"write_html",
|
||||
json!({"targetNodeId": ab["id"], "mode": "insert-children", "html": concat!(
|
||||
"<div layer-name=\"Content\" style=\"display:flex;flex-direction:column;gap:12px;padding:16px\">",
|
||||
"<div layer-name=\"Title\" style=\"color:var(--text);font-size:24px;font-weight:700\">Hello</div>",
|
||||
"<div layer-name=\"Button / Save\" style=\"display:flex;padding:16px;background:var(--accent)\"><div layer-name=\"Label\" style=\"color:var(--text);font-size:16px\">Save</div></div>",
|
||||
"</div>")}),
|
||||
);
|
||||
let r = call(&mut h, "lint_design", json!({}));
|
||||
assert_eq!(
|
||||
(r["errors"].clone(), r["warnings"].clone()),
|
||||
(json!(0), json!(0)),
|
||||
"{r}"
|
||||
);
|
||||
let css = exec::call(&mut h, "get_variables", &json!({"format": "css"})).unwrap();
|
||||
let Out::Text(css) = &css[0] else { panic!() };
|
||||
assert!(
|
||||
css.starts_with(":root {") && css.contains("--accent: #cba6f7;"),
|
||||
"{css}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modes_turn_a_design_from_light_to_dark_and_back_with_undo() {
|
||||
let mut h = host();
|
||||
call(
|
||||
&mut h,
|
||||
"set_variables",
|
||||
json!({"mode": "Light", "variables": {"bg": "#eff1f5", "text": "#4c4f69"}}),
|
||||
);
|
||||
call(
|
||||
&mut h,
|
||||
"set_variables",
|
||||
json!({"mode": "Dark", "variables": {"bg": "#1e1e2e", "text": "#cdd6f4"}}),
|
||||
);
|
||||
let ab = call(
|
||||
&mut h,
|
||||
"create_artboard",
|
||||
json!({"name": "Screen", "styles": {"width": "100px", "height": "100px", "backgroundColor": "var(--bg)"}}),
|
||||
);
|
||||
assert_eq!(
|
||||
fill(&h, &ab["id"]).0,
|
||||
Color::rgb(0xef, 0xf1, 0xf5),
|
||||
"the first mode defined is active"
|
||||
);
|
||||
let r = call(&mut h, "set_variable_mode", json!({"mode": "Dark"}));
|
||||
assert_eq!(r["fillsUpdated"], 1);
|
||||
assert_eq!(fill(&h, &ab["id"]).0, Color::rgb(0x1e, 0x1e, 0x2e));
|
||||
assert_eq!(call(&mut h, "get_variables", json!({}))["mode"], "Dark");
|
||||
call(&mut h, "undo", json!({}));
|
||||
assert_eq!(fill(&h, &ab["id"]).0, Color::rgb(0xef, 0xf1, 0xf5));
|
||||
assert_eq!(h.ed.doc.mode.as_deref(), Some("Light"));
|
||||
assert!(exec::call(&mut h, "set_variable_mode", &json!({"mode": "Nope"})).is_err());
|
||||
// Editing the active mode's value later moves its fills and is stored for that mode only.
|
||||
call(
|
||||
&mut h,
|
||||
"set_variables",
|
||||
json!({"variables": {"bg": "#ffffff"}}),
|
||||
);
|
||||
assert_eq!(h.ed.doc.modes["Light"]["bg"], Color::rgb(255, 255, 255));
|
||||
assert_eq!(h.ed.doc.modes["Dark"]["bg"], Color::rgb(0x1e, 0x1e, 0x2e));
|
||||
// Modes survive a save.
|
||||
let page = papercraft::project::format::PageData {
|
||||
id: "p".into(),
|
||||
name: h.ed.doc.name.clone(),
|
||||
doc: h.ed.doc.clone(),
|
||||
camera: None,
|
||||
};
|
||||
let back = papercraft::project::format::page_from_json(
|
||||
&papercraft::project::format::page_to_json(&page),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(back.doc, h.ed.doc);
|
||||
}
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
mod common;
|
||||
|
||||
use common::*;
|
||||
use paper_rs::editor::shortcuts::Action;
|
||||
use paper_rs::editor::{Button, Editor, InputEvent, Key, Modifiers, Tool};
|
||||
use paper_rs::geom::{Rect, V2, v2};
|
||||
use paper_rs::model::rough::{FillStyle, Rough};
|
||||
use paper_rs::model::{Color, Head, Node, NodeId, NodeKind, Route};
|
||||
use paper_rs::render::draw;
|
||||
use paper_rs::render::frame;
|
||||
use paper_rs::ui::UiState;
|
||||
use papercraft::editor::shortcuts::Action;
|
||||
use papercraft::editor::{Button, Editor, InputEvent, Key, Modifiers, Tool};
|
||||
use papercraft::geom::{Rect, V2, v2};
|
||||
use papercraft::model::rough::{FillStyle, Rough};
|
||||
use papercraft::model::{Color, Head, Node, NodeId, NodeKind, Route};
|
||||
use papercraft::render::draw;
|
||||
use papercraft::render::frame;
|
||||
use papercraft::ui::UiState;
|
||||
|
||||
fn shape(e: &mut Editor, kind: NodeKind, r: Rect, fill: Color) -> NodeId {
|
||||
let mut n = Node::new(NodeId(0), kind, r);
|
||||
@@ -158,7 +158,7 @@ fn arrows_bind_follow_and_unbind() {
|
||||
e.undo();
|
||||
assert_eq!(conn(&e, arrow).0, pts);
|
||||
// Elbow routing: orthogonal segments.
|
||||
paper_rs::ui::inspector::apply(&mut e, "route", &[arrow], |n| {
|
||||
papercraft::ui::inspector::apply(&mut e, "route", &[arrow], |n| {
|
||||
if let NodeKind::Connector { route, .. } = &mut n.kind {
|
||||
*route = Route::Elbow;
|
||||
}
|
||||
@@ -261,10 +261,10 @@ fn quick_connect_and_sticky_notes() {
|
||||
assert_eq!(n.kind.text().unwrap(), "Ship it");
|
||||
// Long text shrinks the font to fit.
|
||||
let long = "word ".repeat(80);
|
||||
paper_rs::ui::inspector::apply(&mut e, "text", &[s], |n| {
|
||||
papercraft::ui::inspector::apply(&mut e, "text", &[s], |n| {
|
||||
*n.kind.text_mut().unwrap() = long.clone()
|
||||
});
|
||||
let l = paper_rs::text::layout_node(e.doc.get(s).unwrap()).unwrap();
|
||||
let l = papercraft::text::layout_node(e.doc.get(s).unwrap()).unwrap();
|
||||
assert!(l.glyphs[0].size < 24.0, "font shrank: {}", l.glyphs[0].size);
|
||||
}
|
||||
|
||||
@@ -285,7 +285,7 @@ fn laser_trail_fades_and_stops_animating() {
|
||||
#[test]
|
||||
fn library_items_insert_with_fresh_ids() {
|
||||
let mut e = editor(800.0, 600.0);
|
||||
let lib = paper_rs::library::starter();
|
||||
let lib = papercraft::library::starter();
|
||||
let db = lib.items.iter().find(|i| i.name == "Database").unwrap();
|
||||
let ids = e.insert_library_item(db, v2(300.0, 300.0));
|
||||
assert_eq!(ids.len(), 2);
|
||||
@@ -314,7 +314,8 @@ fn library_items_insert_with_fresh_ids() {
|
||||
e.run(Action::Tool(Tool::Arrow));
|
||||
ev_drag(&mut e, v2(25.0, 25.0), v2(175.0, 25.0), 0.0, true);
|
||||
let arrow = e.selection[0];
|
||||
let item = paper_rs::library::LibItem::from_selection(&e.doc, &[a, c, arrow], "Pair").unwrap();
|
||||
let item =
|
||||
papercraft::library::LibItem::from_selection(&e.doc, &[a, c, arrow], "Pair").unwrap();
|
||||
let new = e.insert_library_item(&item, v2(500.0, 400.0));
|
||||
let new_arrow = *new
|
||||
.iter()
|
||||
@@ -340,7 +341,7 @@ pub fn whiteboard() -> Editor {
|
||||
n.style.stroke_width = 2.0;
|
||||
n.style.corner_radius = 8.0;
|
||||
let id = e.add_node(n, None);
|
||||
paper_rs::ui::inspector::apply(e, "rough", &[id], |n| {
|
||||
papercraft::ui::inspector::apply(e, "rough", &[id], |n| {
|
||||
n.style.rough = Some(Rough {
|
||||
seed: 100 + id.0 as u32,
|
||||
fill_style: fs,
|
||||
@@ -390,11 +391,11 @@ pub fn whiteboard() -> Editor {
|
||||
] {
|
||||
let r = e.doc.world_rect(id).unwrap();
|
||||
let tid = e.add_node(
|
||||
paper_rs::demo::text_node(t, 22.0, v2(0.0, 0.0), Color::INK),
|
||||
papercraft::demo::text_node(t, 22.0, v2(0.0, 0.0), Color::INK),
|
||||
None,
|
||||
);
|
||||
let tr = e.doc.get(tid).unwrap().rect;
|
||||
paper_rs::ui::inspector::apply(&mut e, "x", &[tid], |n| {
|
||||
papercraft::ui::inspector::apply(&mut e, "x", &[tid], |n| {
|
||||
n.rect.x = (r.center().x - tr.w * 0.5).round();
|
||||
n.rect.y = (r.center().y - tr.h * 0.5).round();
|
||||
});
|
||||
@@ -410,7 +411,7 @@ pub fn whiteboard() -> Editor {
|
||||
e.run(Action::Tool(Tool::Arrow));
|
||||
ev_drag(e, pa, pb, 0.0, true);
|
||||
let id = e.selection[0];
|
||||
paper_rs::ui::inspector::apply(e, "heads", &[id], |n| {
|
||||
papercraft::ui::inspector::apply(e, "heads", &[id], |n| {
|
||||
if let NodeKind::Connector { end_head, .. } = &mut n.kind {
|
||||
*end_head = head;
|
||||
}
|
||||
@@ -431,7 +432,7 @@ pub fn whiteboard() -> Editor {
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
e.sticky_color = paper_rs::ui::inspector::STICKY_COLORS[*c];
|
||||
e.sticky_color = papercraft::ui::inspector::STICKY_COLORS[*c];
|
||||
e.run(Action::Tool(Tool::Sticky));
|
||||
let p = e
|
||||
.camera
|
||||
@@ -446,7 +447,7 @@ pub fn whiteboard() -> Editor {
|
||||
key(&mut e, Key::Escape, Modifiers::NONE);
|
||||
}
|
||||
// Library item and a hand-drawn freehand arrow.
|
||||
let lib = paper_rs::library::starter();
|
||||
let lib = papercraft::library::starter();
|
||||
let db = lib.items.iter().find(|i| i.name == "Database").unwrap();
|
||||
e.insert_library_item(db, v2(1000.0, 420.0));
|
||||
e.selection.clear();
|
||||
@@ -464,7 +465,7 @@ fn m4_whiteboard_screenshot() {
|
||||
show_library: true,
|
||||
..Default::default()
|
||||
};
|
||||
st.library.library = paper_rs::library::starter();
|
||||
st.library.library = papercraft::library::starter();
|
||||
let img = frame::capture(g, &mut e, &mut st, [1600.0, 1000.0], 2.0, |ed| {
|
||||
let b = ed.doc.content_bounds().unwrap();
|
||||
ed.camera.fit(&b, ed.viewport, 60.0, 1.0);
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
mod common;
|
||||
|
||||
use common::*;
|
||||
use paper_rs::editor::vector_ops::BoolOp;
|
||||
use paper_rs::editor::{Button, Editor, InputEvent, Key, Modifiers, Tool};
|
||||
use paper_rs::geom::{Rect, V2, v2};
|
||||
use paper_rs::model::{
|
||||
use papercraft::editor::vector_ops::BoolOp;
|
||||
use papercraft::editor::{Button, Editor, InputEvent, Key, Modifiers, Tool};
|
||||
use papercraft::geom::{Rect, V2, v2};
|
||||
use papercraft::model::{
|
||||
Align, AutoLayout, BlendMode, Color, Constraint, Direction, ImageMode, Node, NodeId, NodeKind,
|
||||
Paint, Sizing,
|
||||
};
|
||||
use paper_rs::render::frame;
|
||||
use paper_rs::ui::UiState;
|
||||
use papercraft::render::frame;
|
||||
use papercraft::ui::UiState;
|
||||
|
||||
fn rect(ed: &mut Editor, r: Rect, c: Color, parent: Option<NodeId>) -> NodeId {
|
||||
let mut n = Node::new(NodeId(0), NodeKind::Rect, r);
|
||||
@@ -102,7 +102,7 @@ fn auto_layout_hugs_reorders_and_fills() {
|
||||
assert!((fr.w - total).abs() < 1e-6, "{fr:?} vs {total}");
|
||||
// Growing a child relayouts its siblings.
|
||||
e.selection = vec![k[0]];
|
||||
paper_rs::ui::inspector::apply(&mut e, "width", &[k[0]], |n| n.rect.w = 100.0);
|
||||
papercraft::ui::inspector::apply(&mut e, "width", &[k[0]], |n| n.rect.w = 100.0);
|
||||
assert_eq!(
|
||||
e.doc.get(k[1]).unwrap().rect.x,
|
||||
l.padding[3] + 100.0 + l.gap
|
||||
@@ -115,11 +115,11 @@ fn auto_layout_hugs_reorders_and_fills() {
|
||||
assert_eq!(e.doc.get(f).unwrap().children, vec![k[1], k[2], k[0]]);
|
||||
assert!(e.doc.get(k[0]).unwrap().rect.x > e.doc.get(k[2]).unwrap().rect.x);
|
||||
// Fill: one child takes the remaining width of a fixed frame.
|
||||
paper_rs::ui::inspector::apply(&mut e, "layout", &[f], |n| {
|
||||
papercraft::ui::inspector::apply(&mut e, "layout", &[f], |n| {
|
||||
n.layout_child.sizing = [Sizing::Fixed, Sizing::Fixed];
|
||||
n.rect.w = 800.0;
|
||||
});
|
||||
paper_rs::ui::inspector::apply(&mut e, "layout", &[k[1]], |n| {
|
||||
papercraft::ui::inspector::apply(&mut e, "layout", &[k[1]], |n| {
|
||||
n.layout_child.sizing[0] = Sizing::Fill
|
||||
});
|
||||
let r = |i: usize| e.doc.get(k[i]).unwrap().rect;
|
||||
@@ -136,10 +136,10 @@ fn constraints_follow_frame_resize_and_cmd_ignores_them() {
|
||||
let f = frame_node(&mut e, Rect::new(100.0, 100.0, 400.0, 300.0), None);
|
||||
let right = rect(&mut e, Rect::new(340.0, 20.0, 40.0, 40.0), RED, Some(f));
|
||||
let stretch = rect(&mut e, Rect::new(20.0, 240.0, 360.0, 40.0), BLUE, Some(f));
|
||||
paper_rs::ui::inspector::apply(&mut e, "layout", &[right], |n| {
|
||||
papercraft::ui::inspector::apply(&mut e, "layout", &[right], |n| {
|
||||
n.layout_child.constraints.h = Constraint::Max
|
||||
});
|
||||
paper_rs::ui::inspector::apply(&mut e, "layout", &[stretch], |n| {
|
||||
papercraft::ui::inspector::apply(&mut e, "layout", &[stretch], |n| {
|
||||
n.layout_child.constraints.h = Constraint::Stretch;
|
||||
n.layout_child.constraints.v = Constraint::Max;
|
||||
});
|
||||
@@ -169,7 +169,7 @@ fn components_sync_instances_and_respect_overrides() {
|
||||
let mut e = editor(1200.0, 900.0);
|
||||
let f = frame_node(&mut e, Rect::new(0.0, 0.0, 200.0, 60.0), None);
|
||||
let label = e.add_node(
|
||||
paper_rs::demo::text_node("Button", 16.0, v2(20.0, 20.0), Color::WHITE),
|
||||
papercraft::demo::text_node("Button", 16.0, v2(20.0, 20.0), Color::WHITE),
|
||||
Some(f),
|
||||
);
|
||||
let bg = rect(&mut e, Rect::new(0.0, 0.0, 200.0, 60.0), BLUE, Some(f));
|
||||
@@ -216,12 +216,12 @@ fn components_sync_instances_and_respect_overrides() {
|
||||
};
|
||||
// Override the fill of instance 2's background.
|
||||
let b2 = inst_bg(&e, i2);
|
||||
paper_rs::ui::inspector::apply(&mut e, "fill", &[b2], |n| n.style.set_fill(Some(RED)));
|
||||
papercraft::ui::inspector::apply(&mut e, "fill", &[b2], |n| n.style.set_fill(Some(RED)));
|
||||
// Edit the main component: fill and corner radius.
|
||||
paper_rs::ui::inspector::apply(&mut e, "fill", &[bg], |n| {
|
||||
papercraft::ui::inspector::apply(&mut e, "fill", &[bg], |n| {
|
||||
n.style.set_fill(Some(Color::rgb(0, 160, 90)))
|
||||
});
|
||||
paper_rs::ui::inspector::apply(&mut e, "radius", &[bg], |n| n.style.corner_radius = 12.0);
|
||||
papercraft::ui::inspector::apply(&mut e, "radius", &[bg], |n| n.style.corner_radius = 12.0);
|
||||
let n1 = e.doc.get(inst_bg(&e, i1)).unwrap();
|
||||
assert_eq!(
|
||||
n1.style.fill(),
|
||||
@@ -236,7 +236,7 @@ fn components_sync_instances_and_respect_overrides() {
|
||||
);
|
||||
// Text override on one instance.
|
||||
let l1 = inst_label(&e, i1);
|
||||
paper_rs::ui::inspector::apply(&mut e, "text", &[l1], |n| {
|
||||
papercraft::ui::inspector::apply(&mut e, "text", &[l1], |n| {
|
||||
if let NodeKind::Text { text, .. } = &mut n.kind {
|
||||
*text = "Buy now".into();
|
||||
}
|
||||
@@ -272,7 +272,7 @@ fn components_sync_instances_and_respect_overrides() {
|
||||
e.selection = vec![i1];
|
||||
e.detach_instance();
|
||||
assert!(!e.is_instance_root(i1));
|
||||
paper_rs::ui::inspector::apply(&mut e, "fill", &[bg], |n| n.style.set_fill(Some(BLUE)));
|
||||
papercraft::ui::inspector::apply(&mut e, "fill", &[bg], |n| n.style.set_fill(Some(BLUE)));
|
||||
assert_ne!(
|
||||
e.doc
|
||||
.get(e.doc.get(i1).unwrap().children[1])
|
||||
@@ -287,7 +287,7 @@ fn components_sync_instances_and_respect_overrides() {
|
||||
#[test]
|
||||
fn pen_draws_curves_and_points_can_be_edited() {
|
||||
let mut e = editor(1000.0, 800.0);
|
||||
e.run(paper_rs::editor::shortcuts::Action::Tool(Tool::Pen));
|
||||
e.run(papercraft::editor::shortcuts::Action::Tool(Tool::Pen));
|
||||
click(&mut e, v2(100.0, 100.0), 0.0);
|
||||
// Click-drag: a smooth point.
|
||||
ev_drag(&mut e, v2(300.0, 100.0), v2(350.0, 160.0), 500.0, true);
|
||||
@@ -343,13 +343,13 @@ fn booleans_and_flatten() {
|
||||
"subtract keeps the bottom layer's style"
|
||||
);
|
||||
assert_eq!(wr(&e, id), Rect::new(0.0, 0.0, 100.0, 100.0));
|
||||
assert!(paper_rs::editor::hit::node_contains(
|
||||
assert!(papercraft::editor::hit::node_contains(
|
||||
&e.doc,
|
||||
id,
|
||||
v2(10.0, 10.0),
|
||||
1.0
|
||||
));
|
||||
assert!(!paper_rs::editor::hit::node_contains(
|
||||
assert!(!papercraft::editor::hit::node_contains(
|
||||
&e.doc,
|
||||
id,
|
||||
v2(90.0, 90.0),
|
||||
@@ -370,7 +370,7 @@ fn booleans_and_flatten() {
|
||||
assert_eq!(wr(&e, e.selection[0]), Rect::new(0.0, 0.0, 150.0, 150.0));
|
||||
// Flatten text into outlines.
|
||||
let t = e.add_node(
|
||||
paper_rs::demo::text_node("Hi", 40.0, v2(300.0, 300.0), Color::INK),
|
||||
papercraft::demo::text_node("Hi", 40.0, v2(300.0, 300.0), Color::INK),
|
||||
None,
|
||||
);
|
||||
e.selection = vec![t];
|
||||
@@ -382,7 +382,7 @@ fn booleans_and_flatten() {
|
||||
fn rich_text_ranges_and_smart_guides() {
|
||||
let mut e = editor(1000.0, 800.0);
|
||||
let t = e.add_node(
|
||||
paper_rs::demo::text_node("Hello world", 20.0, v2(100.0, 100.0), Color::INK),
|
||||
papercraft::demo::text_node("Hello world", 20.0, v2(100.0, 100.0), Color::INK),
|
||||
None,
|
||||
);
|
||||
e.start_text_edit(t, None);
|
||||
@@ -431,7 +431,7 @@ fn rich_text_ranges_and_smart_guides() {
|
||||
assert!(
|
||||
e.guides
|
||||
.iter()
|
||||
.any(|g| matches!(g, paper_rs::editor::snap::Guide::Gap { .. })),
|
||||
.any(|g| matches!(g, papercraft::editor::snap::Guide::Gap { .. })),
|
||||
"{:?}",
|
||||
e.guides
|
||||
);
|
||||
@@ -457,7 +457,7 @@ fn rich_text_ranges_and_smart_guides() {
|
||||
time_ms: 500.0,
|
||||
});
|
||||
assert!(
|
||||
matches!(e.measures.first(), Some(paper_rs::editor::snap::Guide::Gap { a, b }) if (b.x - a.x - 100.0).abs() < 1e-9)
|
||||
matches!(e.measures.first(), Some(papercraft::editor::snap::Guide::Gap { a, b }) if (b.x - a.x - 100.0).abs() < 1e-9)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -506,11 +506,11 @@ pub fn figma_board() -> Editor {
|
||||
// Component: a button with auto layout.
|
||||
let btn = frame_node(&mut e, Rect::new(0.0, 0.0, 10.0, 10.0), None);
|
||||
let label = e.add_node(
|
||||
paper_rs::demo::text_node("Get started", 16.0, v2(0.0, 0.0), Color::WHITE),
|
||||
papercraft::demo::text_node("Get started", 16.0, v2(0.0, 0.0), Color::WHITE),
|
||||
Some(btn),
|
||||
);
|
||||
let _ = label;
|
||||
paper_rs::ui::inspector::apply(&mut e, "layout", &[btn], |n| {
|
||||
papercraft::ui::inspector::apply(&mut e, "layout", &[btn], |n| {
|
||||
n.name = "Button".into();
|
||||
n.component = true;
|
||||
n.style.fills = vec![Paint::linear(
|
||||
@@ -539,18 +539,18 @@ pub fn figma_board() -> Editor {
|
||||
let i1 = e.create_instance(btn, v2(60.0, 140.0)).unwrap();
|
||||
let i2 = e.create_instance(btn, v2(240.0, 140.0)).unwrap();
|
||||
let i2_label = e.doc.get(i2).unwrap().children[0];
|
||||
paper_rs::ui::inspector::apply(&mut e, "text", &[i2_label], |n| {
|
||||
papercraft::ui::inspector::apply(&mut e, "text", &[i2_label], |n| {
|
||||
if let NodeKind::Text { text, .. } = &mut n.kind {
|
||||
*text = "Upgrade →".into();
|
||||
}
|
||||
});
|
||||
paper_rs::ui::inspector::apply(&mut e, "fill", &[i2], |n| {
|
||||
papercraft::ui::inspector::apply(&mut e, "fill", &[i2], |n| {
|
||||
n.style.set_fill(Some(Color::rgb(0x1c, 0x1c, 0x1e)))
|
||||
});
|
||||
let _ = i1;
|
||||
// Card frame with auto layout (vertical) and constraints.
|
||||
let card = frame_node(&mut e, Rect::new(480.0, 40.0, 320.0, 380.0), None);
|
||||
paper_rs::ui::inspector::apply(&mut e, "layout", &[card], |n| {
|
||||
papercraft::ui::inspector::apply(&mut e, "layout", &[card], |n| {
|
||||
n.name = "Card".into();
|
||||
n.style.corner_radius = 20.0;
|
||||
n.style.stroke = Some(Color::rgb(0xe2, 0xe2, 0xe8));
|
||||
@@ -575,9 +575,9 @@ pub fn figma_board() -> Editor {
|
||||
255,
|
||||
]);
|
||||
}
|
||||
let asset = paper_rs::assets::register_rgba(&img);
|
||||
let asset = papercraft::assets::register_rgba(&img);
|
||||
let photo = rect(&mut e, Rect::new(0.0, 0.0, 280.0, 150.0), RED, Some(card));
|
||||
paper_rs::ui::inspector::apply(&mut e, "fill", &[photo], |n| {
|
||||
papercraft::ui::inspector::apply(&mut e, "fill", &[photo], |n| {
|
||||
n.name = "Photo".into();
|
||||
n.style.corner_radius = 12.0;
|
||||
n.style.fills = vec![Paint::Image {
|
||||
@@ -588,7 +588,7 @@ pub fn figma_board() -> Editor {
|
||||
n.layout_child.sizing[0] = Sizing::Fill;
|
||||
});
|
||||
let title = e.add_node(
|
||||
paper_rs::demo::text_node(
|
||||
papercraft::demo::text_node(
|
||||
"Rich text: bold, italic, colour",
|
||||
18.0,
|
||||
v2(0.0, 0.0),
|
||||
@@ -614,7 +614,7 @@ pub fn figma_board() -> Editor {
|
||||
e.style_text("color", |s| s.color = Some(Color::rgb(0xe8, 0x4a, 0x5f)));
|
||||
e.end_text_edit();
|
||||
let body = e.add_node(
|
||||
paper_rs::demo::text_node(
|
||||
papercraft::demo::text_node(
|
||||
"Auto layout stacks these children vertically with a 14 px gap.",
|
||||
14.0,
|
||||
v2(0.0, 0.0),
|
||||
@@ -622,7 +622,7 @@ pub fn figma_board() -> Editor {
|
||||
),
|
||||
Some(card),
|
||||
);
|
||||
paper_rs::ui::inspector::apply(&mut e, "layout", &[body, title], |n| {
|
||||
papercraft::ui::inspector::apply(&mut e, "layout", &[body, title], |n| {
|
||||
n.layout_child.sizing[0] = Sizing::Fill;
|
||||
if let NodeKind::Text { auto_width, .. } = &mut n.kind {
|
||||
*auto_width = false;
|
||||
@@ -654,7 +654,7 @@ pub fn figma_board() -> Editor {
|
||||
poly.style.stroke = Some(Color::rgb(0x0f, 0x5e, 0x3f));
|
||||
poly.style.stroke_width = 4.0;
|
||||
poly.style.dash = vec![10.0, 6.0];
|
||||
poly.style.join = paper_rs::model::Join::Round;
|
||||
poly.style.join = papercraft::model::Join::Round;
|
||||
e.add_node(poly, None);
|
||||
let c1 = e.add_node(
|
||||
Node::new(
|
||||
@@ -672,10 +672,10 @@ pub fn figma_board() -> Editor {
|
||||
),
|
||||
None,
|
||||
);
|
||||
paper_rs::ui::inspector::apply(&mut e, "fill", &[c1], |n| {
|
||||
papercraft::ui::inspector::apply(&mut e, "fill", &[c1], |n| {
|
||||
n.style.set_fill(Some(Color::rgb(0x2f, 0x9b, 0xf3)))
|
||||
});
|
||||
paper_rs::ui::inspector::apply(&mut e, "fill", &[c2], |n| {
|
||||
papercraft::ui::inspector::apply(&mut e, "fill", &[c2], |n| {
|
||||
n.style.set_fill(Some(Color::rgb(0xff, 0x5c, 0x8a)));
|
||||
n.style.blend = BlendMode::Multiply;
|
||||
});
|
||||
@@ -696,13 +696,13 @@ pub fn figma_board() -> Editor {
|
||||
),
|
||||
None,
|
||||
);
|
||||
paper_rs::ui::inspector::apply(&mut e, "fill", &[outer], |n| {
|
||||
papercraft::ui::inspector::apply(&mut e, "fill", &[outer], |n| {
|
||||
n.style.set_fill(Some(Color::rgb(0x7c, 0x4d, 0xff)))
|
||||
});
|
||||
e.selection = vec![outer, inner];
|
||||
e.boolean(BoolOp::Subtract);
|
||||
// Pen path.
|
||||
e.run(paper_rs::editor::shortcuts::Action::Tool(Tool::Pen));
|
||||
e.run(papercraft::editor::shortcuts::Action::Tool(Tool::Pen));
|
||||
let s = |p: V2| e.camera.world_to_screen(p);
|
||||
let (p0, p1, p2) = (s(v2(60.0, 330.0)), s(v2(200.0, 300.0)), s(v2(380.0, 380.0)));
|
||||
click(&mut e, p0, 0.0);
|
||||
@@ -710,10 +710,10 @@ pub fn figma_board() -> Editor {
|
||||
click(&mut e, p2, 900.0);
|
||||
key(&mut e, Key::Enter, Modifiers::NONE);
|
||||
if let Some(&pen) = e.selection.first() {
|
||||
paper_rs::ui::inspector::apply(&mut e, "stroke", &[pen], |n| {
|
||||
papercraft::ui::inspector::apply(&mut e, "stroke", &[pen], |n| {
|
||||
n.style.stroke = Some(Color::rgb(0xe8, 0x4a, 0x5f));
|
||||
n.style.stroke_width = 4.0;
|
||||
n.style.cap = paper_rs::model::Cap::Round;
|
||||
n.style.cap = papercraft::model::Cap::Round;
|
||||
});
|
||||
}
|
||||
e.selection = vec![card];
|
||||
@@ -739,7 +739,7 @@ fn m3_figma_core_screenshot() {
|
||||
let mut e = figma_board();
|
||||
e.selection.clear();
|
||||
e.viewport = v2(1400.0, 520.0);
|
||||
e.camera = paper_rs::geom::Camera {
|
||||
e.camera = papercraft::geom::Camera {
|
||||
pan: v2(20.0, 20.0),
|
||||
zoom: 1.0,
|
||||
};
|
||||
@@ -756,7 +756,7 @@ fn m3_figma_core_screenshot() {
|
||||
// Ring: the hole shows the canvas.
|
||||
assert!(close(
|
||||
at(v2(1225.0, 335.0)),
|
||||
paper_rs::render::scene::CANVAS_BG,
|
||||
papercraft::render::scene::CANVAS_BG,
|
||||
6
|
||||
));
|
||||
// Radial gradient star: center is yellow, tips are orange.
|
||||
|
||||
@@ -5,14 +5,14 @@ mod common;
|
||||
|
||||
use common::*;
|
||||
|
||||
use paper_rs::demo;
|
||||
use paper_rs::editor::{Button, Editor, InputEvent, Key, Modifiers, Tool};
|
||||
use paper_rs::geom::{Rect, V2, v2};
|
||||
use paper_rs::io::{file, png, svg};
|
||||
use paper_rs::model::{Color, Node, NodeId, NodeKind};
|
||||
use paper_rs::render::frame;
|
||||
use paper_rs::render::scene::{self, ACCENT, CANVAS_BG, GUIDE};
|
||||
use paper_rs::ui::UiState;
|
||||
use papercraft::demo;
|
||||
use papercraft::editor::{Button, Editor, InputEvent, Key, Modifiers, Tool};
|
||||
use papercraft::geom::{Rect, V2, v2};
|
||||
use papercraft::io::{file, png, svg};
|
||||
use papercraft::model::{Color, Node, NodeId, NodeKind};
|
||||
use papercraft::render::frame;
|
||||
use papercraft::render::scene::{self, ACCENT, CANVAS_BG, GUIDE};
|
||||
use papercraft::ui::UiState;
|
||||
|
||||
#[test]
|
||||
fn m1_infinite_canvas_pan_and_zoom() {
|
||||
@@ -499,7 +499,7 @@ fn m5_layers_panel_and_undo_redo() {
|
||||
fn m6_save_load_and_export() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let e = demo::showcase();
|
||||
let dir = std::env::temp_dir().join(format!("paper-rs-m6-{}", std::process::id()));
|
||||
let dir = std::env::temp_dir().join(format!("papercraft-m6-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("launch.paper-rs");
|
||||
file::save(&path, &e.doc, Some(&e.camera)).unwrap();
|
||||
@@ -556,7 +556,7 @@ fn app_full_ui_screenshot() {
|
||||
close(
|
||||
img.get_pixel(20, 600).0,
|
||||
{
|
||||
let c = paper_rs::theme::palette(false).sidebar;
|
||||
let c = papercraft::theme::palette(false).sidebar;
|
||||
Color::rgb(c.r(), c.g(), c.b())
|
||||
},
|
||||
3
|
||||
@@ -612,7 +612,7 @@ fn m6_svg_export_matches_gpu_render() {
|
||||
fn skipped_first_frame_keeps_egui_textures_in_sync() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let ctx = egui::Context::default();
|
||||
paper_rs::ui::setup_style(&ctx);
|
||||
papercraft::ui::setup_style(&ctx);
|
||||
let mut e = demo::showcase();
|
||||
let mut st = UiState::default();
|
||||
let (pts, ppp) = ([800.0f32, 600.0f32], 2.0f32);
|
||||
@@ -632,7 +632,7 @@ fn skipped_first_frame_keeps_egui_textures_in_sync() {
|
||||
.collect(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut painter = frame::Painter::new(&g.device, paper_rs::render::gpu::OFFSCREEN_FORMAT);
|
||||
let mut painter = frame::Painter::new(&g.device, papercraft::render::gpu::OFFSCREEN_FORMAT);
|
||||
let tex = g.target(size);
|
||||
let view = tex.create_view(&Default::default());
|
||||
// Frame 1: surface unavailable -> skipped.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
//! `<x-paper-icon>` and `search_icons` against a throwaway icon library. Its own test binary
|
||||
//! with a single test: it sets `PAPER_RS_ICONS` once, before anything reads the environment.
|
||||
//! with a single test: it sets `PAPERCRAFT_ICONS` once, before anything reads the environment.
|
||||
|
||||
use paper_rs::mcp::{Out, exec};
|
||||
use paper_rs::model::{NodeId, NodeKind, Paint};
|
||||
use paper_rs::project::Store;
|
||||
use papercraft::mcp::{Out, exec};
|
||||
use papercraft::model::{NodeId, NodeKind, Paint};
|
||||
use papercraft::project::Store;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const STROKE_ICON: &str = r#"<!-- @license test -->
|
||||
@@ -20,15 +20,15 @@ fn call(h: &mut exec::SessionHost, name: &str, args: Value) -> Result<Value, Str
|
||||
|
||||
#[test]
|
||||
fn icons_place_as_vector_layers_that_follow_a_variable() {
|
||||
let root = std::env::temp_dir().join(format!("paper-rs-icon-lib-{:016x}", fastrand::u64(..)));
|
||||
let root = std::env::temp_dir().join(format!("papercraft-icon-lib-{:016x}", fastrand::u64(..)));
|
||||
std::fs::create_dir_all(root.join("test")).unwrap();
|
||||
std::fs::write(root.join("test/check.svg"), STROKE_ICON).unwrap();
|
||||
std::fs::write(root.join("test/square.svg"), FILL_ICON).unwrap();
|
||||
// SAFETY: the only test in this binary, so nothing else is reading the environment.
|
||||
unsafe { std::env::set_var("PAPER_RS_ICONS", &root) };
|
||||
unsafe { std::env::set_var("PAPERCRAFT_ICONS", &root) };
|
||||
|
||||
let store =
|
||||
Store::open(std::env::temp_dir().join(format!("paper-rs-ic-{:016x}", fastrand::u64(..))))
|
||||
Store::open(std::env::temp_dir().join(format!("papercraft-ic-{:016x}", fastrand::u64(..))))
|
||||
.unwrap();
|
||||
let mut h = exec::SessionHost::new(store);
|
||||
h.session.create(&mut h.ed, "Icons").unwrap();
|
||||
@@ -64,7 +64,7 @@ fn icons_place_as_vector_layers_that_follow_a_variable() {
|
||||
r.to_string().contains("not found"),
|
||||
"the missing icon is reported: {r}"
|
||||
);
|
||||
fn node<'a>(h: &'a exec::SessionHost, v: &Value) -> &'a paper_rs::model::Node {
|
||||
fn node<'a>(h: &'a exec::SessionHost, v: &Value) -> &'a papercraft::model::Node {
|
||||
h.ed.doc
|
||||
.get(NodeId(
|
||||
v.as_str()
|
||||
|
||||
@@ -4,17 +4,17 @@
|
||||
mod common;
|
||||
|
||||
use common::*;
|
||||
use paper_rs::editor::shortcuts::Action;
|
||||
use paper_rs::editor::{Editor, Key, Modifiers};
|
||||
use paper_rs::geom::{Rect, V2, v2};
|
||||
use paper_rs::io::clipboard::{Memory, Provider};
|
||||
use paper_rs::io::export::{self, Format, Scope, Spec};
|
||||
use paper_rs::model::{Color, ImageMode, Node, NodeId, NodeKind, Paint};
|
||||
use papercraft::editor::shortcuts::Action;
|
||||
use papercraft::editor::{Editor, Key, Modifiers};
|
||||
use papercraft::geom::{Rect, V2, v2};
|
||||
use papercraft::io::clipboard::{Memory, Provider};
|
||||
use papercraft::io::export::{self, Format, Scope, Spec};
|
||||
use papercraft::model::{Color, ImageMode, Node, NodeId, NodeKind, Paint};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
fn tmp(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("paper-rs-io-{tag}-{:016x}", fastrand::u64(..)));
|
||||
let d = std::env::temp_dir().join(format!("papercraft-io-{tag}-{:016x}", fastrand::u64(..)));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
@@ -43,7 +43,7 @@ fn rasterize_pdf(pdf: &PathBuf, out_stem: &PathBuf, page: usize) -> Option<image
|
||||
}
|
||||
|
||||
fn board() -> Editor {
|
||||
let mut e = paper_rs::demo::showcase();
|
||||
let mut e = papercraft::demo::showcase();
|
||||
// A gradient, an image fill and a multiply blend, so the PDF exercises every paint.
|
||||
let mut g = Node::new(
|
||||
NodeId(0),
|
||||
@@ -58,7 +58,7 @@ fn board() -> Editor {
|
||||
let img = image::RgbaImage::from_fn(40, 30, |x, y| {
|
||||
image::Rgba([(x * 6) as u8, (y * 8) as u8, 180, 255])
|
||||
});
|
||||
let asset = paper_rs::assets::register_rgba(&img);
|
||||
let asset = papercraft::assets::register_rgba(&img);
|
||||
let mut r = Node::new(
|
||||
NodeId(0),
|
||||
NodeKind::Rect,
|
||||
@@ -132,7 +132,7 @@ fn m6_png_and_svg_export_of_frames_and_all_pages() {
|
||||
);
|
||||
e.switch_page(0);
|
||||
let names: Vec<String> = e.pages.iter().map(|p| p.name.clone()).collect();
|
||||
let pages: Vec<(&str, &paper_rs::model::Document)> = names
|
||||
let pages: Vec<(&str, &papercraft::model::Document)> = names
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, n)| (n.as_str(), e.page_doc(i)))
|
||||
@@ -180,7 +180,7 @@ fn m6_png_and_svg_export_of_frames_and_all_pages() {
|
||||
fn m6_import_excalidraw_and_svg_screenshots() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let json = include_str!("fixtures/excalidraw/basic.excalidraw");
|
||||
let doc = paper_rs::io::excalidraw::import_scene(json).unwrap();
|
||||
let doc = papercraft::io::excalidraw::import_scene(json).unwrap();
|
||||
let mut e = editor(1200.0, 800.0);
|
||||
e.load(doc, None, None);
|
||||
e.viewport = v2(1200.0, 800.0);
|
||||
@@ -195,7 +195,7 @@ fn m6_import_excalidraw_and_svg_screenshots() {
|
||||
<circle cx="80" cy="100" r="70" fill="url(#r)"/>
|
||||
<path d="M170 30 L280 30 L225 170 Z" fill="#4dabf7" stroke="#1c3d5a" stroke-width="6" stroke-linejoin="round"/>
|
||||
<text x="40" y="195" font-size="18" fill="#222">SVG import</text></svg>"##;
|
||||
let doc = paper_rs::io::svg_import::import_page(svg, "logo").unwrap();
|
||||
let doc = papercraft::io::svg_import::import_page(svg, "logo").unwrap();
|
||||
let mut e = editor(800.0, 560.0);
|
||||
e.load(doc, None, None);
|
||||
e.viewport = v2(800.0, 560.0);
|
||||
@@ -316,7 +316,7 @@ fn m6_clipboard_between_apps_and_from_other_tools() {
|
||||
#[test]
|
||||
fn m6_cli_exports_pdf_and_imports_excalidraw() {
|
||||
let dir = tmp("cli");
|
||||
let exe = env!("CARGO_BIN_EXE_paper-rs");
|
||||
let exe = env!("CARGO_BIN_EXE_papercraft");
|
||||
let pdf = dir.join("demo.pdf");
|
||||
let st = std::process::Command::new(exe)
|
||||
.args(["--export-pdf"])
|
||||
@@ -327,7 +327,7 @@ fn m6_cli_exports_pdf_and_imports_excalidraw() {
|
||||
assert!(st.success());
|
||||
assert!(std::fs::read(&pdf).unwrap().starts_with(b"%PDF-1.7"));
|
||||
let out = std::process::Command::new(exe)
|
||||
.env("PAPER_RS_HOME", dir.join("home"))
|
||||
.env("PAPERCRAFT_HOME", dir.join("home"))
|
||||
.arg("--import")
|
||||
.arg(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
@@ -342,7 +342,7 @@ fn m6_cli_exports_pdf_and_imports_excalidraw() {
|
||||
);
|
||||
let proj = PathBuf::from(String::from_utf8_lossy(&out.stdout).trim().to_string());
|
||||
assert!(proj.join("project.json").is_file());
|
||||
let p = paper_rs::project::Project::open(&proj).unwrap();
|
||||
let p = papercraft::project::Project::open(&proj).unwrap();
|
||||
let pages = p.load_pages().unwrap();
|
||||
assert!(pages[0].doc.nodes.len() >= 8);
|
||||
// The fixture's image came along as a project asset.
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
//! MCP server: tool behaviour on a headless session, then the real thing end to end — an rmcp
|
||||
//! client over streamable HTTP, and through the `paper-rs-mcp` stdio bridge.
|
||||
//! client over streamable HTTP, and through the `papercraft-mcp` stdio bridge.
|
||||
|
||||
use paper_rs::mcp::{self, Out, exec};
|
||||
use paper_rs::model::{Color, NodeKind, Paint, Sizing};
|
||||
use paper_rs::project::Store;
|
||||
use papercraft::mcp::{self, Out, exec};
|
||||
use papercraft::model::{Color, NodeKind, Paint, Sizing};
|
||||
use papercraft::project::Store;
|
||||
use rmcp::ServiceExt;
|
||||
use rmcp::model::{CallToolRequestParams, CallToolResult, ContentBlock};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn store() -> Store {
|
||||
Store::open(std::env::temp_dir().join(format!("paper-rs-mcp-{:016x}", fastrand::u64(..))))
|
||||
Store::open(std::env::temp_dir().join(format!("papercraft-mcp-{:016x}", fastrand::u64(..))))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
@@ -32,8 +32,8 @@ fn call(h: &mut exec::SessionHost, name: &str, args: Value) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
fn num(id: &Value) -> paper_rs::model::NodeId {
|
||||
paper_rs::model::NodeId(
|
||||
fn num(id: &Value) -> papercraft::model::NodeId {
|
||||
papercraft::model::NodeId(
|
||||
id.as_str()
|
||||
.unwrap()
|
||||
.rsplit(':')
|
||||
@@ -394,7 +394,7 @@ fn errors_are_reported_not_panics() {
|
||||
|
||||
#[test]
|
||||
fn screenshot_returns_png() {
|
||||
if paper_rs::render::gpu::Headless::new().is_err() {
|
||||
if papercraft::render::gpu::Headless::new().is_err() {
|
||||
eprintln!("no GPU adapter; skipping");
|
||||
return;
|
||||
}
|
||||
@@ -426,7 +426,7 @@ fn screenshot_returns_png() {
|
||||
/// The worker hands back the final document and undo depth when the server stops.
|
||||
fn spawn_app() -> (
|
||||
mcp::server::Running,
|
||||
std::thread::JoinHandle<(paper_rs::model::Document, usize)>,
|
||||
std::thread::JoinHandle<(papercraft::model::Document, usize)>,
|
||||
) {
|
||||
let (tx, rx) = std::sync::mpsc::channel::<mcp::Call>();
|
||||
let worker = std::thread::spawn(move || {
|
||||
@@ -507,7 +507,7 @@ async fn http_client_draws_a_frame_with_text() {
|
||||
let transport = rmcp::transport::StreamableHttpClientTransport::from_uri(running.url());
|
||||
let client = ().serve(transport).await.unwrap();
|
||||
let info = client.peer_info().unwrap();
|
||||
assert_eq!(info.server_info.as_ref().unwrap().name, "paper-rs");
|
||||
assert_eq!(info.server_info.as_ref().unwrap().name, "PaperCraft");
|
||||
assert!(
|
||||
info.instructions
|
||||
.as_deref()
|
||||
@@ -563,8 +563,8 @@ async fn http_rejects_browsers_and_unknown_routes() {
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn stdio_bridge_relays_to_the_app() {
|
||||
let (running, _worker) = spawn_app();
|
||||
let mut cmd = tokio::process::Command::new(env!("CARGO_BIN_EXE_paper-rs-mcp"));
|
||||
cmd.env("PAPER_RS_MCP_PORT", running.port.to_string());
|
||||
let mut cmd = tokio::process::Command::new(env!("CARGO_BIN_EXE_papercraft-mcp"));
|
||||
cmd.env("PAPERCRAFT_MCP_PORT", running.port.to_string());
|
||||
let child = rmcp::transport::TokioChildProcess::new(cmd).unwrap();
|
||||
let client = ().serve(child).await.unwrap();
|
||||
draw_frame_with_text(&client).await;
|
||||
@@ -579,8 +579,8 @@ async fn stdio_bridge_relays_to_the_app() {
|
||||
fn stdio_bridge_results_carry_result_type_for_2026_clients() {
|
||||
use std::io::{BufRead, Write};
|
||||
let (running, _worker) = spawn_app();
|
||||
let mut child = std::process::Command::new(env!("CARGO_BIN_EXE_paper-rs-mcp"))
|
||||
.env("PAPER_RS_MCP_PORT", running.port.to_string())
|
||||
let mut child = std::process::Command::new(env!("CARGO_BIN_EXE_papercraft-mcp"))
|
||||
.env("PAPERCRAFT_MCP_PORT", running.port.to_string())
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::null())
|
||||
@@ -621,8 +621,8 @@ async fn stdio_bridge_lists_tools_without_the_app() {
|
||||
.local_addr()
|
||||
.unwrap()
|
||||
.port();
|
||||
let mut cmd = tokio::process::Command::new(env!("CARGO_BIN_EXE_paper-rs-mcp"));
|
||||
cmd.env("PAPER_RS_MCP_PORT", port.to_string());
|
||||
let mut cmd = tokio::process::Command::new(env!("CARGO_BIN_EXE_papercraft-mcp"));
|
||||
cmd.env("PAPERCRAFT_MCP_PORT", port.to_string());
|
||||
let client = ().serve(rmcp::transport::TokioChildProcess::new(cmd).unwrap()).await.unwrap();
|
||||
assert!(client.list_all_tools().await.unwrap().len() >= 30);
|
||||
let r = client
|
||||
@@ -640,7 +640,7 @@ async fn stdio_bridge_lists_tools_without_the_app() {
|
||||
/// A design written the way an agent would, rendered for the docs (`milestone7_mcp.png`).
|
||||
#[test]
|
||||
fn agent_design_screenshot() {
|
||||
if paper_rs::render::gpu::Headless::new().is_err() {
|
||||
if papercraft::render::gpu::Headless::new().is_err() {
|
||||
eprintln!("no GPU adapter; skipping");
|
||||
return;
|
||||
}
|
||||
@@ -687,15 +687,15 @@ fn agent_design_screenshot() {
|
||||
);
|
||||
}
|
||||
|
||||
/// What a shell-driven agent does with `paper-rs-cli`: list tools, draw, read back, screenshot.
|
||||
/// What a shell-driven agent does with `papercraft-cli`: list tools, draw, read back, screenshot.
|
||||
#[test]
|
||||
fn cli_drives_the_running_app() {
|
||||
let (running, _worker) = spawn_app();
|
||||
let dir = std::env::temp_dir().join(format!("paper-rs-cli-{:016x}", fastrand::u64(..)));
|
||||
let dir = std::env::temp_dir().join(format!("papercraft-cli-{:016x}", fastrand::u64(..)));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let cli = |args: &[&str]| {
|
||||
std::process::Command::new(env!("CARGO_BIN_EXE_paper-rs-cli"))
|
||||
.env("PAPER_RS_MCP_PORT", running.port.to_string())
|
||||
std::process::Command::new(env!("CARGO_BIN_EXE_papercraft-cli"))
|
||||
.env("PAPERCRAFT_MCP_PORT", running.port.to_string())
|
||||
.current_dir(&dir)
|
||||
.args(args)
|
||||
.output()
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
mod common;
|
||||
|
||||
use common::*;
|
||||
use paper_rs::editor::Editor;
|
||||
use paper_rs::geom::{Rect, V2, v2};
|
||||
use paper_rs::model::{Color, Node, NodeId, NodeKind};
|
||||
use paper_rs::render::gpu::{CanvasRenderer, OFFSCREEN_FORMAT};
|
||||
use paper_rs::render::scene::{self, SceneCache};
|
||||
use papercraft::editor::Editor;
|
||||
use papercraft::geom::{Rect, V2, v2};
|
||||
use papercraft::model::{Color, Node, NodeId, NodeKind};
|
||||
use papercraft::render::gpu::{CanvasRenderer, OFFSCREEN_FORMAT};
|
||||
use papercraft::render::scene::{self, SceneCache};
|
||||
use std::time::Instant;
|
||||
|
||||
pub fn ten_thousand() -> Editor {
|
||||
@@ -27,7 +27,7 @@ pub fn ten_thousand() -> Editor {
|
||||
text: format!("#{i}"),
|
||||
font_size: 12.0,
|
||||
auto_width: true,
|
||||
align: paper_rs::model::TextAlign::Left,
|
||||
align: papercraft::model::TextAlign::Left,
|
||||
},
|
||||
};
|
||||
let mut n = Node::new(NodeId(0), kind, Rect::new(x, y, 40.0, 40.0));
|
||||
|
||||
@@ -3,18 +3,18 @@
|
||||
mod common;
|
||||
|
||||
use common::*;
|
||||
use paper_rs::demo;
|
||||
use paper_rs::editor::Editor;
|
||||
use paper_rs::geom::{Rect, v2};
|
||||
use paper_rs::model::{Color, Node, NodeId, NodeKind};
|
||||
use paper_rs::project::{PageData, Project, Store};
|
||||
use paper_rs::render::frame;
|
||||
use paper_rs::session::{self, Session};
|
||||
use paper_rs::ui::UiState;
|
||||
use paper_rs::ui::home::{HomeAction, HomeView};
|
||||
use papercraft::demo;
|
||||
use papercraft::editor::Editor;
|
||||
use papercraft::geom::{Rect, v2};
|
||||
use papercraft::model::{Color, Node, NodeId, NodeKind};
|
||||
use papercraft::project::{PageData, Project, Store};
|
||||
use papercraft::render::frame;
|
||||
use papercraft::session::{self, Session};
|
||||
use papercraft::ui::UiState;
|
||||
use papercraft::ui::home::{HomeAction, HomeView};
|
||||
|
||||
fn temp_store(tag: &str) -> Store {
|
||||
let root = std::env::temp_dir().join(format!("paper-rs-{tag}-{:016x}", fastrand::u64(..)));
|
||||
let root = std::env::temp_dir().join(format!("papercraft-{tag}-{:016x}", fastrand::u64(..)));
|
||||
Store::open(root).unwrap()
|
||||
}
|
||||
|
||||
@@ -22,18 +22,18 @@ fn temp_store(tag: &str) -> Store {
|
||||
fn populate(store: &Store) -> Vec<std::path::PathBuf> {
|
||||
let mut dirs = Vec::new();
|
||||
let showcase = demo::showcase();
|
||||
let boards: Vec<(&str, paper_rs::model::Document)> = vec![
|
||||
let boards: Vec<(&str, papercraft::model::Document)> = vec![
|
||||
("Launch plan", showcase.doc.clone()),
|
||||
("Brand moodboard", demo::moodboard().doc),
|
||||
("Onboarding flow", demo::flow().doc),
|
||||
("Empty ideas", paper_rs::model::Document::default()),
|
||||
("Empty ideas", papercraft::model::Document::default()),
|
||||
];
|
||||
for (i, (name, doc)) in boards.into_iter().enumerate() {
|
||||
let p = store
|
||||
.create_with_pages(
|
||||
name,
|
||||
vec![PageData {
|
||||
id: paper_rs::project::new_id(),
|
||||
id: papercraft::project::new_id(),
|
||||
name: "Page 1".into(),
|
||||
doc,
|
||||
camera: None,
|
||||
@@ -136,7 +136,7 @@ fn m1_autosave_survives_reopen_and_crash_temp_files() {
|
||||
fn m1_home_actions_rename_duplicate_trash() {
|
||||
let store = temp_store("actions");
|
||||
let dirs = populate(&store);
|
||||
let mut hv = HomeView::load(&store, paper_rs::project::now_ms());
|
||||
let mut hv = HomeView::load(&store, papercraft::project::now_ms());
|
||||
hv.actions = vec![
|
||||
HomeAction::Rename(dirs[0].clone(), "Q4 launch".into()),
|
||||
HomeAction::Duplicate(dirs[1].clone()),
|
||||
@@ -156,7 +156,7 @@ fn m1_home_actions_rename_duplicate_trash() {
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
hv.refresh(&store, paper_rs::project::now_ms());
|
||||
hv.refresh(&store, papercraft::project::now_ms());
|
||||
let mut names: Vec<String> = hv.entries.iter().map(|e| e.manifest.name.clone()).collect();
|
||||
names.sort();
|
||||
assert_eq!(
|
||||
@@ -245,14 +245,14 @@ fn m2_copy_paste_between_pages_and_projects() {
|
||||
ed.selection = vec![id];
|
||||
key(
|
||||
&mut ed,
|
||||
paper_rs::editor::Key::Char('c'),
|
||||
paper_rs::editor::Modifiers::cmd(),
|
||||
papercraft::editor::Key::Char('c'),
|
||||
papercraft::editor::Modifiers::cmd(),
|
||||
);
|
||||
ed.add_page();
|
||||
key(
|
||||
&mut ed,
|
||||
paper_rs::editor::Key::Char('v'),
|
||||
paper_rs::editor::Modifiers::cmd(),
|
||||
papercraft::editor::Key::Char('v'),
|
||||
papercraft::editor::Modifiers::cmd(),
|
||||
);
|
||||
assert_eq!(ed.doc.nodes.len(), 1);
|
||||
let pasted = ed.doc.get(ed.selection[0]).unwrap();
|
||||
@@ -270,8 +270,8 @@ fn m2_copy_paste_between_pages_and_projects() {
|
||||
assert!(ed.doc.nodes.is_empty());
|
||||
key(
|
||||
&mut ed,
|
||||
paper_rs::editor::Key::Char('v'),
|
||||
paper_rs::editor::Modifiers::cmd(),
|
||||
papercraft::editor::Key::Char('v'),
|
||||
papercraft::editor::Modifiers::cmd(),
|
||||
);
|
||||
assert_eq!(ed.doc.nodes.len(), 1);
|
||||
std::fs::remove_dir_all(&store.root).ok();
|
||||
@@ -280,8 +280,8 @@ fn m2_copy_paste_between_pages_and_projects() {
|
||||
#[test]
|
||||
fn m2_pages_panel_screenshot() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let page = |name: &str, doc: paper_rs::model::Document| PageData {
|
||||
id: paper_rs::project::new_id(),
|
||||
let page = |name: &str, doc: papercraft::model::Document| PageData {
|
||||
id: papercraft::project::new_id(),
|
||||
name: name.into(),
|
||||
doc,
|
||||
camera: None,
|
||||
@@ -292,7 +292,7 @@ fn m2_pages_panel_screenshot() {
|
||||
page("Launch plan", demo::showcase().doc),
|
||||
page("Moodboard", demo::moodboard().doc),
|
||||
page("Flows", demo::flow().doc),
|
||||
page("Archive", paper_rs::model::Document::default()),
|
||||
page("Archive", papercraft::model::Document::default()),
|
||||
],
|
||||
None,
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Regression: an inline rename used to re-request keyboard focus every frame, so once started
|
||||
//! it blocked every other input (number fields, shortcuts) until Enter.
|
||||
use paper_rs::{demo, editor::Editor, render::frame, ui::UiState};
|
||||
use papercraft::{demo, editor::Editor, render::frame, ui::UiState};
|
||||
|
||||
fn run(ctx: &egui::Context, events: Vec<egui::Event>, e: &mut Editor, st: &mut UiState) {
|
||||
let input = egui::RawInput {
|
||||
@@ -20,7 +20,7 @@ fn run(ctx: &egui::Context, events: Vec<egui::Event>, e: &mut Editor, st: &mut U
|
||||
#[test]
|
||||
fn clicking_another_input_ends_a_page_rename_and_takes_focus() {
|
||||
let ctx = egui::Context::default();
|
||||
paper_rs::ui::setup_style(&ctx);
|
||||
papercraft::ui::setup_style(&ctx);
|
||||
let mut e = demo::showcase();
|
||||
let first = e.doc.children(None).first().copied().unwrap();
|
||||
e.select(vec![first]);
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
mod common;
|
||||
|
||||
use common::*;
|
||||
use paper_rs::editor::shortcuts::{self, Action};
|
||||
use paper_rs::editor::{Button, Editor, InputEvent, Key, Modifiers};
|
||||
use paper_rs::geom::{Rect, v2};
|
||||
use paper_rs::model::{Color, Node, NodeId, NodeKind};
|
||||
use paper_rs::render::frame;
|
||||
use paper_rs::ui::palette;
|
||||
use paper_rs::ui::{LayerDrop, UiState, drop_layer};
|
||||
use papercraft::editor::shortcuts::{self, Action};
|
||||
use papercraft::editor::{Button, Editor, InputEvent, Key, Modifiers};
|
||||
use papercraft::geom::{Rect, v2};
|
||||
use papercraft::model::{Color, Node, NodeId, NodeKind};
|
||||
use papercraft::render::frame;
|
||||
use papercraft::ui::palette;
|
||||
use papercraft::ui::{LayerDrop, UiState, drop_layer};
|
||||
|
||||
fn rect(e: &mut Editor, r: Rect, c: Color, parent: Option<NodeId>) -> NodeId {
|
||||
let mut n = Node::new(NodeId(0), NodeKind::Rect, r);
|
||||
@@ -52,7 +52,7 @@ fn layers_drop_above_below_and_into() {
|
||||
|
||||
#[test]
|
||||
fn command_palette_finds_and_runs() {
|
||||
let mut e = paper_rs::demo::showcase();
|
||||
let mut e = papercraft::demo::showcase();
|
||||
let mut st = UiState::default();
|
||||
let r = palette::search(&e, &st, "zoom fit");
|
||||
assert!(
|
||||
@@ -64,7 +64,7 @@ fn command_palette_finds_and_runs() {
|
||||
key(&mut e, Key::Char('k'), Modifiers::cmd());
|
||||
assert!(matches!(
|
||||
e.ui_events.as_slice(),
|
||||
[paper_rs::editor::commands::UiEvent::TogglePalette]
|
||||
[papercraft::editor::commands::UiEvent::TogglePalette]
|
||||
));
|
||||
// Run "Select layer: Week plan".
|
||||
let r = palette::search(&e, &st, "select layer week");
|
||||
@@ -158,7 +158,7 @@ fn editing_shortcuts() {
|
||||
e.selection = vec![a];
|
||||
key(&mut e, Key::Char('r'), Modifiers::cmd());
|
||||
key(&mut e, Key::Char('\\'), Modifiers::cmd());
|
||||
use paper_rs::editor::commands::UiEvent;
|
||||
use papercraft::editor::commands::UiEvent;
|
||||
assert_eq!(e.ui_events, vec![UiEvent::Rename(a), UiEvent::ToggleUi]);
|
||||
let _ = Button::Primary;
|
||||
}
|
||||
@@ -175,7 +175,7 @@ fn shortcut_table_is_complete_and_conflict_free() {
|
||||
#[test]
|
||||
fn m5_palette_and_shortcuts_screenshots() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let mut e = paper_rs::demo::showcase();
|
||||
let mut e = papercraft::demo::showcase();
|
||||
let mut st = UiState {
|
||||
project_name: "Launch plan".into(),
|
||||
save_state: "Saved".into(),
|
||||
@@ -195,7 +195,7 @@ fn m5_palette_and_shortcuts_screenshots() {
|
||||
img.save(shot("milestone5_shortcuts.png")).unwrap();
|
||||
st.show_help = false;
|
||||
// Inspector for a selected frame, layers tree expanded.
|
||||
let mut e = paper_rs::demo::showcase();
|
||||
let mut e = papercraft::demo::showcase();
|
||||
e.selection = vec![e.doc.root[0]];
|
||||
let img = frame::capture(g, &mut e, &mut st, [1440.0, 900.0], 2.0, |ed| {
|
||||
ed.zoom_to_fit()
|
||||
|
||||