14 Commits

Author SHA1 Message Date
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
Marcos Paulo 057ba30908 chore: release 0.2.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 20:48:21 -03:00
Marcos Paulo 6e7b7e756a feat(web): remote-agent relay and browser gap closure (import, export, clipboard, thumbnails, persistence, backup)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 20:34:14 -03:00
Marcos Paulo 9e478207bb docs(specs): web relay for remote agents and browser gap closure
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:44:36 -03:00
Marcos Paulo 7c56a8d209 feat(web): projects in the browser (vfs over IndexedDB, home screen, autosave)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:41:01 -03:00
Marcos Paulo 01a01129be feat(ui): Figma-style inspector, flat menus, open projects folder, gentler ctrl+wheel zoom
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:37:54 -03:00
Marcos Paulo a1ba859aac feat(web): browser build (canvas + WebGPU shell), native-only deps gated for wasm32
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:37:54 -03:00
66 changed files with 14620 additions and 432 deletions
+7
View File
@@ -0,0 +1,7 @@
target/
analysis/
.git/
.worktrees/
web/pkg/
dist/
packaging/
+2
View File
@@ -3,3 +3,5 @@ target/
dist/
*.asar
app.asar.unpacked/
web/pkg/
.worktrees/
Generated
+404 -16
View File
@@ -222,7 +222,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [
"axum-core",
"base64 0.22.1",
"bytes",
"form_urlencoded",
"futures-util",
"http",
"http-body",
@@ -236,8 +238,13 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"serde_core",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sha1",
"sync_wrapper",
"tokio",
"tokio-tungstenite",
"tower",
"tower-layer",
"tower-service",
@@ -261,6 +268,12 @@ dependencies = [
"tower-service",
]
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64"
version = "0.23.1"
@@ -294,6 +307,15 @@ version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "block2"
version = "0.5.1"
@@ -431,8 +453,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures",
"rand_core",
"cpufeatures 0.3.1",
"rand_core 0.10.1",
]
[[package]]
@@ -507,6 +529,26 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "console_error_panic_hook"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
dependencies = [
"cfg-if",
"wasm-bindgen",
]
[[package]]
name = "console_log"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86919cef3e37b9356ccf54d4421208c17ecfda01beae61393e7ffd72916c0ef1"
dependencies = [
"log",
"web-sys",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -517,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"
@@ -530,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",
]
@@ -543,7 +595,16 @@ 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",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
@@ -594,12 +655,28 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "cursor-icon"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]]
name = "data-encoding"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "data-url"
version = "0.3.2"
@@ -637,6 +714,16 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "dirs"
version = "6.0.0"
@@ -985,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"
@@ -992,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]]
@@ -1006,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"
@@ -1109,6 +1211,16 @@ dependencies = [
"slab",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "gethostname"
version = "1.1.0"
@@ -1140,7 +1252,7 @@ dependencies = [
"cfg-if",
"libc",
"r-efi 6.0.0",
"rand_core",
"rand_core 0.10.1",
]
[[package]]
@@ -1339,13 +1451,29 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
dependencies = [
"base64",
"base64 0.23.1",
"bytes",
"futures-channel",
"futures-util",
@@ -2017,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"
@@ -2485,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"
@@ -2521,12 +2709,14 @@ dependencies = [
[[package]]
name = "papercraft"
version = "0.1.1"
version = "0.3.0"
dependencies = [
"arboard",
"axum",
"base64",
"base64 0.23.1",
"bytemuck",
"console_error_panic_hook",
"console_log",
"craft-ui",
"dirs",
"egui",
@@ -2536,6 +2726,7 @@ dependencies = [
"fastrand",
"i_overlay",
"image",
"js-sys",
"log",
"lyon",
"miniz_oxide 0.9.1",
@@ -2552,9 +2743,14 @@ dependencies = [
"serde",
"serde_json",
"tokio",
"tokio-tungstenite",
"tokio-util",
"ttf-parser",
"usvg",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"web-time",
"wgpu",
"winit",
"winresource",
@@ -2718,6 +2914,15 @@ dependencies = [
"zerovec",
]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "presser"
version = "0.3.1"
@@ -2804,6 +3009,16 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [
"rand_chacha",
"rand_core 0.9.5",
]
[[package]]
name = "rand"
version = "0.10.3"
@@ -2812,7 +3027,26 @@ checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
dependencies = [
"chacha20",
"getrandom 0.4.3",
"rand_core",
"rand_core 0.10.1",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
@@ -2954,7 +3188,7 @@ version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029"
dependencies = [
"base64",
"base64 0.23.1",
"bytes",
"futures-core",
"futures-util",
@@ -2962,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",
@@ -3043,7 +3281,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab5023804cbe0304cbe06197ac16e645a0a0d0ffa2953bf735c15c2fa0213350"
dependencies = [
"async-trait",
"base64",
"base64 0.23.1",
"bytes",
"chrono",
"futures",
@@ -3054,7 +3292,7 @@ dependencies = [
"pastey",
"pin-project-lite",
"process-wrap",
"rand",
"rand 0.10.3",
"reqwest",
"schemars",
"serde",
@@ -3131,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"
@@ -3146,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"
@@ -3197,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"
@@ -3263,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"
@@ -3272,6 +3568,29 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest",
]
[[package]]
name = "shlex"
version = "2.0.1"
@@ -3533,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"
@@ -3690,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"
@@ -3701,6 +4043,18 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-tungstenite"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
dependencies = [
"futures-util",
"log",
"tokio",
"tungstenite",
]
[[package]]
name = "tokio-util"
version = "0.7.20"
@@ -3855,6 +4209,22 @@ version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
[[package]]
name = "tungstenite"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
dependencies = [
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand 0.9.5",
"sha1",
"thiserror 2.0.21",
]
[[package]]
name = "type-map"
version = "0.5.1"
@@ -3864,6 +4234,12 @@ dependencies = [
"rustc-hash 2.1.3",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-bidi"
version = "0.3.18"
@@ -3924,7 +4300,7 @@ version = "0.48.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "977d0a4abdef933f424a99fe09f95576e089b90aebc6f016a3bc813762493e91"
dependencies = [
"base64",
"base64 0.23.1",
"data-url",
"flate2",
"fontdb",
@@ -3969,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"
@@ -4764,7 +5146,7 @@ dependencies = [
"calloop 0.13.0",
"cfg_aliases",
"concurrent-queue",
"core-foundation",
"core-foundation 0.9.4",
"core-graphics",
"cursor-icon",
"dpi",
@@ -4965,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"
+39 -8
View File
@@ -1,6 +1,6 @@
[package]
name = "papercraft"
version = "0.1.1"
version = "0.3.0"
edition = "2024"
rust-version = "1.95"
description = "A native infinite-canvas app for planning, working and sketching"
@@ -22,18 +22,21 @@ path = "src/bin/papercraft-mcp.rs"
name = "papercraft-cli"
path = "src/bin/papercraft-cli.rs"
[[bin]]
name = "papercraft-relay"
path = "src/bin/papercraft-relay.rs"
[dependencies]
egui = "0.36"
egui-wgpu = "0.36"
egui-winit = "0.36"
wgpu = "30"
winit = "0.30"
lyon = "1"
ttf-parser = "0.25"
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp"] }
pollster = "0.4"
web-time = "1"
bytemuck = { version = "1", features = ["derive"] }
log = "0.4"
env_logger = "0.11"
@@ -44,11 +47,7 @@ craft-ui = { version = "0.1.0", git = "https://git.marcospaulo.dev.br/masi/craft
i_overlay = "9.0.1"
miniz_oxide = "0.9.1"
usvg = "0.48"
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"] }
axum = { version = "0.8", default-features = false, features = ["http1", "tokio"] }
tokio-util = "0.7"
tokio = { version = "1", features = ["sync"] }
base64 = "0.23"
[build-dependencies]
@@ -57,6 +56,14 @@ winresource = "0.1"
[profile.release]
debug = false
# Browser bundle only (`web/build.sh`): size over compile time. Native release is untouched.
[profile.web]
inherits = "release"
lto = true
codegen-units = 1
panic = "abort"
opt-level = "z"
[profile.dev.package."*"]
opt-level = 2
@@ -64,6 +71,8 @@ opt-level = 2
resvg = "0.48"
rmcp = { version = "3.5.1", default-features = false, features = ["transport-child-process"] }
reqwest = { version = "0.13", default-features = false }
# The relay test drives a fake tab over a real WebSocket; same version axum's `ws` uses.
tokio-tungstenite = "0.29"
[target.'cfg(target_os = "macos")'.dependencies]
muda = { version = "0.21", default-features = false }
@@ -71,3 +80,25 @@ objc2 = "0.6"
objc2-app-kit = "0.3"
objc2-foundation = "0.3"
raw-window-handle = "0.6"
# Native only: the OS clipboard and the MCP HTTP server do not exist in the browser.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
egui-winit = "0.36"
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", "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]
wgpu = { version = "30", features = ["webgl"] }
wasm-bindgen = "0.2"
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", "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. |
+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
+21 -10
View File
@@ -66,7 +66,10 @@ 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) |
| New screen | `create_artboard` (`width` and `height` required) |
@@ -74,23 +77,31 @@ Full flows: [recipes.md](references/recipes.md).
| 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>` |
| 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>` |
| 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 another page). 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.
* **Text boxes with fixed widths can wrap earlier than in a browser.** Check headlines in the screenshot and widen.
@@ -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`. |
+61 -5
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}.
@@ -320,12 +354,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`
@@ -380,6 +414,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 +449,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. |
+61
View File
@@ -0,0 +1,61 @@
# 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.
+209
View File
@@ -0,0 +1,209 @@
# Web build: remote agent relay + closing the browser gaps
Audience: a coding agent (pi, MiniMax) with no prior context. Everything needed is here.
Work in the git worktree you were started in. Do not commit; leave changes in the working tree.
## 1. What PaperCraft is
PaperCraft is a native infinite-canvas design tool in Rust (edition 2024, rustc >= 1.95).
Crate `papercraft` = library (`src/lib.rs`) + bins (`papercraft`, `papercraft-cli`, `papercraft-mcp`).
UI is egui 0.36 + egui-wgpu; the canvas is drawn by our own wgpu renderer (`src/render/`).
AI agents drive the app over MCP (`src/mcp/`): tool catalogue in `mcp/tools.rs`, executor in
`mcp/exec.rs` (trait `Host`), rmcp HTTP server in `mcp/server.rs` (native only).
## 2. What exists on this branch (already working, do not redo)
A browser build exists (`wasm32-unknown-unknown`):
- `src/web.rs`: browser shell. Canvas + wgpu surface (WebGPU, WebGL fallback), DOM events
translated to egui `RawInput` and to editor `InputEvent`s, requestAnimationFrame loop, home
screen (projects), autosave, `pagehide` save. `Web` struct owns `Editor`, `UiState`, `Session`.
`#demo` in the URL opens the sample board.
- `src/vfs/`: file-system shim. Native = `std::fs` re-export. Wasm (`vfs/mem.rs`) = in-memory tree
mirrored to IndexedDB (`idb_put`/`idb_delete` fire-and-forget; `vfs::load().await` at startup).
Projects live under `/papercraft` (`WEB_ROOT` in web.rs). All code paths that touch files must go
through `crate::vfs`, never `std::fs` / `image::open` / `File::open` (those do not work in wasm).
- Native-only things are gated with `#[cfg(not(target_arch = "wasm32"))]`: `app.rs` (winit shell),
`mcp::server`, arboard clipboard, `rmcp`/`axum`/`tokio` net features (see `Cargo.toml` target tables).
- `web/index.html`, `web/build.sh` (builds `web/pkg`). `web_time::Instant` is used instead of std.
Build/verify commands (ALWAYS put rustup first: Homebrew's cargo is too old):
export PATH="$HOME/.cargo/bin:$PATH"
cargo +stable test --lib # must stay 112+ passing
cargo +stable check --all-targets # native
cargo +stable check --lib --target wasm32-unknown-unknown # browser
cargo +stable check --target x86_64-pc-windows-msvc # windows
./web/build.sh # -> web/pkg (needs wasm-bindgen-cli 0.2.129, installed)
cargo +stable fmt -- <only the files you changed> # do NOT run plain `cargo fmt`; then `git checkout build.rs`
No browser is available in your environment, so you cannot run the web build. Verify with the
checks above plus native tests. Write the logic so it is unit-testable natively where possible
(pure functions, protocol structs, zip writer, byte-based importers).
## 3. Goal
A. Let a remote AI agent drive a PaperCraft tab running in someone's browser (hosted deployment).
B. Close the browser gaps listed in section 5.
## 4. Part A: relay for remote agents
### Architecture
agent --(MCP streamable HTTP)--> relay <--(WebSocket)-- browser tab
POST /s/{token}/mcp GET /tab/{token}
The tab already contains the full executor (`mcp::exec::call(host, name, args)`), so the relay
only forwards tool calls; it holds no document state.
### A1. `src/bin/papercraft-relay.rs` (native binary)
- Add it to `Cargo.toml` as a `[[bin]]`. Needs axum `ws` feature (extend the existing axum entry
in the NON-wasm target table; keep default-features = false). Prefer reusing `mcp::server`
(`PaperServer`, `StreamableHttpService`, `Dispatch`, `Call`) so the tool list and instructions
stay identical to the desktop server. Study `mcp/server.rs::spawn` first; if the service cannot be
mounted under a dynamic path `/s/{token}/mcp`, create one service per token lazily and route by
token, or expose a small refactor in `mcp/server.rs` (keep desktop behaviour unchanged).
- CLI: `papercraft-relay [--listen 0.0.0.0:8787] [--static <dir>]` (env `PAPERCRAFT_RELAY_LISTEN`).
`--static` serves the built web app (`web/index.html`, `web/pkg/*`) from the same origin with
correct MIME types (`.wasm` = `application/wasm`, `.js`, `.html`, `.css`, `.png`, `.svg`), so one
process hosts everything. Implement with `tokio::fs` + a path-traversal check; no new crate unless
unavoidable.
- `GET /tab/{token}`: WebSocket for the browser. One tab per token; a new connection replaces the
old one (close the old with a reason).
- `POST|GET|DELETE /s/{token}/mcp`: streamable-HTTP MCP endpoint for agents.
- Token = 32..64 lowercase hex chars chosen by the tab. Reject anything else with 404. It is a
bearer secret: never log it, never put it in error messages. The relay stores nothing on disk.
- Origin policy: `/tab/*` accepts browser origins (optionally restricted by `--allow-origin`);
`/s/*` rejects requests that carry an `Origin` header (agents are not browsers).
- A tool call is forwarded to the tab, awaited with a 120 s timeout (same as `CALL_TIMEOUT`).
No tab connected => the tool result is an error text:
"No PaperCraft tab is connected for this link. Open PaperCraft and use Agent > Connect."
- Limits: max WebSocket message 16 MiB; ping every 20 s; drop dead sockets; max 8 concurrent
in-flight calls per token (others wait).
### A2. Wire protocol (JSON text frames, UTF-8)
relay -> tab: `{"id": 7, "name": "get_screenshot", "args": { ... }}`
tab -> relay (success): `{"id": 7, "ok": true, "out": [{"type":"text","text":"..."},{"type":"image","mime":"image/png","data":"<base64>"}]}`
tab -> relay (failure): `{"id": 7, "ok": false, "error": "message"}`
Keep-alive: WebSocket ping/pong. Define the frame structs once in a module compiled for both
native and wasm (e.g. `src/mcp/relay_proto.rs`, serde only) and unit-test the (de)serialisation.
### A3. Tab side (`src/web.rs`; split into `src/web/mod.rs` + `src/web/relay.rs` if it helps)
- `web-sys` WebSocket (add features `WebSocket`, `MessageEvent`, `CloseEvent`, `ErrorEvent`,
`Storage`, plus whatever else you need) with auto-reconnect (backoff 1 s -> 15 s).
- Token: generated once with `crypto.getRandomValues` (16 bytes -> 32 hex), stored in
`localStorage["papercraft.relay.token"]`; "Regenerate" action replaces it and reconnects.
- Relay base URL: query param `?relay=wss://host` > `window.PAPERCRAFT_RELAY` > same origin
(`wss://` when the page is https, else `ws://`).
- Incoming call => run `mcp::exec::call(&mut web_host, name, &args)` on the main thread inside the
frame loop (queue them; process at the start of `frame()` so edits and repaint stay in one place;
set `dirty = true`). Implement `mcp::exec::Host` for `Web` mirroring `impl Host for App` in
`src/app.rs` (open_project, save, project, canvas_open, store, editor).
- `Host::gpu()` returns `Option<Headless>` and the native renderer blocks on GPU readback, which is
impossible in wasm. Tools that need pixels (`get_screenshot`, PNG export) must work on the web:
design an async path (e.g. `Host::gpu()` returns None on web and the executor returns a
"needs render" marker; the shell then renders the requested frame/region offscreen, reads it back
with `wgpu::Buffer::map_async` + a JS Promise or via `canvas.toBlob`, and answers the call
later). Keep the desktop code path and its tests unchanged. Document the design in a comment.
- UI: new top-level menu "Agent" (web only) with: "Connect…" opening a small egui window showing the
full agent URL `https://<host>/s/<token>/mcp` with a Copy button, connection state
(Connected / Connecting / Offline), "Regenerate link", and ready-to-paste snippets for
Claude Code (`claude mcp add --transport http papercraft <url>`) and a generic MCP JSON config.
A small status dot in the toolbar when connected is enough. Menus are built in
`src/ui/mod.rs::menu_bar` / `editor/shortcuts.rs::menus()`; the tab needs a way to add an item
without touching the native menus (e.g. a `UiState` flag set only by `web.rs`).
### A4. Acceptance for Part A
- `cargo +stable test` has an integration test (`tests/relay.rs`) that starts the relay on port 0,
connects a fake tab (tokio-tungstenite as dev-dependency is fine), calls a tool through the MCP
HTTP endpoint (use rmcp client as `tests/` already does for the desktop server if present, else raw
JSON-RPC), and asserts the round trip, the no-tab error, token validation, and Origin rejection.
- Wasm and native and windows checks pass; no new warnings.
- `docs/` or `web/README.md` is NOT required (do not create README files).
## 5. Part B: close the browser gaps
Currently these show "Not available in the browser yet" or are broken in the browser:
B1. **Import** (`HomeAction::Import`, `AppRequest::PlaceImage`, `AppRequest::ImageFill`, drag-drop):
use a hidden `<input type=file>` and `dragover/drop` on the canvas. `io::import::import_as_pages`
takes a `&Path`; add a bytes-based entry (`import_bytes(name, bytes)`) used by both the native
wrapper and the web, supporting `.papercraft`, `.excalidraw`, `.svg`, and images (png/jpg/gif/webp).
Native behaviour must not change. Also add importing a project backup zip (see B6).
B2. **Export** (`AppRequest::Export`, `ExportFormat`, inspector Export section): PNG, SVG, PDF built
by the existing `io::export` code, delivered as a browser download (Blob + temporary
`<a download>`). PNG needs the async GPU readback from A3: share that implementation.
B3. **Clipboard**: `Editor.clipboard_io: Option<Box<dyn io::clipboard::Provider>>` is a sync trait.
Implement `WebClipboard` as an in-memory mirror: on copy/cut write text to the mirror AND
`navigator.clipboard.writeText` (fire and forget); on the DOM `paste` event (don't
preventDefault on Ctrl/Cmd+V) fill the mirror from `clipboardData` (text/plain, and image files
decoded with the `image` crate), then trigger the editor's paste action. `paste` text already
reaches egui text fields via `egui::Event::Paste`; keep that working. Also wire `copy`/`cut` DOM
events. `AppRequest::CopyAsPng` = render + `navigator.clipboard.write(ClipboardItem)`.
B4. **Thumbnails**: `Session::write_thumbnail(&Headless, &Editor)` blocks on GPU readback. Produce
the home-screen thumbnail on the web with the async path, write `thumbnail.png` into the project
folder via `vfs`. Also fix `ui/home.rs::thumb` (line ~64) which uses `image::open(path)`: read
bytes with `vfs::read` and decode with `image::load_from_memory`. Grep the whole tree for other
`image::open`, `File::open`, `std::fs`, `std::path::Path::{exists,is_file,is_dir}` uses on
paths that the web build reaches, and route them through `vfs`.
B5. **Settings persistence**: `theme::Choice::path()` returns None on wasm (`dirs::config_dir`). On
wasm use the fixed vfs path `/config/theme` so the theme choice survives reloads. Likewise the
shape library: native loads `Library::load(Library::path_in(&store.root))` in `App::new`; the web
shell currently never loads or saves it (`st.library.library` stays default). Do the same in web.rs
(load at start; save when `st.library.dirty`, see `app.rs`).
B6. **"Open projects folder"** has no meaning in a browser. On wasm, `HomeAction::RevealRoot` becomes
"Download backup (.zip)": a zip of the whole `/papercraft` tree built in memory. Write a small
store-only (no compression) zip writer (pure function over `Vec<(String, Vec<u8>)>`, CRC-32
implemented locally or via an installed crate; `miniz_oxide` is already a dependency) and a
matching reader used by B1's backup import. Label the sidebar item per platform
(`ui/home.rs`: "Open projects folder" native, "Download backup" web).
B7. **Errors**: the web shell only logs and sets `ed.status`. Show errors to the user (the status
bar already renders `ed.status`; make sure it is visible and cleared sensibly), and surface
"Not saved" persistence failures (IndexedDB write errors currently ignored in `vfs/mem.rs`:
keep a shared "last persistence error" and show it).
B8. **Bundle size**: release wasm is ~17 MB. In `web/build.sh` run `wasm-opt -Oz` when available
(skip silently otherwise) and enable `lto = true`, `codegen-units = 1`, `panic = "abort"`
only for a `[profile.web]` that inherits release (do not change the native release profile).
### Acceptance for Part B
- Everything above works through code paths that compile for wasm; native behaviour and all
existing tests unchanged (`cargo +stable test --lib` passes, same or higher count).
- New native unit tests for the pure parts: bytes importer, zip writer/reader round trip, relay
protocol frames, any path/format helpers.
- No remaining user-visible "Not available in the browser yet" for B1-B6 features. Features that
still cannot work (`HomeAction::RevealRoot` native-only semantics) must be explained in code
comments only where non-obvious.
## 6. Constraints (project rules)
- Do exactly this spec. No unrelated refactors, renames, formatting sweeps, or new README/docs.
- Smallest correct diff; reuse existing code (`mcp::exec`, `io::export`, `io::import`, `Session`,
`HomeView`) before writing new code. New dependencies only if unavoidable; list each in your report
with the reason. Never add dependencies to the native-only table that the wasm build needs, and
vice versa.
- Rust style of the surrounding code: named items, early returns, no `unwrap()` on user/IO input,
no magic numbers (named consts), comments only for non-obvious "why".
- `std::time::Instant/SystemTime` panic on wasm: use `web_time` / `crate::project::now_ms`.
- Blocking calls (`pollster::block_on`, `device.poll(Wait)`, `std::thread`, sockets) are forbidden in
code that compiles for wasm.
- No secrets in code or logs. The relay token is a secret.
- Write files in chunks of at most ~300 lines per tool call (your output limit per turn is small).
- Do not touch `build.rs` formatting; do not edit `Cargo.lock` by hand (cargo updates it).
- Do not commit.
## 7. Work order (stop and report on failure)
1. A2 protocol module + tests. 2. A1 relay + `tests/relay.rs`. 3. A3 tab side (without async
screenshot). 4. Async GPU readback (shared by A3, B2, B4). 5. B1, B3, B5, B6, B7. 6. B2, B4.
7. B8. After each step run the three `cargo check` commands and `cargo +stable test --lib`.
## 8. Final report (print at the end)
List: files changed/added, new dependencies and why, each acceptance item with pass/fail evidence
(command output summaries), anything not done and why, and what must be tested manually in a real
browser (exact steps).
+57 -6
View File
@@ -12,7 +12,8 @@ use crate::ui::home::HomeAction;
use crate::ui::{self, UiState};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use std::time::Duration;
use web_time::Instant;
use winit::application::ApplicationHandler;
use winit::event::{ElementState, MouseButton, MouseScrollDelta, TouchPhase, WindowEvent};
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
@@ -218,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();
@@ -329,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())
}
@@ -337,6 +368,21 @@ impl App {
store.delete_forever(&dir).map_err(|e| e.to_string())
}
HomeAction::EmptyTrash => store.empty_trash().map_err(|e| e.to_string()),
HomeAction::RevealRoot => {
let opener = if cfg!(windows) {
"explorer"
} else if cfg!(target_os = "macos") {
"open"
} else {
"xdg-open"
};
// explorer exits non-zero even on success, so only spawn failure counts.
std::process::Command::new(opener)
.arg(&store.root)
.spawn()
.map(|_| ())
.map_err(|e| format!("cannot open {}: {e}", store.root.display()))
}
};
if let Err(e) = r {
self.error(e);
@@ -1261,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) {
+4 -4
View File
@@ -90,11 +90,11 @@ pub fn get(id: &str) -> Option<Arc<ImageAsset>> {
/// Register every image in a project's `assets/` folder.
pub fn load_dir(dir: &Path) {
let Ok(rd) = std::fs::read_dir(dir) else {
let Ok(rd) = crate::vfs::read_dir(dir) else {
return;
};
for e in rd.flatten() {
if let Ok(bytes) = std::fs::read(e.path())
if let Ok(bytes) = crate::vfs::read(e.path())
&& let Err(err) = register(&bytes)
{
log::warn!("{}: {err}", e.path().display());
@@ -107,8 +107,8 @@ pub fn save_to_dir(dir: &Path, ids: impl IntoIterator<Item = String>) -> std::io
for id in ids {
let Some(a) = get(&id) else { continue };
let path = dir.join(format!("{id}.{}", a.ext));
if !path.exists() {
std::fs::create_dir_all(dir)?;
if !crate::vfs::exists(&path) {
crate::vfs::create_dir_all(dir)?;
crate::io::atomic::write(&path, &a.bytes)?;
}
}
+127
View File
@@ -0,0 +1,127 @@
//! `papercraft-relay`: serves MCP for a PaperCraft tab running in a browser.
//!
//! The tab connects to `GET /tab/<token>` and an agent to `POST /s/<token>/mcp`; the relay
//! forwards tool calls between them and holds no document state. `--static` also serves the
//! 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;
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>] [--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
http://<host>:<port>/s/<token>/mcp
";
/// Address used when neither `--listen` nor `$PAPERCRAFT_RELAY_LISTEN` says otherwise.
const DEFAULT_LISTEN: &str = "0.0.0.0:8787";
const ENV_LISTEN: &str = "PAPERCRAFT_RELAY_LISTEN";
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"));
match arg.as_str() {
"--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}")),
}
}
Ok(Args {
listen,
static_dir,
allow_origin,
data_dir,
library,
})
}
fn main() -> ExitCode {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
if std::env::args().any(|a| a == "--help" || a == "-h") {
print!("{USAGE}");
return ExitCode::SUCCESS;
}
let args = match parse_args() {
Ok(a) => a,
Err(e) => {
eprintln!("papercraft-relay: {e}");
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(_) => {
eprintln!(
"papercraft-relay: '{}' is not an address like 0.0.0.0:8787 (set {ENV_LISTEN} to change it)",
args.listen
);
return ExitCode::FAILURE;
}
};
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}");
return ExitCode::FAILURE;
}
};
// The port is the real one, which matters when the request asked for 0.
println!("papercraft-relay: listening on port {}", running.port());
if let Some(dir) = &args.static_dir {
println!("papercraft-relay: serving {}", dir.display());
}
println!(
"papercraft-relay: agent endpoints are http://<this host>:{}/s/<token>/mcp",
running.port()
);
// Serve until the process is signalled; `running` then stops the server on the way out.
loop {
std::thread::park();
}
}
+4 -1
View File
@@ -104,6 +104,9 @@ impl Tool {
}
}
/// Ctrl+wheel zoom rate: one 40px wheel notch is about 13%.
const ZOOM_PER_SCROLL_PX: f64 = 0.003;
/// Requests the editor can't fulfil itself (dialogs, file system, clipboard).
#[derive(Debug, Clone, PartialEq)]
pub enum AppRequest {
@@ -633,7 +636,7 @@ impl Editor {
}
InputEvent::Scroll { delta, pos } => {
if self.mods.ctrl || self.mods.logo {
let factor = (-delta.y * 0.01).exp();
let factor = (-delta.y * ZOOM_PER_SCROLL_PX).exp();
self.camera.zoom_about(pos, self.camera.zoom * factor);
} else {
self.camera.pan_by_screen(-delta);
+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
}
+1 -1
View File
@@ -82,7 +82,7 @@ fn wheel_pans_and_ctrl_wheel_zooms_about_cursor() {
..Modifiers::NONE
}));
e.handle(InputEvent::Scroll {
delta: v2(0.0, -100.0),
delta: v2(0.0, -400.0),
pos: anchor,
});
assert!(e.camera.zoom > 2.5);
+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());
+14 -12
View File
@@ -66,22 +66,24 @@ pub fn dir() -> PathBuf {
let data = dirs::data_dir()?;
let new = data.join("papercraft").join("icons");
let legacy = data.join("paper-rs").join("icons");
Some(if legacy.is_dir() && !new.exists() {
legacy
} else {
new
})
Some(
if crate::vfs::is_dir(&legacy) && !crate::vfs::exists(&new) {
legacy
} else {
new
},
)
})
.unwrap_or_else(|| PathBuf::from("icons"))
}
/// Installed sets with their icon counts.
pub fn installed(root: &Path) -> Vec<(String, usize)> {
let mut out: Vec<(String, usize)> = std::fs::read_dir(root)
let mut out: Vec<(String, usize)> = crate::vfs::read_dir(root)
.into_iter()
.flatten()
.flatten()
.filter(|e| e.path().is_dir())
.filter(|e| crate::vfs::is_dir(&e.path()))
.map(|e| {
let n = names(root, &e.file_name().to_string_lossy()).len();
(e.file_name().to_string_lossy().into_owned(), n)
@@ -93,7 +95,7 @@ pub fn installed(root: &Path) -> Vec<(String, usize)> {
/// Icon names (without `.svg`) in a set, sorted.
pub fn names(root: &Path, set: &str) -> Vec<String> {
let mut v: Vec<String> = std::fs::read_dir(root.join(set))
let mut v: Vec<String> = crate::vfs::read_dir(root.join(set))
.into_iter()
.flatten()
.flatten()
@@ -155,7 +157,7 @@ pub fn load(root: &Path, reference: &str) -> Result<String, String> {
None => installed(root).into_iter().map(|(s, _)| s).collect(),
};
for s in &sets {
if let Ok(svg) = std::fs::read_to_string(root.join(s).join(format!("{name}.svg"))) {
if let Ok(svg) = crate::vfs::read_to_string(root.join(s).join(format!("{name}.svg"))) {
return Ok(svg);
}
}
@@ -185,9 +187,9 @@ mod tests {
let root =
std::env::temp_dir().join(format!("papercraft-icons-{:016x}", fastrand::u64(..)));
let set = root.join("t");
std::fs::create_dir_all(&set).unwrap();
crate::vfs::create_dir_all(&set).unwrap();
for n in ["check", "check-circle", "arrow-check", "x"] {
std::fs::write(set.join(format!("{n}.svg")), "<svg/>").unwrap();
crate::vfs::write(set.join(format!("{n}.svg")), "<svg/>").unwrap();
}
assert!(load(&root, "../etc/passwd").is_err());
assert!(load(&root, "t:../x").is_err());
@@ -203,6 +205,6 @@ mod tests {
.unwrap_err()
.contains("no icon sets")
);
std::fs::remove_dir_all(root).ok();
crate::vfs::remove_dir_all(root).ok();
}
}
+16 -5
View File
@@ -5,9 +5,13 @@
//! platform we target, so readers see either the old file or the new one, never a torn write.
//! On Unix the directory is fsynced too, so the rename itself survives a power loss.
#[cfg(not(target_arch = "wasm32"))]
use std::io::Write;
use std::path::{Path, PathBuf};
use std::path::Path;
#[cfg(not(target_arch = "wasm32"))]
use std::path::PathBuf;
#[cfg(not(target_arch = "wasm32"))]
fn temp_path(path: &Path) -> PathBuf {
let name = path
.file_name()
@@ -20,7 +24,14 @@ fn temp_path(path: &Path) -> PathBuf {
))
}
/// The in-memory tree has no torn writes.
#[cfg(target_arch = "wasm32")]
pub fn write(path: &Path, data: &[u8]) -> std::io::Result<()> {
crate::vfs::write(path, data)
}
/// Atomically replace `path` with `data`.
#[cfg(not(target_arch = "wasm32"))]
pub fn write(path: &Path, data: &[u8]) -> std::io::Result<()> {
let tmp = temp_path(path);
let result = (|| {
@@ -38,25 +49,25 @@ pub fn write(path: &Path, data: &[u8]) -> std::io::Result<()> {
result
}
#[cfg(unix)]
#[cfg(all(unix, not(target_arch = "wasm32")))]
fn sync_dir(dir: &Path) {
if let Ok(d) = std::fs::File::open(dir) {
let _ = d.sync_all();
}
}
#[cfg(not(unix))]
#[cfg(all(not(unix), not(target_arch = "wasm32")))]
fn sync_dir(_dir: &Path) {}
/// Remove temp files left behind by a crash mid-write (best effort).
pub fn clean_stale_temps(dir: &Path) {
let Ok(rd) = std::fs::read_dir(dir) else {
let Ok(rd) = crate::vfs::read_dir(dir) else {
return;
};
for e in rd.flatten() {
let name = e.file_name().to_string_lossy().to_string();
if name.starts_with('.') && name.ends_with(".tmp") {
let _ = std::fs::remove_file(e.path());
let _ = crate::vfs::remove_file(e.path());
}
}
}
+139
View File
@@ -0,0 +1,139 @@
//! Whole-library backups as a zip of the store folder, so a backup made in the browser (where
//! there is no folder to open) can be restored there or anywhere else. Restoring brings back
//! the projects only; the trash and the shape library are left as they are.
use super::zip::{self, Entry};
use crate::project::TRASH_DIR;
use std::collections::HashSet;
use std::path::{Component, Path, PathBuf};
fn collect(dir: &Path, prefix: &str, out: &mut Vec<Entry>) -> std::io::Result<()> {
let mut entries: Vec<_> = crate::vfs::read_dir(dir)?.collect::<Result<_, _>>()?;
entries.sort_by_key(|e| e.file_name());
for e in entries {
let name = format!("{prefix}{}", e.file_name().to_string_lossy());
if e.file_type()?.is_dir() {
collect(&e.path(), &format!("{name}/"), out)?;
} else {
out.push((name, crate::vfs::read(e.path())?));
}
}
Ok(())
}
/// A zip of the whole store folder: every project, the trash and the shape library.
pub fn pack(root: &Path) -> Result<Vec<u8>, String> {
let mut files = Vec::new();
collect(root, "", &mut files).map_err(|e| e.to_string())?;
if files.is_empty() {
return Err("there are no projects to back up".into());
}
Ok(zip::write(&files))
}
/// Whether `bytes` look like a zip (the import path then treats the file as a backup).
pub fn is_zip(bytes: &[u8]) -> bool {
bytes.starts_with(b"PK\x03\x04") || bytes.starts_with(b"PK\x05\x06")
}
/// A relative path made only of normal components, so an archive cannot write outside `root`.
fn safe_relative(name: &str) -> Option<PathBuf> {
let p = Path::new(name);
p.components()
.all(|c| matches!(c, Component::Normal(_)))
.then(|| p.to_path_buf())
}
/// Restore the projects in a backup next to the existing ones (never overwriting: a folder
/// that already exists gets a numbered sibling). Returns the folders created.
pub fn unpack(bytes: &[u8], root: &Path) -> Result<Vec<PathBuf>, String> {
let entries = zip::read(bytes)?;
let mut tops: Vec<String> = Vec::new();
for (name, _) in &entries {
let Some(rel) = safe_relative(name) else {
return Err(format!("unsafe path in the backup: {name}"));
};
let mut parts = rel.components();
let (Some(top), Some(_)) = (parts.next(), parts.next()) else {
continue; // loose file at the top level
};
let top = top.as_os_str().to_string_lossy().into_owned();
if top != TRASH_DIR && !tops.contains(&top) {
tops.push(top);
}
}
let mut taken: HashSet<String> = HashSet::new();
let mut created = Vec::new();
for top in tops {
let has_project = entries
.iter()
.any(|(n, _)| n == &format!("{top}/{}", crate::project::MANIFEST));
if !has_project {
continue;
}
let mut target = top.clone();
let mut i = 2;
while taken.contains(&target) || crate::vfs::exists(&root.join(&target)) {
target = format!("{top} {i}");
i += 1;
}
taken.insert(target.clone());
for (name, data) in &entries {
let Some(rest) = name.strip_prefix(&format!("{top}/")) else {
continue;
};
let path = root.join(&target).join(rest);
if let Some(dir) = path.parent() {
crate::vfs::create_dir_all(dir).map_err(|e| e.to_string())?;
}
crate::vfs::write(&path, data).map_err(|e| e.to_string())?;
}
created.push(root.join(target));
}
if created.is_empty() {
return Err("the zip holds no PaperCraft projects".into());
}
Ok(created)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::project::Store;
fn store() -> Store {
Store::open(
std::env::temp_dir().join(format!("papercraft-backup-{:016x}", fastrand::u64(..))),
)
.unwrap()
}
#[test]
fn backup_round_trips_into_another_store() {
let a = store();
a.create("One").unwrap();
a.create("Two").unwrap();
let zip = pack(&a.root).unwrap();
assert!(is_zip(&zip));
let b = store();
let made = unpack(&zip, &b.root).unwrap();
assert_eq!(made.len(), 2);
let mut names: Vec<_> = b.list().into_iter().map(|e| e.manifest.name).collect();
names.sort();
assert_eq!(names, ["One", "Two"]);
// Restoring again never overwrites.
unpack(&zip, &b.root).unwrap();
assert_eq!(b.list().len(), 4);
}
#[test]
fn refuses_path_traversal() {
let zip = zip::write(&[("../evil/project.json".into(), b"{}".to_vec())]);
assert!(unpack(&zip, &store().root).is_err());
}
#[test]
fn empty_store_has_nothing_to_back_up() {
assert!(pack(&store().root).is_err());
}
}
+3
View File
@@ -46,14 +46,17 @@ impl Provider for Memory {
}
/// The OS clipboard via arboard (None when no clipboard is available, e.g. headless).
#[cfg(not(target_arch = "wasm32"))]
pub struct System(arboard::Clipboard);
#[cfg(not(target_arch = "wasm32"))]
impl System {
pub fn new() -> Option<System> {
arboard::Clipboard::new().ok().map(System)
}
}
#[cfg(not(target_arch = "wasm32"))]
impl Provider for System {
fn read_text(&mut self) -> Option<String> {
self.0.get_text().ok()
+60 -1
View File
@@ -192,7 +192,7 @@ pub fn write(
i += 1;
}
used.push(name.clone());
std::fs::create_dir_all(out).map_err(|e| e.to_string())?;
crate::vfs::create_dir_all(out).map_err(|e| e.to_string())?;
out.join(format!("{name}.{}", spec.format.ext()))
};
match spec.format {
@@ -222,3 +222,62 @@ pub fn write(
}
Ok(written)
}
/// The PNG bytes of an image.
pub fn encode_png(img: &image::RgbaImage) -> Result<Vec<u8>, String> {
let mut buf = std::io::Cursor::new(Vec::new());
img.write_to(&mut buf, image::ImageFormat::Png)
.map_err(|e| e.to_string())?;
Ok(buf.into_inner())
}
/// [`write`] into memory for the browser, which downloads instead of writing paths. Returns
/// `(file name, bytes)` per output file; `base` names a single-file export. PNG goes through
/// the non-blocking GPU readback, so this is the only export entry point that works in wasm.
pub async fn render_files(
spec: &Spec,
items: &[Item<'_>],
base: &str,
gpu: &Headless,
) -> Result<Vec<(String, Vec<u8>)>, String> {
if items.is_empty() {
return Err("nothing to export".into());
}
let bg = (!spec.transparent).then_some(Color::WHITE);
let ext = spec.format.ext();
if spec.format == Format::Pdf {
let frames = spec.scope == Scope::Frames;
let pages: Vec<_> = items
.iter()
.flat_map(|it| crate::io::pdf::pages_for(it.doc, Some(&it.roots), frames, bg))
.collect();
if pages.is_empty() {
return Err("nothing to export".into());
}
return Ok(vec![(
format!("{}.{ext}", safe(base)),
crate::io::pdf::export(&pages),
)]);
}
let single = spec.single_file(items.len());
let mut used = std::collections::HashSet::new();
let mut files = Vec::new();
for it in items {
let name = if single {
format!("{}.{ext}", safe(base))
} else {
crate::io::zip::unique_name(&format!("{}.{ext}", safe(&it.name)), &mut used)
};
let bytes = match spec.format {
Format::Svg => crate::io::svg::export(it.doc, Some(&it.roots), bg)
.ok_or("nothing to export")?
.into_bytes(),
Format::Png => encode_png(
&crate::io::png::render_async(gpu, it.doc, Some(&it.roots), spec.scale, bg).await?,
)?,
Format::Pdf => unreachable!(),
};
files.push((name, bytes));
}
Ok(files)
}
+2528
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -85,12 +85,12 @@ pub fn from_str(s: &str) -> Result<(Document, Option<Camera>), FileError> {
/// Write atomically (temp file + rename) so a crash never leaves a truncated document.
pub fn save(path: &Path, doc: &Document, camera: Option<&Camera>) -> Result<(), FileError> {
let tmp = path.with_extension("papercraft.tmp");
std::fs::write(&tmp, to_string(doc, camera)).map_err(FileError::Io)?;
std::fs::rename(&tmp, path).map_err(FileError::Io)
crate::vfs::write(&tmp, to_string(doc, camera)).map_err(FileError::Io)?;
crate::vfs::rename(&tmp, path).map_err(FileError::Io)
}
pub fn load(path: &Path) -> Result<(Document, Option<Camera>), FileError> {
let s = std::fs::read_to_string(path).map_err(FileError::Io)?;
let s = crate::vfs::read_to_string(path).map_err(FileError::Io)?;
from_str(&s)
}
@@ -136,13 +136,13 @@ mod tests {
#[test]
fn save_and_load_file() {
let dir = std::env::temp_dir().join(format!("papercraft-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
crate::vfs::create_dir_all(&dir).unwrap();
let path = dir.join("doc.paper-rs");
let ed = demo::showcase();
save(&path, &ed.doc, None).unwrap();
let (doc, cam) = load(&path).unwrap();
assert_eq!(doc, ed.doc);
assert!(cam.is_none());
std::fs::remove_dir_all(dir).ok();
crate::vfs::remove_dir_all(dir).ok();
}
}
+139 -4
View File
@@ -1,5 +1,7 @@
//! Importing files into projects.
use crate::library::LibItem;
use crate::model::Node;
use crate::project::{PageData, Project, format};
use std::path::Path;
@@ -9,22 +11,155 @@ pub fn import_as_pages(path: &Path) -> Result<(String, Vec<PageData>), String> {
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "Imported".into());
if path.is_dir() {
if crate::vfs::is_dir(path) {
let p = Project::open(path).map_err(|e| e.to_string())?;
let pages = p.load_pages().map_err(|e| e.to_string())?;
return Ok((p.manifest.name, pages));
}
let text = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
let page = format::page_from_json(&text).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)
}
fn page_from_text(stem: &str, text: &str) -> Result<(String, Vec<PageData>), String> {
let page = format::page_from_json(text).map_err(|e| e.to_string())?;
let name = if page.name.is_empty() || page.name == "Untitled" {
stem
stem.to_string()
} else {
page.name.clone()
};
Ok((name, vec![page]))
}
/// What a dropped or picked file turned out to be.
#[derive(Debug)]
pub enum Imported {
/// A PaperCraft document: pages for a new project, with a suggested name.
Pages(String, Vec<PageData>),
/// Loose layers from an SVG (`group` names the group they stay together in) or an
/// Excalidraw scene.
Scene {
nodes: Vec<Node>,
group: Option<String>,
},
/// Encoded raster image bytes, to be registered as an asset.
Image(Vec<u8>),
/// Shapes for the library (`.excalidrawlib`).
Library(Vec<LibItem>),
/// A project backup zip (see [`crate::io::backup`]); the caller unpacks it.
Backup,
}
/// Lowercase extension of a file name, without the dot.
pub fn extension(name: &str) -> String {
Path::new(name)
.extension()
.map(|e| e.to_string_lossy().to_lowercase())
.unwrap_or_default()
}
/// Classify and parse a file given only its name and bytes (the browser has no paths).
pub fn import_bytes(name: &str, bytes: &[u8]) -> Result<Imported, String> {
let stem = Path::new(name)
.file_stem()
.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);
}
let text = || std::str::from_utf8(bytes).map_err(|_| format!("{name} is not a text file"));
match ext.as_str() {
"png" | "jpg" | "jpeg" | "gif" | "webp" => Ok(Imported::Image(bytes.to_vec())),
"excalidrawlib" => crate::io::excalidraw::import_library(text()?).map(Imported::Library),
"svg" => Ok(Imported::Scene {
nodes: crate::io::svg_import::parse(text()?)?,
group: Some(stem),
}),
"excalidraw" | "json" => {
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()
.filter_map(|id| doc.get(id).cloned())
.collect();
Ok(Imported::Scene { nodes, group: None })
}
_ => page_from_text(&stem, text()?).map(|(n, p)| Imported::Pages(n, p)),
}
}
/// Load the pages of a project folder or of a single document file (CLI export).
pub fn load_any(path: &Path) -> Result<Vec<PageData>, String> {
import_as_pages(path).map(|(_, pages)| pages)
}
#[cfg(test)]
mod tests {
use super::*;
const SVG: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10"/></svg>"#;
#[test]
fn svg_becomes_a_named_scene() {
let Ok(Imported::Scene { nodes, group }) = import_bytes("Logo.SVG", SVG.as_bytes()) else {
panic!("svg should import as a scene");
};
assert!(!nodes.is_empty());
assert_eq!(group.as_deref(), Some("Logo"));
}
#[test]
fn images_pass_through_untouched() {
let Ok(Imported::Image(b)) = import_bytes("a.png", b"bytes") else {
panic!("png should import as an image");
};
assert_eq!(b, b"bytes");
}
#[test]
fn zips_are_backups_whatever_their_name() {
let zip = crate::io::zip::write(&[("a/project.json".into(), vec![])]);
assert!(matches!(import_bytes("x.bin", &zip), Ok(Imported::Backup)));
}
#[test]
fn papercraft_document_round_trips() {
let page = PageData {
id: "p".into(),
name: "Untitled".into(),
doc: Default::default(),
camera: None,
};
let json = format::page_to_json(&page);
let Ok(Imported::Pages(name, pages)) = import_bytes("My doc.papercraft", json.as_bytes())
else {
panic!("document should import as pages");
};
assert_eq!((name.as_str(), pages.len()), ("My doc", 1));
}
#[test]
fn garbage_is_an_error_not_a_panic() {
assert!(import_bytes("x.papercraft", b"\xff\xfe").is_err());
assert!(import_bytes("x.svg", b"not svg").is_err());
}
}
+3
View File
@@ -1,10 +1,13 @@
pub mod atomic;
pub mod backup;
pub mod clipboard;
pub mod excalidraw;
pub mod export;
pub mod fig;
pub mod file;
pub mod import;
pub mod pdf;
pub mod png;
pub mod svg;
pub mod svg_import;
pub mod zip;
+47 -6
View File
@@ -9,14 +9,20 @@ use crate::render::scene::{self, View};
/// Largest exported edge, in pixels.
pub const MAX_EDGE: f64 = 16384.0;
/// Render `roots` (or all top-level nodes) at `scale` px per unit.
pub fn render(
gpu: &Headless,
/// What the GPU has to draw for a PNG: the frame, its pixel size and the clear colour.
struct Plan {
frame: scene::CanvasFrame,
size: [u32; 2],
clear: [f64; 4],
transparent: bool,
}
fn plan(
doc: &Document,
roots: Option<&[NodeId]>,
scale: f64,
background: Option<Color>,
) -> Result<image::RgbaImage, String> {
) -> Result<Plan, String> {
let roots: Vec<NodeId> = roots
.map(|r| r.to_vec())
.unwrap_or_else(|| doc.root.clone());
@@ -44,8 +50,43 @@ pub fn render(
}
None => [0.0; 4],
};
let mut img = gpu.render_frame(&frame, size, 1.0, clear);
if background.is_none() {
Ok(Plan {
frame,
size,
clear,
transparent: background.is_none(),
})
}
/// Render `roots` (or all top-level nodes) at `scale` px per unit.
pub fn render(
gpu: &Headless,
doc: &Document,
roots: Option<&[NodeId]>,
scale: f64,
background: Option<Color>,
) -> Result<image::RgbaImage, String> {
let p = plan(doc, roots, scale, background)?;
let mut img = gpu.render_frame(&p.frame, p.size, 1.0, p.clear);
if p.transparent {
unpremultiply_edges(&mut img);
}
Ok(img)
}
/// [`render`] for the browser, where the GPU readback cannot block.
pub async fn render_async(
gpu: &Headless,
doc: &Document,
roots: Option<&[NodeId]>,
scale: f64,
background: Option<Color>,
) -> Result<image::RgbaImage, String> {
let p = plan(doc, roots, scale, background)?;
let mut img = gpu
.render_frame_async(&p.frame, p.size, 1.0, p.clear)
.await?;
if p.transparent {
unpremultiply_edges(&mut img);
}
Ok(img)
+232
View File
@@ -0,0 +1,232 @@
//! Minimal zip files: a store-only writer (project backups, multi-file exports in the browser)
//! and a reader that understands what the writer and common tools produce (stored or deflated
//! entries, no encryption, no zip64).
use std::collections::HashSet;
const LOCAL_SIG: u32 = 0x0403_4b50;
const CENTRAL_SIG: u32 = 0x0201_4b50;
const END_SIG: u32 = 0x0605_4b50;
const LOCAL_LEN: usize = 30;
const CENTRAL_LEN: usize = 46;
const END_LEN: usize = 22;
/// Zip version needed to extract (2.0: deflate, folders).
const VERSION: u16 = 20;
/// General purpose flag bit 11: names are UTF-8.
const UTF8_FLAG: u16 = 1 << 11;
const METHOD_STORED: u16 = 0;
const METHOD_DEFLATE: u16 = 8;
/// 1980-01-01, the earliest DOS date; entries carry no real timestamp.
const DOS_DATE: u16 = 0x0021;
/// Largest entry the reader inflates, against zip bombs.
const MAX_ENTRY: usize = 512 << 20;
pub type Entry = (String, Vec<u8>);
pub fn crc32(data: &[u8]) -> u32 {
let mut crc = !0u32;
for &b in data {
crc ^= b as u32;
for _ in 0..8 {
crc = (crc >> 1) ^ (0xEDB8_8320 & (crc & 1).wrapping_neg());
}
}
!crc
}
/// A store-only zip of `entries` (`name` uses `/` separators).
pub fn write(entries: &[Entry]) -> Vec<u8> {
let mut out = Vec::new();
let mut central = Vec::new();
for (name, data) in entries {
let offset = out.len() as u32;
let crc = crc32(data);
let size = data.len() as u32;
let name = name.as_bytes();
out.extend(LOCAL_SIG.to_le_bytes());
out.extend(VERSION.to_le_bytes());
out.extend(UTF8_FLAG.to_le_bytes());
out.extend(METHOD_STORED.to_le_bytes());
out.extend(0u16.to_le_bytes()); // time
out.extend(DOS_DATE.to_le_bytes());
out.extend(crc.to_le_bytes());
out.extend(size.to_le_bytes()); // compressed
out.extend(size.to_le_bytes()); // uncompressed
out.extend((name.len() as u16).to_le_bytes());
out.extend(0u16.to_le_bytes()); // extra
out.extend(name);
out.extend(data);
central.extend(CENTRAL_SIG.to_le_bytes());
central.extend(VERSION.to_le_bytes()); // made by
central.extend(VERSION.to_le_bytes()); // needed
central.extend(UTF8_FLAG.to_le_bytes());
central.extend(METHOD_STORED.to_le_bytes());
central.extend(0u16.to_le_bytes());
central.extend(DOS_DATE.to_le_bytes());
central.extend(crc.to_le_bytes());
central.extend(size.to_le_bytes());
central.extend(size.to_le_bytes());
central.extend((name.len() as u16).to_le_bytes());
central.extend([0u8; 12]); // extra len, comment len, disk, internal attrs, external attrs
central.extend(offset.to_le_bytes());
central.extend(name);
}
let central_offset = out.len() as u32;
let central_len = central.len() as u32;
out.extend(central);
out.extend(END_SIG.to_le_bytes());
out.extend([0u8; 4]); // disk numbers
out.extend((entries.len() as u16).to_le_bytes());
out.extend((entries.len() as u16).to_le_bytes());
out.extend(central_len.to_le_bytes());
out.extend(central_offset.to_le_bytes());
out.extend(0u16.to_le_bytes()); // comment
out
}
fn u16_at(b: &[u8], i: usize) -> Result<u16, String> {
b.get(i..i + 2)
.map(|s| u16::from_le_bytes([s[0], s[1]]))
.ok_or_else(|| "truncated zip".to_string())
}
fn u32_at(b: &[u8], i: usize) -> Result<u32, String> {
b.get(i..i + 4)
.map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]]))
.ok_or_else(|| "truncated zip".to_string())
}
/// Every file in the archive (folders are skipped), verifying CRCs.
pub fn read(bytes: &[u8]) -> Result<Vec<Entry>, String> {
let end = (0..=bytes.len().saturating_sub(END_LEN))
.rev()
.find(|&i| u32_at(bytes, i).is_ok_and(|s| s == END_SIG))
.ok_or("not a zip file")?;
let count = u16_at(bytes, end + 10)? as usize;
let mut at = u32_at(bytes, end + 16)? as usize;
let mut out = Vec::with_capacity(count);
for _ in 0..count {
if u32_at(bytes, at)? != CENTRAL_SIG {
return Err("corrupt zip directory".into());
}
let method = u16_at(bytes, at + 10)?;
let crc = u32_at(bytes, at + 16)?;
let csize = u32_at(bytes, at + 20)? as usize;
let usize_ = u32_at(bytes, at + 24)? as usize;
let name_len = u16_at(bytes, at + 28)? as usize;
let extra_len = u16_at(bytes, at + 30)? as usize;
let comment_len = u16_at(bytes, at + 32)? as usize;
let local = u32_at(bytes, at + 42)? as usize;
let name = bytes
.get(at + CENTRAL_LEN..at + CENTRAL_LEN + name_len)
.ok_or("truncated zip")?;
let name = String::from_utf8_lossy(name).into_owned();
at += CENTRAL_LEN + name_len + extra_len + comment_len;
if name.ends_with('/') {
continue;
}
if u32_at(bytes, local)? != LOCAL_SIG {
return Err(format!("corrupt zip entry {name}"));
}
let start = local
+ LOCAL_LEN
+ u16_at(bytes, local + 26)? as usize
+ u16_at(bytes, local + 28)? as usize;
let raw = bytes.get(start..start + csize).ok_or("truncated zip")?;
let data = match method {
METHOD_STORED => raw.to_vec(),
METHOD_DEFLATE => miniz_oxide::inflate::decompress_to_vec_with_limit(raw, MAX_ENTRY)
.map_err(|e| format!("cannot inflate {name}: {e:?}"))?,
m => return Err(format!("{name}: unsupported zip method {m}")),
};
if data.len() != usize_ || crc32(&data) != crc {
return Err(format!("{name}: checksum mismatch"));
}
out.push((name, data));
}
Ok(out)
}
/// `name` made unique among `used` by appending " 2", " 3"… before the extension.
pub fn unique_name(name: &str, used: &mut HashSet<String>) -> String {
let (stem, ext) = name.rsplit_once('.').unwrap_or((name, ""));
let dot = if ext.is_empty() { "" } else { "." };
let mut candidate = name.to_string();
let mut i = 2;
while !used.insert(candidate.clone()) {
candidate = format!("{stem} {i}{dot}{ext}");
i += 1;
}
candidate
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn crc_matches_the_standard_check_value() {
assert_eq!(crc32(b"123456789"), 0xCBF4_3926);
assert_eq!(crc32(b""), 0);
}
#[test]
fn round_trip() {
let entries = vec![
("a/manifest.json".to_string(), b"{\"x\":1}".to_vec()),
("a/pages/p1.json".to_string(), vec![0u8; 1000]),
("empty".to_string(), vec![]),
("ünï.txt".to_string(), vec![1, 2, 3]),
];
assert_eq!(read(&write(&entries)).unwrap(), entries);
}
#[test]
fn empty_archive() {
assert!(read(&write(&[])).unwrap().is_empty());
}
#[test]
fn reads_deflated_entries() {
let data = b"hello hello hello hello".to_vec();
let packed = miniz_oxide::deflate::compress_to_vec(&data, 6);
let mut zip = write(&[("x".into(), data.clone())]);
// Rewrite the single stored entry as deflated by patching both headers.
let name_len = 1;
let local_data = LOCAL_LEN + name_len;
let mut patched = zip[..local_data].to_vec();
patched[8..10].copy_from_slice(&METHOD_DEFLATE.to_le_bytes());
patched[18..22].copy_from_slice(&(packed.len() as u32).to_le_bytes());
patched.extend(&packed);
let central_at = local_data + data.len();
let mut central = zip.split_off(central_at);
central[10..12].copy_from_slice(&METHOD_DEFLATE.to_le_bytes());
central[20..24].copy_from_slice(&(packed.len() as u32).to_le_bytes());
let end = central.len() - END_LEN;
let dir_offset = patched.len() as u32;
let dir_len = (end) as u32;
patched.extend(&central);
let n = patched.len();
patched[n - 6..n - 2].copy_from_slice(&dir_offset.to_le_bytes());
patched[n - 10..n - 6].copy_from_slice(&dir_len.to_le_bytes());
assert_eq!(read(&patched).unwrap(), vec![("x".to_string(), data)]);
}
#[test]
fn rejects_garbage_and_corruption() {
assert!(read(b"nope").is_err());
let mut z = write(&[("f".into(), b"abc".to_vec())]);
z[LOCAL_LEN + 1] ^= 0xff; // flip a data byte
assert!(read(&z).is_err());
}
#[test]
fn unique_names() {
let mut used = HashSet::new();
assert_eq!(unique_name("a.png", &mut used), "a.png");
assert_eq!(unique_name("a.png", &mut used), "a 2.png");
assert_eq!(unique_name("b", &mut used), "b");
assert_eq!(unique_name("b", &mut used), "b 2");
}
}
+388 -21
View File
@@ -77,7 +77,14 @@ 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();
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,
}
})
.fold(0.0, f64::max)
})
.collect();
@@ -104,7 +111,11 @@ 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 s = match kn.layout_child.sizing[ma] {
Sizing::Hug => hug_size(doc, *k, ma),
_ => get(&kn.rect, ma).1,
};
let line = lines.last_mut().unwrap();
if !line.is_empty() && x + l.gap + s > inner + 1e-9 {
lines.push(vec![*k]);
@@ -190,8 +201,37 @@ 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();
match kn.layout_child.sizing[ca] {
Sizing::Hug => hug_size(tx.doc(), *k, ca),
Sizing::Fill | Sizing::Fixed => get(&kn.rect, ca).1,
}
})
.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 +239,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 => hug_size(tx.doc(), *k, ma),
Sizing::Fixed => get(&kn.rect, ma).1,
}
})
.collect();
@@ -229,24 +270,25 @@ 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 (cpos, cs) = match kn.layout_child.sizing[ca] {
Sizing::Fill => (cross_cursor, lh.max(0.0)),
Sizing::Fixed | Sizing::Hug => {
let ch = if kn.layout_child.sizing[ca] == Sizing::Hug {
hug_size(tx.doc(), *k, ca)
} else {
get(&r, ca).1
};
let cy = match l.align_cross {
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 +308,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 +322,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 +825,186 @@ 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)
);
}
}
+5
View File
@@ -1,5 +1,6 @@
//! papercraft: a native infinite canvas for planning, working and sketching.
#[cfg(not(target_arch = "wasm32"))]
pub mod app;
pub mod assets;
pub mod connector;
@@ -19,7 +20,11 @@ pub mod model;
pub mod project;
pub mod render;
pub mod session;
pub mod sync;
pub mod text;
pub mod theme;
pub mod ui;
pub mod vector;
pub mod vfs;
#[cfg(target_arch = "wasm32")]
pub mod web;
+3 -3
View File
@@ -92,7 +92,7 @@ impl Library {
/// Load the library, seeding it with starter shapes on first use.
pub fn load(path: &Path) -> Library {
let mut lib = std::fs::read_to_string(path)
let mut lib = crate::vfs::read_to_string(path)
.ok()
.and_then(|s| serde_json::from_str::<Library>(&s).ok())
.filter(|l| l.format == FORMAT && l.version <= VERSION)
@@ -104,7 +104,7 @@ impl Library {
pub fn save(&self) -> std::io::Result<()> {
let Some(p) = &self.path else { return Ok(()) };
if let Some(dir) = p.parent() {
std::fs::create_dir_all(dir)?;
crate::vfs::create_dir_all(dir)?;
}
crate::io::atomic::write(
p,
@@ -265,7 +265,7 @@ mod tests {
lib.save().unwrap();
let back = Library::load(&path);
assert_eq!(back.items, lib.items);
std::fs::remove_dir_all(dir).ok();
crate::vfs::remove_dir_all(dir).ok();
}
#[test]
+1501
View File
File diff suppressed because it is too large Load Diff
+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:?}");
}
}
+251 -35
View File
@@ -38,6 +38,12 @@ pub trait Host {
fn save(&mut self) -> Result<(), String>;
/// An offscreen renderer for screenshots and PNG export.
fn gpu(&mut self) -> Option<Headless>;
/// The host cannot render synchronously but will render a [`ShotRequest`] itself and answer
/// the call later (the browser: GPU readback is asynchronous there). When true and
/// [`Host::gpu`] is None, `get_screenshot` returns a [`NEEDS_RENDER`] marker error.
fn defer_pixels(&self) -> bool {
false
}
}
/// A host without a window: an editor plus a project session. Used by tests and anything
@@ -128,6 +134,9 @@ impl<'a> Args<'a> {
_ => None,
})
}
fn bool(&self, k: &str) -> Option<bool> {
self.get(k).and_then(Value::as_bool)
}
fn arr(&self, k: &str) -> Result<&'a Vec<Value>, String> {
self.get(k)
.and_then(Value::as_array)
@@ -269,6 +278,8 @@ pub fn call(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult {
"get_tree_summary" => tree_summary(ed, &a),
"get_screenshot" => screenshot(host, &a),
"get_jsx" => jsx(host, &a),
"get_design_spec" => get_design_spec(ed, &a),
"verify_code" => verify_code(ed, &a),
"get_computed_styles" => computed_styles(ed, &a),
"get_fill_image" => fill_image(ed, &a),
"find_nodes" => find_nodes(ed, &a),
@@ -291,6 +302,9 @@ pub fn call(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult {
"import_components" => import_components(ed, &a),
"create_component" => create_component(ed, &a),
"create_instance" => create_instance(ed, &a),
"swap_instance" => swap_instance(ed, &a),
"detach_instance" => detach_instance(ed, &a),
"diff_frames" => diff_frames(ed, &a),
"search_icons" => search_icons(&a),
"rename_nodes" => rename_nodes(ed, &a),
"delete_nodes" => delete_nodes(ed, &a),
@@ -317,7 +331,7 @@ pub fn call(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult {
fn find_project(store: &Store, key: &str) -> Result<PathBuf, String> {
let key = key.trim().trim_start_matches("/file/");
let p = Path::new(key);
if p.is_absolute() && p.join(crate::project::MANIFEST).is_file() {
if p.is_absolute() && crate::vfs::is_file(&p.join(crate::project::MANIFEST)) {
return Ok(p.to_path_buf());
}
let list = store.list();
@@ -800,10 +814,55 @@ fn tree_summary(ed: &mut Editor, a: &Args) -> ToolResult {
/// Largest screenshot edge, in pixels.
const SHOT_MAX_EDGE: f64 = 2048.0;
/// Prefix of the error `get_screenshot` returns on a host that renders asynchronously: the rest
/// is a JSON [`ShotRequest`]. The shell renders it, then replies with [`screenshot_async`].
pub const NEEDS_RENDER: &str = "papercraft:needs-render:";
/// What a screenshot has to draw, resolved against the editor at call time.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ShotRequest {
pub page: usize,
pub roots: Vec<NodeId>,
pub scale: f64,
}
impl ShotRequest {
/// The request inside a [`NEEDS_RENDER`] marker error, if `err` is one.
pub fn from_marker(err: &str) -> Option<ShotRequest> {
serde_json::from_str(err.strip_prefix(NEEDS_RENDER)?).ok()
}
}
/// PNG-encode a screenshot. None (with `scale` reduced) when the payload is too big to send.
fn shot_out(img: &image::RgbaImage, scale: &mut f64) -> Result<Option<Vec<Out>>, String> {
let mut buf = std::io::Cursor::new(Vec::new());
img.write_to(&mut buf, image::ImageFormat::Png)
.map_err(|e| e.to_string())?;
let png = buf.into_inner();
// Keep the base64 payload comfortably under API image limits (~5 MB).
if png.len() > 3_500_000 && *scale > 0.2 {
*scale *= 0.7;
return Ok(None);
}
let (w, h) = img.dimensions();
Ok(Some(vec![
Out::Image {
data: png,
mime: "image/png".into(),
},
Out::Text(
json!({"width": w, "height": h, "scale": (*scale * 1000.0).round() / 1000.0})
.to_string(),
),
]))
}
fn screenshot(host: &mut dyn Host, a: &Args) -> ToolResult {
let gpu = host
.gpu()
.ok_or("screenshots need a GPU and none is available")?;
let gpu = host.gpu();
let defer = host.defer_pixels();
if gpu.is_none() && !defer {
return Err("screenshots need a GPU and none is available".into());
}
let ed = host.editor();
let def = page_arg(ed, a)?;
let (page, roots) = match a.str("nodeId").filter(|k| !k.is_empty() && *k != ROOT) {
@@ -817,28 +876,32 @@ fn screenshot(host: &mut dyn Host, a: &Args) -> ToolResult {
let b = crate::io::svg::export_bounds(doc, &roots).ok_or("nothing to capture (empty)")?;
let want = a.num("scale").unwrap_or(1.0).clamp(0.1, 4.0);
let mut scale = want.min(SHOT_MAX_EDGE / b.w.max(b.h).max(1.0));
let Some(gpu) = gpu else {
let req = ShotRequest { page, roots, scale };
return Err(format!(
"{NEEDS_RENDER}{}",
serde_json::to_string(&req).map_err(|e| e.to_string())?
));
};
loop {
let img = crate::io::png::render(&gpu, doc, Some(&roots), scale, Some(Color::WHITE))?;
let mut buf = std::io::Cursor::new(Vec::new());
img.write_to(&mut buf, image::ImageFormat::Png)
.map_err(|e| e.to_string())?;
let png = buf.into_inner();
// Keep the base64 payload comfortably under API image limits (~5 MB).
if png.len() > 3_500_000 && scale > 0.2 {
scale *= 0.7;
continue;
if let Some(out) = shot_out(&img, &mut scale)? {
return Ok(out);
}
}
}
/// Finish a deferred screenshot: the same rendering as [`screenshot`], awaiting the readback.
/// `doc` is the page the [`ShotRequest`] named, as it is now.
pub async fn screenshot_async(gpu: &Headless, doc: &Document, req: &ShotRequest) -> ToolResult {
let mut scale = req.scale;
loop {
let img =
crate::io::png::render_async(gpu, doc, Some(&req.roots), scale, Some(Color::WHITE))
.await?;
if let Some(out) = shot_out(&img, &mut scale)? {
return Ok(out);
}
let (w, h) = img.dimensions();
return Ok(vec![
Out::Image {
data: png,
mime: "image/png".into(),
},
Out::Text(
json!({"width": w, "height": h, "scale": (scale * 1000.0).round() / 1000.0})
.to_string(),
),
]);
}
}
@@ -1045,9 +1108,50 @@ fn jsx(host: &mut dyn Host, a: &Args) -> ToolResult {
let ed = host.editor();
let (page, id) = resolve(ed, a.req_str("nodeId")?, page_arg(ed, a)?)?;
let doc = ed.page_doc(page);
let mut out = String::new();
jsx_node(doc, id, 0, true, assets_dir.as_deref(), &mut out);
Ok(vec![Out::Text(out)])
match a.str("format") {
None | Some("") | Some("inline") | Some("inline-styles") => {
let mut out = String::new();
jsx_node(doc, id, 0, true, assets_dir.as_deref(), &mut out);
Ok(vec![Out::Text(out)])
}
Some(
f @ ("tailwind" | "jsx" | "react" | "tsx" | "angular" | "ng" | "html" | "css" | "egui"
| "rust"),
) => Ok(vec![Out::Text(super::codegen::code_for_target(
doc, id, f,
)?)]),
Some(f) => Err(format!(
"unknown format `{f}` (inline, tailwind, react, angular, html, or egui)"
)),
}
}
fn get_design_spec(ed: &mut Editor, a: &Args) -> ToolResult {
let (page, id) = resolve(ed, a.req_str("nodeId")?, page_arg(ed, a)?)?;
let doc = ed.page_doc(page);
let target = a.str("target").filter(|s| !s.is_empty()).unwrap_or("react");
match a.str("format").unwrap_or("markdown") {
"" | "markdown" | "md" => {
let md = super::codegen::to_ai_spec_markdown(doc, id, target)?;
Ok(vec![Out::Text(md)])
}
"json" => {
let spec = super::codegen::to_ai_spec(doc, id, target)?;
text(spec)
}
other => Err(format!(
"unknown format `{other}` (expected markdown or json)"
)),
}
}
fn verify_code(ed: &mut Editor, a: &Args) -> ToolResult {
let (page, id) = resolve(ed, a.req_str("nodeId")?, page_arg(ed, a)?)?;
let doc = ed.page_doc(page);
let target = a.req_str("target")?;
let code = a.req_str("code")?;
let report = super::enforcer::verify_code(doc, id, target, code)?;
text(super::enforcer::to_json(&report))
}
fn style_obj(m: &Map<String, Value>) -> String {
@@ -1168,6 +1272,7 @@ fn jsx_node(
// ------------------------------------------------------------------------------ export
#[cfg(not(target_arch = "wasm32"))]
fn parse_scale(s: Option<&str>, b: Rect) -> Result<f64, String> {
let Some(s) = s.map(str::trim).filter(|s| !s.is_empty()) else {
return Ok(2.0);
@@ -1185,6 +1290,7 @@ fn parse_scale(s: Option<&str>, b: Rect) -> Result<f64, String> {
})
}
#[cfg(not(target_arch = "wasm32"))]
fn out_dir(a: &Args) -> PathBuf {
a.str("directory")
.map(PathBuf::from)
@@ -1193,6 +1299,7 @@ fn out_dir(a: &Args) -> PathBuf {
.unwrap_or_else(|| PathBuf::from("."))
}
#[cfg(not(target_arch = "wasm32"))]
fn unique_path(dir: &Path, stem: &str, ext: &str) -> PathBuf {
let stem = crate::project::folder_name(stem);
let mut p = dir.join(format!("{stem}.{ext}"));
@@ -1204,6 +1311,22 @@ fn unique_path(dir: &Path, stem: &str, ext: &str) -> PathBuf {
p
}
/// The browser has no folder to write to: exports there are downloads from the UI.
#[cfg(target_arch = "wasm32")]
const NO_DISK: &str = "this tool writes files to disk, which a browser tab cannot do; \
use get_screenshot, or the Export menu in the PaperCraft tab";
#[cfg(target_arch = "wasm32")]
fn export(_: &mut dyn Host, _: &Args) -> ToolResult {
Err(NO_DISK.into())
}
#[cfg(target_arch = "wasm32")]
fn export_pdf(_: &mut dyn Host, _: &Args) -> ToolResult {
Err(NO_DISK.into())
}
#[cfg(not(target_arch = "wasm32"))]
fn export(host: &mut dyn Host, a: &Args) -> ToolResult {
use crate::io::export::{Format, Item, Scope, Spec};
let gpu = host.gpu();
@@ -1265,6 +1388,7 @@ fn export(host: &mut dyn Host, a: &Args) -> ToolResult {
text(json!({"files": written}))
}
#[cfg(not(target_arch = "wasm32"))]
fn export_pdf(host: &mut dyn Host, a: &Args) -> ToolResult {
use crate::io::export::{Format, Item, Scope, Spec};
let project_name = host.project().map(|p| p.manifest.name.clone());
@@ -1455,7 +1579,7 @@ fn image_asset(src: &str) -> Result<String, String> {
return Ok(path.to_string());
}
let path = percent_decode(path);
std::fs::read(&path).map_err(|e| format!("{path}: {e}"))?
crate::vfs::read(&path).map_err(|e| format!("{path}: {e}"))?
};
if bytes.starts_with(b"<svg") || bytes.starts_with(b"<?xml") {
return Err("SVG images: put the <svg> markup inline instead".into());
@@ -2940,17 +3064,25 @@ fn get_variables(ed: &mut Editor, a: &Args) -> ToolResult {
.map(|(k, c)| format!(" --{k}: {};\n", c.to_hex()))
.chain(doc.tokens.iter().map(|(k, v)| format!(" --{k}: {v};\n")))
.collect();
return Ok(vec![Out::Text(format!(":root {{\n{body}}}\n"))]);
let mut out = format!(":root {{\n{body}}}\n");
for (mode, vars) in &doc.modes {
out.push_str(&format!("[data-theme=\"{mode}\"] {{\n"));
for (k, c) in vars {
out.push_str(&format!(" --{k}: {};\n", c.to_hex()));
}
out.push_str("}\n");
}
return Ok(vec![Out::Text(out)]);
}
Some("json") => {
let tokens: Map<String, Value> = doc
.variables
.iter()
.map(|(k, c)| (k.clone(), json!({"$type": "color", "$value": c.to_hex()})))
.collect();
return text(Value::Object(tokens));
return text(super::codegen::export_variables_dtcg(doc));
}
Some(f) => return Err(format!("unknown format `{f}` (css or json)")),
Some("tailwind") => {
return Ok(vec![Out::Text(super::codegen::export_variables_tailwind(
doc,
))]);
}
Some(f) => return Err(format!("unknown format `{f}` (css, json, or tailwind)")),
None => {}
}
let vars: Map<String, Value> = doc
@@ -3028,6 +3160,15 @@ fn lint_design(ed: &mut Editor, a: &Args) -> ToolResult {
Some(v) => v.iter().filter_map(Value::as_f64).collect(),
None => super::lint::DEFAULT_SCALE.to_vec(),
};
let fixed = if a.bool("fix").unwrap_or(false) {
ed.with_page(page, |ed| {
ed.edit("Auto-fix design lint", |tx| {
super::lint::autofix(tx, &roots, &scale)
})
})
} else {
0
};
let doc = ed.page_doc(page);
let issues = super::lint::lint(doc, &roots, &scale);
let count = |sev: &str| issues.iter().filter(|i| i.severity == sev).count();
@@ -3046,6 +3187,7 @@ fn lint_design(ed: &mut Editor, a: &Args) -> ToolResult {
.collect();
text(json!({
"errors": count("error"), "warnings": count("warning"), "info": count("info"),
"fixedNodes": fixed,
"byRule": by_rule, "issues": list,
"truncated": issues.len() > 200,
}))
@@ -3383,6 +3525,80 @@ fn create_instance(ed: &mut Editor, a: &Args) -> ToolResult {
text(json!({"id": node_key(ed, page, id), "textLayers": texts, "layers": layers}))
}
fn swap_instance(ed: &mut Editor, a: &Args) -> ToolResult {
let def = page_arg(ed, a)?;
let (page, inst) = resolve(ed, a.req_str("nodeId")?, def)?;
let (comp_page, new_main) = resolve(ed, a.req_str("componentId")?, page)?;
if page != comp_page {
return Err("instance and component must be on the same page".into());
}
ed.with_page(page, |ed| {
if !ed.is_instance_root(inst) {
return Err(format!(
"`{}` is not a component instance root",
a.req_str("nodeId").unwrap_or_default()
));
}
if !ed.doc.get(new_main).is_some_and(|m| m.component) {
return Err(format!(
"`{}` is not a main component",
a.req_str("componentId").unwrap_or_default()
));
}
let ok = ed.edit("Swap instance", |tx| {
crate::editor::structure::swap_instance_tx(tx, inst, new_main)
});
if !ok {
return Err("could not swap instance to that component".into());
}
Ok(())
})?;
text(json!({
"id": node_key(ed, page, inst),
"componentId": node_key(ed, page, new_main),
"swapped": true,
}))
}
fn detach_instance(ed: &mut Editor, a: &Args) -> ToolResult {
let def = page_arg(ed, a)?;
let keys = match a.strs("nodeIds") {
Ok(k) => k,
Err(_) => vec![a.req_str("nodeId")?.to_string()],
};
let (page, ids) = resolve_all(ed, &keys, def)?;
let detached = ed.with_page(page, |ed| {
ed.edit("Detach instance", |tx| {
let mut count = 0;
for r in &ids {
if tx.doc().get(*r).and_then(|n| n.instance.as_ref()).is_some() {
for id in tx.doc().subtree(*r) {
if let Some(n) = tx.node_mut(id) {
n.instance = None;
}
}
count += 1;
}
}
count
})
});
text(json!({
"detached": detached,
"nodeIds": ids.iter().map(|i| node_key(ed, page, *i)).collect::<Vec<_>>(),
}))
}
fn diff_frames(ed: &mut Editor, a: &Args) -> ToolResult {
let def = page_arg(ed, a)?;
let (page_a, id_a) = resolve(ed, a.req_str("beforeId")?, def)?;
let (page_b, id_b) = resolve(ed, a.req_str("afterId")?, def)?;
let doc_a = ed.page_doc(page_a);
let doc_b = ed.page_doc(page_b);
let diff = super::diff::diff_subtrees(doc_a, id_a, doc_b, id_b)?;
text(super::diff::to_json(&diff))
}
// ------------------------------------------------------------------------------ icons
fn search_icons(a: &Args) -> ToolResult {
+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);
}
}
+11
View File
@@ -9,12 +9,23 @@
//! * [`exec`]: runs one tool call against a [`exec::Host`] (the app, or a headless session).
//! * [`tools`]: tool names, descriptions and schemas; [`css`] and [`html`]: the style and
//! markup mapping used by `write_html` / `update_styles`.
//! * [`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;
#[cfg(not(target_arch = "wasm32"))]
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.
+560
View File
@@ -0,0 +1,560 @@
//! The relay: lets a remote agent drive a PaperCraft tab running in someone's browser.
//!
//! ```text
//! agent --(MCP streamable HTTP)--> relay <--(WebSocket)-- browser tab
//! POST /s/{token}/mcp GET /tab/{token}
//! ```
//!
//! The relay holds no document state: it serves the same tool list and instructions as the
//! desktop server and forwards each call to the tab as a [`relay_proto::Call`] frame. The token
//! in the path is the only credential, so it is never logged and never appears in an error.
use super::relay_proto::{self, Call as CallFrame, Reply};
use super::server::{CALL_TIMEOUT, Dispatch, PaperServer};
use super::{Call, ToolResult};
use axum::extract::ws::{CloseFrame, Message, WebSocket, WebSocketUpgrade};
use axum::extract::{FromRequestParts, Path, Request, State};
use axum::http::{HeaderMap, StatusCode, Uri, header};
use axum::response::{IntoResponse, Response};
use axum::routing::any;
use rmcp::transport::streamable_http_server::{
StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
};
use serde_json::Value;
use std::collections::HashMap;
use std::net::SocketAddr;
use std::path::{Component, Path as FsPath, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::Duration;
use tokio::sync::{mpsc, oneshot};
use tokio::time::Instant;
use tokio_util::sync::CancellationToken;
/// Biggest frame we accept from (or send to) a tab.
pub const MAX_WS_MESSAGE_BYTES: usize = 16 << 20;
/// How often we ping a tab; a socket that stops answering is dropped.
pub const PING_INTERVAL: Duration = Duration::from_secs(20);
/// A socket that has not ponged within this many ping intervals is considered dead.
const PONG_GRACE_INTERVALS: u32 = 3;
/// Tool calls allowed to be in flight for one link; the rest wait their turn.
pub const MAX_INFLIGHT_CALLS: usize = 8;
/// Cap on an MCP request body (screenshots and assets are big).
const MAX_MCP_BODY_BYTES: usize = 64 << 20;
/// What an agent sees when nothing is connected to the link.
pub const NO_TAB_ERROR: &str =
"No PaperCraft tab is connected for this link. Open PaperCraft and use Agent > Connect.";
/// Close reason sent to the previous socket when a newer tab claims the link.
const TAB_REPLACED: &str = "replaced by a newer tab";
/// Shown for any request the token rules out; deliberately identical for bad tokens and
/// unknown links so a wrong token cannot be told apart from a wrong path.
const NOT_FOUND: &str = "not found";
/// Answered for a route the relay does not serve.
const UNKNOWN_ROUTE: &str = "not found: this relay serves /tab/{token} and /s/{token}/mcp";
type Service = StreamableHttpService<PaperServer, LocalSessionManager>;
/// Frames the relay pushes to a tab.
enum ToTab {
/// A tool call to run.
Call(String),
/// Close the socket with this reason.
Close(&'static str),
}
/// The live connection of one tab, plus the calls waiting for its answers.
struct Tab {
out: mpsc::UnboundedSender<ToTab>,
pending: Mutex<HashMap<u64, oneshot::Sender<Reply>>>,
}
impl Tab {
/// Shorthand for a poisoned lock: a panic cannot leave the map half-written, and a stale
/// entry only costs one abandoned wait.
fn pending(&self) -> std::sync::MutexGuard<'_, HashMap<u64, oneshot::Sender<Reply>>> {
self.pending.lock().unwrap_or_else(PoisonError::into_inner)
}
/// Answer every call still waiting with `error`, so agents stop waiting on a dead socket.
fn fail_all(&self, error: &str) {
for (id, reply) in self.pending().drain() {
let _ = reply.send(Reply::err(id, error));
}
}
}
/// Per-link state shared by the MCP service and the socket: the connected tab and the
/// in-flight limit.
struct Shared {
tab: tokio::sync::Mutex<Option<Arc<Tab>>>,
calls: Arc<tokio::sync::Semaphore>,
next_id: AtomicU64,
}
impl Shared {
fn new() -> Shared {
Shared {
tab: tokio::sync::Mutex::new(None),
calls: Arc::new(tokio::sync::Semaphore::new(MAX_INFLIGHT_CALLS)),
next_id: AtomicU64::new(1),
}
}
/// Hand a tool call to the tab and wait for its answer.
async fn forward(&self, name: String, args: Value) -> ToolResult {
let Some(tab) = self.tab.lock().await.clone() else {
return Err(NO_TAB_ERROR.to_string());
};
// Acquired before the frame goes out, so a link never asks the tab to answer more
// calls at once than it is allowed to hold.
let Ok(_permit) = self.calls.clone().acquire_owned().await else {
return Err("the relay is shutting down".into());
};
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let frame = CallFrame { id, name, args };
let json = match serde_json::to_string(&frame) {
Ok(j) => j,
Err(e) => return Err(format!("cannot encode the call: {e}")),
};
let (reply_tx, reply_rx) = oneshot::channel();
// Registered before the frame goes out: a tab may answer before we get there.
tab.pending().insert(id, reply_tx);
if tab.out.send(ToTab::Call(json)).is_err() {
tab.pending().remove(&id);
return Err(NO_TAB_ERROR.to_string());
}
let answered = tokio::time::timeout(CALL_TIMEOUT, reply_rx).await;
tab.pending().remove(&id);
match answered {
Ok(Ok(reply)) => reply.into_result(),
Ok(Err(_)) => Err(NO_TAB_ERROR.to_string()),
Err(_) => Err("the PaperCraft tab did not answer in time".into()),
}
}
}
/// Everything one link owns: the cached MCP service plus its [`Shared`].
struct Link {
service: Service,
shared: Arc<Shared>,
}
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);
let service: Service = StreamableHttpService::new(
move || Ok(PaperServer::new(dispatch.clone())),
Default::default(),
config,
);
Link { service, shared }
}
}
/// The relay holds no editor thread, so a call runs on its own task and the caller's oneshot
/// gets the answer that arrives over the WebSocket.
fn dispatch(shared: Arc<Shared>) -> Dispatch {
Arc::new(move |call: Call| {
let shared = shared.clone();
tokio::spawn(async move {
let r = shared.forward(call.name, call.args).await;
let _ = call.reply.send(r);
});
true
})
}
struct Relay {
links: tokio::sync::Mutex<HashMap<String, Arc<Link>>>,
allow_origin: Option<String>,
static_dir: Option<PathBuf>,
ct: CancellationToken,
}
impl Relay {
/// The link for `token`, created on first use. An invalid token never gets one.
async fn link(&self, token: &str) -> Option<Arc<Link>> {
if !relay_proto::valid_token(token) {
return None;
}
let mut links = self.links.lock().await;
if let Some(link) = links.get(token) {
return Some(link.clone());
}
let link = Arc::new(Link::new(Arc::new(Shared::new()), self.ct.child_token()));
links.insert(token.to_string(), link.clone());
Some(link)
}
/// `/tab/*` is a browser route: with `--allow-origin` only that exact origin may connect.
fn origin_ok(&self, headers: &HeaderMap) -> bool {
let Some(want) = &self.allow_origin else {
return true;
};
match headers.get(header::ORIGIN) {
Some(v) => v.to_str().map(|o| o == want).unwrap_or(false),
None => true,
}
}
}
fn not_found(body: &'static str) -> Response {
(StatusCode::NOT_FOUND, body).into_response()
}
fn forbidden(body: &'static str) -> Response {
(StatusCode::FORBIDDEN, body).into_response()
}
/// `POST|GET|DELETE /s/{token}/mcp`: the agent's streamable-HTTP endpoint for one link.
async fn mcp_endpoint(
State(relay): State<Arc<Relay>>,
Path(token): Path<String>,
req: Request,
) -> Response {
// rmcp's `enforce_origin_validation` already turns browsers away; checking here keeps the
// policy ours, and it runs before the link lookup so a browser cannot create one.
if req.headers().contains_key(header::ORIGIN) {
return forbidden("this endpoint does not accept browser requests");
}
let Some(link) = relay.link(&token).await else {
return not_found(NOT_FOUND);
};
// axum can only nest a service under a path known when the router is built, and tokens
// arrive at runtime; the service only needs the request, so it is called directly.
link.service.handle(req).await.into_response()
}
/// `GET /tab/{token}`: the browser's socket for one link.
async fn tab_socket(
State(relay): State<Arc<Relay>>,
Path(token): Path<String>,
req: Request,
) -> Response {
let Some(link) = relay.link(&token).await else {
return not_found(NOT_FOUND);
};
if !relay.origin_ok(req.headers()) {
return forbidden("this origin may not connect a tab");
}
// The token is checked before the upgrade: a `WebSocketUpgrade` extractor would answer a
// plain request with 400 before we could refuse a bad token.
let (mut parts, _) = req.into_parts();
let upgrade = match WebSocketUpgrade::from_request_parts(&mut parts, &()).await {
Ok(u) => u,
Err(rejection) => return rejection.into_response(),
};
upgrade
.max_message_size(MAX_WS_MESSAGE_BYTES)
.on_upgrade(move |socket| serve_tab(socket, link))
.into_response()
}
/// What the socket loop is waiting for. Only one branch touches the socket, so the borrow
/// from `recv()` is gone by the time we write to it.
enum Event {
Frame(Result<Message, axum::Error>),
Outgoing(Option<ToTab>),
Ping,
Closed,
}
async fn next_event(
socket: &mut WebSocket,
out: &mut mpsc::UnboundedReceiver<ToTab>,
ping: &mut tokio::time::Interval,
) -> Event {
tokio::select! {
m = socket.recv() => match m {
Some(m) => Event::Frame(m),
None => Event::Closed,
},
o = out.recv() => Event::Outgoing(o),
_ = ping.tick() => Event::Ping,
}
}
/// Answer the call waiting on this frame's id; frames for no known call are ignored.
fn handle_reply(tab: &Tab, text: &str) {
let reply = match serde_json::from_str::<Reply>(text) {
Ok(r) => r,
Err(_) => {
log::debug!("relay: ignoring a frame that is not a reply");
return;
}
};
match tab.pending().remove(&reply.id) {
Some(waiter) => {
let _ = waiter.send(reply);
}
None => log::debug!("relay: ignoring a reply with no pending call"),
}
}
/// One tab's socket: forwards tool calls out, routes replies back to their calls, pings to
/// keep the connection (and any proxy in front of it) alive.
async fn serve_tab(mut socket: WebSocket, link: Arc<Link>) {
let (out, mut out_rx) = mpsc::unbounded_channel::<ToTab>();
let tab = Arc::new(Tab {
out,
pending: Mutex::new(HashMap::new()),
});
// One tab per link: a newer connection takes over and the older socket is closed.
let previous = link.shared.tab.lock().await.replace(tab.clone());
if let Some(old) = previous {
let _ = old.out.send(ToTab::Close(TAB_REPLACED));
}
// The tab is live as of here: say so with a ping, so a client that waits for one never
// races this registration.
let _ = socket.send(Message::Ping(axum::body::Bytes::new())).await;
let mut ping = tokio::time::interval_at(Instant::now() + PING_INTERVAL, PING_INTERVAL);
let mut last_pong = Instant::now();
loop {
match next_event(&mut socket, &mut out_rx, &mut ping).await {
Event::Closed | Event::Frame(Err(_)) => break,
Event::Frame(Ok(Message::Close(_))) => break,
Event::Frame(Ok(Message::Text(t))) => handle_reply(&tab, &t),
Event::Frame(Ok(Message::Pong(_))) => last_pong = Instant::now(),
// Ping replies are sent by the protocol layer; other frame types are not ours.
Event::Frame(Ok(_)) => {}
Event::Outgoing(None) => break,
Event::Outgoing(Some(ToTab::Call(json))) => {
if socket.send(Message::Text(json.into())).await.is_err() {
break;
}
}
Event::Outgoing(Some(ToTab::Close(reason))) => {
let frame = CloseFrame {
code: axum::extract::ws::close_code::AWAY,
reason: reason.into(),
};
let _ = socket.send(Message::Close(Some(frame))).await;
break;
}
Event::Ping => {
// No pong for several intervals means the socket is gone even if TCP has not
// noticed yet.
if last_pong.elapsed() > PING_INTERVAL * PONG_GRACE_INTERVALS {
break;
}
if socket
.send(Message::Ping(axum::body::Bytes::new()))
.await
.is_err()
{
break;
}
}
}
}
let mut current = link.shared.tab.lock().await;
if let Some(t) = current.as_ref()
&& Arc::ptr_eq(t, &tab)
{
*current = None;
}
drop(current);
tab.fail_all(NO_TAB_ERROR);
}
/// The MIME types the built web app needs; anything else is an opaque download.
const MIME_TYPES: &[(&str, &str)] = &[
("wasm", "application/wasm"),
("js", "text/javascript"),
("html", "text/html"),
("css", "text/css"),
("png", "image/png"),
("svg", "image/svg+xml"),
];
/// Fallback for anything not in [`MIME_TYPES`].
const DEFAULT_MIME: &str = "application/octet-stream";
fn mime_of(path: &FsPath) -> &'static str {
let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
return DEFAULT_MIME;
};
MIME_TYPES
.iter()
.find(|(e, _)| e.eq_ignore_ascii_case(ext))
.map_or(DEFAULT_MIME, |(_, m)| *m)
}
/// `<dir>/index.html` for `/`, else `<dir>/<path>`; `None` for anything that would read
/// outside `root` (`..`, absolute paths, `.`).
fn static_path(root: &FsPath, request_path: &str) -> Option<PathBuf> {
let rel = request_path.trim_start_matches('/');
let rel = FsPath::new(if rel.is_empty() { "index.html" } else { rel });
if rel.components().any(|c| !matches!(c, Component::Normal(_))) {
return None;
}
Some(root.join(rel))
}
/// The `--static` tree, and the answer for every route the relay does not serve.
async fn serve_static(State(relay): State<Arc<Relay>>, uri: Uri) -> Response {
let Some(dir) = relay.static_dir.as_deref() else {
return not_found(UNKNOWN_ROUTE);
};
let Some(path) = static_path(dir, uri.path()) else {
return not_found(NOT_FOUND);
};
// Read whole: the files are a wasm bundle, and a streaming body would need another
// dependency for no benefit here.
match tokio::fs::read(&path).await {
Ok(bytes) => ([(header::CONTENT_TYPE, mime_of(&path))], bytes).into_response(),
Err(_) => not_found(NOT_FOUND),
}
}
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);
match store {
Some(store) => app.merge(super::store::router(store)),
None => app,
}
}
/// A running relay; stops when dropped.
pub struct Running {
port: u16,
ct: CancellationToken,
thread: Option<std::thread::JoinHandle<()>>,
}
impl Running {
/// The port actually bound (0 in the request means "any free port").
pub fn port(&self) -> u16 {
self.port
}
pub fn stop(mut self) {
self.shutdown();
}
fn shutdown(&mut self) {
self.ct.cancel();
if let Some(t) = self.thread.take() {
let _ = t.join();
}
}
}
impl Drop for Running {
fn drop(&mut self) {
self.shutdown();
}
}
/// Serve the relay from a background thread. `static_dir` serves the built web app from the
/// same origin; `allow_origin` restricts which browser may connect a tab.
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}"))?;
listener.set_nonblocking(true).map_err(|e| e.to_string())?;
let port = listener.local_addr().map_err(|e| e.to_string())?.port();
let ct = CancellationToken::new();
let thread_ct = ct.clone();
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.thread_name("papercraft-relay")
.enable_all()
.build()
.map_err(|e| e.to_string())?;
let thread = std::thread::Builder::new()
.name("papercraft-relay-http".into())
.spawn(move || {
rt.block_on(async move {
let listener = match tokio::net::TcpListener::from_std(listener) {
Ok(l) => l,
Err(e) => {
log::error!("relay: {e}");
return;
}
};
let relay = Arc::new(Relay {
links: tokio::sync::Mutex::new(HashMap::new()),
allow_origin,
static_dir,
ct: thread_ct.child_token(),
});
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}");
}
});
})
.map_err(|e| e.to_string())?;
Ok(Running {
port,
ct,
thread: Some(thread),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn static_paths_stay_inside_the_root() {
let root = FsPath::new("/srv/web");
assert_eq!(
static_path(root, "/").as_deref(),
Some(FsPath::new("/srv/web/index.html"))
);
assert_eq!(
static_path(root, "/pkg/papercraft_bg.wasm").as_deref(),
Some(FsPath::new("/srv/web/pkg/papercraft_bg.wasm"))
);
for bad in [
"/../Cargo.toml",
"/pkg/../../etc/passwd",
"/./index.html",
"..",
] {
assert_eq!(static_path(root, bad), None, "{bad}");
}
// Extra leading slashes are stripped, not resolved: still inside the root.
assert_eq!(
static_path(root, "//etc/passwd").as_deref(),
Some(FsPath::new("/srv/web/etc/passwd"))
);
}
#[test]
fn mime_types_cover_the_built_web_app() {
assert_eq!(mime_of(FsPath::new("a/b.wasm")), "application/wasm");
assert_eq!(mime_of(FsPath::new("a/b.js")), "text/javascript");
assert_eq!(mime_of(FsPath::new("index.html")), "text/html");
assert_eq!(mime_of(FsPath::new("style.css")), "text/css");
assert_eq!(mime_of(FsPath::new("icon.png")), "image/png");
assert_eq!(mime_of(FsPath::new("logo.svg")), "image/svg+xml");
assert_eq!(mime_of(FsPath::new("LICENSE")), DEFAULT_MIME);
}
}
+291
View File
@@ -0,0 +1,291 @@
//! Wire protocol between the relay and a PaperCraft tab running in a browser.
//!
//! JSON text frames, one tool call per exchange: the relay sends a [`Call`], the tab answers
//! with a [`Reply`] carrying the same `id`. Compiled for native and wasm alike: the tab side
//! speaks it too.
use crate::mcp::{Out, ToolResult};
use serde::{Deserialize, Serialize};
/// Shortest link token a tab may generate.
pub const MIN_TOKEN_LEN: usize = 32;
/// Longest link token a tab may generate.
pub const MAX_TOKEN_LEN: usize = 64;
/// relay -> tab: run `name` with `args`, answer frame `id`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Call {
pub id: u64,
pub name: String,
pub args: serde_json::Value,
}
/// One output block, tagged by `type` so the tab can switch on it.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum OutFrame {
Text {
text: String,
},
/// `data` is base64 (standard alphabet, padded).
Image {
mime: String,
data: String,
},
}
/// tab -> relay: the answer to [`Call::id`]. `out` and `error` are mutually exclusive.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Reply {
pub id: u64,
pub ok: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub out: Option<Vec<OutFrame>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
impl Reply {
pub fn ok(id: u64, out: Vec<Out>) -> Reply {
Reply {
id,
ok: true,
out: Some(out.into_iter().map(OutFrame::from).collect()),
error: None,
}
}
pub fn err(id: u64, error: impl Into<String>) -> Reply {
Reply {
id,
ok: false,
out: None,
error: Some(error.into()),
}
}
/// The tool result this reply carries; a malformed image block is an error, not a panic.
pub fn into_result(self) -> ToolResult {
if !self.ok {
return Err(self
.error
.unwrap_or_else(|| "the PaperCraft tab reported an error".into()));
}
let blocks = match self.out {
Some(b) => b,
None => return Err("the PaperCraft tab sent an empty reply".into()),
};
let mut out = Vec::with_capacity(blocks.len());
for b in blocks {
match b.into() {
Some(o) => out.push(o),
None => return Err("the PaperCraft tab sent an image that is not base64".into()),
}
}
Ok(out)
}
}
impl From<Out> for OutFrame {
fn from(o: Out) -> OutFrame {
match o {
Out::Text(text) => OutFrame::Text { text },
Out::Image { data, mime } => OutFrame::Image {
mime,
data: crate::assets::base64(&data),
},
}
}
}
impl From<OutFrame> for Option<Out> {
fn from(f: OutFrame) -> Option<Out> {
match f {
OutFrame::Text { text } => Some(Out::Text(text)),
OutFrame::Image { mime, data } => {
crate::assets::base64_decode(&data).map(|d| Out::Image { data: d, mime })
}
}
}
}
/// A link token is a bearer secret the tab generates: 32..=64 lowercase hex chars, nothing else.
pub fn valid_token(token: &str) -> bool {
(MIN_TOKEN_LEN..=MAX_TOKEN_LEN).contains(&token.len())
&& token
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
/// Bytes in a freshly generated link token (hex-encoded: [`MIN_TOKEN_LEN`] characters).
pub const TOKEN_BYTES: usize = MIN_TOKEN_LEN / 2;
/// Lowercase hex of `bytes`, the token alphabet.
pub fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
/// Where the tab's WebSocket connects: `base` is a `ws(s)://host` origin.
pub fn tab_url(base: &str, token: &str) -> String {
format!("{}/tab/{token}", base.trim_end_matches('/'))
}
/// The URL an agent is given: the relay's HTTP origin plus the token's MCP endpoint.
pub fn agent_url(origin: &str, token: &str) -> String {
format!("{}/s/{token}/mcp", origin.trim_end_matches('/'))
}
/// The relay's WebSocket base: `?relay=` in the page URL, else `window.PAPERCRAFT_RELAY`, else the
/// page's own origin. `search` is `location.search`; `page_origin` is `location.origin`.
pub fn relay_base(search: &str, global: Option<&str>, page_origin: &str) -> String {
let from_query = search
.trim_start_matches('?')
.split('&')
.find_map(|kv| kv.strip_prefix("relay="))
.filter(|v| !v.is_empty());
match from_query.or(global.filter(|g| !g.is_empty())) {
Some(url) => url.to_string(),
None => page_origin
.replacen("https://", "wss://", 1)
.replacen("http://", "ws://", 1),
}
}
/// The HTTP origin matching a relay WebSocket base (what the agent connects to).
pub fn http_origin(ws_base: &str) -> String {
ws_base
.replacen("wss://", "https://", 1)
.replacen("ws://", "http://", 1)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn png() -> Vec<u8> {
// 1x1 transparent PNG, encoded in the test so the payload stays tiny.
crate::assets::base64_decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
)
.expect("valid base64 fixture")
}
#[test]
fn success_reply_round_trips_through_json() {
let reply = Reply::ok(
7,
vec![
Out::Text("{\"nodes\":1}".into()),
Out::Image {
data: png(),
mime: "image/png".into(),
},
],
);
let json = serde_json::to_string(&reply).unwrap();
assert_eq!(
json,
format!(
r#"{{"id":7,"ok":true,"out":[{{"type":"text","text":"{{\"nodes\":1}}"}},{{"type":"image","mime":"image/png","data":"{}"}}]}}"#,
crate::assets::base64(&png())
)
);
let back: Reply = serde_json::from_str(&json).unwrap();
assert_eq!(back, reply);
let outs = back.into_result().unwrap();
assert_eq!(outs[0], Out::Text("{\"nodes\":1}".into()));
assert_eq!(
outs[1],
Out::Image {
data: png(),
mime: "image/png".into()
}
);
}
#[test]
fn failure_reply_is_exactly_id_ok_error() {
let reply = Reply::err(42, "no artboard");
let json = serde_json::to_string(&reply).unwrap();
assert_eq!(json, r#"{"id":42,"ok":false,"error":"no artboard"}"#);
assert_eq!(serde_json::from_str::<Reply>(&json).unwrap(), reply);
assert_eq!(reply.into_result().unwrap_err(), "no artboard");
}
#[test]
fn a_reply_that_is_not_a_valid_image_is_an_error() {
let bad: Reply = serde_json::from_value(json!({
"id": 1, "ok": true,
"out": [{"type": "image", "mime": "image/png", "data": "not base64!"}]
}))
.unwrap();
assert!(bad.into_result().is_err());
}
#[test]
fn call_round_trips_through_json() {
let call = Call {
id: 7,
name: "get_screenshot".into(),
args: json!({"nodeId": "n1:3"}),
};
let json = serde_json::to_string(&call).unwrap();
assert_eq!(
json,
r#"{"id":7,"name":"get_screenshot","args":{"nodeId":"n1:3"}}"#
);
assert_eq!(serde_json::from_str::<Call>(&json).unwrap(), call);
}
#[test]
fn tokens_are_lowercase_hex_of_the_documented_length() {
let n32 = "a".repeat(MIN_TOKEN_LEN);
let n64 = "0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a69788796a5b4c3d2e1f0";
assert_eq!(n64.len(), MAX_TOKEN_LEN);
assert!(valid_token(&n32));
assert!(valid_token(&n64));
assert!(valid_token(&"0123456789abcdef".repeat(2)));
assert!(!valid_token(""));
assert!(!valid_token(&"a".repeat(MIN_TOKEN_LEN - 1)));
assert!(!valid_token(&"a".repeat(MAX_TOKEN_LEN + 1)));
assert!(!valid_token(&"A".repeat(MIN_TOKEN_LEN)), "uppercase");
assert!(!valid_token(&"g".repeat(MIN_TOKEN_LEN)), "non-hex");
assert!(!valid_token(&format!(
"{}-{}",
"a".repeat(MIN_TOKEN_LEN - 1),
"b"
)));
}
#[test]
fn urls_and_hex() {
assert_eq!(hex(&[0, 15, 255]), "000fff");
assert!(valid_token(&hex(&[0xab; TOKEN_BYTES])));
assert_eq!(tab_url("wss://h.example/", "t"), "wss://h.example/tab/t");
assert_eq!(
agent_url("https://h.example", "t"),
"https://h.example/s/t/mcp"
);
}
#[test]
fn relay_base_prefers_query_then_global_then_origin() {
let o = "https://app.example";
assert_eq!(
relay_base("?relay=wss://r.example&x=1", Some("wss://g"), o),
"wss://r.example"
);
assert_eq!(
relay_base("?x=1", Some("wss://g.example"), o),
"wss://g.example"
);
assert_eq!(relay_base("", None, o), "wss://app.example");
assert_eq!(
relay_base("", Some(""), "http://localhost:8787"),
"ws://localhost:8787"
);
assert_eq!(http_origin("wss://r.example"), "https://r.example");
assert_eq!(http_origin("ws://localhost:1"), "http://localhost:1");
}
}
+3 -2
View File
@@ -23,7 +23,7 @@ use tokio_util::sync::CancellationToken;
pub type Dispatch = Arc<dyn Fn(Call) -> bool + Send + Sync>;
/// Longest a tool call may take on the editor thread.
const CALL_TIMEOUT: Duration = Duration::from_secs(120);
pub const CALL_TIMEOUT: Duration = Duration::from_secs(120);
/// The tool catalogue as rmcp tools.
pub fn tool_list() -> Vec<Tool> {
@@ -107,7 +107,8 @@ impl ServerHandler for PaperServer {
}
/// Add the `Accept` header the streamable HTTP transport requires when a client omits it.
async fn fix_accept(
/// Shared with the relay, which serves the same transport to remote agents.
pub(crate) async fn fix_accept(
mut req: axum::extract::Request,
next: axum::middleware::Next,
) -> axum::response::Response {
+367
View File
@@ -0,0 +1,367 @@
//! 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:?}");
}
}
}
+78 -6
View File
@@ -194,15 +194,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}.",
@@ -440,10 +476,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 +491,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,
),
@@ -539,6 +579,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). \
@@ -610,6 +669,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.",
+6
View File
@@ -252,6 +252,12 @@ fn migrate(
// ---------------------------------------------------------------------------------- helpers
#[cfg(target_arch = "wasm32")]
pub fn now_ms() -> u64 {
js_sys::Date::now() as u64
}
#[cfg(not(target_arch = "wasm32"))]
pub fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
+27 -25
View File
@@ -93,7 +93,7 @@ impl Store {
pub fn open(root: impl Into<PathBuf>) -> std::io::Result<Store> {
let root = root.into();
std::fs::create_dir_all(&root)?;
crate::vfs::create_dir_all(&root)?;
Ok(Store { root })
}
@@ -103,7 +103,7 @@ impl Store {
/// Projects, most recently modified first. Unreadable folders are skipped.
pub fn list(&self) -> Vec<Entry> {
let mut out: Vec<Entry> = std::fs::read_dir(&self.root)
let mut out: Vec<Entry> = crate::vfs::read_dir(&self.root)
.into_iter()
.flatten()
.flatten()
@@ -113,7 +113,7 @@ impl Store {
let manifest = read_manifest(&dir).ok()?;
let thumb = dir.join(THUMBNAIL);
Some(Entry {
thumbnail: thumb.is_file().then_some(thumb),
thumbnail: crate::vfs::is_file(&thumb).then_some(thumb),
dir,
manifest,
})
@@ -133,7 +133,7 @@ impl Store {
let base = folder_name(name);
let mut p = parent.join(&base);
let mut i = 2;
while p.exists() {
while crate::vfs::exists(&p) {
p = parent.join(format!("{base} {i}"));
i += 1;
}
@@ -145,7 +145,7 @@ impl Store {
let taken: Vec<String> = self.list().into_iter().map(|e| e.manifest.name).collect();
let mut n = name.to_string();
let mut i = 2;
while taken.contains(&n) || self.root.join(folder_name(&n)).exists() {
while taken.contains(&n) || crate::vfs::exists(&self.root.join(folder_name(&n))) {
n = format!("{name} {i}");
i += 1;
}
@@ -156,7 +156,7 @@ impl Store {
pub fn create(&self, name: &str) -> Result<(Project, PageData), FormatError> {
let name = self.unique_name(name);
let dir = self.unique_dir(&self.root, &name);
std::fs::create_dir_all(dir.join(PAGES_DIR))?;
crate::vfs::create_dir_all(dir.join(PAGES_DIR))?;
let manifest = Manifest::new(&name);
let page = PageData {
id: manifest.pages[0].id.clone(),
@@ -181,7 +181,7 @@ impl Store {
) -> Result<Project, FormatError> {
let name = self.unique_name(name);
let dir = self.unique_dir(&self.root, &name);
std::fs::create_dir_all(dir.join(PAGES_DIR))?;
crate::vfs::create_dir_all(dir.join(PAGES_DIR))?;
let mut manifest = Manifest::new(&name);
manifest.pages = pages
.iter()
@@ -214,12 +214,12 @@ impl Store {
if wanted == dir {
return Ok(dir.to_path_buf());
}
let target = if wanted.exists() {
let target = if crate::vfs::exists(&wanted) {
self.unique_dir(&self.root, new_name)
} else {
wanted
};
std::fs::rename(dir, &target)?;
crate::vfs::rename(dir, &target)?;
Ok(target)
}
@@ -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;
@@ -244,26 +246,26 @@ impl Store {
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
std::fs::create_dir_all(self.trash_root())?;
crate::vfs::create_dir_all(self.trash_root())?;
let deleted = now_ms();
let target = self
.trash_root()
.join(format!("{}~{deleted}", folder_name(&original)));
std::fs::rename(dir, &target)?;
crate::vfs::rename(dir, &target)?;
let info = serde_json::json!({ "deleted": deleted, "original": original });
atomic::write(&target.join(TRASH_INFO), info.to_string().as_bytes())?;
Ok(target)
}
pub fn list_trash(&self) -> Vec<TrashEntry> {
let mut out: Vec<TrashEntry> = std::fs::read_dir(self.trash_root())
let mut out: Vec<TrashEntry> = crate::vfs::read_dir(self.trash_root())
.into_iter()
.flatten()
.flatten()
.filter_map(|e| {
let dir = e.path();
let manifest = read_manifest(&dir).ok()?;
let info: serde_json::Value = std::fs::read_to_string(dir.join(TRASH_INFO))
let info: serde_json::Value = crate::vfs::read_to_string(dir.join(TRASH_INFO))
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
@@ -290,8 +292,8 @@ impl Store {
.find(|e| e.dir == trash_dir)
.ok_or_else(|| FormatError::Invalid("not in trash".into()))?;
let target = self.unique_dir(&self.root, &entry.original);
std::fs::rename(trash_dir, &target)?;
let _ = std::fs::remove_file(target.join(TRASH_INFO));
crate::vfs::rename(trash_dir, &target)?;
let _ = crate::vfs::remove_file(target.join(TRASH_INFO));
Ok(target)
}
@@ -301,7 +303,7 @@ impl Store {
"refusing to delete outside the trash",
));
}
std::fs::remove_dir_all(trash_dir)
crate::vfs::remove_dir_all(trash_dir)
}
pub fn empty_trash(&self) -> std::io::Result<()> {
@@ -313,18 +315,18 @@ impl Store {
}
fn read_manifest(dir: &Path) -> Result<Manifest, FormatError> {
Manifest::from_json(&std::fs::read_to_string(dir.join(MANIFEST))?)
Manifest::from_json(&crate::vfs::read_to_string(dir.join(MANIFEST))?)
}
fn copy_dir(from: &Path, to: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(to)?;
for e in std::fs::read_dir(from)? {
crate::vfs::create_dir_all(to)?;
for e in crate::vfs::read_dir(from)? {
let e = e?;
let target = to.join(e.file_name());
if e.file_type()?.is_dir() {
copy_dir(&e.path(), &target)?;
} else {
std::fs::copy(e.path(), target)?;
crate::vfs::copy(e.path(), target)?;
}
}
Ok(())
@@ -346,7 +348,7 @@ impl Project {
}
pub fn load_page(&self, id: &str) -> Result<PageData, FormatError> {
let mut p = format::page_from_json(&std::fs::read_to_string(self.page_path(id))?)?;
let mut p = format::page_from_json(&crate::vfs::read_to_string(self.page_path(id))?)?;
if let Some(r) = self.manifest.pages.iter().find(|r| r.id == id) {
// The manifest owns page names (renames touch only the manifest).
p.name = r.name.clone();
@@ -379,7 +381,7 @@ impl Project {
}
pub fn save_page(&self, page: &PageData) -> Result<(), FormatError> {
std::fs::create_dir_all(self.dir.join(PAGES_DIR))?;
crate::vfs::create_dir_all(self.dir.join(PAGES_DIR))?;
atomic::write(
&self.page_path(&page.id),
format::page_to_json(page).as_bytes(),
@@ -388,7 +390,7 @@ impl Project {
}
pub fn delete_page_file(&self, id: &str) {
let _ = std::fs::remove_file(self.page_path(id));
let _ = crate::vfs::remove_file(self.page_path(id));
}
pub fn write_manifest(&self) -> Result<(), FormatError> {
@@ -447,7 +449,7 @@ mod tests {
s.empty_trash().unwrap();
assert!(s.list_trash().is_empty());
assert!(s.delete_forever(&s.root.join("Alpha")).is_err());
std::fs::remove_dir_all(&s.root).ok();
crate::vfs::remove_dir_all(&s.root).ok();
}
#[test]
@@ -474,7 +476,7 @@ mod tests {
assert_eq!(pages[0], page);
assert!(pages[1].doc.nodes.is_empty());
assert_eq!(pages[1].name, "Ghost");
std::fs::remove_dir_all(&s.root).ok();
crate::vfs::remove_dir_all(&s.root).ok();
}
#[test]
+108 -13
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.
@@ -772,8 +775,8 @@ impl Headless {
})
}
/// Copy a texture back to the CPU as RGBA8.
pub fn read(&self, tex: &wgpu::Texture) -> image::RgbaImage {
/// Copy `tex` into a mappable buffer and submit; returns the buffer and its row stride.
fn start_readback(&self, tex: &wgpu::Texture) -> (wgpu::Buffer, u32) {
let (w, h) = (tex.width(), tex.height());
let row = (w * 4).div_ceil(256) * 256;
let buf = self.device.create_buffer(&wgpu::BufferDescriptor {
@@ -805,12 +808,15 @@ impl Headless {
},
);
self.queue.submit([enc.finish()]);
let slice = buf.slice(..);
slice.map_async(wgpu::MapMode::Read, |_| {});
self.device
.poll(wgpu::PollType::wait_indefinitely())
.expect("poll");
let data = slice.get_mapped_range().expect("mapped readback buffer");
(buf, row)
}
/// Strip the row padding off a mapped readback buffer.
fn unpad(buf: &wgpu::Buffer, row: u32, w: u32, h: u32) -> image::RgbaImage {
let data = buf
.slice(..)
.get_mapped_range()
.expect("mapped readback buffer");
let mut img = image::RgbaImage::new(w, h);
for y in 0..h {
let src = &data[(y * row) as usize..(y * row + w * 4) as usize];
@@ -819,14 +825,38 @@ impl Headless {
img
}
/// Render a canvas frame into a new image of `size` physical px.
pub fn render_frame(
/// Copy a texture back to the CPU as RGBA8.
pub fn read(&self, tex: &wgpu::Texture) -> image::RgbaImage {
let (buf, row) = self.start_readback(tex);
buf.slice(..).map_async(wgpu::MapMode::Read, |_| {});
self.device
.poll(wgpu::PollType::wait_indefinitely())
.expect("poll");
Self::unpad(&buf, row, tex.width(), tex.height())
}
/// [`Headless::read`] without blocking: the browser cannot wait on the GPU, so the
/// buffer is mapped with a callback and the caller awaits it (the JS event loop drives it).
pub async fn read_async(&self, tex: &wgpu::Texture) -> Result<image::RgbaImage, String> {
let (buf, row) = self.start_readback(tex);
let (tx, rx) = oneshot();
buf.slice(..)
.map_async(wgpu::MapMode::Read, move |r| tx.send(r));
// WebGPU resolves the mapping from the JS event loop; the WebGL backend only delivers
// callbacks when polled, so nudge it (never blocks).
let _ = self.device.poll(wgpu::PollType::Poll);
rx.await.map_err(|e| format!("GPU readback failed: {e}"))?;
Ok(Self::unpad(&buf, row, tex.width(), tex.height()))
}
/// Draw a canvas frame into a new offscreen texture (not yet read back).
fn draw_frame(
&self,
frame: &CanvasFrame,
size: [u32; 2],
scale: f32,
clear: [f64; 4],
) -> image::RgbaImage {
) -> wgpu::Texture {
let tex = self.target(size);
let view = tex.create_view(&Default::default());
let mut r = CanvasRenderer::new(&self.device, OFFSCREEN_FORMAT);
@@ -842,6 +872,71 @@ impl Headless {
clear,
);
self.queue.submit([enc.finish()]);
self.read(&tex)
tex
}
/// Render a canvas frame into a new image of `size` physical px.
pub fn render_frame(
&self,
frame: &CanvasFrame,
size: [u32; 2],
scale: f32,
clear: [f64; 4],
) -> image::RgbaImage {
self.read(&self.draw_frame(frame, size, scale, clear))
}
/// [`Headless::render_frame`] for the browser (see [`Headless::read_async`]).
pub async fn render_frame_async(
&self,
frame: &CanvasFrame,
size: [u32; 2],
scale: f32,
clear: [f64; 4],
) -> Result<image::RgbaImage, String> {
self.read_async(&self.draw_frame(frame, size, scale, clear))
.await
}
}
/// Minimal one-shot channel: wgpu's map callback on one end, an awaiting task on the other.
/// (No async runtime exists in the browser build, and `tokio::sync::oneshot` is native-only.)
struct Slot<T> {
value: Option<T>,
waker: Option<std::task::Waker>,
}
struct Sender<T>(std::sync::Arc<std::sync::Mutex<Slot<T>>>);
struct Receiver<T>(std::sync::Arc<std::sync::Mutex<Slot<T>>>);
fn oneshot<T>() -> (Sender<T>, Receiver<T>) {
let slot = std::sync::Arc::new(std::sync::Mutex::new(Slot {
value: None,
waker: None,
}));
(Sender(slot.clone()), Receiver(slot))
}
impl<T> Sender<T> {
fn send(self, v: T) {
let mut s = self.0.lock().expect("oneshot lock");
s.value = Some(v);
if let Some(w) = s.waker.take() {
w.wake();
}
}
}
impl<T> std::future::Future for Receiver<T> {
type Output = T;
fn poll(self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> std::task::Poll<T> {
let mut s = self.0.lock().expect("oneshot lock");
match s.value.take() {
Some(v) => std::task::Poll::Ready(v),
None => {
s.waker = Some(cx.waker().clone());
std::task::Poll::Pending
}
}
}
}
+154 -4
View File
@@ -6,17 +6,23 @@ use crate::editor::Editor;
use crate::project::{FormatError, PageRef, Project, Store, now_ms};
use crate::render::gpu::Headless;
use std::path::Path;
use std::time::{Duration, Instant};
use std::time::Duration;
use web_time::Instant;
/// Save this long after the first unsaved change (bounded latency, even while editing).
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>,
@@ -24,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,
@@ -73,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());
@@ -159,12 +262,24 @@ impl Session {
match render_thumbnail(gpu, doc)? {
Some(png) => crate::io::atomic::write(&path, &png).map_err(|e| e.to_string()),
None => {
let _ = std::fs::remove_file(&path);
let _ = crate::vfs::remove_file(&path);
Ok(())
}
}
}
/// Mark the thumbnail as taken and hand back what has to be rendered (the browser renders it
/// asynchronously and then calls [`finish_thumbnail`]).
pub fn start_thumbnail(&mut self, ed: &Editor) -> Option<ThumbJob> {
let p = self.project.as_ref()?;
self.thumb_stale = false;
self.last_thumb = Some(Instant::now());
Some(ThumbJob {
path: p.thumbnail_path(),
doc: ed.page_doc(0).clone(),
})
}
/// Close the project: final save; the caller then shows the home screen.
pub fn close(&mut self, ed: &mut Editor) -> Result<(), FormatError> {
let r = self.save_all(ed);
@@ -174,6 +289,41 @@ impl Session {
}
}
/// A thumbnail waiting to be rendered: the first page as it was when the job started.
pub struct ThumbJob {
pub path: std::path::PathBuf,
pub doc: crate::model::Document,
}
/// Scale that fits the document's bounds into the thumbnail box, None when it's empty.
fn thumbnail_scale(doc: &crate::model::Document) -> Option<f64> {
let b = crate::io::svg::export_bounds(doc, &doc.root)?;
Some((THUMB_SIZE.0 / b.w).min(THUMB_SIZE.1 / b.h).min(2.0))
}
/// [`render_thumbnail`] without blocking on the GPU (browser).
pub async fn render_thumbnail_async(
gpu: &Headless,
doc: &crate::model::Document,
) -> Result<Option<Vec<u8>>, String> {
let Some(scale) = thumbnail_scale(doc) else {
return Ok(None);
};
let img = crate::io::png::render_async(gpu, doc, None, scale, None).await?;
crate::io::export::encode_png(&img).map(Some)
}
/// Store a rendered thumbnail (None: the page is empty, so remove any old one).
pub fn finish_thumbnail(path: &std::path::Path, png: Option<Vec<u8>>) -> Result<(), String> {
match png {
Some(png) => crate::io::atomic::write(path, &png).map_err(|e| e.to_string()),
None => {
let _ = crate::vfs::remove_file(path);
Ok(())
}
}
}
/// PNG bytes of a document fitted into the thumbnail box, or None when it's empty.
pub fn render_thumbnail(
gpu: &Headless,
@@ -231,7 +381,7 @@ mod tests {
Session::new(s.clone()).open(&mut ed2, &dir).unwrap();
assert_eq!(ed2.doc.nodes, ed.doc.nodes);
assert!(!ed2.project_dirty());
std::fs::remove_dir_all(&s.root).ok();
crate::vfs::remove_dir_all(&s.root).ok();
}
#[test]
@@ -262,6 +412,6 @@ mod tests {
p.manifest.active_page.as_deref(),
Some(pages[0].id.as_str())
);
std::fs::remove_dir_all(&s.root).ok();
crate::vfs::remove_dir_all(&s.root).ok();
}
}
+635
View File
@@ -0,0 +1,635 @@
//! 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());
}
}
+58 -6
View File
@@ -20,7 +20,7 @@ pub const MAC_BLUE: Color32 = Color32::from_rgb(0x00, 0x7a, 0xff);
pub const BODY: f32 = 13.0;
pub const SECONDARY: f32 = 11.0;
/// Inner margin of text inputs (egui's default is a cramped 4x2).
pub const INPUT_MARGIN: egui::Margin = egui::Margin::symmetric(8, 5);
pub const INPUT_MARGIN: egui::Margin = egui::Margin::symmetric(10, 7);
/// Height of the unified toolbar.
pub const TOOLBAR_H: f32 = 40.0;
/// Traffic lights: left inset and the height of the bar they are centred in (points).
@@ -192,6 +192,47 @@ pub fn install_fonts(ctx: &egui::Context) {
pub fn medium(size: f32) -> FontId {
FontId::new(size, FontFamily::Name("medium".into()))
}
/// Figma-style inspector controls inside `ui`: flat tinted fields, no borders, 28px rows.
pub fn figma_fields(ui: &mut egui::Ui) {
let dark = ui.visuals().dark_mode;
let tint = |a: u8| {
if dark {
Color32::from_white_alpha(a)
} else {
Color32::from_black_alpha(a)
}
};
let (rest, hover, press) = (tint(14), tint(24), tint(34));
let sp = ui.spacing_mut();
sp.interact_size.y = FIELD_H;
sp.button_padding = vec2(8.0, 4.0);
sp.item_spacing = vec2(8.0, 6.0);
let v = ui.visuals_mut();
v.text_edit_bg_color = Some(rest);
v.extreme_bg_color = rest;
let r5 = CornerRadius::same(5);
let w = &mut v.widgets;
for (s, fill) in [
(&mut w.inactive, rest),
(&mut w.hovered, hover),
(&mut w.active, press),
(&mut w.open, press),
] {
s.bg_fill = fill;
s.weak_bg_fill = fill;
s.bg_stroke = Stroke::NONE;
s.corner_radius = r5;
}
}
/// Height of an inspector field row.
pub const FIELD_H: f32 = 28.0;
/// egui's `small_button` zeroes the padding; this keeps the theme's but stays compact.
pub fn small_button(ui: &mut egui::Ui, text: impl Into<egui::WidgetText>) -> egui::Response {
ui.add(egui::Button::new(text).min_size(vec2(0.0, 24.0)))
}
pub fn semibold(size: f32) -> FontId {
FontId::new(size, FontFamily::Name("semibold".into()))
}
@@ -213,8 +254,8 @@ fn style_for(s: &mut egui::Style, dark: bool, p: Palette) {
]
.into();
s.spacing.item_spacing = vec2(6.0, 4.0);
s.spacing.button_padding = vec2(9.0, 4.0);
s.spacing.interact_size = vec2(24.0, 26.0);
s.spacing.button_padding = vec2(12.0, 6.0);
s.spacing.interact_size = vec2(26.0, 30.0);
s.spacing.menu_margin = egui::Margin::same(5);
s.spacing.window_margin = egui::Margin::same(12);
s.spacing.scroll = egui::style::ScrollStyle {
@@ -351,6 +392,7 @@ impl Choice {
}
/// Persisted in the config dir; a missing or unreadable file means `System`.
#[cfg(not(target_arch = "wasm32"))]
fn path() -> Option<std::path::PathBuf> {
let dir = dirs::config_dir()?;
let new = dir.join("papercraft").join("theme");
@@ -362,9 +404,15 @@ impl Choice {
})
}
/// No config dir in a browser: the virtual file system holds it at a fixed path.
#[cfg(target_arch = "wasm32")]
fn path() -> Option<std::path::PathBuf> {
Some(std::path::PathBuf::from(WEB_PATH))
}
pub fn load() -> Choice {
Choice::path()
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|p| crate::vfs::read_to_string(p).ok())
.and_then(|s| Choice::parse(&s))
.unwrap_or_default()
}
@@ -372,13 +420,17 @@ impl Choice {
pub fn save(self) {
if let Some(p) = Choice::path() {
if let Some(dir) = p.parent() {
let _ = std::fs::create_dir_all(dir);
let _ = crate::vfs::create_dir_all(dir);
}
let _ = std::fs::write(p, self.id());
let _ = crate::vfs::write(p, self.id());
}
}
}
/// Where the theme choice lives in the browser's virtual file system.
#[cfg(target_arch = "wasm32")]
const WEB_PATH: &str = "/config/theme";
static CHOICE: AtomicU8 = AtomicU8::new(0);
pub fn choice() -> Choice {
+161
View File
@@ -0,0 +1,161 @@
//! The browser build's "Agent" menu: a link a remote AI agent can use to drive this tab through
//! the relay (see `mcp::relay`). The shell (`web/relay.rs`) owns the connection and mirrors its
//! state here; this module only draws and records what the user clicked.
use super::theme;
use egui::{Color32, RichText};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LinkState {
#[default]
Offline,
Connecting,
Connected,
}
impl LinkState {
pub fn label(self) -> &'static str {
match self {
LinkState::Offline => "Offline",
LinkState::Connecting => "Connecting",
LinkState::Connected => "Connected",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentAction {
/// Replace the token (the old link stops working) and reconnect.
Regenerate,
}
#[derive(Debug, Default)]
pub struct AgentUi {
/// The full agent URL, `https://<host>/s/<token>/mcp`.
pub url: String,
pub state: LinkState,
pub open: bool,
pub actions: Vec<AgentAction>,
}
const CONNECTED_DOT: Color32 = Color32::from_rgb(0x34, 0xc7, 0x59);
const CONNECTING_DOT: Color32 = Color32::from_rgb(0xff, 0xb3, 0x40);
const DOT_RADIUS: f32 = 4.0;
/// Shell command that registers the link with Claude Code.
pub fn claude_command(url: &str) -> String {
format!("claude mcp add --transport http papercraft {url}")
}
/// Generic MCP client configuration for the link.
pub fn json_config(url: &str) -> String {
serde_json::to_string_pretty(&serde_json::json!({
"mcpServers": { "papercraft": { "type": "http", "url": url } }
}))
.expect("config serializes")
}
impl AgentUi {
/// The top-level "Agent" menu.
pub fn menu(&mut self, ui: &mut egui::Ui) {
ui.menu_button("Agent", |ui| {
if ui.button("Connect…").clicked() {
self.open = true;
ui.close();
}
});
}
/// A status dot for the toolbar; nothing while offline.
pub fn dot(&self, ui: &mut egui::Ui) {
let color = match self.state {
LinkState::Offline => return,
LinkState::Connecting => CONNECTING_DOT,
LinkState::Connected => CONNECTED_DOT,
};
let (rect, resp) = ui.allocate_exact_size(
egui::Vec2::splat(DOT_RADIUS * 2.0 + 4.0),
egui::Sense::hover(),
);
ui.painter().circle_filled(rect.center(), DOT_RADIUS, color);
resp.on_hover_text(format!("Agent link: {}", self.state.label()));
}
/// The "Connect…" window.
pub fn window(&mut self, ctx: &egui::Context) {
let mut open = self.open;
egui::Window::new("Connect an AI agent")
.open(&mut open)
.collapsible(false)
.default_width(460.0)
.show(ctx, |ui| self.contents(ui));
self.open = open && self.open;
}
fn contents(&mut self, ui: &mut egui::Ui) {
let p = theme::pal(ui);
ui.label(
RichText::new(
"Give this link to an MCP-capable agent. Anyone who has it can edit this tab, \
so treat it like a password.",
)
.size(theme::SECONDARY)
.color(p.secondary),
);
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.label("Status:");
let color = match self.state {
LinkState::Connected => CONNECTED_DOT,
LinkState::Connecting => CONNECTING_DOT,
LinkState::Offline => p.secondary,
};
ui.label(RichText::new(self.state.label()).color(color));
});
ui.add_space(6.0);
self.copy_row(ui, "Agent URL", &self.url.clone(), false);
self.copy_row(ui, "Claude Code", &claude_command(&self.url), false);
self.copy_row(ui, "MCP config (JSON)", &json_config(&self.url), true);
ui.add_space(6.0);
if ui.button("Regenerate link").clicked() {
self.actions.push(AgentAction::Regenerate);
}
}
fn copy_row(&self, ui: &mut egui::Ui, label: &str, text: &str, multiline: bool) {
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label(label);
if ui.button("Copy").clicked() {
ui.ctx().copy_text(text.to_string());
}
});
let mut shown = text;
let edit = if multiline {
egui::TextEdit::multiline(&mut shown).desired_rows(5)
} else {
egui::TextEdit::singleline(&mut shown)
};
ui.add(
edit.desired_width(f32::INFINITY)
.font(egui::TextStyle::Monospace),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn snippets_carry_the_url() {
let url = "https://h.example/s/abc/mcp";
assert_eq!(
claude_command(url),
"claude mcp add --transport http papercraft https://h.example/s/abc/mcp"
);
let v: serde_json::Value = serde_json::from_str(&json_config(url)).unwrap();
assert_eq!(v["mcpServers"]["papercraft"]["url"], url);
assert_eq!(v["mcpServers"]["papercraft"]["type"], "http");
}
}
+345 -32
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| {
@@ -33,6 +47,7 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
.id_salt("design")
.auto_shrink(false)
.show(ui, |ui| {
crate::theme::figma_fields(ui);
// ---- open feedback on this page (and a pointer to other pages)
let mut open: Vec<(NodeId, String, String)> = ed
.doc
@@ -82,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);
@@ -95,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() {
@@ -128,10 +181,46 @@ 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 ui
.small_button("✕")
if crate::theme::small_button(ui, "✕")
.on_hover_text("Delete (fills keep their color)")
.clicked()
{
@@ -151,14 +240,8 @@ 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();
if (ui.small_button("Add").clicked() || enter) && !name.is_empty() {
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);
on_every_page(ed, "Add variable", |tx| {
@@ -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 ui.small_button("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 ui.small_button("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,
);
});
}
});
}
});
}
});
}
+138 -9
View File
@@ -18,6 +18,30 @@ pub enum HomeAction {
Restore(PathBuf),
DeleteForever(PathBuf),
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)]
@@ -31,11 +55,25 @@ 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)>,
}
const CARD_W: f32 = 232.0;
const THUMB_H: f32 = 148.0;
/// `HomeAction::RevealRoot` opens the folder natively; a browser has none, so the same action
/// downloads a zip of every project there.
const REVEAL_LABEL: &str = if cfg!(target_arch = "wasm32") {
"Download backup"
} else {
"Open projects folder"
};
impl HomeView {
/// Re-read the project list and trash from the store (keeps cached thumbnails).
@@ -53,13 +91,15 @@ impl HomeView {
}
fn thumb(&mut self, ctx: &egui::Context, path: &Path) -> Option<egui::TextureHandle> {
let mtime = std::fs::metadata(path).and_then(|m| m.modified()).ok();
let mtime = crate::vfs::metadata(path).and_then(|m| m.modified()).ok();
if let Some((t, h)) = self.thumbs.get(path)
&& *t == mtime
{
return Some(h.clone());
}
let img = image::open(path).ok()?.to_rgba8();
let img = image::load_from_memory(&crate::vfs::read(path).ok()?)
.ok()?
.to_rgba8();
let size = [img.width() as usize, img.height() as usize];
let ci = egui::ColorImage::from_rgba_unmultiplied(size, img.as_raw());
let h = ctx.load_texture(path.to_string_lossy(), ci, egui::TextureOptions::LINEAR);
@@ -124,12 +164,13 @@ pub fn draw(ui: &mut Ui, hv: &mut HomeView, native_chrome: bool, translucent: bo
}
ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| {
ui.add_space(4.0);
ui.label(
RichText::new(hv.root.display().to_string())
.size(theme::SECONDARY - 1.0)
.color(p.secondary),
)
.on_hover_text("Projects folder");
let path = RichText::new(hv.root.display().to_string())
.size(theme::SECONDARY - 1.0)
.color(p.secondary);
ui.label(path).on_hover_text("Projects folder");
if sidebar_item(ui, Icon::Page, REVEAL_LABEL, None, false) {
hv.actions.push(HomeAction::RevealRoot);
}
});
});
egui::Panel::top("home-toolbar")
@@ -168,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(),
});
}
}
});
});
@@ -191,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(
@@ -391,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();
@@ -436,7 +565,7 @@ fn trash_grid(ui: &mut Ui, hv: &mut HomeView) {
ui.vertical(|ui| {
ui.set_width(CARD_W);
let thumb = e.dir.join(crate::project::THUMBNAIL);
let thumb = thumb.is_file().then_some(thumb);
let thumb = crate::vfs::is_file(&thumb).then_some(thumb);
let resp = card_frame(ui, hv, thumb.as_deref(), egui::Id::new(&e.dir));
resp.context_menu(|ui| {
if ui.button("Restore").clicked() {
+577 -169
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -105,6 +105,7 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
.id_salt("library")
.auto_shrink(false)
.show(ui, |ui| {
crate::theme::figma_fields(ui);
for row in items.chunks(cols) {
ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
+82 -8
View File
@@ -1,6 +1,7 @@
//! egui chrome: menu bar, layers panel, design (properties) panel, floating toolbar, and the
//! projects home screen.
pub mod agent;
pub mod design;
pub mod home;
pub mod inspector;
@@ -70,8 +71,25 @@ 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.
pub agent_link: Option<agent::AgentUi>,
/// 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.
@@ -118,6 +136,12 @@ pub fn draw(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) -> egui::Rect {
.frame(egui::Frame::NONE.fill(p.window))
.show(ui, |ui| title_bar(ui, ed, st));
}
if let Some(a) = &mut st.agent_link {
a.window(ui.ctx());
}
if let Some((text, error)) = &st.toast {
toast(ui.ctx(), text, *error);
}
if let Some(hv) = &mut st.home {
home::draw(ui, hv, st.native_chrome, st.translucent_sidebar);
return egui::Rect::NOTHING;
@@ -224,8 +248,10 @@ fn top_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
let p = theme::pal(ui);
ui.horizontal_centered(|ui| {
if !st.native_chrome && !st.custom_titlebar {
menu_bar(ui, ed);
ui.add_space(8.0);
ui.scope(|ui| menu_bar(ui, ed, &mut st.agent_link));
ui.add_space(6.0);
ui.separator();
ui.add_space(6.0);
}
if theme::icon_button(ui, theme::Icon::Grid, 26.0, false)
.on_hover_text("All projects")
@@ -266,6 +292,9 @@ fn top_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.spacing_mut().item_spacing.x = 2.0;
if let Some(a) = &st.agent_link {
a.dot(ui);
}
if theme::icon_button(ui, theme::Icon::Question, 26.0, st.show_help)
.on_hover_text("Keyboard shortcuts")
.clicked()
@@ -309,16 +338,36 @@ fn top_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
/// egui menus for platforms without a native menu bar (inline, so the toolbar row keeps
/// the title and zoom controls).
fn menu_bar(ui: &mut Ui, ed: &mut Editor) {
fn menu_bar(ui: &mut Ui, ed: &mut Editor, agent: &mut Option<agent::AgentUi>) {
flat_menus(ui);
for (title, entries) in shortcuts::menus() {
ui.menu_button(title, |ui| {
menu_entries(ui, ed, &entries);
});
}
if let Some(a) = agent {
a.menu(ui);
}
theme_menu(ui);
}
/// Menu titles read as plain text: no fill or border at rest, a tint on hover/press.
fn flat_menus(ui: &mut Ui) {
let p = theme::pal(ui);
ui.spacing_mut().button_padding = egui::vec2(8.0, 4.0);
ui.spacing_mut().item_spacing.x = 0.0;
let w = &mut ui.visuals_mut().widgets;
w.inactive.weak_bg_fill = Color32::TRANSPARENT;
w.hovered.weak_bg_fill = p.hover;
w.active.weak_bg_fill = p.pressed;
w.open.weak_bg_fill = p.pressed;
for s in [&mut w.inactive, &mut w.hovered, &mut w.active, &mut w.open] {
s.bg_stroke = egui::Stroke::NONE;
}
}
fn theme_menu(ui: &mut Ui) {
flat_menus(ui);
ui.menu_button("Theme", |ui| {
for c in theme::Choice::ALL {
if ui
@@ -351,7 +400,7 @@ fn title_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
if st.home.is_some() {
theme_menu(ui);
} else {
menu_bar(ui, ed);
menu_bar(ui, ed, &mut st.agent_link);
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.spacing_mut().item_spacing.x = 0.0;
@@ -360,10 +409,8 @@ fn title_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
(WinCmd::ToggleMaximize, p.hover),
(WinCmd::Minimize, p.hover),
] {
let (r, resp) = ui.allocate_exact_size(
egui::vec2(WIN_BTN_W, TITLEBAR_H),
egui::Sense::click(),
);
let (r, resp) =
ui.allocate_exact_size(egui::vec2(WIN_BTN_W, TITLEBAR_H), egui::Sense::click());
let hot = resp.hovered();
if hot {
ui.painter().rect_filled(r, 0.0, hover);
@@ -404,6 +451,7 @@ fn title_bar(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
}
fn layers(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
theme::figma_fields(ui);
pages::panel(ui, ed, st);
ui.add_space(4.0);
let r = ui.available_rect_before_wrap();
@@ -901,6 +949,32 @@ fn toolbar(ctx: &egui::Context, ed: &mut Editor, canvas: egui::Rect) {
});
}
/// Colour of an error toast's background.
const TOAST_ERROR: Color32 = Color32::from_rgb(0xb4, 0x2b, 0x2b);
fn toast(ctx: &egui::Context, text: &str, error: bool) {
egui::Area::new("toast".into())
.order(egui::Order::Tooltip)
.anchor(Align2::CENTER_TOP, egui::vec2(0.0, 44.0))
.interactable(false)
.show(ctx, |ui| {
let p = theme::pal(ui);
let (fill, ink) = if error {
(TOAST_ERROR, Color32::WHITE)
} else {
(p.popover, p.secondary)
};
egui::Frame::popup(ui.style())
.fill(fill)
.corner_radius(8.0)
.inner_margin(egui::Margin::symmetric(14, 8))
.show(ui, |ui| {
ui.set_max_width(520.0);
ui.label(RichText::new(text).color(ink));
});
});
}
fn status(ctx: &egui::Context, ed: &Editor, canvas: egui::Rect) {
if ed.status.is_empty() {
return;
+372
View File
@@ -0,0 +1,372 @@
//! In-memory tree persisted to IndexedDB (browser).
use std::cell::RefCell;
use std::collections::{BTreeMap, BTreeSet};
use std::ffi::OsString;
use std::io::{Error, ErrorKind, Result};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use wasm_bindgen::prelude::*;
#[wasm_bindgen(inline_js = r#"
let dbp;
function db() {
return (dbp ??= new Promise((res, rej) => {
const r = indexedDB.open('papercraft', 1);
r.onupgradeneeded = () => r.result.createObjectStore('files');
r.onsuccess = () => res(r.result);
r.onerror = () => rej(r.error);
}));
}
function store(d, mode) { return d.transaction('files', mode).objectStore('files'); }
export async function idb_load_all() {
const d = await db();
return new Promise((res, rej) => {
const out = [];
const rq = store(d, 'readonly').openCursor();
rq.onsuccess = () => {
const c = rq.result;
if (c) { out.push([c.key, c.value]); c.continue(); } else res(out);
};
rq.onerror = () => rej(rq.error);
});
}
export async function idb_put(key, bytes) {
const d = await db();
return new Promise((res, rej) => {
const rq = store(d, 'readwrite').put(bytes, key);
rq.onsuccess = () => res();
rq.onerror = () => rej(rq.error);
});
}
export async function idb_delete(key) {
const d = await db();
return new Promise((res, rej) => {
const rq = store(d, 'readwrite').delete(key);
rq.onsuccess = () => res();
rq.onerror = () => rej(rq.error);
});
}
"#)]
extern "C" {
#[wasm_bindgen(catch)]
async fn idb_load_all() -> std::result::Result<JsValue, JsValue>;
fn idb_put(key: &str, bytes: &[u8]) -> js_sys::Promise;
fn idb_delete(key: &str) -> js_sys::Promise;
}
#[derive(Default)]
struct Tree {
files: BTreeMap<PathBuf, Vec<u8>>,
dirs: BTreeSet<PathBuf>,
}
thread_local! {
static TREE: RefCell<Tree> = RefCell::new(Tree::default());
}
fn key(p: &Path) -> String {
p.to_string_lossy().into_owned()
}
thread_local! {
/// The latest IndexedDB write that failed (quota, private mode, blocked database). The files
/// stay in memory, so the shell shows this and the user can still download a backup.
static PERSIST_ERROR: RefCell<Option<String>> = const { RefCell::new(None) };
}
/// Take the latest failed IndexedDB write, if any.
pub fn take_persist_error() -> Option<String> {
PERSIST_ERROR.with(|e| e.borrow_mut().take())
}
/// A failed write is waiting to be shown.
pub fn has_persist_error() -> bool {
PERSIST_ERROR.with(|e| e.borrow().is_some())
}
fn persist(p: &Path, what: &'static str, promise: js_sys::Promise) {
let name = p.display().to_string();
wasm_bindgen_futures::spawn_local(async move {
if let Err(e) = wasm_bindgen_futures::JsFuture::from(promise).await {
let why = e
.as_string()
.or_else(|| {
js_sys::Reflect::get(&e, &"message".into())
.ok()?
.as_string()
})
.unwrap_or_else(|| "storage error".into());
PERSIST_ERROR.with(|slot| {
*slot.borrow_mut() =
Some(format!("Could not {what} {name} in browser storage: {why}"));
});
}
});
}
fn persist_put(p: &Path, data: &[u8]) {
persist(p, "save", idb_put(&key(p), data));
}
fn persist_delete(p: &Path) {
persist(p, "delete", idb_delete(&key(p)));
}
fn not_found(p: &Path) -> Error {
Error::new(ErrorKind::NotFound, format!("{}: not found", p.display()))
}
/// Read everything back from IndexedDB. Call once before the app starts.
pub async fn load() -> std::result::Result<(), String> {
let rows = idb_load_all().await.map_err(|e| format!("{e:?}"))?;
TREE.with(|t| {
let mut t = t.borrow_mut();
for row in js_sys::Array::from(&rows).iter() {
let row = js_sys::Array::from(&row);
let (Some(k), v) = (row.get(0).as_string(), row.get(1)) else {
continue;
};
let p = PathBuf::from(k);
for a in p.ancestors().skip(1) {
t.dirs.insert(a.to_path_buf());
}
t.files.insert(p, js_sys::Uint8Array::new(&v).to_vec());
}
});
Ok(())
}
pub fn is_file(p: &Path) -> bool {
TREE.with(|t| t.borrow().files.contains_key(p))
}
pub fn is_dir(p: &Path) -> bool {
TREE.with(|t| {
let t = t.borrow();
t.dirs.contains(p) || t.files.keys().any(|f| f.starts_with(p) && f != p)
})
}
pub fn exists(p: &Path) -> bool {
is_file(p) || is_dir(p)
}
pub fn create_dir_all(p: impl AsRef<Path>) -> Result<()> {
TREE.with(|t| {
let mut t = t.borrow_mut();
for a in p.as_ref().ancestors() {
if !a.as_os_str().is_empty() {
t.dirs.insert(a.to_path_buf());
}
}
});
Ok(())
}
pub fn read(p: impl AsRef<Path>) -> Result<Vec<u8>> {
let p = p.as_ref();
TREE.with(|t| t.borrow().files.get(p).cloned())
.ok_or_else(|| not_found(p))
}
pub fn read_to_string(p: impl AsRef<Path>) -> Result<String> {
String::from_utf8(read(p)?).map_err(|e| Error::new(ErrorKind::InvalidData, e))
}
pub fn write(p: impl AsRef<Path>, data: impl AsRef<[u8]>) -> Result<()> {
let (p, data) = (p.as_ref(), data.as_ref());
if let Some(dir) = p.parent() {
create_dir_all(dir)?;
}
TREE.with(|t| t.borrow_mut().files.insert(p.to_path_buf(), data.to_vec()));
persist_put(p, data);
Ok(())
}
pub fn copy(from: impl AsRef<Path>, to: impl AsRef<Path>) -> Result<u64> {
let data = read(from)?;
write(to, &data)?;
Ok(data.len() as u64)
}
pub fn remove_file(p: impl AsRef<Path>) -> Result<()> {
let p = p.as_ref();
TREE.with(|t| t.borrow_mut().files.remove(p))
.ok_or_else(|| not_found(p))?;
persist_delete(p);
Ok(())
}
pub fn remove_dir_all(p: impl AsRef<Path>) -> Result<()> {
let p = p.as_ref();
if !exists(p) {
return Err(not_found(p));
}
let gone: Vec<PathBuf> = TREE.with(|t| {
let mut t = t.borrow_mut();
t.dirs.retain(|d| !d.starts_with(p));
let gone: Vec<PathBuf> = t
.files
.keys()
.filter(|f| f.starts_with(p))
.cloned()
.collect();
for f in &gone {
t.files.remove(f);
}
gone
});
for f in gone {
persist_delete(&f);
}
Ok(())
}
/// Moves a file, or a directory with everything under it.
pub fn rename(from: impl AsRef<Path>, to: impl AsRef<Path>) -> Result<()> {
let (from, to) = (from.as_ref(), to.as_ref());
if !exists(from) {
return Err(not_found(from));
}
if let Some(dir) = to.parent() {
create_dir_all(dir)?;
}
let moved: Vec<(PathBuf, PathBuf, Vec<u8>)> = TREE.with(|t| {
let mut t = t.borrow_mut();
let dirs: Vec<PathBuf> = t
.dirs
.iter()
.filter(|d| d.starts_with(from))
.cloned()
.collect();
for d in dirs {
t.dirs.remove(&d);
if let Ok(rest) = d.strip_prefix(from) {
t.dirs.insert(to.join(rest));
}
}
let files: Vec<PathBuf> = t
.files
.keys()
.filter(|f| f.starts_with(from))
.cloned()
.collect();
files
.into_iter()
.filter_map(|f| {
let data = t.files.remove(&f)?;
let dest = to.join(f.strip_prefix(from).ok()?);
t.files.insert(dest.clone(), data.clone());
Some((f, dest, data))
})
.collect()
});
for (old, new, data) in moved {
persist_put(&new, &data);
persist_delete(&old);
}
Ok(())
}
pub struct Metadata {
len: u64,
}
impl Metadata {
pub fn len(&self) -> u64 {
self.len
}
pub fn modified(&self) -> Result<SystemTime> {
Err(Error::new(
ErrorKind::Unsupported,
"no mtime in the browser",
))
}
}
pub fn metadata(p: impl AsRef<Path>) -> Result<Metadata> {
let p = p.as_ref();
match TREE.with(|t| t.borrow().files.get(p).map(|f| f.len() as u64)) {
Some(len) => Ok(Metadata { len }),
None if is_dir(p) => Ok(Metadata { len: 0 }),
None => Err(not_found(p)),
}
}
#[derive(Clone, Copy)]
pub struct FileType {
dir: bool,
}
impl FileType {
pub fn is_dir(&self) -> bool {
self.dir
}
pub fn is_file(&self) -> bool {
!self.dir
}
}
pub struct DirEntry {
path: PathBuf,
dir: bool,
}
impl DirEntry {
pub fn path(&self) -> PathBuf {
self.path.clone()
}
pub fn file_name(&self) -> OsString {
self.path.file_name().unwrap_or_default().to_owned()
}
pub fn file_type(&self) -> Result<FileType> {
Ok(FileType { dir: self.dir })
}
pub fn metadata(&self) -> Result<Metadata> {
metadata(&self.path)
}
}
pub struct ReadDir(std::vec::IntoIter<DirEntry>);
impl Iterator for ReadDir {
type Item = Result<DirEntry>;
fn next(&mut self) -> Option<Self::Item> {
self.0.next().map(Ok)
}
}
pub fn read_dir(p: impl AsRef<Path>) -> Result<ReadDir> {
let p = p.as_ref();
if !is_dir(p) {
return Err(not_found(p));
}
let mut seen: BTreeMap<PathBuf, bool> = BTreeMap::new();
TREE.with(|t| {
let t = t.borrow();
for d in &t.dirs {
if d.parent() == Some(p) {
seen.insert(d.clone(), true);
}
}
for f in t.files.keys() {
let Ok(rest) = f.strip_prefix(p) else {
continue;
};
let mut parts = rest.components();
let Some(first) = parts.next() else { continue };
let child = p.join(first);
let is_dir = parts.next().is_some();
seen.entry(child)
.and_modify(|d| *d |= is_dir)
.or_insert(is_dir);
}
});
Ok(ReadDir(
seen.into_iter()
.map(|(path, dir)| DirEntry { path, dir })
.collect::<Vec<_>>()
.into_iter(),
))
}
+26
View File
@@ -0,0 +1,26 @@
//! The file system the project store runs on.
//!
//! Native builds use `std::fs` directly. The browser has none, so there a whole tree lives in
//! memory and every change is mirrored into IndexedDB (loaded back by [`load`] at startup).
//! Only the calls the app makes are provided.
#[cfg(not(target_arch = "wasm32"))]
pub use std::fs::*;
#[cfg(not(target_arch = "wasm32"))]
pub fn is_file(p: &std::path::Path) -> bool {
p.is_file()
}
#[cfg(not(target_arch = "wasm32"))]
pub fn is_dir(p: &std::path::Path) -> bool {
p.is_dir()
}
#[cfg(not(target_arch = "wasm32"))]
pub fn exists(p: &std::path::Path) -> bool {
p.exists()
}
#[cfg(target_arch = "wasm32")]
mod mem;
#[cfg(target_arch = "wasm32")]
pub use mem::*;
+1083
View File
File diff suppressed because it is too large Load Diff
+173
View File
@@ -0,0 +1,173 @@
//! Small browser helpers: file download and pick, local storage, and the clipboard mirror.
//! Raw JS (like `vfs/mem.rs` does for IndexedDB) where web-sys would need many feature flags
//! or unstable APIs (`ClipboardItem`).
use crate::io::clipboard::Provider;
use std::cell::RefCell;
use std::rc::Rc;
use wasm_bindgen::prelude::*;
#[wasm_bindgen(inline_js = r#"
export function download(name, bytes, mime) {
const url = URL.createObjectURL(new Blob([bytes], { type: mime }));
const a = document.createElement('a');
a.href = url;
a.download = name;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 10000);
}
export async function read_files(files) {
const out = [];
for (const f of files) out.push([f.name, new Uint8Array(await f.arrayBuffer())]);
return out;
}
export function pick_files(accept, multiple) {
return new Promise((res) => {
const i = document.createElement('input');
i.type = 'file';
i.accept = accept;
i.multiple = multiple;
i.style.display = 'none';
i.onchange = () => { res(read_files(i.files)); i.remove(); };
i.oncancel = () => { res([]); i.remove(); };
document.body.appendChild(i);
i.click();
});
}
export async function copy_png(bytes) {
await navigator.clipboard.write([
new ClipboardItem({ 'image/png': new Blob([bytes], { type: 'image/png' }) }),
]);
}
export function relay_global() {
return typeof window.PAPERCRAFT_RELAY === 'string' ? window.PAPERCRAFT_RELAY : '';
}
"#)]
extern "C" {
fn download(name: &str, bytes: &[u8], mime: &str);
fn read_files(files: &JsValue) -> js_sys::Promise;
fn pick_files(accept: &str, multiple: bool) -> js_sys::Promise;
#[wasm_bindgen(catch)]
async fn copy_png(bytes: &[u8]) -> Result<JsValue, JsValue>;
fn relay_global() -> String;
}
/// `window.PAPERCRAFT_RELAY`, set by the page that hosts the app.
pub fn relay_override() -> Option<String> {
Some(relay_global()).filter(|s| !s.is_empty())
}
/// A file the user chose or dropped.
pub struct Picked {
pub name: String,
pub bytes: Vec<u8>,
}
fn picked(rows: JsValue) -> Vec<Picked> {
js_sys::Array::from(&rows)
.iter()
.filter_map(|row| {
let row = js_sys::Array::from(&row);
Some(Picked {
name: row.get(0).as_string()?,
bytes: js_sys::Uint8Array::new(&row.get(1)).to_vec(),
})
})
.collect()
}
/// Ask for files with a hidden `<input type=file>`. Must start from a user gesture; resolves
/// to nothing when the dialog is cancelled.
pub async fn pick(accept: &str, multiple: bool) -> Result<Vec<Picked>, String> {
wasm_bindgen_futures::JsFuture::from(pick_files(accept, multiple))
.await
.map(picked)
.map_err(|e| format!("could not read the chosen file: {e:?}"))
}
/// Read the contents of a DOM `FileList` (dropped or pasted files).
pub async fn read_list(files: &web_sys::FileList) -> Result<Vec<Picked>, String> {
wasm_bindgen_futures::JsFuture::from(read_files(files))
.await
.map(picked)
.map_err(|e| format!("could not read the dropped file: {e:?}"))
}
/// Offer `bytes` as a browser download.
pub fn save_as(name: &str, bytes: &[u8], mime: &str) {
download(name, bytes, mime);
}
pub fn mime_for(name: &str) -> &'static str {
match crate::io::import::extension(name).as_str() {
"png" => "image/png",
"svg" => "image/svg+xml",
"pdf" => "application/pdf",
"zip" => "application/zip",
_ => "application/octet-stream",
}
}
/// Put a PNG on the system clipboard (needs a user gesture and permission; reports failure).
pub async fn copy_png_to_clipboard(png: Vec<u8>) -> Result<(), String> {
copy_png(&png)
.await
.map(|_| ())
.map_err(|e| format!("the browser refused the clipboard write: {e:?}"))
}
/// A page-lifetime `localStorage` value.
pub fn local_get(key: &str) -> Option<String> {
super::window().local_storage().ok()??.get_item(key).ok()?
}
pub fn local_set(key: &str, value: &str) {
if let Some(Ok(Some(s))) = Some(super::window().local_storage()) {
let _ = s.set_item(key, value);
}
}
/// What the last copy or paste carried. The editor's clipboard provider is synchronous but the
/// browser's is not, so the editor reads and writes this mirror; the shell fills it from DOM
/// `paste` events and pushes copies out to `navigator.clipboard`.
#[derive(Default)]
pub struct Mirror {
pub text: Option<String>,
pub image: Option<image::RgbaImage>,
}
pub type SharedMirror = Rc<RefCell<Mirror>>;
pub struct WebClipboard(pub SharedMirror);
impl Provider for WebClipboard {
fn read_text(&mut self) -> Option<String> {
self.0.borrow().text.clone()
}
fn write_text(&mut self, s: &str) {
let mut m = self.0.borrow_mut();
m.text = Some(s.into());
m.image = None;
// Fire and forget: failure only means other apps do not see the copy.
let _ = super::window().navigator().clipboard().write_text(s);
}
fn read_image(&mut self) -> Option<image::RgbaImage> {
self.0.borrow().image.clone()
}
fn write_image(&mut self, img: &image::RgbaImage) {
self.0.borrow_mut().image = Some(img.clone());
let Ok(png) = crate::io::export::encode_png(img) else {
return;
};
wasm_bindgen_futures::spawn_local(async move {
if let Err(e) = copy_png_to_clipboard(png).await {
log::warn!("{e}");
}
});
}
}
+349
View File
@@ -0,0 +1,349 @@
//! Files in the browser: importing (pick / drop / paste), exporting as downloads, project
//! backups and home-screen thumbnails. Everything that touches the GPU is asynchronous here
//! (see `Headless::read_async`); the finished work re-enters the shell through `Web::me`.
use super::Web;
use super::dom::{self, Picked};
use crate::geom::V2;
use crate::io::export::{self, Format, Spec};
use crate::io::file;
use crate::io::import::{self, Imported};
use crate::library::LibItem;
use crate::model::{ImageMode, Node, NodeId, Paint};
use crate::project::format::PageData;
const BACKUP_NAME: &str = "PaperCraft backup.zip";
/// Pixel scale for "Copy as PNG" (matches the desktop app).
const COPY_PNG_SCALE: f64 = 2.0;
const IMAGE_ACCEPT: &str = ".png,.jpg,.jpeg,.gif,.webp";
fn import_accept() -> String {
format!(
".{},.{},.excalidraw,.excalidrawlib,.json,.svg,.zip,{IMAGE_ACCEPT}",
file::EXTENSION,
file::LEGACY_EXTENSION
)
}
impl Web {
/// Run `f` on the shell once the future finishes (the shell is not borrowed across awaits).
pub(super) fn finish<T: 'static>(
&self,
fut: impl std::future::Future<Output = T> + 'static,
f: impl FnOnce(&mut Web, T) + 'static,
) {
let me = self.me.clone();
wasm_bindgen_futures::spawn_local(async move {
let out = fut.await;
if let Some(w) = me.upgrade() {
let mut w = w.borrow_mut();
f(&mut w, out);
w.dirty = true;
}
});
}
/// Open the file chooser and hand what was picked to `then`.
fn pick_then(
&self,
accept: String,
multiple: bool,
then: impl FnOnce(&mut Web, Vec<Picked>) + 'static,
) {
self.finish(
async move { dom::pick(&accept, multiple).await },
|w, r| match r {
Ok(files) if files.is_empty() => {}
Ok(files) => then(w, files),
Err(e) => w.error(e),
},
);
}
// ---------------------------------------------------------------- import
/// Home screen "Import…" and File > Open.
pub(super) fn request_import(&self) {
self.pick_then(import_accept(), true, |w, files| {
for f in files {
w.import_file(&f.name, &f.bytes, None);
}
});
}
/// Insert > Image.
pub(super) fn request_place_image(&self) {
self.pick_then(IMAGE_ACCEPT.into(), false, |w, files| {
for f in files {
w.import_file(&f.name, &f.bytes, None);
}
});
}
/// Image fill for fill `i` of `ids`.
pub(super) fn request_image_fill(&self, ids: Vec<NodeId>, i: usize) {
self.pick_then(IMAGE_ACCEPT.into(), false, move |w, files| {
let Some(f) = files.into_iter().next() else {
return;
};
match crate::assets::register(&f.bytes) {
Ok(asset) => {
crate::ui::inspector::apply(&mut w.ed, "fills", &ids, |n| {
let paint = Paint::Image {
asset: asset.clone(),
mode: ImageMode::Fill,
opacity: 1.0,
};
match n.style.fills.get_mut(i) {
Some(slot) => *slot = paint,
None => n.style.fills.push(paint),
}
});
}
Err(e) => w.error(format!("Could not load {}: {e}", f.name)),
}
});
}
/// Files dropped on the canvas at `screen` (canvas px), or pasted.
pub(super) fn import_files(&mut self, files: Vec<Picked>, screen: Option<egui::Pos2>) {
let at = screen.map(|p| self.ed.camera.screen_to_world(self.to_canvas(p)));
for f in files {
self.import_file(&f.name, &f.bytes, at);
}
}
fn in_project(&self) -> bool {
self.session.project.is_some() && self.st.home.is_none()
}
fn view_center(&self) -> V2 {
self.ed.camera.screen_to_world(self.ed.viewport * 0.5)
}
/// Bring one file in: SVG / Excalidraw / images go on the open page (a new project is made
/// when none is open), documents become pages or a new project, zips restore a backup.
pub(super) fn import_file(&mut self, name: &str, bytes: &[u8], at: Option<V2>) {
let imported = match import::import_bytes(name, bytes) {
Ok(i) => i,
Err(e) => return self.error(format!("Could not import {name}: {e}")),
};
match imported {
Imported::Backup => self.restore_backup(bytes),
Imported::Library(items) => self.add_to_library(items),
Imported::Pages(_pname, pages) if self.in_project() => self.add_pages(pages),
Imported::Pages(pname, pages) => self.new_project_from(&pname, pages),
Imported::Image(_) | Imported::Scene { .. } => {
if !self.in_project() {
let stem = name.rsplit_once('.').map_or(name, |(s, _)| s);
let r = self.session.create(&mut self.ed, stem);
self.enter_project(r);
if !self.in_project() {
return;
}
}
let at = at.unwrap_or_else(|| self.view_center());
match imported {
Imported::Image(b) => self.place_image(name, &b, at),
Imported::Scene { nodes, group } => self.place_scene(name, nodes, group, at),
_ => unreachable!(),
}
}
}
}
fn place_image(&mut self, name: &str, bytes: &[u8], at: V2) {
match crate::assets::register(bytes) {
Ok(id) => {
self.ed.insert_image(&id, at);
self.ed.status = format!("Placed {name}");
}
Err(e) => self.error(format!("Could not place {name}: {e}")),
}
}
fn place_scene(&mut self, name: &str, nodes: Vec<Node>, group: Option<String>, at: V2) {
use crate::io::clipboard::{bounds, center_offset, roots_of};
let (roots, world) = roots_of(&nodes);
let off = center_offset(&world, at);
let label = format!("Import {name}");
let ids = match group {
// Keep an SVG together as one group.
Some(group) => {
let b = bounds(&world).unwrap_or_default();
vec![self.ed.edit(&label, |tx| {
crate::io::svg_import::insert_group(tx, nodes, &group, b.min() + off)
})]
}
None => self
.ed
.insert_subtrees(&label, &nodes, &roots, &world, off, false),
};
self.ed.selection = ids;
self.ed.status = format!("Imported {name}");
}
fn add_pages(&mut self, pages: Vec<PageData>) {
use crate::io::clipboard::roots_of;
let n = pages.len();
for pg in pages {
let i = self.ed.add_page();
self.ed.rename_page(i, &pg.name);
let nodes: Vec<Node> = pg
.doc
.paint_order()
.into_iter()
.filter_map(|id| pg.doc.get(id).cloned())
.collect();
let (roots, world) = roots_of(&nodes);
self.ed
.insert_subtrees("Import page", &nodes, &roots, &world, V2::ZERO, false);
}
self.ed.status = format!("Imported {n} page(s)");
}
fn new_project_from(&mut self, name: &str, pages: Vec<PageData>) {
match self.session.store.create_with_pages(name, pages) {
Ok(proj) => {
let dir = proj.dir.clone();
let r = self.session.open(&mut self.ed, &dir);
self.enter_project(r);
self.ed.status = format!("Imported {name}");
}
Err(e) => self.error(format!("Could not create a project: {e}")),
}
}
fn add_to_library(&mut self, items: Vec<LibItem>) {
let n = items.len();
self.st.library.library.items.extend(items);
self.st.library.dirty = true;
self.st.show_library = true;
self.ed.status = format!("Added {n} item(s) to the library");
}
// ---------------------------------------------------------------- backup
/// "Download backup": a zip of the whole project folder.
pub(super) fn download_backup(&mut self) {
if self.session.project.is_some() {
self.save_project(true);
}
match crate::io::backup::pack(&self.session.store.root) {
Ok(zip) => {
dom::save_as(BACKUP_NAME, &zip, dom::mime_for(BACKUP_NAME));
self.ed.status = "Backup downloaded".into();
}
Err(e) => self.error(format!("Could not back up: {e}")),
}
}
fn restore_backup(&mut self, bytes: &[u8]) {
match crate::io::backup::unpack(bytes, &self.session.store.root) {
Ok(dirs) => {
self.ed.status = format!("Restored {} project(s) from the backup", dirs.len());
if self.st.home.is_some() {
self.show_home();
}
}
Err(e) => self.error(format!("Could not restore the backup: {e}")),
}
}
// ---------------------------------------------------------------- export
/// Export as a download: one file, or a zip when the spec yields several.
pub(super) fn export(&mut self, spec: Spec) {
let names: Vec<String> = self.ed.pages.iter().map(|p| p.name.clone()).collect();
let docs: Vec<_> = (0..names.len())
.map(|i| self.ed.page_doc(i).clone())
.collect();
let (active, sel) = (self.ed.active_page, self.ed.top_selection());
let project = if self.st.project_name.is_empty() {
"PaperCraft".to_string()
} else {
self.st.project_name.clone()
};
let gpu = self.gpu_handle();
self.finish(
async move {
let pages: Vec<_> = names.iter().map(String::as_str).zip(docs.iter()).collect();
let items = export::items(&spec, &pages, active, &sel);
if items.is_empty() {
return Err("Nothing to export".to_string());
}
let base = if items.len() == 1 {
items[0].name.clone()
} else {
project
};
let files = export::render_files(&spec, &items, &base, &gpu).await?;
if let [(name, bytes)] = files.as_slice() {
return Ok((name.clone(), bytes.clone()));
}
let zip = crate::io::zip::write(&files);
Ok((format!("{}.zip", crate::project::folder_name(&base)), zip))
},
|w, r| match r {
Ok((name, bytes)) => {
dom::save_as(&name, &bytes, dom::mime_for(&name));
w.ed.status = format!("Exported {name}");
}
Err(e) => w.error(format!("Export failed: {e}")),
},
);
}
/// Render the selection (or the page) and put the PNG on the clipboard.
pub(super) fn copy_as_png(&mut self) {
let roots = Some(self.ed.top_selection()).filter(|s| !s.is_empty());
let doc = self.ed.doc.clone();
let gpu = self.gpu_handle();
self.finish(
async move {
crate::io::png::render_async(&gpu, &doc, roots.as_deref(), COPY_PNG_SCALE, None)
.await
},
|w, r| match r {
Ok(img) => {
if let Some(io) = &mut w.ed.clipboard_io {
io.write_image(&img);
w.ed.status = format!("Copied {}×{} PNG", img.width(), img.height());
}
}
Err(e) => w.error(format!("Could not copy as PNG: {e}")),
},
);
}
// ---------------------------------------------------------------- thumbnails
/// Render the open project's thumbnail off the main flow; the home screen refreshes when
/// it lands.
pub(super) fn start_thumbnail(&mut self) {
let Some(job) = self.session.start_thumbnail(&self.ed) else {
return;
};
let gpu = self.gpu_handle();
self.finish(
async move {
let png = crate::session::render_thumbnail_async(&gpu, &job.doc).await?;
crate::session::finish_thumbnail(&job.path, png)
},
|w, r| match r {
Ok(()) if w.st.home.is_some() => w.show_home(),
Ok(()) => {}
Err(e) => log::warn!("thumbnail: {e}"),
},
);
}
/// Format picked by the inspector (kept next to `export` so both stay in sync).
pub(super) fn export_format(&mut self, f: Format) {
let spec = Spec {
format: f,
..self.st.export
};
self.export(spec);
}
}
+228
View File
@@ -0,0 +1,228 @@
//! Tab side of the relay: one WebSocket to `papercraft-relay` that carries agent tool calls in
//! and results out (frames in `mcp::relay_proto`). The tab holds the document and the executor;
//! the relay holds nothing, so reconnecting just resumes.
//!
//! The socket callbacks only queue work into an [`Inbox`]; the shell drains it at the start of
//! every frame (`Web::pump_calls`) so edits and repaint stay on one code path.
use super::dom;
use crate::mcp::relay_proto::{self, Call, Reply, TOKEN_BYTES};
use crate::ui::agent::LinkState;
use std::cell::RefCell;
use std::collections::VecDeque;
use std::rc::Rc;
use wasm_bindgen::prelude::*;
use web_sys::{MessageEvent, WebSocket};
const TOKEN_KEY: &str = "papercraft.relay.token";
const BACKOFF_MIN_MS: f64 = 1_000.0;
const BACKOFF_MAX_MS: f64 = 15_000.0;
/// WebSocket close codes: normal closure, and "going away" for a replaced socket.
const CLOSE_NORMAL: u16 = 1000;
#[derive(Default)]
struct Inbox {
calls: VecDeque<Call>,
state: LinkState,
/// Set by the callbacks for the shell's `tick` to act on.
opened: bool,
closed: bool,
/// Which `connect` the callbacks belong to; stale sockets are ignored.
generation: u32,
/// Called when a call arrives (the shell uses it to keep working in a hidden tab, where
/// `requestAnimationFrame` is paused).
wake: Option<Rc<dyn Fn()>>,
}
type Callbacks = (
Closure<dyn FnMut()>,
Closure<dyn FnMut(MessageEvent)>,
Closure<dyn FnMut()>,
);
pub struct Relay {
token: String,
base: String,
inbox: Rc<RefCell<Inbox>>,
socket: Option<WebSocket>,
_callbacks: Option<Callbacks>,
backoff_ms: f64,
retry_at: Option<f64>,
}
fn new_token() -> String {
let mut bytes = [0u8; TOKEN_BYTES];
let filled = super::window()
.crypto()
.is_ok_and(|c| c.get_random_values_with_u8_array(&mut bytes).is_ok());
if !filled {
// No Web Crypto (very old browser or insecure context): fall back to the seeded PRNG.
bytes.iter_mut().for_each(|b| *b = fastrand::u8(..));
}
relay_proto::hex(&bytes)
}
impl Relay {
pub fn new() -> Relay {
let loc = super::window().location();
let base = relay_proto::relay_base(
&loc.search().unwrap_or_default(),
dom::relay_override().as_deref(),
&loc.origin().unwrap_or_default(),
);
let token = dom::local_get(TOKEN_KEY)
.filter(|t| relay_proto::valid_token(t))
.unwrap_or_else(|| {
let t = new_token();
dom::local_set(TOKEN_KEY, &t);
t
});
Relay {
token,
base,
inbox: Rc::default(),
socket: None,
_callbacks: None,
backoff_ms: BACKOFF_MIN_MS,
retry_at: Some(0.0),
}
}
pub fn set_wake(&self, wake: Rc<dyn Fn()>) {
self.inbox.borrow_mut().wake = Some(wake);
}
pub fn state(&self) -> LinkState {
self.inbox.borrow().state
}
pub fn next_call(&mut self) -> Option<Call> {
self.inbox.borrow_mut().calls.pop_front()
}
/// The URL to hand to an agent. Contains the secret token.
pub fn agent_url(&self) -> String {
relay_proto::agent_url(&relay_proto::http_origin(&self.base), &self.token)
}
/// New token (the old link stops working) and a fresh connection.
pub fn regenerate(&mut self, now: f64) {
self.token = new_token();
dom::local_set(TOKEN_KEY, &self.token);
self.backoff_ms = BACKOFF_MIN_MS;
self.connect(now);
}
/// Answer a call. Dropped silently when the socket is gone: the relay already told the agent.
pub fn send(&self, reply: &Reply) {
let Some(ws) = &self.socket else { return };
match serde_json::to_string(reply) {
Ok(json) => {
if ws.send_with_str(&json).is_err() {
log::warn!("papercraft relay: could not send a reply");
}
}
Err(e) => log::warn!("papercraft relay: {e}"),
}
}
/// Drive reconnection; call every frame. True when something changed that needs a repaint.
pub fn tick(&mut self, now: f64) -> bool {
let (opened, closed) = {
let mut i = self.inbox.borrow_mut();
(std::mem::take(&mut i.opened), std::mem::take(&mut i.closed))
};
if opened {
self.backoff_ms = BACKOFF_MIN_MS;
}
if closed {
self.retry_at = Some(now + self.backoff_ms);
self.backoff_ms = (self.backoff_ms * 2.0).min(BACKOFF_MAX_MS);
}
if self.retry_at.is_some_and(|t| now >= t) {
self.connect(now);
}
opened || closed
}
fn connect(&mut self, now: f64) {
self.retry_at = None;
self.detach();
let generation = {
let mut i = self.inbox.borrow_mut();
i.generation += 1;
i.state = LinkState::Connecting;
i.generation
};
let url = relay_proto::tab_url(&self.base, &self.token);
let ws = match WebSocket::new(&url) {
Ok(ws) => ws,
Err(_) => {
// Never log `url`: it carries the token.
log::warn!(
"papercraft relay: cannot open a connection to {}",
self.base
);
self.inbox.borrow_mut().state = LinkState::Offline;
self.retry_at = Some(now + self.backoff_ms);
self.backoff_ms = (self.backoff_ms * 2.0).min(BACKOFF_MAX_MS);
return;
}
};
let current = move |inbox: &Rc<RefCell<Inbox>>| inbox.borrow().generation == generation;
let inbox = self.inbox.clone();
let onopen = Closure::<dyn FnMut()>::new(move || {
if current(&inbox) {
let mut i = inbox.borrow_mut();
i.state = LinkState::Connected;
i.opened = true;
}
});
let inbox = self.inbox.clone();
let onmessage = Closure::<dyn FnMut(MessageEvent)>::new(move |e: MessageEvent| {
if !current(&inbox) {
return;
}
let Some(text) = e.data().as_string() else {
return;
};
let Ok(call) = serde_json::from_str::<Call>(&text) else {
log::warn!("papercraft relay: ignoring a frame that is not a call");
return;
};
let wake = {
let mut i = inbox.borrow_mut();
i.calls.push_back(call);
i.wake.clone()
};
if let Some(wake) = wake {
wake();
}
});
let inbox = self.inbox.clone();
let onclose = Closure::<dyn FnMut()>::new(move || {
if current(&inbox) {
let mut i = inbox.borrow_mut();
i.state = LinkState::Offline;
i.closed = true;
}
});
ws.set_onopen(Some(onopen.as_ref().unchecked_ref()));
ws.set_onmessage(Some(onmessage.as_ref().unchecked_ref()));
// An error is always followed by a close event, which handles the retry.
ws.set_onclose(Some(onclose.as_ref().unchecked_ref()));
self.socket = Some(ws);
self._callbacks = Some((onopen, onmessage, onclose));
}
fn detach(&mut self) {
if let Some(ws) = self.socket.take() {
ws.set_onopen(None);
ws.set_onmessage(None);
ws.set_onclose(None);
let _ = ws.close_with_code_and_reason(CLOSE_NORMAL, "reconnecting");
}
self._callbacks = None;
}
}
+1 -1
View File
@@ -52,7 +52,7 @@ fn m1_infinite_canvas_pan_and_zoom() {
..Modifiers::NONE
}));
e.handle(InputEvent::Scroll {
delta: v2(0.0, -(3.0f64.ln() * 100.0)),
delta: v2(0.0, -(3.0f64.ln() / 0.003)),
pos: anchor,
});
e.handle(InputEvent::Modifiers(Modifiers::NONE));
+419
View File
@@ -0,0 +1,419 @@
//! The relay end to end: an agent over streamable HTTP, a fake tab over a real WebSocket.
//!
//! The tab is the blocking tungstenite client on a thread: the relay keeps no state, so all
//! the test proves is that a call reaches a socket and its answer comes back as MCP content.
use papercraft::mcp::Out;
use papercraft::mcp::relay::{self, NO_TAB_ERROR};
use papercraft::mcp::store::Data;
use papercraft::mcp::relay_proto::{Call as CallFrame, Reply};
use rmcp::ServiceExt;
use rmcp::model::{CallToolRequestParams, CallToolResult, ContentBlock};
use serde_json::{Value, json};
use std::net::SocketAddr;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tokio_tungstenite::tungstenite::{Message as Ws, connect as ws_connect};
/// What the fake tab answers with.
const TAB_TEXT: &str = "answered in the tab";
/// How long to wait for the tab's socket before giving up.
const CONNECT_WAIT: Duration = Duration::from_secs(10);
const CONNECT_POLL: Duration = Duration::from_millis(20);
/// A link token as a tab generates one: 32 lowercase hex characters.
fn token() -> String {
format!("{:032x}", fastrand::u64(..))
}
/// A 1x1 transparent PNG, so the image path is exercised with real bytes.
fn png() -> Vec<u8> {
papercraft::assets::base64_decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
)
.expect("the PNG fixture is valid base64")
}
fn spawn_relay() -> relay::Running {
let listen: SocketAddr = "127.0.0.1:0".parse().expect("a valid address");
relay::spawn(listen, None, None).expect("the relay binds an ephemeral port")
}
fn tool(name: &str, v: Value) -> CallToolRequestParams {
let mut p = CallToolRequestParams::new("");
if let Value::Object(m) = v {
p.arguments = Some(m);
}
p.name = name.to_string().into();
p
}
fn text_of(r: &CallToolResult) -> String {
r.content
.iter()
.find_map(|c| match c {
ContentBlock::Text(t) => Some(t.text.clone()),
_ => None,
})
.expect("a text block")
}
/// A tab that answers `answers` calls with a text block and a PNG, then hangs up so the relay
/// can shut down. Returns the calls it saw.
fn fake_tab(
url: String,
answers: usize,
connected: Arc<AtomicBool>,
) -> std::thread::JoinHandle<Vec<CallFrame>> {
std::thread::spawn(move || {
let (mut ws, _) = ws_connect(&url).expect("the tab connects to the relay");
let mut seen = Vec::new();
while seen.len() < answers {
match ws.read() {
// The relay pings as soon as the link is live; that is the "connected" signal.
Ok(Ws::Ping(_)) => connected.store(true, Ordering::SeqCst),
Ok(Ws::Text(t)) => {
let Ok(call) = serde_json::from_str::<CallFrame>(&t) else {
continue;
};
seen.push(call.clone());
let reply = Reply::ok(
call.id,
vec![
Out::Text(TAB_TEXT.to_string()),
Out::Image {
data: png(),
mime: "image/png".to_string(),
},
],
);
let json = serde_json::to_string(&reply).expect("a reply encodes");
ws.send(Ws::Text(json.into()))
.expect("the reply reaches the relay");
}
Ok(Ws::Close(_)) | Err(_) => break,
Ok(_) => continue,
}
}
seen
})
}
async fn wait_for_tab(connected: &AtomicBool) {
let deadline = tokio::time::Instant::now() + CONNECT_WAIT;
while !connected.load(Ordering::SeqCst) {
assert!(
tokio::time::Instant::now() < deadline,
"the fake tab never connected"
);
tokio::time::sleep(CONNECT_POLL).await;
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_call_reaches_the_tab_and_its_answer_reaches_the_agent() {
let running = spawn_relay();
let token = token();
let connected = Arc::new(AtomicBool::new(false));
let tab = fake_tab(
format!("ws://127.0.0.1:{}/tab/{token}", running.port()),
1,
connected.clone(),
);
wait_for_tab(&connected).await;
let transport = rmcp::transport::StreamableHttpClientTransport::from_uri(format!(
"http://127.0.0.1:{}/s/{token}/mcp",
running.port()
));
let client = ().serve(transport).await.expect("the agent connects");
// The relay serves the same catalogue as the desktop app.
let tools = client.list_all_tools().await.unwrap();
assert!(tools.iter().any(|t| t.name == "get_basic_info"));
let r = client
.call_tool(tool("get_basic_info", json!({})))
.await
.expect("the call completes");
assert_eq!(text_of(&r), TAB_TEXT, "the text came back from the tab");
let image = r
.content
.iter()
.find_map(|c| match c {
ContentBlock::Image(i) => Some(i),
_ => None,
})
.expect("the tab's image block survives the relay");
assert_eq!(image.mime_type, "image/png");
assert_eq!(image.data, papercraft::assets::base64(&png()));
let seen = tab.join().expect("the tab thread finished");
assert_eq!(seen.len(), 1);
assert_eq!(seen[0].name, "get_basic_info");
assert_eq!(seen[0].args, json!({}));
client.cancel().await.unwrap();
running.stop();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_call_without_a_tab_says_so() {
let running = spawn_relay();
let token = token();
let transport = rmcp::transport::StreamableHttpClientTransport::from_uri(format!(
"http://127.0.0.1:{}/s/{token}/mcp",
running.port()
));
let client = ().serve(transport).await.expect("the agent connects");
let r = client
.call_tool(tool("get_basic_info", json!({})))
.await
.expect("the call completes");
assert_eq!(r.is_error, Some(true), "{r:?}");
assert_eq!(text_of(&r), NO_TAB_ERROR);
client.cancel().await.unwrap();
running.stop();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn bad_tokens_browsers_and_unknown_routes_are_refused() {
let running = spawn_relay();
let base = format!("http://127.0.0.1:{}", running.port());
let init = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}"#;
let http = reqwest::Client::new();
let good = token();
// Agents are not browsers: anything carrying an Origin is turned away.
let r = http
.post(format!("{base}/s/{good}/mcp"))
.header("content-type", "application/json")
.header("origin", "https://evil.example")
.body(init)
.send()
.await
.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()),
("too short", "abc".to_string()),
("non-hex", "z".repeat(32)),
("empty", String::new()),
] {
let r = http
.post(format!("{base}/s/{bad}/mcp"))
.header("content-type", "application/json")
.body(init)
.send()
.await
.unwrap();
assert_eq!(r.status(), 404, "{label} token");
let r = http.get(format!("{base}/tab/{bad}")).send().await.unwrap();
assert_eq!(r.status(), 404, "{label} token on the tab route");
}
let r = http.get(format!("{base}/nope")).send().await.unwrap();
assert_eq!(r.status(), 404);
running.stop();
}
/// One tab per link: a newer connection takes over and the older socket is closed with a reason.
#[test]
fn a_newer_tab_takes_over_the_link_and_closes_the_old_socket() {
let running = spawn_relay();
let token = token();
let url = format!("ws://127.0.0.1:{}/tab/{token}", running.port());
let (ready_tx, ready_rx) = std::sync::mpsc::channel();
let (close_tx, close_rx) = std::sync::mpsc::channel();
let first = std::thread::spawn({
let url = url.clone();
move || {
let (mut ws, _) = ws_connect(&url).expect("the first tab connects");
while !matches!(ws.read().expect("the link pings"), Ws::Ping(_)) {}
ready_tx.send(()).expect("the test is waiting");
while let Ok(msg) = ws.read() {
if let Ws::Close(Some(frame)) = msg {
close_tx
.send(frame.reason.to_string())
.expect("the test waits");
return;
}
}
}
});
ready_rx
.recv_timeout(CONNECT_WAIT)
.expect("the first tab is live");
let (mut second, _) = ws_connect(&url).expect("the second tab connects");
while !matches!(second.read().expect("the link pings"), Ws::Ping(_)) {}
assert_eq!(
close_rx
.recv_timeout(CONNECT_WAIT)
.expect("the old socket is closed"),
"replaced by a newer tab"
);
first.join().expect("the first tab thread finished");
drop(second);
running.stop();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn static_files_are_served_with_their_type() {
let dir = std::env::temp_dir().join(format!("papercraft-relay-{:016x}", fastrand::u64(..)));
std::fs::create_dir_all(dir.join("pkg")).unwrap();
std::fs::write(dir.join("index.html"), "<html>paper</html>").unwrap();
std::fs::write(dir.join("pkg/papercraft.js"), "export {};").unwrap();
std::fs::write(dir.join("pkg/papercraft_bg.wasm"), b"\0asm").unwrap();
let listen: SocketAddr = "127.0.0.1:0".parse().expect("a valid address");
let running = relay::spawn(listen, Some(dir.clone()), None).expect("the relay binds");
let base = format!("http://127.0.0.1:{}", running.port());
let http = reqwest::Client::new();
let r = http.get(&base).send().await.unwrap();
assert_eq!(r.status(), 200);
assert_eq!(r.headers().get("content-type").unwrap(), "text/html");
assert_eq!(r.text().await.unwrap(), "<html>paper</html>");
let r = http
.get(format!("{base}/pkg/papercraft_bg.wasm"))
.send()
.await
.unwrap();
assert_eq!(r.status(), 200);
assert_eq!(r.headers().get("content-type").unwrap(), "application/wasm");
let r = http
.get(format!("{base}/pkg/missing.js"))
.send()
.await
.unwrap();
assert_eq!(r.status(), 404);
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();
}
+170
View File
@@ -0,0 +1,170 @@
//! 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());
}
Executable
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
# Build the browser app into web/pkg. Needs: rustup target add wasm32-unknown-unknown,
# cargo install wasm-bindgen-cli (same version as the wasm-bindgen crate in Cargo.lock).
set -e
cd "$(dirname "$0")/.."
# Homebrew's cargo can be older than rust-version; prefer the rustup toolchain.
export PATH="$HOME/.cargo/bin:$PATH"
cargo rustc --lib --profile web --target wasm32-unknown-unknown --crate-type cdylib
TARGET_DIR="${CARGO_TARGET_DIR:-target}"
wasm-bindgen --target web --no-typescript --out-dir web/pkg "$TARGET_DIR/wasm32-unknown-unknown/web/papercraft.wasm"
if command -v wasm-opt >/dev/null 2>&1; then
wasm-opt -Oz -o web/pkg/papercraft_bg.wasm web/pkg/papercraft_bg.wasm
fi
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>PaperCraft</title>
<style>
html, body { margin: 0; height: 100%; overflow: hidden; background: #1e1e20; }
canvas { display: block; width: 100vw; height: 100vh; touch-action: none; }
</style>
</head>
<body>
<canvas id="papercraft"></canvas>
<script type="module">
import init from "./pkg/papercraft.js";
init();
</script>
</body>
</html>