Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 40dcc81ad2 | |||
| 5169fe6342 | |||
| 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.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"axum",
|
||||
@@ -3091,15 +3196,19 @@ dependencies = [
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-tls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
@@ -3260,12 +3369,27 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||
dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
@@ -3275,6 +3399,15 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "1.2.2"
|
||||
@@ -3326,6 +3459,29 @@ dependencies = [
|
||||
"tiny-skia 0.11.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "self_cell"
|
||||
version = "1.3.0"
|
||||
@@ -3392,6 +3548,17 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_path_to_error"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.1.2"
|
||||
@@ -3401,6 +3568,18 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.7"
|
||||
@@ -3673,6 +3852,19 @@ dependencies = [
|
||||
"syn 3.0.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix 1.1.5",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "termcolor"
|
||||
version = "1.4.1"
|
||||
@@ -3830,6 +4022,16 @@ dependencies = [
|
||||
"syn 3.0.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-native-tls"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
||||
dependencies = [
|
||||
"native-tls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-stream"
|
||||
version = "0.1.19"
|
||||
@@ -4143,6 +4345,12 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "vello_common"
|
||||
version = "0.1.0"
|
||||
@@ -4938,7 +5146,7 @@ dependencies = [
|
||||
"calloop 0.13.0",
|
||||
"cfg_aliases",
|
||||
"concurrent-queue",
|
||||
"core-foundation",
|
||||
"core-foundation 0.9.4",
|
||||
"core-graphics",
|
||||
"cursor-icon",
|
||||
"dpi",
|
||||
@@ -5139,6 +5347,12 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.5"
|
||||
|
||||
+5
-3
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "papercraft"
|
||||
version = "0.2.0"
|
||||
version = "0.3.1"
|
||||
edition = "2024"
|
||||
rust-version = "1.95"
|
||||
description = "A native infinite-canvas app for planning, working and sketching"
|
||||
@@ -88,8 +88,10 @@ winit = "0.30"
|
||||
arboard = { version = "3.6.1", features = ["image-data"] }
|
||||
rmcp = { version = "3.5.1", default-features = false, features = ["server", "base64", "transport-streamable-http-server", "transport-io", "client", "transport-streamable-http-client-reqwest"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "macros", "time", "io-std", "fs"] }
|
||||
axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "ws"] }
|
||||
axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "ws", "json", "query"] }
|
||||
tokio-util = "0.7"
|
||||
# Shared-project sync; the system TLS stack so https servers work without bundling one.
|
||||
reqwest = { version = "0.13", default-features = false, features = ["native-tls"] }
|
||||
|
||||
# Browser build (`src/web.rs`): canvas + WebGPU/WebGL surface, DOM events instead of winit.
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
@@ -99,4 +101,4 @@ wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
console_error_panic_hook = "0.1"
|
||||
console_log = "1"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "KeyboardEvent", "PointerEvent", "WheelEvent", "ClipboardEvent", "DataTransfer", "MediaQueryList", "Location", "Performance", "Navigator", "Clipboard", "CssStyleDeclaration", "console", "WebSocket", "MessageEvent", "CloseEvent", "ErrorEvent", "Storage", "Crypto", "DragEvent", "FileList", "File", "Blob"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "KeyboardEvent", "PointerEvent", "WheelEvent", "ClipboardEvent", "DataTransfer", "MediaQueryList", "Location", "Performance", "Navigator", "Clipboard", "CssStyleDeclaration", "console", "WebSocket", "MessageEvent", "CloseEvent", "ErrorEvent", "Storage", "Crypto", "DragEvent", "FileList", "File", "Blob", "Request", "RequestInit", "Response"] }
|
||||
|
||||
+14
-14
@@ -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. |
|
||||
|
||||
|
||||
@@ -17,7 +17,10 @@ fn main() {
|
||||
.set("ProductName", "PaperCraft")
|
||||
.set("FileDescription", "PaperCraft infinite canvas")
|
||||
.set("CompanyName", "PaperCraft Authors")
|
||||
.set("LegalCopyright", "Copyright (c) the PaperCraft authors. MIT OR Apache-2.0.")
|
||||
.set(
|
||||
"LegalCopyright",
|
||||
"Copyright (c) the PaperCraft authors. MIT OR Apache-2.0.",
|
||||
)
|
||||
.set("OriginalFilename", "papercraft.exe")
|
||||
.set("InternalName", "papercraft");
|
||||
if let Err(e) = res.compile() {
|
||||
|
||||
@@ -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
|
||||
+27
-16
@@ -23,9 +23,9 @@ papercraft-cli call get_basic_info # fails with exit 1 if the app is not ru
|
||||
## 2. The loop: build, look, lint, fix
|
||||
|
||||
1. **Tokens first.** `set_variables` once (roles: `bg`, `surface`, `border`, `text`, `text-muted`, `accent`, `on-accent`, `danger`). Add a second mode for dark or light. No hex in the design afterwards.
|
||||
2. **Components first.** Anything that appears twice (button, field, chip, row, tab bar) is built once as an artboard, `create_component`, then placed with `create_instance`.
|
||||
2. **Components first.** Anything that appears twice (button, field, chip, row, tab bar) is built once as an artboard, `create_component`, then placed with `create_instance` or `<x-paper-instance component="Name" component-id="ID"/>`.
|
||||
3. **Build in small steps.** `create_artboard`, then one `write_html` per visual group (header, card, row) so the user sees progress. Name every layer with `layer-name`.
|
||||
4. **Look.** `get_screenshot` of the artboard after each group or two. Scale 2 to read small text. Read it as a designer: hierarchy, alignment, spacing rhythm, clipping, wrapping.
|
||||
4. **Look.** `get_screenshot` of the artboard after each group or two (pass `out` or `-o`/`--out FILE` in `papercraft-cli` to choose the output path). Scale 2 to read small text. Read it as a designer: hierarchy, alignment, spacing rhythm, clipping, wrapping.
|
||||
5. **Lint.** `lint_design` on the artboard. Fix every error and warning; consider the `info` items. Run it again until errors and warnings are zero.
|
||||
6. **Fix with the smallest edit.** `update_styles` and `set_text_content` on the existing nodes, not a rewrite. Rewrite a group with `write_html` mode `replace` only when its structure is wrong.
|
||||
7. **Finish.** `finish_working_on_nodes` (saves). Tell the user what you made in a sentence or two, with artboard names, not ids.
|
||||
@@ -56,7 +56,7 @@ Lint passes are necessary, not sufficient: contrast and grids say nothing about
|
||||
<div layer-name="Nav / Home" style="display:flex;align-items:center;gap:12px"><x-paper-icon name="phosphor:house" size="20" color="var(--text-muted)"/><div layer-name="Label" style="font-size:16px;color:var(--text-muted)">Home</div></div>
|
||||
```
|
||||
|
||||
Artboards: mobile 390×844, tablet 768×1024, desktop 1440×900. Create with `height:"fit-content"` when the content decides the height. Replace margin with `gap` and `padding`.
|
||||
Artboards: mobile 390×844, tablet 768×1024, desktop 1440×900. Create with `height:"fit-content"` when the content decides the height. Replace margin with `gap` and `padding`. `min-width`, `max-width`, `min-height`, `max-height`, `align-self` (`start`, `center`, `end`, `stretch`), per-side borders (`border-top`, `border-right`, `border-bottom`, `border-left`), and `text-decoration-color` / `wavy` (`text-decoration: underline wavy var(--warning)`) are also supported.
|
||||
|
||||
Full flows: [recipes.md](references/recipes.md).
|
||||
|
||||
@@ -66,33 +66,44 @@ Full flows: [recipes.md](references/recipes.md).
|
||||
|---|---|
|
||||
| Know the project, pages, artboards | `get_basic_info`, `get_tree_summary`, `get_children` |
|
||||
| Know what is selected | `get_selection` |
|
||||
| Inspect one node | `get_node_info`, `get_computed_styles`, `get_jsx` |
|
||||
| Inspect one node or export code | `get_node_info`, `get_computed_styles`, `get_jsx` (`inline` \| `tailwind` \| `react` \| `angular` \| `html` \| `egui`) |
|
||||
| Extract AI design-to-code spec | `get_design_spec` (`nodeId`, `target`: `react` \| `tailwind` \| `angular` \| `html` \| `egui`) |
|
||||
| Verify generated code against design | `verify_code` (`nodeId`, `target`, `code`) |
|
||||
| Compare two frames or subtrees | `diff_frames` (`beforeId`, `afterId`) |
|
||||
| Find nodes by text or style | `find_nodes` |
|
||||
| See it | `get_screenshot` (`nodeId`, `scale` 1 or 2) |
|
||||
| See it | `get_screenshot` (`nodeId`, `scale` 1 or 2, `out` / `-o\|--out FILE`) |
|
||||
| New screen | `create_artboard` (`width` and `height` required) |
|
||||
| Add content | `write_html` (`insert-children` or `replace`) |
|
||||
| Change styles or text | `update_styles`, `set_text_content`, `rename_nodes` |
|
||||
| Whiteboard shapes, sticky notes, arrows | `create_shape` |
|
||||
| Copy, move, delete | `duplicate_nodes`, `move_nodes`, `delete_nodes` |
|
||||
| Tokens | `get_variables` (`format` css or json), `set_variables` (`mode`), `set_variable_mode` |
|
||||
| Reuse | `create_component`, `create_instance`, `get_components`, `<x-paper-instance>` |
|
||||
| Copy, move, delete (including across pages) | `duplicate_nodes` (`toPageId`), `move_nodes` (`toPageId`), `<x-paper-clone node-id="<page>:<id>"/>`, `delete_nodes` |
|
||||
| Tokens | `get_variables` (`format`: `css` \| `json` \| `tailwind`), `set_variables` (`mode`), `set_tokens`, `set_variable_mode` |
|
||||
| Reuse & components | `create_component`, `create_instance`, `swap_instance`, `detach_instance`, `import_components`, `get_components`, `<x-paper-instance component="Name" component-id="ID"/>` |
|
||||
| Icons | `search_icons`, `<x-paper-icon name="set:name">`; CLI: `papercraft-cli icons add\|list\|search` |
|
||||
| Check quality | `lint_design` |
|
||||
| Check & auto-fix quality | `lint_design` (`fix: true` to auto-fix grid/scale/variables/targets) |
|
||||
| Export | `export` (PNG, SVG, PDF to files), `export_combined_pdf` |
|
||||
| Point at things for the user | `select_nodes` (`zoom: true`) |
|
||||
| Take back | `undo`, `redo` |
|
||||
| Save | `finish_working_on_nodes` |
|
||||
|
||||
## Design-to-Code Handoff & Enforcement (React, Angular, HTML/CSS, Rust egui)
|
||||
|
||||
When implementing a PaperCraft design in production code (React/TSX, Tailwind JSX, Angular standalone components, HTML + CSS, or Rust `egui`), follow this mandatory three-step protocol:
|
||||
|
||||
1. **Never implement from a screenshot alone.** Always call `get_design_spec` (`nodeId`, `target: "react" | "tailwind" | "angular" | "html" | "egui"`) to extract the structured layout tree, exact auto-layout flow/sizing (`fixed`, `hug`, `fill`), design tokens (`--var`), component instances, and reference code.
|
||||
2. **Write the target code** following the spec's layout flow (Flexbox/Grid for web; `egui::Frame` + `ui.horizontal`/`ui.vertical` + `tokens` module for Rust `egui`), binding all color variables instead of hardcoding hex values, and never using `position: absolute` on flow children.
|
||||
3. **Enforce with `verify_code`**: Pass the written code to `verify_code({nodeId, target, code})`. Fix any reported `missing-text`, `hardcoded-color`, `forbidden-absolute`, `missing-layout`, `missing-component`, or `spacing-drift` violations until `passed: true` (`score: 100`).
|
||||
|
||||
## 6. Known limits (design around them)
|
||||
|
||||
* **Font is Inter only**, regular and bold plus italic. No other families, no weights between. Build hierarchy from size, weight, color and space.
|
||||
* **Variables are colors only**, for fills and text color. Strokes and gradients keep fixed colors. Spacing, radius and type sizes are plain numbers: keep them on the scale by discipline and `lint_design`.
|
||||
* **No margin, grid or tables; no shadows, filters or transforms** (reported under `ignoredStyles`). Fake elevation with a border or a lighter surface.
|
||||
* **Color variables (`set_variables`) apply to fills and text color**; strokes and gradients keep fixed colors. Non-color tokens (`set_tokens`) provide shared spacing, radius, and shadow values expanded on write; `lint_design` (`fix: true`) enforces scale and 4px grid discipline.
|
||||
* **No CSS margin, grid or tables in `write_html`; no CSS filters or transforms** (reported under `ignoredStyles`). Replace margin with `gap` and `padding`.
|
||||
* **No rotation.** Nodes are axis-aligned.
|
||||
* **Components and their instances live on one page.** Put shared components on the page where they are used, or duplicate the component per page.
|
||||
* **No variants or properties**: one component per variant (`Button / Primary`, `Button / Secondary`). No swap or detach tool.
|
||||
* **Components and their instances live on one page** (use `import_components` to copy main components from anywhere on another page, including inside artboards, then instantiate with `<x-paper-instance component="Name"/>` or `component-id="ID"`). Variants are modeled as separate components (`Button / Primary`, `Button / Secondary`) and can be swapped in place with `swap_instance` (preserving overrides) or unlinked with `detach_instance`.
|
||||
* **Supported imports, diffs, and code handoff**: `.fig` file import is supported, `diff_frames` compares two frames/subtrees at the property level, `get_jsx` exports `inline`, `tailwind`, `react`, `angular`, `html`, or `egui`, and `get_design_spec` + `verify_code` provide full AI design-to-code handoff and enforcement.
|
||||
* **Remote images are not fetched.** Use a local path or `data:` URI.
|
||||
* **One project open at a time**; moving layers between pages is not supported.
|
||||
* **One project open at a time**; copying and moving layers between pages is supported via `duplicate_nodes` / `move_nodes` (`toPageId`) and cross-page `<x-paper-clone node-id="<page>:<id>"/>`.
|
||||
* **Text boxes with fixed widths can wrap earlier than in a browser.** Check headlines in the screenshot and widen.
|
||||
* **Instances are skipped by lint**; lint the main component.
|
||||
|
||||
@@ -102,10 +113,10 @@ Full flows: [recipes.md](references/recipes.md).
|
||||
|---|---|
|
||||
| `papercraft-cli` exits 1, "isn't running" | The app is closed, no project is open, or the port differs. Start it and open a project; check `PAPERCRAFT_MCP_PORT`. |
|
||||
| Call fails with an unknown node id | The id is from another page or was deleted. Re-read with `get_children`; pass `pageId`. |
|
||||
| `write_html` reports `ignoredStyles` | You used a CSS property that has no equivalent (`margin`, `box-shadow`, `transform`...). Replace it, see [html-subset.md](references/html-subset.md). |
|
||||
| `write_html` reports `ignoredStyles` | You used a CSS property that has no equivalent (`margin`, `transform`...). Replace it, see [html-subset.md](references/html-subset.md). |
|
||||
| Content is cut off | The artboard height is fixed. `update_styles` with `height: "fit-content"` or a larger number. |
|
||||
| A color did not change with the theme | It was written as hex, or it is a stroke or gradient. Use `var(--name)` on fills and text; strokes need `update_styles` after a mode switch. |
|
||||
| `create_instance` fails | The component is on another page. Instances must be on the component's page. |
|
||||
| `create_instance` fails | The component is on another page. Instances must be on the component's page (or run `import_components` first). |
|
||||
| Icon does not appear | The set is not installed: `papercraft-cli icons add phosphor`, then `papercraft-cli icons search <words>` for the exact `set:name`. |
|
||||
| Layer panel is a pile of `Frame` | You skipped `layer-name`. Fix with `rename_nodes` (batch) before finishing. |
|
||||
| Text looks tiny in the screenshot | Screenshots default to scale 1. Use `scale: 2`. |
|
||||
|
||||
@@ -20,30 +20,30 @@
|
||||
| `<img src>` | an image fill. `src` is an absolute path, a `file://` URL, `paper-asset:///abs/path` or a `data:` URI. `http(s)` is not fetched |
|
||||
| `<svg>...</svg>` | vector layers (paths, gradients, strokes) |
|
||||
| `<x-paper-icon name="set:name" size="20" color="var(--x)"/>` | an icon from an installed set (`phosphor:gear`) |
|
||||
| `<x-paper-instance component-id="ID"/>` | an instance of a component |
|
||||
| `<x-paper-clone node-id="ID" style="..."/>` | a deep copy of an existing node |
|
||||
| `<x-paper-instance component="Name" component-id="ID" overrides='{"Label":"Save"}'/>` | an instance of a component (lookup by `component="Name"` or `component-id="ID"`) |
|
||||
| `<x-paper-clone node-id="ID" style="..."/>` | a deep copy of an existing node (supports cross-page `<page>:<id>`) |
|
||||
| `layer-name="..."` on any of the above | the layer's name (names are truncated to 50 characters) |
|
||||
|
||||
## Layout
|
||||
|
||||
Supported: `display:flex` (and `inline-flex`), `flex-direction`, `flex-wrap`, `gap`, `row-gap`, `column-gap`, `padding` (shorthand, per side, `padding-inline`, `padding-block`), `align-items`, `justify-content` (including `space-between`), `align-self`, `flex-grow` / `flex: 1`, `width`, `height` (px, %, `fit-content`), `min-`/`max-` sizes (accepted), `overflow:hidden` (clips), `position:absolute` with `left`/`top`/`right`/`bottom`, `display:none`, `visibility`.
|
||||
Supported: `display:flex` (and `inline-flex`), `flex-direction`, `flex-wrap`, `gap`, `row-gap`, `column-gap`, `padding` (shorthand, per side, `padding-inline`, `padding-block`), `align-items`, `justify-content` (including `space-between`), `align-self` (`start`, `center`, `end`, `stretch`), `flex-grow` / `flex: 1`, `width`, `height` (px, %, `fit-content`), `min-width`, `max-width`, `min-height`, `max-height`, `overflow:hidden` (clips), `position:absolute` with `left`/`top`/`right`/`bottom`, `display:none`, `visibility`.
|
||||
|
||||
Not supported (check `ignoredStyles` to be sure): `margin` (use `gap` and `padding`), `display:grid`, tables, `float`, `calc()`, viewport units, `transform` and rotation.
|
||||
|
||||
Tips:
|
||||
|
||||
* Artboards are `display:flex; flex-direction:column`. Children of a column stretch to the full width unless they have a width or `align-items` says otherwise.
|
||||
* Artboards are `display:flex; flex-direction:column`. Children of a column stretch to the full width unless they have a width or `align-items` / `align-self` says otherwise.
|
||||
* To center something, put it in a flex parent with `align-items:center; justify-content:center`.
|
||||
* To make a row fill the remaining space, give the child `flex-grow:1`.
|
||||
* Use `height:"fit-content"` on artboards that should grow with content; fixed heights clip.
|
||||
|
||||
## Text
|
||||
|
||||
Supported: `font-size` (px), `font-weight` (600 and above renders bold; otherwise regular), `font-style:italic`, `color`, `line-height` (px or unitless), `letter-spacing`, `text-align`, `text-decoration`. The font is always **Inter**; `font-family` is ignored, and there are only regular and bold weights (plus italic), so hierarchy comes from size, weight, color and space.
|
||||
Supported: `font-size` (px), `font-weight` (600 and above renders bold; otherwise regular), `font-style:italic`, `color`, `line-height` (px or unitless), `letter-spacing`, `text-align`, `text-decoration` (including `text-decoration-color` and `text-decoration-style: wavy`, e.g. `text-decoration: underline wavy var(--warning)`). The font is always **Inter**; `font-family` is ignored, and there are only regular and bold weights (plus italic), so hierarchy comes from size, weight, color and space.
|
||||
|
||||
## Boxes
|
||||
|
||||
Supported: `background` and `background-color` (hex, `rgb()`, `hsl()`, `var(--name)`), gradients (`linear-gradient`, `radial-gradient`), `background-image: url()`, `background-size` (`contain`, `100% 100%`), `border` (width, style, color; one stroke per node), `border-radius` (shorthand and per corner; `999px` makes a pill), `opacity`, `mix-blend-mode`. Strokes and gradients keep their resolved colors; they do not follow variables later.
|
||||
Supported: `background` and `background-color` (hex, `rgb()`, `hsl()`, `var(--name)`), gradients (`linear-gradient`, `radial-gradient`), `background-image: url()`, `background-size` (`contain`, `100% 100%`), `border` and per-side `border-top`, `border-right`, `border-bottom`, `border-left` (width, style, color), `border-radius` (shorthand and per corner; `999px` makes a pill), `opacity`, `mix-blend-mode`. Strokes and gradients keep their resolved colors; they do not follow variables later.
|
||||
|
||||
Not supported (reported as ignored): `box-shadow`, `filter`, `backdrop-filter`, `text-shadow`, `outline-offset`, CSS animations and transitions.
|
||||
|
||||
|
||||
@@ -99,15 +99,49 @@ Compact indented text tree of a subtree: type, name, id and size per node. Much
|
||||
|
||||
### `get_jsx` (read-only)
|
||||
|
||||
JSX for a node and its descendants with inline styles (frames become flex divs, text becomes spans, vectors become inline SVG). Use it to export a design to code.
|
||||
Export a node and its descendants to code: `"inline"` (default, React JSX with inline styles), `"tailwind"` (React JSX with Tailwind CSS utility classes), `"react"` (typed React TSX component with Tailwind classes), `"angular"` (Angular standalone `@Component` with template and CSS), `"html"` (self-contained `<style>` + HTML markup), or `"egui"` (Rust `egui` layout code with a `tokens` module).
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `format` | inline-styles/tailwind | | Only inline styles are produced; "tailwind" is accepted for compatibility. |
|
||||
| `format` | `inline` \| `inline-styles` \| `tailwind` \| `react` \| `angular` \| `html` \| `egui` | | Output format: `"inline"` (default), `"tailwind"`, `"react"`, `"angular"`, `"html"`, or `"egui"`. |
|
||||
| `nodeId` | string | yes | The node to convert. |
|
||||
|
||||
|
||||
### `get_design_spec` (read-only)
|
||||
|
||||
Extract a comprehensive AI design-to-code specification (`ai_spec_markdown` and structured JSON `spec`) for a node and its descendants. Includes the hierarchy, exact auto-layout flow/sizing (`fixed`, `hug`, `fill`), bound design tokens (`--var`), component instances, text content, implementation rules, and reference code for the chosen `target`. Always call this before implementing a design in code.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `nodeId` | string | yes | The root frame or node to extract the spec for. |
|
||||
| `target` | `react` \| `tailwind` \| `angular` \| `html` \| `egui` | | Target framework/format (defaults to `"react"`). |
|
||||
|
||||
|
||||
### `verify_code` (read-only)
|
||||
|
||||
Enforce design-to-code fidelity by checking written `code` against the source PaperCraft node (`nodeId`) for the given `target`. Reports a `score` (0–100), `passed` boolean, and structured violations (`missing-text`, `hardcoded-color`, `forbidden-absolute`, `missing-layout`, `missing-component`, `spacing-drift`) with actionable fixes.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `code` | string | yes | The generated or hand-written implementation code to verify. |
|
||||
| `nodeId` | string | yes | The PaperCraft node id the code implements. |
|
||||
| `target` | `react` \| `tailwind` \| `angular` \| `html` \| `egui` | yes | Target language/framework of `code`. |
|
||||
|
||||
|
||||
### `diff_frames` (read-only)
|
||||
|
||||
Compare two frames or subtrees (`beforeId` and `afterId`) and report property-level differences (size, position, fills, stroke, corner radius, opacity, text, layout, added/removed children).
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `beforeId` | string | yes | First frame or node id (baseline). |
|
||||
| `afterId` | string | yes | Second frame or node id (comparison). |
|
||||
|
||||
|
||||
### `get_computed_styles` (read-only)
|
||||
|
||||
CSS styles (camelCase) of one or more nodes, as {nodeId: styles}.
|
||||
@@ -165,12 +199,13 @@ Read a guide. Topics: "papercraft-mcp-instructions" (read first), "html" (what w
|
||||
|
||||
### `get_screenshot` (read-only)
|
||||
|
||||
Render a node (or the whole page when nodeId is omitted or "root") to a PNG and return it as an image. Scale 1 (default) is enough for layout checks; use 2 to read small text. Large images are scaled down to fit API limits.
|
||||
Render a node (or the whole page when nodeId is omitted or "root") to a PNG and return it as an image. Scale 1 (default) is enough for layout checks; use 2 to read small text. Large images are scaled down to fit API limits. In `papercraft-cli`, pass `-o`/`--out FILE` or `{"out": "FILE"}` to choose the output file path.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `nodeId` | string | | The node to capture; omit for the whole page. |
|
||||
| `out` | string | | Optional file path to save the PNG to (also used by `papercraft-cli`). |
|
||||
| `scale` | number | | Render scale: 1 (default) or 2. |
|
||||
|
||||
|
||||
@@ -214,12 +249,12 @@ Creates an artboard (top-level frame) and returns its id; then add content with
|
||||
|
||||
Parse HTML with inline styles into layers. Write incrementally: each call should add one visual group (a header, one list row, a card) so the user sees progress.
|
||||
- mode "insert-children" appends the HTML inside targetNodeId ("root" = the page); "replace" replaces targetNodeId with it.
|
||||
- Inline styles only. Flexbox (display:flex, flex-direction, gap, padding, align-items, justify-content, flex-grow, flex-wrap) is the layout model; block elements stack vertically. position:absolute with left/top is supported. No margin, grid or tables.
|
||||
- Text: font-size, font-weight (≥600 = bold), font-style, color, line-height, letter-spacing, text-align, text-decoration; <b>/<i>/<u>/<s>/<span style> make rich text. Font family is always Inter.
|
||||
- Boxes: background(-color/-image: colors, linear/radial gradients, url()), border, border-radius, opacity, overflow:hidden, mix-blend-mode, width/height (px, %, fit-content).
|
||||
- Inline styles only. Flexbox (display:flex, flex-direction, gap, padding, align-items, justify-content, align-self, flex-grow, flex-wrap) is the layout model; block elements stack vertically. position:absolute with left/top is supported. No margin, grid or tables.
|
||||
- Text: font-size, font-weight (≥600 = bold), font-style, color, line-height, letter-spacing, text-align, text-decoration (including `wavy` and `text-decoration-color`); <b>/<i>/<u>/<s>/<span style> make rich text. Font family is always Inter.
|
||||
- Boxes: background(-color/-image: colors, linear/radial gradients, url()), border (and border-top/right/bottom/left), border-radius, opacity, overflow:hidden, mix-blend-mode, width/height (px, %, fit-content), min-width/max-width/min-height/max-height.
|
||||
- <img src> takes paper-asset:///abs/path, file:// URLs, absolute paths or data: URIs. <svg> markup is imported as vector layers.
|
||||
- <x-paper-icon name="set:name" size="20" color="var(--accent)"/> places an installed icon (search_icons); <x-paper-instance component-id="ID" layer-name="..."/> places a component instance; colors may be var(--name) of set_variables.
|
||||
- layer-name="..." names layers; <x-paper-clone node-id="ID" style="..."/> clones an existing node.
|
||||
- <x-paper-icon name="set:name" size="20" color="var(--accent)"/> places an installed icon (search_icons); <x-paper-instance component="Name" component-id="ID" layer-name="..."/> places a component instance; colors may be var(--name) of set_variables.
|
||||
- layer-name="..." names layers; <x-paper-clone node-id="ID" style="..."/> clones an existing node (supports cross-page `<page>:<id>`).
|
||||
Returns the ids of the created top-level layers and the styles that were ignored.
|
||||
|
||||
|
||||
@@ -272,22 +307,22 @@ Delete nodes and their descendants. Verify parents with get_node_info first when
|
||||
|
||||
### `duplicate_nodes`
|
||||
|
||||
Deep-clone nodes. Each copy goes under parentId when given, else next to its source; duplicated artboards are placed in an empty spot. Returns new ids and a descendantIdMap from every original descendant id to its copy.
|
||||
Deep-clone nodes, including across pages (via `toPageId` or a cross-page `parentId`; `<x-paper-clone node-id="<page>:<id>"/>` also works across pages). Each copy goes under parentId when given, else next to its source; duplicated artboards are placed in an empty spot. Returns new ids and a descendantIdMap from every original descendant id to its copy.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `nodes` | array of object | yes | |
|
||||
| `nodes` | array of object | yes | Items accept `{id, parentId?, toPageId?}`. |
|
||||
|
||||
|
||||
### `move_nodes`
|
||||
|
||||
Move nodes keeping their ids. Each move is {nodeId, before: siblingId}, {nodeId, after: siblingId} or {nodeId, parentId, index?} (parentId "root" = the page root). Moves apply in order. In auto-layout parents this changes the visual order; in free parents it changes stacking and keeps the world position.
|
||||
Move nodes keeping their ids (or onto another page via `toPageId` or a cross-page `parentId` / `before` / `after`). Each move is {nodeId, before: siblingId}, {nodeId, after: siblingId} or {nodeId, parentId, toPageId?, index?} (parentId "root" = the page root). Moves apply in order. In auto-layout parents this changes the visual order; in free parents it changes stacking and keeps the world position.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `moves` | array of object | yes | |
|
||||
| `moves` | array of object | yes | Items accept `{nodeId, before?, after?, parentId?, toPageId?, index?}`. |
|
||||
|
||||
|
||||
### `create_shape`
|
||||
@@ -320,12 +355,12 @@ Create a whiteboard element: rectangle, ellipse, diamond, triangle, polygon, sta
|
||||
|
||||
### `get_variables` (read-only)
|
||||
|
||||
Color variables of the page's project (name -> hex). Fills that follow a variable were written with `var(--name)`; set_variables moves all of them at once. `format` css returns a `:root { --name: #hex }` block, json a design-tokens object.
|
||||
Color variables and design tokens of the page's project (name -> hex/value). Fills that follow a variable were written with `var(--name)`; set_variables moves all of them at once. `format` `css` returns a `:root { --name: #hex }` block (plus `[data-theme]` rules for modes), `json` returns a W3C DTCG design-tokens object, and `tailwind` returns a Tailwind v4 `@theme` block.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `format` | css/json | | |
|
||||
| `format` | `css` \| `json` \| `tailwind` | | Output format: `css`, `json` (W3C DTCG), or `tailwind` (`@theme`). |
|
||||
|
||||
|
||||
### `set_variables`
|
||||
@@ -354,9 +389,20 @@ Switch the active variable mode (defined with set_variables {mode}): every varia
|
||||
Main components on a page (id, name, size) with how many instances each has.
|
||||
|
||||
|
||||
### `import_components`
|
||||
|
||||
Copy the main components anywhere on `fromPageId` (including inside artboards, typically on a `Design System` page) onto this one, so you can place instances here with `create_instance` or `<x-paper-instance component="Name" component-id="ID"/>`. Components with the same name already here are skipped. Variables and tokens are already shared by every page; components are copies, not live links.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `fromPageId` | string | yes | Source page id. |
|
||||
| `names` | array of string | | Only these components (default all). |
|
||||
|
||||
|
||||
### `create_component`
|
||||
|
||||
Turn a frame or artboard into a main component. Build reusable parts (button, row, chip, tab) once as components, then place them with create_instance or <x-paper-instance>. Components and instances live on one page.
|
||||
Turn a frame or artboard into a main component. Build reusable parts (button, row, chip, tab) once as components, then place them with create_instance or `<x-paper-instance component="Name" component-id="ID"/>`. Components and instances live on one page.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
@@ -380,6 +426,27 @@ Place an instance of a component. Edit text and styles on the instance's descend
|
||||
| `y` | number | | |
|
||||
|
||||
|
||||
### `swap_instance`
|
||||
|
||||
Swap an instance root to point at a different main component on the same page, preserving overrides on descendants matched by name and layer type.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `componentId` | string | yes | The new main component id. |
|
||||
| `nodeId` | string | yes | The instance root node to swap. |
|
||||
|
||||
|
||||
### `detach_instance`
|
||||
|
||||
Detach one or more component instances into plain frames, breaking their link to the main component while keeping their current appearance and children.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `nodeIds` | array of string | yes | Instance root nodes to detach. |
|
||||
|
||||
|
||||
### `search_icons` (read-only)
|
||||
|
||||
Search installed open icon sets (Lucide, Tabler, Phosphor, Heroicons, Material). Returns `set:name` references for <x-paper-icon name="lucide:check" size="20" color="var(--text)"/> in write_html. Without a query it lists installed and installable sets; install with `papercraft-cli icons add lucide`.
|
||||
@@ -394,11 +461,12 @@ Search installed open icon sets (Lucide, Tabler, Phosphor, Heroicons, Material).
|
||||
|
||||
### `lint_design` (read-only)
|
||||
|
||||
Check a page or subtree for common design mistakes and get a fix for each: text contrast (WCAG 4.5:1, 3:1 for large text), text under 12px, font sizes off the type scale, gap/padding off the 4px grid, buttons and tabs under 44px tall, default layer names (Frame, Rectangle), and hard-coded colors that equal a variable. Run it before finish_working_on_nodes and fix errors and warnings; `info` items are suggestions.
|
||||
Check a page or subtree for common design mistakes and get a fix for each: text contrast (WCAG 4.5:1, 3:1 for large text), text under 12px, font sizes off the type scale, gap/padding off the 4px grid, buttons and tabs under 44px tall, default layer names (Frame, Rectangle), and hard-coded colors that equal a variable. Run it before finish_working_on_nodes and fix errors and warnings; `info` items are suggestions. Pass `fix: true` to automatically snap spacing/padding to 4px, relink matching color variables, snap text sizes, and enlarge small touch targets.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `fix` | boolean | | Apply safe automatic fixes before reporting remaining issues. |
|
||||
| `nodeId` | string | | Subtree to check; omit for the whole page. |
|
||||
| `scale` | array of number | | Allowed font sizes. Default 12, 14, 16, 20, 24, 32, 40, 48, 64. |
|
||||
|
||||
@@ -406,6 +474,43 @@ Check a page or subtree for common design mistakes and get a fix for each: text
|
||||
## Session
|
||||
|
||||
|
||||
### `get_feedback` (read-only)
|
||||
|
||||
Notes the user left on layers (the Feedback box in the inspector, shown as amber dots on the canvas): which layer, where it sits, what to change. Omitting `pageId` queries all pages across the project; `openCount` and `resolvedCount` are returned alongside per-page counts in `get_basic_info`. Call it at the start of a session and after each round of edits; fix every open note, then `resolve_feedback`. `get_selection` also includes the selected layers' notes.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `includeResolved` | boolean | | Include resolved notes. |
|
||||
| `nodeId` | string | | Only this layer. |
|
||||
| `pageId` | string | | Omit to query all pages across the project. |
|
||||
|
||||
|
||||
### `add_feedback`
|
||||
|
||||
Leave a note on a layer (author `agent` by default), e.g. a question or a problem you could not fix. The user sees it in the inspector and as a dot on the canvas.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `author` | string | | Defaults to `agent`. |
|
||||
| `nodeId` | string | yes | |
|
||||
| `text` | string | yes | |
|
||||
|
||||
|
||||
### `resolve_feedback`
|
||||
|
||||
Mark notes on a layer resolved, with a short reply saying what you changed. Without `commentId` every open note on the layer is resolved. `delete` removes instead.
|
||||
|
||||
|
||||
| Argument | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `commentId` | number | | |
|
||||
| `delete` | boolean | | |
|
||||
| `nodeId` | string | yes | |
|
||||
| `reply` | string | | |
|
||||
|
||||
|
||||
### `select_nodes`
|
||||
|
||||
Select nodes on the page the user is viewing so they can see what you mean; zoom: true also frames them in the viewport. An empty list clears the selection.
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# Shared projects on the relay
|
||||
|
||||
## Why
|
||||
|
||||
On the web build a project lives in one browser's IndexedDB: it cannot be opened from another
|
||||
device, survives only while the browser keeps its storage, and cannot be edited by two people.
|
||||
The relay already hosts the app, so it can also host the project files.
|
||||
|
||||
## Scope (v1)
|
||||
|
||||
1. **Server store.** `papercraft-relay --data <dir>` keeps shared projects on disk. Without the
|
||||
flag the feature is off and every `/p/` route answers 404.
|
||||
2. **Share a project.** On the home screen a project card's menu has "Share…";
|
||||
the app uploads the project's files and gets a **share link** `https://host/?share=<token>`.
|
||||
3. **Open a shared project.** Opening a share link downloads the project into the tab's vfs and
|
||||
remembers the token, so the project appears on the home screen like any other.
|
||||
4. **Sync, file granularity.** Each shared project polls the relay every 2 s, from the browser
|
||||
and from the native app alike (one engine, `src/sync.rs`, over `crate::vfs`). It compares the
|
||||
server's file list, the folder, and the last synced state (kept in the project's `.share`):
|
||||
folder changes go up, server changes come down and the open project reloads unless it has
|
||||
unsaved edits. Conflicts: whole file, the folder wins a simultaneous change (last write wins).
|
||||
An offline client catches up on its next poll.
|
||||
5. **Native too.** The desktop app shares a project to any relay (`Share…` asks for the server)
|
||||
and joins by link, exactly like the browser; both can edit the same project.
|
||||
|
||||
Out of scope: per-object merging (CRDT), cursors/presence, accounts, per-user permissions,
|
||||
history. The share token is the credential: anyone with the link can read and edit.
|
||||
|
||||
## Protocol
|
||||
|
||||
Token: 32–64 lowercase hex characters, generated by the tab (same rules as the agent token).
|
||||
A different token from the agent link, so sharing a project never exposes the MCP endpoint.
|
||||
|
||||
| Route | Meaning |
|
||||
|-------|---------|
|
||||
| `GET /p/{token}/files` | JSON `[{"path": "project.json", "size": 425, "mtime": <ns>}]` (all files, relative paths) |
|
||||
| `GET /p/{token}/f/{path}` | file bytes, 404 if absent |
|
||||
| `PUT /p/{token}/f/{path}` | write bytes; creates the project on first write; answers the file's `mtime`; optional header `x-client: <id>` |
|
||||
| `DELETE /p/{token}/f/{path}` | remove a file |
|
||||
| `GET /p/{token}/ws` | WebSocket; server pushes `{"op":"put"\|"del","path":"…","rev":N,"client":"<id>"}` |
|
||||
|
||||
The websocket is served but the clients poll `/files` (simpler, same code on both targets);
|
||||
it is there for a later push-based client.
|
||||
|
||||
Rules: paths are relative, no `..`, no absolute, no empty segment; a bad path or token is a plain
|
||||
404 (same text as unknown links). `rev` is a per-project counter the relay bumps on every
|
||||
change. Body cap 64 MiB. Origin is not checked (the web app is served from the relay or another
|
||||
origin). The writer's `client` id is echoed so a tab ignores its own events.
|
||||
|
||||
Storage layout: `<data>/<token>/<path>`; `rev` is kept in memory and restarts at the file count
|
||||
after a relay restart (tabs resync from `/files` on reconnect, so a lower rev is harmless).
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Relay tests: put/get/list/delete round trip; path traversal refused; unknown token and bad
|
||||
token are 404; a second websocket sees the first client's put and delete, the writer does not
|
||||
see its own echo; routes 404 when `--data` is absent.
|
||||
- Web: sharing uploads every file under the project dir; opening `?share=` recreates the project
|
||||
locally; editing a page in tab A shows up in tab B without a manual reload. Wasm check builds
|
||||
clean and native tests stay green.
|
||||
- Local (non-shared) projects behave exactly as before.
|
||||
|
||||
## Where it lives
|
||||
|
||||
| Concern | Code |
|
||||
|---------|------|
|
||||
| Server store, `/p/` routes, path and token checks | `src/mcp/store.rs`, merged into the relay router in `src/mcp/relay.rs` (`spawn_with_data`); `--data` flag in `src/bin/papercraft-relay.rs` |
|
||||
| Sync engine (both targets), `.share` file, link parsing, server-path sanitising | `src/sync.rs` (HTTP: reqwest native, `fetch` wasm) |
|
||||
| Reload of the open project, status, join/share entry points | `src/session.rs` (`sync_poll`, `share_project`, `join_shared`) |
|
||||
| Home screen: Share…, Join shared…, status badge, copy link | `src/ui/home.rs`; actions handled in `src/app.rs` and `src/web.rs`; `?share=` auto-join in `src/web.rs` |
|
||||
| Tests | `tests/relay.rs` (store routes), `tests/sync.rs` (two folders through a real relay), unit tests in `src/sync.rs` and `src/mcp/store.rs` |
|
||||
+40
-5
@@ -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) {
|
||||
|
||||
@@ -19,7 +19,7 @@ USAGE:
|
||||
papercraft-cli list tool names and one-line descriptions (works offline)
|
||||
papercraft-cli schema <tool> a tool's full description and JSON input schema
|
||||
papercraft-cli call <tool> [JSON|-] run a tool; JSON arguments inline, or `-` for stdin
|
||||
[--out FILE] where to save an image result (default: <tool>.png)
|
||||
[-o|--out FILE] where to save an image result (default: <tool>.png, or {\"out\": \"FILE\"})
|
||||
papercraft-cli icons list installed and installable open icon sets
|
||||
papercraft-cli icons add <set>... download sets (lucide tabler phosphor heroicons material)
|
||||
papercraft-cli icons search <words> [--set S] find `set:name` references (works offline)
|
||||
@@ -197,6 +197,13 @@ fn schema(name: &str) -> ExitCode {
|
||||
}
|
||||
|
||||
async fn call(name: String, args: Value, out: Option<String>) -> Result<bool, String> {
|
||||
let out = out.or_else(|| {
|
||||
args.get("out")
|
||||
.or_else(|| args.get("path"))
|
||||
.and_then(Value::as_str)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from)
|
||||
});
|
||||
let url = mcp::url(mcp::port());
|
||||
let client = ()
|
||||
.serve(StreamableHttpClientTransport::from_uri(url.clone()))
|
||||
@@ -219,6 +226,12 @@ async fn call(name: String, args: Value, out: Option<String>) -> Result<bool, St
|
||||
.decode(v["data"].as_str().unwrap_or(""))
|
||||
.map_err(|e| format!("bad image data: {e}"))?;
|
||||
let path = out.clone().unwrap_or_else(|| format!("{name}.png"));
|
||||
if let Some(p) = std::path::Path::new(&path)
|
||||
.parent()
|
||||
.filter(|p| !p.as_os_str().is_empty())
|
||||
{
|
||||
let _ = std::fs::create_dir_all(p);
|
||||
}
|
||||
std::fs::write(&path, data).map_err(|e| format!("{path}: {e}"))?;
|
||||
println!("{path}");
|
||||
}
|
||||
@@ -230,11 +243,19 @@ async fn call(name: String, args: Value, out: Option<String>) -> Result<bool, St
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let mut args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let out = args
|
||||
.iter()
|
||||
.position(|a| a == "--out")
|
||||
.and_then(|i| (i + 1 < args.len()).then(|| args.drain(i..=i + 1).nth(1)))
|
||||
.flatten();
|
||||
let mut out: Option<String> = None;
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
if (args[i] == "--out" || args[i] == "-o") && i + 1 < args.len() {
|
||||
out = Some(args.drain(i..=i + 1).nth(1).unwrap());
|
||||
} else if let Some(v) = args[i].strip_prefix("--out=") {
|
||||
let v = v.to_string();
|
||||
args.remove(i);
|
||||
out = Some(v);
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
match args.first().map(String::as_str) {
|
||||
Some("list") => list(),
|
||||
Some("schema") => match args.get(1) {
|
||||
|
||||
@@ -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());
|
||||
|
||||
+2529
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;
|
||||
|
||||
+508
-27
@@ -29,6 +29,17 @@ fn set(r: &mut Rect, axis: usize, pos: f64, size: f64) {
|
||||
}
|
||||
}
|
||||
|
||||
fn clamp_size(lc: &crate::model::LayoutChild, axis: usize, s: f64) -> f64 {
|
||||
let mut out = s.max(0.0);
|
||||
if let Some(min) = lc.min_size[axis] {
|
||||
out = out.max(min);
|
||||
}
|
||||
if let Some(max) = lc.max_size[axis] {
|
||||
out = out.min(max.max(lc.min_size[axis].unwrap_or(0.0)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Children that take part in the flow.
|
||||
pub fn flow_children(doc: &Document, id: NodeId) -> Vec<NodeId> {
|
||||
doc.children(Some(id))
|
||||
@@ -45,7 +56,9 @@ pub fn flow_children(doc: &Document, id: NodeId) -> Vec<NodeId> {
|
||||
fn hug_size(doc: &Document, id: NodeId, axis: usize) -> f64 {
|
||||
let n = doc.get(id).unwrap();
|
||||
let (_, cur) = get(&n.rect, axis);
|
||||
let Some(l) = n.layout else { return cur };
|
||||
let Some(l) = n.layout else {
|
||||
return clamp_size(&n.layout_child, axis, cur);
|
||||
};
|
||||
let kids = flow_children(doc, id);
|
||||
let pad = if axis == 0 {
|
||||
l.padding[3] + l.padding[1]
|
||||
@@ -57,13 +70,13 @@ fn hug_size(doc: &Document, id: NodeId, axis: usize) -> f64 {
|
||||
.map(|k| {
|
||||
let kn = doc.get(*k).unwrap();
|
||||
match kn.layout_child.sizing[axis] {
|
||||
Sizing::Hug => hug_size(doc, *k, axis),
|
||||
Sizing::Hug => clamp_size(&kn.layout_child, axis, hug_size(doc, *k, axis)),
|
||||
Sizing::Fill => 0.0,
|
||||
Sizing::Fixed => get(&kn.rect, axis).1,
|
||||
Sizing::Fixed => clamp_size(&kn.layout_child, axis, get(&kn.rect, axis).1),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if axis == main_axis(&l) && !l.wrap {
|
||||
let result = if axis == main_axis(&l) && !l.wrap {
|
||||
let gap = if l.gap_auto { 0.0 } else { l.gap };
|
||||
pad + sizes.iter().sum::<f64>() + gap * (kids.len().saturating_sub(1)) as f64
|
||||
} else if axis == main_axis(&l) {
|
||||
@@ -77,14 +90,23 @@ fn hug_size(doc: &Document, id: NodeId, axis: usize) -> f64 {
|
||||
.iter()
|
||||
.map(|line| {
|
||||
line.iter()
|
||||
.map(|k| get(&doc.get(*k).unwrap().rect, axis).1)
|
||||
.map(|k| {
|
||||
let kn = doc.get(*k).unwrap();
|
||||
let raw = match kn.layout_child.sizing[axis] {
|
||||
Sizing::Hug => hug_size(doc, *k, axis),
|
||||
Sizing::Fill => get(&kn.rect, axis).1,
|
||||
Sizing::Fixed => get(&kn.rect, axis).1,
|
||||
};
|
||||
clamp_size(&kn.layout_child, axis, raw)
|
||||
})
|
||||
.fold(0.0, f64::max)
|
||||
})
|
||||
.collect();
|
||||
pad + cross.iter().sum::<f64>() + l.cross_gap * cross.len().saturating_sub(1) as f64
|
||||
} else {
|
||||
pad + sizes.iter().cloned().fold(0.0, f64::max)
|
||||
}
|
||||
};
|
||||
clamp_size(&n.layout_child, axis, result)
|
||||
}
|
||||
|
||||
fn wrap_lines(
|
||||
@@ -104,7 +126,12 @@ fn wrap_lines(
|
||||
let mut lines: Vec<Vec<NodeId>> = vec![Vec::new()];
|
||||
let mut x = 0.0;
|
||||
for k in kids {
|
||||
let s = get(&doc.get(*k).unwrap().rect, ma).1;
|
||||
let kn = doc.get(*k).unwrap();
|
||||
let raw_s = match kn.layout_child.sizing[ma] {
|
||||
Sizing::Hug => hug_size(doc, *k, ma),
|
||||
_ => get(&kn.rect, ma).1,
|
||||
};
|
||||
let s = clamp_size(&kn.layout_child, ma, raw_s);
|
||||
let line = lines.last_mut().unwrap();
|
||||
if !line.is_empty() && x + l.gap + s > inner + 1e-9 {
|
||||
lines.push(vec![*k]);
|
||||
@@ -155,6 +182,8 @@ pub fn layout_frame(tx: &mut Tx, id: NodeId) -> bool {
|
||||
set(&mut rect, axis, p, s.max(0.0));
|
||||
}
|
||||
}
|
||||
let (p, cur_s) = get(&rect, axis);
|
||||
set(&mut rect, axis, p, clamp_size(&n.layout_child, axis, cur_s));
|
||||
}
|
||||
if rect != n.rect {
|
||||
tx.node_mut(id).unwrap().rect = rect;
|
||||
@@ -190,8 +219,38 @@ fn arrange(tx: &mut Tx, id: NodeId, l: &AutoLayout, rect: Rect, ma: usize, ca: u
|
||||
} else {
|
||||
vec![kids.clone()]
|
||||
};
|
||||
let mut cross_cursor = p0c;
|
||||
for line in lines {
|
||||
let line_cross_sizes: Vec<f64> = if l.wrap {
|
||||
lines
|
||||
.iter()
|
||||
.map(|line| {
|
||||
line.iter()
|
||||
.map(|k| {
|
||||
let kn = tx.doc().get(*k).unwrap();
|
||||
let raw = match kn.layout_child.sizing[ca] {
|
||||
Sizing::Hug => hug_size(tx.doc(), *k, ca),
|
||||
Sizing::Fill | Sizing::Fixed => get(&kn.rect, ca).1,
|
||||
};
|
||||
clamp_size(&kn.layout_child, ca, raw)
|
||||
})
|
||||
.fold(0.0, f64::max)
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
vec![inner_c; lines.len()]
|
||||
};
|
||||
let mut cross_cursor = if l.wrap {
|
||||
let total_cross: f64 = line_cross_sizes.iter().sum::<f64>()
|
||||
+ l.cross_gap * lines.len().saturating_sub(1) as f64;
|
||||
let free_cross = (inner_c - total_cross).max(0.0);
|
||||
p0c + match l.align_cross {
|
||||
Align::Start => 0.0,
|
||||
Align::Center => free_cross * 0.5,
|
||||
Align::End => free_cross,
|
||||
}
|
||||
} else {
|
||||
p0c
|
||||
};
|
||||
for (li, line) in lines.into_iter().enumerate() {
|
||||
// Main sizes: fixed / hug as they are; fill shares the rest equally.
|
||||
let mut sizes: Vec<f64> = line
|
||||
.iter()
|
||||
@@ -199,7 +258,8 @@ fn arrange(tx: &mut Tx, id: NodeId, l: &AutoLayout, rect: Rect, ma: usize, ca: u
|
||||
let kn = tx.doc().get(*k).unwrap();
|
||||
match kn.layout_child.sizing[ma] {
|
||||
Sizing::Fill => 0.0,
|
||||
_ => get(&kn.rect, ma).1,
|
||||
Sizing::Hug => clamp_size(&kn.layout_child, ma, hug_size(tx.doc(), *k, ma)),
|
||||
Sizing::Fixed => clamp_size(&kn.layout_child, ma, get(&kn.rect, ma).1),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -211,7 +271,7 @@ fn arrange(tx: &mut Tx, id: NodeId, l: &AutoLayout, rect: Rect, ma: usize, ca: u
|
||||
if !fills.is_empty() && !l.wrap {
|
||||
let share = ((inner_m - fixed) / fills.len() as f64).max(0.0);
|
||||
for i in &fills {
|
||||
sizes[*i] = share;
|
||||
sizes[*i] = clamp_size(&tx.doc().get(line[*i]).unwrap().layout_child, ma, share);
|
||||
}
|
||||
}
|
||||
let used: f64 = sizes.iter().sum::<f64>() + gap * line.len().saturating_sub(1) as f64;
|
||||
@@ -229,24 +289,39 @@ fn arrange(tx: &mut Tx, id: NodeId, l: &AutoLayout, rect: Rect, ma: usize, ca: u
|
||||
};
|
||||
(start, gap)
|
||||
};
|
||||
let line_cross = if l.wrap {
|
||||
line.iter()
|
||||
.map(|k| get(&tx.doc().get(*k).unwrap().rect, ca).1)
|
||||
.fold(0.0, f64::max)
|
||||
} else {
|
||||
inner_c
|
||||
};
|
||||
let lh = line_cross_sizes[li];
|
||||
for (i, k) in line.iter().enumerate() {
|
||||
let kn = tx.doc().get(*k).unwrap();
|
||||
let mut r = kn.rect;
|
||||
let cs = match kn.layout_child.sizing[ca] {
|
||||
Sizing::Fill => line_cross.max(0.0),
|
||||
_ => get(&r, ca).1,
|
||||
};
|
||||
let cpos = match l.align_cross {
|
||||
Align::Start => cross_cursor,
|
||||
Align::Center => cross_cursor + (line_cross - cs) * 0.5,
|
||||
Align::End => cross_cursor + line_cross - cs,
|
||||
let cross_align = kn.layout_child.align_self.unwrap_or(l.align_cross);
|
||||
let (cpos, cs) = match kn.layout_child.sizing[ca] {
|
||||
Sizing::Fill => {
|
||||
let cs = clamp_size(&kn.layout_child, ca, lh.max(0.0));
|
||||
let cy = if cs < lh {
|
||||
match cross_align {
|
||||
Align::Start => cross_cursor,
|
||||
Align::Center => cross_cursor + (lh - cs) * 0.5,
|
||||
Align::End => cross_cursor + lh - cs,
|
||||
}
|
||||
} else {
|
||||
cross_cursor
|
||||
};
|
||||
(cy, cs)
|
||||
}
|
||||
Sizing::Fixed | Sizing::Hug => {
|
||||
let raw_ch = if kn.layout_child.sizing[ca] == Sizing::Hug {
|
||||
hug_size(tx.doc(), *k, ca)
|
||||
} else {
|
||||
get(&r, ca).1
|
||||
};
|
||||
let ch = clamp_size(&kn.layout_child, ca, raw_ch);
|
||||
let cy = match cross_align {
|
||||
Align::Start => cross_cursor,
|
||||
Align::Center => cross_cursor + (lh - ch) * 0.5,
|
||||
Align::End => cross_cursor + lh - ch,
|
||||
};
|
||||
(cy, ch)
|
||||
}
|
||||
};
|
||||
set(&mut r, ma, cursor, sizes[i]);
|
||||
set(&mut r, ca, cpos, cs);
|
||||
@@ -266,7 +341,7 @@ fn arrange(tx: &mut Tx, id: NodeId, l: &AutoLayout, rect: Rect, ma: usize, ca: u
|
||||
}
|
||||
}
|
||||
}
|
||||
cross_cursor += line_cross + l.cross_gap;
|
||||
cross_cursor += lh + l.cross_gap;
|
||||
}
|
||||
// Fill children that are auto-layout frames lay out their own content again.
|
||||
for k in kids {
|
||||
@@ -280,6 +355,149 @@ fn arrange(tx: &mut Tx, id: NodeId, l: &AutoLayout, rect: Rect, ma: usize, ca: u
|
||||
// Absolute children keep their constraints-based position.
|
||||
}
|
||||
|
||||
/// Lay out `flow_children(tx.doc(), id)` into a `cols`-column CSS-style grid inside frame `id`.
|
||||
/// Returns true if anything moved or resized.
|
||||
pub fn layout_grid(tx: &mut Tx, id: NodeId, cols: usize, col_gap: f64, row_gap: f64) -> bool {
|
||||
let Some(n0) = tx.doc().get(id).cloned() else {
|
||||
return false;
|
||||
};
|
||||
let cols = cols.max(1);
|
||||
let before: Vec<(NodeId, Rect)> = tx
|
||||
.doc()
|
||||
.subtree(id)
|
||||
.iter()
|
||||
.filter_map(|i| tx.doc().get(*i).map(|n| (*i, n.rect)))
|
||||
.collect();
|
||||
|
||||
let kids = flow_children(tx.doc(), id);
|
||||
for k in &kids {
|
||||
if tx.doc().get(*k).is_some_and(|c| c.layout.is_some()) {
|
||||
layout_frame(tx, *k);
|
||||
}
|
||||
}
|
||||
|
||||
let n = tx.doc().get(id).unwrap().clone();
|
||||
let pad = n.layout.unwrap_or_default().padding;
|
||||
let (pt, pr, pb, pl) = (pad[0], pad[1], pad[2], pad[3]);
|
||||
let rows = kids.len().div_ceil(cols);
|
||||
|
||||
let mut col_w: Vec<f64> = vec![0.0; cols];
|
||||
let mut col_fill = vec![false; cols];
|
||||
for (idx, k) in kids.iter().enumerate() {
|
||||
let c = idx % cols;
|
||||
let kn = tx.doc().get(*k).unwrap();
|
||||
if kn.layout_child.sizing[0] == Sizing::Fill {
|
||||
col_fill[c] = true;
|
||||
} else {
|
||||
let w = if kn.layout_child.sizing[0] == Sizing::Hug {
|
||||
hug_size(tx.doc(), *k, 0)
|
||||
} else {
|
||||
kn.rect.w
|
||||
};
|
||||
col_w[c] = col_w[c].max(w);
|
||||
}
|
||||
}
|
||||
|
||||
let total_col_gaps = col_gap * cols.saturating_sub(1) as f64;
|
||||
let fill_cols: Vec<usize> = (0..cols).filter(|&c| col_fill[c]).collect();
|
||||
if !fill_cols.is_empty() {
|
||||
let fixed_w: f64 = (0..cols).filter(|&c| !col_fill[c]).map(|c| col_w[c]).sum();
|
||||
let inner_w = (n.rect.w - pl - pr).max(0.0);
|
||||
let share = ((inner_w - total_col_gaps - fixed_w) / fill_cols.len() as f64).max(0.0);
|
||||
for c in fill_cols {
|
||||
col_w[c] = share;
|
||||
}
|
||||
}
|
||||
|
||||
let mut row_h: Vec<f64> = vec![0.0; rows];
|
||||
for (idx, k) in kids.iter().enumerate() {
|
||||
let r = idx / cols;
|
||||
let kn = tx.doc().get(*k).unwrap();
|
||||
let h = if kn.layout_child.sizing[1] == Sizing::Hug {
|
||||
hug_size(tx.doc(), *k, 1)
|
||||
} else {
|
||||
kn.rect.h
|
||||
};
|
||||
row_h[r] = row_h[r].max(h);
|
||||
}
|
||||
|
||||
let total_row_gaps = row_gap * rows.saturating_sub(1) as f64;
|
||||
let mut rect = n.rect;
|
||||
if n.layout_child.sizing[0] == Sizing::Hug {
|
||||
let tracks_w = if kids.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
col_w.iter().sum::<f64>() + total_col_gaps
|
||||
};
|
||||
rect.w = (pl + pr + tracks_w).max(0.0);
|
||||
}
|
||||
if n.layout_child.sizing[1] == Sizing::Hug {
|
||||
let tracks_h = if rows == 0 {
|
||||
0.0
|
||||
} else {
|
||||
row_h.iter().sum::<f64>() + total_row_gaps
|
||||
};
|
||||
rect.h = (pt + pb + tracks_h).max(0.0);
|
||||
}
|
||||
if rect != n0.rect {
|
||||
tx.node_mut(id).unwrap().rect = rect;
|
||||
}
|
||||
|
||||
let mut col_x = vec![0.0; cols];
|
||||
let mut cx = pl;
|
||||
for c in 0..cols {
|
||||
col_x[c] = cx;
|
||||
cx += col_w[c] + col_gap;
|
||||
}
|
||||
let mut row_y = vec![0.0; rows];
|
||||
let mut ry = pt;
|
||||
for r in 0..rows {
|
||||
row_y[r] = ry;
|
||||
ry += row_h[r] + row_gap;
|
||||
}
|
||||
|
||||
for (idx, k) in kids.iter().enumerate() {
|
||||
let c = idx % cols;
|
||||
let r = idx / cols;
|
||||
let kn = tx.doc().get(*k).unwrap();
|
||||
let mut cr = kn.rect;
|
||||
cr.x = col_x[c];
|
||||
cr.y = row_y[r];
|
||||
if kn.layout_child.sizing[0] == Sizing::Fill {
|
||||
cr.w = col_w[c];
|
||||
} else if kn.layout_child.sizing[0] == Sizing::Hug {
|
||||
cr.w = hug_size(tx.doc(), *k, 0);
|
||||
}
|
||||
if kn.layout_child.sizing[1] == Sizing::Fill {
|
||||
cr.h = row_h[r];
|
||||
} else if kn.layout_child.sizing[1] == Sizing::Hug {
|
||||
cr.h = hug_size(tx.doc(), *k, 1);
|
||||
}
|
||||
if cr != kn.rect {
|
||||
let resized = (cr.w - kn.rect.w).abs() > 1e-9 || (cr.h - kn.rect.h).abs() > 1e-9;
|
||||
let old = kn.rect;
|
||||
let cn = tx.node_mut(*k).unwrap();
|
||||
if resized {
|
||||
cn.set_rect_scaling(cr);
|
||||
} else {
|
||||
cn.rect = cr;
|
||||
}
|
||||
if resized {
|
||||
resize_children(tx, *k, old, cr);
|
||||
fit_text_height(tx, *k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let after: Vec<(NodeId, Rect)> = tx
|
||||
.doc()
|
||||
.subtree(id)
|
||||
.iter()
|
||||
.filter_map(|i| tx.doc().get(*i).map(|n| (*i, n.rect)))
|
||||
.collect();
|
||||
before != after
|
||||
}
|
||||
|
||||
/// Re-measure a text node's height after its width changed.
|
||||
pub fn fit_text_height(tx: &mut Tx, id: NodeId) {
|
||||
let Some(n) = tx.doc().get(id) else { return };
|
||||
@@ -640,4 +858,267 @@ mod tests {
|
||||
assert_eq!(r(3), Rect::new(120.0, 60.0, 40.0, 20.0));
|
||||
assert_eq!(r(4), Rect::new(40.0, 20.0, 80.0, 40.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_lines_cross_align_center_and_end() {
|
||||
let base = AutoLayout {
|
||||
wrap: true,
|
||||
gap: 10.0,
|
||||
cross_gap: 10.0,
|
||||
padding: [0.0; 4],
|
||||
align_cross: Align::Center,
|
||||
..Default::default()
|
||||
};
|
||||
// Frame 300x200: line 0 has 20 & 30 (lh=30), line 1 has 20 (lh=20).
|
||||
// total_cross = 30 + 10 + 20 = 60, free_cross = 140.
|
||||
let (doc_c, _, kc) = doc_with(
|
||||
base,
|
||||
FX,
|
||||
&[(120.0, 20.0, FX), (120.0, 30.0, FX), (120.0, 20.0, FX)],
|
||||
);
|
||||
assert_eq!(
|
||||
doc_c.get(kc[0]).unwrap().rect,
|
||||
Rect::new(0.0, 75.0, 120.0, 20.0)
|
||||
);
|
||||
assert_eq!(
|
||||
doc_c.get(kc[1]).unwrap().rect,
|
||||
Rect::new(130.0, 70.0, 120.0, 30.0)
|
||||
);
|
||||
assert_eq!(
|
||||
doc_c.get(kc[2]).unwrap().rect,
|
||||
Rect::new(0.0, 110.0, 120.0, 20.0)
|
||||
);
|
||||
|
||||
let end_l = AutoLayout {
|
||||
align_cross: Align::End,
|
||||
..base
|
||||
};
|
||||
let (doc_e, _, ke) = doc_with(
|
||||
end_l,
|
||||
FX,
|
||||
&[(120.0, 20.0, FX), (120.0, 30.0, FX), (120.0, 20.0, FX)],
|
||||
);
|
||||
assert_eq!(
|
||||
doc_e.get(ke[0]).unwrap().rect,
|
||||
Rect::new(0.0, 150.0, 120.0, 20.0)
|
||||
);
|
||||
assert_eq!(
|
||||
doc_e.get(ke[1]).unwrap().rect,
|
||||
Rect::new(130.0, 140.0, 120.0, 30.0)
|
||||
);
|
||||
assert_eq!(
|
||||
doc_e.get(ke[2]).unwrap().rect,
|
||||
Rect::new(0.0, 180.0, 120.0, 20.0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_per_line_item_align_and_fill_stretch() {
|
||||
let l = AutoLayout {
|
||||
wrap: true,
|
||||
gap: 10.0,
|
||||
cross_gap: 10.0,
|
||||
padding: [10.0; 4],
|
||||
align_cross: Align::Center,
|
||||
..Default::default()
|
||||
};
|
||||
// Inner width = 300 - 20 = 280.
|
||||
// Line 0: three 80w items (80*3 + 20 = 260 <= 280), heights: Fill, 20, 40 -> lh = 40.
|
||||
// Line 1: two 120w items (120*2 + 10 = 250 <= 280), heights: 60, Fill -> lh = 60.
|
||||
let (doc, f, k) = doc_with(
|
||||
l,
|
||||
[Sizing::Fixed, Sizing::Hug],
|
||||
&[
|
||||
(80.0, 5.0, [Sizing::Fixed, Sizing::Fill]),
|
||||
(80.0, 20.0, FX),
|
||||
(80.0, 40.0, FX),
|
||||
(120.0, 60.0, FX),
|
||||
(120.0, 5.0, [Sizing::Fixed, Sizing::Fill]),
|
||||
],
|
||||
);
|
||||
assert_eq!(doc.get(f).unwrap().rect.h, 10.0 + 40.0 + 10.0 + 60.0 + 10.0);
|
||||
assert_eq!(
|
||||
doc.get(k[0]).unwrap().rect,
|
||||
Rect::new(10.0, 10.0, 80.0, 40.0)
|
||||
);
|
||||
assert_eq!(
|
||||
doc.get(k[1]).unwrap().rect,
|
||||
Rect::new(100.0, 20.0, 80.0, 20.0)
|
||||
);
|
||||
assert_eq!(
|
||||
doc.get(k[2]).unwrap().rect,
|
||||
Rect::new(190.0, 10.0, 80.0, 40.0)
|
||||
);
|
||||
assert_eq!(
|
||||
doc.get(k[3]).unwrap().rect,
|
||||
Rect::new(10.0, 60.0, 120.0, 60.0)
|
||||
);
|
||||
assert_eq!(
|
||||
doc.get(k[4]).unwrap().rect,
|
||||
Rect::new(140.0, 60.0, 120.0, 60.0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_grid_columns_fill_and_hug() {
|
||||
// 2-column grid with Hug height and one Fill column.
|
||||
let mut doc = Document::default();
|
||||
let mut rec = Recording::new("t", &doc);
|
||||
let mut tx = Tx::new(&mut doc, &mut rec);
|
||||
let fid = tx.alloc_id();
|
||||
let mut f = Node::new(
|
||||
fid,
|
||||
NodeKind::Frame { clip: true },
|
||||
Rect::new(0.0, 0.0, 300.0, 50.0),
|
||||
);
|
||||
f.layout = Some(AutoLayout {
|
||||
padding: [10.0, 10.0, 10.0, 10.0],
|
||||
..Default::default()
|
||||
});
|
||||
f.layout_child.sizing = [Sizing::Fixed, Sizing::Hug];
|
||||
tx.insert(f, None, None);
|
||||
|
||||
let specs = [
|
||||
(80.0, 30.0, FX),
|
||||
(20.0, 40.0, [Sizing::Fill, Sizing::Fixed]),
|
||||
(100.0, 50.0, FX),
|
||||
(20.0, 10.0, [Sizing::Fill, Sizing::Fill]),
|
||||
];
|
||||
let mut ids = Vec::new();
|
||||
for (w, h, s) in specs {
|
||||
let id = tx.alloc_id();
|
||||
let mut n = Node::new(id, NodeKind::Rect, Rect::new(0.0, 0.0, w, h));
|
||||
n.layout_child.sizing = s;
|
||||
tx.insert(n, Some(fid), None);
|
||||
ids.push(id);
|
||||
}
|
||||
assert!(layout_grid(&mut tx, fid, 2, 20.0, 15.0));
|
||||
// Col 0 max fixed width = 100. Inner width = 280, col_gap = 20 -> Col 1 Fill width = 160.
|
||||
// Row 0 height = max(30, 40) = 40; Row 1 height = max(50, 10) = 50.
|
||||
// Hug height = 10 + 40 + 15 + 50 + 10 = 125.
|
||||
assert_eq!(tx.doc().get(fid).unwrap().rect.h, 125.0);
|
||||
assert_eq!(
|
||||
tx.doc().get(ids[0]).unwrap().rect,
|
||||
Rect::new(10.0, 10.0, 80.0, 30.0)
|
||||
);
|
||||
assert_eq!(
|
||||
tx.doc().get(ids[1]).unwrap().rect,
|
||||
Rect::new(130.0, 10.0, 160.0, 40.0)
|
||||
);
|
||||
assert_eq!(
|
||||
tx.doc().get(ids[2]).unwrap().rect,
|
||||
Rect::new(10.0, 65.0, 100.0, 50.0)
|
||||
);
|
||||
assert_eq!(
|
||||
tx.doc().get(ids[3]).unwrap().rect,
|
||||
Rect::new(130.0, 65.0, 160.0, 50.0)
|
||||
);
|
||||
|
||||
// Re-layout as 3-column grid (col 0 has Fixed 80 & Fill -> Fill; col 1 has Fill -> Fill; col 2 has Fixed 100).
|
||||
// Inner width = 280, gaps = 2 * 10 = 20, fixed col 2 = 100 -> remaining 160 shared by col 0 and col 1 (80 each).
|
||||
assert!(layout_grid(&mut tx, fid, 3, 10.0, 10.0));
|
||||
// Row 0: ids[0](30), ids[1](40), ids[2](50) -> row_h[0] = 50.
|
||||
// Row 1: ids[3](50 from previous stretch) -> row_h[1] = 50.
|
||||
assert_eq!(
|
||||
tx.doc().get(fid).unwrap().rect.h,
|
||||
10.0 + 50.0 + 10.0 + 50.0 + 10.0
|
||||
);
|
||||
assert_eq!(
|
||||
tx.doc().get(ids[0]).unwrap().rect,
|
||||
Rect::new(10.0, 10.0, 80.0, 30.0)
|
||||
);
|
||||
assert_eq!(
|
||||
tx.doc().get(ids[1]).unwrap().rect,
|
||||
Rect::new(100.0, 10.0, 80.0, 40.0)
|
||||
);
|
||||
assert_eq!(
|
||||
tx.doc().get(ids[2]).unwrap().rect,
|
||||
Rect::new(190.0, 10.0, 100.0, 50.0)
|
||||
);
|
||||
assert_eq!(
|
||||
tx.doc().get(ids[3]).unwrap().rect,
|
||||
Rect::new(10.0, 70.0, 80.0, 50.0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn align_self_overrides_parent_cross_align() {
|
||||
let mut doc = Document::default();
|
||||
let mut rec = Recording::new("t", &doc);
|
||||
let mut tx = Tx::new(&mut doc, &mut rec);
|
||||
let fid = tx.alloc_id();
|
||||
let mut f = Node::new(
|
||||
fid,
|
||||
NodeKind::Frame { clip: true },
|
||||
Rect::new(0.0, 0.0, 300.0, 100.0),
|
||||
);
|
||||
f.layout = Some(AutoLayout {
|
||||
direction: Direction::Horizontal,
|
||||
gap: 10.0,
|
||||
padding: [0.0; 4],
|
||||
align_cross: Align::Start,
|
||||
..Default::default()
|
||||
});
|
||||
f.layout_child.sizing = FX;
|
||||
tx.insert(f, None, None);
|
||||
|
||||
let mut ids = Vec::new();
|
||||
for self_align in [None, Some(Align::Center), Some(Align::End)] {
|
||||
let id = tx.alloc_id();
|
||||
let mut n = Node::new(id, NodeKind::Rect, Rect::new(0.0, 0.0, 40.0, 20.0));
|
||||
n.layout_child.sizing = FX;
|
||||
n.layout_child.align_self = self_align;
|
||||
tx.insert(n, Some(fid), None);
|
||||
ids.push(id);
|
||||
}
|
||||
layout_frame(&mut tx, fid);
|
||||
assert_eq!(
|
||||
tx.doc().get(ids[0]).unwrap().rect,
|
||||
Rect::new(0.0, 0.0, 40.0, 20.0)
|
||||
);
|
||||
assert_eq!(
|
||||
tx.doc().get(ids[1]).unwrap().rect,
|
||||
Rect::new(50.0, 40.0, 40.0, 20.0)
|
||||
);
|
||||
assert_eq!(
|
||||
tx.doc().get(ids[2]).unwrap().rect,
|
||||
Rect::new(100.0, 80.0, 40.0, 20.0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn min_size_and_max_size_clamping() {
|
||||
let mut doc = Document::default();
|
||||
let mut rec = Recording::new("t", &doc);
|
||||
let mut tx = Tx::new(&mut doc, &mut rec);
|
||||
let fid = tx.alloc_id();
|
||||
let mut f = Node::new(
|
||||
fid,
|
||||
NodeKind::Frame { clip: true },
|
||||
Rect::new(0.0, 0.0, 400.0, 10.0),
|
||||
);
|
||||
f.layout = Some(AutoLayout {
|
||||
direction: Direction::Horizontal,
|
||||
gap: 0.0,
|
||||
padding: [0.0; 4],
|
||||
align_cross: Align::Center,
|
||||
..Default::default()
|
||||
});
|
||||
f.layout_child.sizing = [Sizing::Hug, Sizing::Hug];
|
||||
f.layout_child.min_size = [None, Some(44.0)];
|
||||
f.layout_child.max_size = [Some(300.0), None];
|
||||
tx.insert(f, None, None);
|
||||
|
||||
let cid = tx.alloc_id();
|
||||
let mut c = Node::new(cid, NodeKind::Rect, Rect::new(0.0, 0.0, 500.0, 20.0));
|
||||
c.layout_child.sizing = FX;
|
||||
c.layout_child.max_size = [Some(350.0), None];
|
||||
tx.insert(c, Some(fid), None);
|
||||
|
||||
layout_frame(&mut tx, fid);
|
||||
let fr = tx.doc().get(fid).unwrap().rect;
|
||||
assert_eq!((fr.w, fr.h), (300.0, 44.0));
|
||||
let cr = tx.doc().get(cid).unwrap().rect;
|
||||
assert_eq!(cr, Rect::new(0.0, 12.0, 350.0, 20.0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
+322
-40
@@ -620,7 +620,7 @@ pub fn apply(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> Applie
|
||||
.map(|(k, v)| match var_name(v) {
|
||||
Some((name, fallback)) => match VARS.with(|m| m.borrow().get(name).copied()) {
|
||||
Some(c) => {
|
||||
if COLOR_PROPS.contains(&k.as_str()) {
|
||||
if COLOR_PROPS.contains(&k.as_str()) && k != "textDecorationColor" {
|
||||
linked.push(name);
|
||||
}
|
||||
(k.clone(), color_css(c))
|
||||
@@ -664,7 +664,13 @@ pub fn apply(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> Applie
|
||||
a
|
||||
}
|
||||
|
||||
const COLOR_PROPS: [&str; 4] = ["backgroundColor", "background", "color", "fill"];
|
||||
const COLOR_PROPS: [&str; 5] = [
|
||||
"backgroundColor",
|
||||
"background",
|
||||
"color",
|
||||
"fill",
|
||||
"textDecorationColor",
|
||||
];
|
||||
|
||||
thread_local! {
|
||||
static VARS: std::cell::RefCell<std::collections::BTreeMap<String, Color>> = const { std::cell::RefCell::new(std::collections::BTreeMap::new()) };
|
||||
@@ -826,8 +832,16 @@ pub fn override_keys(styles: &[(String, String)]) -> Vec<&'static str> {
|
||||
"left" | "top" | "right" | "bottom" | "x" | "y" => "position",
|
||||
"borderRadius" => "radius",
|
||||
"opacity" | "mixBlendMode" => "opacity",
|
||||
"fontSize" | "fontWeight" | "fontStyle" | "lineHeight" | "letterSpacing"
|
||||
| "textAlign" => "font",
|
||||
"fontSize"
|
||||
| "fontWeight"
|
||||
| "fontStyle"
|
||||
| "lineHeight"
|
||||
| "letterSpacing"
|
||||
| "textAlign"
|
||||
| "textDecoration"
|
||||
| "textDecorationLine"
|
||||
| "textDecorationColor"
|
||||
| "textDecorationStyle" => "font",
|
||||
"visibility" => "visible",
|
||||
k if k.starts_with("border") || k == "stroke" => "stroke",
|
||||
_ => "layout",
|
||||
@@ -1075,23 +1089,49 @@ fn apply_resolved(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> A
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
"alignSelf" => match ctx.parent_layout {
|
||||
Some(pl) if lower == "stretch" => {
|
||||
let ca = if pl.direction == Direction::Horizontal {
|
||||
"alignSelf" => {
|
||||
let ca = ctx.parent_layout.map(|pl| {
|
||||
if pl.direction == Direction::Horizontal {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
axis_set(n, ca, Sizing::Fill);
|
||||
if ca == 0
|
||||
&& let NodeKind::Text { auto_width, .. } = &mut n.kind
|
||||
{
|
||||
*auto_width = false;
|
||||
}
|
||||
true
|
||||
});
|
||||
match lower.as_str() {
|
||||
"stretch" => {
|
||||
n.layout_child.align_self = None;
|
||||
if let Some(ca) = ca {
|
||||
axis_set(n, ca, Sizing::Fill);
|
||||
if ca == 0
|
||||
&& let NodeKind::Text { auto_width, .. } = &mut n.kind
|
||||
{
|
||||
*auto_width = false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
"auto" | "normal" => {
|
||||
n.layout_child.align_self = None;
|
||||
true
|
||||
}
|
||||
_ if align_of(&lower).is_some() => {
|
||||
let al = align_of(&lower).unwrap();
|
||||
n.layout_child.align_self = Some(al);
|
||||
if let Some(ca) = ca
|
||||
&& n.layout_child.sizing[ca] == Sizing::Fill
|
||||
{
|
||||
n.layout_child.sizing[ca] = if matches!(n.kind, NodeKind::Frame { .. })
|
||||
{
|
||||
Sizing::Hug
|
||||
} else {
|
||||
Sizing::Fixed
|
||||
};
|
||||
}
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
_ => false,
|
||||
},
|
||||
}
|
||||
"textTransform" if is_text => {
|
||||
if let Some(t) = n.kind.text_mut() {
|
||||
match lower.as_str() {
|
||||
@@ -1121,14 +1161,44 @@ fn apply_resolved(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> A
|
||||
}
|
||||
None => false,
|
||||
},
|
||||
"flexShrink" | "flexBasis" | "boxSizing" | "minWidth" | "minHeight" | "maxWidth"
|
||||
| "maxHeight" | "zIndex" | "cursor" | "whiteSpace" | "fontFamily" | "wordBreak"
|
||||
| "overflowWrap" => {
|
||||
"minWidth" | "minHeight" => {
|
||||
let axis = usize::from(k == "minHeight");
|
||||
if matches!(lower.as_str(), "none" | "auto" | "0") {
|
||||
n.layout_child.min_size[axis] = None;
|
||||
true
|
||||
} else if let Some(px) = length(v, fs) {
|
||||
n.layout_child.min_size[axis] = Some(px.max(0.0));
|
||||
if axis == 0 && n.rect.w < px {
|
||||
n.rect.w = px;
|
||||
} else if axis == 1 && n.rect.h < px {
|
||||
n.rect.h = px;
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
"maxWidth" | "maxHeight" => {
|
||||
let axis = usize::from(k == "maxHeight");
|
||||
if matches!(lower.as_str(), "none" | "auto") {
|
||||
n.layout_child.max_size[axis] = None;
|
||||
true
|
||||
} else if let Some(px) = length(v, fs) {
|
||||
n.layout_child.max_size[axis] = Some(px.max(0.0));
|
||||
if axis == 0 && n.rect.w > px {
|
||||
n.rect.w = px;
|
||||
} else if axis == 1 && n.rect.h > px {
|
||||
n.rect.h = px;
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
"flexShrink" | "flexBasis" | "boxSizing" | "zIndex" | "cursor" | "whiteSpace"
|
||||
| "fontFamily" | "wordBreak" | "overflowWrap" => {
|
||||
// Accepted without an equivalent; harmless. fontFamily: Inter is the only face.
|
||||
!matches!(
|
||||
k.as_str(),
|
||||
"minWidth" | "minHeight" | "maxWidth" | "maxHeight"
|
||||
)
|
||||
true
|
||||
}
|
||||
"overflow" if n.is_frame() => {
|
||||
if let NodeKind::Frame { clip } = &mut n.kind {
|
||||
@@ -1224,18 +1294,79 @@ fn apply_resolved(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> A
|
||||
"textDecoration" | "textDecorationLine" if is_text => {
|
||||
let u = lower.contains("underline");
|
||||
let s = lower.contains("line-through");
|
||||
let wavy = lower.contains("wavy");
|
||||
let dc = split_ws(v).into_iter().find_map(|tok| color(&tok));
|
||||
set_text_flag(n, |sp| {
|
||||
sp.underline = Some(u);
|
||||
sp.strike = Some(s);
|
||||
if wavy {
|
||||
sp.underline_wavy = Some(true);
|
||||
}
|
||||
if let Some(c) = dc {
|
||||
sp.underline_color = Some(c);
|
||||
}
|
||||
});
|
||||
true
|
||||
}
|
||||
"fontWeight" | "fontStyle" | "lineHeight" | "letterSpacing" | "textAlign"
|
||||
| "textDecoration" => true, // inherited by text children
|
||||
"border" | "outline" | "borderTop" | "borderRight" | "borderBottom" | "borderLeft" => {
|
||||
"textDecorationColor" if is_text => match color(v) {
|
||||
Some(c) => {
|
||||
set_text_flag(n, |sp| sp.underline_color = Some(c));
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
},
|
||||
"textDecorationStyle" if is_text => {
|
||||
let wavy = lower.contains("wavy");
|
||||
set_text_flag(n, |sp| sp.underline_wavy = Some(wavy));
|
||||
true
|
||||
}
|
||||
"fontWeight"
|
||||
| "fontStyle"
|
||||
| "lineHeight"
|
||||
| "letterSpacing"
|
||||
| "textAlign"
|
||||
| "textDecoration"
|
||||
| "textDecorationLine"
|
||||
| "textDecorationColor"
|
||||
| "textDecorationStyle" => true, // inherited by text children
|
||||
"border" | "outline" => {
|
||||
n.style.stroke_sides = None;
|
||||
apply_border(n, v, fs)
|
||||
}
|
||||
"borderColor" | "stroke" => match color(v) {
|
||||
"borderTop" | "borderRight" | "borderBottom" | "borderLeft" => {
|
||||
let side_idx = ["borderTop", "borderRight", "borderBottom", "borderLeft"]
|
||||
.iter()
|
||||
.position(|x| x == k)
|
||||
.unwrap();
|
||||
apply_border_side(n, side_idx, v, fs)
|
||||
}
|
||||
"borderTopWidth" | "borderRightWidth" | "borderBottomWidth" | "borderLeftWidth" => {
|
||||
match length(v, fs) {
|
||||
Some(w) => {
|
||||
let side_idx = [
|
||||
"borderTopWidth",
|
||||
"borderRightWidth",
|
||||
"borderBottomWidth",
|
||||
"borderLeftWidth",
|
||||
]
|
||||
.iter()
|
||||
.position(|x| x == k)
|
||||
.unwrap();
|
||||
let mut sides = n.style.stroke_sides.unwrap_or([0.0; 4]);
|
||||
sides[side_idx] = w.max(0.0);
|
||||
n.style.stroke_sides = if sides == [0.0; 4] { None } else { Some(sides) };
|
||||
n.style.stroke_width = sides.iter().copied().fold(0.0, f64::max);
|
||||
n.style.stroke_align = crate::model::StrokeAlign::Inside;
|
||||
if n.style.stroke_width > 0.0 && n.style.stroke.is_none() {
|
||||
n.style.stroke = Some(Color::INK);
|
||||
}
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
"borderColor" | "stroke" | "borderTopColor" | "borderRightColor"
|
||||
| "borderBottomColor" | "borderLeftColor" => match color(v) {
|
||||
Some(c) => {
|
||||
n.style.stroke = (c.a > 0).then_some(c);
|
||||
if n.style.stroke_width <= 0.0 {
|
||||
@@ -1245,19 +1376,39 @@ fn apply_resolved(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> A
|
||||
}
|
||||
None => false,
|
||||
},
|
||||
"borderWidth" | "strokeWidth" => match length(v, fs) {
|
||||
Some(w) => {
|
||||
n.style.stroke_width = w;
|
||||
if k == "borderWidth" {
|
||||
n.style.stroke_align = crate::model::StrokeAlign::Inside;
|
||||
"borderWidth" | "strokeWidth" => {
|
||||
if k == "borderWidth"
|
||||
&& let Some(w) = four(v, fs)
|
||||
{
|
||||
n.style.stroke_align = crate::model::StrokeAlign::Inside;
|
||||
if w.iter().all(|&x| (x - w[0]).abs() < 1e-9) {
|
||||
n.style.stroke_sides = None;
|
||||
n.style.stroke_width = w[0];
|
||||
} else {
|
||||
n.style.stroke_sides = Some(w);
|
||||
n.style.stroke_width = w.iter().copied().fold(0.0, f64::max);
|
||||
}
|
||||
if w > 0.0 && n.style.stroke.is_none() {
|
||||
if n.style.stroke_width > 0.0 && n.style.stroke.is_none() {
|
||||
n.style.stroke = Some(Color::INK);
|
||||
}
|
||||
true
|
||||
} else {
|
||||
match length(v, fs) {
|
||||
Some(w) => {
|
||||
n.style.stroke_sides = None;
|
||||
n.style.stroke_width = w;
|
||||
if k == "borderWidth" {
|
||||
n.style.stroke_align = crate::model::StrokeAlign::Inside;
|
||||
}
|
||||
if w > 0.0 && n.style.stroke.is_none() {
|
||||
n.style.stroke = Some(Color::INK);
|
||||
}
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
None => false,
|
||||
},
|
||||
}
|
||||
"borderStyle" | "strokeStyle" => {
|
||||
set_dash(n, &lower);
|
||||
true
|
||||
@@ -1425,6 +1576,53 @@ fn apply_border(n: &mut Node, v: &str, fs: f64) -> bool {
|
||||
any
|
||||
}
|
||||
|
||||
fn apply_border_side(n: &mut Node, side_idx: usize, v: &str, fs: f64) -> bool {
|
||||
let lower = v.to_ascii_lowercase();
|
||||
let mut sides = n.style.stroke_sides.unwrap_or([0.0; 4]);
|
||||
if lower == "none" || lower == "0" {
|
||||
sides[side_idx] = 0.0;
|
||||
n.style.stroke_sides = if sides == [0.0; 4] { None } else { Some(sides) };
|
||||
n.style.stroke_width = sides.iter().copied().fold(0.0, f64::max);
|
||||
if sides == [0.0; 4] {
|
||||
n.style.stroke = None;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
let mut any = false;
|
||||
let mut width = None;
|
||||
let mut col = None;
|
||||
let mut style = "solid".to_string();
|
||||
for p in split_ws(v) {
|
||||
if let Some(w) = length(&p, fs) {
|
||||
width = Some(w);
|
||||
any = true;
|
||||
} else if let Some(c) = color(&p) {
|
||||
col = Some(c);
|
||||
any = true;
|
||||
} else if matches!(
|
||||
p.to_ascii_lowercase().as_str(),
|
||||
"solid" | "dashed" | "dotted" | "double"
|
||||
) {
|
||||
style = p.to_ascii_lowercase();
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
if any {
|
||||
let w = width.unwrap_or(1.0);
|
||||
sides[side_idx] = w;
|
||||
n.style.stroke_sides = Some(sides);
|
||||
n.style.stroke_width = sides.iter().copied().fold(0.0, f64::max);
|
||||
if let Some(c) = col {
|
||||
n.style.stroke = (c.a > 0).then_some(c);
|
||||
} else if n.style.stroke.is_none() && !lower.contains("transparent") {
|
||||
n.style.stroke = Some(Color::INK);
|
||||
}
|
||||
n.style.stroke_align = crate::model::StrokeAlign::Inside;
|
||||
set_dash(n, &style);
|
||||
}
|
||||
any
|
||||
}
|
||||
|
||||
fn apply_background(n: &mut Node, k: &str, v: &str, a: &mut Applied) -> bool {
|
||||
let lower = v.to_ascii_lowercase();
|
||||
if lower == "none" || lower == "transparent" {
|
||||
@@ -1510,6 +1708,21 @@ pub fn computed(n: &Node, parent_layout: Option<&AutoLayout>, is_root: bool) ->
|
||||
put("width", size_css(0, n.rect.w));
|
||||
put("height", size_css(1, n.rect.h));
|
||||
}
|
||||
if let Some(mw) = n.layout_child.min_size[0] {
|
||||
put("minWidth", px(mw));
|
||||
}
|
||||
if let Some(mh) = n.layout_child.min_size[1] {
|
||||
put("minHeight", px(mh));
|
||||
}
|
||||
if let Some(xw) = n.layout_child.max_size[0] {
|
||||
put("maxWidth", px(xw));
|
||||
}
|
||||
if let Some(xh) = n.layout_child.max_size[1] {
|
||||
put("maxHeight", px(xh));
|
||||
}
|
||||
if let Some(al) = n.layout_child.align_self {
|
||||
put("alignSelf", align_css(al).into());
|
||||
}
|
||||
if let Some(pl) = parent_layout
|
||||
&& in_flow
|
||||
{
|
||||
@@ -1521,7 +1734,7 @@ pub fn computed(n: &Node, parent_layout: Option<&AutoLayout>, is_root: bool) ->
|
||||
if n.layout_child.sizing[ma] == Sizing::Fill {
|
||||
put("flexGrow", "1".into());
|
||||
}
|
||||
if n.layout_child.sizing[1 - ma] == Sizing::Fill {
|
||||
if n.layout_child.align_self.is_none() && n.layout_child.sizing[1 - ma] == Sizing::Fill {
|
||||
put("alignSelf", "stretch".into());
|
||||
}
|
||||
put("flexShrink", "0".into());
|
||||
@@ -1635,10 +1848,23 @@ pub fn computed(n: &Node, parent_layout: Option<&AutoLayout>, is_root: bool) ->
|
||||
} else {
|
||||
"dashed"
|
||||
};
|
||||
put(
|
||||
"border",
|
||||
format!("{} {kind} {}", px(n.style.stroke_width), color_css(s)),
|
||||
);
|
||||
if let Some(sides) = n.style.stroke_sides
|
||||
&& sides.iter().any(|&w| (w - sides[0]).abs() > 1e-9)
|
||||
{
|
||||
for (i, key) in ["borderTop", "borderRight", "borderBottom", "borderLeft"]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
if sides[i] > 0.0 {
|
||||
put(key, format!("{} {kind} {}", px(sides[i]), color_css(s)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
put(
|
||||
"border",
|
||||
format!("{} {kind} {}", px(n.style.stroke_width), color_css(s)),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(r) = n.style.radii {
|
||||
put(
|
||||
@@ -1774,4 +2000,60 @@ mod tests {
|
||||
let r = n.style.rough.unwrap();
|
||||
assert_eq!((r.roughness, r.fill_style), (2.0, FillStyle::CrossHatch));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn border_sides_align_self_min_max_and_wavy_underline() {
|
||||
let mut n = Node::new(
|
||||
NodeId(4),
|
||||
NodeKind::Frame { clip: false },
|
||||
Rect::new(0.0, 0.0, 100.0, 20.0),
|
||||
);
|
||||
let a = apply(
|
||||
&mut n,
|
||||
&parse_inline(
|
||||
"border-left: 3px solid #5eead4; align-self: center; min-height: 44px; max-height: 120px",
|
||||
),
|
||||
&Context {
|
||||
parent_layout: Some(AutoLayout::default()),
|
||||
parent_size: Some((300.0, 300.0)),
|
||||
},
|
||||
);
|
||||
assert!(a.ignored.is_empty(), "{:?}", a.ignored);
|
||||
assert_eq!(n.style.stroke_sides, Some([0.0, 0.0, 0.0, 3.0]));
|
||||
assert_eq!(n.style.stroke_width, 3.0);
|
||||
assert_eq!(n.style.stroke, Some(Color::rgb(0x5e, 0xea, 0xd4)));
|
||||
assert_eq!(n.layout_child.align_self, Some(Align::Center));
|
||||
assert_eq!(n.layout_child.min_size[1], Some(44.0));
|
||||
assert_eq!(n.layout_child.max_size[1], Some(120.0));
|
||||
assert_eq!(n.rect.h, 44.0);
|
||||
let back = computed(&n, Some(&AutoLayout::default()), false);
|
||||
assert_eq!(back["borderLeft"], "3px solid #5eead4");
|
||||
assert!(!back.contains_key("border"));
|
||||
assert_eq!(back["alignSelf"], "center");
|
||||
assert_eq!(back["minHeight"], "44px");
|
||||
assert_eq!(back["maxHeight"], "120px");
|
||||
|
||||
let mut tn = Node::new(
|
||||
NodeId(5),
|
||||
NodeKind::Text {
|
||||
text: "Hello".into(),
|
||||
font_size: 16.0,
|
||||
auto_width: true,
|
||||
align: TextAlign::Left,
|
||||
},
|
||||
Rect::new(0.0, 0.0, 50.0, 20.0),
|
||||
);
|
||||
let ta = apply(
|
||||
&mut tn,
|
||||
&parse_inline(
|
||||
"text-decoration: underline; text-decoration-color: #f9e2af; text-decoration-style: wavy",
|
||||
),
|
||||
&Context::default(),
|
||||
);
|
||||
assert!(ta.ignored.is_empty(), "{:?}", ta.ignored);
|
||||
let sp = &tn.text_style.spans[0];
|
||||
assert_eq!(sp.underline, Some(true));
|
||||
assert_eq!(sp.underline_color, Some(Color::rgb(0xf9, 0xe2, 0xaf)));
|
||||
assert_eq!(sp.underline_wavy, Some(true));
|
||||
}
|
||||
}
|
||||
|
||||
+678
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
+669
-127
File diff suppressed because it is too large
Load Diff
+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.
|
||||
|
||||
+23
-6
@@ -142,7 +142,9 @@ struct Link {
|
||||
impl Link {
|
||||
fn new(shared: Arc<Shared>, ct: CancellationToken) -> Link {
|
||||
let dispatch = dispatch(shared.clone());
|
||||
// Served under a public hostname; the URL token is the credential, not the Host header.
|
||||
let config = StreamableHttpServerConfig::default()
|
||||
.disable_allowed_hosts()
|
||||
.enforce_origin_validation()
|
||||
.with_max_request_body_bytes(MAX_MCP_BODY_BYTES)
|
||||
.with_cancellation_token(ct);
|
||||
@@ -409,13 +411,17 @@ async fn serve_static(State(relay): State<Arc<Relay>>, uri: Uri) -> Response {
|
||||
}
|
||||
}
|
||||
|
||||
fn router(relay: Arc<Relay>) -> axum::Router {
|
||||
axum::Router::new()
|
||||
fn router(relay: Arc<Relay>, store: Option<Arc<super::store::Store>>) -> axum::Router {
|
||||
let app = axum::Router::new()
|
||||
.route("/tab/{token}", any(tab_socket))
|
||||
.route("/s/{token}/mcp", any(mcp_endpoint))
|
||||
.fallback(serve_static)
|
||||
.layer(axum::middleware::from_fn(super::server::fix_accept))
|
||||
.with_state(relay)
|
||||
.with_state(relay);
|
||||
match store {
|
||||
Some(store) => app.merge(super::store::router(store)),
|
||||
None => app,
|
||||
}
|
||||
}
|
||||
|
||||
/// A running relay; stops when dropped.
|
||||
@@ -455,6 +461,16 @@ pub fn spawn(
|
||||
listen: SocketAddr,
|
||||
static_dir: Option<PathBuf>,
|
||||
allow_origin: Option<String>,
|
||||
) -> Result<Running, String> {
|
||||
spawn_with_data(listen, static_dir, allow_origin, None)
|
||||
}
|
||||
|
||||
/// [`spawn`], plus `data`: where shared projects are kept (the `/p/` routes are off without it).
|
||||
pub fn spawn_with_data(
|
||||
listen: SocketAddr,
|
||||
static_dir: Option<PathBuf>,
|
||||
allow_origin: Option<String>,
|
||||
data: Option<super::store::Data>,
|
||||
) -> Result<Running, String> {
|
||||
let listener = std::net::TcpListener::bind(listen)
|
||||
.map_err(|e| format!("cannot listen on {listen}: {e}"))?;
|
||||
@@ -485,9 +501,10 @@ pub fn spawn(
|
||||
static_dir,
|
||||
ct: thread_ct.child_token(),
|
||||
});
|
||||
if let Err(e) = axum::serve(listener, router(relay))
|
||||
.with_graceful_shutdown(thread_ct.cancelled_owned())
|
||||
.await
|
||||
if let Err(e) =
|
||||
axum::serve(listener, router(relay, data.map(super::store::Store::new)))
|
||||
.with_graceful_shutdown(thread_ct.cancelled_owned())
|
||||
.await
|
||||
{
|
||||
log::error!("relay: server stopped: {e}");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
//! Shared projects: the relay keeps a project's files on disk so several tabs (and devices) can
|
||||
//! open and edit the same project.
|
||||
//!
|
||||
//! ```text
|
||||
//! GET /p/{token}/files [{"path","size","mtime"}]
|
||||
//! GET /p/{token}/f/{path} file bytes
|
||||
//! PUT /p/{token}/f/{path} write (creates the project); answers the file's mtime
|
||||
//! DELETE /p/{token}/f/{path} remove
|
||||
//! GET /p/{token}/ws?client=<id> pushes {"op","path","rev","client"} for other clients' changes
|
||||
//! ```
|
||||
//!
|
||||
//! The token is the only credential, like the agent link: anyone holding it can read and edit.
|
||||
//! A bad token or path is a plain 404.
|
||||
//!
|
||||
//! With `Data::library` the relay is a personal library instead: every project in the data
|
||||
//! directory is listed, so a device needs no link to find them (the network is the boundary):
|
||||
//!
|
||||
//! ```text
|
||||
//! GET /library JSON array of every project's token
|
||||
//! DELETE /p/{token} move the project to `.trash/` (kept on disk); later `files` answer 410
|
||||
//! ```
|
||||
|
||||
use super::relay_proto;
|
||||
use axum::body::Bytes;
|
||||
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::{DefaultBodyLimit, Path, Query, State};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::get;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Component, Path as FsPath, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
/// Biggest file a tab may upload (screenshots and assets are big).
|
||||
const MAX_FILE_BYTES: usize = 64 << 20;
|
||||
/// Changes buffered per project for a slow socket; a lagging tab resyncs from `/files`.
|
||||
const EVENT_BACKLOG: usize = 256;
|
||||
/// Header and query name a tab uses to identify itself, so it can skip its own echo.
|
||||
const CLIENT_HEADER: &str = "x-client";
|
||||
|
||||
/// One change, as pushed to the other tabs.
|
||||
#[derive(Serialize, Clone)]
|
||||
struct Event {
|
||||
op: &'static str,
|
||||
path: String,
|
||||
rev: u64,
|
||||
client: String,
|
||||
}
|
||||
|
||||
struct Project {
|
||||
rev: AtomicU64,
|
||||
tx: broadcast::Sender<Arc<Event>>,
|
||||
}
|
||||
|
||||
/// Where deleted library projects go, inside the data directory.
|
||||
const TRASH: &str = ".trash";
|
||||
|
||||
/// The `--data` directory, and whether it is a library (`--library`).
|
||||
#[derive(Clone)]
|
||||
pub struct Data {
|
||||
pub dir: PathBuf,
|
||||
pub library: bool,
|
||||
}
|
||||
|
||||
/// The `--data` directory and the live state of the projects in it.
|
||||
pub struct Store {
|
||||
root: PathBuf,
|
||||
library: bool,
|
||||
projects: Mutex<HashMap<String, Arc<Project>>>,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub fn new(data: Data) -> Arc<Store> {
|
||||
Arc::new(Store {
|
||||
root: data.dir,
|
||||
library: data.library,
|
||||
projects: Mutex::new(HashMap::new()),
|
||||
})
|
||||
}
|
||||
|
||||
fn project(&self, token: &str) -> Arc<Project> {
|
||||
let mut map = self.projects.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
map.entry(token.to_string())
|
||||
.or_insert_with(|| {
|
||||
Arc::new(Project {
|
||||
rev: AtomicU64::new(0),
|
||||
tx: broadcast::channel(EVENT_BACKLOG).0,
|
||||
})
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// True when `token` was deleted: its project sits in the trash and is gone from the root.
|
||||
fn was_deleted(&self, token: &str) -> bool {
|
||||
let prefix = format!("{token}-");
|
||||
std::fs::read_dir(self.root.join(TRASH)).is_ok_and(|mut d| {
|
||||
d.any(|e| e.is_ok_and(|e| e.file_name().to_string_lossy().starts_with(&prefix)))
|
||||
})
|
||||
}
|
||||
|
||||
fn publish(&self, token: &str, op: &'static str, path: String, client: String) {
|
||||
let project = self.project(token);
|
||||
let rev = project.rev.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
// No receiver is fine: nobody else has the project open.
|
||||
let _ = project.tx.send(Arc::new(Event {
|
||||
op,
|
||||
path,
|
||||
rev,
|
||||
client,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// `files/` relative path, or `None` for anything that could leave the project directory.
|
||||
fn clean(path: &str) -> Option<PathBuf> {
|
||||
let p = FsPath::new(path);
|
||||
if path.is_empty() || p.components().any(|c| !matches!(c, Component::Normal(_))) {
|
||||
return None;
|
||||
}
|
||||
Some(p.to_path_buf())
|
||||
}
|
||||
|
||||
fn not_found() -> Response {
|
||||
(StatusCode::NOT_FOUND, "not found").into_response()
|
||||
}
|
||||
|
||||
/// The project directory and cleaned file path for a request, or the 404 to answer.
|
||||
fn target(store: &Store, token: &str, path: &str) -> Result<(PathBuf, PathBuf), Response> {
|
||||
if !relay_proto::valid_token(token) {
|
||||
return Err(not_found());
|
||||
}
|
||||
let rel = clean(path).ok_or_else(not_found)?;
|
||||
let dir = store.root.join(token);
|
||||
Ok((dir.join(&rel), rel))
|
||||
}
|
||||
|
||||
fn client_of(headers: &HeaderMap) -> String {
|
||||
headers
|
||||
.get(CLIENT_HEADER)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Last-modified time in ns; clients compare it for equality only, to spot remote changes.
|
||||
fn mtime_ns(meta: &std::fs::Metadata) -> u64 {
|
||||
meta.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map_or(0, |d| d.as_nanos() as u64)
|
||||
}
|
||||
|
||||
fn walk(dir: &FsPath, base: &FsPath, out: &mut Vec<serde_json::Value>) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
walk(&p, base, out);
|
||||
} else if let (Ok(rel), Ok(meta)) = (p.strip_prefix(base), e.metadata()) {
|
||||
// Temp files of an upload in flight are not part of the project.
|
||||
let rel = rel.to_string_lossy().replace('\\', "/");
|
||||
if !rel.ends_with(".part") {
|
||||
out.push(serde_json::json!({ "path": rel, "size": meta.len(), "mtime": mtime_ns(&meta) }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn list(State(store): State<Arc<Store>>, Path(token): Path<String>) -> Response {
|
||||
if !relay_proto::valid_token(&token) {
|
||||
return not_found();
|
||||
}
|
||||
let dir = store.root.join(&token);
|
||||
if !dir.is_dir() {
|
||||
if store.was_deleted(&token) {
|
||||
return StatusCode::GONE.into_response();
|
||||
}
|
||||
return not_found();
|
||||
}
|
||||
let files = tokio::task::spawn_blocking(move || {
|
||||
let mut out = Vec::new();
|
||||
walk(&dir, &dir, &mut out);
|
||||
out
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
axum::Json(files).into_response()
|
||||
}
|
||||
|
||||
/// Every project's token (library mode).
|
||||
async fn library(State(store): State<Arc<Store>>) -> Response {
|
||||
let mut tokens: Vec<String> = std::fs::read_dir(&store.root)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter(|e| e.path().is_dir())
|
||||
.filter_map(|e| e.file_name().into_string().ok())
|
||||
.filter(|name| relay_proto::valid_token(name))
|
||||
.collect();
|
||||
tokens.sort();
|
||||
axum::Json(tokens).into_response()
|
||||
}
|
||||
|
||||
/// Delete a project: it moves to `.trash/<token>-<ms>` so nothing is lost on the server.
|
||||
async fn delete_project(State(store): State<Arc<Store>>, Path(token): Path<String>) -> Response {
|
||||
if !relay_proto::valid_token(&token) {
|
||||
return not_found();
|
||||
}
|
||||
let dir = store.root.join(&token);
|
||||
if !dir.is_dir() {
|
||||
return not_found();
|
||||
}
|
||||
let ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_millis());
|
||||
let trash = store.root.join(TRASH);
|
||||
let moved = tokio::fs::create_dir_all(&trash).await.is_ok()
|
||||
&& tokio::fs::rename(&dir, trash.join(format!("{token}-{ms}")))
|
||||
.await
|
||||
.is_ok();
|
||||
if !moved {
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
store
|
||||
.projects
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.remove(&token);
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
|
||||
async fn read(
|
||||
State(store): State<Arc<Store>>,
|
||||
Path((token, path)): Path<(String, String)>,
|
||||
) -> Response {
|
||||
let (file, _) = match target(&store, &token, &path) {
|
||||
Ok(t) => t,
|
||||
Err(r) => return r,
|
||||
};
|
||||
match tokio::fs::read(&file).await {
|
||||
Ok(bytes) => bytes.into_response(),
|
||||
Err(_) => not_found(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn write(
|
||||
State(store): State<Arc<Store>>,
|
||||
Path((token, path)): Path<(String, String)>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
let (file, rel) = match target(&store, &token, &path) {
|
||||
Ok(t) => t,
|
||||
Err(r) => return r,
|
||||
};
|
||||
if let Some(parent) = file.parent()
|
||||
&& tokio::fs::create_dir_all(parent).await.is_err()
|
||||
{
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
// Write beside, then rename, so a reader never sees a half-written file.
|
||||
let tmp = file.with_extension("part");
|
||||
if tokio::fs::write(&tmp, &body).await.is_err() || tokio::fs::rename(&tmp, &file).await.is_err()
|
||||
{
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
store.publish(
|
||||
&token,
|
||||
"put",
|
||||
rel.to_string_lossy().replace('\\', "/"),
|
||||
client_of(&headers),
|
||||
);
|
||||
// The new stamp, so the writer does not mistake its own upload for someone else's change.
|
||||
let stamp = tokio::fs::metadata(&file).await.map_or(0, |m| mtime_ns(&m));
|
||||
stamp.to_string().into_response()
|
||||
}
|
||||
|
||||
async fn remove(
|
||||
State(store): State<Arc<Store>>,
|
||||
Path((token, path)): Path<(String, String)>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
let (file, rel) = match target(&store, &token, &path) {
|
||||
Ok(t) => t,
|
||||
Err(r) => return r,
|
||||
};
|
||||
if tokio::fs::remove_file(&file).await.is_err() {
|
||||
return not_found();
|
||||
}
|
||||
store.publish(
|
||||
&token,
|
||||
"del",
|
||||
rel.to_string_lossy().replace('\\', "/"),
|
||||
client_of(&headers),
|
||||
);
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
|
||||
async fn socket(
|
||||
State(store): State<Arc<Store>>,
|
||||
Path(token): Path<String>,
|
||||
Query(q): Query<HashMap<String, String>>,
|
||||
upgrade: WebSocketUpgrade,
|
||||
) -> Response {
|
||||
if !relay_proto::valid_token(&token) {
|
||||
return not_found();
|
||||
}
|
||||
let me = q.get("client").cloned().unwrap_or_default();
|
||||
let rx = store.project(&token).tx.subscribe();
|
||||
upgrade
|
||||
.on_upgrade(move |s| serve(s, rx, me))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// Push other clients' changes until the socket closes.
|
||||
async fn serve(mut socket: WebSocket, mut rx: broadcast::Receiver<Arc<Event>>, me: String) {
|
||||
loop {
|
||||
tokio::select! {
|
||||
ev = rx.recv() => match ev {
|
||||
Ok(ev) if ev.client != me || me.is_empty() => {
|
||||
let Ok(json) = serde_json::to_string(&*ev) else { continue };
|
||||
if socket.send(Message::Text(json.into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(_) => {}
|
||||
// Missed events: tell the tab to resync everything.
|
||||
Err(broadcast::error::RecvError::Lagged(_)) => {
|
||||
let resync = r#"{"op":"resync"}"#;
|
||||
if socket.send(Message::Text(resync.into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
},
|
||||
m = socket.recv() => match m {
|
||||
Some(Ok(Message::Close(_))) | Some(Err(_)) | None => break,
|
||||
Some(Ok(_)) => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn router(store: Arc<Store>) -> axum::Router {
|
||||
let mut app = axum::Router::new()
|
||||
.route("/p/{token}/files", get(list))
|
||||
.route("/p/{token}/f/{*path}", get(read).put(write).delete(remove))
|
||||
.route("/p/{token}/ws", get(socket));
|
||||
if store.library {
|
||||
app = app
|
||||
.route("/library", get(library))
|
||||
.route("/p/{token}", axum::routing::delete(delete_project));
|
||||
}
|
||||
app.layer(DefaultBodyLimit::max(MAX_FILE_BYTES))
|
||||
.with_state(store)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn paths_stay_inside_the_project() {
|
||||
assert_eq!(clean("pages/a.json"), Some(PathBuf::from("pages/a.json")));
|
||||
for bad in ["", "../x", "a/../../x", "/etc/passwd", "./x"] {
|
||||
assert_eq!(clean(bad), None, "{bad:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+124
-37
@@ -187,6 +187,7 @@ pub fn definitions() -> Vec<Value> {
|
||||
json!({
|
||||
"nodeId": node_id("The node to capture; omit for the whole page."),
|
||||
"scale": {"type": "number", "description": "Render scale: 1 (default) or 2."},
|
||||
"out": {"type": "string", "description": "Optional file path to save the PNG to (also used by papercraft-cli)."},
|
||||
"pageId": page_id(),
|
||||
}),
|
||||
&[],
|
||||
@@ -194,15 +195,51 @@ pub fn definitions() -> Vec<Value> {
|
||||
),
|
||||
tool(
|
||||
"get_jsx",
|
||||
"JSX for a node and its descendants with inline styles (frames become flex divs, text \
|
||||
becomes spans, vectors become inline SVG). Use it to export a design to code.",
|
||||
"Export a node and its descendants to code: \"inline\" (default, React JSX with inline \
|
||||
styles), \"tailwind\" (React JSX with Tailwind CSS utility classes), \"react\" (React \
|
||||
TSX component), \"angular\" (Angular v17+ standalone component), \"html\" \
|
||||
(self-contained <style> + HTML markup), or \"egui\" (Rust egui 0.36 code with tokens).",
|
||||
json!({
|
||||
"nodeId": node_id("The node to convert."),
|
||||
"format": {"type": "string", "enum": ["inline-styles", "tailwind"], "description": "Only inline styles are produced; \"tailwind\" is accepted for compatibility."},
|
||||
"format": {"type": "string", "enum": ["inline", "inline-styles", "tailwind", "react", "angular", "html", "egui"], "description": "Output format: \"inline\" (default), \"tailwind\", \"react\", \"angular\", \"html\", or \"egui\"."},
|
||||
"pageId": page_id(),
|
||||
}),
|
||||
&["nodeId"],
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"get_design_spec",
|
||||
"Export a deterministic AI Design-to-Code specification for a frame or subtree. \
|
||||
Includes all design tokens/variables, component instances, exact auto-layout tree \
|
||||
(sizing, flex/grid/egui flow, gaps, paddings, bound `--var` colors), mandatory \
|
||||
enforcement rules, and reference target code. Always call this before implementing \
|
||||
a design in React, Tailwind, Angular, HTML/CSS, or Rust egui instead of guessing \
|
||||
from a screenshot.",
|
||||
json!({
|
||||
"nodeId": node_id("Root frame or node id to export."),
|
||||
"target": {"type": "string", "enum": ["react", "tailwind", "angular", "html", "egui"], "description": "Target UI framework (default \"react\")."},
|
||||
"format": {"type": "string", "enum": ["markdown", "json"], "description": "Output format: \"markdown\" (default) or \"json\"."},
|
||||
"pageId": page_id(),
|
||||
}),
|
||||
&["nodeId"],
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"verify_code",
|
||||
"Verify that AI-generated UI code (`react`, `tailwind`, `angular`, `html`, or `egui`) \
|
||||
accurately implements a design subtree. Checks for missing text strings, hardcoded \
|
||||
hex colors where `--var` or `tokens::*` must be used, forbidden `position: absolute` \
|
||||
on auto-layout flow children, missing flex/grid/egui layout containers, unreferenced \
|
||||
component instances, and spacing drift. Returns `{passed, score, errors, warnings, violations}`.",
|
||||
json!({
|
||||
"nodeId": node_id("Root frame or node id that the code implements."),
|
||||
"target": {"type": "string", "enum": ["react", "tailwind", "angular", "html", "egui"], "description": "Target UI framework."},
|
||||
"code": {"type": "string", "description": "The generated UI source code to verify against the design."},
|
||||
"pageId": page_id(),
|
||||
}),
|
||||
&["nodeId", "target", "code"],
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"get_computed_styles",
|
||||
"CSS styles (camelCase) of one or more nodes, as {nodeId: styles}.",
|
||||
@@ -309,22 +346,22 @@ pub fn definitions() -> Vec<Value> {
|
||||
- mode \"insert-children\" appends the HTML inside targetNodeId (\"root\" = the page); \
|
||||
\"replace\" replaces targetNodeId with it.\n\
|
||||
- Inline styles only. Flexbox (display:flex, flex-direction, gap, padding, \
|
||||
align-items, justify-content, flex-grow, flex-wrap) is the layout model; block \
|
||||
align-items, justify-content, align-self, flex-grow, flex-wrap) is the layout model; block \
|
||||
elements stack vertically. position:absolute with left/top is supported. No margin, \
|
||||
grid or tables.\n\
|
||||
- Text: font-size, font-weight (≥600 = bold), font-style, color, line-height, \
|
||||
letter-spacing, text-align, text-decoration; <b>/<i>/<u>/<s>/<span style> make rich \
|
||||
text. Font family is always Inter.\n\
|
||||
- Boxes: background(-color/-image: colors, linear/radial gradients, url()), border, \
|
||||
border-radius, opacity, overflow:hidden, mix-blend-mode, width/height (px, %, \
|
||||
fit-content).\n\
|
||||
letter-spacing, text-align, text-decoration (including wavy and text-decoration-color); \
|
||||
<b>/<i>/<u>/<s>/<span style> make rich text. Font family is always Inter.\n\
|
||||
- Boxes: background(-color/-image: colors, linear/radial gradients, url()), border \
|
||||
(and border-top/right/bottom/left), border-radius, opacity, overflow:hidden, \
|
||||
mix-blend-mode, width/height (px, %, fit-content), min-width/max-width/min-height/max-height.\n\
|
||||
- <img src> takes paper-asset:///abs/path, file:// URLs, absolute paths or data: URIs. \
|
||||
<svg> markup is imported as vector layers.\n\
|
||||
- <x-paper-icon name=\"set:name\" size=\"20\" color=\"var(--accent)\"/> places an installed icon (search_icons); \
|
||||
<x-paper-instance component-id=\"ID\" layer-name=\"...\"/> places a component instance; \
|
||||
<x-paper-instance component=\"Name\" component-id=\"ID\" layer-name=\"...\"/> places a component instance; \
|
||||
colors may be var(--name) of set_variables.\n\
|
||||
- layer-name=\"...\" names layers; <x-paper-clone node-id=\"ID\" style=\"...\"/> \
|
||||
clones an existing node.\n\
|
||||
clones an existing node (supports cross-page `<page>:<id>`).\n\
|
||||
Returns the ids of the created top-level layers and the styles that were ignored.",
|
||||
json!({
|
||||
"html": {"type": "string", "description": "HTML to parse into layers."},
|
||||
@@ -405,10 +442,11 @@ pub fn definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"get_feedback",
|
||||
"Notes the user left on layers (the Feedback box in the inspector, shown as amber dots \
|
||||
on the canvas): which layer, where it sits, what to change. Call it at the start of a \
|
||||
session and after each round of edits; fix every open note, then resolve_feedback. \
|
||||
get_basic_info reports `openFeedback`, and get_selection includes the selected \
|
||||
layers' notes.",
|
||||
on the canvas): which layer, where it sits, what to change. Omitting `pageId` queries \
|
||||
all pages across the project; `openCount` and `resolvedCount` are returned alongside \
|
||||
per-page counts in `get_basic_info`. Call it at the start of a session and after each \
|
||||
round of edits; fix every open note, then resolve_feedback. `get_selection` also \
|
||||
includes the selected layers' notes.",
|
||||
json!({"nodeId": {"type": "string", "description": "Only this layer."},
|
||||
"pageId": page_id(),
|
||||
"includeResolved": {"type": "boolean"}}),
|
||||
@@ -440,10 +478,13 @@ pub fn definitions() -> Vec<Value> {
|
||||
contrast (WCAG 4.5:1, 3:1 for large text), text under 12px, font sizes off the type \
|
||||
scale, gap/padding off the 4px grid, buttons and tabs under 44px tall, default layer \
|
||||
names (Frame, Rectangle), and hard-coded colors that equal a variable. Run it before \
|
||||
finish_working_on_nodes and fix errors and warnings; `info` items are suggestions.",
|
||||
finish_working_on_nodes and fix errors and warnings; `info` items are suggestions. \
|
||||
Pass `fix: true` to automatically snap spacing/padding to 4px, relink matching color \
|
||||
variables, snap text sizes, and enlarge small touch targets.",
|
||||
json!({"nodeId": {"type": "string", "description": "Subtree to check; omit for the whole page."},
|
||||
"scale": {"type": "array", "items": {"type": "number"},
|
||||
"description": "Allowed font sizes. Default 12, 14, 16, 20, 24, 32, 40, 48, 64."},
|
||||
"fix": {"type": "boolean", "description": "Apply safe automatic fixes before reporting remaining issues."},
|
||||
"pageId": page_id()}),
|
||||
&[],
|
||||
true,
|
||||
@@ -452,9 +493,10 @@ pub fn definitions() -> Vec<Value> {
|
||||
"get_variables",
|
||||
"Color variables of the page's project (name -> hex). Fills that follow a variable \
|
||||
were written with `var(--name)`; set_variables moves all of them at once. \
|
||||
`format` css returns a `:root { --name: #hex }` block, json a design-tokens object.",
|
||||
`format` css returns a `:root { --name: #hex }` block (plus `[data-theme]` rules for modes), \
|
||||
json a W3C DTCG design-tokens object, and tailwind a Tailwind v4 `@theme` block.",
|
||||
json!({"pageId": page_id(),
|
||||
"format": {"type": "string", "enum": ["css", "json"]}}),
|
||||
"format": {"type": "string", "enum": ["css", "json", "tailwind"]}}),
|
||||
&[],
|
||||
true,
|
||||
),
|
||||
@@ -503,11 +545,11 @@ pub fn definitions() -> Vec<Value> {
|
||||
),
|
||||
tool(
|
||||
"import_components",
|
||||
"Copy the main components of another page (typically a `Design System` page) onto this \
|
||||
one, so you can place instances here with create_instance or \
|
||||
<x-paper-instance component=\"Name\"/>. Components with the same name already here \
|
||||
are skipped. Variables and tokens are already shared by every page; components are \
|
||||
copies, not live links.",
|
||||
"Copy the main components anywhere on `fromPageId` (including inside artboards, \
|
||||
typically on a `Design System` page) onto this one, so you can place instances here \
|
||||
with create_instance or <x-paper-instance component=\"Name\"/> / component-id=\"ID\". \
|
||||
Components with the same name already here are skipped. Variables and tokens are \
|
||||
already shared by every page; components are copies, not live links.",
|
||||
json!({"fromPageId": {"type": "string"}, "pageId": page_id(),
|
||||
"names": {"type": "array", "items": {"type": "string"},
|
||||
"description": "Only these components (default all)."}}),
|
||||
@@ -539,6 +581,25 @@ pub fn definitions() -> Vec<Value> {
|
||||
&["componentId"],
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"swap_instance",
|
||||
"Swap an instance root to point at a different main component on the same page, \
|
||||
preserving overrides on descendants matched by name and layer type.",
|
||||
json!({"nodeId": node_id("The instance root node to swap."),
|
||||
"componentId": node_id("The new main component id."),
|
||||
"pageId": page_id()}),
|
||||
&["nodeId", "componentId"],
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"detach_instance",
|
||||
"Detach one or more component instances into plain frames, breaking their link to \
|
||||
the main component while keeping their current appearance and children.",
|
||||
json!({"nodeIds": ids("Instance root nodes to detach."),
|
||||
"pageId": page_id()}),
|
||||
&["nodeIds"],
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"search_icons",
|
||||
"Search installed open icon sets (Lucide, Tabler, Phosphor, Heroicons, Material). \
|
||||
@@ -559,12 +620,18 @@ pub fn definitions() -> Vec<Value> {
|
||||
),
|
||||
tool(
|
||||
"duplicate_nodes",
|
||||
"Deep-clone nodes. Each copy goes under parentId when given, else next to its source; \
|
||||
duplicated artboards are placed in an empty spot. Returns new ids and a \
|
||||
descendantIdMap from every original descendant id to its copy.",
|
||||
"Deep-clone nodes, including across pages (via `toPageId` or a cross-page `parentId`; \
|
||||
`<x-paper-clone node-id=\"<page>:<id>\"/>` also works across pages). Each copy goes \
|
||||
under parentId when given, else next to its source; duplicated artboards are placed \
|
||||
in an empty spot. Returns new ids and a descendantIdMap from every original \
|
||||
descendant id to its copy.",
|
||||
json!({"nodes": {"type": "array", "items": {
|
||||
"type": "object",
|
||||
"properties": {"id": {"type": "string"}, "parentId": {"type": "string"}},
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"parentId": {"type": "string"},
|
||||
"toPageId": {"type": "string", "description": "Optional destination page id when copying/moving to another page."},
|
||||
},
|
||||
"required": ["id"],
|
||||
}}}),
|
||||
&["nodes"],
|
||||
@@ -572,15 +639,17 @@ pub fn definitions() -> Vec<Value> {
|
||||
),
|
||||
tool(
|
||||
"move_nodes",
|
||||
"Move nodes keeping their ids. Each move is {nodeId, before: siblingId}, {nodeId, \
|
||||
after: siblingId} or {nodeId, parentId, index?} (parentId \"root\" = the page root). \
|
||||
Moves apply in order. In auto-layout parents this changes the visual order; in free \
|
||||
parents it changes stacking and keeps the world position.",
|
||||
"Move nodes keeping their ids (or onto another page via `toPageId` or a cross-page \
|
||||
`parentId` / `before` / `after`). Each move is {nodeId, before: siblingId}, {nodeId, \
|
||||
after: siblingId} or {nodeId, parentId, toPageId?, index?} (parentId \"root\" = the \
|
||||
page root). Moves apply in order. In auto-layout parents this changes the visual \
|
||||
order; in free parents it changes stacking and keeps the world position.",
|
||||
json!({"moves": {"type": "array", "minItems": 1, "items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"nodeId": {"type": "string"}, "before": {"type": "string"},
|
||||
"after": {"type": "string"}, "parentId": {"type": "string"},
|
||||
"toPageId": {"type": "string", "description": "Optional destination page id when copying/moving to another page."},
|
||||
"index": {"type": "integer", "minimum": 0},
|
||||
},
|
||||
"required": ["nodeId"],
|
||||
@@ -610,6 +679,19 @@ pub fn definitions() -> Vec<Value> {
|
||||
&[],
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"diff_frames",
|
||||
"Compare two frames or subtrees (`beforeId` and `afterId`) and report property-level \
|
||||
differences (size, position, fills, stroke, corner radius, opacity, text, layout, \
|
||||
added/removed children).",
|
||||
json!({
|
||||
"beforeId": node_id("First frame or node id (baseline)."),
|
||||
"afterId": node_id("Second frame or node id (comparison)."),
|
||||
"pageId": page_id(),
|
||||
}),
|
||||
&["beforeId", "afterId"],
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"finish_working_on_nodes",
|
||||
"Call when done creating or editing. Saves the project.",
|
||||
@@ -652,17 +734,22 @@ pub const GUIDE_HTML: &str = "\
|
||||
Elements: div/section/header/footer/nav/main/article/aside/ul/ol/li/button/form → frames;
|
||||
p/h1–h6/span/label/a/strong/em/code/pre (elements holding only text) → text layers (with a
|
||||
frame around them when they have a background, border or padding); img → image rectangle;
|
||||
svg → vector layers; br → line break; x-paper-clone node-id=ID → copy of an existing layer.
|
||||
svg → vector layers; br → line break;
|
||||
<x-paper-instance component=\"Name\" component-id=\"ID\" overrides='{\"Label\":\"Save\"}'/> → component instance;
|
||||
<x-paper-clone node-id=\"ID\"/> → copy of an existing layer (supports cross-page `<page>:<id>`).
|
||||
|
||||
Default sizes (like CSS): h1 32px bold, h2 24px bold, h3 20px bold, h4–h6 16px bold, p 16px,
|
||||
small 13px, code/pre 14px; color is inherited, default black.
|
||||
|
||||
Styles: width, height (px, %, fit-content), left/top/right/bottom with position:absolute,
|
||||
display (flex, block, none), flex-direction, flex-wrap, gap, row-gap, column-gap, padding*,
|
||||
justify-content, align-items, align-self, flex-grow, overflow, background, background-color,
|
||||
Styles: width, height (px, %, fit-content), min-width, max-width, min-height, max-height,
|
||||
left/top/right/bottom with position:absolute, display (flex, block, none), flex-direction,
|
||||
flex-wrap, gap, row-gap, column-gap, padding*, justify-content, align-items,
|
||||
align-self (start, center, end, stretch), flex-grow, overflow, background, background-color,
|
||||
background-image (url(), linear-gradient(), radial-gradient()), background-size/object-fit,
|
||||
border, border-color/width/style, border-radius (1–4 values), opacity, mix-blend-mode, color,
|
||||
font-size, font-weight, font-style, line-height, letter-spacing, text-align, text-decoration.
|
||||
border, border-top/border-right/border-bottom/border-left, border-color/width/style,
|
||||
border-radius (1–4 values), opacity, mix-blend-mode, color, font-size, font-weight, font-style,
|
||||
line-height, letter-spacing, text-align, text-decoration (including `text-decoration-color` and
|
||||
`text-decoration-style: wavy`, e.g. `text-decoration: underline wavy var(--warning)`).
|
||||
Ignored (reported back): margin, grid, box-shadow, transform, filters, transitions.";
|
||||
|
||||
pub const GUIDE_WHITEBOARD: &str = "\
|
||||
|
||||
@@ -88,6 +88,9 @@ pub struct Style {
|
||||
pub fills: Vec<Paint>,
|
||||
pub stroke: Option<Color>,
|
||||
pub stroke_width: f64,
|
||||
/// Per-side stroke widths (top, right, bottom, left); overrides uniform `stroke_width` when set.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub stroke_sides: Option<[f64; 4]>,
|
||||
#[serde(skip_serializing_if = "is_default")]
|
||||
pub stroke_align: StrokeAlign,
|
||||
/// Dash pattern (lengths in px); empty = solid.
|
||||
@@ -133,6 +136,7 @@ impl Default for Style {
|
||||
fills: Vec::new(),
|
||||
stroke: None,
|
||||
stroke_width: 1.0,
|
||||
stroke_sides: None,
|
||||
stroke_align: StrokeAlign::Center,
|
||||
dash: Vec::new(),
|
||||
cap: Cap::Butt,
|
||||
@@ -434,6 +438,9 @@ impl Node {
|
||||
pub fn open_comments(&self) -> usize {
|
||||
self.comments.iter().filter(|c| !c.resolved).count()
|
||||
}
|
||||
pub fn resolved_comments(&self) -> usize {
|
||||
self.comments.iter().filter(|c| c.resolved).count()
|
||||
}
|
||||
}
|
||||
|
||||
/// One anchor of a vector path, with optional bezier handles (node-local, absolute).
|
||||
|
||||
+49
-15
@@ -306,6 +306,10 @@ pub struct TextSpan {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub underline: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub underline_color: Option<Color>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub underline_wavy: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub strike: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -338,6 +342,8 @@ pub struct CharStyle {
|
||||
pub size: f64,
|
||||
pub color: Color,
|
||||
pub underline: bool,
|
||||
pub underline_color: Option<Color>,
|
||||
pub underline_wavy: bool,
|
||||
pub strike: bool,
|
||||
}
|
||||
|
||||
@@ -349,6 +355,8 @@ impl TextStyle {
|
||||
size,
|
||||
color,
|
||||
underline: false,
|
||||
underline_color: None,
|
||||
underline_wavy: false,
|
||||
strike: false,
|
||||
};
|
||||
for sp in &self.spans {
|
||||
@@ -368,6 +376,12 @@ impl TextStyle {
|
||||
if let Some(u) = sp.underline {
|
||||
s.underline = u;
|
||||
}
|
||||
if let Some(uc) = sp.underline_color {
|
||||
s.underline_color = Some(uc);
|
||||
}
|
||||
if let Some(uw) = sp.underline_wavy {
|
||||
s.underline_wavy = uw;
|
||||
}
|
||||
if let Some(u) = sp.strike {
|
||||
s.strike = u;
|
||||
}
|
||||
@@ -440,13 +454,6 @@ impl TextStyle {
|
||||
let mut any = false;
|
||||
for s in self.spans.iter().filter(|s| s.start <= a && s.end >= b) {
|
||||
any = true;
|
||||
m.bold = s.bold.or(m.bold);
|
||||
m.italic = s.italic.or(m.italic);
|
||||
m.size = s.size.or(m.size);
|
||||
m.color = s.color.or(m.color);
|
||||
m.underline = s.underline.or(m.underline);
|
||||
m.strike = s.strike.or(m.strike);
|
||||
// Later spans take precedence.
|
||||
if s.bold.is_some() {
|
||||
m.bold = s.bold;
|
||||
}
|
||||
@@ -462,6 +469,12 @@ impl TextStyle {
|
||||
if s.underline.is_some() {
|
||||
m.underline = s.underline;
|
||||
}
|
||||
if s.underline_color.is_some() {
|
||||
m.underline_color = s.underline_color;
|
||||
}
|
||||
if s.underline_wavy.is_some() {
|
||||
m.underline_wavy = s.underline_wavy;
|
||||
}
|
||||
if s.strike.is_some() {
|
||||
m.strike = s.strike;
|
||||
}
|
||||
@@ -471,18 +484,26 @@ impl TextStyle {
|
||||
&& m.size.is_none()
|
||||
&& m.color.is_none()
|
||||
&& m.underline.is_none()
|
||||
&& m.underline_color.is_none()
|
||||
&& m.underline_wavy.is_none()
|
||||
&& m.strike.is_none();
|
||||
if !any || empty {
|
||||
continue;
|
||||
}
|
||||
let key = |x: &TextSpan| {
|
||||
(
|
||||
x.bold,
|
||||
x.italic,
|
||||
x.size,
|
||||
x.color,
|
||||
x.underline,
|
||||
x.underline_color,
|
||||
x.underline_wavy,
|
||||
x.strike,
|
||||
)
|
||||
};
|
||||
match out.last_mut() {
|
||||
Some(l)
|
||||
if l.end == a
|
||||
&& (l.bold, l.italic, l.size, l.color, l.underline, l.strike)
|
||||
== (m.bold, m.italic, m.size, m.color, m.underline, m.strike) =>
|
||||
{
|
||||
l.end = b
|
||||
}
|
||||
Some(l) if l.end == a && key(l) == key(&m) => l.end = b,
|
||||
_ => out.push(m),
|
||||
}
|
||||
}
|
||||
@@ -573,8 +594,12 @@ pub struct Constraints {
|
||||
pub v: Constraint,
|
||||
}
|
||||
|
||||
fn is_none_pair(p: &[Option<f64>; 2]) -> bool {
|
||||
p[0].is_none() && p[1].is_none()
|
||||
}
|
||||
|
||||
/// Per-node layout participation (as a child of an auto-layout or constrained frame).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct LayoutChild {
|
||||
/// Horizontal and vertical sizing.
|
||||
@@ -582,6 +607,15 @@ pub struct LayoutChild {
|
||||
/// Ignore auto layout (positioned by constraints instead).
|
||||
pub absolute: bool,
|
||||
pub constraints: Constraints,
|
||||
/// Per-child cross-axis alignment override (`align-self: start | center | end`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub align_self: Option<Align>,
|
||||
/// Minimum `[width, height]` in px (`min-width`, `min-height`).
|
||||
#[serde(default, skip_serializing_if = "is_none_pair")]
|
||||
pub min_size: [Option<f64>; 2],
|
||||
/// Maximum `[width, height]` in px (`max-width`, `max-height`).
|
||||
#[serde(default, skip_serializing_if = "is_none_pair")]
|
||||
pub max_size: [Option<f64>; 2],
|
||||
}
|
||||
|
||||
impl LayoutChild {
|
||||
|
||||
@@ -229,6 +229,8 @@ impl Store {
|
||||
let name = self.unique_name(&format!("{} copy", src.manifest.name));
|
||||
let target = self.unique_dir(&self.root, &name);
|
||||
copy_dir(dir, &target)?;
|
||||
// A copy is its own project, not another editor of the shared one.
|
||||
crate::vfs::remove_file(target.join(crate::sync::SHARE_FILE)).ok();
|
||||
let mut p = Project::open(&target)?;
|
||||
p.manifest.id = new_id();
|
||||
p.manifest.name = name;
|
||||
|
||||
+123
-17
@@ -354,18 +354,88 @@ pub fn node_draw(n: &Node, wr: Rect, opacity: f64) -> NodeDraw {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(c) = n.style.stroke
|
||||
&& n.style.stroke_width > 0.0
|
||||
&& let Some(sp) = stroke_path(n, wr)
|
||||
{
|
||||
ops.push(DrawOp::Stroke {
|
||||
path: sp,
|
||||
color: rgba(c, opacity),
|
||||
width: n.style.stroke_width,
|
||||
cap: n.style.cap,
|
||||
join: n.style.join,
|
||||
dash: n.style.dash.clone(),
|
||||
});
|
||||
if let Some(c) = n.style.stroke {
|
||||
if let Some(sides) = n.style.stroke_sides
|
||||
&& matches!(n.kind, NodeKind::Frame { .. } | NodeKind::Rect)
|
||||
&& sides.iter().any(|&w| (w - sides[0]).abs() > 1e-9)
|
||||
{
|
||||
let col = rgba(c, opacity);
|
||||
let side_specs = [
|
||||
(
|
||||
sides[0],
|
||||
Rect::new(wr.x, wr.y, wr.w, sides[0].min(wr.h)),
|
||||
[
|
||||
v2(wr.x, wr.y + sides[0] * 0.5),
|
||||
v2(wr.right(), wr.y + sides[0] * 0.5),
|
||||
],
|
||||
),
|
||||
(
|
||||
sides[1],
|
||||
Rect::new(
|
||||
(wr.right() - sides[1]).max(wr.x),
|
||||
wr.y,
|
||||
sides[1].min(wr.w),
|
||||
wr.h,
|
||||
),
|
||||
[
|
||||
v2(wr.right() - sides[1] * 0.5, wr.y),
|
||||
v2(wr.right() - sides[1] * 0.5, wr.bottom()),
|
||||
],
|
||||
),
|
||||
(
|
||||
sides[2],
|
||||
Rect::new(
|
||||
wr.x,
|
||||
(wr.bottom() - sides[2]).max(wr.y),
|
||||
wr.w,
|
||||
sides[2].min(wr.h),
|
||||
),
|
||||
[
|
||||
v2(wr.x, wr.bottom() - sides[2] * 0.5),
|
||||
v2(wr.right(), wr.bottom() - sides[2] * 0.5),
|
||||
],
|
||||
),
|
||||
(
|
||||
sides[3],
|
||||
Rect::new(wr.x, wr.y, sides[3].min(wr.w), wr.h),
|
||||
[
|
||||
v2(wr.x + sides[3] * 0.5, wr.y),
|
||||
v2(wr.x + sides[3] * 0.5, wr.bottom()),
|
||||
],
|
||||
),
|
||||
];
|
||||
for (w, sr, line_pts) in side_specs {
|
||||
if w > 0.0 && sr.w > 0.0 && sr.h > 0.0 {
|
||||
if !n.style.dash.is_empty() {
|
||||
ops.push(DrawOp::Stroke {
|
||||
path: VPath::polyline(&line_pts, false),
|
||||
color: col,
|
||||
width: w,
|
||||
cap: Cap::Butt,
|
||||
join: n.style.join,
|
||||
dash: n.style.dash.clone(),
|
||||
});
|
||||
} else {
|
||||
ops.push(DrawOp::Fill {
|
||||
path: vector::rounded_rect(sr, [0.0; 4]),
|
||||
paint: PaintSpec::Solid(col),
|
||||
even_odd: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if n.style.stroke_width > 0.0
|
||||
&& let Some(sp) = stroke_path(n, wr)
|
||||
{
|
||||
ops.push(DrawOp::Stroke {
|
||||
path: sp,
|
||||
color: rgba(c, opacity),
|
||||
width: n.style.stroke_width,
|
||||
cap: n.style.cap,
|
||||
join: n.style.join,
|
||||
dash: n.style.dash.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -464,19 +534,55 @@ fn text_ops(n: &Node, wr: Rect, opacity: f64, ops: &mut Vec<DrawOp>) {
|
||||
glyphs,
|
||||
});
|
||||
}
|
||||
// Underline / strikethrough as thin rectangles.
|
||||
// Underline / strikethrough as thin rectangles (or wavy stroke for wavy underlines).
|
||||
for g in l.glyphs.iter().filter(|g| g.underline || g.strike) {
|
||||
let th = (g.size / 14.0).max(0.5);
|
||||
for (on, dy) in [(g.underline, g.size * 0.12), (g.strike, -g.size * 0.28)] {
|
||||
if on {
|
||||
let r = Rect::new(o.x + g.x, o.y + g.baseline + dy, g.advance, th);
|
||||
if g.underline {
|
||||
let u_col = g.underline_color.unwrap_or(g.color);
|
||||
if g.underline_wavy {
|
||||
let x0 = o.x + g.x;
|
||||
let x1 = x0 + g.advance;
|
||||
let y0 = o.y + g.baseline + g.size * 0.14;
|
||||
let step = (g.size * 0.14).max(1.8);
|
||||
let amp = (g.size * 0.06).max(0.8);
|
||||
let mut pts = Vec::new();
|
||||
let mut x = x0;
|
||||
let mut k = 0usize;
|
||||
while x < x1 - 1e-6 {
|
||||
let dy = if k.is_multiple_of(2) { -amp } else { amp };
|
||||
pts.push(v2(x, y0 + dy));
|
||||
x += step;
|
||||
k += 1;
|
||||
}
|
||||
let dy = if k.is_multiple_of(2) { -amp } else { amp };
|
||||
pts.push(v2(x1, y0 + dy));
|
||||
if pts.len() >= 2 {
|
||||
ops.push(DrawOp::Stroke {
|
||||
path: VPath::polyline(&pts, false),
|
||||
color: rgba(u_col, opacity),
|
||||
width: th,
|
||||
cap: Cap::Round,
|
||||
join: Join::Round,
|
||||
dash: Vec::new(),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let r = Rect::new(o.x + g.x, o.y + g.baseline + g.size * 0.12, g.advance, th);
|
||||
ops.push(DrawOp::Fill {
|
||||
path: vector::rounded_rect(r, [0.0; 4]),
|
||||
paint: PaintSpec::Solid(rgba(g.color, opacity)),
|
||||
paint: PaintSpec::Solid(rgba(u_col, opacity)),
|
||||
even_odd: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
if g.strike {
|
||||
let r = Rect::new(o.x + g.x, o.y + g.baseline - g.size * 0.28, g.advance, th);
|
||||
ops.push(DrawOp::Fill {
|
||||
path: vector::rounded_rect(r, [0.0; 4]),
|
||||
paint: PaintSpec::Solid(rgba(g.color, opacity)),
|
||||
even_odd: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -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());
|
||||
|
||||
+639
@@ -0,0 +1,639 @@
|
||||
//! Keep a project folder in step with a shared copy on a relay (`mcp::store`), from the browser
|
||||
//! and from the native app alike (everything goes through [`crate::vfs`]).
|
||||
//!
|
||||
//! A shared project has a `.share` file in its folder: the server, the share token, and the
|
||||
//! state of the last sync. Every [`POLL`] the engine compares three views of each file, the
|
||||
//! server's, the folder's and the last synced one:
|
||||
//!
|
||||
//! * changed in the folder: upload (or delete) it. The folder wins a simultaneous change, so
|
||||
//! whoever is typing keeps their work and the other side sees it on its next poll;
|
||||
//! * changed on the server only: download (or delete) it, and flag [`Status::take_changed`] so
|
||||
//! the shell reloads the open project.
|
||||
//!
|
||||
//! Granularity is a whole file (one page), last write wins.
|
||||
//!
|
||||
//! A relay started with `--library` is a personal library: [`Sync::enable_library`] lists it
|
||||
//! every [`LIBRARY_POLL`], joins the projects this device does not have and shares the local
|
||||
//! ones, so every device that opens the server ends up with the same projects. The sync loops
|
||||
//! above do the actual file work; the library only writes `.share` files.
|
||||
|
||||
use crate::mcp::relay_proto;
|
||||
use crate::vfs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
use std::time::Duration;
|
||||
|
||||
/// File in a project folder that marks it as shared.
|
||||
pub const SHARE_FILE: &str = ".share";
|
||||
/// How often a shared project asks the server what changed.
|
||||
const POLL: Duration = Duration::from_secs(2);
|
||||
/// How often a library device looks for projects added or deleted elsewhere.
|
||||
const LIBRARY_POLL: Duration = Duration::from_secs(5);
|
||||
/// Files that stay on this machine: the sync state and the home-screen preview.
|
||||
const LOCAL_ONLY: [&str; 2] = [SHARE_FILE, "thumbnail.png"];
|
||||
|
||||
/// What the last sync knew about one file.
|
||||
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
|
||||
struct Stamp {
|
||||
/// The server's modified time (compared for equality only).
|
||||
remote: u64,
|
||||
/// Hash of the file's bytes here.
|
||||
local: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Default)]
|
||||
pub struct Share {
|
||||
/// `https://host[:port]`, no trailing slash.
|
||||
pub server: String,
|
||||
pub token: String,
|
||||
#[serde(default)]
|
||||
synced: BTreeMap<String, Stamp>,
|
||||
}
|
||||
|
||||
impl Share {
|
||||
pub fn new(server: &str) -> Share {
|
||||
Share {
|
||||
server: server.trim_end_matches('/').to_string(),
|
||||
token: new_token(),
|
||||
synced: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load(dir: &Path) -> Option<Share> {
|
||||
let text = vfs::read_to_string(dir.join(SHARE_FILE)).ok()?;
|
||||
serde_json::from_str(&text).ok()
|
||||
}
|
||||
|
||||
pub fn save(&self, dir: &Path) -> Result<(), String> {
|
||||
let json = serde_json::to_string(self).map_err(|e| e.to_string())?;
|
||||
vfs::write(dir.join(SHARE_FILE), json).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// What to send to a collaborator: opening it in the web app joins the project.
|
||||
pub fn link(&self) -> String {
|
||||
format!("{}/?share={}", self.server, self.token)
|
||||
}
|
||||
|
||||
/// The share a link such as `https://host/?share=<token>` stands for.
|
||||
pub fn from_link(link: &str) -> Result<Share, String> {
|
||||
let link = link.trim();
|
||||
let (server, query) = link
|
||||
.split_once("?share=")
|
||||
.ok_or("not a share link (it should end in ?share=<token>)")?;
|
||||
let token: String = query
|
||||
.chars()
|
||||
.take_while(char::is_ascii_alphanumeric)
|
||||
.collect();
|
||||
if !relay_proto::valid_token(&token) {
|
||||
return Err("the share token in this link is not valid".into());
|
||||
}
|
||||
let server = server.trim_end_matches('/');
|
||||
if !server.starts_with("http://") && !server.starts_with("https://") {
|
||||
return Err("the link must start with http:// or https://".into());
|
||||
}
|
||||
Ok(Share {
|
||||
server: server.to_string(),
|
||||
token,
|
||||
synced: BTreeMap::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn base(&self) -> String {
|
||||
format!("{}/p/{}", self.server, self.token)
|
||||
}
|
||||
}
|
||||
|
||||
fn new_token() -> String {
|
||||
(0..relay_proto::MIN_TOKEN_LEN)
|
||||
.map(|_| char::from_digit(fastrand::u32(0..16), 16).unwrap_or('0'))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// FNV-1a: a stable fingerprint of a file's bytes (the std hasher may change between releases,
|
||||
/// and the value is stored in `.share`).
|
||||
fn fingerprint(bytes: &[u8]) -> u64 {
|
||||
bytes.iter().fold(0xcbf2_9ce4_8422_2325, |h, b| {
|
||||
(h ^ u64::from(*b)).wrapping_mul(0x0100_0000_01b3)
|
||||
})
|
||||
}
|
||||
|
||||
/// A file path from the server, only if it stays inside the project folder.
|
||||
fn safe_path(rel: &str) -> Option<PathBuf> {
|
||||
let p = Path::new(rel);
|
||||
let ok = !rel.is_empty()
|
||||
&& p.components().all(|c| matches!(c, Component::Normal(_)))
|
||||
&& !LOCAL_ONLY.contains(&rel);
|
||||
ok.then(|| p.to_path_buf())
|
||||
}
|
||||
|
||||
/// Every file under `dir` that syncs, as relative `/` paths with their fingerprints.
|
||||
fn scan(dir: &Path) -> BTreeMap<String, u64> {
|
||||
fn walk(base: &Path, dir: &Path, out: &mut BTreeMap<String, u64>) {
|
||||
let Ok(entries) = vfs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if e.file_type().is_ok_and(|t| t.is_dir()) {
|
||||
walk(base, &p, out);
|
||||
} else if let (Ok(rel), Ok(bytes)) = (p.strip_prefix(base), vfs::read(&p)) {
|
||||
let rel = rel.to_string_lossy().replace('\\', "/");
|
||||
if safe_path(&rel).is_some() {
|
||||
out.insert(rel, fingerprint(&bytes));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = BTreeMap::new();
|
||||
walk(dir, dir, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RemoteFile {
|
||||
path: String,
|
||||
mtime: u64,
|
||||
}
|
||||
|
||||
/// Why a shared project stopped syncing for good.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Gone {
|
||||
/// Someone deleted it on the server (library mode): it belongs in this device's trash.
|
||||
Deleted,
|
||||
/// It had synced before but the server no longer knows it, as after losing its data:
|
||||
/// the folder is the only copy left, so it stays and can be shared again.
|
||||
Lost,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum SyncError {
|
||||
Gone(Gone),
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl From<String> for SyncError {
|
||||
fn from(e: String) -> SyncError {
|
||||
SyncError::Other(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SyncError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
SyncError::Gone(Gone::Deleted) => f.write_str("deleted on the server"),
|
||||
SyncError::Gone(Gone::Lost) => f.write_str("the server no longer has this project"),
|
||||
SyncError::Other(e) => f.write_str(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The server's files for a project. `synced_before`: this folder has already synced once, so
|
||||
/// an unknown project is a loss, not a first upload.
|
||||
async fn list_remote(base: &str, synced_before: bool) -> Result<BTreeMap<String, u64>, SyncError> {
|
||||
let (code, body) = net::request("GET", &format!("{base}/files"), None).await?;
|
||||
match code {
|
||||
200 => {}
|
||||
410 => return Err(SyncError::Gone(Gone::Deleted)),
|
||||
// Nothing uploaded yet: the first sync of a freshly shared project.
|
||||
404 if !synced_before => return Ok(BTreeMap::new()),
|
||||
404 => return Err(SyncError::Gone(Gone::Lost)),
|
||||
c => return Err(format!("the server answered {c}").into()),
|
||||
}
|
||||
let files: Vec<RemoteFile> = serde_json::from_slice(&body).map_err(|e| e.to_string())?;
|
||||
Ok(files
|
||||
.into_iter()
|
||||
.filter(|f| safe_path(&f.path).is_some())
|
||||
.map(|f| (f.path, f.mtime))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// One round: reconcile the folder with the server. True when the server's version of some
|
||||
/// file was applied here, so the open project may need reloading.
|
||||
pub async fn sync_once(dir: &Path) -> Result<bool, SyncError> {
|
||||
let mut share = Share::load(dir).ok_or_else(|| "this project is not shared".to_string())?;
|
||||
let base = share.base();
|
||||
let remote = list_remote(&base, !share.synced.is_empty()).await?;
|
||||
let local = scan(dir);
|
||||
let paths: BTreeSet<&String> = remote
|
||||
.keys()
|
||||
.chain(local.keys())
|
||||
.chain(share.synced.keys())
|
||||
.collect();
|
||||
let (mut synced, mut applied) = (share.synced.clone(), false);
|
||||
for path in paths {
|
||||
let (l, r) = (local.get(path).copied(), remote.get(path).copied());
|
||||
let was = synced.get(path).copied();
|
||||
let url = format!("{base}/f/{path}");
|
||||
let file = dir.join(path);
|
||||
if l != was.map(|s| s.local) {
|
||||
let Some(hash) = l else {
|
||||
let (code, _) = net::request("DELETE", &url, None).await?;
|
||||
if code != 204 && code != 404 {
|
||||
return Err(format!("could not delete {path}: server answered {code}").into());
|
||||
}
|
||||
synced.remove(path);
|
||||
continue;
|
||||
};
|
||||
let bytes = vfs::read(&file).map_err(|e| format!("{path}: {e}"))?;
|
||||
let (code, body) = net::request("PUT", &url, Some(bytes)).await?;
|
||||
if code != 200 {
|
||||
return Err(format!("could not upload {path}: server answered {code}").into());
|
||||
}
|
||||
let remote = String::from_utf8_lossy(&body).trim().parse().unwrap_or(0);
|
||||
synced.insert(
|
||||
path.clone(),
|
||||
Stamp {
|
||||
remote,
|
||||
local: hash,
|
||||
},
|
||||
);
|
||||
} else if r != was.map(|s| s.remote) {
|
||||
applied = true;
|
||||
let Some(stamp) = r else {
|
||||
vfs::remove_file(&file).ok();
|
||||
synced.remove(path);
|
||||
continue;
|
||||
};
|
||||
let (code, bytes) = net::request("GET", &url, None).await?;
|
||||
if code != 200 {
|
||||
return Err(format!("could not download {path}: server answered {code}").into());
|
||||
}
|
||||
if let Some(parent) = file.parent() {
|
||||
vfs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
vfs::write(&file, &bytes).map_err(|e| format!("{path}: {e}"))?;
|
||||
synced.insert(
|
||||
path.clone(),
|
||||
Stamp {
|
||||
remote: stamp,
|
||||
local: fingerprint(&bytes),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
if synced != share.synced {
|
||||
share.synced = synced;
|
||||
share.save(dir)?;
|
||||
}
|
||||
Ok(applied)
|
||||
}
|
||||
|
||||
/// What the shell shows and acts on for one shared project.
|
||||
#[derive(Default)]
|
||||
pub struct Status {
|
||||
/// What to send a collaborator.
|
||||
pub link: String,
|
||||
stop: AtomicBool,
|
||||
inner: Mutex<Inner>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Inner {
|
||||
error: Option<String>,
|
||||
gone: Option<Gone>,
|
||||
synced_once: bool,
|
||||
changed: bool,
|
||||
}
|
||||
|
||||
impl Status {
|
||||
fn inner(&self) -> std::sync::MutexGuard<'_, Inner> {
|
||||
self.inner.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// The server's copy changed files in the folder since the last call.
|
||||
pub fn take_changed(&self) -> bool {
|
||||
std::mem::take(&mut self.inner().changed)
|
||||
}
|
||||
|
||||
/// Why this project stopped syncing for good, if it did.
|
||||
pub fn gone(&self) -> Option<Gone> {
|
||||
self.inner().gone
|
||||
}
|
||||
|
||||
pub fn label(&self) -> String {
|
||||
let i = self.inner();
|
||||
match (&i.error, i.synced_once) {
|
||||
(Some(e), _) => format!("Sync error: {e}"),
|
||||
(None, true) => "Synced".into(),
|
||||
(None, false) => "Connecting".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(dir: PathBuf, status: Arc<Status>) {
|
||||
while !status.stop.load(Ordering::Relaxed) {
|
||||
let round = sync_once(&dir).await;
|
||||
{
|
||||
let mut i = status.inner();
|
||||
match round {
|
||||
Ok(changed) => {
|
||||
i.error = None;
|
||||
i.synced_once = true;
|
||||
i.changed |= changed;
|
||||
}
|
||||
Err(SyncError::Gone(g)) => {
|
||||
i.gone = Some(g);
|
||||
status.stop.store(true, Ordering::Relaxed);
|
||||
}
|
||||
Err(e) => i.error = Some(e.to_string()),
|
||||
}
|
||||
}
|
||||
net::sleep(POLL).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// One look at the library: share local projects, join the ones this device lacks. False when
|
||||
/// the relay has no library.
|
||||
pub async fn library_round(root: &Path, server: &str) -> Result<bool, String> {
|
||||
let (code, body) = net::request("GET", &format!("{server}/library"), None).await?;
|
||||
match code {
|
||||
200 => {}
|
||||
404 => return Ok(false),
|
||||
c => return Err(format!("the server answered {c}")),
|
||||
}
|
||||
let tokens: Vec<String> = serde_json::from_slice(&body).map_err(|e| e.to_string())?;
|
||||
let mut known = BTreeSet::new();
|
||||
for e in vfs::read_dir(root).into_iter().flatten().flatten() {
|
||||
let hidden = e.file_name().to_string_lossy().starts_with('.');
|
||||
if !hidden && let Some(s) = Share::load(&e.path()) {
|
||||
known.insert(s.token);
|
||||
}
|
||||
}
|
||||
let store = crate::project::Store::open(root).map_err(|e| e.to_string())?;
|
||||
for entry in store.list() {
|
||||
if Share::load(&entry.dir).is_none() {
|
||||
Share::new(server).save(&entry.dir)?;
|
||||
}
|
||||
}
|
||||
for token in tokens.iter().filter(|t| !known.contains(*t)) {
|
||||
let share = Share::from_link(&format!("{server}/?share={token}"))?;
|
||||
let dir = root.join(format!("shared-{}", &token[..8]));
|
||||
vfs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
share.save(&dir)?;
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn run_library(root: PathBuf, server: String, alive: Arc<AtomicBool>) {
|
||||
while alive.load(Ordering::Relaxed) {
|
||||
match library_round(&root, &server).await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => alive.store(false, Ordering::Relaxed),
|
||||
Err(e) => log::warn!("library: {e}"),
|
||||
}
|
||||
net::sleep(LIBRARY_POLL).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// The shared projects this process is keeping in sync.
|
||||
#[derive(Default)]
|
||||
pub struct Sync {
|
||||
running: HashMap<PathBuf, Arc<Status>>,
|
||||
library: Option<Library>,
|
||||
}
|
||||
|
||||
/// The relay this device keeps a library with; `alive` goes false when it turns out not to
|
||||
/// be one.
|
||||
struct Library {
|
||||
server: String,
|
||||
alive: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Sync {
|
||||
/// Start syncing the project in `dir` (it must have a `.share` file). A second call for the
|
||||
/// same folder does nothing.
|
||||
pub fn start(&mut self, dir: &Path) {
|
||||
if self.running.contains_key(dir) || Share::load(dir).is_none() {
|
||||
return;
|
||||
}
|
||||
let status = Arc::new(Status {
|
||||
link: Share::load(dir).map(|s| s.link()).unwrap_or_default(),
|
||||
..Status::default()
|
||||
});
|
||||
net::spawn(run(dir.to_path_buf(), status.clone()));
|
||||
self.running.insert(dir.to_path_buf(), status);
|
||||
}
|
||||
|
||||
/// Resume every shared project under the store root (at startup).
|
||||
pub fn start_all(&mut self, root: &Path) {
|
||||
let Ok(entries) = vfs::read_dir(root) else {
|
||||
return;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
if e.file_type().is_ok_and(|t| t.is_dir()) {
|
||||
self.start(&e.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Make `dir` a shared project on `server`; returns the link to hand out.
|
||||
pub fn share(&mut self, dir: &Path, server: &str) -> Result<String, String> {
|
||||
if let Some(existing) = Share::load(dir) {
|
||||
return Ok(existing.link());
|
||||
}
|
||||
let share = Share::new(server);
|
||||
share.save(dir)?;
|
||||
self.start(dir);
|
||||
Ok(share.link())
|
||||
}
|
||||
|
||||
/// Download the project behind `link` into a new folder `dir` and keep it in sync.
|
||||
pub fn join(&mut self, dir: &Path, link: &str) -> Result<(), String> {
|
||||
let share = Share::from_link(link)?;
|
||||
vfs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
share.save(dir)?;
|
||||
self.start(dir);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn status(&self, dir: &Path) -> Option<Arc<Status>> {
|
||||
self.running.get(dir).cloned()
|
||||
}
|
||||
|
||||
pub fn dirs(&self) -> Vec<PathBuf> {
|
||||
self.running.keys().cloned().collect()
|
||||
}
|
||||
|
||||
pub fn is_active(&self) -> bool {
|
||||
!self.running.is_empty() || self.library_active()
|
||||
}
|
||||
|
||||
/// Keep a library with the relay at `server` (the page's own origin on the web): its
|
||||
/// projects appear here and this device's projects appear there. Does nothing, quietly, when
|
||||
/// the relay was not started with `--library`.
|
||||
pub fn enable_library(&mut self, root: &Path, server: &str) {
|
||||
if self.library.is_some() {
|
||||
return;
|
||||
}
|
||||
let server = server.trim_end_matches('/').to_string();
|
||||
let alive = Arc::new(AtomicBool::new(true));
|
||||
net::spawn(run_library(
|
||||
root.to_path_buf(),
|
||||
server.clone(),
|
||||
alive.clone(),
|
||||
));
|
||||
self.library = Some(Library { server, alive });
|
||||
}
|
||||
|
||||
/// True while a library relay is being followed: projects there are the user's own, so
|
||||
/// the share/join controls do not apply.
|
||||
pub fn library_active(&self) -> bool {
|
||||
self.library
|
||||
.as_ref()
|
||||
.is_some_and(|l| l.alive.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// A project leaves this device for good (trashed). In a library it is deleted on the relay
|
||||
/// too, so the other devices drop it; otherwise only this folder stops syncing.
|
||||
pub fn drop_project(&mut self, dir: &Path) {
|
||||
if let (Some(lib), Some(share)) = (&self.library, Share::load(dir))
|
||||
&& lib.alive.load(Ordering::Relaxed)
|
||||
&& share.server == lib.server
|
||||
{
|
||||
let url = share.base();
|
||||
net::spawn(async move {
|
||||
match net::request("DELETE", &url, None).await {
|
||||
Ok((204 | 404 | 410, _)) => {}
|
||||
Ok((code, _)) => log::warn!("library: delete answered {code}"),
|
||||
Err(e) => log::warn!("library: could not delete: {e}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
self.unshare(dir);
|
||||
}
|
||||
|
||||
/// Stop syncing `dir` and forget its share (the files stay).
|
||||
pub fn unshare(&mut self, dir: &Path) {
|
||||
if let Some(s) = self.running.remove(dir) {
|
||||
s.stop.store(true, Ordering::Relaxed);
|
||||
}
|
||||
vfs::remove_file(dir.join(SHARE_FILE)).ok();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod net {
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
|
||||
pub async fn request(
|
||||
method: &str,
|
||||
url: &str,
|
||||
body: Option<Vec<u8>>,
|
||||
) -> Result<(u16, Vec<u8>), String> {
|
||||
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
|
||||
let client = CLIENT.get_or_init(reqwest::Client::new);
|
||||
let method = reqwest::Method::from_bytes(method.as_bytes()).map_err(|e| e.to_string())?;
|
||||
let mut req = client.request(method, url).timeout(Duration::from_secs(60));
|
||||
if let Some(b) = body {
|
||||
req = req.body(b);
|
||||
}
|
||||
let res = req.send().await.map_err(|e| e.to_string())?;
|
||||
let code = res.status().as_u16();
|
||||
let bytes = res.bytes().await.map_err(|e| e.to_string())?;
|
||||
Ok((code, bytes.to_vec()))
|
||||
}
|
||||
|
||||
pub async fn sleep(d: Duration) {
|
||||
tokio::time::sleep(d).await;
|
||||
}
|
||||
|
||||
/// Own thread and runtime: the editor's event loop has none.
|
||||
pub fn spawn(fut: impl Future<Output = ()> + Send + 'static) {
|
||||
std::thread::Builder::new()
|
||||
.name("papercraft-sync".into())
|
||||
.spawn(move || {
|
||||
if let Ok(rt) = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
rt.block_on(fut);
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod net {
|
||||
use std::time::Duration;
|
||||
use wasm_bindgen::{JsCast, JsValue};
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
fn js(e: JsValue) -> String {
|
||||
e.as_string().unwrap_or_else(|| format!("{e:?}"))
|
||||
}
|
||||
|
||||
pub async fn request(
|
||||
method: &str,
|
||||
url: &str,
|
||||
body: Option<Vec<u8>>,
|
||||
) -> Result<(u16, Vec<u8>), String> {
|
||||
let init = web_sys::RequestInit::new();
|
||||
init.set_method(method);
|
||||
if let Some(b) = body {
|
||||
init.set_body(&js_sys::Uint8Array::from(b.as_slice()));
|
||||
}
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let res = JsFuture::from(window.fetch_with_str_and_init(url, &init))
|
||||
.await
|
||||
.map_err(js)?;
|
||||
let res: web_sys::Response = res.dyn_into().map_err(js)?;
|
||||
let buf = JsFuture::from(res.array_buffer().map_err(js)?)
|
||||
.await
|
||||
.map_err(js)?;
|
||||
Ok((res.status(), js_sys::Uint8Array::new(&buf).to_vec()))
|
||||
}
|
||||
|
||||
pub async fn sleep(d: Duration) {
|
||||
let promise = js_sys::Promise::new(&mut |resolve, _| {
|
||||
if let Some(w) = web_sys::window() {
|
||||
let _ = w.set_timeout_with_callback_and_timeout_and_arguments_0(
|
||||
&resolve,
|
||||
d.as_millis() as i32,
|
||||
);
|
||||
}
|
||||
});
|
||||
let _ = JsFuture::from(promise).await;
|
||||
}
|
||||
|
||||
pub fn spawn(fut: impl Future<Output = ()> + 'static) {
|
||||
wasm_bindgen_futures::spawn_local(fut);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn server_paths_cannot_leave_the_project() {
|
||||
assert!(safe_path("pages/a.json").is_some());
|
||||
for bad in [
|
||||
"",
|
||||
"../x",
|
||||
"a/../../x",
|
||||
"/etc/passwd",
|
||||
".share",
|
||||
"thumbnail.png",
|
||||
] {
|
||||
assert!(safe_path(bad).is_none(), "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_round_trip() {
|
||||
let s = Share::new("https://papercraft.example.com/");
|
||||
let back = Share::from_link(&s.link()).unwrap();
|
||||
assert_eq!(
|
||||
(back.server.as_str(), back.token.as_str()),
|
||||
(s.server.as_str(), s.token.as_str())
|
||||
);
|
||||
assert!(Share::from_link("https://x/?share=zz").is_err());
|
||||
assert!(Share::from_link("https://x/").is_err());
|
||||
assert!(Share::from_link("ftp://x/?share=00000000000000000000000000000000").is_err());
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,8 @@ pub struct PlacedGlyph {
|
||||
/// Char index into the source string this glyph comes from.
|
||||
pub char_index: usize,
|
||||
pub underline: bool,
|
||||
pub underline_color: Option<Color>,
|
||||
pub underline_wavy: bool,
|
||||
pub strike: bool,
|
||||
}
|
||||
|
||||
@@ -223,6 +225,8 @@ pub fn layout_rich(
|
||||
advance: a,
|
||||
char_index: i,
|
||||
underline: st.underline,
|
||||
underline_color: st.underline_color,
|
||||
underline_wavy: st.underline_wavy,
|
||||
strike: st.strike,
|
||||
});
|
||||
x += a;
|
||||
|
||||
+342
-29
@@ -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.
|
||||
|
||||
+54
-2
@@ -170,6 +170,22 @@ impl Web {
|
||||
Editor::default()
|
||||
};
|
||||
ed.clipboard_io = Some(Box::new(dom::WebClipboard(mirror.clone())));
|
||||
let mut session = Session::new(store);
|
||||
// A share link (`/?share=<token>`) opens the shared project; joining twice is a no-op.
|
||||
let loc = window().location();
|
||||
if let (Ok(search), Ok(origin)) = (loc.search(), loc.origin())
|
||||
&& let Some(token) = search
|
||||
.trim_start_matches('?')
|
||||
.split('&')
|
||||
.find_map(|kv| kv.strip_prefix("share="))
|
||||
&& let Err(e) = session.join_shared(&format!("{origin}/?share={token}"))
|
||||
{
|
||||
log::warn!("share link: {e}");
|
||||
}
|
||||
// A relay started with --library keeps every project for every device that opens it.
|
||||
if !demo && let Ok(origin) = loc.origin() {
|
||||
session.follow_library(&origin);
|
||||
}
|
||||
Ok(Web {
|
||||
painter: Painter::new(&device, format),
|
||||
canvas,
|
||||
@@ -180,7 +196,7 @@ impl Web {
|
||||
ctx,
|
||||
ed,
|
||||
st,
|
||||
session: Session::new(store),
|
||||
session,
|
||||
events: vec![],
|
||||
mods: Modifiers::NONE,
|
||||
pointer: None,
|
||||
@@ -447,9 +463,33 @@ impl Web {
|
||||
}
|
||||
}
|
||||
|
||||
/// The server this page came from: where "Share…" puts a project unless told otherwise.
|
||||
fn default_server(&self) -> String {
|
||||
window().location().origin().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Look at the shared projects' sync engines (about once a second).
|
||||
fn tick_sync(&mut self, now: f64) {
|
||||
if self
|
||||
.session
|
||||
.sync_poll(&mut self.ed, web_time::Instant::now())
|
||||
{
|
||||
if self.st.home.is_some() {
|
||||
self.show_home();
|
||||
}
|
||||
self.dirty = true;
|
||||
}
|
||||
if self.session.sync_deadline().is_some() {
|
||||
self.wake_at(now + 1000.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn show_home(&mut self) {
|
||||
let mut hv = self.st.home.take().unwrap_or_default();
|
||||
hv.refresh(&self.session.store, crate::project::now_ms());
|
||||
hv.shared = self.session.shared_info();
|
||||
hv.default_server = self.default_server();
|
||||
hv.library = self.session.sync.library_active();
|
||||
self.st.home = Some(hv);
|
||||
self.st.project_name.clear();
|
||||
self.st.save_state.clear();
|
||||
@@ -503,7 +543,18 @@ impl Web {
|
||||
HomeAction::Duplicate(dir) => {
|
||||
store.duplicate(&dir).map(|_| ()).map_err(|e| e.to_string())
|
||||
}
|
||||
HomeAction::Trash(dir) => store.trash(&dir).map(|_| ()).map_err(|e| e.to_string()),
|
||||
HomeAction::Trash(dir) => {
|
||||
// A trashed project leaves the share (and a library); restoring it brings
|
||||
// back a local copy.
|
||||
self.session.sync.drop_project(&dir);
|
||||
store.trash(&dir).map(|_| ()).map_err(|e| e.to_string())
|
||||
}
|
||||
HomeAction::Share(dir, server) => self.session.share_project(&dir, &server),
|
||||
HomeAction::Join(link) => self.session.join_shared(&link),
|
||||
HomeAction::Unshare(dir) => {
|
||||
self.session.sync.unshare(&dir);
|
||||
Ok(())
|
||||
}
|
||||
HomeAction::Restore(dir) => {
|
||||
store.restore(&dir).map(|_| ()).map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -672,6 +723,7 @@ impl Web {
|
||||
self.handle_requests();
|
||||
self.handle_home_actions();
|
||||
self.handle_agent_actions(now);
|
||||
self.tick_sync(now);
|
||||
if self.st.library.dirty {
|
||||
self.st.library.dirty = false;
|
||||
if let Err(e) = self.st.library.library.save() {
|
||||
|
||||
+131
@@ -6,6 +6,7 @@
|
||||
use papercraft::mcp::Out;
|
||||
use papercraft::mcp::relay::{self, NO_TAB_ERROR};
|
||||
use papercraft::mcp::relay_proto::{Call as CallFrame, Reply};
|
||||
use papercraft::mcp::store::Data;
|
||||
use rmcp::ServiceExt;
|
||||
use rmcp::model::{CallToolRequestParams, CallToolResult, ContentBlock};
|
||||
use serde_json::{Value, json};
|
||||
@@ -194,6 +195,18 @@ async fn bad_tokens_browsers_and_unknown_routes_are_refused() {
|
||||
.unwrap();
|
||||
assert_eq!(r.status(), 403);
|
||||
|
||||
// A public hostname in Host is fine: the token is the credential.
|
||||
let r = http
|
||||
.post(format!("{base}/s/{good}/mcp"))
|
||||
.header("content-type", "application/json")
|
||||
.header("accept", "application/json, text/event-stream")
|
||||
.header("host", "papercraft.example.com")
|
||||
.body(init)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_ne!(r.status(), 403);
|
||||
|
||||
// Tokens are 32..=64 lowercase hex characters; anything else is a plain 404.
|
||||
for (label, bad) in [
|
||||
("uppercase", good.to_uppercase()),
|
||||
@@ -294,3 +307,121 @@ async fn static_files_are_served_with_their_type() {
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
running.stop();
|
||||
}
|
||||
|
||||
/// Shared projects: files round-trip, bad paths are refused, and a second tab hears about the
|
||||
/// first tab's changes (but a tab does not hear its own).
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn shared_projects_store_files_and_tell_the_other_tabs() {
|
||||
let dir = std::env::temp_dir().join(format!("papercraft-data-{:016x}", fastrand::u64(..)));
|
||||
let listen: SocketAddr = "127.0.0.1:0".parse().expect("a valid address");
|
||||
let running = relay::spawn_with_data(
|
||||
listen,
|
||||
None,
|
||||
None,
|
||||
Some(Data {
|
||||
dir: dir.clone(),
|
||||
library: false,
|
||||
}),
|
||||
)
|
||||
.expect("the relay binds");
|
||||
let port = running.port();
|
||||
let base = format!("http://127.0.0.1:{port}");
|
||||
let http = reqwest::Client::new();
|
||||
let t = token();
|
||||
|
||||
// Unknown project: nothing to list.
|
||||
let r = http
|
||||
.get(format!("{base}/p/{t}/files"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(r.status(), 404);
|
||||
|
||||
// Tab B listens before A writes.
|
||||
let url = format!("ws://127.0.0.1:{port}/p/{t}/ws?client=b");
|
||||
let (tx, rx) = std::sync::mpsc::channel::<String>();
|
||||
std::thread::spawn(move || {
|
||||
let (mut ws, _) = ws_connect(url).expect("connect");
|
||||
while let Ok(Ws::Text(m)) = ws.read() {
|
||||
tx.send(m.to_string()).ok();
|
||||
}
|
||||
});
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
|
||||
let put = |path: &str, client: &str| {
|
||||
http.put(format!("{base}/p/{t}/f/{path}"))
|
||||
.header("x-client", client)
|
||||
.body("hello")
|
||||
.send()
|
||||
};
|
||||
assert_eq!(put("pages/p1.json", "a").await.unwrap().status(), 200);
|
||||
let ev: Value = serde_json::from_str(&rx.recv_timeout(CONNECT_WAIT).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
(ev["op"].as_str(), ev["path"].as_str()),
|
||||
(Some("put"), Some("pages/p1.json"))
|
||||
);
|
||||
|
||||
// B's own writes are not echoed back to B.
|
||||
assert_eq!(put("project.json", "b").await.unwrap().status(), 200);
|
||||
assert!(rx.recv_timeout(Duration::from_millis(300)).is_err());
|
||||
|
||||
let r = http
|
||||
.get(format!("{base}/p/{t}/f/pages/p1.json"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(r.text().await.unwrap(), "hello");
|
||||
let files: Value = http
|
||||
.get(format!("{base}/p/{t}/files"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(files.as_array().unwrap().len(), 2);
|
||||
|
||||
let r = http
|
||||
.delete(format!("{base}/p/{t}/f/pages/p1.json"))
|
||||
.header("x-client", "a")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(r.status(), 204);
|
||||
let ev: Value = serde_json::from_str(&rx.recv_timeout(CONNECT_WAIT).unwrap()).unwrap();
|
||||
assert_eq!(ev["op"], "del");
|
||||
let r = http
|
||||
.get(format!("{base}/p/{t}/f/pages/p1.json"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(r.status(), 404);
|
||||
|
||||
// Traversal and bad tokens are plain 404s.
|
||||
for url in [
|
||||
format!("{base}/p/{t}/f/%2e%2e/escape"),
|
||||
format!("{base}/p/{t}/f/a/%2e%2e/%2e%2e/escape"),
|
||||
format!("{base}/p/ZZ/f/project.json"),
|
||||
] {
|
||||
let r = http.put(&url).body("x").send().await.unwrap();
|
||||
assert_eq!(r.status(), 404, "{url}");
|
||||
}
|
||||
assert!(!dir.join("escape").exists());
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
running.stop();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn shared_project_routes_are_off_without_a_data_dir() {
|
||||
let running = spawn_relay();
|
||||
let base = format!("http://127.0.0.1:{}", running.port());
|
||||
let r = reqwest::Client::new()
|
||||
.put(format!("{base}/p/{}/f/project.json", token()))
|
||||
.body("x")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(r.status(), 404);
|
||||
running.stop();
|
||||
}
|
||||
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
//! Shared projects end to end: two project folders kept in step through a real relay.
|
||||
|
||||
use papercraft::mcp::relay;
|
||||
use papercraft::mcp::store::Data;
|
||||
use papercraft::sync::{self, SHARE_FILE, Share};
|
||||
use std::net::SocketAddr;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn temp(label: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!(
|
||||
"papercraft-sync-{label}-{:016x}",
|
||||
fastrand::u64(..)
|
||||
));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn read(dir: &Path, rel: &str) -> Option<String> {
|
||||
std::fs::read_to_string(dir.join(rel)).ok()
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn two_folders_follow_each_other_through_the_relay() {
|
||||
let data = temp("data");
|
||||
let listen: SocketAddr = "127.0.0.1:0".parse().unwrap();
|
||||
let running = relay::spawn_with_data(
|
||||
listen,
|
||||
None,
|
||||
None,
|
||||
Some(Data {
|
||||
dir: data.clone(),
|
||||
library: false,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let server = format!("http://127.0.0.1:{}", running.port());
|
||||
|
||||
// A shares a project with two pages.
|
||||
let a = temp("a");
|
||||
std::fs::create_dir_all(a.join("pages")).unwrap();
|
||||
std::fs::write(a.join("project.json"), "manifest v1").unwrap();
|
||||
std::fs::write(a.join("pages/p1.json"), "page one").unwrap();
|
||||
let share = Share::new(&server);
|
||||
share.save(&a).unwrap();
|
||||
assert!(
|
||||
!sync::sync_once(&a).await.unwrap(),
|
||||
"uploading is not an applied change"
|
||||
);
|
||||
|
||||
// B joins from the link and gets everything, but not A's local-only state.
|
||||
let b = temp("b");
|
||||
let joined = Share::from_link(&share.link()).unwrap();
|
||||
joined.save(&b).unwrap();
|
||||
assert!(sync::sync_once(&b).await.unwrap());
|
||||
assert_eq!(read(&b, "project.json").as_deref(), Some("manifest v1"));
|
||||
assert_eq!(read(&b, "pages/p1.json").as_deref(), Some("page one"));
|
||||
// Nothing changed: both sides settle.
|
||||
assert!(!sync::sync_once(&b).await.unwrap());
|
||||
assert!(!sync::sync_once(&a).await.unwrap());
|
||||
|
||||
// B edits one page and adds another; A receives both.
|
||||
std::fs::write(b.join("pages/p1.json"), "page one, edited in B").unwrap();
|
||||
std::fs::write(b.join("pages/p2.json"), "page two").unwrap();
|
||||
sync::sync_once(&b).await.unwrap();
|
||||
assert!(sync::sync_once(&a).await.unwrap());
|
||||
assert_eq!(
|
||||
read(&a, "pages/p1.json").as_deref(),
|
||||
Some("page one, edited in B")
|
||||
);
|
||||
assert_eq!(read(&a, "pages/p2.json").as_deref(), Some("page two"));
|
||||
|
||||
// A deletes a page; B loses it too.
|
||||
std::fs::remove_file(a.join("pages/p2.json")).unwrap();
|
||||
sync::sync_once(&a).await.unwrap();
|
||||
assert!(sync::sync_once(&b).await.unwrap());
|
||||
assert!(read(&b, "pages/p2.json").is_none());
|
||||
|
||||
// Both edit the same file before syncing: the one that syncs last (the folder) wins.
|
||||
std::fs::write(a.join("pages/p1.json"), "from A").unwrap();
|
||||
std::fs::write(b.join("pages/p1.json"), "from B").unwrap();
|
||||
sync::sync_once(&a).await.unwrap();
|
||||
sync::sync_once(&b).await.unwrap();
|
||||
sync::sync_once(&a).await.unwrap();
|
||||
assert_eq!(read(&a, "pages/p1.json"), read(&b, "pages/p1.json"));
|
||||
|
||||
// The sync state never travels: the server holds no .share.
|
||||
assert!(!data.join(&share.token).join(SHARE_FILE).exists());
|
||||
|
||||
for d in [&a, &b, &data] {
|
||||
std::fs::remove_dir_all(d).ok();
|
||||
}
|
||||
running.stop();
|
||||
}
|
||||
|
||||
fn project(root: &Path, name: &str) -> PathBuf {
|
||||
let store = papercraft::project::Store::open(root).unwrap();
|
||||
store.create(name).unwrap().0.dir
|
||||
}
|
||||
|
||||
/// Every project directory under `root` that a device would list.
|
||||
fn names(root: &Path) -> Vec<String> {
|
||||
let store = papercraft::project::Store::open(root).unwrap();
|
||||
store.list().into_iter().map(|e| e.manifest.name).collect()
|
||||
}
|
||||
|
||||
fn library_relay(data: &Path, library: bool) -> (relay::Running, String) {
|
||||
let listen: SocketAddr = "127.0.0.1:0".parse().unwrap();
|
||||
let data = Data {
|
||||
dir: data.to_path_buf(),
|
||||
library,
|
||||
};
|
||||
let running = relay::spawn_with_data(listen, None, None, Some(data)).unwrap();
|
||||
let server = format!("http://127.0.0.1:{}", running.port());
|
||||
(running, server)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_library_shows_every_project_on_every_device() {
|
||||
let data = temp("lib-data");
|
||||
let (_relay, server) = library_relay(&data, true);
|
||||
|
||||
// Device A has a project; one library round shares it and a sync uploads it.
|
||||
let (a, b) = (temp("lib-a"), temp("lib-b"));
|
||||
let alpha = project(&a, "Alpha");
|
||||
assert!(sync::library_round(&a, &server).await.unwrap());
|
||||
let token = Share::load(&alpha).expect("shared by the library").token;
|
||||
sync::sync_once(&alpha).await.unwrap();
|
||||
|
||||
// Device B starts empty, joins what the library lists and downloads it.
|
||||
assert!(sync::library_round(&b, &server).await.unwrap());
|
||||
let joined = b.join(format!("shared-{}", &token[..8]));
|
||||
sync::sync_once(&joined).await.unwrap();
|
||||
assert_eq!(names(&b), ["Alpha"]);
|
||||
|
||||
// Another round changes nothing (no duplicate join, no re-share).
|
||||
sync::library_round(&b, &server).await.unwrap();
|
||||
sync::library_round(&a, &server).await.unwrap();
|
||||
assert_eq!(names(&a), ["Alpha"]);
|
||||
assert_eq!(names(&b), ["Alpha"]);
|
||||
|
||||
// B deletes it on the server: the data is kept in .trash, A is told it is deleted.
|
||||
let http = reqwest::Client::new();
|
||||
let res = http
|
||||
.delete(format!("{server}/p/{token}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 204);
|
||||
assert!(data.join(".trash").read_dir().unwrap().next().is_some());
|
||||
let listed = http
|
||||
.get(format!("{server}/library"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.text()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!listed.contains(&token), "{listed}");
|
||||
match sync::sync_once(&alpha).await {
|
||||
Err(sync::SyncError::Gone(sync::Gone::Deleted)) => {}
|
||||
other => panic!("expected Gone(Deleted), got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_server_that_lost_its_data_does_not_delete_projects() {
|
||||
let data = temp("lost-data");
|
||||
let (_relay, server) = library_relay(&data, true);
|
||||
let a = temp("lost-a");
|
||||
let proj = project(&a, "Keep me");
|
||||
sync::library_round(&a, &server).await.unwrap();
|
||||
sync::sync_once(&proj).await.unwrap();
|
||||
|
||||
// The server's disk is wiped.
|
||||
let token = Share::load(&proj).unwrap().token;
|
||||
std::fs::remove_dir_all(data.join(&token)).unwrap();
|
||||
match sync::sync_once(&proj).await {
|
||||
Err(sync::SyncError::Gone(sync::Gone::Lost)) => {}
|
||||
other => panic!("expected Gone(Lost), got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_relay_without_library_says_so() {
|
||||
let (_relay, server) = library_relay(&temp("plain-data"), false);
|
||||
let a = temp("plain-a");
|
||||
project(&a, "Private");
|
||||
assert!(!sync::library_round(&a, &server).await.unwrap());
|
||||
// Nothing was shared behind the user's back.
|
||||
assert!(names(&a).len() == 1);
|
||||
let projects = papercraft::project::Store::open(&a).unwrap().list();
|
||||
assert!(Share::load(&projects[0].dir).is_none());
|
||||
}
|
||||
Reference in New Issue
Block a user