10 Commits

Author SHA1 Message Date
Marcos Paulo 40dcc81ad2 chore: release 0.3.1 2026-10-17 20:01:04 -03:00
Marcos Paulo 5169fe6342 fix: resolve issues #1-#8 (side borders, align-self, min/max size, wavy/colored underline, cross-page layers, screenshot out path, feedback counts, nested component import) 2026-10-17 20:00:47 -03:00
Marcos Paulo fa0511bc94 chore: release 0.3.0 2026-10-17 18:45:33 -03:00
Marcos Paulo 3f37de39f8 feat: design system UI, grid/wrap layout, .fig import, frame diff, multi-target codegen, and AI design-to-code enforcer 2026-10-17 18:45:33 -03:00
Marcos Paulo edc0b818ff chore(deploy): roll out the library build
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 10:05:45 -03:00
Marcos Paulo ea6f1b3ac4 feat(relay): personal library mode, every project on every device
papercraft-relay --library lists every project in --data at /library and lets a
project be deleted (moved to .trash, kept on disk). A device that opens the site
joins the projects it lacks and shares its local ones, through the existing sync
loops. A deleted project answers 410 and lands in the other devices' trash; one the
server forgot (404 after it had synced) is kept locally and shared again, so losing
the server's disk never deletes anyone's work. Share controls are hidden in library
mode. Meant for a network-bounded, single-user deployment: there are no accounts.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 09:55:34 -03:00
Marcos Paulo 5f667a5ae9 feat(deploy): container image and K3s manifest for the web build
Dockerfile builds the wasm bundle and papercraft-relay, which serves the app, the
agent relay and shared projects (--data on a PV). craft-libs is a private git
dependency, so the build takes host-vendored crates as a build context.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 09:39:05 -03:00
Marcos Paulo 0b78bb1d85 fix(render): sample image textures with explicit gradients so the canvas shader validates on WebGPU
WebGPU rejects textureSample under non-uniform control flow (the branch on the
per-fragment kind), which left the canvas pipeline invalid and made the first
editor frame fail with Queue.Submit: Invalid CommandBuffer. Take dpdx/dpdy before
the branch and use textureSampleGrad; mip selection is unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 09:39:04 -03:00
Marcos Paulo 110c99d47f feat: shared projects synced through the relay, from web and native
The relay (--data <dir>) stores shared projects under /p/{token}; every shared
project polls it and reconciles files through crate::vfs. Home screen gets
Share, Join shared, copy link and stop sharing.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 08:42:52 -03:00
Marcos Paulo b43fa4fe43 fix(relay): accept public Host headers; the URL token is the credential
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 08:42:52 -03:00
46 changed files with 10368 additions and 382 deletions
+7
View File
@@ -0,0 +1,7 @@
target/
analysis/
.git/
.worktrees/
web/pkg/
dist/
packaging/
Generated
+220 -6
View File
@@ -224,6 +224,7 @@ dependencies = [
"axum-core",
"base64 0.22.1",
"bytes",
"form_urlencoded",
"futures-util",
"http",
"http-body",
@@ -237,6 +238,9 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"serde_core",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sha1",
"sync_wrapper",
"tokio",
@@ -555,6 +559,16 @@ dependencies = [
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
@@ -568,9 +582,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
dependencies = [
"bitflags 1.3.2",
"core-foundation",
"core-foundation 0.9.4",
"core-graphics-types",
"foreign-types",
"foreign-types 0.5.0",
"libc",
]
@@ -581,7 +595,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
dependencies = [
"bitflags 1.3.2",
"core-foundation",
"core-foundation 0.9.4",
"libc",
]
@@ -1058,6 +1072,15 @@ dependencies = [
"tinyvec",
]
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared 0.1.1",
]
[[package]]
name = "foreign-types"
version = "0.5.0"
@@ -1065,7 +1088,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [
"foreign-types-macros",
"foreign-types-shared",
"foreign-types-shared 0.3.1",
]
[[package]]
@@ -1079,6 +1102,12 @@ dependencies = [
"syn 3.0.7",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "foreign-types-shared"
version = "0.3.1"
@@ -1422,6 +1451,22 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.21"
@@ -2100,6 +2145,23 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "native-tls"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "ndk"
version = "0.9.0"
@@ -2568,6 +2630,49 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "openssl"
version = "0.10.81"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45"
dependencies = [
"bitflags 2.13.2",
"cfg-if",
"foreign-types 0.3.2",
"libc",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-sys"
version = "0.9.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -2604,7 +2709,7 @@ dependencies = [
[[package]]
name = "papercraft"
version = "0.2.0"
version = "0.3.1"
dependencies = [
"arboard",
"axum",
@@ -3091,15 +3196,19 @@ dependencies = [
"http-body",
"http-body-util",
"hyper",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"native-tls",
"percent-encoding",
"pin-project-lite",
"rustls-pki-types",
"serde",
"serde_json",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-util",
"tower",
"tower-http",
@@ -3260,12 +3369,27 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"zeroize",
]
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "same-file"
version = "1.0.6"
@@ -3275,6 +3399,15 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "schemars"
version = "1.2.2"
@@ -3326,6 +3459,29 @@ dependencies = [
"tiny-skia 0.11.4",
]
[[package]]
name = "security-framework"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.13.2",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "self_cell"
version = "1.3.0"
@@ -3392,6 +3548,17 @@ dependencies = [
"zmij",
]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]]
name = "serde_spanned"
version = "1.1.2"
@@ -3401,6 +3568,18 @@ dependencies = [
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "sha1"
version = "0.10.7"
@@ -3673,6 +3852,19 @@ dependencies = [
"syn 3.0.7",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"once_cell",
"rustix 1.1.5",
"windows-sys 0.52.0",
]
[[package]]
name = "termcolor"
version = "1.4.1"
@@ -3830,6 +4022,16 @@ dependencies = [
"syn 3.0.7",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-stream"
version = "0.1.19"
@@ -4143,6 +4345,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "vello_common"
version = "0.1.0"
@@ -4938,7 +5146,7 @@ dependencies = [
"calloop 0.13.0",
"cfg_aliases",
"concurrent-queue",
"core-foundation",
"core-foundation 0.9.4",
"core-graphics",
"cursor-icon",
"dpi",
@@ -5139,6 +5347,12 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
[[package]]
name = "zerotrie"
version = "0.2.5"
+5 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "papercraft"
version = "0.2.0"
version = "0.3.1"
edition = "2024"
rust-version = "1.95"
description = "A native infinite-canvas app for planning, working and sketching"
@@ -88,8 +88,10 @@ winit = "0.30"
arboard = { version = "3.6.1", features = ["image-data"] }
rmcp = { version = "3.5.1", default-features = false, features = ["server", "base64", "transport-streamable-http-server", "transport-io", "client", "transport-streamable-http-client-reqwest"] }
tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "macros", "time", "io-std", "fs"] }
axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "ws"] }
axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "ws", "json", "query"] }
tokio-util = "0.7"
# Shared-project sync; the system TLS stack so https servers work without bundling one.
reqwest = { version = "0.13", default-features = false, features = ["native-tls"] }
# Browser build (`src/web.rs`): canvas + WebGPU/WebGL surface, DOM events instead of winit.
[target.'cfg(target_arch = "wasm32")'.dependencies]
@@ -99,4 +101,4 @@ wasm-bindgen-futures = "0.4"
js-sys = "0.3"
console_error_panic_hook = "0.1"
console_log = "1"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "KeyboardEvent", "PointerEvent", "WheelEvent", "ClipboardEvent", "DataTransfer", "MediaQueryList", "Location", "Performance", "Navigator", "Clipboard", "CssStyleDeclaration", "console", "WebSocket", "MessageEvent", "CloseEvent", "ErrorEvent", "Storage", "Crypto", "DragEvent", "FileList", "File", "Blob"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "KeyboardEvent", "PointerEvent", "WheelEvent", "ClipboardEvent", "DataTransfer", "MediaQueryList", "Location", "Performance", "Navigator", "Clipboard", "CssStyleDeclaration", "console", "WebSocket", "MessageEvent", "CloseEvent", "ErrorEvent", "Storage", "Crypto", "DragEvent", "FileList", "File", "Blob", "Request", "RequestInit", "Response"] }
+14 -14
View File
@@ -13,31 +13,31 @@ Agents can already build a design system through the tools; people cannot yet ma
| # | 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. |
| 1 | Inspector UI for variables | **done** | Design tab lists/renames/adds/deletes variables & modes; Inspector binds/unlinks solid fills to variables. |
| 2 | Inspector UI for components | **done** | Design tab searches components, inserts instances, swaps/resets/detaches instances; Inspector header swaps/resets instances. |
| 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. |
| 4 | Lint auto-fix | **done** | `lint_design {fix: true}` and UI `Fix` / `Fix all`: snaps spacing to 4px grid, snaps type scale, enforces 44px targets, links matching colors to variables. |
| 5 | Lint in the UI | **done** | Live Lint section in Design tab; click issue to select & zoom, or click `Fix` / `Fix all`. |
| 6 | Variable modes | **done** | Mode selector + create/delete modes in Design tab and via `set_variables {mode}` / `set_variable_mode`. |
| 7 | Number variables (spacing, radius, font size) | partial | Non-color tokens (`--spacing-md`) editable in Design tab and substituted in CSS; typed per-field bindings todo. |
| 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. |
| 11 | Swap instance, detach by tool | **done** | `swap_instance` and `detach_instance` MCP tools + UI controls in Design tab and Inspector. |
| 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. |
| 13 | Export design tokens | **done** | `get_variables` with `format: "css"`, `"json"` (W3C DTCG + modes), or `"tailwind"` (`@theme`), plus Copy CSS / Copy JSON in Design tab. |
| 14 | Apply a design system to existing work | partial | `lint_design {fix: true}` links matching literal colors to variables and snaps spacing/type scale. |
## 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`. |
| 15 | MCP and CLI tool surface | **done** | 43 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). |
| 18 | Code export from frames | **done** | `get_jsx` supports `format: "inline"`, `"tailwind"` (Tailwind utility classes), and `"html"` (HTML + CSS rules with `:root` variables). |
| 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. |
| 20 | Screenshot diffing | **done** | `diff_frames` tool (`src/mcp/diff.rs`) for structural/property frame diffing + `diff_rgba` pixel diffing with highlight PNG. |
| 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. |
@@ -48,7 +48,7 @@ Agents can already build a design system through the tools; people cannot yet ma
| 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. |
| 26 | Margin and grid layout | partial | Wrapped-line cross alignment, per-line fill/align, and `layout_grid` track layout (`src/layout.rs`) implemented; CSS margin still ignored. |
| 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. |
@@ -60,7 +60,7 @@ Agents can already build a design system through the tools; people cannot yet ma
| # | 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. |
| 33 | Figma import | **done** | `.fig` binary (Kiwi wire decoder + zip container) and Figma REST API JSON import in `src/io/fig.rs`. |
| 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. |
+4 -1
View File
@@ -17,7 +17,10 @@ fn main() {
.set("ProductName", "PaperCraft")
.set("FileDescription", "PaperCraft infinite canvas")
.set("CompanyName", "PaperCraft Authors")
.set("LegalCopyright", "Copyright (c) the PaperCraft authors. MIT OR Apache-2.0.")
.set(
"LegalCopyright",
"Copyright (c) the PaperCraft authors. MIT OR Apache-2.0.",
)
.set("OriginalFilename", "papercraft.exe")
.set("InternalName", "papercraft");
if let Err(e) = res.compile() {
+32
View File
@@ -0,0 +1,32 @@
# Browser build + relay in one image: `papercraft-relay --static` serves web/ and the agent relay.
# craft-libs is a private Gitea dependency, so vendor on the host (authenticated) and pass it in:
# cargo vendor --locked /path/vendor/crates >/dev/null
# docker build -f deploy/Dockerfile --build-context vendor=/path/vendor -t localhost:32000/papercraft-web:<tag> .
FROM rust:slim AS build
# Must match the wasm-bindgen crate in Cargo.lock.
ARG WASM_BINDGEN=0.2.129
RUN rustup target add wasm32-unknown-unknown \
&& apt-get update && apt-get install -y --no-install-recommends binaryen pkg-config libssl-dev \
&& rm -rf /var/lib/apt/lists/* \
&& cargo install wasm-bindgen-cli --version ${WASM_BINDGEN} --locked
COPY --from=vendor crates /vendor
RUN printf '%s\n' \
'[source.crates-io]' 'replace-with = "vendored-sources"' \
'[source."git+https://git.marcospaulo.dev.br/masi/craft-libs?rev=22544b6d619d4f16779b8bdfa3f0b642a86fc065"]' \
'git = "https://git.marcospaulo.dev.br/masi/craft-libs"' \
'rev = "22544b6d619d4f16779b8bdfa3f0b642a86fc065"' 'replace-with = "vendored-sources"' \
'[source.vendored-sources]' 'directory = "/vendor"' > "$CARGO_HOME/config.toml"
WORKDIR /src
COPY . .
RUN sh web/build.sh && cargo build --release --locked --bin papercraft-relay
FROM debian:stable-slim
RUN apt-get update && apt-get install -y --no-install-recommends libssl3 ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --system --uid 10001 papercraft
COPY --from=build /src/target/release/papercraft-relay /usr/local/bin/papercraft-relay
COPY --from=build /src/web/index.html /srv/web/index.html
COPY --from=build /src/web/pkg /srv/web/pkg
USER 10001
EXPOSE 8787
ENTRYPOINT ["papercraft-relay", "--static", "/srv/web", "--data", "/data"]
+86
View File
@@ -0,0 +1,86 @@
# Shared projects live in /data (`--data`) on the HDD: PV /srv/hdd/papercraft, class hdd-storage, Retain (same pattern as actual, kavita, ...).
# --library: every project in /data is listed at /library and synced to every device that opens the site, with no link
# needed. There are no accounts, so the Tailnet-only ingress is the whole access boundary; never expose this publicly.
# Tailnet access (DNS, TLS, ingress) is added with `expose papercraft/papercraft papercraft`; see deploy/README.md.
apiVersion: v1
kind: Namespace
metadata:
name: papercraft
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: pv-hdd-papercraft
spec:
capacity: {storage: 5Gi}
accessModes: [ReadWriteOnce]
persistentVolumeReclaimPolicy: Retain
storageClassName: hdd-storage
volumeMode: Filesystem
local: {path: /srv/hdd/papercraft}
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- {key: kubernetes.io/hostname, operator: In, values: [kubernets]}
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: papercraft-data
namespace: papercraft
spec:
accessModes: [ReadWriteOnce]
storageClassName: hdd-storage
volumeName: pv-hdd-papercraft
resources:
requests: {storage: 5Gi}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: papercraft
namespace: papercraft
spec:
replicas: 1
selector:
matchLabels: {app: papercraft}
template:
metadata:
labels: {app: papercraft}
spec:
securityContext: {fsGroup: 10001}
volumes:
- name: data
persistentVolumeClaim: {claimName: papercraft-data}
containers:
- name: web
image: localhost:32000/papercraft-web:0.2.0-ea6f1b3
args: ["--library", "--allow-origin", "https://papercraft.marcospaulo.dev.br"]
volumeMounts:
- {name: data, mountPath: /data}
ports:
- containerPort: 8787
readinessProbe:
httpGet: {path: /index.html, port: 8787}
livenessProbe:
httpGet: {path: /index.html, port: 8787}
initialDelaySeconds: 10
resources:
requests: {cpu: 20m, memory: 32Mi}
limits: {cpu: 500m, memory: 256Mi}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: {drop: [ALL]}
---
apiVersion: v1
kind: Service
metadata:
name: papercraft
namespace: papercraft
spec:
selector: {app: papercraft}
ports:
- port: 80
targetPort: 8787
+27 -16
View File
@@ -23,9 +23,9 @@ papercraft-cli call get_basic_info # fails with exit 1 if the app is not ru
## 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`.
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` or `<x-paper-instance component="Name" component-id="ID"/>`.
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.
4. **Look.** `get_screenshot` of the artboard after each group or two (pass `out` or `-o`/`--out FILE` in `papercraft-cli` to choose the output path). 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.
@@ -56,7 +56,7 @@ Lint passes are necessary, not sufficient: contrast and grids say nothing about
<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`.
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`. `min-width`, `max-width`, `min-height`, `max-height`, `align-self` (`start`, `center`, `end`, `stretch`), per-side borders (`border-top`, `border-right`, `border-bottom`, `border-left`), and `text-decoration-color` / `wavy` (`text-decoration: underline wavy var(--warning)`) are also supported.
Full flows: [recipes.md](references/recipes.md).
@@ -66,33 +66,44 @@ Full flows: [recipes.md](references/recipes.md).
|---|---|
| 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` |
| Inspect one node or export code | `get_node_info`, `get_computed_styles`, `get_jsx` (`inline` \| `tailwind` \| `react` \| `angular` \| `html` \| `egui`) |
| Extract AI design-to-code spec | `get_design_spec` (`nodeId`, `target`: `react` \| `tailwind` \| `angular` \| `html` \| `egui`) |
| Verify generated code against design | `verify_code` (`nodeId`, `target`, `code`) |
| Compare two frames or subtrees | `diff_frames` (`beforeId`, `afterId`) |
| Find nodes by text or style | `find_nodes` |
| See it | `get_screenshot` (`nodeId`, `scale` 1 or 2) |
| See it | `get_screenshot` (`nodeId`, `scale` 1 or 2, `out` / `-o\|--out FILE`) |
| 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>` |
| Copy, move, delete (including across pages) | `duplicate_nodes` (`toPageId`), `move_nodes` (`toPageId`), `<x-paper-clone node-id="<page>:<id>"/>`, `delete_nodes` |
| Tokens | `get_variables` (`format`: `css` \| `json` \| `tailwind`), `set_variables` (`mode`), `set_tokens`, `set_variable_mode` |
| Reuse & components | `create_component`, `create_instance`, `swap_instance`, `detach_instance`, `import_components`, `get_components`, `<x-paper-instance component="Name" component-id="ID"/>` |
| Icons | `search_icons`, `<x-paper-icon name="set:name">`; CLI: `papercraft-cli icons add\|list\|search` |
| Check quality | `lint_design` |
| Check & auto-fix quality | `lint_design` (`fix: true` to auto-fix grid/scale/variables/targets) |
| 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` |
## Design-to-Code Handoff & Enforcement (React, Angular, HTML/CSS, Rust egui)
When implementing a PaperCraft design in production code (React/TSX, Tailwind JSX, Angular standalone components, HTML + CSS, or Rust `egui`), follow this mandatory three-step protocol:
1. **Never implement from a screenshot alone.** Always call `get_design_spec` (`nodeId`, `target: "react" | "tailwind" | "angular" | "html" | "egui"`) to extract the structured layout tree, exact auto-layout flow/sizing (`fixed`, `hug`, `fill`), design tokens (`--var`), component instances, and reference code.
2. **Write the target code** following the spec's layout flow (Flexbox/Grid for web; `egui::Frame` + `ui.horizontal`/`ui.vertical` + `tokens` module for Rust `egui`), binding all color variables instead of hardcoding hex values, and never using `position: absolute` on flow children.
3. **Enforce with `verify_code`**: Pass the written code to `verify_code({nodeId, target, code})`. Fix any reported `missing-text`, `hardcoded-color`, `forbidden-absolute`, `missing-layout`, `missing-component`, or `spacing-drift` violations until `passed: true` (`score: 100`).
## 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.
* **Color variables (`set_variables`) apply to fills and text color**; strokes and gradients keep fixed colors. Non-color tokens (`set_tokens`) provide shared spacing, radius, and shadow values expanded on write; `lint_design` (`fix: true`) enforces scale and 4px grid discipline.
* **No CSS margin, grid or tables in `write_html`; no CSS filters or transforms** (reported under `ignoredStyles`). Replace margin with `gap` and `padding`.
* **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.
* **Components and their instances live on one page** (use `import_components` to copy main components from anywhere on another page, including inside artboards, then instantiate with `<x-paper-instance component="Name"/>` or `component-id="ID"`). Variants are modeled as separate components (`Button / Primary`, `Button / Secondary`) and can be swapped in place with `swap_instance` (preserving overrides) or unlinked with `detach_instance`.
* **Supported imports, diffs, and code handoff**: `.fig` file import is supported, `diff_frames` compares two frames/subtrees at the property level, `get_jsx` exports `inline`, `tailwind`, `react`, `angular`, `html`, or `egui`, and `get_design_spec` + `verify_code` provide full AI design-to-code handoff and enforcement.
* **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.
* **One project open at a time**; copying and moving layers between pages is supported via `duplicate_nodes` / `move_nodes` (`toPageId`) and cross-page `<x-paper-clone node-id="<page>:<id>"/>`.
* **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.
@@ -102,10 +113,10 @@ Full flows: [recipes.md](references/recipes.md).
|---|---|
| `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). |
| `write_html` reports `ignoredStyles` | You used a CSS property that has no equivalent (`margin`, `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. |
| `create_instance` fails | The component is on another page. Instances must be on the component's page (or run `import_components` first). |
| 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`. |
+6 -6
View File
@@ -20,30 +20,30 @@
| `<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 |
| `<x-paper-instance component="Name" component-id="ID" overrides='{"Label":"Save"}'/>` | an instance of a component (lookup by `component="Name"` or `component-id="ID"`) |
| `<x-paper-clone node-id="ID" style="..."/>` | a deep copy of an existing node (supports cross-page `<page>:<id>`) |
| `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`.
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` (`start`, `center`, `end`, `stretch`), `flex-grow` / `flex: 1`, `width`, `height` (px, %, `fit-content`), `min-width`, `max-width`, `min-height`, `max-height`, `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.
* Artboards are `display:flex; flex-direction:column`. Children of a column stretch to the full width unless they have a width or `align-items` / `align-self` 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.
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` (including `text-decoration-color` and `text-decoration-style: wavy`, e.g. `text-decoration: underline wavy var(--warning)`). 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.
Supported: `background` and `background-color` (hex, `rgb()`, `hsl()`, `var(--name)`), gradients (`linear-gradient`, `radial-gradient`), `background-image: url()`, `background-size` (`contain`, `100% 100%`), `border` and per-side `border-top`, `border-right`, `border-bottom`, `border-left` (width, style, color), `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.
+121 -16
View File
@@ -99,15 +99,49 @@ Compact indented text tree of a subtree: type, name, id and size per node. Much
### `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.
Export a node and its descendants to code: `"inline"` (default, React JSX with inline styles), `"tailwind"` (React JSX with Tailwind CSS utility classes), `"react"` (typed React TSX component with Tailwind classes), `"angular"` (Angular standalone `@Component` with template and CSS), `"html"` (self-contained `<style>` + HTML markup), or `"egui"` (Rust `egui` layout code with a `tokens` module).
| Argument | Type | Required | Notes |
|---|---|---|---|
| `format` | inline-styles/tailwind | | Only inline styles are produced; "tailwind" is accepted for compatibility. |
| `format` | `inline` \| `inline-styles` \| `tailwind` \| `react` \| `angular` \| `html` \| `egui` | | Output format: `"inline"` (default), `"tailwind"`, `"react"`, `"angular"`, `"html"`, or `"egui"`. |
| `nodeId` | string | yes | The node to convert. |
### `get_design_spec` (read-only)
Extract a comprehensive AI design-to-code specification (`ai_spec_markdown` and structured JSON `spec`) for a node and its descendants. Includes the hierarchy, exact auto-layout flow/sizing (`fixed`, `hug`, `fill`), bound design tokens (`--var`), component instances, text content, implementation rules, and reference code for the chosen `target`. Always call this before implementing a design in code.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `nodeId` | string | yes | The root frame or node to extract the spec for. |
| `target` | `react` \| `tailwind` \| `angular` \| `html` \| `egui` | | Target framework/format (defaults to `"react"`). |
### `verify_code` (read-only)
Enforce design-to-code fidelity by checking written `code` against the source PaperCraft node (`nodeId`) for the given `target`. Reports a `score` (0–100), `passed` boolean, and structured violations (`missing-text`, `hardcoded-color`, `forbidden-absolute`, `missing-layout`, `missing-component`, `spacing-drift`) with actionable fixes.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `code` | string | yes | The generated or hand-written implementation code to verify. |
| `nodeId` | string | yes | The PaperCraft node id the code implements. |
| `target` | `react` \| `tailwind` \| `angular` \| `html` \| `egui` | yes | Target language/framework of `code`. |
### `diff_frames` (read-only)
Compare two frames or subtrees (`beforeId` and `afterId`) and report property-level differences (size, position, fills, stroke, corner radius, opacity, text, layout, added/removed children).
| Argument | Type | Required | Notes |
|---|---|---|---|
| `beforeId` | string | yes | First frame or node id (baseline). |
| `afterId` | string | yes | Second frame or node id (comparison). |
### `get_computed_styles` (read-only)
CSS styles (camelCase) of one or more nodes, as {nodeId: styles}.
@@ -165,12 +199,13 @@ Read a guide. Topics: "papercraft-mcp-instructions" (read first), "html" (what w
### `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.
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. In `papercraft-cli`, pass `-o`/`--out FILE` or `{"out": "FILE"}` to choose the output file path.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `nodeId` | string | | The node to capture; omit for the whole page. |
| `out` | string | | Optional file path to save the PNG to (also used by `papercraft-cli`). |
| `scale` | number | | Render scale: 1 (default) or 2. |
@@ -214,12 +249,12 @@ Creates an artboard (top-level frame) and returns its id; then add content with
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).
- Inline styles only. Flexbox (display:flex, flex-direction, gap, padding, align-items, justify-content, align-self, 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 (including `wavy` and `text-decoration-color`); <b>/<i>/<u>/<s>/<span style> make rich text. Font family is always Inter.
- Boxes: background(-color/-image: colors, linear/radial gradients, url()), border (and border-top/right/bottom/left), border-radius, opacity, overflow:hidden, mix-blend-mode, width/height (px, %, fit-content), min-width/max-width/min-height/max-height.
- <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.
- <x-paper-icon name="set:name" size="20" color="var(--accent)"/> places an installed icon (search_icons); <x-paper-instance component="Name" 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 (supports cross-page `<page>:<id>`).
Returns the ids of the created top-level layers and the styles that were ignored.
@@ -272,22 +307,22 @@ Delete nodes and their descendants. Verify parents with get_node_info first when
### `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.
Deep-clone nodes, including across pages (via `toPageId` or a cross-page `parentId`; `<x-paper-clone node-id="<page>:<id>"/>` also works across pages). 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 | |
| `nodes` | array of object | yes | Items accept `{id, parentId?, toPageId?}`. |
### `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.
Move nodes keeping their ids (or onto another page via `toPageId` or a cross-page `parentId` / `before` / `after`). Each move is {nodeId, before: siblingId}, {nodeId, after: siblingId} or {nodeId, parentId, toPageId?, 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 | |
| `moves` | array of object | yes | Items accept `{nodeId, before?, after?, parentId?, toPageId?, index?}`. |
### `create_shape`
@@ -320,12 +355,12 @@ Create a whiteboard element: rectangle, ellipse, diamond, triangle, polygon, sta
### `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.
Color variables and design tokens of the page's project (name -> hex/value). 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 (plus `[data-theme]` rules for modes), `json` returns a W3C DTCG design-tokens object, and `tailwind` returns a Tailwind v4 `@theme` block.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `format` | css/json | | |
| `format` | `css` \| `json` \| `tailwind` | | Output format: `css`, `json` (W3C DTCG), or `tailwind` (`@theme`). |
### `set_variables`
@@ -354,9 +389,20 @@ Switch the active variable mode (defined with set_variables {mode}): every varia
Main components on a page (id, name, size) with how many instances each has.
### `import_components`
Copy the main components anywhere on `fromPageId` (including inside artboards, typically on a `Design System` page) onto this one, so you can place instances here with `create_instance` or `<x-paper-instance component="Name" component-id="ID"/>`. Components with the same name already here are skipped. Variables and tokens are already shared by every page; components are copies, not live links.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `fromPageId` | string | yes | Source page id. |
| `names` | array of string | | Only these components (default all). |
### `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.
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 component="Name" component-id="ID"/>`. Components and instances live on one page.
| Argument | Type | Required | Notes |
@@ -380,6 +426,27 @@ Place an instance of a component. Edit text and styles on the instance's descend
| `y` | number | | |
### `swap_instance`
Swap an instance root to point at a different main component on the same page, preserving overrides on descendants matched by name and layer type.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `componentId` | string | yes | The new main component id. |
| `nodeId` | string | yes | The instance root node to swap. |
### `detach_instance`
Detach one or more component instances into plain frames, breaking their link to the main component while keeping their current appearance and children.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `nodeIds` | array of string | yes | Instance root nodes to detach. |
### `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`.
@@ -394,11 +461,12 @@ Search installed open icon sets (Lucide, Tabler, Phosphor, Heroicons, Material).
### `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.
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. Pass `fix: true` to automatically snap spacing/padding to 4px, relink matching color variables, snap text sizes, and enlarge small touch targets.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `fix` | boolean | | Apply safe automatic fixes before reporting remaining issues. |
| `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. |
@@ -406,6 +474,43 @@ Check a page or subtree for common design mistakes and get a fix for each: text
## Session
### `get_feedback` (read-only)
Notes the user left on layers (the Feedback box in the inspector, shown as amber dots on the canvas): which layer, where it sits, what to change. Omitting `pageId` queries all pages across the project; `openCount` and `resolvedCount` are returned alongside per-page counts in `get_basic_info`. Call it at the start of a session and after each round of edits; fix every open note, then `resolve_feedback`. `get_selection` also includes the selected layers' notes.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `includeResolved` | boolean | | Include resolved notes. |
| `nodeId` | string | | Only this layer. |
| `pageId` | string | | Omit to query all pages across the project. |
### `add_feedback`
Leave a note on a layer (author `agent` by default), e.g. a question or a problem you could not fix. The user sees it in the inspector and as a dot on the canvas.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `author` | string | | Defaults to `agent`. |
| `nodeId` | string | yes | |
| `text` | string | yes | |
### `resolve_feedback`
Mark notes on a layer resolved, with a short reply saying what you changed. Without `commentId` every open note on the layer is resolved. `delete` removes instead.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `commentId` | number | | |
| `delete` | boolean | | |
| `nodeId` | string | yes | |
| `reply` | string | | |
### `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.
+71
View File
@@ -0,0 +1,71 @@
# Shared projects on the relay
## Why
On the web build a project lives in one browser's IndexedDB: it cannot be opened from another
device, survives only while the browser keeps its storage, and cannot be edited by two people.
The relay already hosts the app, so it can also host the project files.
## Scope (v1)
1. **Server store.** `papercraft-relay --data <dir>` keeps shared projects on disk. Without the
flag the feature is off and every `/p/` route answers 404.
2. **Share a project.** On the home screen a project card's menu has "Share…";
the app uploads the project's files and gets a **share link** `https://host/?share=<token>`.
3. **Open a shared project.** Opening a share link downloads the project into the tab's vfs and
remembers the token, so the project appears on the home screen like any other.
4. **Sync, file granularity.** Each shared project polls the relay every 2 s, from the browser
and from the native app alike (one engine, `src/sync.rs`, over `crate::vfs`). It compares the
server's file list, the folder, and the last synced state (kept in the project's `.share`):
folder changes go up, server changes come down and the open project reloads unless it has
unsaved edits. Conflicts: whole file, the folder wins a simultaneous change (last write wins).
An offline client catches up on its next poll.
5. **Native too.** The desktop app shares a project to any relay (`Share…` asks for the server)
and joins by link, exactly like the browser; both can edit the same project.
Out of scope: per-object merging (CRDT), cursors/presence, accounts, per-user permissions,
history. The share token is the credential: anyone with the link can read and edit.
## Protocol
Token: 32–64 lowercase hex characters, generated by the tab (same rules as the agent token).
A different token from the agent link, so sharing a project never exposes the MCP endpoint.
| Route | Meaning |
|-------|---------|
| `GET /p/{token}/files` | JSON `[{"path": "project.json", "size": 425, "mtime": <ns>}]` (all files, relative paths) |
| `GET /p/{token}/f/{path}` | file bytes, 404 if absent |
| `PUT /p/{token}/f/{path}` | write bytes; creates the project on first write; answers the file's `mtime`; optional header `x-client: <id>` |
| `DELETE /p/{token}/f/{path}` | remove a file |
| `GET /p/{token}/ws` | WebSocket; server pushes `{"op":"put"\|"del","path":"…","rev":N,"client":"<id>"}` |
The websocket is served but the clients poll `/files` (simpler, same code on both targets);
it is there for a later push-based client.
Rules: paths are relative, no `..`, no absolute, no empty segment; a bad path or token is a plain
404 (same text as unknown links). `rev` is a per-project counter the relay bumps on every
change. Body cap 64 MiB. Origin is not checked (the web app is served from the relay or another
origin). The writer's `client` id is echoed so a tab ignores its own events.
Storage layout: `<data>/<token>/<path>`; `rev` is kept in memory and restarts at the file count
after a relay restart (tabs resync from `/files` on reconnect, so a lower rev is harmless).
## Acceptance
- Relay tests: put/get/list/delete round trip; path traversal refused; unknown token and bad
token are 404; a second websocket sees the first client's put and delete, the writer does not
see its own echo; routes 404 when `--data` is absent.
- Web: sharing uploads every file under the project dir; opening `?share=` recreates the project
locally; editing a page in tab A shows up in tab B without a manual reload. Wasm check builds
clean and native tests stay green.
- Local (non-shared) projects behave exactly as before.
## Where it lives
| Concern | Code |
|---------|------|
| Server store, `/p/` routes, path and token checks | `src/mcp/store.rs`, merged into the relay router in `src/mcp/relay.rs` (`spawn_with_data`); `--data` flag in `src/bin/papercraft-relay.rs` |
| Sync engine (both targets), `.share` file, link parsing, server-path sanitising | `src/sync.rs` (HTTP: reqwest native, `fetch` wasm) |
| Reload of the open project, status, join/share entry points | `src/session.rs` (`sync_poll`, `share_project`, `join_shared`) |
| Home screen: Share…, Join shared…, status badge, copy link | `src/ui/home.rs`; actions handled in `src/app.rs` and `src/web.rs`; `?share=` auto-join in `src/web.rs` |
| Tests | `tests/relay.rs` (store routes), `tests/sync.rs` (two folders through a real relay), unit tests in `src/sync.rs` and `src/mcp/store.rs` |
+40 -5
View File
@@ -219,9 +219,28 @@ impl App {
}
}
fn default_server(&self) -> String {
self.session.server_hint()
}
/// Look at the shared projects' sync engines (about once a second).
fn tick_sync(&mut self) {
if self.session.sync_poll(&mut self.ed, Instant::now()) {
if self.st.home.is_some() {
self.show_home();
}
if let Some(g) = &self.gfx {
g.window.request_redraw();
}
}
}
fn show_home(&mut self) {
let mut hv = self.st.home.take().unwrap_or_default();
hv.refresh(&self.session.store, crate::project::now_ms());
hv.shared = self.session.shared_info();
hv.default_server = self.default_server();
hv.library = self.session.sync.library_active();
self.st.home = Some(hv);
self.st.project_name.clear();
self.st.save_state.clear();
@@ -330,7 +349,18 @@ impl App {
HomeAction::Duplicate(dir) => {
store.duplicate(&dir).map(|_| ()).map_err(|e| e.to_string())
}
HomeAction::Trash(dir) => store.trash(&dir).map(|_| ()).map_err(|e| e.to_string()),
HomeAction::Trash(dir) => {
// A trashed project leaves the share (and a library); restoring it brings
// back a local copy.
self.session.sync.drop_project(&dir);
store.trash(&dir).map(|_| ()).map_err(|e| e.to_string())
}
HomeAction::Share(dir, server) => self.session.share_project(&dir, &server),
HomeAction::Join(link) => self.session.join_shared(&link),
HomeAction::Unshare(dir) => {
self.session.sync.unshare(&dir);
Ok(())
}
HomeAction::Restore(dir) => {
store.restore(&dir).map(|_| ()).map_err(|e| e.to_string())
}
@@ -1277,10 +1307,15 @@ impl ApplicationHandler<UserEvent> for App {
g.window.request_redraw();
}
}
let wake = match (self.next_repaint, self.session.next_deadline()) {
(Some(a), Some(b)) => Some(a.min(b)),
(a, b) => a.or(b),
};
self.tick_sync();
let wake = [
self.next_repaint,
self.session.next_deadline(),
self.session.sync_deadline(),
]
.into_iter()
.flatten()
.min();
match wake {
Some(t) if Instant::now() >= t => {
if self.next_repaint.is_some_and(|r| r <= t) {
+27 -6
View File
@@ -19,7 +19,7 @@ USAGE:
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)
[-o|--out FILE] where to save an image result (default: <tool>.png, or {\"out\": \"FILE\"})
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)
@@ -197,6 +197,13 @@ fn schema(name: &str) -> ExitCode {
}
async fn call(name: String, args: Value, out: Option<String>) -> Result<bool, String> {
let out = out.or_else(|| {
args.get("out")
.or_else(|| args.get("path"))
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(String::from)
});
let url = mcp::url(mcp::port());
let client = ()
.serve(StreamableHttpClientTransport::from_uri(url.clone()))
@@ -219,6 +226,12 @@ async fn call(name: String, args: Value, out: Option<String>) -> Result<bool, St
.decode(v["data"].as_str().unwrap_or(""))
.map_err(|e| format!("bad image data: {e}"))?;
let path = out.clone().unwrap_or_else(|| format!("{name}.png"));
if let Some(p) = std::path::Path::new(&path)
.parent()
.filter(|p| !p.as_os_str().is_empty())
{
let _ = std::fs::create_dir_all(p);
}
std::fs::write(&path, data).map_err(|e| format!("{path}: {e}"))?;
println!("{path}");
}
@@ -230,11 +243,19 @@ async fn call(name: String, args: Value, out: Option<String>) -> Result<bool, St
fn main() -> ExitCode {
let mut args: Vec<String> = std::env::args().skip(1).collect();
let out = args
.iter()
.position(|a| a == "--out")
.and_then(|i| (i + 1 < args.len()).then(|| args.drain(i..=i + 1).nth(1)))
.flatten();
let mut out: Option<String> = None;
let mut i = 0;
while i < args.len() {
if (args[i] == "--out" || args[i] == "-o") && i + 1 < args.len() {
out = Some(args.drain(i..=i + 1).nth(1).unwrap());
} else if let Some(v) = args[i].strip_prefix("--out=") {
let v = v.to_string();
args.remove(i);
out = Some(v);
} else {
i += 1;
}
}
match args.first().map(String::as_str) {
Some("list") => list(),
Some("schema") => match args.get(1) {
+27 -2
View File
@@ -5,6 +5,7 @@
//! built web app (`web/index.html` + `web/pkg`) from the same origin, so one process hosts it.
use papercraft::mcp::relay;
use papercraft::mcp::store::Data;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::process::ExitCode;
@@ -13,12 +14,16 @@ const USAGE: &str = "\
papercraft-relay — MCP relay for a PaperCraft tab in a browser
USAGE:
papercraft-relay [--listen 0.0.0.0:8787] [--static <dir>] [--allow-origin <origin>]
papercraft-relay [--listen 0.0.0.0:8787] [--static <dir>] [--allow-origin <origin>] [--data <dir>] [--library]
OPTIONS:
--listen <addr> address to serve on (default: $PAPERCRAFT_RELAY_LISTEN or 0.0.0.0:8787)
--static <dir> serve the built web app from <dir> (e.g. the repo's web/ directory)
--allow-origin <o> only let this browser origin connect a tab (default: any)
--data <dir> keep shared projects in <dir> (off without it): /p/{token}/...
--library with --data: a personal library. Every project in <dir> is listed at
/library and synced to every device that opens this server, with no
link needed, so only run it where the network is the boundary
--help print this text
The tab picks a 32..64 hex character token and shows the agent URL
@@ -33,12 +38,16 @@ struct Args {
listen: String,
static_dir: Option<PathBuf>,
allow_origin: Option<String>,
data_dir: Option<PathBuf>,
library: bool,
}
fn parse_args() -> Result<Args, String> {
let mut listen = std::env::var(ENV_LISTEN).unwrap_or_else(|_| DEFAULT_LISTEN.to_string());
let mut static_dir = None;
let mut allow_origin = None;
let mut data_dir = None;
let mut library = false;
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
let mut value = || args.next().ok_or_else(|| format!("{arg} needs a value"));
@@ -46,6 +55,8 @@ fn parse_args() -> Result<Args, String> {
"--listen" => listen = value()?,
"--static" => static_dir = Some(PathBuf::from(value()?)),
"--allow-origin" => allow_origin = Some(value()?),
"--data" => data_dir = Some(PathBuf::from(value()?)),
"--library" => library = true,
other => return Err(format!("unknown argument {other}\n\n{USAGE}")),
}
}
@@ -53,6 +64,8 @@ fn parse_args() -> Result<Args, String> {
listen,
static_dir,
allow_origin,
data_dir,
library,
})
}
@@ -69,6 +82,10 @@ fn main() -> ExitCode {
return ExitCode::FAILURE;
}
};
if args.library && args.data_dir.is_none() {
eprintln!("papercraft-relay: --library needs --data <dir>");
return ExitCode::FAILURE;
}
let listen: SocketAddr = match args.listen.parse() {
Ok(a) => a,
Err(_) => {
@@ -79,7 +96,15 @@ fn main() -> ExitCode {
return ExitCode::FAILURE;
}
};
let running = match relay::spawn(listen, args.static_dir.clone(), args.allow_origin.clone()) {
let running = match relay::spawn_with_data(
listen,
args.static_dir.clone(),
args.allow_origin.clone(),
args.data_dir.clone().map(|dir| Data {
dir,
library: args.library,
}),
) {
Ok(r) => r,
Err(e) => {
eprintln!("papercraft-relay: {e}");
+63
View File
@@ -548,6 +548,20 @@ impl Editor {
}
}
/// Swap an instance root to point at `new_main` component, preserving overrides on children
/// matched by name and kind.
pub fn swap_instance(&mut self, inst: NodeId, new_main: NodeId) -> bool {
if !self.is_instance_root(inst)
|| !self.doc.get(new_main).is_some_and(|m| m.component)
|| inst == new_main
|| self.doc.is_ancestor(inst, new_main)
{
return false;
}
self.edit("Swap instance", |tx| swap_instance_tx(tx, inst, new_main));
true
}
// ---------------------------------------------------------------------- auto layout
/// Add auto layout (Shift+A): converts a selected frame, or wraps the selection in a new
@@ -767,3 +781,52 @@ pub fn infer_layout(rects: &[Rect], fw: f64, fh: f64) -> AutoLayout {
];
l
}
/// Swap `inst` to `new_main` inside an open transaction, matching child overrides by name/kind.
pub fn swap_instance_tx(tx: &mut Tx, inst: NodeId, new_main: NodeId) -> bool {
let Some(new_comp) = tx.doc().get(new_main).cloned() else {
return false;
};
if !new_comp.component {
return false;
}
let mut target_by_name: Vec<(String, &'static str, NodeId)> = Vec::new();
for cid in tx.doc().subtree(new_main).into_iter().skip(1) {
if let Some(cn) = tx.doc().get(cid) {
target_by_name.push((cn.name.clone(), cn.kind.type_name(), cid));
}
}
let mut used: HashSet<NodeId> = HashSet::new();
let inst_children: Vec<NodeId> = tx.doc().subtree(inst).into_iter().skip(1).collect();
for k in inst_children {
let Some(kn) = tx.doc().get(k).cloned() else {
continue;
};
let matched = target_by_name
.iter()
.find(|(name, kind, tid)| {
!used.contains(tid) && *name == kn.name && *kind == kn.kind.type_name()
})
.map(|(_, _, tid)| *tid);
if let Some(tid) = matched {
used.insert(tid);
if let Some(link) = tx.node_mut(k).and_then(|n| n.instance.as_mut()) {
link.main = tid;
}
}
}
if let Some(root) = tx.node_mut(inst) {
if let Some(link) = &mut root.instance {
link.main = new_main;
}
if root
.instance
.as_ref()
.is_none_or(|l| !l.overrides.iter().any(|o| o == "name"))
{
root.name = new_comp.name;
}
}
sync_node(tx, inst, true);
true
}
+60
View File
@@ -160,12 +160,72 @@ impl<'a> Tx<'a> {
}
}
/// Rename a variable across all modes and update every fill linked to it.
pub fn rename_variable(&mut self, old: &str, new: &str) -> usize {
if old == new || new.is_empty() {
return 0;
}
self.snapshot_variables();
if let Some(c) = self.doc.variables.remove(old) {
self.doc.variables.insert(new.to_string(), c);
}
for m in self.doc.modes.values_mut() {
if let Some(c) = m.remove(old) {
m.insert(new.to_string(), c);
}
}
let users: Vec<NodeId> = self
.doc
.nodes
.values()
.filter(|n| n.style.fills.iter().any(|p| p.var() == Some(old)))
.map(|n| n.id)
.collect();
let mut count = 0;
for id in users {
if let Some(n) = self.node_mut(id) {
for p in &mut n.style.fills {
if let crate::model::Paint::Solid { var: Some(v), .. } = p
&& v == old
{
*v = new.to_string();
count += 1;
}
}
}
}
count
}
/// Define or change a non-color design token.
pub fn set_token(&mut self, name: &str, value: &str) {
self.snapshot_variables();
self.doc.tokens.insert(name.to_string(), value.to_string());
}
/// Delete a non-color design token.
pub fn remove_token(&mut self, name: &str) {
self.snapshot_variables();
self.doc.tokens.remove(name);
}
/// Remove a variable mode. If it was active, switch to the first remaining mode (or None).
pub fn remove_mode(&mut self, mode: &str) {
self.snapshot_variables();
self.doc.modes.remove(mode);
if self.doc.mode.as_deref() == Some(mode) {
let next = self.doc.modes.keys().next().cloned();
match next {
Some(m) => {
self.set_mode(&m);
}
None => {
self.doc.mode = None;
}
}
}
}
fn snapshot_variables(&mut self) {
if self.rec.variables_before.is_none() {
self.rec.variables_before = Some(self.doc.var_state());
+2529
View File
File diff suppressed because it is too large Load Diff
+21 -2
View File
@@ -16,7 +16,18 @@ pub fn import_as_pages(path: &Path) -> Result<(String, Vec<PageData>), String> {
let pages = p.load_pages().map_err(|e| e.to_string())?;
return Ok((p.manifest.name, pages));
}
let text = crate::vfs::read_to_string(path).map_err(|e| e.to_string())?;
let bytes = crate::vfs::read(path).map_err(|e| e.to_string())?;
let ext = path
.extension()
.map(|e| e.to_string_lossy().to_lowercase())
.unwrap_or_default();
if ext == "fig" || crate::io::fig::is_fig(&bytes) {
return crate::io::fig::import_fig_bytes(&stem, &bytes);
}
let text = String::from_utf8(bytes).map_err(|e| e.to_string())?;
if crate::io::fig::is_figma_rest_json(&text) {
return crate::io::fig::import_rest_json(&stem, &text);
}
page_from_text(&stem, &text)
}
@@ -64,6 +75,9 @@ pub fn import_bytes(name: &str, bytes: &[u8]) -> Result<Imported, String> {
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "Imported".into());
let ext = extension(name);
if ext == "fig" || crate::io::fig::is_fig(bytes) {
return crate::io::fig::import_fig_bytes(&stem, bytes).map(|(n, p)| Imported::Pages(n, p));
}
if ext == "zip" || super::backup::is_zip(bytes) {
return Ok(Imported::Backup);
}
@@ -76,7 +90,12 @@ pub fn import_bytes(name: &str, bytes: &[u8]) -> Result<Imported, String> {
group: Some(stem),
}),
"excalidraw" | "json" => {
let doc = crate::io::excalidraw::import_scene(text()?)?;
let t = text()?;
if crate::io::fig::is_figma_rest_json(t) {
return crate::io::fig::import_rest_json(&stem, t)
.map(|(n, p)| Imported::Pages(n, p));
}
let doc = crate::io::excalidraw::import_scene(t)?;
let nodes = doc
.paint_order()
.into_iter()
+1
View File
@@ -3,6 +3,7 @@ pub mod backup;
pub mod clipboard;
pub mod excalidraw;
pub mod export;
pub mod fig;
pub mod file;
pub mod import;
pub mod pdf;
+508 -27
View File
@@ -29,6 +29,17 @@ fn set(r: &mut Rect, axis: usize, pos: f64, size: f64) {
}
}
fn clamp_size(lc: &crate::model::LayoutChild, axis: usize, s: f64) -> f64 {
let mut out = s.max(0.0);
if let Some(min) = lc.min_size[axis] {
out = out.max(min);
}
if let Some(max) = lc.max_size[axis] {
out = out.min(max.max(lc.min_size[axis].unwrap_or(0.0)));
}
out
}
/// Children that take part in the flow.
pub fn flow_children(doc: &Document, id: NodeId) -> Vec<NodeId> {
doc.children(Some(id))
@@ -45,7 +56,9 @@ pub fn flow_children(doc: &Document, id: NodeId) -> Vec<NodeId> {
fn hug_size(doc: &Document, id: NodeId, axis: usize) -> f64 {
let n = doc.get(id).unwrap();
let (_, cur) = get(&n.rect, axis);
let Some(l) = n.layout else { return cur };
let Some(l) = n.layout else {
return clamp_size(&n.layout_child, axis, cur);
};
let kids = flow_children(doc, id);
let pad = if axis == 0 {
l.padding[3] + l.padding[1]
@@ -57,13 +70,13 @@ fn hug_size(doc: &Document, id: NodeId, axis: usize) -> f64 {
.map(|k| {
let kn = doc.get(*k).unwrap();
match kn.layout_child.sizing[axis] {
Sizing::Hug => hug_size(doc, *k, axis),
Sizing::Hug => clamp_size(&kn.layout_child, axis, hug_size(doc, *k, axis)),
Sizing::Fill => 0.0,
Sizing::Fixed => get(&kn.rect, axis).1,
Sizing::Fixed => clamp_size(&kn.layout_child, axis, get(&kn.rect, axis).1),
}
})
.collect();
if axis == main_axis(&l) && !l.wrap {
let result = if axis == main_axis(&l) && !l.wrap {
let gap = if l.gap_auto { 0.0 } else { l.gap };
pad + sizes.iter().sum::<f64>() + gap * (kids.len().saturating_sub(1)) as f64
} else if axis == main_axis(&l) {
@@ -77,14 +90,23 @@ fn hug_size(doc: &Document, id: NodeId, axis: usize) -> f64 {
.iter()
.map(|line| {
line.iter()
.map(|k| get(&doc.get(*k).unwrap().rect, axis).1)
.map(|k| {
let kn = doc.get(*k).unwrap();
let raw = match kn.layout_child.sizing[axis] {
Sizing::Hug => hug_size(doc, *k, axis),
Sizing::Fill => get(&kn.rect, axis).1,
Sizing::Fixed => get(&kn.rect, axis).1,
};
clamp_size(&kn.layout_child, axis, raw)
})
.fold(0.0, f64::max)
})
.collect();
pad + cross.iter().sum::<f64>() + l.cross_gap * cross.len().saturating_sub(1) as f64
} else {
pad + sizes.iter().cloned().fold(0.0, f64::max)
}
};
clamp_size(&n.layout_child, axis, result)
}
fn wrap_lines(
@@ -104,7 +126,12 @@ fn wrap_lines(
let mut lines: Vec<Vec<NodeId>> = vec![Vec::new()];
let mut x = 0.0;
for k in kids {
let s = get(&doc.get(*k).unwrap().rect, ma).1;
let kn = doc.get(*k).unwrap();
let raw_s = match kn.layout_child.sizing[ma] {
Sizing::Hug => hug_size(doc, *k, ma),
_ => get(&kn.rect, ma).1,
};
let s = clamp_size(&kn.layout_child, ma, raw_s);
let line = lines.last_mut().unwrap();
if !line.is_empty() && x + l.gap + s > inner + 1e-9 {
lines.push(vec![*k]);
@@ -155,6 +182,8 @@ pub fn layout_frame(tx: &mut Tx, id: NodeId) -> bool {
set(&mut rect, axis, p, s.max(0.0));
}
}
let (p, cur_s) = get(&rect, axis);
set(&mut rect, axis, p, clamp_size(&n.layout_child, axis, cur_s));
}
if rect != n.rect {
tx.node_mut(id).unwrap().rect = rect;
@@ -190,8 +219,38 @@ fn arrange(tx: &mut Tx, id: NodeId, l: &AutoLayout, rect: Rect, ma: usize, ca: u
} else {
vec![kids.clone()]
};
let mut cross_cursor = p0c;
for line in lines {
let line_cross_sizes: Vec<f64> = if l.wrap {
lines
.iter()
.map(|line| {
line.iter()
.map(|k| {
let kn = tx.doc().get(*k).unwrap();
let raw = match kn.layout_child.sizing[ca] {
Sizing::Hug => hug_size(tx.doc(), *k, ca),
Sizing::Fill | Sizing::Fixed => get(&kn.rect, ca).1,
};
clamp_size(&kn.layout_child, ca, raw)
})
.fold(0.0, f64::max)
})
.collect()
} else {
vec![inner_c; lines.len()]
};
let mut cross_cursor = if l.wrap {
let total_cross: f64 = line_cross_sizes.iter().sum::<f64>()
+ l.cross_gap * lines.len().saturating_sub(1) as f64;
let free_cross = (inner_c - total_cross).max(0.0);
p0c + match l.align_cross {
Align::Start => 0.0,
Align::Center => free_cross * 0.5,
Align::End => free_cross,
}
} else {
p0c
};
for (li, line) in lines.into_iter().enumerate() {
// Main sizes: fixed / hug as they are; fill shares the rest equally.
let mut sizes: Vec<f64> = line
.iter()
@@ -199,7 +258,8 @@ fn arrange(tx: &mut Tx, id: NodeId, l: &AutoLayout, rect: Rect, ma: usize, ca: u
let kn = tx.doc().get(*k).unwrap();
match kn.layout_child.sizing[ma] {
Sizing::Fill => 0.0,
_ => get(&kn.rect, ma).1,
Sizing::Hug => clamp_size(&kn.layout_child, ma, hug_size(tx.doc(), *k, ma)),
Sizing::Fixed => clamp_size(&kn.layout_child, ma, get(&kn.rect, ma).1),
}
})
.collect();
@@ -211,7 +271,7 @@ fn arrange(tx: &mut Tx, id: NodeId, l: &AutoLayout, rect: Rect, ma: usize, ca: u
if !fills.is_empty() && !l.wrap {
let share = ((inner_m - fixed) / fills.len() as f64).max(0.0);
for i in &fills {
sizes[*i] = share;
sizes[*i] = clamp_size(&tx.doc().get(line[*i]).unwrap().layout_child, ma, share);
}
}
let used: f64 = sizes.iter().sum::<f64>() + gap * line.len().saturating_sub(1) as f64;
@@ -229,24 +289,39 @@ fn arrange(tx: &mut Tx, id: NodeId, l: &AutoLayout, rect: Rect, ma: usize, ca: u
};
(start, gap)
};
let line_cross = if l.wrap {
line.iter()
.map(|k| get(&tx.doc().get(*k).unwrap().rect, ca).1)
.fold(0.0, f64::max)
} else {
inner_c
};
let lh = line_cross_sizes[li];
for (i, k) in line.iter().enumerate() {
let kn = tx.doc().get(*k).unwrap();
let mut r = kn.rect;
let cs = match kn.layout_child.sizing[ca] {
Sizing::Fill => line_cross.max(0.0),
_ => get(&r, ca).1,
};
let cpos = match l.align_cross {
Align::Start => cross_cursor,
Align::Center => cross_cursor + (line_cross - cs) * 0.5,
Align::End => cross_cursor + line_cross - cs,
let cross_align = kn.layout_child.align_self.unwrap_or(l.align_cross);
let (cpos, cs) = match kn.layout_child.sizing[ca] {
Sizing::Fill => {
let cs = clamp_size(&kn.layout_child, ca, lh.max(0.0));
let cy = if cs < lh {
match cross_align {
Align::Start => cross_cursor,
Align::Center => cross_cursor + (lh - cs) * 0.5,
Align::End => cross_cursor + lh - cs,
}
} else {
cross_cursor
};
(cy, cs)
}
Sizing::Fixed | Sizing::Hug => {
let raw_ch = if kn.layout_child.sizing[ca] == Sizing::Hug {
hug_size(tx.doc(), *k, ca)
} else {
get(&r, ca).1
};
let ch = clamp_size(&kn.layout_child, ca, raw_ch);
let cy = match cross_align {
Align::Start => cross_cursor,
Align::Center => cross_cursor + (lh - ch) * 0.5,
Align::End => cross_cursor + lh - ch,
};
(cy, ch)
}
};
set(&mut r, ma, cursor, sizes[i]);
set(&mut r, ca, cpos, cs);
@@ -266,7 +341,7 @@ fn arrange(tx: &mut Tx, id: NodeId, l: &AutoLayout, rect: Rect, ma: usize, ca: u
}
}
}
cross_cursor += line_cross + l.cross_gap;
cross_cursor += lh + l.cross_gap;
}
// Fill children that are auto-layout frames lay out their own content again.
for k in kids {
@@ -280,6 +355,149 @@ fn arrange(tx: &mut Tx, id: NodeId, l: &AutoLayout, rect: Rect, ma: usize, ca: u
// Absolute children keep their constraints-based position.
}
/// Lay out `flow_children(tx.doc(), id)` into a `cols`-column CSS-style grid inside frame `id`.
/// Returns true if anything moved or resized.
pub fn layout_grid(tx: &mut Tx, id: NodeId, cols: usize, col_gap: f64, row_gap: f64) -> bool {
let Some(n0) = tx.doc().get(id).cloned() else {
return false;
};
let cols = cols.max(1);
let before: Vec<(NodeId, Rect)> = tx
.doc()
.subtree(id)
.iter()
.filter_map(|i| tx.doc().get(*i).map(|n| (*i, n.rect)))
.collect();
let kids = flow_children(tx.doc(), id);
for k in &kids {
if tx.doc().get(*k).is_some_and(|c| c.layout.is_some()) {
layout_frame(tx, *k);
}
}
let n = tx.doc().get(id).unwrap().clone();
let pad = n.layout.unwrap_or_default().padding;
let (pt, pr, pb, pl) = (pad[0], pad[1], pad[2], pad[3]);
let rows = kids.len().div_ceil(cols);
let mut col_w: Vec<f64> = vec![0.0; cols];
let mut col_fill = vec![false; cols];
for (idx, k) in kids.iter().enumerate() {
let c = idx % cols;
let kn = tx.doc().get(*k).unwrap();
if kn.layout_child.sizing[0] == Sizing::Fill {
col_fill[c] = true;
} else {
let w = if kn.layout_child.sizing[0] == Sizing::Hug {
hug_size(tx.doc(), *k, 0)
} else {
kn.rect.w
};
col_w[c] = col_w[c].max(w);
}
}
let total_col_gaps = col_gap * cols.saturating_sub(1) as f64;
let fill_cols: Vec<usize> = (0..cols).filter(|&c| col_fill[c]).collect();
if !fill_cols.is_empty() {
let fixed_w: f64 = (0..cols).filter(|&c| !col_fill[c]).map(|c| col_w[c]).sum();
let inner_w = (n.rect.w - pl - pr).max(0.0);
let share = ((inner_w - total_col_gaps - fixed_w) / fill_cols.len() as f64).max(0.0);
for c in fill_cols {
col_w[c] = share;
}
}
let mut row_h: Vec<f64> = vec![0.0; rows];
for (idx, k) in kids.iter().enumerate() {
let r = idx / cols;
let kn = tx.doc().get(*k).unwrap();
let h = if kn.layout_child.sizing[1] == Sizing::Hug {
hug_size(tx.doc(), *k, 1)
} else {
kn.rect.h
};
row_h[r] = row_h[r].max(h);
}
let total_row_gaps = row_gap * rows.saturating_sub(1) as f64;
let mut rect = n.rect;
if n.layout_child.sizing[0] == Sizing::Hug {
let tracks_w = if kids.is_empty() {
0.0
} else {
col_w.iter().sum::<f64>() + total_col_gaps
};
rect.w = (pl + pr + tracks_w).max(0.0);
}
if n.layout_child.sizing[1] == Sizing::Hug {
let tracks_h = if rows == 0 {
0.0
} else {
row_h.iter().sum::<f64>() + total_row_gaps
};
rect.h = (pt + pb + tracks_h).max(0.0);
}
if rect != n0.rect {
tx.node_mut(id).unwrap().rect = rect;
}
let mut col_x = vec![0.0; cols];
let mut cx = pl;
for c in 0..cols {
col_x[c] = cx;
cx += col_w[c] + col_gap;
}
let mut row_y = vec![0.0; rows];
let mut ry = pt;
for r in 0..rows {
row_y[r] = ry;
ry += row_h[r] + row_gap;
}
for (idx, k) in kids.iter().enumerate() {
let c = idx % cols;
let r = idx / cols;
let kn = tx.doc().get(*k).unwrap();
let mut cr = kn.rect;
cr.x = col_x[c];
cr.y = row_y[r];
if kn.layout_child.sizing[0] == Sizing::Fill {
cr.w = col_w[c];
} else if kn.layout_child.sizing[0] == Sizing::Hug {
cr.w = hug_size(tx.doc(), *k, 0);
}
if kn.layout_child.sizing[1] == Sizing::Fill {
cr.h = row_h[r];
} else if kn.layout_child.sizing[1] == Sizing::Hug {
cr.h = hug_size(tx.doc(), *k, 1);
}
if cr != kn.rect {
let resized = (cr.w - kn.rect.w).abs() > 1e-9 || (cr.h - kn.rect.h).abs() > 1e-9;
let old = kn.rect;
let cn = tx.node_mut(*k).unwrap();
if resized {
cn.set_rect_scaling(cr);
} else {
cn.rect = cr;
}
if resized {
resize_children(tx, *k, old, cr);
fit_text_height(tx, *k);
}
}
}
let after: Vec<(NodeId, Rect)> = tx
.doc()
.subtree(id)
.iter()
.filter_map(|i| tx.doc().get(*i).map(|n| (*i, n.rect)))
.collect();
before != after
}
/// Re-measure a text node's height after its width changed.
pub fn fit_text_height(tx: &mut Tx, id: NodeId) {
let Some(n) = tx.doc().get(id) else { return };
@@ -640,4 +858,267 @@ mod tests {
assert_eq!(r(3), Rect::new(120.0, 60.0, 40.0, 20.0));
assert_eq!(r(4), Rect::new(40.0, 20.0, 80.0, 40.0));
}
#[test]
fn wrap_lines_cross_align_center_and_end() {
let base = AutoLayout {
wrap: true,
gap: 10.0,
cross_gap: 10.0,
padding: [0.0; 4],
align_cross: Align::Center,
..Default::default()
};
// Frame 300x200: line 0 has 20 & 30 (lh=30), line 1 has 20 (lh=20).
// total_cross = 30 + 10 + 20 = 60, free_cross = 140.
let (doc_c, _, kc) = doc_with(
base,
FX,
&[(120.0, 20.0, FX), (120.0, 30.0, FX), (120.0, 20.0, FX)],
);
assert_eq!(
doc_c.get(kc[0]).unwrap().rect,
Rect::new(0.0, 75.0, 120.0, 20.0)
);
assert_eq!(
doc_c.get(kc[1]).unwrap().rect,
Rect::new(130.0, 70.0, 120.0, 30.0)
);
assert_eq!(
doc_c.get(kc[2]).unwrap().rect,
Rect::new(0.0, 110.0, 120.0, 20.0)
);
let end_l = AutoLayout {
align_cross: Align::End,
..base
};
let (doc_e, _, ke) = doc_with(
end_l,
FX,
&[(120.0, 20.0, FX), (120.0, 30.0, FX), (120.0, 20.0, FX)],
);
assert_eq!(
doc_e.get(ke[0]).unwrap().rect,
Rect::new(0.0, 150.0, 120.0, 20.0)
);
assert_eq!(
doc_e.get(ke[1]).unwrap().rect,
Rect::new(130.0, 140.0, 120.0, 30.0)
);
assert_eq!(
doc_e.get(ke[2]).unwrap().rect,
Rect::new(0.0, 180.0, 120.0, 20.0)
);
}
#[test]
fn wrap_per_line_item_align_and_fill_stretch() {
let l = AutoLayout {
wrap: true,
gap: 10.0,
cross_gap: 10.0,
padding: [10.0; 4],
align_cross: Align::Center,
..Default::default()
};
// Inner width = 300 - 20 = 280.
// Line 0: three 80w items (80*3 + 20 = 260 <= 280), heights: Fill, 20, 40 -> lh = 40.
// Line 1: two 120w items (120*2 + 10 = 250 <= 280), heights: 60, Fill -> lh = 60.
let (doc, f, k) = doc_with(
l,
[Sizing::Fixed, Sizing::Hug],
&[
(80.0, 5.0, [Sizing::Fixed, Sizing::Fill]),
(80.0, 20.0, FX),
(80.0, 40.0, FX),
(120.0, 60.0, FX),
(120.0, 5.0, [Sizing::Fixed, Sizing::Fill]),
],
);
assert_eq!(doc.get(f).unwrap().rect.h, 10.0 + 40.0 + 10.0 + 60.0 + 10.0);
assert_eq!(
doc.get(k[0]).unwrap().rect,
Rect::new(10.0, 10.0, 80.0, 40.0)
);
assert_eq!(
doc.get(k[1]).unwrap().rect,
Rect::new(100.0, 20.0, 80.0, 20.0)
);
assert_eq!(
doc.get(k[2]).unwrap().rect,
Rect::new(190.0, 10.0, 80.0, 40.0)
);
assert_eq!(
doc.get(k[3]).unwrap().rect,
Rect::new(10.0, 60.0, 120.0, 60.0)
);
assert_eq!(
doc.get(k[4]).unwrap().rect,
Rect::new(140.0, 60.0, 120.0, 60.0)
);
}
#[test]
fn layout_grid_columns_fill_and_hug() {
// 2-column grid with Hug height and one Fill column.
let mut doc = Document::default();
let mut rec = Recording::new("t", &doc);
let mut tx = Tx::new(&mut doc, &mut rec);
let fid = tx.alloc_id();
let mut f = Node::new(
fid,
NodeKind::Frame { clip: true },
Rect::new(0.0, 0.0, 300.0, 50.0),
);
f.layout = Some(AutoLayout {
padding: [10.0, 10.0, 10.0, 10.0],
..Default::default()
});
f.layout_child.sizing = [Sizing::Fixed, Sizing::Hug];
tx.insert(f, None, None);
let specs = [
(80.0, 30.0, FX),
(20.0, 40.0, [Sizing::Fill, Sizing::Fixed]),
(100.0, 50.0, FX),
(20.0, 10.0, [Sizing::Fill, Sizing::Fill]),
];
let mut ids = Vec::new();
for (w, h, s) in specs {
let id = tx.alloc_id();
let mut n = Node::new(id, NodeKind::Rect, Rect::new(0.0, 0.0, w, h));
n.layout_child.sizing = s;
tx.insert(n, Some(fid), None);
ids.push(id);
}
assert!(layout_grid(&mut tx, fid, 2, 20.0, 15.0));
// Col 0 max fixed width = 100. Inner width = 280, col_gap = 20 -> Col 1 Fill width = 160.
// Row 0 height = max(30, 40) = 40; Row 1 height = max(50, 10) = 50.
// Hug height = 10 + 40 + 15 + 50 + 10 = 125.
assert_eq!(tx.doc().get(fid).unwrap().rect.h, 125.0);
assert_eq!(
tx.doc().get(ids[0]).unwrap().rect,
Rect::new(10.0, 10.0, 80.0, 30.0)
);
assert_eq!(
tx.doc().get(ids[1]).unwrap().rect,
Rect::new(130.0, 10.0, 160.0, 40.0)
);
assert_eq!(
tx.doc().get(ids[2]).unwrap().rect,
Rect::new(10.0, 65.0, 100.0, 50.0)
);
assert_eq!(
tx.doc().get(ids[3]).unwrap().rect,
Rect::new(130.0, 65.0, 160.0, 50.0)
);
// Re-layout as 3-column grid (col 0 has Fixed 80 & Fill -> Fill; col 1 has Fill -> Fill; col 2 has Fixed 100).
// Inner width = 280, gaps = 2 * 10 = 20, fixed col 2 = 100 -> remaining 160 shared by col 0 and col 1 (80 each).
assert!(layout_grid(&mut tx, fid, 3, 10.0, 10.0));
// Row 0: ids[0](30), ids[1](40), ids[2](50) -> row_h[0] = 50.
// Row 1: ids[3](50 from previous stretch) -> row_h[1] = 50.
assert_eq!(
tx.doc().get(fid).unwrap().rect.h,
10.0 + 50.0 + 10.0 + 50.0 + 10.0
);
assert_eq!(
tx.doc().get(ids[0]).unwrap().rect,
Rect::new(10.0, 10.0, 80.0, 30.0)
);
assert_eq!(
tx.doc().get(ids[1]).unwrap().rect,
Rect::new(100.0, 10.0, 80.0, 40.0)
);
assert_eq!(
tx.doc().get(ids[2]).unwrap().rect,
Rect::new(190.0, 10.0, 100.0, 50.0)
);
assert_eq!(
tx.doc().get(ids[3]).unwrap().rect,
Rect::new(10.0, 70.0, 80.0, 50.0)
);
}
#[test]
fn align_self_overrides_parent_cross_align() {
let mut doc = Document::default();
let mut rec = Recording::new("t", &doc);
let mut tx = Tx::new(&mut doc, &mut rec);
let fid = tx.alloc_id();
let mut f = Node::new(
fid,
NodeKind::Frame { clip: true },
Rect::new(0.0, 0.0, 300.0, 100.0),
);
f.layout = Some(AutoLayout {
direction: Direction::Horizontal,
gap: 10.0,
padding: [0.0; 4],
align_cross: Align::Start,
..Default::default()
});
f.layout_child.sizing = FX;
tx.insert(f, None, None);
let mut ids = Vec::new();
for self_align in [None, Some(Align::Center), Some(Align::End)] {
let id = tx.alloc_id();
let mut n = Node::new(id, NodeKind::Rect, Rect::new(0.0, 0.0, 40.0, 20.0));
n.layout_child.sizing = FX;
n.layout_child.align_self = self_align;
tx.insert(n, Some(fid), None);
ids.push(id);
}
layout_frame(&mut tx, fid);
assert_eq!(
tx.doc().get(ids[0]).unwrap().rect,
Rect::new(0.0, 0.0, 40.0, 20.0)
);
assert_eq!(
tx.doc().get(ids[1]).unwrap().rect,
Rect::new(50.0, 40.0, 40.0, 20.0)
);
assert_eq!(
tx.doc().get(ids[2]).unwrap().rect,
Rect::new(100.0, 80.0, 40.0, 20.0)
);
}
#[test]
fn min_size_and_max_size_clamping() {
let mut doc = Document::default();
let mut rec = Recording::new("t", &doc);
let mut tx = Tx::new(&mut doc, &mut rec);
let fid = tx.alloc_id();
let mut f = Node::new(
fid,
NodeKind::Frame { clip: true },
Rect::new(0.0, 0.0, 400.0, 10.0),
);
f.layout = Some(AutoLayout {
direction: Direction::Horizontal,
gap: 0.0,
padding: [0.0; 4],
align_cross: Align::Center,
..Default::default()
});
f.layout_child.sizing = [Sizing::Hug, Sizing::Hug];
f.layout_child.min_size = [None, Some(44.0)];
f.layout_child.max_size = [Some(300.0), None];
tx.insert(f, None, None);
let cid = tx.alloc_id();
let mut c = Node::new(cid, NodeKind::Rect, Rect::new(0.0, 0.0, 500.0, 20.0));
c.layout_child.sizing = FX;
c.layout_child.max_size = [Some(350.0), None];
tx.insert(c, Some(fid), None);
layout_frame(&mut tx, fid);
let fr = tx.doc().get(fid).unwrap().rect;
assert_eq!((fr.w, fr.h), (300.0, 44.0));
let cr = tx.doc().get(cid).unwrap().rect;
assert_eq!(cr, Rect::new(0.0, 12.0, 350.0, 20.0));
}
}
+1
View File
@@ -20,6 +20,7 @@ pub mod model;
pub mod project;
pub mod render;
pub mod session;
pub mod sync;
pub mod text;
pub mod theme;
pub mod ui;
+1501
View File
File diff suppressed because it is too large Load Diff
+322 -40
View File
@@ -620,7 +620,7 @@ pub fn apply(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> Applie
.map(|(k, v)| match var_name(v) {
Some((name, fallback)) => match VARS.with(|m| m.borrow().get(name).copied()) {
Some(c) => {
if COLOR_PROPS.contains(&k.as_str()) {
if COLOR_PROPS.contains(&k.as_str()) && k != "textDecorationColor" {
linked.push(name);
}
(k.clone(), color_css(c))
@@ -664,7 +664,13 @@ pub fn apply(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> Applie
a
}
const COLOR_PROPS: [&str; 4] = ["backgroundColor", "background", "color", "fill"];
const COLOR_PROPS: [&str; 5] = [
"backgroundColor",
"background",
"color",
"fill",
"textDecorationColor",
];
thread_local! {
static VARS: std::cell::RefCell<std::collections::BTreeMap<String, Color>> = const { std::cell::RefCell::new(std::collections::BTreeMap::new()) };
@@ -826,8 +832,16 @@ pub fn override_keys(styles: &[(String, String)]) -> Vec<&'static str> {
"left" | "top" | "right" | "bottom" | "x" | "y" => "position",
"borderRadius" => "radius",
"opacity" | "mixBlendMode" => "opacity",
"fontSize" | "fontWeight" | "fontStyle" | "lineHeight" | "letterSpacing"
| "textAlign" => "font",
"fontSize"
| "fontWeight"
| "fontStyle"
| "lineHeight"
| "letterSpacing"
| "textAlign"
| "textDecoration"
| "textDecorationLine"
| "textDecorationColor"
| "textDecorationStyle" => "font",
"visibility" => "visible",
k if k.starts_with("border") || k == "stroke" => "stroke",
_ => "layout",
@@ -1075,23 +1089,49 @@ fn apply_resolved(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> A
None => false,
}
}
"alignSelf" => match ctx.parent_layout {
Some(pl) if lower == "stretch" => {
let ca = if pl.direction == Direction::Horizontal {
"alignSelf" => {
let ca = ctx.parent_layout.map(|pl| {
if pl.direction == Direction::Horizontal {
1
} else {
0
};
axis_set(n, ca, Sizing::Fill);
if ca == 0
&& let NodeKind::Text { auto_width, .. } = &mut n.kind
{
*auto_width = false;
}
true
});
match lower.as_str() {
"stretch" => {
n.layout_child.align_self = None;
if let Some(ca) = ca {
axis_set(n, ca, Sizing::Fill);
if ca == 0
&& let NodeKind::Text { auto_width, .. } = &mut n.kind
{
*auto_width = false;
}
}
true
}
"auto" | "normal" => {
n.layout_child.align_self = None;
true
}
_ if align_of(&lower).is_some() => {
let al = align_of(&lower).unwrap();
n.layout_child.align_self = Some(al);
if let Some(ca) = ca
&& n.layout_child.sizing[ca] == Sizing::Fill
{
n.layout_child.sizing[ca] = if matches!(n.kind, NodeKind::Frame { .. })
{
Sizing::Hug
} else {
Sizing::Fixed
};
}
true
}
_ => false,
}
_ => false,
},
}
"textTransform" if is_text => {
if let Some(t) = n.kind.text_mut() {
match lower.as_str() {
@@ -1121,14 +1161,44 @@ fn apply_resolved(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> A
}
None => false,
},
"flexShrink" | "flexBasis" | "boxSizing" | "minWidth" | "minHeight" | "maxWidth"
| "maxHeight" | "zIndex" | "cursor" | "whiteSpace" | "fontFamily" | "wordBreak"
| "overflowWrap" => {
"minWidth" | "minHeight" => {
let axis = usize::from(k == "minHeight");
if matches!(lower.as_str(), "none" | "auto" | "0") {
n.layout_child.min_size[axis] = None;
true
} else if let Some(px) = length(v, fs) {
n.layout_child.min_size[axis] = Some(px.max(0.0));
if axis == 0 && n.rect.w < px {
n.rect.w = px;
} else if axis == 1 && n.rect.h < px {
n.rect.h = px;
}
true
} else {
false
}
}
"maxWidth" | "maxHeight" => {
let axis = usize::from(k == "maxHeight");
if matches!(lower.as_str(), "none" | "auto") {
n.layout_child.max_size[axis] = None;
true
} else if let Some(px) = length(v, fs) {
n.layout_child.max_size[axis] = Some(px.max(0.0));
if axis == 0 && n.rect.w > px {
n.rect.w = px;
} else if axis == 1 && n.rect.h > px {
n.rect.h = px;
}
true
} else {
false
}
}
"flexShrink" | "flexBasis" | "boxSizing" | "zIndex" | "cursor" | "whiteSpace"
| "fontFamily" | "wordBreak" | "overflowWrap" => {
// Accepted without an equivalent; harmless. fontFamily: Inter is the only face.
!matches!(
k.as_str(),
"minWidth" | "minHeight" | "maxWidth" | "maxHeight"
)
true
}
"overflow" if n.is_frame() => {
if let NodeKind::Frame { clip } = &mut n.kind {
@@ -1224,18 +1294,79 @@ fn apply_resolved(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> A
"textDecoration" | "textDecorationLine" if is_text => {
let u = lower.contains("underline");
let s = lower.contains("line-through");
let wavy = lower.contains("wavy");
let dc = split_ws(v).into_iter().find_map(|tok| color(&tok));
set_text_flag(n, |sp| {
sp.underline = Some(u);
sp.strike = Some(s);
if wavy {
sp.underline_wavy = Some(true);
}
if let Some(c) = dc {
sp.underline_color = Some(c);
}
});
true
}
"fontWeight" | "fontStyle" | "lineHeight" | "letterSpacing" | "textAlign"
| "textDecoration" => true, // inherited by text children
"border" | "outline" | "borderTop" | "borderRight" | "borderBottom" | "borderLeft" => {
"textDecorationColor" if is_text => match color(v) {
Some(c) => {
set_text_flag(n, |sp| sp.underline_color = Some(c));
true
}
None => false,
},
"textDecorationStyle" if is_text => {
let wavy = lower.contains("wavy");
set_text_flag(n, |sp| sp.underline_wavy = Some(wavy));
true
}
"fontWeight"
| "fontStyle"
| "lineHeight"
| "letterSpacing"
| "textAlign"
| "textDecoration"
| "textDecorationLine"
| "textDecorationColor"
| "textDecorationStyle" => true, // inherited by text children
"border" | "outline" => {
n.style.stroke_sides = None;
apply_border(n, v, fs)
}
"borderColor" | "stroke" => match color(v) {
"borderTop" | "borderRight" | "borderBottom" | "borderLeft" => {
let side_idx = ["borderTop", "borderRight", "borderBottom", "borderLeft"]
.iter()
.position(|x| x == k)
.unwrap();
apply_border_side(n, side_idx, v, fs)
}
"borderTopWidth" | "borderRightWidth" | "borderBottomWidth" | "borderLeftWidth" => {
match length(v, fs) {
Some(w) => {
let side_idx = [
"borderTopWidth",
"borderRightWidth",
"borderBottomWidth",
"borderLeftWidth",
]
.iter()
.position(|x| x == k)
.unwrap();
let mut sides = n.style.stroke_sides.unwrap_or([0.0; 4]);
sides[side_idx] = w.max(0.0);
n.style.stroke_sides = if sides == [0.0; 4] { None } else { Some(sides) };
n.style.stroke_width = sides.iter().copied().fold(0.0, f64::max);
n.style.stroke_align = crate::model::StrokeAlign::Inside;
if n.style.stroke_width > 0.0 && n.style.stroke.is_none() {
n.style.stroke = Some(Color::INK);
}
true
}
None => false,
}
}
"borderColor" | "stroke" | "borderTopColor" | "borderRightColor"
| "borderBottomColor" | "borderLeftColor" => match color(v) {
Some(c) => {
n.style.stroke = (c.a > 0).then_some(c);
if n.style.stroke_width <= 0.0 {
@@ -1245,19 +1376,39 @@ fn apply_resolved(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> A
}
None => false,
},
"borderWidth" | "strokeWidth" => match length(v, fs) {
Some(w) => {
n.style.stroke_width = w;
if k == "borderWidth" {
n.style.stroke_align = crate::model::StrokeAlign::Inside;
"borderWidth" | "strokeWidth" => {
if k == "borderWidth"
&& let Some(w) = four(v, fs)
{
n.style.stroke_align = crate::model::StrokeAlign::Inside;
if w.iter().all(|&x| (x - w[0]).abs() < 1e-9) {
n.style.stroke_sides = None;
n.style.stroke_width = w[0];
} else {
n.style.stroke_sides = Some(w);
n.style.stroke_width = w.iter().copied().fold(0.0, f64::max);
}
if w > 0.0 && n.style.stroke.is_none() {
if n.style.stroke_width > 0.0 && n.style.stroke.is_none() {
n.style.stroke = Some(Color::INK);
}
true
} else {
match length(v, fs) {
Some(w) => {
n.style.stroke_sides = None;
n.style.stroke_width = w;
if k == "borderWidth" {
n.style.stroke_align = crate::model::StrokeAlign::Inside;
}
if w > 0.0 && n.style.stroke.is_none() {
n.style.stroke = Some(Color::INK);
}
true
}
None => false,
}
}
None => false,
},
}
"borderStyle" | "strokeStyle" => {
set_dash(n, &lower);
true
@@ -1425,6 +1576,53 @@ fn apply_border(n: &mut Node, v: &str, fs: f64) -> bool {
any
}
fn apply_border_side(n: &mut Node, side_idx: usize, v: &str, fs: f64) -> bool {
let lower = v.to_ascii_lowercase();
let mut sides = n.style.stroke_sides.unwrap_or([0.0; 4]);
if lower == "none" || lower == "0" {
sides[side_idx] = 0.0;
n.style.stroke_sides = if sides == [0.0; 4] { None } else { Some(sides) };
n.style.stroke_width = sides.iter().copied().fold(0.0, f64::max);
if sides == [0.0; 4] {
n.style.stroke = None;
}
return true;
}
let mut any = false;
let mut width = None;
let mut col = None;
let mut style = "solid".to_string();
for p in split_ws(v) {
if let Some(w) = length(&p, fs) {
width = Some(w);
any = true;
} else if let Some(c) = color(&p) {
col = Some(c);
any = true;
} else if matches!(
p.to_ascii_lowercase().as_str(),
"solid" | "dashed" | "dotted" | "double"
) {
style = p.to_ascii_lowercase();
any = true;
}
}
if any {
let w = width.unwrap_or(1.0);
sides[side_idx] = w;
n.style.stroke_sides = Some(sides);
n.style.stroke_width = sides.iter().copied().fold(0.0, f64::max);
if let Some(c) = col {
n.style.stroke = (c.a > 0).then_some(c);
} else if n.style.stroke.is_none() && !lower.contains("transparent") {
n.style.stroke = Some(Color::INK);
}
n.style.stroke_align = crate::model::StrokeAlign::Inside;
set_dash(n, &style);
}
any
}
fn apply_background(n: &mut Node, k: &str, v: &str, a: &mut Applied) -> bool {
let lower = v.to_ascii_lowercase();
if lower == "none" || lower == "transparent" {
@@ -1510,6 +1708,21 @@ pub fn computed(n: &Node, parent_layout: Option<&AutoLayout>, is_root: bool) ->
put("width", size_css(0, n.rect.w));
put("height", size_css(1, n.rect.h));
}
if let Some(mw) = n.layout_child.min_size[0] {
put("minWidth", px(mw));
}
if let Some(mh) = n.layout_child.min_size[1] {
put("minHeight", px(mh));
}
if let Some(xw) = n.layout_child.max_size[0] {
put("maxWidth", px(xw));
}
if let Some(xh) = n.layout_child.max_size[1] {
put("maxHeight", px(xh));
}
if let Some(al) = n.layout_child.align_self {
put("alignSelf", align_css(al).into());
}
if let Some(pl) = parent_layout
&& in_flow
{
@@ -1521,7 +1734,7 @@ pub fn computed(n: &Node, parent_layout: Option<&AutoLayout>, is_root: bool) ->
if n.layout_child.sizing[ma] == Sizing::Fill {
put("flexGrow", "1".into());
}
if n.layout_child.sizing[1 - ma] == Sizing::Fill {
if n.layout_child.align_self.is_none() && n.layout_child.sizing[1 - ma] == Sizing::Fill {
put("alignSelf", "stretch".into());
}
put("flexShrink", "0".into());
@@ -1635,10 +1848,23 @@ pub fn computed(n: &Node, parent_layout: Option<&AutoLayout>, is_root: bool) ->
} else {
"dashed"
};
put(
"border",
format!("{} {kind} {}", px(n.style.stroke_width), color_css(s)),
);
if let Some(sides) = n.style.stroke_sides
&& sides.iter().any(|&w| (w - sides[0]).abs() > 1e-9)
{
for (i, key) in ["borderTop", "borderRight", "borderBottom", "borderLeft"]
.into_iter()
.enumerate()
{
if sides[i] > 0.0 {
put(key, format!("{} {kind} {}", px(sides[i]), color_css(s)));
}
}
} else {
put(
"border",
format!("{} {kind} {}", px(n.style.stroke_width), color_css(s)),
);
}
}
if let Some(r) = n.style.radii {
put(
@@ -1774,4 +2000,60 @@ mod tests {
let r = n.style.rough.unwrap();
assert_eq!((r.roughness, r.fill_style), (2.0, FillStyle::CrossHatch));
}
#[test]
fn border_sides_align_self_min_max_and_wavy_underline() {
let mut n = Node::new(
NodeId(4),
NodeKind::Frame { clip: false },
Rect::new(0.0, 0.0, 100.0, 20.0),
);
let a = apply(
&mut n,
&parse_inline(
"border-left: 3px solid #5eead4; align-self: center; min-height: 44px; max-height: 120px",
),
&Context {
parent_layout: Some(AutoLayout::default()),
parent_size: Some((300.0, 300.0)),
},
);
assert!(a.ignored.is_empty(), "{:?}", a.ignored);
assert_eq!(n.style.stroke_sides, Some([0.0, 0.0, 0.0, 3.0]));
assert_eq!(n.style.stroke_width, 3.0);
assert_eq!(n.style.stroke, Some(Color::rgb(0x5e, 0xea, 0xd4)));
assert_eq!(n.layout_child.align_self, Some(Align::Center));
assert_eq!(n.layout_child.min_size[1], Some(44.0));
assert_eq!(n.layout_child.max_size[1], Some(120.0));
assert_eq!(n.rect.h, 44.0);
let back = computed(&n, Some(&AutoLayout::default()), false);
assert_eq!(back["borderLeft"], "3px solid #5eead4");
assert!(!back.contains_key("border"));
assert_eq!(back["alignSelf"], "center");
assert_eq!(back["minHeight"], "44px");
assert_eq!(back["maxHeight"], "120px");
let mut tn = Node::new(
NodeId(5),
NodeKind::Text {
text: "Hello".into(),
font_size: 16.0,
auto_width: true,
align: TextAlign::Left,
},
Rect::new(0.0, 0.0, 50.0, 20.0),
);
let ta = apply(
&mut tn,
&parse_inline(
"text-decoration: underline; text-decoration-color: #f9e2af; text-decoration-style: wavy",
),
&Context::default(),
);
assert!(ta.ignored.is_empty(), "{:?}", ta.ignored);
let sp = &tn.text_style.spans[0];
assert_eq!(sp.underline, Some(true));
assert_eq!(sp.underline_color, Some(Color::rgb(0xf9, 0xe2, 0xaf)));
assert_eq!(sp.underline_wavy, Some(true));
}
}
+678
View File
@@ -0,0 +1,678 @@
//! Structural and pixel diffing between frames (`diff_subtrees`, `diff_rgba`).
use crate::geom::V2;
use crate::model::{
BlendMode, Direction, Document, Node, NodeId, NodeKind, Paint, Stop, TextAlign,
};
use serde::Serialize;
use serde_json::{Value, json};
use std::collections::{HashMap, HashSet};
/// One property difference on a paired node.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct PropChange {
pub field: &'static str,
pub before: String,
pub after: String,
}
/// Structural or property diff entry for a single node.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct NodeDiff {
pub id_a: Option<NodeId>,
pub id_b: Option<NodeId>,
pub path: String,
pub name: String,
pub kind: &'static str,
pub status: &'static str,
pub changes: Vec<PropChange>,
}
/// Summary and per-node list of differences between two subtrees.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct FrameDiff {
pub added: usize,
pub removed: usize,
pub modified: usize,
pub unchanged: usize,
pub nodes: Vec<NodeDiff>,
}
/// Result of a pixel-by-pixel RGBA comparison.
#[derive(Debug, Clone, PartialEq)]
pub struct PixelDiff {
pub width: u32,
pub height: u32,
pub total_pixels: usize,
pub mismatched_pixels: usize,
pub ratio: f64,
pub bounds: Option<[u32; 4]>,
pub diff_png: Vec<u8>,
}
fn fmt_f64(v: f64) -> String {
let r = (v * 100.0).round() / 100.0;
if r == 0.0 {
return "0".into();
}
if (r - r.round()).abs() < 1e-9 {
format!("{}", r as i64)
} else {
format!("{r}")
}
}
fn fmt_v2(v: V2) -> String {
format!("({}, {})", fmt_f64(v.x), fmt_f64(v.y))
}
fn fmt_stops(stops: &[Stop]) -> String {
stops
.iter()
.map(|s| format!("{}@{}", s.color.to_hex(), fmt_f64(s.pos)))
.collect::<Vec<_>>()
.join(", ")
}
fn fmt_fills(n: &Node) -> String {
if n.style.fills.is_empty() {
return "none".into();
}
n.style
.fills
.iter()
.map(|p| match p {
Paint::Solid {
color,
var: Some(v),
} => format!("var(--{v}) ({})", color.to_hex()),
Paint::Solid { color, var: None } => color.to_hex(),
Paint::Linear { stops, from, to } => {
format!(
"linear({}, {} -> {})",
fmt_stops(stops),
fmt_v2(*from),
fmt_v2(*to)
)
}
Paint::Radial {
stops,
center,
radius,
} => {
format!(
"radial({}, center={}, radius={})",
fmt_stops(stops),
fmt_v2(*center),
fmt_v2(*radius)
)
}
Paint::Image {
asset,
mode,
opacity,
} => {
format!("image({asset}, {mode:?}, {})", fmt_f64(*opacity))
}
})
.collect::<Vec<_>>()
.join(", ")
}
fn fmt_stroke(n: &Node) -> String {
match n.style.stroke {
Some(c) if n.style.stroke_width > 0.0 => {
format!("{} {}px", c.to_hex(), fmt_f64(n.style.stroke_width))
}
_ => "none".into(),
}
}
fn fmt_corner_radius(n: &Node) -> String {
match n.style.radii {
Some([tl, tr, br, bl]) => {
if (tl - tr).abs() < 1e-9 && (tr - br).abs() < 1e-9 && (br - bl).abs() < 1e-9 {
fmt_f64(tl)
} else {
format!(
"{} {} {} {}",
fmt_f64(tl),
fmt_f64(tr),
fmt_f64(br),
fmt_f64(bl)
)
}
}
None => fmt_f64(n.style.corner_radius),
}
}
fn fmt_opacity(n: &Node) -> String {
if n.style.blend == BlendMode::Normal {
fmt_f64(n.style.opacity)
} else {
format!("{} {}", fmt_f64(n.style.opacity), n.style.blend.css())
}
}
fn fmt_font_props(n: &Node) -> Option<String> {
match &n.kind {
NodeKind::Text {
font_size, align, ..
} => {
let mut parts = vec![format!("{}px", fmt_f64(*font_size))];
if n.text_style.bold {
parts.push("bold".into());
}
if n.text_style.italic {
parts.push("italic".into());
}
let al = match align {
TextAlign::Left => "left",
TextAlign::Center => "center",
TextAlign::Right => "right",
};
parts.push(al.into());
Some(parts.join(" "))
}
NodeKind::Sticky { font_size, .. } => Some(format!("{}px", fmt_f64(*font_size))),
_ => None,
}
}
fn fmt_layout(n: &Node) -> String {
let Some(l) = n.layout else {
return "none".into();
};
let dir = match l.direction {
Direction::Horizontal => "row",
Direction::Vertical => "column",
};
let gap = if l.gap_auto {
"auto".into()
} else {
fmt_f64(l.gap)
};
let [pt, pr, pb, pl] = l.padding;
let pad = format!(
"{},{},{},{}",
fmt_f64(pt),
fmt_f64(pr),
fmt_f64(pb),
fmt_f64(pl)
);
let wrap = if l.wrap { " wrap" } else { "" };
format!(
"{dir}{wrap} gap={gap} pad={pad} align={:?}/{:?}",
l.align_main, l.align_cross
)
.to_lowercase()
}
fn compare_node_props(na: &Node, nb: &Node, is_root: bool) -> Vec<PropChange> {
let mut out = Vec::new();
let mut check = |field: &'static str, before: String, after: String| {
if before != after {
out.push(PropChange {
field,
before,
after,
});
}
};
check(
"size",
format!("{} × {}", fmt_f64(na.rect.w), fmt_f64(na.rect.h)),
format!("{} × {}", fmt_f64(nb.rect.w), fmt_f64(nb.rect.h)),
);
if !is_root || (na.parent.is_some() && nb.parent.is_some()) {
check(
"position",
format!("{}, {}", fmt_f64(na.rect.x), fmt_f64(na.rect.y)),
format!("{}, {}", fmt_f64(nb.rect.x), fmt_f64(nb.rect.y)),
);
}
check("fills", fmt_fills(na), fmt_fills(nb));
check("stroke", fmt_stroke(na), fmt_stroke(nb));
check(
"corner_radius",
fmt_corner_radius(na),
fmt_corner_radius(nb),
);
check("opacity", fmt_opacity(na), fmt_opacity(nb));
if let (Some(ta), Some(tb)) = (na.kind.text(), nb.kind.text()) {
check("text", ta.clone(), tb.clone());
}
if let (Some(fa), Some(fb)) = (fmt_font_props(na), fmt_font_props(nb)) {
check("font_size", fa, fb);
}
check("layout", fmt_layout(na), fmt_layout(nb));
check("visible", na.visible.to_string(), nb.visible.to_string());
out
}
fn record_unmatched(doc: &Document, id: NodeId, path: String, added: bool, diff: &mut FrameDiff) {
let Some(n) = doc.get(id) else { return };
if added {
diff.added += 1;
} else {
diff.removed += 1;
}
diff.nodes.push(NodeDiff {
id_a: (!added).then_some(id),
id_b: added.then_some(id),
path: path.clone(),
name: n.name.clone(),
kind: n.kind.type_name(),
status: if added { "added" } else { "removed" },
changes: Vec::new(),
});
for &child_id in &n.children {
if let Some(child) = doc.get(child_id) {
let child_path = format!("{path} > {}", child.name);
record_unmatched(doc, child_id, child_path, added, diff);
}
}
}
fn walk_pair(
doc_a: &Document,
id_a: NodeId,
doc_b: &Document,
id_b: NodeId,
path: String,
is_root: bool,
diff: &mut FrameDiff,
) {
let (Some(na), Some(nb)) = (doc_a.get(id_a), doc_b.get(id_b)) else {
return;
};
let changes = compare_node_props(na, nb, is_root);
if changes.is_empty() {
diff.unchanged += 1;
} else {
diff.modified += 1;
diff.nodes.push(NodeDiff {
id_a: Some(id_a),
id_b: Some(id_b),
path: path.clone(),
name: nb.name.clone(),
kind: nb.kind.type_name(),
status: "modified",
changes,
});
}
let mut b_counts: HashMap<(&str, &'static str), usize> = HashMap::new();
let mut b_map: HashMap<(&str, &'static str, usize), NodeId> = HashMap::new();
for &cid_b in &nb.children {
if let Some(cb) = doc_b.get(cid_b) {
let key = (cb.name.as_str(), cb.kind.type_name());
let occ = *b_counts.entry(key).and_modify(|c| *c += 1).or_insert(0);
b_map.insert((key.0, key.1, occ), cid_b);
}
}
let mut a_counts: HashMap<(&str, &'static str), usize> = HashMap::new();
let mut matched_b: HashSet<NodeId> = HashSet::new();
for &cid_a in &na.children {
let Some(ca) = doc_a.get(cid_a) else { continue };
let key = (ca.name.as_str(), ca.kind.type_name());
let occ = *a_counts.entry(key).and_modify(|c| *c += 1).or_insert(0);
let child_path = format!("{path} > {}", ca.name);
if let Some(&cid_b) = b_map.get(&(key.0, key.1, occ)) {
matched_b.insert(cid_b);
walk_pair(doc_a, cid_a, doc_b, cid_b, child_path, false, diff);
} else {
record_unmatched(doc_a, cid_a, child_path, false, diff);
}
}
for &cid_b in &nb.children {
if matched_b.contains(&cid_b) {
continue;
}
if let Some(cb) = doc_b.get(cid_b) {
let child_path = format!("{path} > {}", cb.name);
record_unmatched(doc_b, cid_b, child_path, true, diff);
}
}
}
/// Compare two frames or subtrees recursively, matching siblings by `(name, kind, occurrence)`.
pub fn diff_subtrees(
doc_a: &Document,
root_a: NodeId,
doc_b: &Document,
root_b: NodeId,
) -> Result<FrameDiff, String> {
if doc_a.get(root_a).is_none() {
return Err(format!("node {root_a} not found in first document"));
}
let nb = doc_b
.get(root_b)
.ok_or_else(|| format!("node {root_b} not found in second document"))?;
let mut diff = FrameDiff {
added: 0,
removed: 0,
modified: 0,
unchanged: 0,
nodes: Vec::new(),
};
walk_pair(
doc_a,
root_a,
doc_b,
root_b,
nb.name.clone(),
true,
&mut diff,
);
Ok(diff)
}
/// Serialize a [`FrameDiff`] into a clean JSON value for MCP output.
pub fn to_json(d: &FrameDiff) -> Value {
let changes: Vec<Value> = d
.nodes
.iter()
.map(|n| {
let mut obj = json!({
"path": n.path,
"name": n.name,
"type": n.kind,
"status": n.status,
});
if let Some(id_a) = n.id_a {
obj["idA"] = json!(id_a.0);
}
if let Some(id_b) = n.id_b {
obj["idB"] = json!(id_b.0);
}
if !n.changes.is_empty() {
let props: Vec<Value> = n
.changes
.iter()
.map(|c| {
json!({
"field": c.field,
"before": c.before,
"after": c.after,
})
})
.collect();
obj["changes"] = Value::Array(props);
}
obj
})
.collect();
json!({
"added": d.added,
"removed": d.removed,
"modified": d.modified,
"unchanged": d.unchanged,
"changes": changes,
})
}
/// Compare two RGBA8 buffers over their union dimensions and return a visual diff PNG.
pub fn diff_rgba(
a: &[u8],
w_a: u32,
h_a: u32,
b: &[u8],
w_b: u32,
h_b: u32,
threshold: u8,
) -> Result<PixelDiff, String> {
let expected_a = (w_a as usize)
.checked_mul(h_a as usize)
.and_then(|px| px.checked_mul(4))
.ok_or_else(|| "overflow in image A dimensions".to_string())?;
let expected_b = (w_b as usize)
.checked_mul(h_b as usize)
.and_then(|px| px.checked_mul(4))
.ok_or_else(|| "overflow in image B dimensions".to_string())?;
if a.len() < expected_a {
return Err(format!(
"image A buffer too short: got {}, expected {expected_a}",
a.len()
));
}
if b.len() < expected_b {
return Err(format!(
"image B buffer too short: got {}, expected {expected_b}",
b.len()
));
}
let width = w_a.max(w_b);
let height = h_a.max(h_b);
if width == 0 || height == 0 {
return Err("cannot diff zero-sized images".into());
}
let total_pixels = (width as usize) * (height as usize);
let mut diff_buf = vec![0u8; total_pixels * 4];
let mut mismatched_pixels = 0usize;
let mut min_x = u32::MAX;
let mut min_y = u32::MAX;
let mut max_x = 0u32;
let mut max_y = 0u32;
for y in 0..height {
for x in 0..width {
let pa = if x < w_a && y < h_a {
let idx = ((y * w_a + x) as usize) * 4;
[a[idx], a[idx + 1], a[idx + 2], a[idx + 3]]
} else {
[0, 0, 0, 0]
};
let pb = if x < w_b && y < h_b {
let idx = ((y * w_b + x) as usize) * 4;
[b[idx], b[idx + 1], b[idx + 2], b[idx + 3]]
} else {
[0, 0, 0, 0]
};
let differs = (0..4).any(|c| pa[c].abs_diff(pb[c]) > threshold);
let out_idx = ((y * width + x) as usize) * 4;
if differs {
mismatched_pixels += 1;
min_x = min_x.min(x);
min_y = min_y.min(y);
max_x = max_x.max(x);
max_y = max_y.max(y);
diff_buf[out_idx..out_idx + 4].copy_from_slice(&[239, 68, 68, 255]);
} else {
let alpha = pa[3] as f64 / 255.0;
let r = pa[0] as f64 * alpha + 255.0 * (1.0 - alpha);
let g = pa[1] as f64 * alpha + 255.0 * (1.0 - alpha);
let bl = pa[2] as f64 * alpha + 255.0 * (1.0 - alpha);
let lum = 0.299 * r + 0.587 * g + 0.114 * bl;
let dimmed = (255.0 * 0.65 + lum * 0.35).round().clamp(0.0, 255.0) as u8;
diff_buf[out_idx..out_idx + 4].copy_from_slice(&[dimmed, dimmed, dimmed, 255]);
}
}
}
let bounds = (mismatched_pixels > 0).then_some([min_x, min_y, max_x, max_y]);
let ratio = mismatched_pixels as f64 / total_pixels as f64;
let img = image::RgbaImage::from_raw(width, height, diff_buf)
.ok_or_else(|| "failed to build diff image".to_string())?;
let diff_png = crate::io::export::encode_png(&img)?;
Ok(PixelDiff {
width,
height,
total_pixels,
mismatched_pixels,
ratio,
bounds,
diff_png,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::geom::Rect;
use crate::model::Color;
fn add_node(
doc: &mut Document,
parent: Option<NodeId>,
name: &str,
kind: NodeKind,
rect: Rect,
) -> NodeId {
let id = doc.alloc_id();
let mut n = Node::new(id, kind, rect);
n.name = name.to_string();
n.parent = parent;
doc.nodes.insert(id, n);
doc.children_mut(parent).unwrap().push(id);
id
}
#[test]
fn diff_subtrees_detects_modified_added_removed() {
let mut doc_a = Document::default();
let root_a = add_node(
&mut doc_a,
None,
"Card",
NodeKind::Frame { clip: true },
Rect::new(0.0, 0.0, 320.0, 200.0),
);
let title_a = add_node(
&mut doc_a,
Some(root_a),
"Title",
NodeKind::Text {
text: "Hello".into(),
font_size: 16.0,
auto_width: true,
align: TextAlign::Left,
},
Rect::new(16.0, 16.0, 100.0, 24.0),
);
doc_a
.nodes
.get_mut(&title_a)
.unwrap()
.style
.set_fill(Some(Color::rgb(0x11, 0x22, 0x33)));
let _old_badge = add_node(
&mut doc_a,
Some(root_a),
"OldBadge",
NodeKind::Rect,
Rect::new(16.0, 50.0, 40.0, 20.0),
);
let mut doc_b = doc_a.clone();
// Modify root size, title text + fill, remove OldBadge, and add NewButton.
doc_b.nodes.get_mut(&root_a).unwrap().rect.h = 240.0;
if let NodeKind::Text { text, .. } = &mut doc_b.nodes.get_mut(&title_a).unwrap().kind {
*text = "Welcome".into();
}
doc_b.nodes.get_mut(&title_a).unwrap().style.fills =
vec![Paint::solid_var(Color::rgb(0xff, 0x00, 0x00), "accent")];
// Remove OldBadge from doc_b
let old_id = doc_b.nodes[&root_a].children[1];
doc_b.nodes.get_mut(&root_a).unwrap().children.remove(1);
doc_b.nodes.remove(&old_id);
// Add NewButton to doc_b
add_node(
&mut doc_b,
Some(root_a),
"NewButton",
NodeKind::Rect,
Rect::new(16.0, 180.0, 120.0, 40.0),
);
let d = diff_subtrees(&doc_a, root_a, &doc_b, root_a).unwrap();
assert_eq!(d.modified, 2);
assert_eq!(d.added, 1);
assert_eq!(d.removed, 1);
assert_eq!(d.unchanged, 0);
let card_diff = d.nodes.iter().find(|n| n.path == "Card").unwrap();
assert_eq!(card_diff.status, "modified");
assert!(
card_diff
.changes
.iter()
.any(|c| c.field == "size" && c.before == "320 × 200" && c.after == "320 × 240")
);
let title_diff = d.nodes.iter().find(|n| n.path == "Card > Title").unwrap();
assert_eq!(title_diff.status, "modified");
assert!(
title_diff
.changes
.iter()
.any(|c| c.field == "text" && c.before == "Hello" && c.after == "Welcome")
);
assert!(
title_diff
.changes
.iter()
.any(|c| c.field == "fills" && c.after.contains("var(--accent)"))
);
assert!(
d.nodes
.iter()
.any(|n| n.path == "Card > OldBadge" && n.status == "removed")
);
assert!(
d.nodes
.iter()
.any(|n| n.path == "Card > NewButton" && n.status == "added")
);
let j = to_json(&d);
assert_eq!(j["modified"], 2);
assert_eq!(j["added"], 1);
assert_eq!(j["removed"], 1);
assert_eq!(j["changes"].as_array().unwrap().len(), 4);
}
#[test]
fn diff_rgba_identical_and_changed() {
let w = 4u32;
let h = 4u32;
let a = vec![200u8; (w * h * 4) as usize];
let same = diff_rgba(&a, w, h, &a, w, h, 2).unwrap();
assert_eq!(same.mismatched_pixels, 0);
assert_eq!(same.bounds, None);
assert_eq!(same.ratio, 0.0);
assert!(same.diff_png.starts_with(b"\x89PNG"));
let mut b = a.clone();
// Change pixels at (1, 2) and (2, 3)
let idx1 = ((2 * w + 1) * 4) as usize;
b[idx1] = 10;
let idx2 = ((3 * w + 2) * 4) as usize;
b[idx2 + 1] = 20;
let changed = diff_rgba(&a, w, h, &b, w, h, 2).unwrap();
assert_eq!(changed.mismatched_pixels, 2);
assert_eq!(changed.bounds, Some([1, 2, 2, 3]));
assert!((changed.ratio - 2.0 / 16.0).abs() < 1e-9);
assert!(changed.diff_png.starts_with(b"\x89PNG"));
}
}
+754
View File
@@ -0,0 +1,754 @@
//! AI design-to-code enforcer: verifies generated Web (React, Angular, HTML+CSS) and Rust (`egui`)
//! code against a PaperCraft frame's layout, tokens, components, and text content.
use crate::model::{Direction, Document, NodeId, NodeKind, Paint};
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub struct Violation {
pub rule: &'static str,
pub severity: &'static str, // "error" or "warning"
pub node_id: u64,
pub node_name: String,
pub expected: String,
pub found: String,
pub fix: String,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub struct EnforcementReport {
pub passed: bool,
pub score: u32, // 0..=100
pub target: String,
pub errors: usize,
pub warnings: usize,
pub checked_nodes: usize,
pub violations: Vec<Violation>,
}
fn normalize_target(target: &str) -> Result<&'static str, String> {
match target.trim().to_ascii_lowercase().as_str() {
"react" | "tsx" => Ok("react"),
"tailwind" | "jsx" => Ok("tailwind"),
"angular" | "ng" => Ok("angular"),
"html" | "css" => Ok("html"),
"egui" | "rust" => Ok("egui"),
other => Err(format!(
"unknown enforcement target {other:?}; expected react, tailwind, angular, html, or egui"
)),
}
}
fn is_visible_in_subtree(doc: &Document, root: NodeId, id: NodeId) -> bool {
let mut cur = Some(id);
while let Some(c) = cur {
let Some(n) = doc.get(c) else { return false };
if !n.visible {
return false;
}
if c == root {
return true;
}
cur = n.parent;
}
false
}
fn visible_subtree(doc: &Document, root: NodeId) -> Vec<NodeId> {
doc.subtree(root)
.into_iter()
.filter(|&id| is_visible_in_subtree(doc, root, id))
.collect()
}
fn escape_html(t: &str) -> String {
t.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
fn text_snippet_present(code: &str, snippet: &str) -> bool {
if code.contains(snippet) || code.contains(&escape_html(snippet)) {
return true;
}
if let Ok(json_escaped) = serde_json::to_string(snippet) {
let inner = json_escaped.trim_matches('"');
if code.contains(inner) {
return true;
}
}
false
}
/// Strip CSS variable declaration blocks (`:root { ... }`, `@theme { ... }`, `:host { ... }`,
/// `[data-mode...] { ... }`, `[data-theme...] { ... }`) so hex literals inside token definitions
/// are not flagged as hardcoded style values.
fn strip_token_declaration_blocks(code: &str) -> String {
let mut out = String::with_capacity(code.len());
let mut i = 0;
while i < code.len() {
let rest = &code[i..];
let starts_decl = ["root", "theme", "host", "data-mode", "data-theme"]
.iter()
.any(|kw| {
let pfx_len = match *kw {
"root" | "host" => 1 + kw.len(), // :root, :host
"theme" => 1 + kw.len(), // @theme
_ => 1 + kw.len(), // [data-mode, [data-theme
};
if rest.len() < pfx_len {
return false;
}
let head = &rest[..pfx_len];
head.eq_ignore_ascii_case(":root")
|| head.eq_ignore_ascii_case("@theme")
|| head.eq_ignore_ascii_case(":host")
|| head.eq_ignore_ascii_case("[data-mode")
|| head.eq_ignore_ascii_case("[data-theme")
});
if starts_decl && let Some(open_rel) = rest.find('{') {
let mut depth = 0usize;
let mut end_idx = None;
for (rel, ch) in rest[open_rel..].char_indices() {
if ch == '{' {
depth += 1;
} else if ch == '}' {
depth = depth.saturating_sub(1);
if depth == 0 {
end_idx = Some(i + open_rel + rel + ch.len_utf8());
break;
}
}
}
if let Some(next_i) = end_idx {
i = next_i;
continue;
}
}
if let Some(ch) = rest.chars().next() {
out.push(ch);
i += ch.len_utf8();
} else {
break;
}
}
out
}
fn web_var_referenced(code: &str, var_name: &str) -> bool {
let outside = strip_token_declaration_blocks(code);
let css_var = format!("--{var_name}");
let tw_color_var = format!("--color-{var_name}");
outside.contains(&css_var) || outside.contains(&tw_color_var)
}
fn count_web_absolute(code: &str) -> usize {
let lower = code.to_ascii_lowercase();
let mut count = 0usize;
// Count CSS `position: absolute` / `position:absolute`
let mut rest = lower.as_str();
while let Some(pos) = rest.find("position") {
let after = &rest[pos + "position".len()..];
let after_trim = after.trim_start();
if let Some(after_colon) = after_trim.strip_prefix(':') {
let val = after_colon.trim_start();
if val.starts_with("absolute") {
let next_ch = val.as_bytes().get("absolute".len()).copied();
if !next_ch.is_some_and(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_') {
count += 1;
}
}
}
rest = after;
}
// Count standalone Tailwind `absolute` class tokens inside class/className strings
for attr in ["classname=", "class="] {
let mut scan = lower.as_str();
while let Some(idx) = scan.find(attr) {
let after_attr = &scan[idx + attr.len()..].trim_start();
let quote = after_attr.as_bytes().first().copied();
if let Some(q) = quote
&& (q == b'"' || q == b'\'' || q == b'`')
{
let inner = &after_attr[1..];
if let Some(end) = inner.find(q as char) {
let class_str = &inner[..end];
for token in class_str.split_whitespace() {
let base = token.rsplit(':').next().unwrap_or(token);
if base == "absolute" {
count += 1;
}
}
scan = &inner[end + 1..];
continue;
}
}
scan = after_attr;
}
}
count
}
fn px_num(v: f64) -> String {
let r = (v * 100.0).round() / 100.0;
if (r - r.trunc()).abs() < 1e-9 {
format!("{}", r as i64)
} else {
format!("{r}")
}
}
fn tw_gap_scale(px: f64) -> Option<&'static str> {
let r = (px * 100.0).round() / 100.0;
match (r * 10.0).round() as i64 {
0 => Some("0"),
20 => Some("0.5"),
40 => Some("1"),
60 => Some("1.5"),
80 => Some("2"),
100 => Some("2.5"),
120 => Some("3"),
140 => Some("3.5"),
160 => Some("4"),
200 => Some("5"),
240 => Some("6"),
280 => Some("7"),
320 => Some("8"),
360 => Some("9"),
400 => Some("10"),
440 => Some("11"),
480 => Some("12"),
560 => Some("14"),
640 => Some("16"),
_ => None,
}
}
fn web_gap_represented(doc: &Document, code: &str, gap: f64) -> bool {
let num = px_num(gap);
let px_str = format!("{num}px");
if code.contains(&px_str) {
return true;
}
if let Some(scale) = tw_gap_scale(gap) {
for prefix in ["gap-", "gap-x-", "gap-y-", "space-x-", "space-y-"] {
let pat = format!("{prefix}{scale}");
if code.contains(&pat) {
return true;
}
}
}
let rem = gap / 16.0;
let rem_str = format!("{}rem", px_num(rem));
if code.contains(&rem_str) {
return true;
}
// Check document spacing tokens matching `{num}px`
for (k, v) in &doc.tokens {
if v.trim() == px_str && code.contains(&format!("--{k}")) {
return true;
}
}
if code.contains("var(--gap") || code.contains("var(--space") || code.contains("var(--spacing")
{
return true;
}
false
}
fn egui_gap_represented(code: &str, gap: f64) -> bool {
let num = px_num(gap);
code.contains("item_spacing")
|| code.contains(&format!("add_space({num})"))
|| code.contains(&format!("add_space({num}.0)"))
}
fn to_pascal_case(s: &str) -> String {
s.split(|c: char| !c.is_ascii_alphanumeric())
.filter(|w| !w.is_empty())
.map(|w| {
let mut chars = w.chars();
match chars.next() {
Some(first) => {
let mut out = String::new();
out.push(first.to_ascii_uppercase());
out.push_str(&chars.as_str().to_ascii_lowercase());
out
}
None => String::new(),
}
})
.collect()
}
fn to_kebab_case(s: &str) -> String {
let mut words: Vec<String> = Vec::new();
let mut cur = String::new();
for c in s.chars() {
if !c.is_ascii_alphanumeric() {
if !cur.is_empty() {
words.push(std::mem::take(&mut cur));
}
} else if c.is_ascii_uppercase() && !cur.is_empty() {
words.push(std::mem::take(&mut cur));
cur.push(c.to_ascii_lowercase());
} else {
cur.push(c.to_ascii_lowercase());
}
}
if !cur.is_empty() {
words.push(cur);
}
words.join("-")
}
fn component_referenced(code: &str, comp_name: &str, is_egui: bool) -> bool {
let trimmed = comp_name.trim();
if trimmed.is_empty() {
return true;
}
let pascal = to_pascal_case(trimmed);
let kebab = to_kebab_case(trimmed);
let snake = kebab.replace('-', "_");
if code.contains(trimmed)
|| (!pascal.is_empty() && code.contains(&pascal))
|| (!kebab.is_empty() && code.contains(&kebab))
{
return true;
}
if is_egui && !snake.is_empty() && code.to_ascii_lowercase().contains(&snake) {
return true;
}
false
}
pub fn verify_code(
doc: &Document,
root: NodeId,
target: &str,
code: &str,
) -> Result<EnforcementReport, String> {
let Some(root_node) = doc.get(root) else {
return Err(format!("missing node {root}"));
};
let norm_target = normalize_target(target)?;
let is_egui = norm_target == "egui";
let visible_ids = visible_subtree(doc, root);
let mut violations: Vec<Violation> = Vec::new();
let outside_decls = strip_token_declaration_blocks(code);
let outside_lower = outside_decls.to_ascii_lowercase();
// 1 & 2 & 5 & 6: Walk visible nodes
let mut out_of_flow_nodes = 0usize;
let mut has_any_auto_layout = false;
let mut needs_horizontal = false;
let mut needs_vertical = false;
for &id in &visible_ids {
let Some(n) = doc.get(id) else { continue };
if id != root {
let parent_has_layout = n
.parent
.and_then(|p| doc.get(p))
.is_some_and(|p| p.layout.is_some());
if n.layout_child.absolute || !parent_has_layout {
out_of_flow_nodes += 1;
}
}
if let Some(l) = &n.layout {
has_any_auto_layout = true;
if !n.children.is_empty() {
match l.direction {
Direction::Horizontal => needs_horizontal = true,
Direction::Vertical => needs_vertical = true,
}
}
}
// Rule 1: missing-text (error)
if let NodeKind::Text { text, .. } = &n.kind {
let trimmed = text.trim();
if !trimmed.is_empty() {
for line in trimmed.lines() {
let line = line.trim();
if !line.is_empty() && !text_snippet_present(code, line) {
violations.push(Violation {
rule: "missing-text",
severity: "error",
node_id: id.0,
node_name: n.name.clone(),
expected: line.to_string(),
found: "missing in generated code".into(),
fix: format!("Include exact text {:?} from layer {:?}", line, n.name),
});
}
}
}
}
// Rule 2: hardcoded-color (error)
for fill in &n.style.fills {
if let Paint::Solid {
color,
var: Some(var_name),
} = fill
{
let hex = color.to_hex().to_ascii_lowercase();
let upper_var = var_name.to_ascii_uppercase().replace('-', "_");
if is_egui {
let has_token = code.contains(&upper_var) || code.contains(var_name.as_str());
if !has_token {
let rgb_tuple = format!("{}, {}, {}", color.r, color.g, color.b);
let found = if code.contains(&rgb_tuple) || outside_lower.contains(&hex) {
format!("raw color ({rgb_tuple}) / {hex}")
} else {
format!("missing token {upper_var}")
};
violations.push(Violation {
rule: "hardcoded-color",
severity: "error",
node_id: id.0,
node_name: n.name.clone(),
expected: format!("tokens::{upper_var}"),
found,
fix: format!(
"Use design token constant `tokens::{upper_var}` instead of raw color {hex}"
),
});
}
} else {
let has_var = web_var_referenced(code, var_name);
let has_raw_hex = outside_lower.contains(&hex);
if !has_var {
violations.push(Violation {
rule: "hardcoded-color",
severity: "error",
node_id: id.0,
node_name: n.name.clone(),
expected: format!("var(--{var_name})"),
found: if has_raw_hex {
hex.clone()
} else {
format!("missing --{var_name}")
},
fix: format!(
"Use `var(--{var_name})` (or `bg-[var(--{var_name})]` / `text-[var(--{var_name})]`) instead of hardcoded {hex}"
),
});
}
}
}
}
// Rule 5: missing-component (warning)
if let Some(inst) = n.instance.as_ref()
&& let Some(main_comp) = doc.get(inst.main)
&& main_comp.component
&& !component_referenced(code, &main_comp.name, is_egui)
{
let pascal = to_pascal_case(&main_comp.name);
let kebab = to_kebab_case(&main_comp.name);
violations.push(Violation {
rule: "missing-component",
severity: "warning",
node_id: id.0,
node_name: n.name.clone(),
expected: main_comp.name.clone(),
found: "inlined / unnamed markup".into(),
fix: if is_egui {
format!(
"Reference component `{}` (e.g. `{}` widget/helper)",
main_comp.name,
kebab.replace('-', "_")
)
} else {
format!(
"Instantiate component `<{pascal} />`, `<app-{kebab}>`, or `data-component=\"{}\"`",
main_comp.name
)
},
});
}
// Rule 6: spacing-drift (warning)
if let Some(l) = &n.layout
&& !n.children.is_empty()
&& l.gap > 0.0
&& !l.gap_auto
{
let ok = if is_egui {
egui_gap_represented(code, l.gap)
} else {
web_gap_represented(doc, code, l.gap)
};
if !ok {
let gap_str = px_num(l.gap);
violations.push(Violation {
rule: "spacing-drift",
severity: "warning",
node_id: id.0,
node_name: n.name.clone(),
expected: format!("{gap_str}px gap"),
found: "gap not found in code".into(),
fix: if is_egui {
format!(
"Set `item_spacing` or call `ui.add_space({gap_str}.0)` on container {:?}",
n.name
)
} else {
let tw_hint = tw_gap_scale(l.gap)
.map(|s| format!("`gap-{s}` or "))
.unwrap_or_default();
format!(
"Apply {tw_hint}`gap: {gap_str}px` on container {:?}",
n.name
)
},
});
}
}
}
// Rule 3: forbidden-absolute (error)
if !is_egui {
let code_abs = count_web_absolute(code);
if code_abs > out_of_flow_nodes {
violations.push(Violation {
rule: "forbidden-absolute",
severity: "error",
node_id: root.0,
node_name: root_node.name.clone(),
expected: format!("at most {out_of_flow_nodes} absolute node(s)"),
found: format!("{code_abs} absolute positioning occurrence(s)"),
fix: "Flow children in auto-layout frames must use Flexbox/Grid, never position: absolute".into(),
});
}
}
// Rule 4: missing-layout (error)
if has_any_auto_layout {
if is_egui {
let has_grid = code.contains("egui::Grid") || code.contains("Grid::new");
let has_horiz = code.contains("ui.horizontal") || code.contains(".horizontal(");
let has_vert = code.contains("ui.vertical") || code.contains(".vertical(");
let any_layout = has_grid || has_horiz || has_vert;
let dir_ok = has_grid
|| ((!needs_horizontal || has_horiz)
&& (!needs_vertical || has_vert)
&& any_layout);
if !dir_ok {
violations.push(Violation {
rule: "missing-layout",
severity: "error",
node_id: root.0,
node_name: root_node.name.clone(),
expected: if needs_horizontal && needs_vertical {
"ui.horizontal + ui.vertical (or egui::Grid)".into()
} else if needs_horizontal {
"ui.horizontal (or egui::Grid)".into()
} else {
"ui.vertical (or egui::Grid)".into()
},
found: "no matching egui layout container".into(),
fix: "Use `ui.horizontal(|ui| ...)` / `ui.vertical(|ui| ...)` or `egui::Grid` matching the frame's auto-layout direction".into(),
});
}
} else {
let lower = code.to_ascii_lowercase();
let has_flex_or_grid = lower.contains("flex")
|| lower.contains("display: flex")
|| lower.contains("display:flex")
|| lower.contains("grid");
if !has_flex_or_grid {
violations.push(Violation {
rule: "missing-layout",
severity: "error",
node_id: root.0,
node_name: root_node.name.clone(),
expected: "flex or grid layout".into(),
found: "no flex/grid layout found".into(),
fix: "Use Flexbox (`flex`, `display: flex`) or CSS Grid (`grid`, `display: grid`) for auto-layout frames".into(),
});
}
}
}
let errors = violations.iter().filter(|v| v.severity == "error").count();
let warnings = violations
.iter()
.filter(|v| v.severity == "warning")
.count();
let score = 100u32.saturating_sub((errors * 15 + warnings * 5) as u32);
let passed = errors == 0;
Ok(EnforcementReport {
passed,
score,
target: norm_target.to_string(),
errors,
warnings,
checked_nodes: visible_ids.len(),
violations,
})
}
pub fn to_json(r: &EnforcementReport) -> serde_json::Value {
serde_json::to_value(r).unwrap_or(serde_json::Value::Null)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::geom::Rect;
use crate::model::{
Align, AutoLayout, Color, Direction, InstanceLink, Node, NodeKind, Paint, TextAlign,
};
fn sample_doc() -> (Document, NodeId) {
let mut doc = Document::default();
let surface = Color::rgb(0x1e, 0x1e, 0x2e);
doc.variables.insert("surface".into(), surface);
let root = doc.alloc_id();
let mut frame = Node::new(
root,
NodeKind::Frame { clip: true },
Rect::new(0.0, 0.0, 320.0, 120.0),
);
frame.name = "Action Card".into();
frame.layout = Some(AutoLayout {
direction: Direction::Vertical,
wrap: false,
gap: 16.0,
gap_auto: false,
cross_gap: 0.0,
padding: [16.0; 4],
align_main: Align::Center,
align_cross: Align::Center,
});
frame.style.fills = vec![Paint::solid_var(surface, "surface")];
let txt_id = doc.alloc_id();
let mut txt = Node::new(
txt_id,
NodeKind::Text {
text: "Continue".into(),
font_size: 16.0,
auto_width: true,
align: TextAlign::Center,
},
Rect::new(0.0, 0.0, 80.0, 20.0),
);
txt.name = "CTA Label".into();
txt.parent = Some(root);
txt.style.fills = vec![Paint::solid(Color::WHITE)];
frame.children.push(txt_id);
doc.nodes.insert(root, frame);
doc.nodes.insert(txt_id, txt);
doc.root.push(root);
(doc, root)
}
#[test]
fn valid_tailwind_angular_and_egui_pass() {
let (doc, root) = sample_doc();
let tw_code = r#"
<div className="flex flex-col gap-4 p-4 bg-[var(--surface)]">
<span className="text-base text-white">Continue</span>
</div>
"#;
let tw_report = verify_code(&doc, root, "tailwind", tw_code).unwrap();
assert!(tw_report.passed, "{tw_report:?}");
assert_eq!(tw_report.errors, 0);
assert!(tw_report.score >= 95);
let ng_code = r#"
@Component({
selector: 'app-action-card',
template: `<div class="card"><span>Continue</span></div>`,
styles: [`
:host { --surface: #1e1e2e; }
.card { display: flex; flex-direction: column; gap: 16px; background: var(--surface); }
`]
})
export class ActionCardComponent {}
"#;
let ng_report = verify_code(&doc, root, "angular", ng_code).unwrap();
assert!(ng_report.passed, "{ng_report:?}");
assert_eq!(ng_report.errors, 0);
assert!(ng_report.score >= 95);
let egui_code = r#"
egui::Frame::none().fill(tokens::SURFACE).show(ui, |ui| {
ui.spacing_mut().item_spacing = egui::vec2(16.0, 16.0);
ui.vertical(|ui| {
ui.label("Continue");
});
});
"#;
let egui_report = verify_code(&doc, root, "egui", egui_code).unwrap();
assert!(egui_report.passed, "{egui_report:?}");
assert_eq!(egui_report.errors, 0);
assert!(egui_report.score >= 95);
}
#[test]
fn broken_code_reports_missing_text_hardcoded_color_and_forbidden_absolute() {
let (doc, root) = sample_doc();
let bad_code = r#"
<div className="flex flex-col gap-4 bg-[#1e1e2e]">
<span className="absolute left-0 top-0">Wrong Label</span>
</div>
"#;
let report = verify_code(&doc, root, "react", bad_code).unwrap();
assert!(!report.passed);
assert!(report.errors >= 3);
let rules: Vec<&str> = report.violations.iter().map(|v| v.rule).collect();
assert!(rules.contains(&"missing-text"), "{rules:?}");
assert!(rules.contains(&"hardcoded-color"), "{rules:?}");
assert!(rules.contains(&"forbidden-absolute"), "{rules:?}");
let json_val = to_json(&report);
assert_eq!(json_val["passed"], false);
}
#[test]
fn detects_missing_layout_component_and_spacing_drift() {
let (mut doc, root) = sample_doc();
let comp_id = doc.alloc_id();
let mut comp = Node::new(
comp_id,
NodeKind::Frame { clip: true },
Rect::new(0.0, 0.0, 100.0, 40.0),
);
comp.name = "PrimaryButton".into();
comp.component = true;
doc.nodes.insert(comp_id, comp);
doc.nodes.get_mut(&root).unwrap().instance = Some(InstanceLink {
main: comp_id,
overrides: Vec::new(),
});
let drift_code = r#"
<div className="bg-[var(--surface)]">
<span>Continue</span>
</div>
"#;
let report = verify_code(&doc, root, "tailwind", drift_code).unwrap();
assert!(!report.passed);
let rules: Vec<&str> = report.violations.iter().map(|v| v.rule).collect();
assert!(rules.contains(&"missing-layout"), "{rules:?}");
assert!(rules.contains(&"missing-component"), "{rules:?}");
assert!(rules.contains(&"spacing-drift"), "{rules:?}");
}
}
+669 -127
View File
File diff suppressed because it is too large Load Diff
+147
View File
@@ -248,6 +248,120 @@ pub fn lint(doc: &Document, roots: &[NodeId], scale: &[f64]) -> Vec<Issue> {
out
}
fn snap4(v: f64) -> f64 {
if v <= 0.0 {
0.0
} else {
((v / 4.0).round() * 4.0).max(4.0)
}
}
/// Apply safe automatic fixes (`spacing-grid`, `hard-coded-color`, `text-size`, `type-scale`,
/// `touch-target`) to `id` (or all non-instance visible descendants under `roots`).
/// Returns the number of nodes modified.
pub fn autofix(tx: &mut crate::history::Tx, roots: &[NodeId], scale: &[f64]) -> usize {
let mut ids: Vec<NodeId> = Vec::new();
for r in roots {
ids.extend(tx.doc().subtree(*r));
}
let mut fixed = 0;
for id in ids {
if autofix_node(tx, id, scale) {
fixed += 1;
}
}
fixed
}
/// Apply safe automatic fixes to a single node `id`. Returns true if anything changed.
pub fn autofix_node(tx: &mut crate::history::Tx, id: NodeId, scale: &[f64]) -> bool {
let Some(n) = tx.doc().get(id).cloned() else {
return false;
};
if !n.visible || n.instance.is_some() {
return false;
}
let vars = tx.doc().variables.clone();
let mut changed = false;
// 1. Snap off-grid auto-layout spacing/padding to 4px multiples.
let mut new_layout = n.layout;
if let Some(l) = &mut new_layout {
if off_grid(l.gap) {
l.gap = snap4(l.gap);
changed = true;
}
if off_grid(l.cross_gap) {
l.cross_gap = snap4(l.cross_gap);
changed = true;
}
for p in &mut l.padding {
if off_grid(*p) {
*p = snap4(*p);
changed = true;
}
}
}
// 2. Relink hard-coded solid fills to matching document color variables.
let mut new_fills = n.style.fills.clone();
for fill in &mut new_fills {
if let Paint::Solid { color, var } = fill
&& var.is_none()
&& color.a == 255
&& let Some((name, _)) = vars.iter().find(|(_, v)| **v == *color)
{
*var = Some(name.clone());
changed = true;
}
}
// 3. Snap text size to >= 12px and nearest type-scale value.
let mut new_kind = n.kind.clone();
if let crate::model::NodeKind::Text { font_size, .. } = &mut new_kind {
let target = if *font_size < 12.0 {
scale.iter().copied().find(|s| *s >= 12.0).unwrap_or(12.0)
} else if !scale.is_empty() && !scale.iter().any(|s| (s - *font_size).abs() < 0.01) {
scale
.iter()
.min_by(|a, b| (*a - *font_size).abs().total_cmp(&(*b - *font_size).abs()))
.copied()
.unwrap_or(*font_size)
} else {
*font_size
};
if (target - *font_size).abs() > 0.01 {
*font_size = target;
changed = true;
}
}
// 4. Touch target height >= 44px for buttons/tabs.
let lower = n.name.to_lowercase();
let tap =
lower.starts_with("button") || lower.starts_with("tab /") || lower.starts_with("tab bar /");
let mut new_h = n.rect.h;
if tap && n.is_frame() && n.rect.h < 44.0 && n.rect.h > 0.0 && !lower.contains("icon") {
let extra = ((44.0 - n.rect.h) / 2.0).ceil();
if let Some(l) = &mut new_layout
&& n.layout_child.sizing[1] == crate::model::Sizing::Hug
{
l.padding[0] = snap4(l.padding[0] + extra);
l.padding[2] = snap4(l.padding[2] + extra);
}
new_h = 44.0;
changed = true;
}
if changed && let Some(m) = tx.node_mut(id) {
m.layout = new_layout;
m.style.fills = new_fills;
m.kind = new_kind;
m.rect.h = new_h;
}
changed
}
fn join(v: &[f64]) -> String {
v.iter()
.map(|s| format!("{s}"))
@@ -268,4 +382,37 @@ mod tests {
assert!(off_grid(13.0) && !off_grid(12.0) && !off_grid(0.0));
assert!(is_default_name("Frame 12") && !is_default_name("Task Row"));
}
#[test]
fn autofix_snaps_grid_and_links_variables() {
let mut doc = Document::default();
let accent = Color::rgb(0x3b, 0x82, 0xf6);
doc.variables.insert("accent".into(), accent);
let id = doc.alloc_id();
let mut n = Node::new(
id,
crate::model::NodeKind::Frame { clip: false },
crate::geom::Rect::new(0.0, 0.0, 120.0, 36.0),
);
n.name = "Button / Primary".into();
n.style.fills = vec![Paint::solid(accent)];
n.layout = Some(crate::model::AutoLayout {
gap: 10.0,
padding: [6.0, 14.0, 6.0, 14.0],
..Default::default()
});
doc.nodes.insert(id, n);
doc.root.push(id);
let mut rec = crate::history::Recording::new("autofix", &doc);
let mut tx = crate::history::Tx::new(&mut doc, &mut rec);
let count = autofix(&mut tx, &[id], &DEFAULT_SCALE);
assert_eq!(count, 1);
let fixed = doc.get(id).unwrap();
assert_eq!(fixed.style.fills[0].var(), Some("accent"));
let l = fixed.layout.unwrap();
assert_eq!(l.gap, 12.0);
assert_eq!(l.padding, [8.0, 16.0, 8.0, 16.0]);
assert_eq!(fixed.rect.h, 44.0);
}
}
+5
View File
@@ -12,7 +12,10 @@
//! * [`relay_proto`]: JSON frames between the relay and a browser tab; [`relay`]: the
//! relay server itself (native only).
pub mod codegen;
pub mod css;
pub mod diff;
pub mod enforcer;
pub mod exec;
pub mod html;
pub mod lint;
@@ -21,6 +24,8 @@ pub mod relay;
pub mod relay_proto;
#[cfg(not(target_arch = "wasm32"))]
pub mod server;
#[cfg(not(target_arch = "wasm32"))]
pub mod store;
pub mod tools;
/// Paper uses 29979; papercraft listens next door so both apps can run side by side.
+23 -6
View File
@@ -142,7 +142,9 @@ struct Link {
impl Link {
fn new(shared: Arc<Shared>, ct: CancellationToken) -> Link {
let dispatch = dispatch(shared.clone());
// Served under a public hostname; the URL token is the credential, not the Host header.
let config = StreamableHttpServerConfig::default()
.disable_allowed_hosts()
.enforce_origin_validation()
.with_max_request_body_bytes(MAX_MCP_BODY_BYTES)
.with_cancellation_token(ct);
@@ -409,13 +411,17 @@ async fn serve_static(State(relay): State<Arc<Relay>>, uri: Uri) -> Response {
}
}
fn router(relay: Arc<Relay>) -> axum::Router {
axum::Router::new()
fn router(relay: Arc<Relay>, store: Option<Arc<super::store::Store>>) -> axum::Router {
let app = axum::Router::new()
.route("/tab/{token}", any(tab_socket))
.route("/s/{token}/mcp", any(mcp_endpoint))
.fallback(serve_static)
.layer(axum::middleware::from_fn(super::server::fix_accept))
.with_state(relay)
.with_state(relay);
match store {
Some(store) => app.merge(super::store::router(store)),
None => app,
}
}
/// A running relay; stops when dropped.
@@ -455,6 +461,16 @@ pub fn spawn(
listen: SocketAddr,
static_dir: Option<PathBuf>,
allow_origin: Option<String>,
) -> Result<Running, String> {
spawn_with_data(listen, static_dir, allow_origin, None)
}
/// [`spawn`], plus `data`: where shared projects are kept (the `/p/` routes are off without it).
pub fn spawn_with_data(
listen: SocketAddr,
static_dir: Option<PathBuf>,
allow_origin: Option<String>,
data: Option<super::store::Data>,
) -> Result<Running, String> {
let listener = std::net::TcpListener::bind(listen)
.map_err(|e| format!("cannot listen on {listen}: {e}"))?;
@@ -485,9 +501,10 @@ pub fn spawn(
static_dir,
ct: thread_ct.child_token(),
});
if let Err(e) = axum::serve(listener, router(relay))
.with_graceful_shutdown(thread_ct.cancelled_owned())
.await
if let Err(e) =
axum::serve(listener, router(relay, data.map(super::store::Store::new)))
.with_graceful_shutdown(thread_ct.cancelled_owned())
.await
{
log::error!("relay: server stopped: {e}");
}
+374
View File
@@ -0,0 +1,374 @@
//! Shared projects: the relay keeps a project's files on disk so several tabs (and devices) can
//! open and edit the same project.
//!
//! ```text
//! GET /p/{token}/files [{"path","size","mtime"}]
//! GET /p/{token}/f/{path} file bytes
//! PUT /p/{token}/f/{path} write (creates the project); answers the file's mtime
//! DELETE /p/{token}/f/{path} remove
//! GET /p/{token}/ws?client=<id> pushes {"op","path","rev","client"} for other clients' changes
//! ```
//!
//! The token is the only credential, like the agent link: anyone holding it can read and edit.
//! A bad token or path is a plain 404.
//!
//! With `Data::library` the relay is a personal library instead: every project in the data
//! directory is listed, so a device needs no link to find them (the network is the boundary):
//!
//! ```text
//! GET /library JSON array of every project's token
//! DELETE /p/{token} move the project to `.trash/` (kept on disk); later `files` answer 410
//! ```
use super::relay_proto;
use axum::body::Bytes;
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::{DefaultBodyLimit, Path, Query, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use serde::Serialize;
use std::collections::HashMap;
use std::path::{Component, Path as FsPath, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use tokio::sync::broadcast;
/// Biggest file a tab may upload (screenshots and assets are big).
const MAX_FILE_BYTES: usize = 64 << 20;
/// Changes buffered per project for a slow socket; a lagging tab resyncs from `/files`.
const EVENT_BACKLOG: usize = 256;
/// Header and query name a tab uses to identify itself, so it can skip its own echo.
const CLIENT_HEADER: &str = "x-client";
/// One change, as pushed to the other tabs.
#[derive(Serialize, Clone)]
struct Event {
op: &'static str,
path: String,
rev: u64,
client: String,
}
struct Project {
rev: AtomicU64,
tx: broadcast::Sender<Arc<Event>>,
}
/// Where deleted library projects go, inside the data directory.
const TRASH: &str = ".trash";
/// The `--data` directory, and whether it is a library (`--library`).
#[derive(Clone)]
pub struct Data {
pub dir: PathBuf,
pub library: bool,
}
/// The `--data` directory and the live state of the projects in it.
pub struct Store {
root: PathBuf,
library: bool,
projects: Mutex<HashMap<String, Arc<Project>>>,
}
impl Store {
pub fn new(data: Data) -> Arc<Store> {
Arc::new(Store {
root: data.dir,
library: data.library,
projects: Mutex::new(HashMap::new()),
})
}
fn project(&self, token: &str) -> Arc<Project> {
let mut map = self.projects.lock().unwrap_or_else(PoisonError::into_inner);
map.entry(token.to_string())
.or_insert_with(|| {
Arc::new(Project {
rev: AtomicU64::new(0),
tx: broadcast::channel(EVENT_BACKLOG).0,
})
})
.clone()
}
/// True when `token` was deleted: its project sits in the trash and is gone from the root.
fn was_deleted(&self, token: &str) -> bool {
let prefix = format!("{token}-");
std::fs::read_dir(self.root.join(TRASH)).is_ok_and(|mut d| {
d.any(|e| e.is_ok_and(|e| e.file_name().to_string_lossy().starts_with(&prefix)))
})
}
fn publish(&self, token: &str, op: &'static str, path: String, client: String) {
let project = self.project(token);
let rev = project.rev.fetch_add(1, Ordering::Relaxed) + 1;
// No receiver is fine: nobody else has the project open.
let _ = project.tx.send(Arc::new(Event {
op,
path,
rev,
client,
}));
}
}
/// `files/` relative path, or `None` for anything that could leave the project directory.
fn clean(path: &str) -> Option<PathBuf> {
let p = FsPath::new(path);
if path.is_empty() || p.components().any(|c| !matches!(c, Component::Normal(_))) {
return None;
}
Some(p.to_path_buf())
}
fn not_found() -> Response {
(StatusCode::NOT_FOUND, "not found").into_response()
}
/// The project directory and cleaned file path for a request, or the 404 to answer.
fn target(store: &Store, token: &str, path: &str) -> Result<(PathBuf, PathBuf), Response> {
if !relay_proto::valid_token(token) {
return Err(not_found());
}
let rel = clean(path).ok_or_else(not_found)?;
let dir = store.root.join(token);
Ok((dir.join(&rel), rel))
}
fn client_of(headers: &HeaderMap) -> String {
headers
.get(CLIENT_HEADER)
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_string()
}
/// Last-modified time in ns; clients compare it for equality only, to spot remote changes.
fn mtime_ns(meta: &std::fs::Metadata) -> u64 {
meta.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map_or(0, |d| d.as_nanos() as u64)
}
fn walk(dir: &FsPath, base: &FsPath, out: &mut Vec<serde_json::Value>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for e in entries.flatten() {
let p = e.path();
if p.is_dir() {
walk(&p, base, out);
} else if let (Ok(rel), Ok(meta)) = (p.strip_prefix(base), e.metadata()) {
// Temp files of an upload in flight are not part of the project.
let rel = rel.to_string_lossy().replace('\\', "/");
if !rel.ends_with(".part") {
out.push(serde_json::json!({ "path": rel, "size": meta.len(), "mtime": mtime_ns(&meta) }));
}
}
}
}
async fn list(State(store): State<Arc<Store>>, Path(token): Path<String>) -> Response {
if !relay_proto::valid_token(&token) {
return not_found();
}
let dir = store.root.join(&token);
if !dir.is_dir() {
if store.was_deleted(&token) {
return StatusCode::GONE.into_response();
}
return not_found();
}
let files = tokio::task::spawn_blocking(move || {
let mut out = Vec::new();
walk(&dir, &dir, &mut out);
out
})
.await
.unwrap_or_default();
axum::Json(files).into_response()
}
/// Every project's token (library mode).
async fn library(State(store): State<Arc<Store>>) -> Response {
let mut tokens: Vec<String> = std::fs::read_dir(&store.root)
.into_iter()
.flatten()
.flatten()
.filter(|e| e.path().is_dir())
.filter_map(|e| e.file_name().into_string().ok())
.filter(|name| relay_proto::valid_token(name))
.collect();
tokens.sort();
axum::Json(tokens).into_response()
}
/// Delete a project: it moves to `.trash/<token>-<ms>` so nothing is lost on the server.
async fn delete_project(State(store): State<Arc<Store>>, Path(token): Path<String>) -> Response {
if !relay_proto::valid_token(&token) {
return not_found();
}
let dir = store.root.join(&token);
if !dir.is_dir() {
return not_found();
}
let ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_millis());
let trash = store.root.join(TRASH);
let moved = tokio::fs::create_dir_all(&trash).await.is_ok()
&& tokio::fs::rename(&dir, trash.join(format!("{token}-{ms}")))
.await
.is_ok();
if !moved {
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
store
.projects
.lock()
.unwrap_or_else(PoisonError::into_inner)
.remove(&token);
StatusCode::NO_CONTENT.into_response()
}
async fn read(
State(store): State<Arc<Store>>,
Path((token, path)): Path<(String, String)>,
) -> Response {
let (file, _) = match target(&store, &token, &path) {
Ok(t) => t,
Err(r) => return r,
};
match tokio::fs::read(&file).await {
Ok(bytes) => bytes.into_response(),
Err(_) => not_found(),
}
}
async fn write(
State(store): State<Arc<Store>>,
Path((token, path)): Path<(String, String)>,
headers: HeaderMap,
body: Bytes,
) -> Response {
let (file, rel) = match target(&store, &token, &path) {
Ok(t) => t,
Err(r) => return r,
};
if let Some(parent) = file.parent()
&& tokio::fs::create_dir_all(parent).await.is_err()
{
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
// Write beside, then rename, so a reader never sees a half-written file.
let tmp = file.with_extension("part");
if tokio::fs::write(&tmp, &body).await.is_err() || tokio::fs::rename(&tmp, &file).await.is_err()
{
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
store.publish(
&token,
"put",
rel.to_string_lossy().replace('\\', "/"),
client_of(&headers),
);
// The new stamp, so the writer does not mistake its own upload for someone else's change.
let stamp = tokio::fs::metadata(&file).await.map_or(0, |m| mtime_ns(&m));
stamp.to_string().into_response()
}
async fn remove(
State(store): State<Arc<Store>>,
Path((token, path)): Path<(String, String)>,
headers: HeaderMap,
) -> Response {
let (file, rel) = match target(&store, &token, &path) {
Ok(t) => t,
Err(r) => return r,
};
if tokio::fs::remove_file(&file).await.is_err() {
return not_found();
}
store.publish(
&token,
"del",
rel.to_string_lossy().replace('\\', "/"),
client_of(&headers),
);
StatusCode::NO_CONTENT.into_response()
}
async fn socket(
State(store): State<Arc<Store>>,
Path(token): Path<String>,
Query(q): Query<HashMap<String, String>>,
upgrade: WebSocketUpgrade,
) -> Response {
if !relay_proto::valid_token(&token) {
return not_found();
}
let me = q.get("client").cloned().unwrap_or_default();
let rx = store.project(&token).tx.subscribe();
upgrade
.on_upgrade(move |s| serve(s, rx, me))
.into_response()
}
/// Push other clients' changes until the socket closes.
async fn serve(mut socket: WebSocket, mut rx: broadcast::Receiver<Arc<Event>>, me: String) {
loop {
tokio::select! {
ev = rx.recv() => match ev {
Ok(ev) if ev.client != me || me.is_empty() => {
let Ok(json) = serde_json::to_string(&*ev) else { continue };
if socket.send(Message::Text(json.into())).await.is_err() {
break;
}
}
Ok(_) => {}
// Missed events: tell the tab to resync everything.
Err(broadcast::error::RecvError::Lagged(_)) => {
let resync = r#"{"op":"resync"}"#;
if socket.send(Message::Text(resync.into())).await.is_err() {
break;
}
}
Err(_) => break,
},
m = socket.recv() => match m {
Some(Ok(Message::Close(_))) | Some(Err(_)) | None => break,
Some(Ok(_)) => {}
},
}
}
}
pub fn router(store: Arc<Store>) -> axum::Router {
let mut app = axum::Router::new()
.route("/p/{token}/files", get(list))
.route("/p/{token}/f/{*path}", get(read).put(write).delete(remove))
.route("/p/{token}/ws", get(socket));
if store.library {
app = app
.route("/library", get(library))
.route("/p/{token}", axum::routing::delete(delete_project));
}
app.layer(DefaultBodyLimit::max(MAX_FILE_BYTES))
.with_state(store)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn paths_stay_inside_the_project() {
assert_eq!(clean("pages/a.json"), Some(PathBuf::from("pages/a.json")));
for bad in ["", "../x", "a/../../x", "/etc/passwd", "./x"] {
assert_eq!(clean(bad), None, "{bad:?}");
}
}
}
+124 -37
View File
@@ -187,6 +187,7 @@ pub fn definitions() -> Vec<Value> {
json!({
"nodeId": node_id("The node to capture; omit for the whole page."),
"scale": {"type": "number", "description": "Render scale: 1 (default) or 2."},
"out": {"type": "string", "description": "Optional file path to save the PNG to (also used by papercraft-cli)."},
"pageId": page_id(),
}),
&[],
@@ -194,15 +195,51 @@ pub fn definitions() -> Vec<Value> {
),
tool(
"get_jsx",
"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.",
"Export a node and its descendants to code: \"inline\" (default, React JSX with inline \
styles), \"tailwind\" (React JSX with Tailwind CSS utility classes), \"react\" (React \
TSX component), \"angular\" (Angular v17+ standalone component), \"html\" \
(self-contained <style> + HTML markup), or \"egui\" (Rust egui 0.36 code with tokens).",
json!({
"nodeId": node_id("The node to convert."),
"format": {"type": "string", "enum": ["inline-styles", "tailwind"], "description": "Only inline styles are produced; \"tailwind\" is accepted for compatibility."},
"format": {"type": "string", "enum": ["inline", "inline-styles", "tailwind", "react", "angular", "html", "egui"], "description": "Output format: \"inline\" (default), \"tailwind\", \"react\", \"angular\", \"html\", or \"egui\"."},
"pageId": page_id(),
}),
&["nodeId"],
true,
),
tool(
"get_design_spec",
"Export a deterministic AI Design-to-Code specification for a frame or subtree. \
Includes all design tokens/variables, component instances, exact auto-layout tree \
(sizing, flex/grid/egui flow, gaps, paddings, bound `--var` colors), mandatory \
enforcement rules, and reference target code. Always call this before implementing \
a design in React, Tailwind, Angular, HTML/CSS, or Rust egui instead of guessing \
from a screenshot.",
json!({
"nodeId": node_id("Root frame or node id to export."),
"target": {"type": "string", "enum": ["react", "tailwind", "angular", "html", "egui"], "description": "Target UI framework (default \"react\")."},
"format": {"type": "string", "enum": ["markdown", "json"], "description": "Output format: \"markdown\" (default) or \"json\"."},
"pageId": page_id(),
}),
&["nodeId"],
true,
),
tool(
"verify_code",
"Verify that AI-generated UI code (`react`, `tailwind`, `angular`, `html`, or `egui`) \
accurately implements a design subtree. Checks for missing text strings, hardcoded \
hex colors where `--var` or `tokens::*` must be used, forbidden `position: absolute` \
on auto-layout flow children, missing flex/grid/egui layout containers, unreferenced \
component instances, and spacing drift. Returns `{passed, score, errors, warnings, violations}`.",
json!({
"nodeId": node_id("Root frame or node id that the code implements."),
"target": {"type": "string", "enum": ["react", "tailwind", "angular", "html", "egui"], "description": "Target UI framework."},
"code": {"type": "string", "description": "The generated UI source code to verify against the design."},
"pageId": page_id(),
}),
&["nodeId", "target", "code"],
true,
),
tool(
"get_computed_styles",
"CSS styles (camelCase) of one or more nodes, as {nodeId: styles}.",
@@ -309,22 +346,22 @@ pub fn definitions() -> Vec<Value> {
- mode \"insert-children\" appends the HTML inside targetNodeId (\"root\" = the page); \
\"replace\" replaces targetNodeId with it.\n\
- Inline styles only. Flexbox (display:flex, flex-direction, gap, padding, \
align-items, justify-content, flex-grow, flex-wrap) is the layout model; block \
align-items, justify-content, align-self, flex-grow, flex-wrap) is the layout model; block \
elements stack vertically. position:absolute with left/top is supported. No margin, \
grid or tables.\n\
- 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.\n\
- Boxes: background(-color/-image: colors, linear/radial gradients, url()), border, \
border-radius, opacity, overflow:hidden, mix-blend-mode, width/height (px, %, \
fit-content).\n\
letter-spacing, text-align, text-decoration (including wavy and text-decoration-color); \
<b>/<i>/<u>/<s>/<span style> make rich text. Font family is always Inter.\n\
- Boxes: background(-color/-image: colors, linear/radial gradients, url()), border \
(and border-top/right/bottom/left), border-radius, opacity, overflow:hidden, \
mix-blend-mode, width/height (px, %, fit-content), min-width/max-width/min-height/max-height.\n\
- <img src> takes paper-asset:///abs/path, file:// URLs, absolute paths or data: URIs. \
<svg> markup is imported as vector layers.\n\
- <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; \
<x-paper-instance component=\"Name\" component-id=\"ID\" layer-name=\"...\"/> places a component instance; \
colors may be var(--name) of set_variables.\n\
- layer-name=\"...\" names layers; <x-paper-clone node-id=\"ID\" style=\"...\"/> \
clones an existing node.\n\
clones an existing node (supports cross-page `<page>:<id>`).\n\
Returns the ids of the created top-level layers and the styles that were ignored.",
json!({
"html": {"type": "string", "description": "HTML to parse into layers."},
@@ -405,10 +442,11 @@ pub fn definitions() -> Vec<Value> {
tool(
"get_feedback",
"Notes the user left on layers (the Feedback box in the inspector, shown as amber dots \
on the canvas): which layer, where it sits, what to change. Call it at the start of a \
session and after each round of edits; fix every open note, then resolve_feedback. \
get_basic_info reports `openFeedback`, and get_selection includes the selected \
layers' notes.",
on the canvas): which layer, where it sits, what to change. Omitting `pageId` queries \
all pages across the project; `openCount` and `resolvedCount` are returned alongside \
per-page counts in `get_basic_info`. Call it at the start of a session and after each \
round of edits; fix every open note, then resolve_feedback. `get_selection` also \
includes the selected layers' notes.",
json!({"nodeId": {"type": "string", "description": "Only this layer."},
"pageId": page_id(),
"includeResolved": {"type": "boolean"}}),
@@ -440,10 +478,13 @@ pub fn definitions() -> Vec<Value> {
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.",
finish_working_on_nodes and fix errors and warnings; `info` items are suggestions. \
Pass `fix: true` to automatically snap spacing/padding to 4px, relink matching color \
variables, snap text sizes, and enlarge small touch targets.",
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."},
"fix": {"type": "boolean", "description": "Apply safe automatic fixes before reporting remaining issues."},
"pageId": page_id()}),
&[],
true,
@@ -452,9 +493,10 @@ pub fn definitions() -> Vec<Value> {
"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. \
`format` css returns a `:root { --name: #hex }` block, json a design-tokens object.",
`format` css returns a `:root { --name: #hex }` block (plus `[data-theme]` rules for modes), \
json a W3C DTCG design-tokens object, and tailwind a Tailwind v4 `@theme` block.",
json!({"pageId": page_id(),
"format": {"type": "string", "enum": ["css", "json"]}}),
"format": {"type": "string", "enum": ["css", "json", "tailwind"]}}),
&[],
true,
),
@@ -503,11 +545,11 @@ pub fn definitions() -> Vec<Value> {
),
tool(
"import_components",
"Copy the main components of another page (typically a `Design System` page) onto this \
one, so you can place instances here with create_instance or \
<x-paper-instance component=\"Name\"/>. Components with the same name already here \
are skipped. Variables and tokens are already shared by every page; components are \
copies, not live links.",
"Copy the main components anywhere on `fromPageId` (including inside artboards, \
typically on a `Design System` page) onto this one, so you can place instances here \
with create_instance or <x-paper-instance component=\"Name\"/> / component-id=\"ID\". \
Components with the same name already here are skipped. Variables and tokens are \
already shared by every page; components are copies, not live links.",
json!({"fromPageId": {"type": "string"}, "pageId": page_id(),
"names": {"type": "array", "items": {"type": "string"},
"description": "Only these components (default all)."}}),
@@ -539,6 +581,25 @@ pub fn definitions() -> Vec<Value> {
&["componentId"],
false,
),
tool(
"swap_instance",
"Swap an instance root to point at a different main component on the same page, \
preserving overrides on descendants matched by name and layer type.",
json!({"nodeId": node_id("The instance root node to swap."),
"componentId": node_id("The new main component id."),
"pageId": page_id()}),
&["nodeId", "componentId"],
false,
),
tool(
"detach_instance",
"Detach one or more component instances into plain frames, breaking their link to \
the main component while keeping their current appearance and children.",
json!({"nodeIds": ids("Instance root nodes to detach."),
"pageId": page_id()}),
&["nodeIds"],
false,
),
tool(
"search_icons",
"Search installed open icon sets (Lucide, Tabler, Phosphor, Heroicons, Material). \
@@ -559,12 +620,18 @@ pub fn definitions() -> Vec<Value> {
),
tool(
"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.",
"Deep-clone nodes, including across pages (via `toPageId` or a cross-page `parentId`; \
`<x-paper-clone node-id=\"<page>:<id>\"/>` also works across pages). 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.",
json!({"nodes": {"type": "array", "items": {
"type": "object",
"properties": {"id": {"type": "string"}, "parentId": {"type": "string"}},
"properties": {
"id": {"type": "string"},
"parentId": {"type": "string"},
"toPageId": {"type": "string", "description": "Optional destination page id when copying/moving to another page."},
},
"required": ["id"],
}}}),
&["nodes"],
@@ -572,15 +639,17 @@ pub fn definitions() -> Vec<Value> {
),
tool(
"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.",
"Move nodes keeping their ids (or onto another page via `toPageId` or a cross-page \
`parentId` / `before` / `after`). Each move is {nodeId, before: siblingId}, {nodeId, \
after: siblingId} or {nodeId, parentId, toPageId?, 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.",
json!({"moves": {"type": "array", "minItems": 1, "items": {
"type": "object",
"properties": {
"nodeId": {"type": "string"}, "before": {"type": "string"},
"after": {"type": "string"}, "parentId": {"type": "string"},
"toPageId": {"type": "string", "description": "Optional destination page id when copying/moving to another page."},
"index": {"type": "integer", "minimum": 0},
},
"required": ["nodeId"],
@@ -610,6 +679,19 @@ pub fn definitions() -> Vec<Value> {
&[],
false,
),
tool(
"diff_frames",
"Compare two frames or subtrees (`beforeId` and `afterId`) and report property-level \
differences (size, position, fills, stroke, corner radius, opacity, text, layout, \
added/removed children).",
json!({
"beforeId": node_id("First frame or node id (baseline)."),
"afterId": node_id("Second frame or node id (comparison)."),
"pageId": page_id(),
}),
&["beforeId", "afterId"],
true,
),
tool(
"finish_working_on_nodes",
"Call when done creating or editing. Saves the project.",
@@ -652,17 +734,22 @@ pub const GUIDE_HTML: &str = "\
Elements: div/section/header/footer/nav/main/article/aside/ul/ol/li/button/form → frames;
p/h1–h6/span/label/a/strong/em/code/pre (elements holding only text) → text layers (with a
frame around them when they have a background, border or padding); img → image rectangle;
svg → vector layers; br → line break; x-paper-clone node-id=ID → copy of an existing layer.
svg → vector layers; br → line break;
<x-paper-instance component=\"Name\" component-id=\"ID\" overrides='{\"Label\":\"Save\"}'/> → component instance;
<x-paper-clone node-id=\"ID\"/> → copy of an existing layer (supports cross-page `<page>:<id>`).
Default sizes (like CSS): h1 32px bold, h2 24px bold, h3 20px bold, h4–h6 16px bold, p 16px,
small 13px, code/pre 14px; color is inherited, default black.
Styles: width, height (px, %, fit-content), left/top/right/bottom with position:absolute,
display (flex, block, none), flex-direction, flex-wrap, gap, row-gap, column-gap, padding*,
justify-content, align-items, align-self, flex-grow, overflow, background, background-color,
Styles: width, height (px, %, fit-content), min-width, max-width, min-height, max-height,
left/top/right/bottom with position:absolute, display (flex, block, none), flex-direction,
flex-wrap, gap, row-gap, column-gap, padding*, justify-content, align-items,
align-self (start, center, end, stretch), flex-grow, overflow, background, background-color,
background-image (url(), linear-gradient(), radial-gradient()), background-size/object-fit,
border, border-color/width/style, border-radius (1–4 values), opacity, mix-blend-mode, color,
font-size, font-weight, font-style, line-height, letter-spacing, text-align, text-decoration.
border, border-top/border-right/border-bottom/border-left, border-color/width/style,
border-radius (1–4 values), opacity, mix-blend-mode, color, font-size, font-weight, font-style,
line-height, letter-spacing, text-align, text-decoration (including `text-decoration-color` and
`text-decoration-style: wavy`, e.g. `text-decoration: underline wavy var(--warning)`).
Ignored (reported back): margin, grid, box-shadow, transform, filters, transitions.";
pub const GUIDE_WHITEBOARD: &str = "\
+7
View File
@@ -88,6 +88,9 @@ pub struct Style {
pub fills: Vec<Paint>,
pub stroke: Option<Color>,
pub stroke_width: f64,
/// Per-side stroke widths (top, right, bottom, left); overrides uniform `stroke_width` when set.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stroke_sides: Option<[f64; 4]>,
#[serde(skip_serializing_if = "is_default")]
pub stroke_align: StrokeAlign,
/// Dash pattern (lengths in px); empty = solid.
@@ -133,6 +136,7 @@ impl Default for Style {
fills: Vec::new(),
stroke: None,
stroke_width: 1.0,
stroke_sides: None,
stroke_align: StrokeAlign::Center,
dash: Vec::new(),
cap: Cap::Butt,
@@ -434,6 +438,9 @@ impl Node {
pub fn open_comments(&self) -> usize {
self.comments.iter().filter(|c| !c.resolved).count()
}
pub fn resolved_comments(&self) -> usize {
self.comments.iter().filter(|c| c.resolved).count()
}
}
/// One anchor of a vector path, with optional bezier handles (node-local, absolute).
+49 -15
View File
@@ -306,6 +306,10 @@ pub struct TextSpan {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub underline: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub underline_color: Option<Color>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub underline_wavy: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub strike: Option<bool>,
}
@@ -338,6 +342,8 @@ pub struct CharStyle {
pub size: f64,
pub color: Color,
pub underline: bool,
pub underline_color: Option<Color>,
pub underline_wavy: bool,
pub strike: bool,
}
@@ -349,6 +355,8 @@ impl TextStyle {
size,
color,
underline: false,
underline_color: None,
underline_wavy: false,
strike: false,
};
for sp in &self.spans {
@@ -368,6 +376,12 @@ impl TextStyle {
if let Some(u) = sp.underline {
s.underline = u;
}
if let Some(uc) = sp.underline_color {
s.underline_color = Some(uc);
}
if let Some(uw) = sp.underline_wavy {
s.underline_wavy = uw;
}
if let Some(u) = sp.strike {
s.strike = u;
}
@@ -440,13 +454,6 @@ impl TextStyle {
let mut any = false;
for s in self.spans.iter().filter(|s| s.start <= a && s.end >= b) {
any = true;
m.bold = s.bold.or(m.bold);
m.italic = s.italic.or(m.italic);
m.size = s.size.or(m.size);
m.color = s.color.or(m.color);
m.underline = s.underline.or(m.underline);
m.strike = s.strike.or(m.strike);
// Later spans take precedence.
if s.bold.is_some() {
m.bold = s.bold;
}
@@ -462,6 +469,12 @@ impl TextStyle {
if s.underline.is_some() {
m.underline = s.underline;
}
if s.underline_color.is_some() {
m.underline_color = s.underline_color;
}
if s.underline_wavy.is_some() {
m.underline_wavy = s.underline_wavy;
}
if s.strike.is_some() {
m.strike = s.strike;
}
@@ -471,18 +484,26 @@ impl TextStyle {
&& m.size.is_none()
&& m.color.is_none()
&& m.underline.is_none()
&& m.underline_color.is_none()
&& m.underline_wavy.is_none()
&& m.strike.is_none();
if !any || empty {
continue;
}
let key = |x: &TextSpan| {
(
x.bold,
x.italic,
x.size,
x.color,
x.underline,
x.underline_color,
x.underline_wavy,
x.strike,
)
};
match out.last_mut() {
Some(l)
if l.end == a
&& (l.bold, l.italic, l.size, l.color, l.underline, l.strike)
== (m.bold, m.italic, m.size, m.color, m.underline, m.strike) =>
{
l.end = b
}
Some(l) if l.end == a && key(l) == key(&m) => l.end = b,
_ => out.push(m),
}
}
@@ -573,8 +594,12 @@ pub struct Constraints {
pub v: Constraint,
}
fn is_none_pair(p: &[Option<f64>; 2]) -> bool {
p[0].is_none() && p[1].is_none()
}
/// Per-node layout participation (as a child of an auto-layout or constrained frame).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct LayoutChild {
/// Horizontal and vertical sizing.
@@ -582,6 +607,15 @@ pub struct LayoutChild {
/// Ignore auto layout (positioned by constraints instead).
pub absolute: bool,
pub constraints: Constraints,
/// Per-child cross-axis alignment override (`align-self: start | center | end`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub align_self: Option<Align>,
/// Minimum `[width, height]` in px (`min-width`, `min-height`).
#[serde(default, skip_serializing_if = "is_none_pair")]
pub min_size: [Option<f64>; 2],
/// Maximum `[width, height]` in px (`max-width`, `max-height`).
#[serde(default, skip_serializing_if = "is_none_pair")]
pub max_size: [Option<f64>; 2],
}
impl LayoutChild {
+2
View File
@@ -229,6 +229,8 @@ impl Store {
let name = self.unique_name(&format!("{} copy", src.manifest.name));
let target = self.unique_dir(&self.root, &name);
copy_dir(dir, &target)?;
// A copy is its own project, not another editor of the shared one.
crate::vfs::remove_file(target.join(crate::sync::SHARE_FILE)).ok();
let mut p = Project::open(&target)?;
p.manifest.id = new_id();
p.manifest.name = name;
+123 -17
View File
@@ -354,18 +354,88 @@ pub fn node_draw(n: &Node, wr: Rect, opacity: f64) -> NodeDraw {
}
}
}
if let Some(c) = n.style.stroke
&& n.style.stroke_width > 0.0
&& let Some(sp) = stroke_path(n, wr)
{
ops.push(DrawOp::Stroke {
path: sp,
color: rgba(c, opacity),
width: n.style.stroke_width,
cap: n.style.cap,
join: n.style.join,
dash: n.style.dash.clone(),
});
if let Some(c) = n.style.stroke {
if let Some(sides) = n.style.stroke_sides
&& matches!(n.kind, NodeKind::Frame { .. } | NodeKind::Rect)
&& sides.iter().any(|&w| (w - sides[0]).abs() > 1e-9)
{
let col = rgba(c, opacity);
let side_specs = [
(
sides[0],
Rect::new(wr.x, wr.y, wr.w, sides[0].min(wr.h)),
[
v2(wr.x, wr.y + sides[0] * 0.5),
v2(wr.right(), wr.y + sides[0] * 0.5),
],
),
(
sides[1],
Rect::new(
(wr.right() - sides[1]).max(wr.x),
wr.y,
sides[1].min(wr.w),
wr.h,
),
[
v2(wr.right() - sides[1] * 0.5, wr.y),
v2(wr.right() - sides[1] * 0.5, wr.bottom()),
],
),
(
sides[2],
Rect::new(
wr.x,
(wr.bottom() - sides[2]).max(wr.y),
wr.w,
sides[2].min(wr.h),
),
[
v2(wr.x, wr.bottom() - sides[2] * 0.5),
v2(wr.right(), wr.bottom() - sides[2] * 0.5),
],
),
(
sides[3],
Rect::new(wr.x, wr.y, sides[3].min(wr.w), wr.h),
[
v2(wr.x + sides[3] * 0.5, wr.y),
v2(wr.x + sides[3] * 0.5, wr.bottom()),
],
),
];
for (w, sr, line_pts) in side_specs {
if w > 0.0 && sr.w > 0.0 && sr.h > 0.0 {
if !n.style.dash.is_empty() {
ops.push(DrawOp::Stroke {
path: VPath::polyline(&line_pts, false),
color: col,
width: w,
cap: Cap::Butt,
join: n.style.join,
dash: n.style.dash.clone(),
});
} else {
ops.push(DrawOp::Fill {
path: vector::rounded_rect(sr, [0.0; 4]),
paint: PaintSpec::Solid(col),
even_odd: false,
});
}
}
}
} else if n.style.stroke_width > 0.0
&& let Some(sp) = stroke_path(n, wr)
{
ops.push(DrawOp::Stroke {
path: sp,
color: rgba(c, opacity),
width: n.style.stroke_width,
cap: n.style.cap,
join: n.style.join,
dash: n.style.dash.clone(),
});
}
}
}
}
@@ -464,19 +534,55 @@ fn text_ops(n: &Node, wr: Rect, opacity: f64, ops: &mut Vec<DrawOp>) {
glyphs,
});
}
// Underline / strikethrough as thin rectangles.
// Underline / strikethrough as thin rectangles (or wavy stroke for wavy underlines).
for g in l.glyphs.iter().filter(|g| g.underline || g.strike) {
let th = (g.size / 14.0).max(0.5);
for (on, dy) in [(g.underline, g.size * 0.12), (g.strike, -g.size * 0.28)] {
if on {
let r = Rect::new(o.x + g.x, o.y + g.baseline + dy, g.advance, th);
if g.underline {
let u_col = g.underline_color.unwrap_or(g.color);
if g.underline_wavy {
let x0 = o.x + g.x;
let x1 = x0 + g.advance;
let y0 = o.y + g.baseline + g.size * 0.14;
let step = (g.size * 0.14).max(1.8);
let amp = (g.size * 0.06).max(0.8);
let mut pts = Vec::new();
let mut x = x0;
let mut k = 0usize;
while x < x1 - 1e-6 {
let dy = if k.is_multiple_of(2) { -amp } else { amp };
pts.push(v2(x, y0 + dy));
x += step;
k += 1;
}
let dy = if k.is_multiple_of(2) { -amp } else { amp };
pts.push(v2(x1, y0 + dy));
if pts.len() >= 2 {
ops.push(DrawOp::Stroke {
path: VPath::polyline(&pts, false),
color: rgba(u_col, opacity),
width: th,
cap: Cap::Round,
join: Join::Round,
dash: Vec::new(),
});
}
} else {
let r = Rect::new(o.x + g.x, o.y + g.baseline + g.size * 0.12, g.advance, th);
ops.push(DrawOp::Fill {
path: vector::rounded_rect(r, [0.0; 4]),
paint: PaintSpec::Solid(rgba(g.color, opacity)),
paint: PaintSpec::Solid(rgba(u_col, opacity)),
even_odd: false,
});
}
}
if g.strike {
let r = Rect::new(o.x + g.x, o.y + g.baseline - g.size * 0.28, g.advance, th);
ops.push(DrawOp::Fill {
path: vector::rounded_rect(r, [0.0; 4]),
paint: PaintSpec::Solid(rgba(g.color, opacity)),
even_odd: false,
});
}
}
}
+4 -1
View File
@@ -58,6 +58,9 @@ fn ramp(t: f32, row: u32) -> vec4<f32> {
fn fs(i: VsOut) -> @location(0) vec4<f32> {
let k = i.kind & 255u;
let row = i.kind >> 8u;
// Derivatives must be taken in uniform control flow (WebGPU rejects textureSample under `k`).
let duv_dx = dpdx(i.uv);
let duv_dy = dpdy(i.uv);
var c = i.color;
if (k == 1u) {
let r = ramp(i.uv.x, row);
@@ -69,7 +72,7 @@ fn fs(i: VsOut) -> @location(0) vec4<f32> {
if (k == 3u && (i.uv.x < 0.0 || i.uv.y < 0.0 || i.uv.x > 1.0 || i.uv.y > 1.0)) {
discard;
}
let t = textureSample(img_tex, img_samp, i.uv);
let t = textureSampleGrad(img_tex, img_samp, i.uv, duv_dx, duv_dy);
c = vec4<f32>(t.rgb, t.a * i.color.a);
}
// Premultiplied output; every pipeline blends premultiplied.
+102
View File
@@ -13,11 +13,16 @@ use web_time::Instant;
pub const AUTOSAVE_DELAY: Duration = Duration::from_millis(1000);
/// Re-render the home-screen thumbnail at most this often while editing.
pub const THUMBNAIL_INTERVAL: Duration = Duration::from_secs(20);
/// How often the shell looks at what the sync engines downloaded.
const SYNC_CHECK: Duration = Duration::from_secs(1);
pub const THUMB_SIZE: (f64, f64) = (464.0, 296.0);
pub struct Session {
pub store: Store,
pub project: Option<Project>,
/// Shared projects kept in step with a relay.
pub sync: crate::sync::Sync,
sync_checked: Option<Instant>,
dirty_since: Option<Instant>,
thumb_stale: bool,
last_thumb: Option<Instant>,
@@ -25,9 +30,13 @@ pub struct Session {
impl Session {
pub fn new(store: Store) -> Session {
let mut sync = crate::sync::Sync::default();
sync.start_all(&store.root);
Session {
store,
project: None,
sync,
sync_checked: None,
dirty_since: None,
thumb_stale: false,
last_thumb: None,
@@ -74,6 +83,99 @@ impl Session {
now.duration_since(since) >= AUTOSAVE_DELAY && !ed.is_busy()
}
/// When the sync engines should next be looked at (None while nothing is shared).
pub fn sync_deadline(&self) -> Option<Instant> {
self.sync.is_active().then(|| {
self.sync_checked
.map_or_else(Instant::now, |t| t + SYNC_CHECK)
})
}
/// Look at the sync engines: the open project is reloaded when they downloaded changes,
/// unless it holds unsaved edits (those win and go up on the next save). True when a check
/// ran, so the shell can refresh the home list and the sync status shown there.
pub fn sync_poll(&mut self, ed: &mut Editor, now: Instant) -> bool {
if self.sync_deadline().is_none_or(|d| now < d) {
return false;
}
self.sync_checked = Some(now);
if self.sync.library_active() {
// The library task writes `.share` files for projects it joined or shared.
self.sync.start_all(&self.store.root);
}
let open = self.project.as_ref().map(|p| p.dir.clone());
for dir in self.sync.dirs() {
let is_open = open.as_ref() == Some(&dir);
// A project the server dropped waits until it is closed.
if !is_open && let Some(gone) = self.sync.status(&dir).and_then(|s| s.gone()) {
self.sync.unshare(&dir);
if gone == crate::sync::Gone::Deleted {
self.store.trash(&dir).ok();
}
continue;
}
if is_open && ed.project_dirty() {
continue;
}
if self.sync.status(&dir).is_some_and(|s| s.take_changed()) {
if is_open && let Err(e) = self.open(ed, &dir) {
log::warn!("reload shared project: {e}");
}
}
}
true
}
/// Put the project in `dir` on the relay at `server`.
pub fn share_project(&mut self, dir: &Path, server: &str) -> Result<(), String> {
self.sync.share(dir, server).map(|_| ())
}
/// Open the shared project behind `link` as a project of its own; its files arrive with the
/// first sync.
pub fn join_shared(&mut self, link: &str) -> Result<(), String> {
let token = crate::sync::Share::from_link(link)?.token;
let dir = self.store.root.join(format!("shared-{}", &token[..8]));
if crate::sync::Share::load(&dir).is_some() {
return Ok(());
}
self.sync.join(&dir, link)
}
/// The shared projects for the home screen: their status and links.
pub fn shared_info(
&self,
) -> std::collections::HashMap<std::path::PathBuf, crate::ui::home::ShareInfo> {
self.sync
.dirs()
.into_iter()
.filter_map(|dir| {
let s = self.sync.status(&dir)?;
let info = crate::ui::home::ShareInfo {
status: s.label(),
link: s.link.clone(),
};
Some((dir, info))
})
.collect()
}
/// Keep a library with the relay at `server`, if it runs one (see `Sync::enable_library`).
pub fn follow_library(&mut self, server: &str) {
let root = self.store.root.clone();
self.sync.enable_library(&root, server);
}
/// The server of any shared project here: the likely answer for the next "Share…".
pub fn server_hint(&self) -> String {
self.sync
.dirs()
.iter()
.find_map(|d| crate::sync::Share::load(d))
.map(|s| s.server)
.unwrap_or_default()
}
/// Retry later (after a failed save) instead of spinning on an overdue deadline.
pub fn defer(&mut self) {
self.dirty_since = Some(Instant::now());
+639
View File
@@ -0,0 +1,639 @@
//! Keep a project folder in step with a shared copy on a relay (`mcp::store`), from the browser
//! and from the native app alike (everything goes through [`crate::vfs`]).
//!
//! A shared project has a `.share` file in its folder: the server, the share token, and the
//! state of the last sync. Every [`POLL`] the engine compares three views of each file, the
//! server's, the folder's and the last synced one:
//!
//! * changed in the folder: upload (or delete) it. The folder wins a simultaneous change, so
//! whoever is typing keeps their work and the other side sees it on its next poll;
//! * changed on the server only: download (or delete) it, and flag [`Status::take_changed`] so
//! the shell reloads the open project.
//!
//! Granularity is a whole file (one page), last write wins.
//!
//! A relay started with `--library` is a personal library: [`Sync::enable_library`] lists it
//! every [`LIBRARY_POLL`], joins the projects this device does not have and shares the local
//! ones, so every device that opens the server ends up with the same projects. The sync loops
//! above do the actual file work; the library only writes `.share` files.
use crate::mcp::relay_proto;
use crate::vfs;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::{Component, Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::Duration;
/// File in a project folder that marks it as shared.
pub const SHARE_FILE: &str = ".share";
/// How often a shared project asks the server what changed.
const POLL: Duration = Duration::from_secs(2);
/// How often a library device looks for projects added or deleted elsewhere.
const LIBRARY_POLL: Duration = Duration::from_secs(5);
/// Files that stay on this machine: the sync state and the home-screen preview.
const LOCAL_ONLY: [&str; 2] = [SHARE_FILE, "thumbnail.png"];
/// What the last sync knew about one file.
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
struct Stamp {
/// The server's modified time (compared for equality only).
remote: u64,
/// Hash of the file's bytes here.
local: u64,
}
#[derive(Serialize, Deserialize, Clone, Default)]
pub struct Share {
/// `https://host[:port]`, no trailing slash.
pub server: String,
pub token: String,
#[serde(default)]
synced: BTreeMap<String, Stamp>,
}
impl Share {
pub fn new(server: &str) -> Share {
Share {
server: server.trim_end_matches('/').to_string(),
token: new_token(),
synced: BTreeMap::new(),
}
}
pub fn load(dir: &Path) -> Option<Share> {
let text = vfs::read_to_string(dir.join(SHARE_FILE)).ok()?;
serde_json::from_str(&text).ok()
}
pub fn save(&self, dir: &Path) -> Result<(), String> {
let json = serde_json::to_string(self).map_err(|e| e.to_string())?;
vfs::write(dir.join(SHARE_FILE), json).map_err(|e| e.to_string())
}
/// What to send to a collaborator: opening it in the web app joins the project.
pub fn link(&self) -> String {
format!("{}/?share={}", self.server, self.token)
}
/// The share a link such as `https://host/?share=<token>` stands for.
pub fn from_link(link: &str) -> Result<Share, String> {
let link = link.trim();
let (server, query) = link
.split_once("?share=")
.ok_or("not a share link (it should end in ?share=<token>)")?;
let token: String = query
.chars()
.take_while(char::is_ascii_alphanumeric)
.collect();
if !relay_proto::valid_token(&token) {
return Err("the share token in this link is not valid".into());
}
let server = server.trim_end_matches('/');
if !server.starts_with("http://") && !server.starts_with("https://") {
return Err("the link must start with http:// or https://".into());
}
Ok(Share {
server: server.to_string(),
token,
synced: BTreeMap::new(),
})
}
fn base(&self) -> String {
format!("{}/p/{}", self.server, self.token)
}
}
fn new_token() -> String {
(0..relay_proto::MIN_TOKEN_LEN)
.map(|_| char::from_digit(fastrand::u32(0..16), 16).unwrap_or('0'))
.collect()
}
/// FNV-1a: a stable fingerprint of a file's bytes (the std hasher may change between releases,
/// and the value is stored in `.share`).
fn fingerprint(bytes: &[u8]) -> u64 {
bytes.iter().fold(0xcbf2_9ce4_8422_2325, |h, b| {
(h ^ u64::from(*b)).wrapping_mul(0x0100_0000_01b3)
})
}
/// A file path from the server, only if it stays inside the project folder.
fn safe_path(rel: &str) -> Option<PathBuf> {
let p = Path::new(rel);
let ok = !rel.is_empty()
&& p.components().all(|c| matches!(c, Component::Normal(_)))
&& !LOCAL_ONLY.contains(&rel);
ok.then(|| p.to_path_buf())
}
/// Every file under `dir` that syncs, as relative `/` paths with their fingerprints.
fn scan(dir: &Path) -> BTreeMap<String, u64> {
fn walk(base: &Path, dir: &Path, out: &mut BTreeMap<String, u64>) {
let Ok(entries) = vfs::read_dir(dir) else {
return;
};
for e in entries.flatten() {
let p = e.path();
if e.file_type().is_ok_and(|t| t.is_dir()) {
walk(base, &p, out);
} else if let (Ok(rel), Ok(bytes)) = (p.strip_prefix(base), vfs::read(&p)) {
let rel = rel.to_string_lossy().replace('\\', "/");
if safe_path(&rel).is_some() {
out.insert(rel, fingerprint(&bytes));
}
}
}
}
let mut out = BTreeMap::new();
walk(dir, dir, &mut out);
out
}
#[derive(Deserialize)]
struct RemoteFile {
path: String,
mtime: u64,
}
/// Why a shared project stopped syncing for good.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Gone {
/// Someone deleted it on the server (library mode): it belongs in this device's trash.
Deleted,
/// It had synced before but the server no longer knows it, as after losing its data:
/// the folder is the only copy left, so it stays and can be shared again.
Lost,
}
#[derive(Debug)]
pub enum SyncError {
Gone(Gone),
Other(String),
}
impl From<String> for SyncError {
fn from(e: String) -> SyncError {
SyncError::Other(e)
}
}
impl std::fmt::Display for SyncError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SyncError::Gone(Gone::Deleted) => f.write_str("deleted on the server"),
SyncError::Gone(Gone::Lost) => f.write_str("the server no longer has this project"),
SyncError::Other(e) => f.write_str(e),
}
}
}
/// The server's files for a project. `synced_before`: this folder has already synced once, so
/// an unknown project is a loss, not a first upload.
async fn list_remote(base: &str, synced_before: bool) -> Result<BTreeMap<String, u64>, SyncError> {
let (code, body) = net::request("GET", &format!("{base}/files"), None).await?;
match code {
200 => {}
410 => return Err(SyncError::Gone(Gone::Deleted)),
// Nothing uploaded yet: the first sync of a freshly shared project.
404 if !synced_before => return Ok(BTreeMap::new()),
404 => return Err(SyncError::Gone(Gone::Lost)),
c => return Err(format!("the server answered {c}").into()),
}
let files: Vec<RemoteFile> = serde_json::from_slice(&body).map_err(|e| e.to_string())?;
Ok(files
.into_iter()
.filter(|f| safe_path(&f.path).is_some())
.map(|f| (f.path, f.mtime))
.collect())
}
/// One round: reconcile the folder with the server. True when the server's version of some
/// file was applied here, so the open project may need reloading.
pub async fn sync_once(dir: &Path) -> Result<bool, SyncError> {
let mut share = Share::load(dir).ok_or_else(|| "this project is not shared".to_string())?;
let base = share.base();
let remote = list_remote(&base, !share.synced.is_empty()).await?;
let local = scan(dir);
let paths: BTreeSet<&String> = remote
.keys()
.chain(local.keys())
.chain(share.synced.keys())
.collect();
let (mut synced, mut applied) = (share.synced.clone(), false);
for path in paths {
let (l, r) = (local.get(path).copied(), remote.get(path).copied());
let was = synced.get(path).copied();
let url = format!("{base}/f/{path}");
let file = dir.join(path);
if l != was.map(|s| s.local) {
let Some(hash) = l else {
let (code, _) = net::request("DELETE", &url, None).await?;
if code != 204 && code != 404 {
return Err(format!("could not delete {path}: server answered {code}").into());
}
synced.remove(path);
continue;
};
let bytes = vfs::read(&file).map_err(|e| format!("{path}: {e}"))?;
let (code, body) = net::request("PUT", &url, Some(bytes)).await?;
if code != 200 {
return Err(format!("could not upload {path}: server answered {code}").into());
}
let remote = String::from_utf8_lossy(&body).trim().parse().unwrap_or(0);
synced.insert(
path.clone(),
Stamp {
remote,
local: hash,
},
);
} else if r != was.map(|s| s.remote) {
applied = true;
let Some(stamp) = r else {
vfs::remove_file(&file).ok();
synced.remove(path);
continue;
};
let (code, bytes) = net::request("GET", &url, None).await?;
if code != 200 {
return Err(format!("could not download {path}: server answered {code}").into());
}
if let Some(parent) = file.parent() {
vfs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
vfs::write(&file, &bytes).map_err(|e| format!("{path}: {e}"))?;
synced.insert(
path.clone(),
Stamp {
remote: stamp,
local: fingerprint(&bytes),
},
);
}
}
if synced != share.synced {
share.synced = synced;
share.save(dir)?;
}
Ok(applied)
}
/// What the shell shows and acts on for one shared project.
#[derive(Default)]
pub struct Status {
/// What to send a collaborator.
pub link: String,
stop: AtomicBool,
inner: Mutex<Inner>,
}
#[derive(Default)]
struct Inner {
error: Option<String>,
gone: Option<Gone>,
synced_once: bool,
changed: bool,
}
impl Status {
fn inner(&self) -> std::sync::MutexGuard<'_, Inner> {
self.inner.lock().unwrap_or_else(PoisonError::into_inner)
}
/// The server's copy changed files in the folder since the last call.
pub fn take_changed(&self) -> bool {
std::mem::take(&mut self.inner().changed)
}
/// Why this project stopped syncing for good, if it did.
pub fn gone(&self) -> Option<Gone> {
self.inner().gone
}
pub fn label(&self) -> String {
let i = self.inner();
match (&i.error, i.synced_once) {
(Some(e), _) => format!("Sync error: {e}"),
(None, true) => "Synced".into(),
(None, false) => "Connecting".into(),
}
}
}
async fn run(dir: PathBuf, status: Arc<Status>) {
while !status.stop.load(Ordering::Relaxed) {
let round = sync_once(&dir).await;
{
let mut i = status.inner();
match round {
Ok(changed) => {
i.error = None;
i.synced_once = true;
i.changed |= changed;
}
Err(SyncError::Gone(g)) => {
i.gone = Some(g);
status.stop.store(true, Ordering::Relaxed);
}
Err(e) => i.error = Some(e.to_string()),
}
}
net::sleep(POLL).await;
}
}
/// One look at the library: share local projects, join the ones this device lacks. False when
/// the relay has no library.
pub async fn library_round(root: &Path, server: &str) -> Result<bool, String> {
let (code, body) = net::request("GET", &format!("{server}/library"), None).await?;
match code {
200 => {}
404 => return Ok(false),
c => return Err(format!("the server answered {c}")),
}
let tokens: Vec<String> = serde_json::from_slice(&body).map_err(|e| e.to_string())?;
let mut known = BTreeSet::new();
for e in vfs::read_dir(root).into_iter().flatten().flatten() {
let hidden = e.file_name().to_string_lossy().starts_with('.');
if !hidden && let Some(s) = Share::load(&e.path()) {
known.insert(s.token);
}
}
let store = crate::project::Store::open(root).map_err(|e| e.to_string())?;
for entry in store.list() {
if Share::load(&entry.dir).is_none() {
Share::new(server).save(&entry.dir)?;
}
}
for token in tokens.iter().filter(|t| !known.contains(*t)) {
let share = Share::from_link(&format!("{server}/?share={token}"))?;
let dir = root.join(format!("shared-{}", &token[..8]));
vfs::create_dir_all(&dir).map_err(|e| e.to_string())?;
share.save(&dir)?;
}
Ok(true)
}
async fn run_library(root: PathBuf, server: String, alive: Arc<AtomicBool>) {
while alive.load(Ordering::Relaxed) {
match library_round(&root, &server).await {
Ok(true) => {}
Ok(false) => alive.store(false, Ordering::Relaxed),
Err(e) => log::warn!("library: {e}"),
}
net::sleep(LIBRARY_POLL).await;
}
}
/// The shared projects this process is keeping in sync.
#[derive(Default)]
pub struct Sync {
running: HashMap<PathBuf, Arc<Status>>,
library: Option<Library>,
}
/// The relay this device keeps a library with; `alive` goes false when it turns out not to
/// be one.
struct Library {
server: String,
alive: Arc<AtomicBool>,
}
impl Sync {
/// Start syncing the project in `dir` (it must have a `.share` file). A second call for the
/// same folder does nothing.
pub fn start(&mut self, dir: &Path) {
if self.running.contains_key(dir) || Share::load(dir).is_none() {
return;
}
let status = Arc::new(Status {
link: Share::load(dir).map(|s| s.link()).unwrap_or_default(),
..Status::default()
});
net::spawn(run(dir.to_path_buf(), status.clone()));
self.running.insert(dir.to_path_buf(), status);
}
/// Resume every shared project under the store root (at startup).
pub fn start_all(&mut self, root: &Path) {
let Ok(entries) = vfs::read_dir(root) else {
return;
};
for e in entries.flatten() {
if e.file_type().is_ok_and(|t| t.is_dir()) {
self.start(&e.path());
}
}
}
/// Make `dir` a shared project on `server`; returns the link to hand out.
pub fn share(&mut self, dir: &Path, server: &str) -> Result<String, String> {
if let Some(existing) = Share::load(dir) {
return Ok(existing.link());
}
let share = Share::new(server);
share.save(dir)?;
self.start(dir);
Ok(share.link())
}
/// Download the project behind `link` into a new folder `dir` and keep it in sync.
pub fn join(&mut self, dir: &Path, link: &str) -> Result<(), String> {
let share = Share::from_link(link)?;
vfs::create_dir_all(dir).map_err(|e| e.to_string())?;
share.save(dir)?;
self.start(dir);
Ok(())
}
pub fn status(&self, dir: &Path) -> Option<Arc<Status>> {
self.running.get(dir).cloned()
}
pub fn dirs(&self) -> Vec<PathBuf> {
self.running.keys().cloned().collect()
}
pub fn is_active(&self) -> bool {
!self.running.is_empty() || self.library_active()
}
/// Keep a library with the relay at `server` (the page's own origin on the web): its
/// projects appear here and this device's projects appear there. Does nothing, quietly, when
/// the relay was not started with `--library`.
pub fn enable_library(&mut self, root: &Path, server: &str) {
if self.library.is_some() {
return;
}
let server = server.trim_end_matches('/').to_string();
let alive = Arc::new(AtomicBool::new(true));
net::spawn(run_library(
root.to_path_buf(),
server.clone(),
alive.clone(),
));
self.library = Some(Library { server, alive });
}
/// True while a library relay is being followed: projects there are the user's own, so
/// the share/join controls do not apply.
pub fn library_active(&self) -> bool {
self.library
.as_ref()
.is_some_and(|l| l.alive.load(Ordering::Relaxed))
}
/// A project leaves this device for good (trashed). In a library it is deleted on the relay
/// too, so the other devices drop it; otherwise only this folder stops syncing.
pub fn drop_project(&mut self, dir: &Path) {
if let (Some(lib), Some(share)) = (&self.library, Share::load(dir))
&& lib.alive.load(Ordering::Relaxed)
&& share.server == lib.server
{
let url = share.base();
net::spawn(async move {
match net::request("DELETE", &url, None).await {
Ok((204 | 404 | 410, _)) => {}
Ok((code, _)) => log::warn!("library: delete answered {code}"),
Err(e) => log::warn!("library: could not delete: {e}"),
}
});
}
self.unshare(dir);
}
/// Stop syncing `dir` and forget its share (the files stay).
pub fn unshare(&mut self, dir: &Path) {
if let Some(s) = self.running.remove(dir) {
s.stop.store(true, Ordering::Relaxed);
}
vfs::remove_file(dir.join(SHARE_FILE)).ok();
}
}
#[cfg(not(target_arch = "wasm32"))]
mod net {
use std::sync::OnceLock;
use std::time::Duration;
pub async fn request(
method: &str,
url: &str,
body: Option<Vec<u8>>,
) -> Result<(u16, Vec<u8>), String> {
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
let client = CLIENT.get_or_init(reqwest::Client::new);
let method = reqwest::Method::from_bytes(method.as_bytes()).map_err(|e| e.to_string())?;
let mut req = client.request(method, url).timeout(Duration::from_secs(60));
if let Some(b) = body {
req = req.body(b);
}
let res = req.send().await.map_err(|e| e.to_string())?;
let code = res.status().as_u16();
let bytes = res.bytes().await.map_err(|e| e.to_string())?;
Ok((code, bytes.to_vec()))
}
pub async fn sleep(d: Duration) {
tokio::time::sleep(d).await;
}
/// Own thread and runtime: the editor's event loop has none.
pub fn spawn(fut: impl Future<Output = ()> + Send + 'static) {
std::thread::Builder::new()
.name("papercraft-sync".into())
.spawn(move || {
if let Ok(rt) = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
rt.block_on(fut);
}
})
.ok();
}
}
#[cfg(target_arch = "wasm32")]
mod net {
use std::time::Duration;
use wasm_bindgen::{JsCast, JsValue};
use wasm_bindgen_futures::JsFuture;
fn js(e: JsValue) -> String {
e.as_string().unwrap_or_else(|| format!("{e:?}"))
}
pub async fn request(
method: &str,
url: &str,
body: Option<Vec<u8>>,
) -> Result<(u16, Vec<u8>), String> {
let init = web_sys::RequestInit::new();
init.set_method(method);
if let Some(b) = body {
init.set_body(&js_sys::Uint8Array::from(b.as_slice()));
}
let window = web_sys::window().ok_or("no window")?;
let res = JsFuture::from(window.fetch_with_str_and_init(url, &init))
.await
.map_err(js)?;
let res: web_sys::Response = res.dyn_into().map_err(js)?;
let buf = JsFuture::from(res.array_buffer().map_err(js)?)
.await
.map_err(js)?;
Ok((res.status(), js_sys::Uint8Array::new(&buf).to_vec()))
}
pub async fn sleep(d: Duration) {
let promise = js_sys::Promise::new(&mut |resolve, _| {
if let Some(w) = web_sys::window() {
let _ = w.set_timeout_with_callback_and_timeout_and_arguments_0(
&resolve,
d.as_millis() as i32,
);
}
});
let _ = JsFuture::from(promise).await;
}
pub fn spawn(fut: impl Future<Output = ()> + 'static) {
wasm_bindgen_futures::spawn_local(fut);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn server_paths_cannot_leave_the_project() {
assert!(safe_path("pages/a.json").is_some());
for bad in [
"",
"../x",
"a/../../x",
"/etc/passwd",
".share",
"thumbnail.png",
] {
assert!(safe_path(bad).is_none(), "{bad:?}");
}
}
#[test]
fn links_round_trip() {
let s = Share::new("https://papercraft.example.com/");
let back = Share::from_link(&s.link()).unwrap();
assert_eq!(
(back.server.as_str(), back.token.as_str()),
(s.server.as_str(), s.token.as_str())
);
assert!(Share::from_link("https://x/?share=zz").is_err());
assert!(Share::from_link("https://x/").is_err());
assert!(Share::from_link("ftp://x/?share=00000000000000000000000000000000").is_err());
}
}
+4
View File
@@ -72,6 +72,8 @@ pub struct PlacedGlyph {
/// Char index into the source string this glyph comes from.
pub char_index: usize,
pub underline: bool,
pub underline_color: Option<Color>,
pub underline_wavy: bool,
pub strike: bool,
}
@@ -223,6 +225,8 @@ pub fn layout_rich(
advance: a,
char_index: i,
underline: st.underline,
underline_color: st.underline_color,
underline_wavy: st.underline_wavy,
strike: st.strike,
});
x += a;
+342 -29
View File
@@ -18,6 +18,20 @@ fn on_every_page(ed: &mut Editor, label: &str, f: impl Fn(&mut crate::history::T
}
}
fn sanitize_name(s: &str) -> String {
s.trim()
.trim_start_matches("--")
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' {
c
} else {
'-'
}
})
.collect()
}
pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
let p = theme::pal(ui);
let heading = |ui: &mut Ui, t: &str| {
@@ -83,10 +97,10 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
}
}
// ---- 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();
heading(ui, "Modes");
let modes: Vec<String> = ed.doc.modes.keys().cloned().collect();
let active = ed.doc.mode.clone();
if !modes.is_empty() {
ui.horizontal_wrapped(|ui| {
for m in &modes {
let on = active.as_deref() == Some(m);
@@ -96,9 +110,47 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
tx.set_mode(&m);
});
}
if crate::theme::small_button(ui, "✕")
.on_hover_text("Delete mode")
.clicked()
{
let m = m.clone();
on_every_page(ed, "Remove variable mode", |tx| {
tx.remove_mode(&m);
});
}
}
});
}
ui.horizontal(|ui| {
let w = (ui.available_width() - 44.0).max(60.0);
let r = ui.add(
egui::TextEdit::singleline(&mut st.mode_draft)
.hint_text("New mode (e.g. Dark)")
.desired_width(w)
.margin(crate::theme::INPUT_MARGIN),
);
let enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
let mode_name = st.mode_draft.trim().to_string();
if (crate::theme::small_button(ui, "Add").clicked() || enter)
&& !mode_name.is_empty()
{
st.mode_draft.clear();
let snapshot: Vec<(String, Color)> = ed
.doc
.variables
.iter()
.map(|(k, v)| (k.clone(), *v))
.collect();
on_every_page(ed, "Add variable mode", |tx| {
tx.doc.modes.entry(mode_name.clone()).or_default();
for (k, v) in &snapshot {
tx.set_variable_in_mode(&mode_name, k, *v);
}
tx.set_mode(&mode_name);
});
}
});
// ---- variables
heading(ui, "Variables");
if ed.doc.variables.is_empty() {
@@ -129,7 +181,44 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
});
}
}
ui.label(RichText::new(format!("--{name}")).monospace().size(theme::SECONDARY + 1.0));
let is_renaming = st
.var_renaming
.as_ref()
.is_some_and(|(old, _)| old == &name);
if is_renaming {
let (_, mut draft) = st.var_renaming.clone().unwrap();
let r = ui.add(
egui::TextEdit::singleline(&mut draft)
.desired_width(90.0)
.margin(crate::theme::INPUT_MARGIN),
);
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
st.var_renaming = None;
} else if r.lost_focus() || ui.input(|i| i.key_pressed(egui::Key::Enter)) {
let new = sanitize_name(&draft);
let old = name.clone();
st.var_renaming = None;
if !new.is_empty() && new != old {
on_every_page(ed, "Rename variable", |tx| {
tx.rename_variable(&old, &new);
});
}
} else {
st.var_renaming = Some((name.clone(), draft));
}
} else {
let lbl = ui.add(
egui::Label::new(
RichText::new(format!("--{name}"))
.monospace()
.size(theme::SECONDARY + 1.0),
)
.sense(egui::Sense::click()),
);
if lbl.on_hover_text("Click to rename").clicked() {
st.var_renaming = Some((name.clone(), name.clone()));
}
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if crate::theme::small_button(ui, "✕")
.on_hover_text("Delete (fills keep their color)")
@@ -151,13 +240,7 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
.margin(crate::theme::INPUT_MARGIN),
);
let enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
let name: String = st
.variable_draft
.trim()
.trim_start_matches("--")
.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '_' { c } else { '-' })
.collect();
let name = sanitize_name(&st.variable_draft);
if (crate::theme::small_button(ui, "Add").clicked() || enter) && !name.is_empty() {
st.variable_draft.clear();
let base = Color::rgb(0x80, 0x80, 0x80);
@@ -166,21 +249,108 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
});
}
});
// ---- tokens
if !ed.doc.tokens.is_empty() {
heading(ui, "Tokens");
for (k, v) in &ed.doc.tokens {
ui.horizontal(|ui| {
ui.label(RichText::new(format!("--{k}")).monospace().size(theme::SECONDARY));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(RichText::new(v).monospace().weak().size(theme::SECONDARY));
});
});
}
if !ed.doc.variables.is_empty() || !ed.doc.tokens.is_empty() {
ui.horizontal(|ui| {
if crate::theme::small_button(ui, "Copy CSS")
.on_hover_text("Copy variables and tokens as CSS custom properties")
.clicked()
{
let mut lines = vec![":root {".to_string()];
for (k, v) in &ed.doc.variables {
lines.push(format!(" --{k}: {};", v.to_hex()));
}
for (k, v) in &ed.doc.tokens {
lines.push(format!(" --{k}: {v};"));
}
lines.push("}".to_string());
for (mode, map) in &ed.doc.modes {
lines.push(format!("[data-theme=\"{mode}\"] {{"));
for (k, v) in map {
lines.push(format!(" --{k}: {};", v.to_hex()));
}
lines.push("}".to_string());
}
ui.ctx().copy_text(lines.join("\n"));
ed.status = "Copied tokens to clipboard".into();
}
if crate::theme::small_button(ui, "Copy JSON")
.on_hover_text("Copy variables and tokens in W3C DTCG JSON format")
.clicked()
{
let mut root = serde_json::Map::new();
for (k, v) in &ed.doc.variables {
root.insert(
k.clone(),
serde_json::json!({ "$type": "color", "$value": v.to_hex() }),
);
}
for (k, v) in &ed.doc.tokens {
root.insert(
k.clone(),
serde_json::json!({ "$type": "dimension", "$value": v }),
);
}
if let Ok(json) =
serde_json::to_string_pretty(&serde_json::Value::Object(root))
{
ui.ctx().copy_text(json);
ed.status = "Copied tokens to clipboard".into();
}
}
});
}
// ---- tokens
heading(ui, "Tokens");
let tokens: Vec<(String, String)> = ed
.doc
.tokens
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
for (k, v) in tokens {
ui.horizontal(|ui| {
ui.label(RichText::new(format!("--{k}")).monospace().size(theme::SECONDARY));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if crate::theme::small_button(ui, "✕")
.on_hover_text("Delete token")
.clicked()
{
on_every_page(ed, "Delete token", |tx| tx.remove_token(&k));
}
ui.label(RichText::new(&v).monospace().weak().size(theme::SECONDARY));
});
});
}
ui.horizontal(|ui| {
let avail = (ui.available_width() - 48.0).max(80.0);
let r1 = ui.add(
egui::TextEdit::singleline(&mut st.token_name_draft)
.hint_text("spacing-md")
.desired_width(avail * 0.55)
.margin(crate::theme::INPUT_MARGIN),
);
let r2 = ui.add(
egui::TextEdit::singleline(&mut st.token_val_draft)
.hint_text("16px")
.desired_width(avail * 0.45)
.margin(crate::theme::INPUT_MARGIN),
);
let enter = (r1.lost_focus() || r2.lost_focus())
&& ui.input(|i| i.key_pressed(egui::Key::Enter));
let name = sanitize_name(&st.token_name_draft);
let val = st.token_val_draft.trim().to_string();
if (crate::theme::small_button(ui, "Add").clicked() || enter)
&& !name.is_empty()
&& !val.is_empty()
{
st.token_name_draft.clear();
st.token_val_draft.clear();
on_every_page(ed, "Set token", |tx| tx.set_token(&name, &val));
}
});
// ---- components
heading(ui, "Components");
let mut comps: Vec<(NodeId, String, usize)> = ed
let mut comps: Vec<(NodeId, String, f64, f64, usize)> = ed
.doc
.nodes
.values()
@@ -192,7 +362,7 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
.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)
(n.id, n.name.clone(), n.rect.w, n.rect.h, uses)
})
.collect();
comps.sort_by_key(|c| c.0);
@@ -203,21 +373,164 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
.color(p.secondary),
);
}
for (id, name, uses) in comps {
if comps.len() > 3 || !st.component_filter.is_empty() {
ui.add(
egui::TextEdit::singleline(&mut st.component_filter)
.hint_text("Filter components…")
.desired_width(f32::INFINITY)
.margin(crate::theme::INPUT_MARGIN),
);
ui.add_space(2.0);
}
if let Some(inst_id) = ed
.selection
.first()
.copied()
.filter(|id| ed.is_instance_root(*id))
{
let cur_main = ed.instance_main_root(inst_id);
let cur_name = cur_main
.and_then(|m| ed.doc.get(m))
.map(|n| n.name.clone())
.unwrap_or_else(|| "Instance".into());
ui.horizontal_wrapped(|ui| {
ui.label(
RichText::new("Instance")
.size(theme::SECONDARY)
.color(p.secondary),
);
egui::ComboBox::from_id_salt("design_swap_instance")
.selected_text(cur_name)
.width(100.0)
.show_ui(ui, |ui| {
for (cid, cname, _, _, _) in &comps {
if ui.selectable_label(cur_main == Some(*cid), cname).clicked()
&& cur_main != Some(*cid)
{
ed.swap_instance(inst_id, *cid);
}
}
});
if crate::theme::small_button(ui, "Reset").clicked() {
ed.reset_overrides();
}
if crate::theme::small_button(ui, "Detach").clicked() {
ed.detach_instance();
}
});
}
let needle = st.component_filter.trim().to_lowercase();
for (id, name, w, h, uses) in comps {
if !needle.is_empty() && !name.to_lowercase().contains(&needle) {
continue;
}
ui.horizontal(|ui| {
ui.label(RichText::new(&name).font(theme::medium(theme::BODY)));
ui.label(RichText::new(format!("{uses}")).weak().size(theme::SECONDARY));
ui.label(
RichText::new(format!("{w:.0}×{h:.0} · {uses}×"))
.weak()
.size(theme::SECONDARY),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if crate::theme::small_button(ui, "Insert").on_hover_text("Place an instance in view").clicked() {
if crate::theme::small_button(ui, "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 crate::theme::small_button(ui, "Go to").on_hover_text("Select the main component").clicked() {
if crate::theme::small_button(ui, "Go to")
.on_hover_text("Select the main component")
.clicked()
{
ed.selection = vec![id];
ed.run(crate::editor::shortcuts::Action::ZoomSelection);
}
});
});
}
// ---- design lint
let issues = crate::mcp::lint::lint(
&ed.doc,
&ed.doc.root,
&crate::mcp::lint::DEFAULT_SCALE,
);
ui.horizontal(|ui| {
let title = if issues.is_empty() {
"Lint · Clean".to_string()
} else {
format!("Lint · {} issues", issues.len())
};
heading(ui, &title);
if !issues.is_empty() {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if crate::theme::small_button(ui, "Fix all")
.on_hover_text("Auto-fix grid, color variables, text scale, and touch targets")
.clicked()
{
ed.edit("Auto-fix lint issues", |tx| {
let roots = tx.doc().root.clone();
crate::mcp::lint::autofix(
tx,
&roots,
&crate::mcp::lint::DEFAULT_SCALE,
);
});
}
});
}
});
for issue in issues.iter().take(30) {
let node_name = ed
.doc
.get(issue.node)
.map(|n| n.name.clone())
.unwrap_or_else(|| "Layer".into());
let dot_color = match issue.severity {
"error" => egui::Color32::from_rgb(239, 68, 68),
"warning" => egui::Color32::from_rgb(245, 158, 11),
_ => p.secondary,
};
let fixable = matches!(
issue.rule,
"spacing-grid"
| "hard-coded-color"
| "text-size"
| "type-scale"
| "touch-target"
);
ui.horizontal(|ui| {
ui.label(RichText::new("●").color(dot_color).size(theme::SECONDARY));
let r = ui.add(
egui::Label::new(
RichText::new(format!("{node_name}: {}", issue.message))
.size(theme::SECONDARY),
)
.truncate()
.sense(egui::Sense::click()),
);
if r.on_hover_text(&issue.fix).clicked() {
ed.selection = vec![issue.node];
ed.run(crate::editor::shortcuts::Action::ZoomSelection);
}
if fixable {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if crate::theme::small_button(ui, "Fix")
.on_hover_text(&issue.fix)
.clicked()
{
let target = issue.node;
ed.edit("Fix lint issue", |tx| {
crate::mcp::lint::autofix_node(
tx,
target,
&crate::mcp::lint::DEFAULT_SCALE,
);
});
}
});
}
});
}
});
}
+117
View File
@@ -20,6 +20,28 @@ pub enum HomeAction {
EmptyTrash,
/// Show the projects folder in the system file manager.
RevealRoot,
/// Put a project on this server (a relay) so others can open and edit it.
Share(PathBuf, String),
/// Open the shared project behind a share link.
Join(String),
/// Stop syncing a project; it stays here as a local copy.
Unshare(PathBuf),
}
/// The small window opened by "Share…" and "Join shared project…".
#[derive(Debug, Clone)]
pub struct ShareDialog {
/// The project to share, or None to join.
pub dir: Option<PathBuf>,
/// The server to share on, or the link to join.
pub text: String,
}
/// What the sync engine reports for one shared project.
#[derive(Debug, Clone)]
pub struct ShareInfo {
pub status: String,
pub link: String,
}
#[derive(Default)]
@@ -33,6 +55,13 @@ pub struct HomeView {
/// "Now" for relative timestamps (unix ms); fixed in screenshots.
pub now_ms: u64,
pub root: PathBuf,
pub dialog: Option<ShareDialog>,
/// Shared projects by folder, filled by the shell every frame.
pub shared: HashMap<PathBuf, ShareInfo>,
/// Where a new share goes unless the user types another server.
pub default_server: String,
/// The server keeps a library: every project is shared, so no share controls.
pub library: bool,
thumbs: HashMap<PathBuf, (Option<SystemTime>, egui::TextureHandle)>,
}
@@ -180,6 +209,12 @@ pub fn draw(ui: &mut Ui, hv: &mut HomeView, native_chrome: bool, translucent: bo
if ui.button("Import…").clicked() {
hv.actions.push(HomeAction::Import);
}
if !hv.library && ui.button("Join shared…").clicked() {
hv.dialog = Some(ShareDialog {
dir: None,
text: String::new(),
});
}
}
});
});
@@ -203,6 +238,55 @@ pub fn draw(ui: &mut Ui, hv: &mut HomeView, native_chrome: bool, translucent: bo
}
});
});
share_dialog(ui, hv);
}
fn share_dialog(ui: &mut Ui, hv: &mut HomeView) {
let Some(mut d) = hv.dialog.take() else {
return;
};
let (mut keep, mut submit) = (true, false);
let (title, hint, button) = match d.dir {
Some(_) => (
"Share project",
"Server, e.g. https://papercraft.example.com",
"Share",
),
None => ("Join shared project", "Paste the share link", "Join"),
};
egui::Window::new(title)
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.open(&mut keep)
.show(ui.ctx(), |ui| {
ui.set_width(380.0);
let r = ui.add(
egui::TextEdit::singleline(&mut d.text)
.hint_text(hint)
.desired_width(f32::INFINITY),
);
if d.dir.is_some() {
ui.label(
RichText::new("Anyone with the link can open and edit the project.")
.size(theme::SECONDARY)
.color(theme::pal(ui).secondary),
);
}
let enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
if ui.button(button).clicked() || enter {
submit = true;
}
});
if submit && !d.text.trim().is_empty() {
let text = d.text.trim().to_string();
hv.actions.push(match d.dir {
Some(dir) => HomeAction::Share(dir, text),
None => HomeAction::Join(text),
});
} else if keep {
hv.dialog = Some(d);
}
}
fn sidebar_item(
@@ -403,10 +487,43 @@ fn project_card(ui: &mut Ui, hv: &mut HomeView, e: &Entry) {
.size(theme::SECONDARY)
.color(p.secondary),
);
if let Some(s) = hv.shared.get(&e.dir) {
let prefix = if hv.library { "" } else { "Shared · " };
ui.label(
RichText::new(format!("{prefix}{}", s.status))
.size(theme::SECONDARY)
.color(p.secondary),
);
}
});
}
fn card_menu(ui: &mut Ui, hv: &mut HomeView, e: &Entry) {
match hv.shared.get(&e.dir).map(|s| s.link.clone()) {
_ if hv.library => {}
Some(link) => {
if ui.button("Copy share link").clicked() {
ui.ctx().copy_text(link);
ui.close();
}
if ui.button("Stop sharing").clicked() {
hv.actions.push(HomeAction::Unshare(e.dir.clone()));
ui.close();
}
}
None => {
if ui.button("Share…").clicked() {
hv.dialog = Some(ShareDialog {
dir: Some(e.dir.clone()),
text: hv.default_server.clone(),
});
ui.close();
}
}
}
if !hv.library {
ui.separator();
}
if ui.button("Open").clicked() {
hv.actions.push(HomeAction::Open(e.dir.clone()));
ui.close();
+122 -4
View File
@@ -783,6 +783,55 @@ fn export_section(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
ed.run(crate::editor::shortcuts::Action::CopyAsSvg);
}
});
let root_id = ed
.top_selection()
.first()
.copied()
.or_else(|| ed.doc.root.first().copied());
if let Some(root_id) = root_id {
ui.add_space(6.0);
ui.label(title_text("Code & AI Handoff"));
if st.code_target.is_empty() {
st.code_target = "react".into();
}
let targets = [
("react", "React (TSX)"),
("tailwind", "Tailwind JSX"),
("angular", "Angular"),
("html", "HTML + CSS"),
("egui", "Rust (egui)"),
];
let cur = targets
.iter()
.find(|(k, _)| *k == st.code_target)
.map_or("react", |(k, _)| *k);
row(ui, "Target", |ui| {
if let Some(v) = combo(ui, "code-target", cur, &targets, ui.available_width()) {
st.code_target = v.into();
}
});
ui.horizontal(|ui| {
let w = half(ui);
if ui
.add(egui::Button::new("Copy Code").min_size(egui::vec2(w, 24.0)))
.clicked()
&& let Ok(code) =
crate::mcp::codegen::code_for_target(&ed.doc, root_id, &st.code_target)
{
ui.ctx().copy_text(code);
ed.status = format!("Copied {} code to clipboard", st.code_target);
}
if ui
.add(egui::Button::new("Copy AI Spec").min_size(egui::vec2(w, 24.0)))
.clicked()
&& let Ok(md) =
crate::mcp::codegen::to_ai_spec_markdown(&ed.doc, root_id, &st.code_target)
{
ui.ctx().copy_text(md);
ed.status = format!("Copied {} AI spec to clipboard", st.code_target);
}
});
}
}
fn nothing_selected(ui: &mut Ui, ed: &mut Editor) {
@@ -877,15 +926,50 @@ fn header(ui: &mut Ui, ed: &mut Editor, ids: &[NodeId], first: &Node) {
first.kind.type_name()
};
ui.label(title_text(format!("{kind} · {}", first.name)));
let is_inst_root = ed.is_instance_root(first.id);
let has_subtree_overrides = is_inst_root
&& ed.doc.subtree(first.id).into_iter().any(|id| {
ed.doc
.get(id)
.and_then(|n| n.instance.as_ref())
.is_some_and(|l| !l.overrides.is_empty())
});
let other_comps: Vec<(NodeId, String)> = if is_inst_root {
let cur_main = ed.instance_main_root(first.id);
let mut v: Vec<(NodeId, String)> = ed
.doc
.nodes
.values()
.filter(|n| n.component && Some(n.id) != cur_main && n.id != first.id)
.map(|n| (n.id, n.name.clone()))
.collect();
v.sort_by_key(|c| c.0);
v
} else {
Vec::new()
};
ui.horizontal_wrapped(|ui| {
if first.component {
if crate::theme::small_button(ui, "Create instance").clicked() {
ed.instance_of_selection();
}
} else if ed.is_instance_root(first.id) {
} else if is_inst_root {
if crate::theme::small_button(ui, "Go to main").clicked() {
ed.go_to_main();
}
if !other_comps.is_empty() {
ui.menu_button("Swap", |ui| {
for (comp_id, cname) in &other_comps {
if ui.button(cname).clicked() {
ed.swap_instance(first.id, *comp_id);
ui.close();
}
}
});
}
if has_subtree_overrides && crate::theme::small_button(ui, "Reset").clicked() {
ed.reset_overrides();
}
if crate::theme::small_button(ui, "Detach").clicked() {
ed.detach_instance();
}
@@ -894,7 +978,8 @@ fn header(ui: &mut Ui, ed: &mut Editor, ids: &[NodeId], first: &Node) {
{
ed.create_component();
}
if first.instance.is_some()
if !is_inst_root
&& first.instance.is_some()
&& first
.instance
.as_ref()
@@ -1437,10 +1522,43 @@ fn fill_row(ui: &mut Ui, ed: &mut Editor, ids: &[NodeId], i: usize, p: &Paint) {
}
match p {
Paint::Solid { color, var } => {
let w = ui.available_width() - 32.0;
if let Some(c) = color_field(ui, ed, *color, var.as_deref(), w) {
let has_vars = !ed.doc.variables.is_empty() || var.is_some();
let w = ui.available_width() - if has_vars { 68.0 } else { 32.0 };
if let Some(c) = color_field(ui, ed, *color, var.as_deref(), w.max(60.0)) {
set(ed, Paint::solid(c));
}
if has_vars {
let vars: Vec<(String, Color)> = ed
.doc
.variables
.iter()
.map(|(k, v)| (k.clone(), *v))
.collect();
let btn_label = if var.is_some() { "var" } else { "--" };
ui.menu_button(btn_label, |ui| {
for (vname, vcol) in &vars {
let active = var.as_deref() == Some(vname.as_str());
if ui
.selectable_label(active, format!("--{vname} ({})", vcol.to_hex()))
.clicked()
{
set(ed, Paint::solid_var(*vcol, vname));
ui.close();
}
}
if var.is_some() {
if !vars.is_empty() {
ui.separator();
}
if ui.button("Unlink variable").clicked() {
let mut unlinked = p.clone();
unlinked.unlink_var();
set(ed, unlinked);
ui.close();
}
}
});
}
}
Paint::Image {
mode,
+12
View File
@@ -71,6 +71,16 @@ pub struct UiState {
pub layer_filter: String,
/// Name typed into the Design tab's new-variable box.
pub variable_draft: String,
/// Name typed into the Design tab's new-mode box.
pub mode_draft: String,
/// Name typed into the Design tab's new-token name box.
pub token_name_draft: String,
/// Value typed into the Design tab's new-token value box.
pub token_val_draft: String,
/// Filter text for the Design tab's components list.
pub component_filter: String,
/// Variable currently being renamed inline `(old_name, draft_new_name)`.
pub var_renaming: Option<(String, String)>,
/// The last tool an agent ran and when, for the toolbar's activity hint.
pub agent: Option<(String, std::time::Instant)>,
/// The browser's remote-agent link (Agent menu); None everywhere but the web shell.
@@ -78,6 +88,8 @@ pub struct UiState {
/// A message shown over the home screen and the canvas (text, is_error); the shell that
/// sets it also clears it. Only the web shell uses it: native shows errors in dialogs.
pub toast: Option<(String, bool)>,
/// Selected codegen target in the inspector's Code & AI Handoff section.
pub code_target: String,
}
/// Where a dragged layer lands relative to a row.
+54 -2
View File
@@ -170,6 +170,22 @@ impl Web {
Editor::default()
};
ed.clipboard_io = Some(Box::new(dom::WebClipboard(mirror.clone())));
let mut session = Session::new(store);
// A share link (`/?share=<token>`) opens the shared project; joining twice is a no-op.
let loc = window().location();
if let (Ok(search), Ok(origin)) = (loc.search(), loc.origin())
&& let Some(token) = search
.trim_start_matches('?')
.split('&')
.find_map(|kv| kv.strip_prefix("share="))
&& let Err(e) = session.join_shared(&format!("{origin}/?share={token}"))
{
log::warn!("share link: {e}");
}
// A relay started with --library keeps every project for every device that opens it.
if !demo && let Ok(origin) = loc.origin() {
session.follow_library(&origin);
}
Ok(Web {
painter: Painter::new(&device, format),
canvas,
@@ -180,7 +196,7 @@ impl Web {
ctx,
ed,
st,
session: Session::new(store),
session,
events: vec![],
mods: Modifiers::NONE,
pointer: None,
@@ -447,9 +463,33 @@ impl Web {
}
}
/// The server this page came from: where "Share…" puts a project unless told otherwise.
fn default_server(&self) -> String {
window().location().origin().unwrap_or_default()
}
/// Look at the shared projects' sync engines (about once a second).
fn tick_sync(&mut self, now: f64) {
if self
.session
.sync_poll(&mut self.ed, web_time::Instant::now())
{
if self.st.home.is_some() {
self.show_home();
}
self.dirty = true;
}
if self.session.sync_deadline().is_some() {
self.wake_at(now + 1000.0);
}
}
fn show_home(&mut self) {
let mut hv = self.st.home.take().unwrap_or_default();
hv.refresh(&self.session.store, crate::project::now_ms());
hv.shared = self.session.shared_info();
hv.default_server = self.default_server();
hv.library = self.session.sync.library_active();
self.st.home = Some(hv);
self.st.project_name.clear();
self.st.save_state.clear();
@@ -503,7 +543,18 @@ impl Web {
HomeAction::Duplicate(dir) => {
store.duplicate(&dir).map(|_| ()).map_err(|e| e.to_string())
}
HomeAction::Trash(dir) => store.trash(&dir).map(|_| ()).map_err(|e| e.to_string()),
HomeAction::Trash(dir) => {
// A trashed project leaves the share (and a library); restoring it brings
// back a local copy.
self.session.sync.drop_project(&dir);
store.trash(&dir).map(|_| ()).map_err(|e| e.to_string())
}
HomeAction::Share(dir, server) => self.session.share_project(&dir, &server),
HomeAction::Join(link) => self.session.join_shared(&link),
HomeAction::Unshare(dir) => {
self.session.sync.unshare(&dir);
Ok(())
}
HomeAction::Restore(dir) => {
store.restore(&dir).map(|_| ()).map_err(|e| e.to_string())
}
@@ -672,6 +723,7 @@ impl Web {
self.handle_requests();
self.handle_home_actions();
self.handle_agent_actions(now);
self.tick_sync(now);
if self.st.library.dirty {
self.st.library.dirty = false;
if let Err(e) = self.st.library.library.save() {
+131
View File
@@ -6,6 +6,7 @@
use papercraft::mcp::Out;
use papercraft::mcp::relay::{self, NO_TAB_ERROR};
use papercraft::mcp::relay_proto::{Call as CallFrame, Reply};
use papercraft::mcp::store::Data;
use rmcp::ServiceExt;
use rmcp::model::{CallToolRequestParams, CallToolResult, ContentBlock};
use serde_json::{Value, json};
@@ -194,6 +195,18 @@ async fn bad_tokens_browsers_and_unknown_routes_are_refused() {
.unwrap();
assert_eq!(r.status(), 403);
// A public hostname in Host is fine: the token is the credential.
let r = http
.post(format!("{base}/s/{good}/mcp"))
.header("content-type", "application/json")
.header("accept", "application/json, text/event-stream")
.header("host", "papercraft.example.com")
.body(init)
.send()
.await
.unwrap();
assert_ne!(r.status(), 403);
// Tokens are 32..=64 lowercase hex characters; anything else is a plain 404.
for (label, bad) in [
("uppercase", good.to_uppercase()),
@@ -294,3 +307,121 @@ async fn static_files_are_served_with_their_type() {
std::fs::remove_dir_all(&dir).ok();
running.stop();
}
/// Shared projects: files round-trip, bad paths are refused, and a second tab hears about the
/// first tab's changes (but a tab does not hear its own).
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shared_projects_store_files_and_tell_the_other_tabs() {
let dir = std::env::temp_dir().join(format!("papercraft-data-{:016x}", fastrand::u64(..)));
let listen: SocketAddr = "127.0.0.1:0".parse().expect("a valid address");
let running = relay::spawn_with_data(
listen,
None,
None,
Some(Data {
dir: dir.clone(),
library: false,
}),
)
.expect("the relay binds");
let port = running.port();
let base = format!("http://127.0.0.1:{port}");
let http = reqwest::Client::new();
let t = token();
// Unknown project: nothing to list.
let r = http
.get(format!("{base}/p/{t}/files"))
.send()
.await
.unwrap();
assert_eq!(r.status(), 404);
// Tab B listens before A writes.
let url = format!("ws://127.0.0.1:{port}/p/{t}/ws?client=b");
let (tx, rx) = std::sync::mpsc::channel::<String>();
std::thread::spawn(move || {
let (mut ws, _) = ws_connect(url).expect("connect");
while let Ok(Ws::Text(m)) = ws.read() {
tx.send(m.to_string()).ok();
}
});
tokio::time::sleep(Duration::from_millis(200)).await;
let put = |path: &str, client: &str| {
http.put(format!("{base}/p/{t}/f/{path}"))
.header("x-client", client)
.body("hello")
.send()
};
assert_eq!(put("pages/p1.json", "a").await.unwrap().status(), 200);
let ev: Value = serde_json::from_str(&rx.recv_timeout(CONNECT_WAIT).unwrap()).unwrap();
assert_eq!(
(ev["op"].as_str(), ev["path"].as_str()),
(Some("put"), Some("pages/p1.json"))
);
// B's own writes are not echoed back to B.
assert_eq!(put("project.json", "b").await.unwrap().status(), 200);
assert!(rx.recv_timeout(Duration::from_millis(300)).is_err());
let r = http
.get(format!("{base}/p/{t}/f/pages/p1.json"))
.send()
.await
.unwrap();
assert_eq!(r.text().await.unwrap(), "hello");
let files: Value = http
.get(format!("{base}/p/{t}/files"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(files.as_array().unwrap().len(), 2);
let r = http
.delete(format!("{base}/p/{t}/f/pages/p1.json"))
.header("x-client", "a")
.send()
.await
.unwrap();
assert_eq!(r.status(), 204);
let ev: Value = serde_json::from_str(&rx.recv_timeout(CONNECT_WAIT).unwrap()).unwrap();
assert_eq!(ev["op"], "del");
let r = http
.get(format!("{base}/p/{t}/f/pages/p1.json"))
.send()
.await
.unwrap();
assert_eq!(r.status(), 404);
// Traversal and bad tokens are plain 404s.
for url in [
format!("{base}/p/{t}/f/%2e%2e/escape"),
format!("{base}/p/{t}/f/a/%2e%2e/%2e%2e/escape"),
format!("{base}/p/ZZ/f/project.json"),
] {
let r = http.put(&url).body("x").send().await.unwrap();
assert_eq!(r.status(), 404, "{url}");
}
assert!(!dir.join("escape").exists());
std::fs::remove_dir_all(&dir).ok();
running.stop();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shared_project_routes_are_off_without_a_data_dir() {
let running = spawn_relay();
let base = format!("http://127.0.0.1:{}", running.port());
let r = reqwest::Client::new()
.put(format!("{base}/p/{}/f/project.json", token()))
.body("x")
.send()
.await
.unwrap();
assert_eq!(r.status(), 404);
running.stop();
}
+193
View File
@@ -0,0 +1,193 @@
//! Shared projects end to end: two project folders kept in step through a real relay.
use papercraft::mcp::relay;
use papercraft::mcp::store::Data;
use papercraft::sync::{self, SHARE_FILE, Share};
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
fn temp(label: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!(
"papercraft-sync-{label}-{:016x}",
fastrand::u64(..)
));
std::fs::create_dir_all(&d).unwrap();
d
}
fn read(dir: &Path, rel: &str) -> Option<String> {
std::fs::read_to_string(dir.join(rel)).ok()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn two_folders_follow_each_other_through_the_relay() {
let data = temp("data");
let listen: SocketAddr = "127.0.0.1:0".parse().unwrap();
let running = relay::spawn_with_data(
listen,
None,
None,
Some(Data {
dir: data.clone(),
library: false,
}),
)
.unwrap();
let server = format!("http://127.0.0.1:{}", running.port());
// A shares a project with two pages.
let a = temp("a");
std::fs::create_dir_all(a.join("pages")).unwrap();
std::fs::write(a.join("project.json"), "manifest v1").unwrap();
std::fs::write(a.join("pages/p1.json"), "page one").unwrap();
let share = Share::new(&server);
share.save(&a).unwrap();
assert!(
!sync::sync_once(&a).await.unwrap(),
"uploading is not an applied change"
);
// B joins from the link and gets everything, but not A's local-only state.
let b = temp("b");
let joined = Share::from_link(&share.link()).unwrap();
joined.save(&b).unwrap();
assert!(sync::sync_once(&b).await.unwrap());
assert_eq!(read(&b, "project.json").as_deref(), Some("manifest v1"));
assert_eq!(read(&b, "pages/p1.json").as_deref(), Some("page one"));
// Nothing changed: both sides settle.
assert!(!sync::sync_once(&b).await.unwrap());
assert!(!sync::sync_once(&a).await.unwrap());
// B edits one page and adds another; A receives both.
std::fs::write(b.join("pages/p1.json"), "page one, edited in B").unwrap();
std::fs::write(b.join("pages/p2.json"), "page two").unwrap();
sync::sync_once(&b).await.unwrap();
assert!(sync::sync_once(&a).await.unwrap());
assert_eq!(
read(&a, "pages/p1.json").as_deref(),
Some("page one, edited in B")
);
assert_eq!(read(&a, "pages/p2.json").as_deref(), Some("page two"));
// A deletes a page; B loses it too.
std::fs::remove_file(a.join("pages/p2.json")).unwrap();
sync::sync_once(&a).await.unwrap();
assert!(sync::sync_once(&b).await.unwrap());
assert!(read(&b, "pages/p2.json").is_none());
// Both edit the same file before syncing: the one that syncs last (the folder) wins.
std::fs::write(a.join("pages/p1.json"), "from A").unwrap();
std::fs::write(b.join("pages/p1.json"), "from B").unwrap();
sync::sync_once(&a).await.unwrap();
sync::sync_once(&b).await.unwrap();
sync::sync_once(&a).await.unwrap();
assert_eq!(read(&a, "pages/p1.json"), read(&b, "pages/p1.json"));
// The sync state never travels: the server holds no .share.
assert!(!data.join(&share.token).join(SHARE_FILE).exists());
for d in [&a, &b, &data] {
std::fs::remove_dir_all(d).ok();
}
running.stop();
}
fn project(root: &Path, name: &str) -> PathBuf {
let store = papercraft::project::Store::open(root).unwrap();
store.create(name).unwrap().0.dir
}
/// Every project directory under `root` that a device would list.
fn names(root: &Path) -> Vec<String> {
let store = papercraft::project::Store::open(root).unwrap();
store.list().into_iter().map(|e| e.manifest.name).collect()
}
fn library_relay(data: &Path, library: bool) -> (relay::Running, String) {
let listen: SocketAddr = "127.0.0.1:0".parse().unwrap();
let data = Data {
dir: data.to_path_buf(),
library,
};
let running = relay::spawn_with_data(listen, None, None, Some(data)).unwrap();
let server = format!("http://127.0.0.1:{}", running.port());
(running, server)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_library_shows_every_project_on_every_device() {
let data = temp("lib-data");
let (_relay, server) = library_relay(&data, true);
// Device A has a project; one library round shares it and a sync uploads it.
let (a, b) = (temp("lib-a"), temp("lib-b"));
let alpha = project(&a, "Alpha");
assert!(sync::library_round(&a, &server).await.unwrap());
let token = Share::load(&alpha).expect("shared by the library").token;
sync::sync_once(&alpha).await.unwrap();
// Device B starts empty, joins what the library lists and downloads it.
assert!(sync::library_round(&b, &server).await.unwrap());
let joined = b.join(format!("shared-{}", &token[..8]));
sync::sync_once(&joined).await.unwrap();
assert_eq!(names(&b), ["Alpha"]);
// Another round changes nothing (no duplicate join, no re-share).
sync::library_round(&b, &server).await.unwrap();
sync::library_round(&a, &server).await.unwrap();
assert_eq!(names(&a), ["Alpha"]);
assert_eq!(names(&b), ["Alpha"]);
// B deletes it on the server: the data is kept in .trash, A is told it is deleted.
let http = reqwest::Client::new();
let res = http
.delete(format!("{server}/p/{token}"))
.send()
.await
.unwrap();
assert_eq!(res.status(), 204);
assert!(data.join(".trash").read_dir().unwrap().next().is_some());
let listed = http
.get(format!("{server}/library"))
.send()
.await
.unwrap()
.text()
.await
.unwrap();
assert!(!listed.contains(&token), "{listed}");
match sync::sync_once(&alpha).await {
Err(sync::SyncError::Gone(sync::Gone::Deleted)) => {}
other => panic!("expected Gone(Deleted), got {other:?}"),
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_server_that_lost_its_data_does_not_delete_projects() {
let data = temp("lost-data");
let (_relay, server) = library_relay(&data, true);
let a = temp("lost-a");
let proj = project(&a, "Keep me");
sync::library_round(&a, &server).await.unwrap();
sync::sync_once(&proj).await.unwrap();
// The server's disk is wiped.
let token = Share::load(&proj).unwrap().token;
std::fs::remove_dir_all(data.join(&token)).unwrap();
match sync::sync_once(&proj).await {
Err(sync::SyncError::Gone(sync::Gone::Lost)) => {}
other => panic!("expected Gone(Lost), got {other:?}"),
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_relay_without_library_says_so() {
let (_relay, server) = library_relay(&temp("plain-data"), false);
let a = temp("plain-a");
project(&a, "Private");
assert!(!sync::library_round(&a, &server).await.unwrap());
// Nothing was shared behind the user's back.
assert!(names(&a).len() == 1);
let projects = papercraft::project::Store::open(&a).unwrap().list();
assert!(Share::load(&projects[0].dir).is_none());
}