Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fa0511bc94 | |||
| 3f37de39f8 | |||
| edc0b818ff | |||
| ea6f1b3ac4 | |||
| 5f667a5ae9 | |||
| 0b78bb1d85 | |||
| 110c99d47f | |||
| b43fa4fe43 |
@@ -0,0 +1,7 @@
|
||||
target/
|
||||
analysis/
|
||||
.git/
|
||||
.worktrees/
|
||||
web/pkg/
|
||||
dist/
|
||||
packaging/
|
||||
Generated
+220
-6
@@ -224,6 +224,7 @@ dependencies = [
|
||||
"axum-core",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"form_urlencoded",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
@@ -237,6 +238,9 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"serde_core",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_urlencoded",
|
||||
"sha1",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
@@ -555,6 +559,16 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
@@ -568,9 +582,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"core-foundation",
|
||||
"core-foundation 0.9.4",
|
||||
"core-graphics-types",
|
||||
"foreign-types",
|
||||
"foreign-types 0.5.0",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -581,7 +595,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"core-foundation",
|
||||
"core-foundation 0.9.4",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -1058,6 +1072,15 @@ dependencies = [
|
||||
"tinyvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||
dependencies = [
|
||||
"foreign-types-shared 0.1.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.5.0"
|
||||
@@ -1065,7 +1088,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
||||
dependencies = [
|
||||
"foreign-types-macros",
|
||||
"foreign-types-shared",
|
||||
"foreign-types-shared 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1079,6 +1102,12 @@ dependencies = [
|
||||
"syn 3.0.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.3.1"
|
||||
@@ -1422,6 +1451,22 @@ dependencies = [
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-tls"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"native-tls",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.21"
|
||||
@@ -2100,6 +2145,23 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"openssl",
|
||||
"openssl-probe",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.9.0"
|
||||
@@ -2568,6 +2630,49 @@ version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.81"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"cfg-if",
|
||||
"foreign-types 0.3.2",
|
||||
"libc",
|
||||
"openssl-macros",
|
||||
"openssl-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
@@ -2604,7 +2709,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "papercraft"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"axum",
|
||||
@@ -3091,15 +3196,19 @@ dependencies = [
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-tls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
@@ -3260,12 +3369,27 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||
dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
@@ -3275,6 +3399,15 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "1.2.2"
|
||||
@@ -3326,6 +3459,29 @@ dependencies = [
|
||||
"tiny-skia 0.11.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "self_cell"
|
||||
version = "1.3.0"
|
||||
@@ -3392,6 +3548,17 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_path_to_error"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.1.2"
|
||||
@@ -3401,6 +3568,18 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.7"
|
||||
@@ -3673,6 +3852,19 @@ dependencies = [
|
||||
"syn 3.0.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix 1.1.5",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "termcolor"
|
||||
version = "1.4.1"
|
||||
@@ -3830,6 +4022,16 @@ dependencies = [
|
||||
"syn 3.0.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-native-tls"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
||||
dependencies = [
|
||||
"native-tls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-stream"
|
||||
version = "0.1.19"
|
||||
@@ -4143,6 +4345,12 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "vello_common"
|
||||
version = "0.1.0"
|
||||
@@ -4938,7 +5146,7 @@ dependencies = [
|
||||
"calloop 0.13.0",
|
||||
"cfg_aliases",
|
||||
"concurrent-queue",
|
||||
"core-foundation",
|
||||
"core-foundation 0.9.4",
|
||||
"core-graphics",
|
||||
"cursor-icon",
|
||||
"dpi",
|
||||
@@ -5139,6 +5347,12 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.5"
|
||||
|
||||
+5
-3
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "papercraft"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.95"
|
||||
description = "A native infinite-canvas app for planning, working and sketching"
|
||||
@@ -88,8 +88,10 @@ winit = "0.30"
|
||||
arboard = { version = "3.6.1", features = ["image-data"] }
|
||||
rmcp = { version = "3.5.1", default-features = false, features = ["server", "base64", "transport-streamable-http-server", "transport-io", "client", "transport-streamable-http-client-reqwest"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "macros", "time", "io-std", "fs"] }
|
||||
axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "ws"] }
|
||||
axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "ws", "json", "query"] }
|
||||
tokio-util = "0.7"
|
||||
# Shared-project sync; the system TLS stack so https servers work without bundling one.
|
||||
reqwest = { version = "0.13", default-features = false, features = ["native-tls"] }
|
||||
|
||||
# Browser build (`src/web.rs`): canvas + WebGPU/WebGL surface, DOM events instead of winit.
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
@@ -99,4 +101,4 @@ wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
console_error_panic_hook = "0.1"
|
||||
console_log = "1"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "KeyboardEvent", "PointerEvent", "WheelEvent", "ClipboardEvent", "DataTransfer", "MediaQueryList", "Location", "Performance", "Navigator", "Clipboard", "CssStyleDeclaration", "console", "WebSocket", "MessageEvent", "CloseEvent", "ErrorEvent", "Storage", "Crypto", "DragEvent", "FileList", "File", "Blob"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "KeyboardEvent", "PointerEvent", "WheelEvent", "ClipboardEvent", "DataTransfer", "MediaQueryList", "Location", "Performance", "Navigator", "Clipboard", "CssStyleDeclaration", "console", "WebSocket", "MessageEvent", "CloseEvent", "ErrorEvent", "Storage", "Crypto", "DragEvent", "FileList", "File", "Blob", "Request", "RequestInit", "Response"] }
|
||||
|
||||
+14
-14
@@ -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. |
|
||||
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
@@ -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`. |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
|
||||
@@ -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.
|
||||
+40
-5
@@ -219,9 +219,28 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn default_server(&self) -> String {
|
||||
self.session.server_hint()
|
||||
}
|
||||
|
||||
/// Look at the shared projects' sync engines (about once a second).
|
||||
fn tick_sync(&mut self) {
|
||||
if self.session.sync_poll(&mut self.ed, Instant::now()) {
|
||||
if self.st.home.is_some() {
|
||||
self.show_home();
|
||||
}
|
||||
if let Some(g) = &self.gfx {
|
||||
g.window.request_redraw();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn show_home(&mut self) {
|
||||
let mut hv = self.st.home.take().unwrap_or_default();
|
||||
hv.refresh(&self.session.store, crate::project::now_ms());
|
||||
hv.shared = self.session.shared_info();
|
||||
hv.default_server = self.default_server();
|
||||
hv.library = self.session.sync.library_active();
|
||||
self.st.home = Some(hv);
|
||||
self.st.project_name.clear();
|
||||
self.st.save_state.clear();
|
||||
@@ -330,7 +349,18 @@ impl App {
|
||||
HomeAction::Duplicate(dir) => {
|
||||
store.duplicate(&dir).map(|_| ()).map_err(|e| e.to_string())
|
||||
}
|
||||
HomeAction::Trash(dir) => store.trash(&dir).map(|_| ()).map_err(|e| e.to_string()),
|
||||
HomeAction::Trash(dir) => {
|
||||
// A trashed project leaves the share (and a library); restoring it brings
|
||||
// back a local copy.
|
||||
self.session.sync.drop_project(&dir);
|
||||
store.trash(&dir).map(|_| ()).map_err(|e| e.to_string())
|
||||
}
|
||||
HomeAction::Share(dir, server) => self.session.share_project(&dir, &server),
|
||||
HomeAction::Join(link) => self.session.join_shared(&link),
|
||||
HomeAction::Unshare(dir) => {
|
||||
self.session.sync.unshare(&dir);
|
||||
Ok(())
|
||||
}
|
||||
HomeAction::Restore(dir) => {
|
||||
store.restore(&dir).map(|_| ()).map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -1277,10 +1307,15 @@ impl ApplicationHandler<UserEvent> for App {
|
||||
g.window.request_redraw();
|
||||
}
|
||||
}
|
||||
let wake = match (self.next_repaint, self.session.next_deadline()) {
|
||||
(Some(a), Some(b)) => Some(a.min(b)),
|
||||
(a, b) => a.or(b),
|
||||
};
|
||||
self.tick_sync();
|
||||
let wake = [
|
||||
self.next_repaint,
|
||||
self.session.next_deadline(),
|
||||
self.session.sync_deadline(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.min();
|
||||
match wake {
|
||||
Some(t) if Instant::now() >= t => {
|
||||
if self.next_repaint.is_some_and(|r| r <= t) {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! built web app (`web/index.html` + `web/pkg`) from the same origin, so one process hosts it.
|
||||
|
||||
use papercraft::mcp::relay;
|
||||
use papercraft::mcp::store::Data;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
@@ -13,12 +14,16 @@ const USAGE: &str = "\
|
||||
papercraft-relay — MCP relay for a PaperCraft tab in a browser
|
||||
|
||||
USAGE:
|
||||
papercraft-relay [--listen 0.0.0.0:8787] [--static <dir>] [--allow-origin <origin>]
|
||||
papercraft-relay [--listen 0.0.0.0:8787] [--static <dir>] [--allow-origin <origin>] [--data <dir>] [--library]
|
||||
|
||||
OPTIONS:
|
||||
--listen <addr> address to serve on (default: $PAPERCRAFT_RELAY_LISTEN or 0.0.0.0:8787)
|
||||
--static <dir> serve the built web app from <dir> (e.g. the repo's web/ directory)
|
||||
--allow-origin <o> only let this browser origin connect a tab (default: any)
|
||||
--data <dir> keep shared projects in <dir> (off without it): /p/{token}/...
|
||||
--library with --data: a personal library. Every project in <dir> is listed at
|
||||
/library and synced to every device that opens this server, with no
|
||||
link needed, so only run it where the network is the boundary
|
||||
--help print this text
|
||||
|
||||
The tab picks a 32..64 hex character token and shows the agent URL
|
||||
@@ -33,12 +38,16 @@ struct Args {
|
||||
listen: String,
|
||||
static_dir: Option<PathBuf>,
|
||||
allow_origin: Option<String>,
|
||||
data_dir: Option<PathBuf>,
|
||||
library: bool,
|
||||
}
|
||||
|
||||
fn parse_args() -> Result<Args, String> {
|
||||
let mut listen = std::env::var(ENV_LISTEN).unwrap_or_else(|_| DEFAULT_LISTEN.to_string());
|
||||
let mut static_dir = None;
|
||||
let mut allow_origin = None;
|
||||
let mut data_dir = None;
|
||||
let mut library = false;
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
let mut value = || args.next().ok_or_else(|| format!("{arg} needs a value"));
|
||||
@@ -46,6 +55,8 @@ fn parse_args() -> Result<Args, String> {
|
||||
"--listen" => listen = value()?,
|
||||
"--static" => static_dir = Some(PathBuf::from(value()?)),
|
||||
"--allow-origin" => allow_origin = Some(value()?),
|
||||
"--data" => data_dir = Some(PathBuf::from(value()?)),
|
||||
"--library" => library = true,
|
||||
other => return Err(format!("unknown argument {other}\n\n{USAGE}")),
|
||||
}
|
||||
}
|
||||
@@ -53,6 +64,8 @@ fn parse_args() -> Result<Args, String> {
|
||||
listen,
|
||||
static_dir,
|
||||
allow_origin,
|
||||
data_dir,
|
||||
library,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -69,6 +82,10 @@ fn main() -> ExitCode {
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
if args.library && args.data_dir.is_none() {
|
||||
eprintln!("papercraft-relay: --library needs --data <dir>");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
let listen: SocketAddr = match args.listen.parse() {
|
||||
Ok(a) => a,
|
||||
Err(_) => {
|
||||
@@ -79,7 +96,15 @@ fn main() -> ExitCode {
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
let running = match relay::spawn(listen, args.static_dir.clone(), args.allow_origin.clone()) {
|
||||
let running = match relay::spawn_with_data(
|
||||
listen,
|
||||
args.static_dir.clone(),
|
||||
args.allow_origin.clone(),
|
||||
args.data_dir.clone().map(|dir| Data {
|
||||
dir,
|
||||
library: args.library,
|
||||
}),
|
||||
) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
eprintln!("papercraft-relay: {e}");
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
+2528
File diff suppressed because it is too large
Load Diff
+21
-2
@@ -16,7 +16,18 @@ pub fn import_as_pages(path: &Path) -> Result<(String, Vec<PageData>), String> {
|
||||
let pages = p.load_pages().map_err(|e| e.to_string())?;
|
||||
return Ok((p.manifest.name, pages));
|
||||
}
|
||||
let text = crate::vfs::read_to_string(path).map_err(|e| e.to_string())?;
|
||||
let bytes = crate::vfs::read(path).map_err(|e| e.to_string())?;
|
||||
let ext = path
|
||||
.extension()
|
||||
.map(|e| e.to_string_lossy().to_lowercase())
|
||||
.unwrap_or_default();
|
||||
if ext == "fig" || crate::io::fig::is_fig(&bytes) {
|
||||
return crate::io::fig::import_fig_bytes(&stem, &bytes);
|
||||
}
|
||||
let text = String::from_utf8(bytes).map_err(|e| e.to_string())?;
|
||||
if crate::io::fig::is_figma_rest_json(&text) {
|
||||
return crate::io::fig::import_rest_json(&stem, &text);
|
||||
}
|
||||
page_from_text(&stem, &text)
|
||||
}
|
||||
|
||||
@@ -64,6 +75,9 @@ pub fn import_bytes(name: &str, bytes: &[u8]) -> Result<Imported, String> {
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "Imported".into());
|
||||
let ext = extension(name);
|
||||
if ext == "fig" || crate::io::fig::is_fig(bytes) {
|
||||
return crate::io::fig::import_fig_bytes(&stem, bytes).map(|(n, p)| Imported::Pages(n, p));
|
||||
}
|
||||
if ext == "zip" || super::backup::is_zip(bytes) {
|
||||
return Ok(Imported::Backup);
|
||||
}
|
||||
@@ -76,7 +90,12 @@ pub fn import_bytes(name: &str, bytes: &[u8]) -> Result<Imported, String> {
|
||||
group: Some(stem),
|
||||
}),
|
||||
"excalidraw" | "json" => {
|
||||
let doc = crate::io::excalidraw::import_scene(text()?)?;
|
||||
let t = text()?;
|
||||
if crate::io::fig::is_figma_rest_json(t) {
|
||||
return crate::io::fig::import_rest_json(&stem, t)
|
||||
.map(|(n, p)| Imported::Pages(n, p));
|
||||
}
|
||||
let doc = crate::io::excalidraw::import_scene(t)?;
|
||||
let nodes = doc
|
||||
.paint_order()
|
||||
.into_iter()
|
||||
|
||||
@@ -3,6 +3,7 @@ pub mod backup;
|
||||
pub mod clipboard;
|
||||
pub mod excalidraw;
|
||||
pub mod export;
|
||||
pub mod fig;
|
||||
pub mod file;
|
||||
pub mod import;
|
||||
pub mod pdf;
|
||||
|
||||
+388
-21
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ pub mod model;
|
||||
pub mod project;
|
||||
pub mod render;
|
||||
pub mod session;
|
||||
pub mod sync;
|
||||
pub mod text;
|
||||
pub mod theme;
|
||||
pub mod ui;
|
||||
|
||||
+1501
File diff suppressed because it is too large
Load Diff
+678
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
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:?}");
|
||||
}
|
||||
}
|
||||
+152
-11
@@ -134,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)
|
||||
@@ -275,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),
|
||||
@@ -297,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),
|
||||
@@ -1100,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 {
|
||||
@@ -3015,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
|
||||
@@ -3103,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();
|
||||
@@ -3121,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,
|
||||
}))
|
||||
@@ -3458,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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,10 @@
|
||||
//! * [`relay_proto`]: JSON frames between the relay and a browser tab; [`relay`]: the
|
||||
//! relay server itself (native only).
|
||||
|
||||
pub mod codegen;
|
||||
pub mod css;
|
||||
pub mod diff;
|
||||
pub mod enforcer;
|
||||
pub mod exec;
|
||||
pub mod html;
|
||||
pub mod lint;
|
||||
@@ -21,6 +24,8 @@ pub mod relay;
|
||||
pub mod relay_proto;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod server;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod store;
|
||||
pub mod tools;
|
||||
|
||||
/// Paper uses 29979; papercraft listens next door so both apps can run side by side.
|
||||
|
||||
+20
-4
@@ -142,7 +142,9 @@ struct Link {
|
||||
impl Link {
|
||||
fn new(shared: Arc<Shared>, ct: CancellationToken) -> Link {
|
||||
let dispatch = dispatch(shared.clone());
|
||||
// Served under a public hostname; the URL token is the credential, not the Host header.
|
||||
let config = StreamableHttpServerConfig::default()
|
||||
.disable_allowed_hosts()
|
||||
.enforce_origin_validation()
|
||||
.with_max_request_body_bytes(MAX_MCP_BODY_BYTES)
|
||||
.with_cancellation_token(ct);
|
||||
@@ -409,13 +411,17 @@ async fn serve_static(State(relay): State<Arc<Relay>>, uri: Uri) -> Response {
|
||||
}
|
||||
}
|
||||
|
||||
fn router(relay: Arc<Relay>) -> axum::Router {
|
||||
axum::Router::new()
|
||||
fn router(relay: Arc<Relay>, store: Option<Arc<super::store::Store>>) -> axum::Router {
|
||||
let app = axum::Router::new()
|
||||
.route("/tab/{token}", any(tab_socket))
|
||||
.route("/s/{token}/mcp", any(mcp_endpoint))
|
||||
.fallback(serve_static)
|
||||
.layer(axum::middleware::from_fn(super::server::fix_accept))
|
||||
.with_state(relay)
|
||||
.with_state(relay);
|
||||
match store {
|
||||
Some(store) => app.merge(super::store::router(store)),
|
||||
None => app,
|
||||
}
|
||||
}
|
||||
|
||||
/// A running relay; stops when dropped.
|
||||
@@ -455,6 +461,16 @@ pub fn spawn(
|
||||
listen: SocketAddr,
|
||||
static_dir: Option<PathBuf>,
|
||||
allow_origin: Option<String>,
|
||||
) -> Result<Running, String> {
|
||||
spawn_with_data(listen, static_dir, allow_origin, None)
|
||||
}
|
||||
|
||||
/// [`spawn`], plus `data`: where shared projects are kept (the `/p/` routes are off without it).
|
||||
pub fn spawn_with_data(
|
||||
listen: SocketAddr,
|
||||
static_dir: Option<PathBuf>,
|
||||
allow_origin: Option<String>,
|
||||
data: Option<super::store::Data>,
|
||||
) -> Result<Running, String> {
|
||||
let listener = std::net::TcpListener::bind(listen)
|
||||
.map_err(|e| format!("cannot listen on {listen}: {e}"))?;
|
||||
@@ -485,7 +501,7 @@ pub fn spawn(
|
||||
static_dir,
|
||||
ct: thread_ct.child_token(),
|
||||
});
|
||||
if let Err(e) = axum::serve(listener, router(relay))
|
||||
if let Err(e) = axum::serve(listener, router(relay, data.map(super::store::Store::new)))
|
||||
.with_graceful_shutdown(thread_ct.cancelled_owned())
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -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
@@ -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.",
|
||||
|
||||
@@ -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;
|
||||
|
||||
+4
-1
@@ -58,6 +58,9 @@ fn ramp(t: f32, row: u32) -> vec4<f32> {
|
||||
fn fs(i: VsOut) -> @location(0) vec4<f32> {
|
||||
let k = i.kind & 255u;
|
||||
let row = i.kind >> 8u;
|
||||
// Derivatives must be taken in uniform control flow (WebGPU rejects textureSample under `k`).
|
||||
let duv_dx = dpdx(i.uv);
|
||||
let duv_dy = dpdy(i.uv);
|
||||
var c = i.color;
|
||||
if (k == 1u) {
|
||||
let r = ramp(i.uv.x, row);
|
||||
@@ -69,7 +72,7 @@ fn fs(i: VsOut) -> @location(0) vec4<f32> {
|
||||
if (k == 3u && (i.uv.x < 0.0 || i.uv.y < 0.0 || i.uv.x > 1.0 || i.uv.y > 1.0)) {
|
||||
discard;
|
||||
}
|
||||
let t = textureSample(img_tex, img_samp, i.uv);
|
||||
let t = textureSampleGrad(img_tex, img_samp, i.uv, duv_dx, duv_dy);
|
||||
c = vec4<f32>(t.rgb, t.a * i.color.a);
|
||||
}
|
||||
// Premultiplied output; every pipeline blends premultiplied.
|
||||
|
||||
+102
@@ -13,11 +13,16 @@ use web_time::Instant;
|
||||
pub const AUTOSAVE_DELAY: Duration = Duration::from_millis(1000);
|
||||
/// Re-render the home-screen thumbnail at most this often while editing.
|
||||
pub const THUMBNAIL_INTERVAL: Duration = Duration::from_secs(20);
|
||||
/// How often the shell looks at what the sync engines downloaded.
|
||||
const SYNC_CHECK: Duration = Duration::from_secs(1);
|
||||
pub const THUMB_SIZE: (f64, f64) = (464.0, 296.0);
|
||||
|
||||
pub struct Session {
|
||||
pub store: Store,
|
||||
pub project: Option<Project>,
|
||||
/// Shared projects kept in step with a relay.
|
||||
pub sync: crate::sync::Sync,
|
||||
sync_checked: Option<Instant>,
|
||||
dirty_since: Option<Instant>,
|
||||
thumb_stale: bool,
|
||||
last_thumb: Option<Instant>,
|
||||
@@ -25,9 +30,13 @@ pub struct Session {
|
||||
|
||||
impl Session {
|
||||
pub fn new(store: Store) -> Session {
|
||||
let mut sync = crate::sync::Sync::default();
|
||||
sync.start_all(&store.root);
|
||||
Session {
|
||||
store,
|
||||
project: None,
|
||||
sync,
|
||||
sync_checked: None,
|
||||
dirty_since: None,
|
||||
thumb_stale: false,
|
||||
last_thumb: None,
|
||||
@@ -74,6 +83,99 @@ impl Session {
|
||||
now.duration_since(since) >= AUTOSAVE_DELAY && !ed.is_busy()
|
||||
}
|
||||
|
||||
/// When the sync engines should next be looked at (None while nothing is shared).
|
||||
pub fn sync_deadline(&self) -> Option<Instant> {
|
||||
self.sync.is_active().then(|| {
|
||||
self.sync_checked
|
||||
.map_or_else(Instant::now, |t| t + SYNC_CHECK)
|
||||
})
|
||||
}
|
||||
|
||||
/// Look at the sync engines: the open project is reloaded when they downloaded changes,
|
||||
/// unless it holds unsaved edits (those win and go up on the next save). True when a check
|
||||
/// ran, so the shell can refresh the home list and the sync status shown there.
|
||||
pub fn sync_poll(&mut self, ed: &mut Editor, now: Instant) -> bool {
|
||||
if self.sync_deadline().is_none_or(|d| now < d) {
|
||||
return false;
|
||||
}
|
||||
self.sync_checked = Some(now);
|
||||
if self.sync.library_active() {
|
||||
// The library task writes `.share` files for projects it joined or shared.
|
||||
self.sync.start_all(&self.store.root);
|
||||
}
|
||||
let open = self.project.as_ref().map(|p| p.dir.clone());
|
||||
for dir in self.sync.dirs() {
|
||||
let is_open = open.as_ref() == Some(&dir);
|
||||
// A project the server dropped waits until it is closed.
|
||||
if !is_open && let Some(gone) = self.sync.status(&dir).and_then(|s| s.gone()) {
|
||||
self.sync.unshare(&dir);
|
||||
if gone == crate::sync::Gone::Deleted {
|
||||
self.store.trash(&dir).ok();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if is_open && ed.project_dirty() {
|
||||
continue;
|
||||
}
|
||||
if self.sync.status(&dir).is_some_and(|s| s.take_changed()) {
|
||||
if is_open && let Err(e) = self.open(ed, &dir) {
|
||||
log::warn!("reload shared project: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Put the project in `dir` on the relay at `server`.
|
||||
pub fn share_project(&mut self, dir: &Path, server: &str) -> Result<(), String> {
|
||||
self.sync.share(dir, server).map(|_| ())
|
||||
}
|
||||
|
||||
/// Open the shared project behind `link` as a project of its own; its files arrive with the
|
||||
/// first sync.
|
||||
pub fn join_shared(&mut self, link: &str) -> Result<(), String> {
|
||||
let token = crate::sync::Share::from_link(link)?.token;
|
||||
let dir = self.store.root.join(format!("shared-{}", &token[..8]));
|
||||
if crate::sync::Share::load(&dir).is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
self.sync.join(&dir, link)
|
||||
}
|
||||
|
||||
/// The shared projects for the home screen: their status and links.
|
||||
pub fn shared_info(
|
||||
&self,
|
||||
) -> std::collections::HashMap<std::path::PathBuf, crate::ui::home::ShareInfo> {
|
||||
self.sync
|
||||
.dirs()
|
||||
.into_iter()
|
||||
.filter_map(|dir| {
|
||||
let s = self.sync.status(&dir)?;
|
||||
let info = crate::ui::home::ShareInfo {
|
||||
status: s.label(),
|
||||
link: s.link.clone(),
|
||||
};
|
||||
Some((dir, info))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Keep a library with the relay at `server`, if it runs one (see `Sync::enable_library`).
|
||||
pub fn follow_library(&mut self, server: &str) {
|
||||
let root = self.store.root.clone();
|
||||
self.sync.enable_library(&root, server);
|
||||
}
|
||||
|
||||
/// The server of any shared project here: the likely answer for the next "Share…".
|
||||
pub fn server_hint(&self) -> String {
|
||||
self.sync
|
||||
.dirs()
|
||||
.iter()
|
||||
.find_map(|d| crate::sync::Share::load(d))
|
||||
.map(|s| s.server)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Retry later (after a failed save) instead of spinning on an overdue deadline.
|
||||
pub fn defer(&mut self) {
|
||||
self.dirty_since = Some(Instant::now());
|
||||
|
||||
+635
@@ -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());
|
||||
}
|
||||
}
|
||||
+342
-29
@@ -18,6 +18,20 @@ fn on_every_page(ed: &mut Editor, label: &str, f: impl Fn(&mut crate::history::T
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_name(s: &str) -> String {
|
||||
s.trim()
|
||||
.trim_start_matches("--")
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '_' {
|
||||
c
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
let p = theme::pal(ui);
|
||||
let heading = |ui: &mut Ui, t: &str| {
|
||||
@@ -83,10 +97,10 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
}
|
||||
}
|
||||
// ---- modes
|
||||
if !ed.doc.modes.is_empty() {
|
||||
heading(ui, "Mode");
|
||||
let modes: Vec<String> = ed.doc.modes.keys().cloned().collect();
|
||||
let active = ed.doc.mode.clone();
|
||||
heading(ui, "Modes");
|
||||
let modes: Vec<String> = ed.doc.modes.keys().cloned().collect();
|
||||
let active = ed.doc.mode.clone();
|
||||
if !modes.is_empty() {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for m in &modes {
|
||||
let on = active.as_deref() == Some(m);
|
||||
@@ -96,9 +110,47 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
tx.set_mode(&m);
|
||||
});
|
||||
}
|
||||
if crate::theme::small_button(ui, "✕")
|
||||
.on_hover_text("Delete mode")
|
||||
.clicked()
|
||||
{
|
||||
let m = m.clone();
|
||||
on_every_page(ed, "Remove variable mode", |tx| {
|
||||
tx.remove_mode(&m);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
let w = (ui.available_width() - 44.0).max(60.0);
|
||||
let r = ui.add(
|
||||
egui::TextEdit::singleline(&mut st.mode_draft)
|
||||
.hint_text("New mode (e.g. Dark)")
|
||||
.desired_width(w)
|
||||
.margin(crate::theme::INPUT_MARGIN),
|
||||
);
|
||||
let enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
let mode_name = st.mode_draft.trim().to_string();
|
||||
if (crate::theme::small_button(ui, "Add").clicked() || enter)
|
||||
&& !mode_name.is_empty()
|
||||
{
|
||||
st.mode_draft.clear();
|
||||
let snapshot: Vec<(String, Color)> = ed
|
||||
.doc
|
||||
.variables
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), *v))
|
||||
.collect();
|
||||
on_every_page(ed, "Add variable mode", |tx| {
|
||||
tx.doc.modes.entry(mode_name.clone()).or_default();
|
||||
for (k, v) in &snapshot {
|
||||
tx.set_variable_in_mode(&mode_name, k, *v);
|
||||
}
|
||||
tx.set_mode(&mode_name);
|
||||
});
|
||||
}
|
||||
});
|
||||
// ---- variables
|
||||
heading(ui, "Variables");
|
||||
if ed.doc.variables.is_empty() {
|
||||
@@ -129,7 +181,44 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
});
|
||||
}
|
||||
}
|
||||
ui.label(RichText::new(format!("--{name}")).monospace().size(theme::SECONDARY + 1.0));
|
||||
let is_renaming = st
|
||||
.var_renaming
|
||||
.as_ref()
|
||||
.is_some_and(|(old, _)| old == &name);
|
||||
if is_renaming {
|
||||
let (_, mut draft) = st.var_renaming.clone().unwrap();
|
||||
let r = ui.add(
|
||||
egui::TextEdit::singleline(&mut draft)
|
||||
.desired_width(90.0)
|
||||
.margin(crate::theme::INPUT_MARGIN),
|
||||
);
|
||||
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
st.var_renaming = None;
|
||||
} else if r.lost_focus() || ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
let new = sanitize_name(&draft);
|
||||
let old = name.clone();
|
||||
st.var_renaming = None;
|
||||
if !new.is_empty() && new != old {
|
||||
on_every_page(ed, "Rename variable", |tx| {
|
||||
tx.rename_variable(&old, &new);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
st.var_renaming = Some((name.clone(), draft));
|
||||
}
|
||||
} else {
|
||||
let lbl = ui.add(
|
||||
egui::Label::new(
|
||||
RichText::new(format!("--{name}"))
|
||||
.monospace()
|
||||
.size(theme::SECONDARY + 1.0),
|
||||
)
|
||||
.sense(egui::Sense::click()),
|
||||
);
|
||||
if lbl.on_hover_text("Click to rename").clicked() {
|
||||
st.var_renaming = Some((name.clone(), name.clone()));
|
||||
}
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if crate::theme::small_button(ui, "✕")
|
||||
.on_hover_text("Delete (fills keep their color)")
|
||||
@@ -151,13 +240,7 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
.margin(crate::theme::INPUT_MARGIN),
|
||||
);
|
||||
let enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
let name: String = st
|
||||
.variable_draft
|
||||
.trim()
|
||||
.trim_start_matches("--")
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || c == '_' { c } else { '-' })
|
||||
.collect();
|
||||
let name = sanitize_name(&st.variable_draft);
|
||||
if (crate::theme::small_button(ui, "Add").clicked() || enter) && !name.is_empty() {
|
||||
st.variable_draft.clear();
|
||||
let base = Color::rgb(0x80, 0x80, 0x80);
|
||||
@@ -166,21 +249,108 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
});
|
||||
}
|
||||
});
|
||||
// ---- tokens
|
||||
if !ed.doc.tokens.is_empty() {
|
||||
heading(ui, "Tokens");
|
||||
for (k, v) in &ed.doc.tokens {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(format!("--{k}")).monospace().size(theme::SECONDARY));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.label(RichText::new(v).monospace().weak().size(theme::SECONDARY));
|
||||
});
|
||||
});
|
||||
}
|
||||
if !ed.doc.variables.is_empty() || !ed.doc.tokens.is_empty() {
|
||||
ui.horizontal(|ui| {
|
||||
if crate::theme::small_button(ui, "Copy CSS")
|
||||
.on_hover_text("Copy variables and tokens as CSS custom properties")
|
||||
.clicked()
|
||||
{
|
||||
let mut lines = vec![":root {".to_string()];
|
||||
for (k, v) in &ed.doc.variables {
|
||||
lines.push(format!(" --{k}: {};", v.to_hex()));
|
||||
}
|
||||
for (k, v) in &ed.doc.tokens {
|
||||
lines.push(format!(" --{k}: {v};"));
|
||||
}
|
||||
lines.push("}".to_string());
|
||||
for (mode, map) in &ed.doc.modes {
|
||||
lines.push(format!("[data-theme=\"{mode}\"] {{"));
|
||||
for (k, v) in map {
|
||||
lines.push(format!(" --{k}: {};", v.to_hex()));
|
||||
}
|
||||
lines.push("}".to_string());
|
||||
}
|
||||
ui.ctx().copy_text(lines.join("\n"));
|
||||
ed.status = "Copied tokens to clipboard".into();
|
||||
}
|
||||
if crate::theme::small_button(ui, "Copy JSON")
|
||||
.on_hover_text("Copy variables and tokens in W3C DTCG JSON format")
|
||||
.clicked()
|
||||
{
|
||||
let mut root = serde_json::Map::new();
|
||||
for (k, v) in &ed.doc.variables {
|
||||
root.insert(
|
||||
k.clone(),
|
||||
serde_json::json!({ "$type": "color", "$value": v.to_hex() }),
|
||||
);
|
||||
}
|
||||
for (k, v) in &ed.doc.tokens {
|
||||
root.insert(
|
||||
k.clone(),
|
||||
serde_json::json!({ "$type": "dimension", "$value": v }),
|
||||
);
|
||||
}
|
||||
if let Ok(json) =
|
||||
serde_json::to_string_pretty(&serde_json::Value::Object(root))
|
||||
{
|
||||
ui.ctx().copy_text(json);
|
||||
ed.status = "Copied tokens to clipboard".into();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// ---- tokens
|
||||
heading(ui, "Tokens");
|
||||
let tokens: Vec<(String, String)> = ed
|
||||
.doc
|
||||
.tokens
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect();
|
||||
for (k, v) in tokens {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(format!("--{k}")).monospace().size(theme::SECONDARY));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if crate::theme::small_button(ui, "✕")
|
||||
.on_hover_text("Delete token")
|
||||
.clicked()
|
||||
{
|
||||
on_every_page(ed, "Delete token", |tx| tx.remove_token(&k));
|
||||
}
|
||||
ui.label(RichText::new(&v).monospace().weak().size(theme::SECONDARY));
|
||||
});
|
||||
});
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
let avail = (ui.available_width() - 48.0).max(80.0);
|
||||
let r1 = ui.add(
|
||||
egui::TextEdit::singleline(&mut st.token_name_draft)
|
||||
.hint_text("spacing-md")
|
||||
.desired_width(avail * 0.55)
|
||||
.margin(crate::theme::INPUT_MARGIN),
|
||||
);
|
||||
let r2 = ui.add(
|
||||
egui::TextEdit::singleline(&mut st.token_val_draft)
|
||||
.hint_text("16px")
|
||||
.desired_width(avail * 0.45)
|
||||
.margin(crate::theme::INPUT_MARGIN),
|
||||
);
|
||||
let enter = (r1.lost_focus() || r2.lost_focus())
|
||||
&& ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
let name = sanitize_name(&st.token_name_draft);
|
||||
let val = st.token_val_draft.trim().to_string();
|
||||
if (crate::theme::small_button(ui, "Add").clicked() || enter)
|
||||
&& !name.is_empty()
|
||||
&& !val.is_empty()
|
||||
{
|
||||
st.token_name_draft.clear();
|
||||
st.token_val_draft.clear();
|
||||
on_every_page(ed, "Set token", |tx| tx.set_token(&name, &val));
|
||||
}
|
||||
});
|
||||
// ---- components
|
||||
heading(ui, "Components");
|
||||
let mut comps: Vec<(NodeId, String, usize)> = ed
|
||||
let mut comps: Vec<(NodeId, String, f64, f64, usize)> = ed
|
||||
.doc
|
||||
.nodes
|
||||
.values()
|
||||
@@ -192,7 +362,7 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
.values()
|
||||
.filter(|i| i.id != n.id && i.instance.as_ref().is_some_and(|l| l.main == n.id))
|
||||
.count();
|
||||
(n.id, n.name.clone(), uses)
|
||||
(n.id, n.name.clone(), n.rect.w, n.rect.h, uses)
|
||||
})
|
||||
.collect();
|
||||
comps.sort_by_key(|c| c.0);
|
||||
@@ -203,21 +373,164 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
.color(p.secondary),
|
||||
);
|
||||
}
|
||||
for (id, name, uses) in comps {
|
||||
if comps.len() > 3 || !st.component_filter.is_empty() {
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut st.component_filter)
|
||||
.hint_text("Filter components…")
|
||||
.desired_width(f32::INFINITY)
|
||||
.margin(crate::theme::INPUT_MARGIN),
|
||||
);
|
||||
ui.add_space(2.0);
|
||||
}
|
||||
if let Some(inst_id) = ed
|
||||
.selection
|
||||
.first()
|
||||
.copied()
|
||||
.filter(|id| ed.is_instance_root(*id))
|
||||
{
|
||||
let cur_main = ed.instance_main_root(inst_id);
|
||||
let cur_name = cur_main
|
||||
.and_then(|m| ed.doc.get(m))
|
||||
.map(|n| n.name.clone())
|
||||
.unwrap_or_else(|| "Instance".into());
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(
|
||||
RichText::new("Instance")
|
||||
.size(theme::SECONDARY)
|
||||
.color(p.secondary),
|
||||
);
|
||||
egui::ComboBox::from_id_salt("design_swap_instance")
|
||||
.selected_text(cur_name)
|
||||
.width(100.0)
|
||||
.show_ui(ui, |ui| {
|
||||
for (cid, cname, _, _, _) in &comps {
|
||||
if ui.selectable_label(cur_main == Some(*cid), cname).clicked()
|
||||
&& cur_main != Some(*cid)
|
||||
{
|
||||
ed.swap_instance(inst_id, *cid);
|
||||
}
|
||||
}
|
||||
});
|
||||
if crate::theme::small_button(ui, "Reset").clicked() {
|
||||
ed.reset_overrides();
|
||||
}
|
||||
if crate::theme::small_button(ui, "Detach").clicked() {
|
||||
ed.detach_instance();
|
||||
}
|
||||
});
|
||||
}
|
||||
let needle = st.component_filter.trim().to_lowercase();
|
||||
for (id, name, w, h, uses) in comps {
|
||||
if !needle.is_empty() && !name.to_lowercase().contains(&needle) {
|
||||
continue;
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(&name).font(theme::medium(theme::BODY)));
|
||||
ui.label(RichText::new(format!("{uses}")).weak().size(theme::SECONDARY));
|
||||
ui.label(
|
||||
RichText::new(format!("{w:.0}×{h:.0} · {uses}×"))
|
||||
.weak()
|
||||
.size(theme::SECONDARY),
|
||||
);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if crate::theme::small_button(ui, "Insert").on_hover_text("Place an instance in view").clicked() {
|
||||
if crate::theme::small_button(ui, "Insert")
|
||||
.on_hover_text("Place an instance in view")
|
||||
.clicked()
|
||||
{
|
||||
let at = ed.camera.screen_to_world(ed.viewport * 0.5);
|
||||
ed.create_instance(id, at);
|
||||
}
|
||||
if crate::theme::small_button(ui, "Go to").on_hover_text("Select the main component").clicked() {
|
||||
if crate::theme::small_button(ui, "Go to")
|
||||
.on_hover_text("Select the main component")
|
||||
.clicked()
|
||||
{
|
||||
ed.selection = vec![id];
|
||||
ed.run(crate::editor::shortcuts::Action::ZoomSelection);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
// ---- design lint
|
||||
let issues = crate::mcp::lint::lint(
|
||||
&ed.doc,
|
||||
&ed.doc.root,
|
||||
&crate::mcp::lint::DEFAULT_SCALE,
|
||||
);
|
||||
ui.horizontal(|ui| {
|
||||
let title = if issues.is_empty() {
|
||||
"Lint · Clean".to_string()
|
||||
} else {
|
||||
format!("Lint · {} issues", issues.len())
|
||||
};
|
||||
heading(ui, &title);
|
||||
if !issues.is_empty() {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if crate::theme::small_button(ui, "Fix all")
|
||||
.on_hover_text("Auto-fix grid, color variables, text scale, and touch targets")
|
||||
.clicked()
|
||||
{
|
||||
ed.edit("Auto-fix lint issues", |tx| {
|
||||
let roots = tx.doc().root.clone();
|
||||
crate::mcp::lint::autofix(
|
||||
tx,
|
||||
&roots,
|
||||
&crate::mcp::lint::DEFAULT_SCALE,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
for issue in issues.iter().take(30) {
|
||||
let node_name = ed
|
||||
.doc
|
||||
.get(issue.node)
|
||||
.map(|n| n.name.clone())
|
||||
.unwrap_or_else(|| "Layer".into());
|
||||
let dot_color = match issue.severity {
|
||||
"error" => egui::Color32::from_rgb(239, 68, 68),
|
||||
"warning" => egui::Color32::from_rgb(245, 158, 11),
|
||||
_ => p.secondary,
|
||||
};
|
||||
let fixable = matches!(
|
||||
issue.rule,
|
||||
"spacing-grid"
|
||||
| "hard-coded-color"
|
||||
| "text-size"
|
||||
| "type-scale"
|
||||
| "touch-target"
|
||||
);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new("●").color(dot_color).size(theme::SECONDARY));
|
||||
let r = ui.add(
|
||||
egui::Label::new(
|
||||
RichText::new(format!("{node_name}: {}", issue.message))
|
||||
.size(theme::SECONDARY),
|
||||
)
|
||||
.truncate()
|
||||
.sense(egui::Sense::click()),
|
||||
);
|
||||
if r.on_hover_text(&issue.fix).clicked() {
|
||||
ed.selection = vec![issue.node];
|
||||
ed.run(crate::editor::shortcuts::Action::ZoomSelection);
|
||||
}
|
||||
if fixable {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if crate::theme::small_button(ui, "Fix")
|
||||
.on_hover_text(&issue.fix)
|
||||
.clicked()
|
||||
{
|
||||
let target = issue.node;
|
||||
ed.edit("Fix lint issue", |tx| {
|
||||
crate::mcp::lint::autofix_node(
|
||||
tx,
|
||||
target,
|
||||
&crate::mcp::lint::DEFAULT_SCALE,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+117
@@ -20,6 +20,28 @@ pub enum HomeAction {
|
||||
EmptyTrash,
|
||||
/// Show the projects folder in the system file manager.
|
||||
RevealRoot,
|
||||
/// Put a project on this server (a relay) so others can open and edit it.
|
||||
Share(PathBuf, String),
|
||||
/// Open the shared project behind a share link.
|
||||
Join(String),
|
||||
/// Stop syncing a project; it stays here as a local copy.
|
||||
Unshare(PathBuf),
|
||||
}
|
||||
|
||||
/// The small window opened by "Share…" and "Join shared project…".
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ShareDialog {
|
||||
/// The project to share, or None to join.
|
||||
pub dir: Option<PathBuf>,
|
||||
/// The server to share on, or the link to join.
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// What the sync engine reports for one shared project.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ShareInfo {
|
||||
pub status: String,
|
||||
pub link: String,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -33,6 +55,13 @@ pub struct HomeView {
|
||||
/// "Now" for relative timestamps (unix ms); fixed in screenshots.
|
||||
pub now_ms: u64,
|
||||
pub root: PathBuf,
|
||||
pub dialog: Option<ShareDialog>,
|
||||
/// Shared projects by folder, filled by the shell every frame.
|
||||
pub shared: HashMap<PathBuf, ShareInfo>,
|
||||
/// Where a new share goes unless the user types another server.
|
||||
pub default_server: String,
|
||||
/// The server keeps a library: every project is shared, so no share controls.
|
||||
pub library: bool,
|
||||
thumbs: HashMap<PathBuf, (Option<SystemTime>, egui::TextureHandle)>,
|
||||
}
|
||||
|
||||
@@ -180,6 +209,12 @@ pub fn draw(ui: &mut Ui, hv: &mut HomeView, native_chrome: bool, translucent: bo
|
||||
if ui.button("Import…").clicked() {
|
||||
hv.actions.push(HomeAction::Import);
|
||||
}
|
||||
if !hv.library && ui.button("Join shared…").clicked() {
|
||||
hv.dialog = Some(ShareDialog {
|
||||
dir: None,
|
||||
text: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -203,6 +238,55 @@ pub fn draw(ui: &mut Ui, hv: &mut HomeView, native_chrome: bool, translucent: bo
|
||||
}
|
||||
});
|
||||
});
|
||||
share_dialog(ui, hv);
|
||||
}
|
||||
|
||||
fn share_dialog(ui: &mut Ui, hv: &mut HomeView) {
|
||||
let Some(mut d) = hv.dialog.take() else {
|
||||
return;
|
||||
};
|
||||
let (mut keep, mut submit) = (true, false);
|
||||
let (title, hint, button) = match d.dir {
|
||||
Some(_) => (
|
||||
"Share project",
|
||||
"Server, e.g. https://papercraft.example.com",
|
||||
"Share",
|
||||
),
|
||||
None => ("Join shared project", "Paste the share link", "Join"),
|
||||
};
|
||||
egui::Window::new(title)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
|
||||
.open(&mut keep)
|
||||
.show(ui.ctx(), |ui| {
|
||||
ui.set_width(380.0);
|
||||
let r = ui.add(
|
||||
egui::TextEdit::singleline(&mut d.text)
|
||||
.hint_text(hint)
|
||||
.desired_width(f32::INFINITY),
|
||||
);
|
||||
if d.dir.is_some() {
|
||||
ui.label(
|
||||
RichText::new("Anyone with the link can open and edit the project.")
|
||||
.size(theme::SECONDARY)
|
||||
.color(theme::pal(ui).secondary),
|
||||
);
|
||||
}
|
||||
let enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
if ui.button(button).clicked() || enter {
|
||||
submit = true;
|
||||
}
|
||||
});
|
||||
if submit && !d.text.trim().is_empty() {
|
||||
let text = d.text.trim().to_string();
|
||||
hv.actions.push(match d.dir {
|
||||
Some(dir) => HomeAction::Share(dir, text),
|
||||
None => HomeAction::Join(text),
|
||||
});
|
||||
} else if keep {
|
||||
hv.dialog = Some(d);
|
||||
}
|
||||
}
|
||||
|
||||
fn sidebar_item(
|
||||
@@ -403,10 +487,43 @@ fn project_card(ui: &mut Ui, hv: &mut HomeView, e: &Entry) {
|
||||
.size(theme::SECONDARY)
|
||||
.color(p.secondary),
|
||||
);
|
||||
if let Some(s) = hv.shared.get(&e.dir) {
|
||||
let prefix = if hv.library { "" } else { "Shared · " };
|
||||
ui.label(
|
||||
RichText::new(format!("{prefix}{}", s.status))
|
||||
.size(theme::SECONDARY)
|
||||
.color(p.secondary),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn card_menu(ui: &mut Ui, hv: &mut HomeView, e: &Entry) {
|
||||
match hv.shared.get(&e.dir).map(|s| s.link.clone()) {
|
||||
_ if hv.library => {}
|
||||
Some(link) => {
|
||||
if ui.button("Copy share link").clicked() {
|
||||
ui.ctx().copy_text(link);
|
||||
ui.close();
|
||||
}
|
||||
if ui.button("Stop sharing").clicked() {
|
||||
hv.actions.push(HomeAction::Unshare(e.dir.clone()));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if ui.button("Share…").clicked() {
|
||||
hv.dialog = Some(ShareDialog {
|
||||
dir: Some(e.dir.clone()),
|
||||
text: hv.default_server.clone(),
|
||||
});
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
if !hv.library {
|
||||
ui.separator();
|
||||
}
|
||||
if ui.button("Open").clicked() {
|
||||
hv.actions.push(HomeAction::Open(e.dir.clone()));
|
||||
ui.close();
|
||||
|
||||
+122
-4
@@ -783,6 +783,55 @@ fn export_section(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
|
||||
ed.run(crate::editor::shortcuts::Action::CopyAsSvg);
|
||||
}
|
||||
});
|
||||
let root_id = ed
|
||||
.top_selection()
|
||||
.first()
|
||||
.copied()
|
||||
.or_else(|| ed.doc.root.first().copied());
|
||||
if let Some(root_id) = root_id {
|
||||
ui.add_space(6.0);
|
||||
ui.label(title_text("Code & AI Handoff"));
|
||||
if st.code_target.is_empty() {
|
||||
st.code_target = "react".into();
|
||||
}
|
||||
let targets = [
|
||||
("react", "React (TSX)"),
|
||||
("tailwind", "Tailwind JSX"),
|
||||
("angular", "Angular"),
|
||||
("html", "HTML + CSS"),
|
||||
("egui", "Rust (egui)"),
|
||||
];
|
||||
let cur = targets
|
||||
.iter()
|
||||
.find(|(k, _)| *k == st.code_target)
|
||||
.map_or("react", |(k, _)| *k);
|
||||
row(ui, "Target", |ui| {
|
||||
if let Some(v) = combo(ui, "code-target", cur, &targets, ui.available_width()) {
|
||||
st.code_target = v.into();
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
let w = half(ui);
|
||||
if ui
|
||||
.add(egui::Button::new("Copy Code").min_size(egui::vec2(w, 24.0)))
|
||||
.clicked()
|
||||
&& let Ok(code) =
|
||||
crate::mcp::codegen::code_for_target(&ed.doc, root_id, &st.code_target)
|
||||
{
|
||||
ui.ctx().copy_text(code);
|
||||
ed.status = format!("Copied {} code to clipboard", st.code_target);
|
||||
}
|
||||
if ui
|
||||
.add(egui::Button::new("Copy AI Spec").min_size(egui::vec2(w, 24.0)))
|
||||
.clicked()
|
||||
&& let Ok(md) =
|
||||
crate::mcp::codegen::to_ai_spec_markdown(&ed.doc, root_id, &st.code_target)
|
||||
{
|
||||
ui.ctx().copy_text(md);
|
||||
ed.status = format!("Copied {} AI spec to clipboard", st.code_target);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn nothing_selected(ui: &mut Ui, ed: &mut Editor) {
|
||||
@@ -877,15 +926,50 @@ fn header(ui: &mut Ui, ed: &mut Editor, ids: &[NodeId], first: &Node) {
|
||||
first.kind.type_name()
|
||||
};
|
||||
ui.label(title_text(format!("{kind} · {}", first.name)));
|
||||
let is_inst_root = ed.is_instance_root(first.id);
|
||||
let has_subtree_overrides = is_inst_root
|
||||
&& ed.doc.subtree(first.id).into_iter().any(|id| {
|
||||
ed.doc
|
||||
.get(id)
|
||||
.and_then(|n| n.instance.as_ref())
|
||||
.is_some_and(|l| !l.overrides.is_empty())
|
||||
});
|
||||
let other_comps: Vec<(NodeId, String)> = if is_inst_root {
|
||||
let cur_main = ed.instance_main_root(first.id);
|
||||
let mut v: Vec<(NodeId, String)> = ed
|
||||
.doc
|
||||
.nodes
|
||||
.values()
|
||||
.filter(|n| n.component && Some(n.id) != cur_main && n.id != first.id)
|
||||
.map(|n| (n.id, n.name.clone()))
|
||||
.collect();
|
||||
v.sort_by_key(|c| c.0);
|
||||
v
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
if first.component {
|
||||
if crate::theme::small_button(ui, "Create instance").clicked() {
|
||||
ed.instance_of_selection();
|
||||
}
|
||||
} else if ed.is_instance_root(first.id) {
|
||||
} else if is_inst_root {
|
||||
if crate::theme::small_button(ui, "Go to main").clicked() {
|
||||
ed.go_to_main();
|
||||
}
|
||||
if !other_comps.is_empty() {
|
||||
ui.menu_button("Swap", |ui| {
|
||||
for (comp_id, cname) in &other_comps {
|
||||
if ui.button(cname).clicked() {
|
||||
ed.swap_instance(first.id, *comp_id);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if has_subtree_overrides && crate::theme::small_button(ui, "Reset").clicked() {
|
||||
ed.reset_overrides();
|
||||
}
|
||||
if crate::theme::small_button(ui, "Detach").clicked() {
|
||||
ed.detach_instance();
|
||||
}
|
||||
@@ -894,7 +978,8 @@ fn header(ui: &mut Ui, ed: &mut Editor, ids: &[NodeId], first: &Node) {
|
||||
{
|
||||
ed.create_component();
|
||||
}
|
||||
if first.instance.is_some()
|
||||
if !is_inst_root
|
||||
&& first.instance.is_some()
|
||||
&& first
|
||||
.instance
|
||||
.as_ref()
|
||||
@@ -1437,10 +1522,43 @@ fn fill_row(ui: &mut Ui, ed: &mut Editor, ids: &[NodeId], i: usize, p: &Paint) {
|
||||
}
|
||||
match p {
|
||||
Paint::Solid { color, var } => {
|
||||
let w = ui.available_width() - 32.0;
|
||||
if let Some(c) = color_field(ui, ed, *color, var.as_deref(), w) {
|
||||
let has_vars = !ed.doc.variables.is_empty() || var.is_some();
|
||||
let w = ui.available_width() - if has_vars { 68.0 } else { 32.0 };
|
||||
if let Some(c) = color_field(ui, ed, *color, var.as_deref(), w.max(60.0)) {
|
||||
set(ed, Paint::solid(c));
|
||||
}
|
||||
if has_vars {
|
||||
let vars: Vec<(String, Color)> = ed
|
||||
.doc
|
||||
.variables
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), *v))
|
||||
.collect();
|
||||
let btn_label = if var.is_some() { "var" } else { "--" };
|
||||
ui.menu_button(btn_label, |ui| {
|
||||
for (vname, vcol) in &vars {
|
||||
let active = var.as_deref() == Some(vname.as_str());
|
||||
if ui
|
||||
.selectable_label(active, format!("--{vname} ({})", vcol.to_hex()))
|
||||
.clicked()
|
||||
{
|
||||
set(ed, Paint::solid_var(*vcol, vname));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
if var.is_some() {
|
||||
if !vars.is_empty() {
|
||||
ui.separator();
|
||||
}
|
||||
if ui.button("Unlink variable").clicked() {
|
||||
let mut unlinked = p.clone();
|
||||
unlinked.unlink_var();
|
||||
set(ed, unlinked);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Paint::Image {
|
||||
mode,
|
||||
|
||||
@@ -71,6 +71,16 @@ pub struct UiState {
|
||||
pub layer_filter: String,
|
||||
/// Name typed into the Design tab's new-variable box.
|
||||
pub variable_draft: String,
|
||||
/// Name typed into the Design tab's new-mode box.
|
||||
pub mode_draft: String,
|
||||
/// Name typed into the Design tab's new-token name box.
|
||||
pub token_name_draft: String,
|
||||
/// Value typed into the Design tab's new-token value box.
|
||||
pub token_val_draft: String,
|
||||
/// Filter text for the Design tab's components list.
|
||||
pub component_filter: String,
|
||||
/// Variable currently being renamed inline `(old_name, draft_new_name)`.
|
||||
pub var_renaming: Option<(String, String)>,
|
||||
/// The last tool an agent ran and when, for the toolbar's activity hint.
|
||||
pub agent: Option<(String, std::time::Instant)>,
|
||||
/// The browser's remote-agent link (Agent menu); None everywhere but the web shell.
|
||||
@@ -78,6 +88,8 @@ pub struct UiState {
|
||||
/// A message shown over the home screen and the canvas (text, is_error); the shell that
|
||||
/// sets it also clears it. Only the web shell uses it: native shows errors in dialogs.
|
||||
pub toast: Option<(String, bool)>,
|
||||
/// Selected codegen target in the inspector's Code & AI Handoff section.
|
||||
pub code_target: String,
|
||||
}
|
||||
|
||||
/// Where a dragged layer lands relative to a row.
|
||||
|
||||
+48
-2
@@ -170,6 +170,19 @@ impl Web {
|
||||
Editor::default()
|
||||
};
|
||||
ed.clipboard_io = Some(Box::new(dom::WebClipboard(mirror.clone())));
|
||||
let mut session = Session::new(store);
|
||||
// A share link (`/?share=<token>`) opens the shared project; joining twice is a no-op.
|
||||
let loc = window().location();
|
||||
if let (Ok(search), Ok(origin)) = (loc.search(), loc.origin())
|
||||
&& let Some(token) = search.trim_start_matches('?').split('&').find_map(|kv| kv.strip_prefix("share="))
|
||||
&& let Err(e) = session.join_shared(&format!("{origin}/?share={token}"))
|
||||
{
|
||||
log::warn!("share link: {e}");
|
||||
}
|
||||
// A relay started with --library keeps every project for every device that opens it.
|
||||
if !demo && let Ok(origin) = loc.origin() {
|
||||
session.follow_library(&origin);
|
||||
}
|
||||
Ok(Web {
|
||||
painter: Painter::new(&device, format),
|
||||
canvas,
|
||||
@@ -180,7 +193,7 @@ impl Web {
|
||||
ctx,
|
||||
ed,
|
||||
st,
|
||||
session: Session::new(store),
|
||||
session,
|
||||
events: vec![],
|
||||
mods: Modifiers::NONE,
|
||||
pointer: None,
|
||||
@@ -447,9 +460,30 @@ impl Web {
|
||||
}
|
||||
}
|
||||
|
||||
/// The server this page came from: where "Share…" puts a project unless told otherwise.
|
||||
fn default_server(&self) -> String {
|
||||
window().location().origin().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Look at the shared projects' sync engines (about once a second).
|
||||
fn tick_sync(&mut self, now: f64) {
|
||||
if self.session.sync_poll(&mut self.ed, web_time::Instant::now()) {
|
||||
if self.st.home.is_some() {
|
||||
self.show_home();
|
||||
}
|
||||
self.dirty = true;
|
||||
}
|
||||
if self.session.sync_deadline().is_some() {
|
||||
self.wake_at(now + 1000.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn show_home(&mut self) {
|
||||
let mut hv = self.st.home.take().unwrap_or_default();
|
||||
hv.refresh(&self.session.store, crate::project::now_ms());
|
||||
hv.shared = self.session.shared_info();
|
||||
hv.default_server = self.default_server();
|
||||
hv.library = self.session.sync.library_active();
|
||||
self.st.home = Some(hv);
|
||||
self.st.project_name.clear();
|
||||
self.st.save_state.clear();
|
||||
@@ -503,7 +537,18 @@ impl Web {
|
||||
HomeAction::Duplicate(dir) => {
|
||||
store.duplicate(&dir).map(|_| ()).map_err(|e| e.to_string())
|
||||
}
|
||||
HomeAction::Trash(dir) => store.trash(&dir).map(|_| ()).map_err(|e| e.to_string()),
|
||||
HomeAction::Trash(dir) => {
|
||||
// A trashed project leaves the share (and a library); restoring it brings
|
||||
// back a local copy.
|
||||
self.session.sync.drop_project(&dir);
|
||||
store.trash(&dir).map(|_| ()).map_err(|e| e.to_string())
|
||||
}
|
||||
HomeAction::Share(dir, server) => self.session.share_project(&dir, &server),
|
||||
HomeAction::Join(link) => self.session.join_shared(&link),
|
||||
HomeAction::Unshare(dir) => {
|
||||
self.session.sync.unshare(&dir);
|
||||
Ok(())
|
||||
}
|
||||
HomeAction::Restore(dir) => {
|
||||
store.restore(&dir).map(|_| ()).map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -672,6 +717,7 @@ impl Web {
|
||||
self.handle_requests();
|
||||
self.handle_home_actions();
|
||||
self.handle_agent_actions(now);
|
||||
self.tick_sync(now);
|
||||
if self.st.library.dirty {
|
||||
self.st.library.dirty = false;
|
||||
if let Err(e) = self.st.library.library.save() {
|
||||
|
||||
+123
@@ -5,6 +5,7 @@
|
||||
|
||||
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};
|
||||
@@ -194,6 +195,18 @@ async fn bad_tokens_browsers_and_unknown_routes_are_refused() {
|
||||
.unwrap();
|
||||
assert_eq!(r.status(), 403);
|
||||
|
||||
// A public hostname in Host is fine: the token is the credential.
|
||||
let r = http
|
||||
.post(format!("{base}/s/{good}/mcp"))
|
||||
.header("content-type", "application/json")
|
||||
.header("accept", "application/json, text/event-stream")
|
||||
.header("host", "papercraft.example.com")
|
||||
.body(init)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_ne!(r.status(), 403);
|
||||
|
||||
// Tokens are 32..=64 lowercase hex characters; anything else is a plain 404.
|
||||
for (label, bad) in [
|
||||
("uppercase", good.to_uppercase()),
|
||||
@@ -294,3 +307,113 @@ async fn static_files_are_served_with_their_type() {
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
running.stop();
|
||||
}
|
||||
|
||||
/// Shared projects: files round-trip, bad paths are refused, and a second tab hears about the
|
||||
/// first tab's changes (but a tab does not hear its own).
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn shared_projects_store_files_and_tell_the_other_tabs() {
|
||||
let dir = std::env::temp_dir().join(format!("papercraft-data-{:016x}", fastrand::u64(..)));
|
||||
let listen: SocketAddr = "127.0.0.1:0".parse().expect("a valid address");
|
||||
let running =
|
||||
relay::spawn_with_data(listen, None, None, Some(Data { dir: dir.clone(), library: false })).expect("the relay binds");
|
||||
let port = running.port();
|
||||
let base = format!("http://127.0.0.1:{port}");
|
||||
let http = reqwest::Client::new();
|
||||
let t = token();
|
||||
|
||||
// Unknown project: nothing to list.
|
||||
let r = http
|
||||
.get(format!("{base}/p/{t}/files"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(r.status(), 404);
|
||||
|
||||
// Tab B listens before A writes.
|
||||
let url = format!("ws://127.0.0.1:{port}/p/{t}/ws?client=b");
|
||||
let (tx, rx) = std::sync::mpsc::channel::<String>();
|
||||
std::thread::spawn(move || {
|
||||
let (mut ws, _) = ws_connect(url).expect("connect");
|
||||
while let Ok(Ws::Text(m)) = ws.read() {
|
||||
tx.send(m.to_string()).ok();
|
||||
}
|
||||
});
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
|
||||
let put = |path: &str, client: &str| {
|
||||
http.put(format!("{base}/p/{t}/f/{path}"))
|
||||
.header("x-client", client)
|
||||
.body("hello")
|
||||
.send()
|
||||
};
|
||||
assert_eq!(put("pages/p1.json", "a").await.unwrap().status(), 200);
|
||||
let ev: Value = serde_json::from_str(&rx.recv_timeout(CONNECT_WAIT).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
(ev["op"].as_str(), ev["path"].as_str()),
|
||||
(Some("put"), Some("pages/p1.json"))
|
||||
);
|
||||
|
||||
// B's own writes are not echoed back to B.
|
||||
assert_eq!(put("project.json", "b").await.unwrap().status(), 200);
|
||||
assert!(rx.recv_timeout(Duration::from_millis(300)).is_err());
|
||||
|
||||
let r = http
|
||||
.get(format!("{base}/p/{t}/f/pages/p1.json"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(r.text().await.unwrap(), "hello");
|
||||
let files: Value = http
|
||||
.get(format!("{base}/p/{t}/files"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(files.as_array().unwrap().len(), 2);
|
||||
|
||||
let r = http
|
||||
.delete(format!("{base}/p/{t}/f/pages/p1.json"))
|
||||
.header("x-client", "a")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(r.status(), 204);
|
||||
let ev: Value = serde_json::from_str(&rx.recv_timeout(CONNECT_WAIT).unwrap()).unwrap();
|
||||
assert_eq!(ev["op"], "del");
|
||||
let r = http
|
||||
.get(format!("{base}/p/{t}/f/pages/p1.json"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(r.status(), 404);
|
||||
|
||||
// Traversal and bad tokens are plain 404s.
|
||||
for url in [
|
||||
format!("{base}/p/{t}/f/%2e%2e/escape"),
|
||||
format!("{base}/p/{t}/f/a/%2e%2e/%2e%2e/escape"),
|
||||
format!("{base}/p/ZZ/f/project.json"),
|
||||
] {
|
||||
let r = http.put(&url).body("x").send().await.unwrap();
|
||||
assert_eq!(r.status(), 404, "{url}");
|
||||
}
|
||||
assert!(!dir.join("escape").exists());
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
running.stop();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn shared_project_routes_are_off_without_a_data_dir() {
|
||||
let running = spawn_relay();
|
||||
let base = format!("http://127.0.0.1:{}", running.port());
|
||||
let r = reqwest::Client::new()
|
||||
.put(format!("{base}/p/{}/f/project.json", token()))
|
||||
.body("x")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(r.status(), 404);
|
||||
running.stop();
|
||||
}
|
||||
|
||||
+170
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user