21 Commits

Author SHA1 Message Date
Marcos Paulo 057ba30908 chore: release 0.2.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 20:48:21 -03:00
Marcos Paulo 6e7b7e756a feat(web): remote-agent relay and browser gap closure (import, export, clipboard, thumbnails, persistence, backup)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 20:34:14 -03:00
Marcos Paulo 9e478207bb docs(specs): web relay for remote agents and browser gap closure
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:44:36 -03:00
Marcos Paulo 7c56a8d209 feat(web): projects in the browser (vfs over IndexedDB, home screen, autosave)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:41:01 -03:00
Marcos Paulo 01a01129be feat(ui): Figma-style inspector, flat menus, open projects folder, gentler ctrl+wheel zoom
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:37:54 -03:00
Marcos Paulo a1ba859aac feat(web): browser build (canvas + WebGPU shell), native-only deps gated for wasm32
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:37:54 -03:00
Marcos Paulo 7fdef386b7 chore: release 0.1.1 2026-10-13 11:59:27 -03:00
Marcos Paulo b3a2e6d6f4 feat(ui): custom title bar on Windows with menus, theme, drag and window buttons
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 11:22:28 -03:00
Marcos Paulo fdbb12aca5 docs(examples): Interview design-system and page build scripts
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 17:07:38 -03:00
Marcos Paulo b31483c70d feat(ui): Theme menu in the egui menu bar so Windows/Linux can pick Catppuccin
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 17:02:34 -03:00
Marcos Paulo ba8074a850 build(windows): package papercraft for Windows (MSI, portable zip, cli zip) 2026-10-12 13:26:36 -03:00
Marcos Paulo bbb1256313 fix(mcp): multi-var shorthand, text-transform, var() in svg markup, icon recolor via color
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 12:52:53 -03:00
Marcos Paulo f303d08467 feat: import_components across pages; Design tab feedback list, variable add/delete, tokens; layers search; agent activity hint
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 12:39:57 -03:00
Marcos Paulo d3b43066cd feat: feedback notes for agents, scroll-to-layer, box-shadow, design tokens, tables, instance by name
Feedback: notes on layers (inspector box, amber badges, get/add/resolve_feedback, openFeedback in
get_basic_info). Canvas selection unfolds and scrolls the layers panel. box-shadow renders; set_tokens
adds non-color tokens substituted into var(--name) values; table/tr/td lay out as flex rows;
x-paper-instance takes component="Name" and overrides='{"Layer":"text"}'.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 12:35:28 -03:00
Marcos Paulo 53b8dc470b feat: rebrand to PaperCraft; variable modes, lint_design, Design tab, icon, docs and skills
Product, crate and binaries are now PaperCraft/papercraft (papercraft-cli, papercraft-mcp);
projects, icon sets and theme in the old paper-rs locations are still read and .paper-rs files
still open. set_variables {mode} and set_variable_mode switch whole themes; lint_design checks
contrast, type scale, 4px grid, touch targets, layer names and hard-coded colors; get_variables
exports css/json tokens; the sidebar gets a Design tab for variables, modes and components.
New app icon, README, docs, ROADMAP and agent skills.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 12:08:55 -03:00
Marcos Paulo 810a077bf5 feat(mcp): color variables, components and instances, open icon sets
set_variables/get_variables with var(--name) fills that follow and undo; create_component,
create_instance, get_components and <x-paper-instance> with automatic overrides;
<x-paper-icon>, search_icons and paper-rs-cli icons add/list/search (Lucide, Tabler, Phosphor,
Heroicons, Material Symbols from npm, licences kept).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 11:47:20 -03:00
Marcos Paulo 3638a1c520 feat(cli): paper-rs-cli, the MCP tools from a shell
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 11:24:51 -03:00
Marcos Paulo dfb2316784 test: drop a redundant assertion
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 11:09:23 -03:00
Marcos Paulo 217241027c fix(ui): rename fields grab focus once; Catppuccin palette for panels
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 11:08:41 -03:00
Marcos Paulo 3667e916cd fix(ui): Catppuccin canvas, opaque sidebar and roomier inputs
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 10:58:08 -03:00
Marcos Paulo 67c6ef83e4 build(macos): package paper-rs.app on a DMG, plus the MCP server zip
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 10:50:35 -03:00
133 changed files with 16177 additions and 857 deletions
+3
View File
@@ -1,4 +1,7 @@
analysis/raw/
target/
dist/
*.asar
app.asar.unpacked/
web/pkg/
.worktrees/
+22
View File
@@ -0,0 +1,22 @@
# Attribution
Bundled assets and where they come from. Ported code and dependency notices are in
[THIRD_PARTY.md](THIRD_PARTY.md).
| Path | Title | Author | Source | License |
|---|---|---|---|---|
| `assets/fonts/Inter-*.ttf`, `assets/fonts/OFL.txt` | Inter 4.1 (Regular, Medium, SemiBold, Bold, Italic, Bold Italic) | The Inter Project Authors | https://rsms.me/inter/ | SIL Open Font License 1.1 |
| `assets/app-icon/*` | PaperCraft app icon | PaperCraft contributors (original artwork; colors from the Catppuccin palette) | this repository | MIT OR Apache-2.0 |
## Palettes
The Catppuccin Latte, Frappé, Macchiato and Mocha themes use the colors of the
[Catppuccin](https://github.com/catppuccin/catppuccin) palette (MIT, Copyright (c) 2021 Catppuccin),
taken from the `craft-ui` crate of [craft-libs](https://github.com/storytold/craft-libs).
## Icon sets (not bundled)
`papercraft-cli icons add <set>` downloads open icon sets from the npm registry into the user's data
folder, each with its own license file: Lucide (ISC), Tabler (MIT), Phosphor
(MIT, https://github.com/phosphor-icons/core), Heroicons (MIT) and Material Symbols (Apache-2.0).
They are not part of this repository or of the release builds.
Generated
+227 -11
View File
@@ -222,6 +222,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [
"axum-core",
"base64 0.22.1",
"bytes",
"futures-util",
"http",
@@ -236,8 +237,10 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"serde_core",
"sha1",
"sync_wrapper",
"tokio",
"tokio-tungstenite",
"tower",
"tower-layer",
"tower-service",
@@ -261,6 +264,12 @@ dependencies = [
"tower-service",
]
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64"
version = "0.23.1"
@@ -294,6 +303,15 @@ version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "block2"
version = "0.5.1"
@@ -431,8 +449,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures",
"rand_core",
"cpufeatures 0.3.1",
"rand_core 0.10.1",
]
[[package]]
@@ -507,6 +525,26 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "console_error_panic_hook"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
dependencies = [
"cfg-if",
"wasm-bindgen",
]
[[package]]
name = "console_log"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86919cef3e37b9356ccf54d4421208c17ecfda01beae61393e7ffd72916c0ef1"
dependencies = [
"log",
"web-sys",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -547,6 +585,15 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.1"
@@ -594,12 +641,28 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "cursor-icon"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]]
name = "data-encoding"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "data-url"
version = "0.3.2"
@@ -637,6 +700,16 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "dirs"
version = "6.0.0"
@@ -1109,6 +1182,16 @@ dependencies = [
"slab",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "gethostname"
version = "1.1.0"
@@ -1140,7 +1223,7 @@ dependencies = [
"cfg-if",
"libc",
"r-efi 6.0.0",
"rand_core",
"rand_core 0.10.1",
]
[[package]]
@@ -1345,7 +1428,7 @@ version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
dependencies = [
"base64",
"base64 0.23.1",
"bytes",
"futures-channel",
"futures-util",
@@ -2520,12 +2603,15 @@ dependencies = [
]
[[package]]
name = "paper-rs"
version = "0.1.0"
name = "papercraft"
version = "0.2.0"
dependencies = [
"arboard",
"axum",
"base64 0.23.1",
"bytemuck",
"console_error_panic_hook",
"console_log",
"craft-ui",
"dirs",
"egui",
@@ -2535,6 +2621,7 @@ dependencies = [
"fastrand",
"i_overlay",
"image",
"js-sys",
"log",
"lyon",
"miniz_oxide 0.9.1",
@@ -2551,11 +2638,17 @@ dependencies = [
"serde",
"serde_json",
"tokio",
"tokio-tungstenite",
"tokio-util",
"ttf-parser",
"usvg",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"web-time",
"wgpu",
"winit",
"winresource",
]
[[package]]
@@ -2716,6 +2809,15 @@ dependencies = [
"zerovec",
]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "presser"
version = "0.3.1"
@@ -2802,6 +2904,16 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [
"rand_chacha",
"rand_core 0.9.5",
]
[[package]]
name = "rand"
version = "0.10.3"
@@ -2810,7 +2922,26 @@ checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
dependencies = [
"chacha20",
"getrandom 0.4.3",
"rand_core",
"rand_core 0.10.1",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
@@ -2952,7 +3083,7 @@ version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029"
dependencies = [
"base64",
"base64 0.23.1",
"bytes",
"futures-core",
"futures-util",
@@ -3041,7 +3172,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab5023804cbe0304cbe06197ac16e645a0a0d0ffa2953bf735c15c2fa0213350"
dependencies = [
"async-trait",
"base64",
"base64 0.23.1",
"bytes",
"chrono",
"futures",
@@ -3052,7 +3183,7 @@ dependencies = [
"pastey",
"pin-project-lite",
"process-wrap",
"rand",
"rand 0.10.3",
"reqwest",
"schemars",
"serde",
@@ -3261,6 +3392,26 @@ dependencies = [
"zmij",
]
[[package]]
name = "serde_spanned"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c"
dependencies = [
"serde_core",
]
[[package]]
name = "sha1"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest",
]
[[package]]
name = "shlex"
version = "2.0.1"
@@ -3690,6 +3841,18 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-tungstenite"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
dependencies = [
"futures-util",
"log",
"tokio",
"tungstenite",
]
[[package]]
name = "tokio-util"
version = "0.7.20"
@@ -3704,6 +3867,21 @@ dependencies = [
"tokio",
]
[[package]]
name = "toml"
version = "1.1.8+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow",
]
[[package]]
name = "toml_datetime"
version = "1.1.2+spec-1.1.0"
@@ -3734,6 +3912,12 @@ dependencies = [
"winnow",
]
[[package]]
name = "toml_writer"
version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770"
[[package]]
name = "tower"
version = "0.5.3"
@@ -3823,6 +4007,22 @@ version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
[[package]]
name = "tungstenite"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
dependencies = [
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand 0.9.5",
"sha1",
"thiserror 2.0.21",
]
[[package]]
name = "type-map"
version = "0.5.1"
@@ -3832,6 +4032,12 @@ dependencies = [
"rustc-hash 2.1.3",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-bidi"
version = "0.3.18"
@@ -3892,7 +4098,7 @@ version = "0.48.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "977d0a4abdef933f424a99fe09f95576e089b90aebc6f016a3bc813762493e91"
dependencies = [
"base64",
"base64 0.23.1",
"data-url",
"flate2",
"fontdb",
@@ -4778,6 +4984,16 @@ dependencies = [
"memchr",
]
[[package]]
name = "winresource"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b"
dependencies = [
"toml",
"version_check",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
+50 -13
View File
@@ -1,35 +1,42 @@
[package]
name = "paper-rs"
version = "0.1.0"
name = "papercraft"
version = "0.2.0"
edition = "2024"
rust-version = "1.95"
description = "A native infinite-canvas app for planning, working and sketching"
license = "MIT OR Apache-2.0"
[lib]
name = "paper_rs"
name = "papercraft"
path = "src/lib.rs"
[[bin]]
name = "paper-rs"
name = "papercraft"
path = "src/main.rs"
[[bin]]
name = "paper-rs-mcp"
path = "src/bin/paper-rs-mcp.rs"
name = "papercraft-mcp"
path = "src/bin/papercraft-mcp.rs"
[[bin]]
name = "papercraft-cli"
path = "src/bin/papercraft-cli.rs"
[[bin]]
name = "papercraft-relay"
path = "src/bin/papercraft-relay.rs"
[dependencies]
egui = "0.36"
egui-wgpu = "0.36"
egui-winit = "0.36"
wgpu = "30"
winit = "0.30"
lyon = "1"
ttf-parser = "0.25"
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp"] }
pollster = "0.4"
web-time = "1"
bytemuck = { version = "1", features = ["derive"] }
log = "0.4"
env_logger = "0.11"
@@ -40,15 +47,23 @@ craft-ui = { version = "0.1.0", git = "https://git.marcospaulo.dev.br/masi/craft
i_overlay = "9.0.1"
miniz_oxide = "0.9.1"
usvg = "0.48"
arboard = { version = "3.6.1", features = ["image-data"] }
rmcp = { version = "3.5.1", default-features = false, features = ["server", "base64", "transport-streamable-http-server", "transport-io", "client", "transport-streamable-http-client-reqwest"] }
tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "macros", "time", "io-std"] }
axum = { version = "0.8", default-features = false, features = ["http1", "tokio"] }
tokio-util = "0.7"
tokio = { version = "1", features = ["sync"] }
base64 = "0.23"
[build-dependencies]
winresource = "0.1"
[profile.release]
debug = false
# Browser bundle only (`web/build.sh`): size over compile time. Native release is untouched.
[profile.web]
inherits = "release"
lto = true
codegen-units = 1
panic = "abort"
opt-level = "z"
[profile.dev.package."*"]
opt-level = 2
@@ -56,6 +71,8 @@ opt-level = 2
resvg = "0.48"
rmcp = { version = "3.5.1", default-features = false, features = ["transport-child-process"] }
reqwest = { version = "0.13", default-features = false }
# The relay test drives a fake tab over a real WebSocket; same version axum's `ws` uses.
tokio-tungstenite = "0.29"
[target.'cfg(target_os = "macos")'.dependencies]
muda = { version = "0.21", default-features = false }
@@ -63,3 +80,23 @@ objc2 = "0.6"
objc2-app-kit = "0.3"
objc2-foundation = "0.3"
raw-window-handle = "0.6"
# Native only: the OS clipboard and the MCP HTTP server do not exist in the browser.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
egui-winit = "0.36"
winit = "0.30"
arboard = { version = "3.6.1", features = ["image-data"] }
rmcp = { version = "3.5.1", default-features = false, features = ["server", "base64", "transport-streamable-http-server", "transport-io", "client", "transport-streamable-http-client-reqwest"] }
tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "macros", "time", "io-std", "fs"] }
axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "ws"] }
tokio-util = "0.7"
# Browser build (`src/web.rs`): canvas + WebGPU/WebGL surface, DOM events instead of winit.
[target.'cfg(target_arch = "wasm32")'.dependencies]
wgpu = { version = "30", features = ["webgl"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
console_error_panic_hook = "0.1"
console_log = "1"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "KeyboardEvent", "PointerEvent", "WheelEvent", "ClipboardEvent", "DataTransfer", "MediaQueryList", "Location", "Performance", "Navigator", "Clipboard", "CssStyleDeclaration", "console", "WebSocket", "MessageEvent", "CloseEvent", "ErrorEvent", "Storage", "Crypto", "DragEvent", "FileList", "File", "Blob"] }
+260 -240
View File
@@ -1,219 +1,280 @@
# paper-rs
<h1 align="center">PaperCraft</h1>
A native Rust infinite canvas for planning, working and sketching. It is modeled on the
behavior of [Paper](https://paper.design) and built clean-room on `wgpu` + `winit` + `egui`.
<p align="center">
<b>Design and whiteboarding on an infinite canvas; a native, local-first, agent-driven design tool, built in pure Rust.</b>
</p>
![paper-rs main window](docs/screenshots/app_demo.png)
<p align="center">
Frames, auto layout, components, variables and icons, plus a hand-drawn whiteboard mode,<br>
in a native app written entirely in Rust. Open source, offline, and drivable by AI agents.
</p>
A local-first design and whiteboard tool. Projects are folders of pages; each page is an
infinite canvas with frames, groups, components and instances, auto layout and constraints,
vector paths and booleans, gradients and image fills, rich text, smart guides, and
pressure-sensitive ink — plus a whiteboard mode: hand-drawn (rough.js-style) shapes, arrows that
bind to shapes and re-route, elbow connectors, sticky notes, a laser pointer and a shape
library. Everything autosaves, and exports to PNG, SVG and PDF; Excalidraw files and SVG import.
<p align="center">
<img alt="Status: early alpha" src="https://img.shields.io/badge/status-early%20alpha-d69e2e?style=flat-square">
<img alt="100% Rust" src="https://img.shields.io/badge/100%25-Rust-b7410e?style=flat-square&logo=rust">
<img alt="Native on egui and wgpu" src="https://img.shields.io/badge/egui%20%C2%B7%20wgpu-native-2f7bf5?style=flat-square">
<img alt="MCP server and CLI for agents" src="https://img.shields.io/badge/agents-MCP%20%2B%20CLI-555555?style=flat-square">
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%2F%20Apache--2.0-3a3a3a?style=flat-square">
</p>
![Figma-core features](docs/screenshots/milestone3_figma_core.png)
<br>
> **Clean-room note.** `analysis/PAPER_ARCHITECTURE.md` documents how Paper Desktop 0.5.15 is
> put together, from a licensed install. The Electron package turned out to be a thin shell
> around the hosted web editor. paper-rs re-implements *behavior* only. No Paper code,
> shaders, icons, fonts or assets are used or committed. The UI and canvas text use
> [Inter](https://rsms.me/inter/) 4.1 (SIL OFL 1.1), bundled in `assets/fonts/`.
<p align="center">
<img src="docs/screenshots/app_demo.png" alt="PaperCraft with a launch plan: a Week plan frame with sticky notes and a progress bar, a Sketches frame with hand-drawn shapes, the Layers panel and the Design inspector" width="100%">
<br>
<sub>A planning board: frames, sticky notes, a progress bar, hand-drawn sketches, the Layers panel and the Design inspector.</sub>
</p>
## Reverse-engineered origin
<p align="center">
<a href="#features">Features</a> ·
<a href="#drive-it-with-agents">Agents</a> ·
<a href="#design-systems-variables-components-icons">Design systems</a> ·
<a href="#get-started">Get started</a> ·
<a href="#keyboard-shortcuts">Shortcuts</a> ·
<a href="#status">Status</a> ·
<a href="#downloads">Downloads</a> ·
<a href="#license-and-credits">License</a>
</p>
paper-rs is an independent, clean-room-style reimplementation. It was reverse engineered from
[Paper](https://paper.design) by studying the behavior of the Paper desktop app with
[REA](https://github.com/morluto/rea) and Claude Code. It is not affiliated with or endorsed by
Paper. No original Paper code or assets are included in this repository.
<br>
## Projects
<table>
<tr>
<td width="25%" valign="top">
<h3>Local-first</h3>
Projects are plain folders of JSON in <code>~/Documents/papercraft</code>. Autosave with atomic writes, no account, no cloud.
</td>
<td width="25%" valign="top">
<h3>Native and fast</h3>
egui on wgpu (Metal, Vulkan, DX12). No Electron, no web view. Cached tessellation: a frame with 10k visible nodes draws in about 3 ms.
</td>
<td width="25%" valign="top">
<h3>Design and whiteboard</h3>
Auto layout, components and variables next to hand-drawn shapes, bound arrows, sticky notes and a laser pointer, on the same canvas.
</td>
<td width="25%" valign="top">
<h3>Agent-ready</h3>
40 MCP tools and a shell CLI. Agents write HTML into layers, screenshot the result and lint it, one undo step per call.
</td>
</tr>
</table>
paper-rs opens on a **home screen** of your projects (recents grid with thumbnails, New, Import,
and Rename / Duplicate / Move to Trash from each card's ⋯ menu or right-click; restore from
Trash). Each project is a plain folder in `~/Documents/paper-rs/<name>/` holding `project.json`
and one JSON file per page in `pages/`. Work is autosaved about a second after each change with
crash-safe atomic writes; files are versioned and older versions migrate on load. Set
`PAPER_RS_HOME` to use another folder.
<br>
A project has any number of **pages** (Pages list at the top of the left sidebar: + to add,
double-click to rename, right-click to duplicate or delete, drag to reorder). Each page is its
own infinite canvas with its own undo history and view. Copy and paste works between pages and
between projects.
## Features
## Build and run
<table>
<tr>
<td width="50%" valign="top">
<img src="docs/screenshots/milestone3_figma_core.png" alt="A design with frames, auto layout, a component and its instances, and vector shapes" width="100%">
<br>
<h3>Design tools</h3>
Frames with clipping, groups, components with instances and per-property overrides, auto layout (direction, gap, padding, hug, fill, wrap, space-between), constraints, the Pen with Bézier editing, boolean operations, gradients, image fills, strokes, per-corner radii, opacity and blend modes, rich text, smart guides and measurements, align and distribute.
</td>
<td width="50%" valign="top">
<img src="docs/screenshots/milestone4_whiteboard.png" alt="A whiteboard with hand-drawn shapes, bound arrows, an elbow connector and sticky notes" width="100%">
<br>
<h3>A whiteboard that sketches</h3>
Hand-drawn (rough.js-style) rectangles, ellipses, diamonds and lines, arrows that bind to shapes and re-route when they move, elbow connectors, sticky notes, freehand ink with pressure, a laser pointer and a shape library.
</td>
</tr>
<tr>
<td width="50%" valign="top">
<img src="docs/screenshots/milestone5_inspector.png" alt="The inspector with position, size, constraints, auto layout, fills, strokes and export sections" width="100%">
<br>
<h3>Inspector, layers and a palette</h3>
A sectioned inspector, a layers tree you can drag to reorder or re-parent, 100+ keyboard shortcuts, and a command palette over every action, page and layer (⌘K).
</td>
<td width="50%" valign="top">
<img src="docs/screenshots/milestone6_svg_import.png" alt="An imported SVG on the canvas" width="100%">
<br>
<h3>Import and export</h3>
Export a selection, each frame, a page or the whole project to PNG (1 to 4x), SVG or vector PDF. Import <code>.excalidraw</code> scenes and libraries, SVG and images; paste between pages, projects and apps.
</td>
</tr>
</table>
You need Rust 1.95 or newer (`rust-version` in `Cargo.toml`; the latest stable is recommended). Install it with [rustup](https://rustup.rs).
### Themes
```sh
cargo build --release
./target/release/paper-rs # projects home screen
./target/release/paper-rs --demo # sample planning board (not saved)
./target/release/paper-rs ~/Documents/paper-rs/Plan # open a project folder
./target/release/paper-rs board.paper-rs # open a paper-rs 0.1 document
```
System, Light, Dark and the four Catppuccin flavours: Latte, Frappé, Macchiato and Mocha. Pick one in **View › Theme** or search "theme" in the ⌘K palette.
### macOS
### Projects and pages
```sh
xcode-select --install # command line tools (linker)
curl https://sh.rustup.rs -sSf | sh
cargo build --release && ./target/release/paper-rs --demo
```
PaperCraft opens on a **home screen** of your projects (recents grid with thumbnails; New, Import, and Rename, Duplicate or Move to Trash from each card's ⋯ menu or right-click; restore from Trash). A project is a folder in `~/Documents/papercraft/<name>/` with `project.json` and one JSON file per page in `pages/`. Work autosaves about a second after each change with crash-safe atomic writes; files are versioned and older versions migrate on load. Set `PAPERCRAFT_HOME` to use another folder. If you used the app under its earlier name, `~/Documents/paper-rs` is still read when it is the only one that exists.
It uses Metal through wgpu. Shortcuts use ⌘, and trackpad pinch zooms. The macOS target is
type-checked in CI-style (`cargo check --target aarch64-apple-darwin`) but has not been run on
Mac hardware yet (see Known gaps).
Each project has any number of **pages** (the Pages list at the top of the left sidebar: + to add, double-click to rename, right-click to duplicate or delete, drag to reorder). Each page is its own infinite canvas with its own undo history and view. Copy and paste works between pages and projects.
### Linux
### File formats
```sh
# Debian/Ubuntu runtime libraries (usually already present on desktops)
sudo apt install libxkbcommon-x11-0 libxcb-xkb1 libvulkan1 mesa-vulkan-drivers
cargo build --release && ./target/release/paper-rs --demo
```
| Format | Use |
|---|---|
| Project folder (`project.json` + `pages/*.json`) | The native format. Plain, versioned JSON. |
| `.papercraft` | A single-file document (`papercraft FILE.papercraft`, not autosaved). `--import` turns it into a project. |
| `.paper-rs` | The earlier single-file extension. Old files still open. |
| PNG, SVG, PDF | Export: selection, frames, page or all pages. PDF is vector, with gradients, images and blend modes. |
| `.excalidraw`, `.excalidrawlib`, SVG, images | Import (drag and drop, ⌘O, or `--import`). |
It runs on X11 and Wayland through Vulkan, or GL as a fallback. Shortcuts use Ctrl. File
dialogs use the XDG desktop portal.
### Headless (servers, CI)
No display is needed. wgpu renders offscreen, through Mesa lavapipe when there is no GPU:
```sh
paper-rs --screenshot ui.png --demo --size 1440x900 --scale 2 # full UI
paper-rs --screenshot home.png --home # projects home screen
paper-rs --export-png out.png PROJECT_DIR --page 0 --scale 2 [--transparent]
paper-rs --export-svg out.svg PROJECT_DIR
paper-rs --export-pdf out.pdf PROJECT_DIR --frames # one PDF page per frame
paper-rs --export out_dir PROJECT_DIR --format png --scope frames # a folder of PNGs
paper-rs --import board.excalidraw # new project, prints its folder
```
## AI agents (MCP)
## Drive it with agents
![A sign-in screen written by an agent through write_html](docs/screenshots/milestone7_mcp.png)
paper-rs serves the [Model Context Protocol](https://modelcontextprotocol.io) while it runs, so
agents such as Claude Code and Codex can read and design on the open canvas, the way Paper's
own MCP server works (behaviour notes: `analysis/paper-mcp.md`). Every tool call runs through
the editor like a user edit: one undo step per call, and the window repaints immediately.
PaperCraft serves the [Model Context Protocol](https://modelcontextprotocol.io) while it runs, so agents such as Claude Code and Codex can read and design on the open canvas. Every tool call runs through the editor like a user edit: one undo step per call, and the window repaints immediately. Tool names and arguments follow other design-tool MCP servers where there is an equivalent, so prompts written for them carry over.
* **Streamable HTTP** (built into the app): `http://127.0.0.1:29980/mcp`. Paper uses 29979, so
both can run. Loopback only; requests from browsers (with an `Origin` header) and foreign
`Host` headers are rejected. `PAPER_RS_MCP_PORT=NNNN` changes the port; `PAPER_RS_MCP=0`
turns the server off.
* **stdio**: `paper-rs-mcp` (built next to `paper-rs`) relays stdio to the running app. It
lists the tools even when the app is closed, and tells the agent to start paper-rs if a
call can't reach it.
<table>
<tr>
<td width="33%" valign="top">
<img src="docs/screenshots/agent_signin.png" alt="A dark sign-in screen built from a component, an icon and variables" width="100%">
<br><sub>A sign-in screen: variables, an icon and a component instance.</sub>
</td>
<td width="67%" valign="top">
<img src="docs/screenshots/agent_dashboard.png" alt="A dashboard with a sidebar, three stat cards and a bar chart" width="100%">
<br><sub>A dashboard written in one <code>write_html</code> call, then checked with <code>lint_design</code>.</sub>
</td>
</tr>
<tr>
<td colspan="2" valign="top">
<img src="docs/screenshots/agent_landing.png" alt="A landing page with a hero and three feature cards" width="100%">
<br><sub>A landing page: hero, call to action and feature cards, all colors from variables.</sub>
</td>
</tr>
</table>
* **Streamable HTTP** (built into the app): `http://127.0.0.1:29980/mcp`. Loopback only; requests from browsers (with an `Origin` header) and foreign `Host` headers are rejected. `PAPERCRAFT_MCP_PORT=NNNN` changes the port; `PAPERCRAFT_MCP=0` turns the server off.
* **stdio**: `papercraft-mcp` (built next to `papercraft`) relays stdio to the running app. It lists the tools even when the app is closed, and tells the agent to start PaperCraft if a call can't reach it.
* **Shell**: `papercraft-cli` calls the same tools, so an agent with a terminal needs no schemas in its context.
### Claude Code
```sh
# HTTP (the app must be running)
claude mcp add --transport http --scope user paper-rs http://127.0.0.1:29980/mcp
claude mcp add --transport http --scope user papercraft http://127.0.0.1:29980/mcp
# or stdio through the bridge
claude mcp add --scope user paper-rs -- /path/to/paper-rs/target/release/paper-rs-mcp
claude mcp add --scope user papercraft -- /path/to/papercraft-mcp
```
### Design tab
The sidebar's **Design** tab (next to Layers and Library) lists the project's color variables as
editable swatches, switches variable modes (for example the four Catppuccin flavours), and lists the
page's components with Go to and Insert buttons. Editing a variable recolours every fill that follows it.
### Codex
```sh
# HTTP
codex mcp add paper-rs --url http://127.0.0.1:29980/mcp
codex mcp add papercraft --url http://127.0.0.1:29980/mcp
# or stdio
codex mcp add paper-rs -- /path/to/paper-rs/target/release/paper-rs-mcp
codex mcp add papercraft -- /path/to/papercraft-mcp
```
which writes this to `~/.codex/config.toml` (either form works):
```toml
[mcp_servers.paper-rs]
[mcp_servers.papercraft]
url = "http://127.0.0.1:29980/mcp"
# command = "/path/to/paper-rs/target/release/paper-rs-mcp"
# command = "/path/to/papercraft-mcp"
```
Then ask the agent something like *"In paper-rs, create a 390×844 mobile artboard and design a
sign-in screen"*, or *"draw a flowchart of our deploy process on a new page, hand-drawn
style"*.
Then ask the agent something like *"In PaperCraft, create a 390×844 mobile artboard and design a sign-in screen"*, or *"draw a flowchart of our deploy process on a new page, hand-drawn style"*. The [`papercraft` and `papercraft-design` agent skills](skills) teach an agent the workflow (build, screenshot, lint, fix) and design rules; [docs/agents.md](docs/agents.md) has the full guide.
### Shell (no MCP client needed)
The app must be running with a project open.
```sh
papercraft-cli list # tool names, one line each (works offline)
papercraft-cli schema write_html # one tool's full schema
papercraft-cli call get_basic_info
papercraft-cli call create_artboard '{"name":"Hero","styles":{"width":"800px","height":"400px"}}'
echo '{"nodeId":"12"}' | papercraft-cli call get_screenshot - --out hero.png
```
Text goes to stdout, images are saved to `--out` (default `<tool>.png`) and the path is printed, and the exit status is 1 on a tool error or when the app isn't running.
### Tools
Names and arguments follow Paper's MCP server where paper-rs has an equivalent, so prompts
written for Paper work here too.
| Group | Tools |
|---|---|
| Projects & pages | `list_resources`, `open_file`, `create_file`, `create_page`, `rename_pages` |
| Read | `get_basic_info`, `get_selection`, `get_node_info`, `get_children`, `get_tree_summary`, `get_computed_styles`, `get_jsx`, `find_nodes`, `get_fill_image`, `get_font_family_info`, `get_guide` |
| See | `get_screenshot` (PNG image content of a node or the page), `export` (PNG/SVG/PDF files), `export_combined_pdf` |
| Write | `create_artboard`, `write_html`, `update_styles`, `set_text_content`, `rename_nodes`, `duplicate_nodes`, `move_nodes`, `delete_nodes`, `finish_working_on_nodes` (saves) |
| paper-rs extras | `create_shape` (rectangles, ellipses, diamonds, stars, stickies, text, lines, arrows bound to shapes), `select_nodes`, `undo`, `redo` |
| Whiteboard and session | `create_shape` (rectangles, ellipses, diamonds, stars, stickies, text, lines, arrows bound to shapes), `select_nodes`, `undo`, `redo` |
| Design system | `get_variables`, `set_variables`, `set_variable_mode`, `get_components`, `create_component`, `create_instance`, `search_icons`, `lint_design` |
`write_html` takes HTML with inline styles: flexbox (`display:flex`, direction, gap, padding,
alignment, `flex-grow`, wrap) and block stacking map onto auto layout with CSS's stretch rules;
text gets size, weight, italic, color, line height, letter spacing, alignment and rich-text
runs (`<b>`, `<i>`, `<span style>`); boxes get backgrounds (colors, gradients, images),
borders, radii, opacity and blend modes; `<img>` takes local paths or `data:` URIs and `<svg>`
is imported as vectors. `update_styles` takes the same CSS as JSON, plus the hand-drawn keys
`sketch`, `roughness`, `fillStyle`, `strokeStyle`. Node ids look like `3fa9c1:14` (page and
node); `pageId` lets an agent work on another page without switching the user's view.
`write_html` takes HTML with inline styles: flexbox (`display:flex`, direction, gap, padding, alignment, `flex-grow`, wrap) and block stacking map onto auto layout with CSS's stretch rules; text gets size, weight, italic, color, line height, letter spacing, alignment and rich-text runs (`<b>`, `<i>`, `<span style>`); boxes get backgrounds (colors, gradients, images), borders, radii, opacity and blend modes; `<img>` takes local paths or `data:` URIs and `<svg>` is imported as vectors. `update_styles` takes the same CSS as JSON, plus the hand-drawn keys `sketch`, `roughness`, `fillStyle`, `strokeStyle`. Node ids look like `3fa9c1:14` (page and node); `pageId` lets an agent work on another page without switching the user's view. The full reference is in [skills/papercraft/references/tools.md](skills/papercraft/references/tools.md).
## Tests
## Design systems: variables, components, icons
Agents (and `papercraft-cli`) get the pieces of a design system, so files aren't a pile of hex codes and copies:
```sh
cargo test # ~140 tests: unit, scenario, headless GPU, CLI
PAPER_RS_REQUIRE_GPU=1 cargo test # fail (instead of skip) when no GPU adapter exists
papercraft-cli call set_variables '{"variables":{"surface":"#313244","accent":"#cba6f7"}}'
papercraft-cli call write_html '{"targetNodeId":"root","mode":"insert-children","html":"<div style=\"background:var(--surface)\">…</div>"}'
papercraft-cli call create_component '{"nodeId":"<frame>","name":"Button / Primary"}'
papercraft-cli call create_instance '{"componentId":"<id>","parentId":"<frame>"}' # then set_text_content on its layers
papercraft-cli call lint_design '{"nodeId":"<frame>"}' # contrast, grid, type scale, names
papercraft-cli icons add phosphor lucide # open icon sets from npm, with their licences
papercraft-cli icons search arrow left
# in write_html: <x-paper-icon name="phosphor:gear" size="20" color="var(--accent)"/>
```
* **Variables** are named colors per project (`get_variables`, `set_variables`). A fill or text color written as `var(--name)` stays linked; `set_variables` moves every linked fill on every page in one undoable step, and the link is saved in the file. `set_variables` with a `mode` stores one set of values per mode (Light, Dark) and `set_variable_mode` switches the whole project. Strokes and gradients keep fixed colors; fill-based icon sets (Phosphor, Material) follow variables, stroke sets (Lucide) don't.
* **Components** (`create_component`, `create_instance`, `get_components`, `<x-paper-instance>`): edit the main component and every instance follows, except what an instance overrode (text, fills, size, ... are marked automatically when set through `set_text_content` / `update_styles`). Components and their instances live on one page.
* **Icons** live in `<data dir>/papercraft/icons/<set>/` (override with `PAPERCRAFT_ICONS`). Sets: Lucide (ISC), Tabler (MIT), Phosphor (MIT), Heroicons (MIT), Material Symbols (Apache-2.0); each keeps its licence file. `examples/taskflow.py` builds an 8-screen app with all three.
* **Lint** (`lint_design`) checks WCAG contrast, spacing on a 4px grid, the type scale, text under 12px, touch targets under 44px, default layer names and hard-coded colors that should be variables, and says how to fix each.
More in [docs/design-system.md](docs/design-system.md).
## Get started
### Download
The macOS app (`papercraft-<version>-macos-<arch>.dmg`, containing PaperCraft.app) and the command-line tools (`papercraft-cli-<version>-macos-<arch>.zip`, with `papercraft-cli` and `papercraft-mcp`) are attached to each release of [masi/papercraft](https://git.marcospaulo.dev.br/masi/papercraft/releases). Builds are ad-hoc signed and not notarized, so on another Mac open the app with right-click › Open the first time.
### Build from source
You need Rust 1.95 or newer (`rust-version` in `Cargo.toml`). Install it with [rustup](https://rustup.rs).
```sh
cargo build --release
./target/release/papercraft # projects home screen
./target/release/papercraft --demo # sample planning board (not saved)
./target/release/papercraft ~/Documents/papercraft/Plan # open a project folder
./target/release/papercraft board.papercraft # open a document
packaging/macos/package.sh # PaperCraft.app on a DMG, plus the CLI zip (macOS)
```
**macOS:** `xcode-select --install` for the linker, then the commands above. It uses Metal through wgpu. Shortcuts use ⌘, and trackpad pinch zooms.
**Linux:** install the runtime libraries (`sudo apt install libxkbcommon-x11-0 libxcb-xkb1 libvulkan1 mesa-vulkan-drivers` on Debian/Ubuntu), then build. It runs on X11 and Wayland through Vulkan, or GL as a fallback. Shortcuts use Ctrl. File dialogs use the XDG desktop portal.
### Headless (servers, CI)
No display is needed. wgpu renders offscreen, through Mesa lavapipe when there is no GPU:
```sh
papercraft --screenshot ui.png --demo --size 1440x900 --scale 2 # full UI
papercraft --screenshot home.png --home # projects home screen
papercraft --export-png out.png PROJECT_DIR --page 0 --scale 2 [--transparent]
papercraft --export-svg out.svg PROJECT_DIR
papercraft --export-pdf out.pdf PROJECT_DIR --frames # one PDF page per frame
papercraft --export out_dir PROJECT_DIR --format png --scope frames # a folder of PNGs
papercraft --import board.excalidraw # new project, prints its folder
```
### Tests
```sh
cargo test # unit, scenario, headless GPU and CLI tests
PAPERCRAFT_REQUIRE_GPU=1 cargo test # fail (instead of skip) when no GPU adapter exists
cargo test --release --test perf -- --nocapture # 10k-element frame timings
```
* **Unit tests** cover geometry, vector paths, the rough.js port (compared with real
rough.js 4.6.4 output), auto layout and constraints, booleans, rich text spans, the elbow
router, formats and migrations, atomic writes, the project store, PDF structure, Excalidraw
and SVG import, the palette's fuzzy matching and the shortcut table.
* **Scenario tests** drive the editor with synthetic pointer and keyboard events:
`src/editor/tests.rs` (core editing), `tests/projects.rs` (projects, autosave, pages),
`tests/figma.rs` (groups, components, auto layout, constraints, pen, booleans, rich text,
guides), `tests/excalidraw.rs` (hand-drawn style, arrows, quick-connect, stickies, laser,
library), `tests/workflow.rs` (layers drag, palette, shortcuts), `tests/io.rs` (export,
import, clipboard, CLI), `tests/mcp.rs` (every MCP tool on a headless session, then an rmcp
client over HTTP and through the `paper-rs-mcp` stdio bridge).
* **Headless GPU tests** render through wgpu, assert pixels and write the screenshots below.
One rasterizes the SVG export with resvg and compares it with the GPU render; one renders
the PDF export with pdftoppm when installed.
The scenario tests drive the editor with synthetic pointer and keyboard events; `tests/mcp.rs` exercises every MCP tool on a headless session, then through an rmcp client over HTTP and through the `papercraft-mcp` stdio bridge. The SVG export is rasterized with resvg and compared with the GPU render.
| Milestone | Screenshot(s) in `docs/screenshots/` |
|---|---|
| 1. Local projects | `milestone1_home.png`, `milestone1_trash.png` |
| 2. Pages | `milestone2_pages.png` |
| 3. Figma core | `milestone3_figma_core.png`, `milestone3_canvas.png` |
| 4. Excalidraw core | `milestone4_whiteboard.png` |
| 5. Inspector, palette, shortcuts | `milestone5_inspector.png`, `milestone5_palette.png`, `milestone5_shortcuts.png` |
| 6. Import / export | `milestone6_pdf_page.png` (PDF rasterized by pdftoppm), `milestone6_excalidraw_import.png`, `milestone6_svg_import.png` |
| 7. MCP server | `milestone7_mcp.png` (a screen written through `write_html`, captured with `get_screenshot`), `milestone7_mcp_live_xvfb.png` (the live app after Claude Code drew into it over the stdio bridge) |
| paper-rs 0.1 | `m1_*` … `m6_*`, `app_demo.png`, `x11_window_xvfb.png` |
## Keyboard shortcuts
## Using it
![Whiteboard features](docs/screenshots/milestone4_whiteboard.png)
The tools sit in the floating bar at the bottom of the canvas. Select a layer to edit it in
the **Design** panel on the right. The **Layers** panel on the left shows the tree: click to
select, Shift-click to add, double-click to rename, and use 👁 / 🔒 to hide or lock. The
**Up/Down** buttons reorder.
* Draw a frame around existing shapes and it adopts them. Shapes drawn inside a frame become
its children, and dragging a layer into or out of a frame re-parents it.
* Top-level frames that contain content act as canvas regions. Drag from their body to
marquee-select inside them, and drag the frame by its name label.
* The pencil keeps a constant on-screen width at any zoom. With nothing selected, set width,
smoothing and color in the Design panel.
* Hold ⌘/Ctrl while dragging to suspend snapping.
### Keyboard shortcuts
⌘ on macOS, Ctrl on Linux and Windows. Press `?` or ⌘/ in the app for the full grouped list
(100+ bindings), or ⌘K to search every command.
⌘ on macOS, Ctrl on Linux and Windows. Press `?` or ⌘/ in the app for the full grouped list (100+ bindings), or ⌘K to search every command.
| Keys | Action | Keys | Action |
|---|---|---|---|
@@ -235,82 +296,25 @@ select, Shift-click to add, double-click to rename, and use 👁 / 🔒 to hide
| ⌘= / ⌘- / ⇧0 | Zoom in / out / 100% | ⇧1 / ⇧2 | Zoom to fit / selection |
| PageUp / PageDown | Previous / next page | ⌘\\ | Show / hide UI |
## Feature parity: Paper / Figma / Excalidraw vs paper-rs
Draw a frame around existing shapes and it adopts them; shapes drawn inside a frame become its children, and dragging a layer into or out of a frame re-parents it. Hold ⌘/Ctrl while dragging to suspend snapping.
Columns 2–4 say whether each product has the feature (✓) and where the behavior was learned:
Paper from its desktop shell and public docs (`analysis/PAPER_ARCHITECTURE.md`), Figma from
public docs (`analysis/FIGMA_NOTES.md`), Excalidraw from its MIT source
(`analysis/EXCALIDRAW_NOTES.md`). The last column is paper-rs: ✅ done, ⚠️ partial (see
Known gaps), ⏳ planned in a later milestone, ❌ not planned.
## Status
| Feature | Paper | Figma | Excalidraw | paper-rs |
|---|---|---|---|---|
| Local projects, recents grid, trash | (cloud dashboard) | ✓ (cloud) | (single file) | ✅ Folders in `~/Documents/paper-rs`, thumbnails, rename/duplicate/trash/restore |
| Autosave, crash-safe, versioned files | ✓ (cloud) | ✓ (cloud) | ✓ (browser storage) | ✅ 1 s autosave, atomic writes, migration chain (page v1→v3) |
| Pages, infinite canvas per page | ✓ | ✓ | – | ✅ Per-page history and camera, reorder/duplicate/delete |
| Frames with clipping | ✓ | ✓ | ✓ | ✅ ⚠️ clip is rectangular (corner radius not clipped) |
| Groups | ✓ | ✓ | ✓ | ✅ Bounds follow children, click selects group, double-click enters, scale on resize |
| Components, instances, overrides | – | ✓ | (library only) | ✅ Live sync, per-property overrides, reset, detach, go to main |
| Auto layout (direction, gap, padding, hug/fill/fixed, wrap) | ✓ (flexbox) | ✓ | – | ✅ Drag-reorder in flow, space-between, absolute children |
| Constraints | – | ✓ | – | ✅ Left/right/stretch/center/scale per axis, ⌘ to ignore |
| Pen tool, vector point editing | ✓ | ✓ | – | ✅ Corner/smooth points, handles (⌥ breaks), close, delete, toggle |
| Boolean ops (union/subtract/intersect/exclude), flatten | (roadmap) | ✓ | – | ✅ ⚠️ destructive (result is a path, curves flattened) |
| Gradients (linear, radial) | ✓ | ✓ | – | ✅ Multi-stop, angle, radius; ⚠️ no on-canvas gradient handles |
| Image fills | ✓ | ✓ | ✓ (image element) | ✅ Fill/fit/stretch/tile, place image (⌘⇧K), drag-and-drop, stored in project |
| Strokes: width, align, dash, caps, joins | ✓ | ✓ | ✓ | ✅ |
| Corner radius (per corner), polygons, stars | ✓ | ✓ | – | ✅ |
| Opacity, blend modes | ✓ | ✓ | ✓ (opacity) | ✅ ⚠️ canvas renders 5 modes exactly, all 16 exact in SVG |
| Rich text (per-range bold/italic/size/colour) | ✓ | ✓ | – | ✅ Plus underline, strikethrough, line height, letter spacing |
| Align / distribute | ✓ | ✓ | ✓ | ✅ ⌥A/D/W/S/H/V, ⌥⇧H/V |
| Smart guides, spacing, measurement | ✓ | ✓ | – | ✅ Edge/center snap, equal spacing, ⌥-hover distances |
| Hand-drawn (rough) style per element | – | – | ✓ | ✅ rough.js 4.6.4 port, verified against rough.js output; sloppiness, fill styles |
| Bound arrows, elbow arrows, quick shapes, sticky notes | – | (FigJam) | ✓ | ✅ Orbit binding that follows shapes, straight/curved/elbow, 5 heads, diamond, quick-connect, stickies |
| Laser pointer, shape library | – | (FigJam) | ✓ | ✅ Fading laser trail; shared library with starter items |
| Inspector, layers drag-reorder, command palette (⌘K) | ✓ | ✓ | ✓ | ✅ Sectioned inspector, tree drag above/below/into, fuzzy palette over actions, pages, layers, library |
| Export PNG / SVG / PDF of selection, frame, page | ✓ | ✓ | ✓ (PNG/SVG) | ✅ Selection, each frame, page or all pages; vector PDF with gradients, images, blend modes; copy as PNG/SVG |
| Import .excalidraw / SVG, copy-paste across projects | – | ✓ (SVG) | ✓ | ✅ .excalidraw scenes, .excalidrawlib libraries, SVG (paths, gradients, images, text), system clipboard between pages, projects and apps |
| Freehand ink with pressure | – | (pencil) | ✓ | ✅ Smoothing, pressure, eraser |
| MCP server for AI agents | ✓ | ✓ (Dev Mode) | ✓ (community) | ✅ Streamable HTTP in the app + stdio bridge; Paper-compatible tool names, `write_html`, screenshots, whiteboard shapes |
| Multiplayer, cloud sync, plugins | ✓ | ✓ | ✓ | ❌ local-first by design |
> [!IMPORTANT]
> **PaperCraft is in early alpha.** It is usable for planning, sketching and building screens, but it is not a replacement for a mature design tool. [ROADMAP.md](ROADMAP.md) lists what is missing, in priority order; [PROGRESS.md](PROGRESS.md) is the milestone history.
## Known gaps
Known limits:
* **Paper's document model is HTML/CSS.** paper-rs uses geometric nodes with flexbox-like auto
layout on frames; there are no variables, CSS export or code generation.
* **Rendering approximations.** Frame clipping is a rectangle (scissor), so rounded frames
don't clip their corners. Group opacity multiplies into children instead of compositing the
group. Overlay, color dodge/burn, hard/soft light, difference, exclusion, hue, saturation,
color and luminosity blend as Normal on the canvas (they are exact in SVG export).
* **Booleans are destructive**: the result is a new vector path with flattened curves.
Gradients are edited in the inspector only.
* **Not run on macOS hardware yet.** The macOS build type-checks and uses only portable APIs
(Metal via wgpu, winit, rfd), but it has not been launched on a Mac. On Linux, the real
window was exercised under Xvfb; the screenshot is `x11_window_xvfb.png`.
* **Pen pressure.** winit 0.30 does not expose tablet pressure for pen events that arrive as
mouse input, which is common with macOS and Wacom tablets. Pressure comes from touch/pen
`force` when the platform reports it. Otherwise it is simulated from stroke velocity.
* **Text** uses Inter (regular, bold, italic, bold italic) with no shaping, so there are no
ligatures, complex scripts or RTL, and no font picker. IME preedit is not displayed
(committed text works).
* **No rotation.** Nodes are axis-aligned boxes; rotated imports are baked into paths.
* **Undo granularity.** Document renames from the Design panel are not undoable, and the
selection is not part of history.
* **Performance.** Nodes are tessellated once per zoom octave and cached; unchanged frames
reuse the previous item list and GPU buffers, so panning uploads nothing. With 10k visible
nodes a cached frame takes ~3 ms (release, `tests/perf.rs`); the first frame at a new zoom
octave takes ~140 ms on a CPU. Not tuned for 100k+.
* **PDF**: text is outlined (not selectable); gradient stop transparency uses the first
stop's alpha; tiled image fills are drawn as repeated images.
* **Import**: Excalidraw rotation is baked into paths (text rotation is dropped), bound text
inside shapes stays a separate text layer (except sticky notes), and fonts map to Inter.
SVG patterns, filters, masks and clip paths are not imported.
* **MCP**: one project is open at a time, so `fileId` must name the open project (call
`open_file` to switch; the user's window switches too). No design tokens, comments or AI
image generation (Paper has them). Moving or duplicating layers between pages isn't
supported. Remote `http(s)` images aren't fetched; pass a local path or a `data:` URI.
CSS margin, grid, shadows and transforms are reported back as ignored styles.
* **Linux file dialogs** need an XDG desktop portal. Without one, Save/Open dialogs return
nothing. The headless CLI is unaffected.
* **Text** is Inter only (regular, bold, italic, bold italic) with no shaping: no ligatures, complex scripts or RTL, and no font picker. IME preedit is not displayed (committed text works).
* **Rendering approximations.** Frame clipping is a rectangle, so rounded frames don't clip their corners. Group opacity multiplies into children. Overlay, dodge, burn, light, difference, exclusion and the HSL blend modes draw as Normal on the canvas (they are exact in SVG export).
* **Booleans are destructive**: the result is a new vector path with flattened curves.
* **Variables are colors only** and apply to fills and text color; strokes and gradients keep fixed colors.
* **`write_html`** ignores margin, grid, tables, shadows and transforms (it reports them as `ignoredStyles`). Remote `http(s)` images aren't fetched; pass a local path or a `data:` URI.
* **MCP**: one project is open at a time, so `fileId` must name the open project. Moving or duplicating layers between pages isn't supported.
* **Pen pressure.** winit 0.30 does not expose tablet pressure for pen events that arrive as mouse input, which is common with macOS and Wacom tablets. Otherwise it is simulated from stroke velocity.
* **Linux file dialogs** need an XDG desktop portal. The headless CLI is unaffected.
* **PDF** text is outlined (not selectable).
## Project layout
@@ -322,24 +326,40 @@ src/history.rs transactions, undo/redo
src/layout.rs auto layout, constraints, group bounds
src/ink.rs stroke smoothing, pressure, outline
src/text.rs rich text layout and glyph outlines
src/assets.rs image registry (content-hashed)
src/project/ project store, manifest/page formats, migrations
src/session.rs open project, autosave, thumbnails
src/editor/ tools, structure (groups/components), vector ops, pages, text editing
src/render/ draw ops → cached meshes → wgpu; headless capture
src/io/ PNG, SVG, PDF, export scopes, Excalidraw + SVG import, clipboard,
atomic writes
src/connector.rs arrow bindings, elbow router, arrowheads
src/library.rs shared shape library
src/ui/ home screen, pages, layers, inspector
src/app.rs winit application
src/mcp/ MCP server (rmcp): tools, HTML/CSS mapping, HTTP service
src/bin/paper-rs-mcp.rs stdio bridge to the running app
analysis/ reverse-engineering findings and design (raw dumps are gitignored)
docs/screenshots/ headless renders produced by `cargo test`
src/io/ PNG, SVG, PDF, export scopes, Excalidraw + SVG import, clipboard
src/mcp/ MCP server (rmcp): tools, HTML/CSS mapping, lint, HTTP service
src/ui/ home screen, pages, layers, inspector, palette
src/bin/ papercraft-cli (shell access) and papercraft-mcp (stdio bridge)
skills/ agent skills: papercraft, papercraft-design
analysis/ behaviour studies and design notes
docs/ guides and headless screenshots produced by `cargo test`
```
## License
## Downloads
MIT OR Apache-2.0 for the code in this repository. Inter (Regular, Medium, SemiBold) is
© The Inter Project Authors under the SIL Open Font License 1.1; see `assets/fonts/OFL.txt`.
| Build | File | Notes |
|---|---|---|
| Windows installer | `papercraft-<ver>-windows-<arch>.msi` | Per-machine installer (WiX) |
| Windows portable | `papercraft-<ver>-windows-<arch>-portable.zip` | `papercraft.exe` + CLI and MCP bridge |
| macOS app | `papercraft-<ver>-macos-<arch>.dmg` | PaperCraft.app, drag to Applications. Ad-hoc signed, not notarized |
| Command-line tools | `papercraft-cli-<ver>-<platform>-<arch>.zip` | `papercraft-cli` and `papercraft-mcp` |
Releases: https://git.marcospaulo.dev.br/masi/papercraft/releases. Other platforms: build from source.
## License and credits
PaperCraft is dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE), at your option.
Bundled fonts, icons and other assets keep their own open licenses; each one is listed with its author, source and license in [ATTRIBUTION.md](ATTRIBUTION.md). Inter (Regular, Medium, SemiBold, Bold and italics) is © The Inter Project Authors under the SIL Open Font License 1.1 (`assets/fonts/OFL.txt`). A few algorithms are ported from MIT-licensed projects (rough.js, hachure-fill, Excalidraw); they are listed with their notices in [THIRD_PARTY.md](THIRD_PARTY.md).
The app icon is original artwork ([`assets/app-icon/`](assets/app-icon/README.md)).
### Reverse-engineered origin
PaperCraft is an independent, clean-room-style reimplementation. It was reverse engineered from [Paper](https://paper.design) by studying the behavior of the Paper desktop app (`analysis/PAPER_ARCHITECTURE.md`) with [REA](https://github.com/morluto/rea) and Claude Code; Figma and Excalidraw were studied from their public docs and, for Excalidraw, its MIT source (`analysis/`). Only *behavior* was studied: no Paper or Figma code, shaders, icons, fonts or assets are used or committed. PaperCraft is not affiliated with, sponsored by or endorsed by Paper or Figma; those names are used only to describe the workflows it is compatible with.
<sub>Photoshop, Illustrator and Adobe are trademarks of Adobe Inc.; Figma is a trademark of Figma, Inc.; Paper is a product of its respective owners. PaperCraft is an independent, open-source project.</sub>
+73
View File
@@ -0,0 +1,73 @@
# Roadmap
PaperCraft is an early alpha: a local-first infinite-canvas design app that agents can drive. This
file lists what is still missing, in priority order, with an honest status. Done items are checked
against `src/mcp/tools.rs` and the UI code (2026-10-12). Milestone history is in
[PROGRESS.md](PROGRESS.md); known rendering and import limits are in the README.
Status: **done** (works today), **partial** (some of it works), **todo**.
## 1. Design systems (the current focus)
Agents can already build a design system through the tools; people cannot yet manage one in the UI.
| # | Item | Status | Notes |
|---|---|---|---|
| 1 | Inspector UI for variables | todo | Variables exist only through `get_variables` / `set_variables`. No panel to list, rename, edit or bind them. |
| 2 | Inspector UI for components | todo | Create component, detach and go-to-main are shortcuts (⌘⌥K / ⌘⌥B); there is no Components panel or asset browser. |
| 3 | Design lint | **done** | `lint_design`: WCAG contrast, 4px grid, type scale, tiny text, 44px targets, default names, hard-coded colors. |
| 4 | Lint auto-fix | todo | `lint_design` suggests a fix per issue; nothing applies it. Safe candidates: snap spacing, rename layers, relink colors to variables. |
| 5 | Lint in the UI | todo | Show issues on the canvas or in a panel, click to select. |
| 6 | Variable modes | partial | Done through tools: `set_variables {mode}` stores values per mode and `set_variable_mode` switches the whole project. Not yet in the UI, and not per frame or page. |
| 7 | Number variables (spacing, radius, font size) | todo | Variables are colors only, and only for fills and text color. |
| 8 | Stroke and gradient variables | todo | Strokes and gradients keep fixed colors. |
| 9 | Text styles | todo | Named type styles (Heading / L, Body / M) that update every text layer using them. |
| 10 | Component variants and properties | todo | Variants are separate components named `Button / Primary`. No variant sets, boolean or text properties. |
| 11 | Swap instance, detach by tool | partial | Detach is a shortcut in the UI. No `swap_instance` or `detach_instance` tool. |
| 12 | Cross-page and shared libraries | todo | Components and instances must live on one page. |
| 13 | Export design tokens | partial | `get_variables` with `format: "css"` or `"json"` returns tokens. No Tailwind or Style Dictionary output, no file export from the UI. |
| 14 | Apply a design system to existing work | todo | Map hard-coded colors and sizes onto variables and components in one pass. |
## 2. Agent workflow
| # | Item | Status | Notes |
|---|---|---|---|
| 15 | MCP and CLI tool surface | **done** | 40 tools, one undo step per call, `papercraft-cli list / schema / call`. |
| 16 | Agent skills | **done** | `skills/papercraft` and `skills/papercraft-design`. |
| 17 | Accessibility annotations | todo | Reading order, roles and alt text on layers, checked by the linter. |
| 18 | Code export from frames | todo | `get_jsx` returns inline-style JSX only. No React / HTML+CSS with variables, no Tailwind (the `tailwind` format is accepted and ignored). |
| 19 | Plugin and skill marketplace | todo | Skills are copied by hand today. |
| 20 | Screenshot diffing | todo | Compare two renders of a frame to confirm a change did what was asked. |
| 21 | Moving nodes between pages | todo | `move_nodes` stays on one page. |
| 22 | Remote images in `write_html` | todo | Only local paths and `data:` URIs. |
## 3. Design features
| # | Item | Status | Notes |
|---|---|---|---|
| 23 | Prototyping links and flows | todo | No click-through between frames. |
| 24 | Responsive constraints and breakpoint preview | partial | Constraints (left, right, stretch, center, scale) exist in the editor; there is no breakpoint preview or per-size variants. |
| 25 | Shadows, blur and effects | todo | CSS `box-shadow` and filters are reported as ignored. |
| 26 | Margin and grid layout | todo | Flexbox only. |
| 27 | Font choice | todo | Inter only. |
| 28 | Rotation | todo | Nodes are axis-aligned. |
| 29 | Overlay, dodge, burn, light blend modes on the canvas | todo | Exact in SVG and PDF export, approximated as Normal on the canvas. |
| 30 | Rounded frame clipping | todo | Clipping is a rectangle. |
| 31 | Non-destructive booleans | todo | The result is a flattened path. |
## 4. Files and collaboration
| # | Item | Status | Notes |
|---|---|---|---|
| 32 | Version history | todo | Undo per page and crash-safe autosave only; no named versions or restore. |
| 33 | Figma import | todo | SVG and Excalidraw import exist; `.fig` does not. |
| 34 | Multiplayer and cloud sync | todo | Local-first by design. If added, it must stay optional. |
| 35 | Release builds for Windows and Linux | todo | The code targets them (wgpu, winit); packaging today is the macOS DMG and the `papercraft-cli` zip. |
## 5. Quality
| # | Item | Status | Notes |
|---|---|---|---|
| 36 | Pen pressure on macOS tablets | todo | winit 0.30 does not expose pressure for mouse-like pen events. |
| 37 | Text shaping, ligatures, RTL | todo | No shaping. IME preedit is not shown. |
| 38 | Performance beyond 10k visible nodes | todo | Cached frames take about 3 ms at 10k nodes; not tuned for 100k. |
+1 -1
View File
@@ -1,6 +1,6 @@
# Third-party code and assets
paper-rs is MIT OR Apache-2.0. It ports a few algorithms from MIT-licensed projects (listed
papercraft is MIT OR Apache-2.0. It ports a few algorithms from MIT-licensed projects (listed
below with their copyright notices and license text) and bundles the Inter font (SIL OFL 1.1,
`assets/fonts/OFL.txt`). Figma and Paper are proprietary: only their *behavior* was studied
(`analysis/FIGMA_NOTES.md`, `analysis/PAPER_ARCHITECTURE.md`), and no code, shaders, icons or
+5
View File
@@ -0,0 +1,5 @@
# PaperCraft app icon
An original design: a sheet of paper with a folded corner inside a selection frame with handles, and a pen nib drawing on it. Catppuccin palette: Crust/Mantle gradient tile, Text `#cdd6f4` sheet, Mauve `#cba6f7` fold and nib, Blue `#89b4fa` frame.
`papercraft.svg` is the source (macOS tile with Apple's margin, 824/1024 of the canvas); regenerate the PNGs with `resvg papercraft.svg papercraft-1024.png -w 1024`, then `iconutil` for the `.icns`. The `.ico` is the tile cropped edge to edge.
License: original artwork, MIT OR Apache-2.0 like the rest of the repository.
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

+28
View File
@@ -0,0 +1,28 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
<title>PaperCraft app icon</title>
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#313244"/><stop offset="1" stop-color="#181825"/></linearGradient>
<linearGradient id="nib" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#d9bcfa"/><stop offset="1" stop-color="#b48ef0"/></linearGradient>
<clipPath id="tile"><path d="M924.0,512.0 L923.9,608.0 L923.8,638.6 L923.5,660.9 L923.1,679.0 L922.6,694.5 L922.0,708.2 L921.2,720.5 L920.4,731.8 L919.4,742.2 L918.3,751.9 L917.1,761.0 L915.8,769.6 L914.4,777.6 L912.8,785.3 L911.2,792.6 L909.4,799.5 L907.5,806.1 L905.4,812.4 L903.3,818.5 L901.0,824.2 L898.5,829.8 L896.0,835.1 L893.3,840.2 L890.5,845.1 L887.6,849.8 L884.5,854.3 L881.2,858.7 L877.9,862.8 L874.3,866.8 L870.7,870.7 L866.8,874.3 L862.8,877.9 L858.7,881.2 L854.3,884.5 L849.8,887.6 L845.1,890.5 L840.2,893.3 L835.1,896.0 L829.8,898.5 L824.2,901.0 L818.5,903.3 L812.4,905.4 L806.1,907.5 L799.5,909.4 L792.6,911.2 L785.3,912.8 L777.6,914.4 L769.6,915.8 L761.0,917.1 L751.9,918.3 L742.2,919.4 L731.8,920.4 L720.5,921.2 L708.2,922.0 L694.5,922.6 L679.0,923.1 L660.9,923.5 L638.6,923.8 L608.0,923.9 L512.0,924.0 L416.0,923.9 L385.4,923.8 L363.1,923.5 L345.0,923.1 L329.5,922.6 L315.8,922.0 L303.5,921.2 L292.2,920.4 L281.8,919.4 L272.1,918.3 L263.0,917.1 L254.4,915.8 L246.4,914.4 L238.7,912.8 L231.4,911.2 L224.5,909.4 L217.9,907.5 L211.6,905.4 L205.5,903.3 L199.8,901.0 L194.2,898.5 L188.9,896.0 L183.8,893.3 L178.9,890.5 L174.2,887.6 L169.7,884.5 L165.3,881.2 L161.2,877.9 L157.2,874.3 L153.3,870.7 L149.7,866.8 L146.1,862.8 L142.8,858.7 L139.5,854.3 L136.4,849.8 L133.5,845.1 L130.7,840.2 L128.0,835.1 L125.5,829.8 L123.0,824.2 L120.7,818.5 L118.6,812.4 L116.5,806.1 L114.6,799.5 L112.8,792.6 L111.2,785.3 L109.6,777.6 L108.2,769.6 L106.9,761.0 L105.7,751.9 L104.6,742.2 L103.6,731.8 L102.8,720.5 L102.0,708.2 L101.4,694.5 L100.9,679.0 L100.5,660.9 L100.2,638.6 L100.1,608.0 L100.0,512.0 L100.1,416.0 L100.2,385.4 L100.5,363.1 L100.9,345.0 L101.4,329.5 L102.0,315.8 L102.8,303.5 L103.6,292.2 L104.6,281.8 L105.7,272.1 L106.9,263.0 L108.2,254.4 L109.6,246.4 L111.2,238.7 L112.8,231.4 L114.6,224.5 L116.5,217.9 L118.6,211.6 L120.7,205.5 L123.0,199.8 L125.5,194.2 L128.0,188.9 L130.7,183.8 L133.5,178.9 L136.4,174.2 L139.5,169.7 L142.8,165.3 L146.1,161.2 L149.7,157.2 L153.3,153.3 L157.2,149.7 L161.2,146.1 L165.3,142.8 L169.7,139.5 L174.2,136.4 L178.9,133.5 L183.8,130.7 L188.9,128.0 L194.2,125.5 L199.8,123.0 L205.5,120.7 L211.6,118.6 L217.9,116.5 L224.5,114.6 L231.4,112.8 L238.7,111.2 L246.4,109.6 L254.4,108.2 L263.0,106.9 L272.1,105.7 L281.8,104.6 L292.2,103.6 L303.5,102.8 L315.8,102.0 L329.5,101.4 L345.0,100.9 L363.1,100.5 L385.4,100.2 L416.0,100.1 L512.0,100.0 L608.0,100.1 L638.6,100.2 L660.9,100.5 L679.0,100.9 L694.5,101.4 L708.2,102.0 L720.5,102.8 L731.8,103.6 L742.2,104.6 L751.9,105.7 L761.0,106.9 L769.6,108.2 L777.6,109.6 L785.3,111.2 L792.6,112.8 L799.5,114.6 L806.1,116.5 L812.4,118.6 L818.5,120.7 L824.2,123.0 L829.8,125.5 L835.1,128.0 L840.2,130.7 L845.1,133.5 L849.8,136.4 L854.3,139.5 L858.7,142.8 L862.8,146.1 L866.8,149.7 L870.7,153.3 L874.3,157.2 L877.9,161.2 L881.2,165.3 L884.5,169.7 L887.6,174.2 L890.5,178.9 L893.3,183.8 L896.0,188.9 L898.5,194.2 L901.0,199.8 L903.3,205.5 L905.4,211.6 L907.5,217.9 L909.4,224.5 L911.2,231.4 L912.8,238.7 L914.4,246.4 L915.8,254.4 L917.1,263.0 L918.3,272.1 L919.4,281.8 L920.4,292.2 L921.2,303.5 L922.0,315.8 L922.6,329.5 L923.1,345.0 L923.5,363.1 L923.8,385.4 L923.9,416.0Z"/></clipPath>
<filter id="sh" x="-20%" y="-20%" width="140%" height="150%"><feDropShadow dx="0" dy="14" stdDeviation="16" flood-color="#000" flood-opacity=".4"/></filter>
</defs>
<path d="M924.0,512.0 L923.9,608.0 L923.8,638.6 L923.5,660.9 L923.1,679.0 L922.6,694.5 L922.0,708.2 L921.2,720.5 L920.4,731.8 L919.4,742.2 L918.3,751.9 L917.1,761.0 L915.8,769.6 L914.4,777.6 L912.8,785.3 L911.2,792.6 L909.4,799.5 L907.5,806.1 L905.4,812.4 L903.3,818.5 L901.0,824.2 L898.5,829.8 L896.0,835.1 L893.3,840.2 L890.5,845.1 L887.6,849.8 L884.5,854.3 L881.2,858.7 L877.9,862.8 L874.3,866.8 L870.7,870.7 L866.8,874.3 L862.8,877.9 L858.7,881.2 L854.3,884.5 L849.8,887.6 L845.1,890.5 L840.2,893.3 L835.1,896.0 L829.8,898.5 L824.2,901.0 L818.5,903.3 L812.4,905.4 L806.1,907.5 L799.5,909.4 L792.6,911.2 L785.3,912.8 L777.6,914.4 L769.6,915.8 L761.0,917.1 L751.9,918.3 L742.2,919.4 L731.8,920.4 L720.5,921.2 L708.2,922.0 L694.5,922.6 L679.0,923.1 L660.9,923.5 L638.6,923.8 L608.0,923.9 L512.0,924.0 L416.0,923.9 L385.4,923.8 L363.1,923.5 L345.0,923.1 L329.5,922.6 L315.8,922.0 L303.5,921.2 L292.2,920.4 L281.8,919.4 L272.1,918.3 L263.0,917.1 L254.4,915.8 L246.4,914.4 L238.7,912.8 L231.4,911.2 L224.5,909.4 L217.9,907.5 L211.6,905.4 L205.5,903.3 L199.8,901.0 L194.2,898.5 L188.9,896.0 L183.8,893.3 L178.9,890.5 L174.2,887.6 L169.7,884.5 L165.3,881.2 L161.2,877.9 L157.2,874.3 L153.3,870.7 L149.7,866.8 L146.1,862.8 L142.8,858.7 L139.5,854.3 L136.4,849.8 L133.5,845.1 L130.7,840.2 L128.0,835.1 L125.5,829.8 L123.0,824.2 L120.7,818.5 L118.6,812.4 L116.5,806.1 L114.6,799.5 L112.8,792.6 L111.2,785.3 L109.6,777.6 L108.2,769.6 L106.9,761.0 L105.7,751.9 L104.6,742.2 L103.6,731.8 L102.8,720.5 L102.0,708.2 L101.4,694.5 L100.9,679.0 L100.5,660.9 L100.2,638.6 L100.1,608.0 L100.0,512.0 L100.1,416.0 L100.2,385.4 L100.5,363.1 L100.9,345.0 L101.4,329.5 L102.0,315.8 L102.8,303.5 L103.6,292.2 L104.6,281.8 L105.7,272.1 L106.9,263.0 L108.2,254.4 L109.6,246.4 L111.2,238.7 L112.8,231.4 L114.6,224.5 L116.5,217.9 L118.6,211.6 L120.7,205.5 L123.0,199.8 L125.5,194.2 L128.0,188.9 L130.7,183.8 L133.5,178.9 L136.4,174.2 L139.5,169.7 L142.8,165.3 L146.1,161.2 L149.7,157.2 L153.3,153.3 L157.2,149.7 L161.2,146.1 L165.3,142.8 L169.7,139.5 L174.2,136.4 L178.9,133.5 L183.8,130.7 L188.9,128.0 L194.2,125.5 L199.8,123.0 L205.5,120.7 L211.6,118.6 L217.9,116.5 L224.5,114.6 L231.4,112.8 L238.7,111.2 L246.4,109.6 L254.4,108.2 L263.0,106.9 L272.1,105.7 L281.8,104.6 L292.2,103.6 L303.5,102.8 L315.8,102.0 L329.5,101.4 L345.0,100.9 L363.1,100.5 L385.4,100.2 L416.0,100.1 L512.0,100.0 L608.0,100.1 L638.6,100.2 L660.9,100.5 L679.0,100.9 L694.5,101.4 L708.2,102.0 L720.5,102.8 L731.8,103.6 L742.2,104.6 L751.9,105.7 L761.0,106.9 L769.6,108.2 L777.6,109.6 L785.3,111.2 L792.6,112.8 L799.5,114.6 L806.1,116.5 L812.4,118.6 L818.5,120.7 L824.2,123.0 L829.8,125.5 L835.1,128.0 L840.2,130.7 L845.1,133.5 L849.8,136.4 L854.3,139.5 L858.7,142.8 L862.8,146.1 L866.8,149.7 L870.7,153.3 L874.3,157.2 L877.9,161.2 L881.2,165.3 L884.5,169.7 L887.6,174.2 L890.5,178.9 L893.3,183.8 L896.0,188.9 L898.5,194.2 L901.0,199.8 L903.3,205.5 L905.4,211.6 L907.5,217.9 L909.4,224.5 L911.2,231.4 L912.8,238.7 L914.4,246.4 L915.8,254.4 L917.1,263.0 L918.3,272.1 L919.4,281.8 L920.4,292.2 L921.2,303.5 L922.0,315.8 L922.6,329.5 L923.1,345.0 L923.5,363.1 L923.8,385.4 L923.9,416.0Z" fill="url(#bg)"/>
<g clip-path="url(#tile)">
<!-- selection frame -->
<rect x="282" y="212" width="460" height="600" fill="none" stroke="#89b4fa" stroke-width="22"/>
<!-- sheet -->
<g filter="url(#sh)">
<path d="M322 252H600L702 354V772H322Z" fill="#cdd6f4"/>
</g>
<path d="M600 252L702 354H600Z" fill="#cba6f7"/>
<rect x="382" y="440" width="260" height="30" rx="15" fill="#9399b2" opacity=".6"/>
<rect x="382" y="512" width="260" height="30" rx="15" fill="#9399b2" opacity=".6"/>
<rect x="254" y="184" width="56" height="56" rx="6" fill="#1e1e2e" stroke="#89b4fa" stroke-width="22"/><rect x="714" y="184" width="56" height="56" rx="6" fill="#1e1e2e" stroke="#89b4fa" stroke-width="22"/><rect x="254" y="784" width="56" height="56" rx="6" fill="#1e1e2e" stroke="#89b4fa" stroke-width="22"/><rect x="714" y="784" width="56" height="56" rx="6" fill="#1e1e2e" stroke="#89b4fa" stroke-width="22"/>
<!-- pen nib -->
<g transform="translate(470 580) rotate(135) scale(1.0)" filter="url(#sh)">
<path d="M0 0L-52 -120Q-70 -170 -44 -214H44Q70 -170 52 -120Z" fill="url(#nib)" stroke="#1e1e2e" stroke-width="22" stroke-linejoin="round"/>
<path d="M0 0V-104" stroke="#1e1e2e" stroke-width="12" stroke-linecap="round"/>
<circle cx="0" cy="-132" r="17" fill="#1e1e2e"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.1 KiB

+30
View File
@@ -0,0 +1,30 @@
//! Windows only: embed the app icon and version info (VERSIONINFO) into `papercraft.exe`, so it
//! shows in Explorer, the taskbar, the Start menu and Alt-Tab.
//!
//! On every other target this does nothing. A missing resource compiler is a warning, so a
//! cross-compile from macOS or Linux still links, unless `PAPERCRAFT_REQUIRE_WINRES=1` turns it
//! into an error (for release builds).
fn main() {
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=assets/app-icon/papercraft.ico");
println!("cargo:rerun-if-env-changed=PAPERCRAFT_REQUIRE_WINRES");
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
return;
}
let mut res = winresource::WindowsResource::new();
res.set_icon("assets/app-icon/papercraft.ico")
.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("OriginalFilename", "papercraft.exe")
.set("InternalName", "papercraft");
if let Err(e) = res.compile() {
if std::env::var_os("PAPERCRAFT_REQUIRE_WINRES").is_some() {
eprintln!("embedding Windows resources failed: {e}");
std::process::exit(1);
}
println!("cargo:warning=papercraft.exe built without icon/version resources: {e}");
}
}
+86
View File
@@ -0,0 +1,86 @@
# Driving PaperCraft with agents
PaperCraft exposes one set of tools (40 at the time of writing) three ways. They all run the same code inside the app, so an agent sees what you see and every call is one undo step.
| Way | Use it when | Needs the app running |
|---|---|---|
| MCP over HTTP, `http://127.0.0.1:29980/mcp` | the agent client speaks MCP (Claude Code, Codex, pi) | yes |
| MCP over stdio, `papercraft-mcp` | the client only starts stdio servers | for calls; `tools/list` works offline |
| Shell, `papercraft-cli` | the agent has a terminal and you want no tool schemas in its context | for `call`; `list`, `schema` and `icons` work offline |
## Install
1. Get the app and the tools: the DMG and the `papercraft-cli` zip from the [releases](https://git.marcospaulo.dev.br/masi/papercraft/releases), or `cargo build --release` (binaries `papercraft`, `papercraft-cli`, `papercraft-mcp` in `target/release/`).
2. Put `papercraft-cli` and `papercraft-mcp` on your `PATH` (for example in `~/.local/bin`).
3. Start PaperCraft and open or create a project. The server starts with the app. `PAPERCRAFT_MCP_PORT` changes the port (the CLI and the bridge read it too); `PAPERCRAFT_MCP=0` turns the server off.
Check the connection:
```sh
papercraft-cli call get_basic_info # project, page, artboards
```
Exit status 1 with "isn't running" means the app is closed or the port differs.
## Register with an agent
Claude Code:
```sh
claude mcp add --transport http --scope user papercraft http://127.0.0.1:29980/mcp
# or through the stdio bridge
claude mcp add --scope user papercraft -- /path/to/papercraft-mcp
```
Codex:
```sh
codex mcp add papercraft --url http://127.0.0.1:29980/mcp
codex mcp add papercraft -- /path/to/papercraft-mcp # stdio alternative
```
pi (and other agents without built-in MCP): use the shell. Tell the agent to run `papercraft-cli list`, then `papercraft-cli schema <tool>` for the tools it needs, and install the skills below.
Any other MCP client: point it at the HTTP URL, or run `papercraft-mcp` as a stdio server.
## Install the skills
Two agent skills in [`skills/`](../skills) teach the workflow and the design rules:
* `papercraft`: how to drive the tools (connection check, build, screenshot, lint, fix loop, naming, layout recipes, known limits).
* `papercraft-design`: how to make the result look good (a direction first, 4/8pt spacing, a type scale, contrast, states, components and tokens).
Link them where your agent looks for skills, for example `ln -s "$PWD/skills/papercraft" ~/.agents/skills/papercraft` (same for `papercraft-design`).
## First prompts
* "In PaperCraft, create a 390×844 mobile artboard and design a sign-in screen. Use variables for every color, build the button as a component, and run `lint_design` before you finish."
* "Add a dark mode to this project: define Light and Dark values for every variable and switch with `set_variable_mode`."
* "Draw a flowchart of our deploy process on a new page, hand-drawn style."
* "Lint this page and fix every warning."
## A session by hand
```sh
export PATH="$PWD/target/release:$PATH"
papercraft-cli call get_basic_info
papercraft-cli call set_variables '{"variables":{"bg":"#1e1e2e","text":"#cdd6f4","accent":"#cba6f7"}}'
papercraft-cli call create_artboard '{"name":"Hello","styles":{"width":"390px","height":"844px","backgroundColor":"var(--bg)","padding":"24px"}}'
# -> {"id":"abc123:1",...}
echo '{"targetNodeId":"abc123:1","mode":"insert-children","html":"<div layer-name=\"Title\" style=\"font-size:32px;font-weight:700;color:var(--text)\">Hello</div>"}' | papercraft-cli call write_html -
papercraft-cli call lint_design '{"nodeId":"abc123:1"}'
papercraft-cli call get_screenshot '{"nodeId":"abc123:1"}' --out hello.png
papercraft-cli call finish_working_on_nodes '{"nodeIds":["abc123:1"]}'
```
Pass JSON inline, or `-` to read it from stdin (best for HTML, which is full of quotes; `jq -Rs` can wrap a file into the right JSON). Text results go to stdout. Images are saved to `--out` (default `<tool>.png`) and the path is printed.
## Safety
The server listens on loopback only, rejects requests that carry an `Origin` header (browsers) and foreign `Host` headers. Anything on your machine that can reach `127.0.0.1:29980` can edit the open project, so do not forward the port. Undo (⌘Z, or the `undo` tool) reverts any agent edit.
## Reference
* [Tool reference](../skills/papercraft/references/tools.md), generated from `papercraft-cli schema`.
* [Design systems](design-system.md): variables, components, icons, lint.
* `papercraft-cli --help`-style usage: run `papercraft-cli` with no arguments.
+63
View File
@@ -0,0 +1,63 @@
# Design systems in PaperCraft
A PaperCraft project can hold a small design system: color variables, components with instances, and icon sets. Today these are built and managed through the tools (MCP or `papercraft-cli`); the editor shows the results but has no panel for them yet (see [ROADMAP.md](../ROADMAP.md)).
## Variables
Named colors per project. Define them once, use them as `var(--name)`:
```sh
papercraft-cli call set_variables '{"variables":{"surface":"#313244","accent":"#cba6f7","on-accent":"#11111b"}}'
```
* Use `var(--name)` in `write_html`, `update_styles` and `create_artboard` for `background`, `backgroundColor` and `color`. The fill stays linked and the link is saved in the file.
* Calling `set_variables` again with a new value moves every linked fill, on every page, in one undo step.
* **Modes.** `set_variables {"mode":"Dark", ...}` stores one set of values for a mode; define another mode the same way. The first mode you define is active. `set_variable_mode {"mode":"Light"}` switches the whole project.
* `get_variables` lists the variables and modes. With `"format":"css"` it returns a `:root { --name: #hex }` block, with `"json"` a design-tokens object (`{"accent":{"$type":"color","$value":"#cba6f7"}}`).
* **Limits.** Colors only. Fills and text color follow variables; strokes and gradients get the resolved color at write time and do not follow later changes. No spacing, radius or font-size variables yet.
Naming that works: roles, not hues (`surface`, `text-muted`, `accent`, `on-accent`, `danger`), so a mode can change the value without changing the meaning.
## Components and instances
```sh
papercraft-cli call create_component '{"nodeId":"<frame>","name":"Button / Primary"}'
papercraft-cli call create_instance '{"componentId":"<id>","parentId":"<frame>","name":"Button / Sign in"}'
papercraft-cli call set_text_content '{"updates":[{"nodeId":"<label layer id from create_instance>","textContent":"Sign in"}]}'
```
* Build the main component as a frame (usually its own artboard), then convert it. Name it `Component / Variant`.
* `create_instance` returns the instance id and its layers, so you can set text and styles on them. Changes made through `set_text_content` or `update_styles` on an instance are recorded as overrides; everything else follows the main component.
* In `write_html`, `<x-paper-instance component-id="<id>" layer-name="Button / Save"/>` places an instance inline.
* `get_components` lists main components and how many instances each has.
* **Limits.** Components and their instances must be on the same page. No variants or properties: model variants as separate components (`Button / Primary`, `Button / Secondary`). In the editor, detach with ⌘⌥B; there is no detach or swap tool.
## Icons
`papercraft-cli icons add phosphor lucide` downloads sets from the npm registry into the icons folder (`<data dir>/papercraft/icons/<set>/`, or `$PAPERCRAFT_ICONS`), each with its license file. Sets: Lucide (ISC), Tabler (MIT), Phosphor (MIT), Heroicons (MIT), Material Symbols (Apache-2.0). Find names with `papercraft-cli icons search arrow left` (offline) or the `search_icons` tool, then place one in `write_html`:
```html
<x-paper-icon name="phosphor:gear" size="20" color="var(--accent)"/>
```
Fill-based sets (Phosphor, Material) follow variables; Lucide is stroke-based and keeps a fixed color.
## Lint
`lint_design` checks a subtree or the page and returns issues with a rule, a severity and a fix:
| Rule | Severity | Checks |
|---|---|---|
| `contrast` | error | WCAG 4.5:1 for text, 3:1 for large text (24px+, or 18.66px+ bold), against the nearest opaque fill behind it |
| `text-size` | warning | text under 12px |
| `type-scale` | info | font size not on the scale (default 12, 14, 16, 20, 24, 32, 40, 48, 64; pass `scale` to change) |
| `spacing-grid` | warning | gap or padding not a multiple of 4 |
| `touch-target` | warning | a frame named `Button…`, `Tab /…` or `Tab Bar /…` under 44px tall |
| `layer-name` | warning | default names (Frame, Rectangle, Group...) on layers with children |
| `hard-coded-color` | warning, info | a literal color that equals a variable (warning), or a literal used repeatedly when variables exist (info) |
Instances are skipped (their main component is linted). The rules are conventions, not law: pass `scale` to match your own type scale and ignore `info` items you disagree with. Lint does not judge taste; always look at `get_screenshot` too.
## Worked example
[`examples/taskflow.py`](../examples/taskflow.py) builds an 8-screen app: a Catppuccin theme as variables (the only place hex appears), components for buttons, fields, chips, task rows, toggles and tab bars, Phosphor icons, and a design-system page of swatches. Run it against the running app after `papercraft-cli icons add phosphor`.
Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

+396
View File
@@ -0,0 +1,396 @@
# Interview design system (PaperCraft page `Design System`)
Generated by `design_system.py` + `gen_manifest.py`. Main components are top-level frames on the `Design System` page (library area, right of the doc sections, x >= 1560). Documentation sections: `Section / Colors`, `Section / Typography`, `Section / Spacing`, `Section / Radius & Shadow`, `Section / Page Layout`, `Section / Components` (captioned instances of everything).
## How to use
1. On your page: `papercraft-cli call import_components '{"fromPageId":"<Design System page id>","pageId":"<your page id>"}'` (skips names already there; if you delete a copy and re-import, its instances lose overrides).
2. Place: `<x-paper-instance component="Button / Primary" overrides='{"Label":"Save"}'/>` or `inst_html("Button / Primary", Label="Save")` from pc.py.
3. Overrides JSON: key = exact layer name inside the component (first match), value = string (text) or object (CSS camelCase styles, e.g. `{"Label":{"color":"var(--color-error)"}}`). Hide a part: `{"Col 5":{"display":"none"}}`.
4. The instance root accepts a `style` attribute: `style="width:720px"`, `style="background-color:var(--color-avatar-purple)"`, `style="opacity:.5"` (disabled).
5. Icons inside components cannot be recolored or swapped by overrides (vector layers). That is why the sidebars exist as one variant per active item. For anything else, compose in raw HTML with the recipes below.
6. Components hug their content (fixed width where noted). To stretch, set `style="width:Npx"` on the instance. Uppercase labels (table headers, Stat label, sidebar group labels) are typed uppercase: text-transform is not supported.
7. Instances are page-local; no variants/properties exist, so every variant is a separate component.
## Components
### Buttons (packages/ui Button)
| Component (exact name) | Looks like | Text layers (override keys) |
|---|---|---|
| `Button / Primary` | 34px high, padding 0 16px, 14px label | `Label` |
| `Button / Primary Small` | 28px high, padding 0 12px, 12px label | `Label` |
| `Button / Primary Large` | 40px high, padding 0 20px, 14px label | `Label` |
| `Button / Default` | 34px high, padding 0 16px, 14px label | `Label` |
| `Button / Default Small` | 28px high, padding 0 12px, 12px label | `Label` |
| `Button / Default Large` | 40px high, padding 0 20px, 14px label | `Label` |
| `Button / Ghost` | 34px high, padding 0 16px, 14px label | `Label` |
| `Button / Ghost Small` | 28px high, padding 0 12px, 12px label | `Label` |
| `Button / Ghost Large` | 40px high, padding 0 20px, 14px label | `Label` |
| `Button / Danger` | 34px high, padding 0 16px, 14px label | `Label` |
| `Button / Danger Small` | 28px high, padding 0 12px, 12px label | `Label` |
| `Button / Danger Large` | 40px high, padding 0 20px, 14px label | `Label` |
| `Button / Dark` | 34px high, padding 0 16px, 14px label | `Label` |
| `Button / Dark Small` | 28px high, padding 0 12px, 12px label | `Label` |
| `Button / Dark Large` | 40px high, padding 0 20px, 14px label | `Label` |
| `Button / Notification Bell` | AdminShell bell: 36x36, red unread dot | (none) |
### Buttons (AntD Button)
| Component (exact name) | Looks like | Text layers (override keys) |
|---|---|---|
| `Ant Button / Primary` | AntD button 32px | `Label` |
| `Ant Button / Primary Small` | AntD button 24px | `Label` |
| `Ant Button / Primary Large` | AntD button 40px | `Label` |
| `Ant Button / Default` | AntD button 32px | `Label` |
| `Ant Button / Default Small` | AntD button 24px | `Label` |
| `Ant Button / Default Large` | AntD button 40px | `Label` |
| `Ant Button / Danger` | AntD button 32px | `Label` |
| `Ant Button / Danger Small` | AntD button 24px | `Label` |
| `Ant Button / Danger Large` | AntD button 40px | `Label` |
| `Ant Button / Text` | AntD button 32px | `Label` |
| `Ant Button / Text Small` | AntD button 24px | `Label` |
| `Ant Button / Text Large` | AntD button 40px | `Label` |
| `Ant Button / Text Danger` | AntD button 32px | `Label` |
| `Ant Button / Text Danger Small` | AntD button 24px | `Label` |
| `Ant Button / Text Danger Large` | AntD button 40px | `Label` |
### Avatar
| Component (exact name) | Looks like | Text layers (override keys) |
|---|---|---|
| `Avatar` | 32px circle, initials, colour by name hash | `Initials` |
| `Avatar / 30` | 30px circle, initials, colour by name hash | `Initials` |
| `Avatar / 34` | 34px circle, initials, colour by name hash | `Initials` |
| `Avatar / 36` | 36px circle, initials, colour by name hash | `Initials` |
| `Avatar / 40` | 40px circle, initials, colour by name hash | `Initials` |
### Card
| Component (exact name) | Looks like | Text layers (override keys) |
|---|---|---|
| `Card` | Card: 24px padding, radius-lg, shadow-sm | `Card Title`, `Card Body` |
| `Card / Hover` | hover: shadow-md, blue border | `Card Title`, `Card Body` |
| `Card / Compact` | pad=md (16px) | `Card Title`, `Card Body` |
### Sparkline
| Component (exact name) | Looks like | Text layers (override keys) |
|---|---|---|
| `Sparkline / Blue` | 72x30 line + gradient area | (none) |
| `Sparkline / Purple` | 72x30 line + gradient area | (none) |
| `Sparkline / Orange` | 72x30 line + gradient area | (none) |
| `Sparkline / Green` | 72x30 line + gradient area | (none) |
| `Sparkline / Teal` | 72x30 line + gradient area | (none) |
| `Sparkline / Orange Down` | downward trend | (none) |
### Stat
| Component (exact name) | Looks like | Text layers (override keys) |
|---|---|---|
| `Stat / Blue` | label, value 30/700, sparkline, delta line | `Label`, `Value`, `Delta Arrow`, `Delta`, `Trend` |
| `Stat / Purple` | label, value 30/700, sparkline, delta line | `Label`, `Value`, `Delta Arrow`, `Delta`, `Trend` |
| `Stat / Orange` | label, value 30/700, sparkline, delta line | `Label`, `Value`, `Delta Arrow`, `Delta`, `Trend` |
| `Stat / Green` | label, value 30/700, sparkline, delta line | `Label`, `Value`, `Delta Arrow`, `Delta`, `Trend` |
| `Stat / Teal` | label, value 30/700, sparkline, delta line | `Label`, `Value`, `Delta Arrow`, `Delta`, `Trend` |
| `Stat / Orange Down` | negative delta: red arrow | `Label`, `Value`, `Delta Arrow`, `Delta`, `Trend` |
### Tag
| Component (exact name) | Looks like | Text layers (override keys) |
|---|---|---|
| `Tag / Blue` | 22px chip, 12px/500 | `Label` |
| `Tag Dot / Blue` | with status dot | `Label` |
| `Tag / Green` | 22px chip, 12px/500 | `Label` |
| `Tag Dot / Green` | with status dot | `Label` |
| `Tag / Gold` | 22px chip, 12px/500 | `Label` |
| `Tag Dot / Gold` | with status dot | `Label` |
| `Tag / Red` | 22px chip, 12px/500 | `Label` |
| `Tag Dot / Red` | with status dot | `Label` |
| `Tag / Purple` | 22px chip, 12px/500 | `Label` |
| `Tag Dot / Purple` | with status dot | `Label` |
| `Tag / Cyan` | 22px chip, 12px/500 | `Label` |
| `Tag Dot / Cyan` | with status dot | `Label` |
| `Tag / Gray` | neutral | `Label` |
| `Tag Dot / Gray` | neutral with dot | `Label` |
### Tag (AntD Tag status colors)
| Component (exact name) | Looks like | Text layers (override keys) |
|---|---|---|
| `Ant Tag / Default` | antd <Tag color=...> | `Label` |
| `Ant Tag / Success` | antd <Tag color=...> | `Label` |
| `Ant Tag / Warning` | antd <Tag color=...> | `Label` |
| `Ant Tag / Error` | antd <Tag color=...> | `Label` |
| `Ant Tag / Processing` | antd <Tag color=...> | `Label` |
### Submission Status Badge
| Component (exact name) | Looks like | Text layers (override keys) |
|---|---|---|
| `Submission Status Badge / Queued` | SubmissionStatusBadge | `Label` |
| `Submission Status Badge / Running` | SubmissionStatusBadge | `Label` |
| `Submission Status Badge / Graded` | SubmissionStatusBadge | `Label` |
| `Submission Status Badge / Errored` | SubmissionStatusBadge | `Label` |
| `Submission Status Badge / Unknown` | SubmissionStatusBadge | `Label` |
### Pill (filter chip)
| Component (exact name) | Looks like | Text layers (override keys) |
|---|---|---|
| `Pill / Active` | filter chip, radius 99 | `Label` |
| `Pill / Active With Count` | filter chip, radius 99 | `Label`, `Count` |
| `Pill / Inactive` | filter chip, radius 99 | `Label` |
| `Pill / Inactive With Count` | filter chip, radius 99 | `Label`, `Count` |
### Search Input
| Component (exact name) | Looks like | Text layers (override keys) |
|---|---|---|
| `Search Input` | 260x34 | `Placeholder` |
| `Search Input / Focused` | focus ring | `Placeholder` |
| `Search Input / Filled` | with value | `Placeholder` |
### Segmented (packages/ui)
| Component (exact name) | Looks like | Text layers (override keys) |
|---|---|---|
| `Segmented / 2 Options Active 1` | track border-light, active = white + shadow-sm | `Option 1`, `Option 2` |
| `Segmented / 2 Options Active 2` | track border-light, active = white + shadow-sm | `Option 1`, `Option 2` |
| `Segmented / 3 Options Active 1` | track border-light, active = white + shadow-sm | `Option 1`, `Option 2`, `Option 3` |
| `Segmented / 3 Options Active 2` | track border-light, active = white + shadow-sm | `Option 1`, `Option 2`, `Option 3` |
| `Segmented / 3 Options Active 3` | track border-light, active = white + shadow-sm | `Option 1`, `Option 2`, `Option 3` |
| `Segmented / 4 Options Active 1` | track border-light, active = white + shadow-sm | `Option 1`, `Option 2`, `Option 3`, `Option 4` |
| `Segmented / 4 Options Active 2` | track border-light, active = white + shadow-sm | `Option 1`, `Option 2`, `Option 3`, `Option 4` |
| `Segmented / 4 Options Active 3` | track border-light, active = white + shadow-sm | `Option 1`, `Option 2`, `Option 3`, `Option 4` |
| `Segmented / 4 Options Active 4` | track border-light, active = white + shadow-sm | `Option 1`, `Option 2`, `Option 3`, `Option 4` |
### Segmented (AntD)
| Component (exact name) | Looks like | Text layers (override keys) |
|---|---|---|
| `Ant Segmented / 3 Options Active 1` | antd Segmented (challenges page) | `Option 1`, `Option 2`, `Option 3` |
| `Ant Segmented / 3 Options Active 2` | antd Segmented (challenges page) | `Option 1`, `Option 2`, `Option 3` |
| `Ant Segmented / 3 Options Active 3` | antd Segmented (challenges page) | `Option 1`, `Option 2`, `Option 3` |
### Score Bar / Dots / Stars
| Component (exact name) | Looks like | Text layers (override keys) |
|---|---|---|
| `Score Bar / High` | 160x6 track; set Fill width in px | (none) |
| `Score Bar / Mid` | 160x6 track; set Fill width in px | (none) |
| `Score Bar / Low` | 160x6 track; set Fill width in px | (none) |
| `Score Dots / 0` | 5 squares 26px + label | (none) |
| `Score Dots / 1` | 5 squares 26px + label | `Dot 1 Value`, `Label` |
| `Score Dots / 2` | 5 squares 26px + label | `Dot 1 Value`, `Dot 2 Value`, `Label` |
| `Score Dots / 3` | 5 squares 26px + label | `Dot 1 Value`, `Dot 2 Value`, `Dot 3 Value`, `Label` |
| `Score Dots / 4` | 5 squares 26px + label | `Dot 1 Value`, `Dot 2 Value`, `Dot 3 Value`, `Dot 4 Value`, `Label` |
| `Score Dots / 5` | 5 squares 26px + label | `Dot 1 Value`, `Dot 2 Value`, `Dot 3 Value`, `Dot 4 Value`, `Dot 5 Value`, `Label` |
| `Stars / 0` | ★ gold / ☆ border | (none) |
| `Stars / 1` | ★ gold / ☆ border | (none) |
| `Stars / 2` | ★ gold / ☆ border | (none) |
| `Stars / 3` | ★ gold / ☆ border | (none) |
| `Stars / 4` | ★ gold / ☆ border | (none) |
| `Stars / 5` | ★ gold / ☆ border | (none) |
### Donut / Spin / Empty / Loading
| Component (exact name) | Looks like | Text layers (override keys) |
|---|---|---|
| `Donut` | 132px ring, 16px stroke; segments = stroke colors | `Total`, `Caption` |
| `Spin / Small` | antd Spin small | (none) |
| `Spin` | antd Spin default | (none) |
| `Spin / Large` | antd Spin large | (none) |
| `Loading Block` | LoadingBlock: Spin + tip | `Tip` |
| `Empty / Simple` | antd Empty (simple image) | `Description` |
| `Empty State` | EmptyState: title + description | `Title`, `Description` |
### Page headers
| Component (exact name) | Looks like | Text layers (override keys) |
|---|---|---|
| `Page Header` | PageHeader w/o actions (1192 = 1440 - sidebar) | `Title`, `Subtitle` |
| `Admin Shell Header` | AdminShell header: title, subtitle, bell (instance of Button / Notification Bell) | `Title`, `Subtitle` |
| `Candidate Shell Header` | CandidateShell header (white card inset on layout bg) | `Title`, `Subtitle`, `Exit Label` |
| `Section Title` | SectionTitle: 16/600 + 12 sub | `Title`, `Subtitle` |
| `Section Title / With Action` | with action slot | `Title`, `Subtitle`, `Action` |
### Data Table (packages/ui DataTable)
| Component (exact name) | Looks like | Text layers (override keys) |
|---|---|---|
| `Data Table / Header Row` | uppercase 12/600; hide unused Col n with display:none | `Col 1`, `Col 2`, `Col 3`, `Col 4`, `Col 5` |
| `Data Table / Row` | 13px vertical padding in code (comfortable) | `Cell 1`, `Cell 2`, `Cell 3`, `Cell 4`, `Cell 5` |
| `Data Table / Row Hover` | 13px vertical padding in code (comfortable) | `Cell 1`, `Cell 2`, `Cell 3`, `Cell 4`, `Cell 5` |
| `Pagination` | antd Pagination: 32px items, active = blue border | `Page Label` |
| `Data Table / Footer` | summary + pagination | `Summary`, `Page Label` |
| `Data Table / Empty` | emptyMessage, 48px padding | `Message` |
### Ant Table
| Component (exact name) | Looks like | Text layers (override keys) |
|---|---|---|
| `Ant Table / Header Row` | antd Table th (#fafafa, 14/600) | `Col 1`, `Col 2`, `Col 3`, `Col 4`, `Col 5` |
| `Ant Table / Row` | antd Table td, row ~55px | `Cell 1`, `Cell 2`, `Cell 3`, `Cell 4`, `Cell 5` |
### Input (AntD)
| Component (exact name) | Looks like | Text layers (override keys) |
|---|---|---|
| `Input` | 32px, radius 8, border #d9d9d9 | `Value` |
| `Input / Filled` | with value | `Value` |
| `Input / Focus` | focus ring | `Value` |
| `Input / Error` | error state | `Value` |
| `Input / Disabled` | disabled | `Value` |
| `Input / Large` | size=large: 40px / 16px (forms) | `Value` |
| `Input / Large Filled` | size=large with value | `Value` |
| `Input / Large Focus` | size=large focus | `Value` |
| `Input / Large Error` | size=large error | `Value` |
| `Input / With Prefix` | Workspace URL field | `Prefix`, `Value` |
| `Input / Password` | Input.Password large | `Value` |
| `Input / Search` | Input.Search | `Value` |
| `Input Number` | InputNumber (steppers on the right) | `Value` |
| `Date Picker` | DatePicker | `Value` |
| `Textarea` | Input.TextArea, 3 rows | `Value` |
| `Textarea / Filled` | filled | `Value` |
### Select (AntD)
| Component (exact name) | Looks like | Text layers (override keys) |
|---|---|---|
| `Select` | Select closed | `Value` |
| `Select / Filled` | Select closed | `Value` |
| `Select / Large` | Select closed | `Value` |
| `Select / Large Filled` | Select closed | `Value` |
| `Select / Dropdown` | open options panel; selected = bg-selected + 600 | `Option 1 Label`, `Option 2 Label`, `Option 3 Label` |
### Checkbox / Radio / Switch
| Component (exact name) | Looks like | Text layers (override keys) |
|---|---|---|
| `Checkbox / Unchecked` | 16px box | `Label` |
| `Checkbox / Checked` | 16px box | `Label` |
| `Radio / Unselected` | radio | `Label` |
| `Radio / Selected` | radio | `Label` |
| `Switch / Off` | 44x22 | (none) |
| `Switch / On` | 44x22 | (none) |
### Form Field
| Component (exact name) | Looks like | Text layers (override keys) |
|---|---|---|
| `Form Field` | label + large input (AntD Form.Item vertical) | `Label`, `Value` |
| `Form Field / Required` | * required mark | `Required Mark`, `Label`, `Value` |
| `Form Field / Error` | error + message | `Required Mark`, `Label`, `Value`, `Help` |
| `Form Field / With Help` | help text | `Label`, `Value`, `Help` |
### Overlays
| Component (exact name) | Looks like | Text layers (override keys) |
|---|---|---|
| `Modal` | antd Modal; put on a bg-overlay frame | `Title`, `Body`, `Cancel Label`, `OK Label` |
| `Drawer` | antd Drawer (right, full height) | `Title`, `Body` |
| `Tooltip` | antd Tooltip | `Text` |
### Alert
| Component (exact name) | Looks like | Text layers (override keys) |
|---|---|---|
| `Alert / Info` | antd Alert | `Message` |
| `Alert / Info With Icon` | antd Alert | `Message` |
| `Alert / Success` | antd Alert | `Message` |
| `Alert / Success With Icon` | antd Alert | `Message` |
| `Alert / Warning` | antd Alert | `Message` |
| `Alert / Warning With Icon` | antd Alert | `Message` |
| `Alert / Error` | antd Alert | `Message` |
| `Alert / Error With Icon` | antd Alert | `Message` |
### Tabs
| Component (exact name) | Looks like | Text layers (override keys) |
|---|---|---|
| `Tabs / Tab Active` | antd Tabs item (ink bar when active) | `Label` |
| `Tabs / Tab Active Large` | antd Tabs item (ink bar when active) | `Label` |
| `Tabs / Tab Inactive` | antd Tabs item (ink bar when active) | `Label` |
| `Tabs / Tab Inactive Large` | antd Tabs item (ink bar when active) | `Label` |
| `Tabs / Bar 3 Active 1` | antd Tabs bar, 14px | `Tab 1 Label`, `Tab 2 Label`, `Tab 3 Label` |
| `Tabs / Bar 3 Active 2` | antd Tabs bar, 14px | `Tab 1 Label`, `Tab 2 Label`, `Tab 3 Label` |
| `Tabs / Bar 3 Active 3` | antd Tabs bar, 14px | `Tab 1 Label`, `Tab 2 Label`, `Tab 3 Label` |
| `Tabs / Bar 5 Large Active 1` | configuration page tabs (size=large) | `Tab 1 Label`, `Tab 2 Label`, `Tab 3 Label`, `Tab 4 Label`, `Tab 5 Label` |
| `Tabs / Bar 5 Large Active 2` | configuration page tabs (size=large) | `Tab 1 Label`, `Tab 2 Label`, `Tab 3 Label`, `Tab 4 Label`, `Tab 5 Label` |
| `Tabs / Bar 5 Large Active 3` | configuration page tabs (size=large) | `Tab 1 Label`, `Tab 2 Label`, `Tab 3 Label`, `Tab 4 Label`, `Tab 5 Label` |
| `Tabs / Bar 5 Large Active 4` | configuration page tabs (size=large) | `Tab 1 Label`, `Tab 2 Label`, `Tab 3 Label`, `Tab 4 Label`, `Tab 5 Label` |
| `Tabs / Bar 5 Large Active 5` | configuration page tabs (size=large) | `Tab 1 Label`, `Tab 2 Label`, `Tab 3 Label`, `Tab 4 Label`, `Tab 5 Label` |
### Misc AntD
| Component (exact name) | Looks like | Text layers (override keys) |
|---|---|---|
| `Progress / Circle` | Progress type=circle size=64 | `Value` |
| `Skeleton` | antd Skeleton active (static) | (none) |
### App shell
| Component (exact name) | Looks like | Text layers (override keys) |
|---|---|---|
| `Admin Sidebar` | 248x900 dark nav; no active item | `Brand Mark Letter`, `Brand Name`, `Brand Sub`, `Group Label / Workspace`, `Nav Label / Dashboard`, `Nav Label / Challenges`, `Nav Label / Question bank`, `Nav Label / Sessions`, `Nav Label / Schedule`, `Group Label / Manage`, `Nav Label / Configuration`, `Nav Label / Rubric`, `User Initials`, `User Name`, `User Role Tag Label`, `User Email` |
| `Admin Sidebar / Dashboard Active` | Dashboard highlighted | `Brand Mark Letter`, `Brand Name`, `Brand Sub`, `Group Label / Workspace`, `Nav Label / Dashboard`, `Nav Label / Challenges`, `Nav Label / Question bank`, `Nav Label / Sessions`, `Nav Label / Schedule`, `Group Label / Manage`, `Nav Label / Configuration`, `Nav Label / Rubric`, `User Initials`, `User Name`, `User Role Tag Label`, `User Email` |
| `Admin Sidebar / Challenges Active` | Challenges highlighted | `Brand Mark Letter`, `Brand Name`, `Brand Sub`, `Group Label / Workspace`, `Nav Label / Dashboard`, `Nav Label / Challenges`, `Nav Label / Question bank`, `Nav Label / Sessions`, `Nav Label / Schedule`, `Group Label / Manage`, `Nav Label / Configuration`, `Nav Label / Rubric`, `User Initials`, `User Name`, `User Role Tag Label`, `User Email` |
| `Admin Sidebar / Question bank Active` | Question bank highlighted | `Brand Mark Letter`, `Brand Name`, `Brand Sub`, `Group Label / Workspace`, `Nav Label / Dashboard`, `Nav Label / Challenges`, `Nav Label / Question bank`, `Nav Label / Sessions`, `Nav Label / Schedule`, `Group Label / Manage`, `Nav Label / Configuration`, `Nav Label / Rubric`, `User Initials`, `User Name`, `User Role Tag Label`, `User Email` |
| `Admin Sidebar / Sessions Active` | Sessions highlighted | `Brand Mark Letter`, `Brand Name`, `Brand Sub`, `Group Label / Workspace`, `Nav Label / Dashboard`, `Nav Label / Challenges`, `Nav Label / Question bank`, `Nav Label / Sessions`, `Nav Label / Schedule`, `Group Label / Manage`, `Nav Label / Configuration`, `Nav Label / Rubric`, `User Initials`, `User Name`, `User Role Tag Label`, `User Email` |
| `Admin Sidebar / Schedule Active` | Schedule highlighted | `Brand Mark Letter`, `Brand Name`, `Brand Sub`, `Group Label / Workspace`, `Nav Label / Dashboard`, `Nav Label / Challenges`, `Nav Label / Question bank`, `Nav Label / Sessions`, `Nav Label / Schedule`, `Group Label / Manage`, `Nav Label / Configuration`, `Nav Label / Rubric`, `User Initials`, `User Name`, `User Role Tag Label`, `User Email` |
| `Admin Sidebar / Configuration Active` | Configuration highlighted | `Brand Mark Letter`, `Brand Name`, `Brand Sub`, `Group Label / Workspace`, `Nav Label / Dashboard`, `Nav Label / Challenges`, `Nav Label / Question bank`, `Nav Label / Sessions`, `Nav Label / Schedule`, `Group Label / Manage`, `Nav Label / Configuration`, `Nav Label / Rubric`, `User Initials`, `User Name`, `User Role Tag Label`, `User Email` |
| `Admin Sidebar / Rubric Active` | Rubric highlighted | `Brand Mark Letter`, `Brand Name`, `Brand Sub`, `Group Label / Workspace`, `Nav Label / Dashboard`, `Nav Label / Challenges`, `Nav Label / Question bank`, `Nav Label / Sessions`, `Nav Label / Schedule`, `Group Label / Manage`, `Nav Label / Configuration`, `Nav Label / Rubric`, `User Initials`, `User Name`, `User Role Tag Label`, `User Email` |
| `Candidate Sidebar` | 248x900; no active item | `Brand Name`, `Brand Sub`, `Nav Label / Home`, `Nav Label / My interview`, `User Initials`, `User Name`, `User Email` |
| `Candidate Sidebar / Home Active` | Home highlighted | `Brand Name`, `Brand Sub`, `Nav Label / Home`, `Nav Label / My interview`, `User Initials`, `User Name`, `User Email` |
| `Candidate Sidebar / My interview Active` | My interview highlighted | `Brand Name`, `Brand Sub`, `Nav Label / Home`, `Nav Label / My interview`, `User Initials`, `User Name`, `User Email` |
## Style-override tips
- **Recolor an Avatar**: `style="background-color:var(--color-avatar-purple)"` on the instance (palette: brand-primary, avatar-purple, avatar-teal, warning, avatar-pink, success, avatar-orange, avatar-indigo; the app picks by a hash of the name). Set initials with `{"Initials":"PI"}`.
- **Active sidebar item**: use the variant `Admin Sidebar / <Item> Active` (Dashboard, Challenges, Question bank, Sessions, Schedule, Configuration, Rubric) or `Candidate Sidebar / Home Active` / `My interview Active`. Plain `Admin Sidebar` / `Candidate Sidebar` have no active item. Manual route (text/bg only; icon stays gray): `{"Nav / Sessions":{"backgroundColor":"var(--color-brand-primary)"},"Nav Label / Sessions":{"color":"var(--color-text-inverse)","fontWeight":600}}`. User name/email: `User Name`, `User Email`, `User Initials`, `User Role Tag Label`.
- **Score Bar**: set `Fill` width in px of the 160px track: `{"Fill":{"width":"100px"}}`; recolor `{"Fill":{"backgroundColor":"var(--color-avatar-purple)"}}`; thinner: `{"Fill":{"height":"4px"}}` plus root `style="height:4px"`.
- **Segmented / Ant Segmented**: pick the variant whose `Active N` matches; edit `Option 1..n`.
- **Tabs / Bar**: pick `Active N` variant; edit `Tab N Label`. Bars have a 1px divider under them.
- **Stat**: `Label` (uppercase), `Value`, `Delta`, `Trend`, `Delta Arrow` (use `Stat / Orange Down` for a negative delta). Sparkline shape is fixed.
- **Pill / Tag / Badge status colors**: choose the tone component. Custom: `{"Label":{"color":"var(--color-tag-red-fg)"}}` and root `style="background-color:var(--color-tag-red-bg);border:1px solid var(--color-tag-red-bd)"`.
- **Data Table / Header Row, Row, Ant Table rows**: 5 equal flex cells `Col 1..5` / `Cell 1..5`; for fewer columns hide the trailing ones with display:none; width via `style`. For rows with Tags/Avatars compose raw HTML in a `<table>` and reuse `Tag / ...` instances in cells. Wrap a table in a Card-like frame: radius-lg, 1px border-light, overflow hidden.
- **Inputs**: `Value` is the text (placeholder color by default; use `Input / Filled` for values). Width via instance style. `Form Field*` has `Label`, `Required Mark`, `Value`, `Help`.
- **Disabled button**: `style="opacity:.5"` on the instance. **Block button**: `style="width:100%"` (add `justify-content:center`: already centered).
- **Card with gradient** (dashboard Awaiting review): build raw: `background:linear-gradient(180deg,#fffdf5,#ffffff)`.
## Recipes for raw HTML (when no component fits)
- Card: `display:flex;flex-direction:column;padding:var(--spacing-lg);background:var(--color-bg-elevated);border:1px solid var(--color-border-light);border-radius:var(--radius-lg);box-shadow:var(--shadow-sm)`.
- Button with icon: row, gap 8px, height 34px (sm 28, lg 40), padding 0 16px, radius-md; icon 14-16px via `<x-paper-icon name="phosphor:plus" size="16" color="var(--color-text-inverse)"/>`.
- Page frame 1440x900: left `Admin Sidebar ...` (248x900) + main column 1192 wide: `Admin Shell Header` (about 102px tall) + content (padding 24, bg-layout, max-width 900/1100 centered).
- Modal overlay: full-screen frame `background:var(--color-bg-overlay)` with a `Modal` instance centered.
- Section titles inside cards: `Section Title` / `Section Title / With Action` instances.
## Variables (use as `var(--name)`)
Core tokens exist in the project (from tokens.css, same names minus the leading `--`): color-brand-primary, color-brand-secondary, color-bg-layout, color-bg-elevated, color-bg-sider, color-bg-selected, color-bg-overlay, color-border, color-border-light, color-border-strong, color-text-primary, color-text-secondary, color-text-disabled, color-text-inverse, color-text-link, color-success, color-warning, color-error, color-info, color-diff-added, color-diff-removed, color-diff-neutral, color-code-bg, color-code-fg; spacing-xs/sm/md/lg/xl/xxl (4/8/16/24/32/48), page-padding (24), page-padding-lg (48), page-max-width-xs/sm/md/lg/xl/narrow/default/wide (480/420/720/900/1100/720/900/1100), font-size-xs..xxl (12/14/16/18/20/24), radius-sm/md/lg (4/8/12), shadow-sm/md/lg, z-base/dropdown/modal/toast (1/1000/1050/1100).
Note: multi-value shorthands (`padding:var(--spacing-md) var(--page-padding)`) are NOT resolved by write_html: use single-value padding with var, or literal px for shorthands. Single `var(--spacing-*)`, `var(--radius-*)`, `var(--shadow-*)`, `var(--font-size-*)` work.
### Extra color variables added by the Design System page
- **Brand**: `color-button-primary-hover` #4096ff
- **Border**: `color-card-hover-border` #bfdbff
- **Text**: `color-text-placeholder` #00000040
- **Status**: `color-star` #faad14, `color-score-warn` #faad14, `color-score-poor` #ff4d4f
- **Sidebar (dark shell)**: `color-sidebar-bg` #11161d, `color-sidebar-border` #ffffff12, `color-sidebar-text` #ffffffa6, `color-sidebar-text-muted` #ffffff73, `color-sidebar-text-faint` #ffffff59, `color-sidebar-hover` #ffffff0f, `color-sidebar-badge` #ffffff40, `color-sidebar-tag-bg` #1677ff40
- **Tag tones**: `color-tag-blue-fg` #0958d9, `color-tag-blue-bg` #e6f4ff, `color-tag-blue-bd` #91caff, `color-tag-green-fg` #389e0d, `color-tag-green-bg` #f6ffed, `color-tag-green-bd` #b7eb8f, `color-tag-gold-fg` #ad6800, `color-tag-gold-bg` #fffbe6, `color-tag-gold-bd` #ffe58f, `color-tag-red-fg` #cf1322, `color-tag-red-bg` #fff1f0, `color-tag-red-bd` #ffccc7, `color-tag-purple-fg` #531dab, `color-tag-purple-bg` #f9f0ff, `color-tag-purple-bd` #d3adf7, `color-tag-cyan-fg` #08979c, `color-tag-cyan-bg` #e6fffb, `color-tag-cyan-bd` #87e8de
- **Avatar palette**: `color-avatar-purple` #722ed1, `color-avatar-teal` #13a8a8, `color-avatar-pink` #eb2f96, `color-avatar-orange` #fa541c, `color-avatar-indigo` #2f54eb
- **Controls**: `color-button-dark` #1f1f1f, `color-tooltip-bg` #000000d9, `color-switch-off` #00000040, `color-focus-ring` #1677ff1a
### Font stack and type scale
Inter only (regular/bold; weights 500 render regular, 600+ bold). Sizes: 12 labels/tags/table headers, 14 body, 16 section/card title, 24 page title (600), 30 stat value (700), 15/16 brand name.
## Known deviations from the source
- Pill in the code is the filter chip (active/inactive); there are no tone variants (`Pill / Success` does not exist). Tones are `Tag`.
- `lint_design`: remaining errors/warnings are faithful brand colors (white on #1677ff 4.1:1, success green on white, etc.), control heights under 44px (34/32px as in the source), and svg-internal names. Not changed on purpose.
- Text under 12px in the source (10.5/11) is rendered at 12; padding off the 4px grid was snapped (11->12, 18->16, 14->16, 9->0 with fixed 36px item height).
- Main components cannot be nested in the `Section / Components` frame (create_component/import_components need top-level frames), so the section shows instances; masters are in the library area.
+123
View File
@@ -0,0 +1,123 @@
"""Shared helpers for the Admin > Sessions scripts (admin_sessions.py and its adm_*.py modules)."""
import json, sys
sys.path.insert(0, "/Volumes/Storage/Projects/craft-skills/skills/papercraft/scripts")
from pc import * # noqa: F401,F403,E402
PROJECT = "Interview"
SP = {4: "xs", 8: "sm", 16: "md", 24: "lg", 32: "xl", 48: "xxl"}
FS = {12: "xs", 14: "sm", 16: "md", 18: "lg", 20: "xl", 24: "xxl"}
LH = {10: 14, 11: 16, 12: 16, 13: 18, 14: 22, 15: 20, 16: 24, 18: 26, 20: 28, 22: 28, 24: 32, 30: 36}
# IDE / code surfaces use literal hexes in the source; they get variables so nothing is hard-coded here.
IDE_VARS = {
"color-ide-toolbar": "#252526", "color-ide-border": "#1a1a1a", "color-ide-code-pane": "#161616",
"color-ide-preview": "#0d1117", "color-ide-muted": "#888888", "color-ide-faint": "#555555",
"color-ide-chip": "#2a2a2a", "color-ide-wb": "#13131f", "color-ide-wb-bar": "#1c1c2e",
"color-ide-wb-border": "#252540", "color-ide-card": "#161616", "color-ide-card-border": "#252525",
"color-ide-card-text": "#dddddd", "color-ide-card-label": "#484848", "color-ide-gutter": "#5a5a5a",
"color-ide-err-bg": "#1a0808", "color-ide-err-border": "#3a1010", "color-ide-del-bg": "#3a1a1a",
"color-wb-white": "#e0e0e0", "color-wb-blue": "#6ab0ff", "color-wb-amber": "#d29922", "color-wb-purple": "#c792ea",
"color-wb-orange": "#ff9f43", "color-ide-wb-tool": "#9999bb", "color-ide-wb-btn": "#252540", "color-ide-sel": "#2a2d2e",
"color-ide-console": "#181818", "color-tok-comment": "#6a9955", "color-tok-string": "#ce9178", "color-tok-keyword": "#569cd6",
"color-tok-type": "#4ec9b0", "color-tok-number": "#b5cea8", "color-tok-fn": "#dcdcaa",
}
STATUS = { # label: Tag Dot tone
"Draft": "Gray", "Open": "Blue", "In progress": "Gold", "Submitted": "Purple", "Graded": "Green", "Cancelled": "Red",
}
def sp(n):
return f"var(--spacing-{SP[n]})" if n in SP else f"{n}px"
def fs(n):
return f"var(--font-size-{FS[n]})" if n in FS else f"{n}px"
def rad(n):
return {4: "var(--radius-sm)", 8: "var(--radius-md)", 12: "var(--radius-lg)"}.get(n, f"{n}px")
def T(name, t, size=14, color="var(--color-text-primary)", weight=400, lh=None, **st):
return text(name, t, size=fs(size), color=color, weight=weight, line_height=f"{lh or LH.get(size, round(size * 1.4))}px", **st)
def ic(name, size=16, color="var(--color-text-secondary)", layer=None):
return f'<x-paper-icon name="phosphor:{name}" size="{size}" color="{color}" layer-name="{layer or "Icon / " + name}"/>'
def inst(component, name=None, style="", **ov):
o = json.dumps(ov, ensure_ascii=False).replace("'", "&#39;")
n = f' layer-name="{name}"' if name else ""
s = f' style="{style}"' if style else ""
return f"<x-paper-instance component=\"{component}\"{n}{s} overrides='{o}'/>"
def setup():
fid = next(i["id"] for i in call("list_resources")["items"] if i["name"] == PROJECT)
ps = pages(fid)
A = ps["Admin"]
call("set_variables", {"fileId": fid, "variables": IDE_VARS})
call("import_components", {"fileId": fid, "fromPageId": ps["Design System"].id, "pageId": A.id})
return fid, A
def reset(A, name):
ids = [c["id"] for c in A.c("get_children", nodeId="root")["children"] if c["name"] == name]
if ids:
A.c("delete_nodes", nodeIds=ids)
def section(A, name, title, per_row=3):
reset(A, name)
w = per_row * 1440 + (per_row - 1) * 32 + 64
sid = A.artboard(name, display="flex", flex_direction="row", flex_wrap="wrap", gap="32px", padding="32px",
width=f"{w}px", height="fit-content", background_color="transparent", align_content="flex-start")
A.add(sid, T("Section Title", title, 24, weight=600, width="100%"))
return sid
def add_screen(A, sid, name, route, body, **frame):
"""body = inner HTML of the 1440x900 screen frame."""
st = dict(display="flex", width="1440px", height="900px", background_color="var(--color-bg-layout)", overflow="hidden")
st.update(frame)
html = col(f"Screen Slot / {name}", T("Route", route, 12, "var(--color-text-secondary)") + box(f"Screen / {name}", body, **st),
gap="8px", width="1440px")
r = A.c("write_html", targetNodeId=sid, mode="insert-children", html=html)
for k in ("warnings", "ignoredStyles"):
if r.get(k):
print(f" [{name}] {k}:", sorted(set(map(str, r[k])))[:8])
return r["createdNodeIds"][0]
USER = dict(**{"User Name": "Elena Vasquez", "User Email": "elena@netcracker.com", "User Initials": "EV"})
def sidebar(active="Sessions"):
return inst(f"Admin Sidebar / {active} Active", "Sidebar", **USER)
def shell(title, subtitle, content, actions="", wide=True):
"""Admin shell screen body: sidebar + main (header, content). `actions` are absolute-positioned header buttons."""
main = col("Main", inst("Admin Shell Header", "Header", Title=title, Subtitle=subtitle) + actions + content,
flex_grow="1", height="900px", position="relative", overflow="hidden")
return sidebar() + main
def page_content(inner, wide=True):
w = 1100 if wide else 900
return row("Content Wrap", col("Content", inner, width=f"{w}px", padding="24px", gap="16px"), justify_content="center",
align_items="flex-start", flex_grow="1", width="1192px")
def header_action(label_component, label, width=108, right=72):
return f'<div layer-name="Header Actions" style="position:absolute;left:{1192-right-width}px;top:34px;display:flex;gap:12px">' + \
inst(label_component, label.replace("_", " "), Label=label) + "</div>"
def card(name, inner, **st):
base = dict(display="flex", flex_direction="column", background_color="var(--color-bg-elevated)",
border="1px solid var(--color-border-light)", border_radius="var(--radius-lg)", overflow="hidden")
base.update(st)
return box(name, inner, **base)
+141
View File
@@ -0,0 +1,141 @@
"""IDE-style read-only surfaces shared by Live Session and Review (CodeEditor, Mermaid preview, Whiteboard, Theory view)."""
import re
from adm_common import *
TOK = {"comment": "var(--color-tok-comment)", "string": "var(--color-tok-string)", "keyword": "var(--color-tok-keyword)",
"type": "var(--color-tok-type)", "number": "var(--color-tok-number)", "fn": "var(--color-tok-fn)", "ident": "var(--color-code-fg)"}
KW = re.compile(r"^(?:import|from|export|default|const|let|var|function|return|if|else|for|while|await|async|new|class|interface|type|of|in|try|catch|throw|null|undefined|true|false|void|this)\b")
RULES = [("comment", re.compile(r"^//.*")), ("string", re.compile(r"""^`[^`]*`?|^"[^"]*"?|^'[^']*'?""")), ("keyword", KW),
("type", re.compile(r"^[A-Z][A-Za-z0-9_]*")), ("number", re.compile(r"^\d+(?:\.\d+)?")),
("fn", re.compile(r"^[a-zA-Z_$][\w$]*(?=\s*\()")), ("ident", re.compile(r"^[a-zA-Z_$][\w$]*")), ("ws", re.compile(r"^\s+"))]
def esc(t):
return t.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace(" ", "&nbsp;")
def tokenize(line):
out, i = "", 0
while i < len(line):
s = line[i:]
for cls, rx in RULES:
m = rx.match(s)
if m:
t = m.group(0)
out += esc(t) if cls == "ws" else f'<span style="color:{TOK[cls]}">{esc(t)}</span>'
i += len(t)
break
else:
out += esc(line[i]); i += 1
return out
def code_editor(code, height=None, size=13, name="Code Editor"):
"""Read-only CodeEditor: gutter + highlighted lines on color-code-bg."""
lines = code.split("\n")
lh = round(size * 1.55)
gut = "".join(T(f"Ln {i+1}", str(i + 1), size, "var(--color-ide-gutter)", lh=lh, text_align="right") for i in range(len(lines)))
src = "".join(box(f"Line {i+1}", tokenize(l) or "&nbsp;", font_size=f"{size}px", line_height=f"{lh}px", color="var(--color-code-fg)") for i, l in enumerate(lines))
st = dict(background_color="var(--color-code-bg)", width="100%", overflow="hidden", padding="12px")
if height:
st["height"] = f"{height}px"
return row(name, col("Gutter", gut, width="32px", flex_shrink="0") + col("Source", src, flex_grow="1", padding="0 16px"), align_items="flex-start", **st)
TWO_SUM = """// Two Sum: one pass with a hash map, O(n)
export function twoSum(nums: number[], target: number): number[] {
const seen = new Map<number, number>();
for (let i = 0; i < nums.length; i++) {
const need = target - nums[i];
if (seen.has(need)) {
return [seen.get(need)!, i];
}
seen.set(nums[i], i);
}
return [];
}"""
MERMAID = """graph TD
Client -->|HTTPS| LB[Load Balancer]
LB --> API1[API Server 1]
LB --> API2[API Server 2]
API1 <--> Cache[(Redis Cache)]
API2 <--> Cache
API1 --> DB[(PostgreSQL Primary)]
API2 --> DB
DB -.->|replication| Replica[(Read Replica)]
API1 --> Queue([Message Queue])
Queue --> Worker[Background Worker]"""
def node(label, accent=False):
return row(f"Node / {label}", T("Label", label, 12, "var(--color-ide-card-text)"), padding="8px 16px", justify_content="center",
background_color="var(--color-ide-toolbar)", border="1px solid var(--color-ide-muted)", border_radius="var(--radius-sm)")
def arrow(label=""):
return row("Edge", ic("arrow-down", 14, "var(--color-ide-muted)") + (T("Edge Label", label, 12, "var(--color-ide-muted)") if label else ""), gap="4px")
def diagram_editor(height, readonly_note="Read-only — candidate's diagram", active=0, name="Diagram Editor"):
tpls = ["System design", "Sequence", "Class diagram", "State machine"]
chips = "".join(row(f"Template / {t}", T("Label", t, 12, "#fff" if i == active else "var(--color-ide-muted)"), padding="4px 12px",
border_radius="5px", background_color="var(--color-brand-primary)" if i == active else "var(--color-ide-chip)")
for i, t in enumerate(tpls))
bar = row("Template Bar", T("Label", "Template:", 12, "var(--color-ide-faint)") + chips + box("Spacer", "", flex_grow="1") +
T("Note", readonly_note, 12, "var(--color-ide-faint)"), gap="8px", padding="8px 16px", background_color="var(--color-ide-toolbar)",
border_bottom="1px solid var(--color-ide-border)", width="100%")
src = "".join(T(f"Src {i+1}", l.replace(" ", "&nbsp;") or "&nbsp;", 13, "var(--color-code-fg)", lh=23) for i, l in enumerate(MERMAID.split("\n")))
left = col("Mermaid Source", src + T("Pane Label", "MERMAID", 12, "var(--color-ide-faint)", position="absolute", right="12px", top="8px"),
width="50%", padding="16px", background_color="var(--color-ide-code-pane)", border_right="1px solid var(--color-ide-border)", position="relative")
api = row("API Row", node("API Server 1") + node("API Server 2"), gap="32px")
store = row("Store Row", node("Redis Cache") + node("PostgreSQL Primary") + node("Message Queue"), gap="24px")
graph = col("Rendered Graph", node("Client") + arrow("HTTPS") + node("Load Balancer") + arrow() + api + arrow() + store + arrow("replication") +
row("Leaf Row", node("Read Replica") + node("Background Worker"), gap="32px"), align_items="center", gap="4px")
right = row("Rendered Preview", graph, width="50%", justify_content="center", align_items="flex-start", padding="28px 20px",
background_color="var(--color-ide-preview)")
return col(name, bar + row("Panes", left + right, flex_grow="1", width="100%", align_items="stretch", height=f"{height - 41}px"),
height=f"{height}px", width="100%", background_color="var(--color-code-bg)")
def shape_box(name, x, y, w, h, color, label="", radius=4):
return box(f"Shape / {name}", T("Text", label, 14, color, text_align="center", width=f"{w}px") if label else "",
position="absolute", left=f"{x}px", top=f"{y}px", width=f"{w}px", height=f"{h}px", border=f"2px solid {color}",
border_radius=f"{radius}px", display="flex", align_items="center", justify_content="center")
def whiteboard(height, empty=False, name="Whiteboard"):
shapes = ""
if empty:
shapes = col("Empty Hint", T("Glyph", "◻", 32, "var(--color-ide-faint)", opacity=".3") + T("Hint", "Candidate hasn't drawn anything yet", 12, "var(--color-ide-faint)"),
position="absolute", left="0px", top="0px", width="1192px", height=f"{height}px", align_items="center", justify_content="center", gap="8px")
else:
W, B, G, R, O = ("var(--color-wb-white)", "var(--color-wb-blue)", "var(--color-diff-added)", "var(--color-diff-removed)", "var(--color-wb-orange)")
shapes = (shape_box("Client", 80, 120, 140, 64, W, "Client") + shape_box("API", 380, 120, 160, 64, B, "API Gateway") +
shape_box("Auth", 380, 300, 160, 64, G, "Auth Service") + shape_box("DB", 720, 120, 160, 64, O, "Postgres", 32) +
shape_box("Cache", 720, 300, 160, 64, R, "Redis", 32) +
ic("arrow-right", 40, W, "Arrow / Client to API").replace("<x-paper-icon", '<x-paper-icon style="position:absolute;left:250px;top:132px"') +
ic("arrow-right", 40, B, "Arrow / API to DB").replace("<x-paper-icon", '<x-paper-icon style="position:absolute;left:570px;top:132px"') +
ic("arrow-down", 40, B, "Arrow / API to Auth").replace("<x-paper-icon", '<x-paper-icon style="position:absolute;left:440px;top:210px"') +
ic("arrow-right", 40, G, "Arrow / Auth to Cache").replace("<x-paper-icon", '<x-paper-icon style="position:absolute;left:570px;top:312px"') +
T("Note", "cache sessions, TTL 15m", 15, "var(--color-wb-purple)", position="absolute", left="700px", top="390px") +
row("Read-only Badge", T("Label", "Read-only — candidate's whiteboard", 12, "var(--color-ide-faint)"), position="absolute", right="14px", bottom="10px",
padding="4px 12px", background_color="var(--color-ide-wb-bar)", border="1px solid var(--color-ide-wb-border)", border_radius="5px"))
return box(name, shapes, position="relative", width="100%", height=f"{height}px", background_color="var(--color-ide-wb)", overflow="hidden")
def theory_view(height, answers=None, name="Theory Answers"):
if not answers:
return row(name, T("Waiting", "Waiting for candidate answers…", 13, "var(--color-ide-faint)"), width="100%", height=f"{height}px",
justify_content="center", align_items="center", background_color="var(--color-code-bg)")
cards = "".join(col(f"Answer / {q}", T("Question Id", q.upper(), 12, "var(--color-ide-card-label)", letter_spacing="0.6px") +
T("Answer", a, 14, "var(--color-ide-card-text)", lh=24), gap="8px", padding="16px",
background_color="var(--color-ide-card)", border="1px solid var(--color-ide-card-border)", border_radius="var(--radius-md)")
for q, a in answers)
return row(name, col("Answers", cards, gap="16px", width="760px", padding="24px"), width="100%", height=f"{height}px", justify_content="center",
align_items="flex-start", background_color="var(--color-code-bg)", overflow="hidden")
THEORY = [("q-event-loop", "The event loop pulls tasks from the queue only when the call stack is empty. Microtasks (promise callbacks) run before the next macrotask, so a resolved promise always logs before a setTimeout(0)."),
("q-cap", "In a partition we must choose between consistency and availability. For the booking flow I would pick CP: reject writes on the minority side rather than double-book a slot."),
("q-indexes", "A composite index on (tenant_id, created_at) serves the dashboard query. The leading column filters, the second column gives ordered scans without a sort.")]
+38
View File
@@ -0,0 +1,38 @@
"""Section / Admin / Live Session: interviewer live view (tabs: Diagrams, Whiteboard, Code, Theory; Live / Waiting)."""
from adm_ide import *
TABS = [("Diagrams", "tree-structure"), ("Whiteboard", "selection"), ("Code", "brackets-curly"), ("Theory", "question")]
def toolbar(active, live):
tabs = ""
for t, i in TABS:
on = t == active
tabs += row(f"Tab / {t}", ic(i, 14, "#fff" if on else "var(--color-ide-muted)") + T("Label", t, 13, "#fff" if on else "var(--color-ide-muted)", weight=600 if on else 400),
gap="8px", padding="0 16px", height="52px", border_bottom=f"2px solid {'var(--color-brand-primary)' if on else 'transparent'}")
tag = inst("Ant Tag / Success", "Live", Label="● Live") if live else inst("Ant Tag / Default", "Waiting", Label="Waiting")
return row("Live Toolbar", T("Title", "Live session", 13, "#fff", weight=600) + col("Tabs Gap", "", width="8px") + tabs + box("Spacer", "", flex_grow="1") + tag +
inst("Ant Button / Primary Small", "End & score", Label="End & score →") + col("Pad", "", width="4px"), gap="16px", padding="0 12px 0 16px",
height="52px", width="100%", background_color="var(--color-ide-toolbar)", border_bottom="1px solid var(--color-ide-border)", flex_shrink="0")
def live_shell(active, live, content):
return sidebar() + col("Main", toolbar(active, live) + content, flex_grow="1", height="900px", background_color="var(--color-code-bg)", overflow="hidden")
def build(A):
sid = section(A, "Section / Admin / Live Session", "Admin / Live Session")
H = 848
r = "/sessions/[id]/live"
add_screen(A, sid, "Live Diagrams", r, live_shell("Diagrams", True, diagram_editor(H)))
add_screen(A, sid, "Live Whiteboard", r + " (whiteboard tab)", live_shell("Whiteboard", True, whiteboard(H)))
add_screen(A, sid, "Live Whiteboard Waiting", r + " (waiting, nothing drawn)", live_shell("Whiteboard", False, whiteboard(H, empty=True)))
add_screen(A, sid, "Live Code", r + " (code tab)", live_shell("Code", True, col("Code Pane", code_editor(TWO_SUM, H), height=f"{H}px", width="100%")))
add_screen(A, sid, "Live Theory", r + " (theory tab)", live_shell("Theory", True, theory_view(H, THEORY)))
add_screen(A, sid, "Live Theory Waiting", r + " (waiting for answers)", live_shell("Theory", False, theory_view(H)))
return sid
if __name__ == "__main__":
fid, A = setup()
print(build(A))
+144
View File
@@ -0,0 +1,144 @@
"""Section / Admin / Sessions: list (3 states), new session (2 states), session detail (3 states)."""
from adm_common import *
ROWS = [
("Senior backend screen — Marcos R.", "K7M2-9QXA", "Graded"),
("Frontend pairing — Aisha Khan", "4HTR-B2LN", "In progress"),
("Platform engineer loop — Diego Alvarez", "P9WD-33EC", "Submitted"),
("Data engineer screen — Lena Fischer", "M3QZ-81VY", "Open"),
("Staff SRE system design — Tom Becker", "X6JA-0RKD", "Draft"),
("Mobile lead interview — Priya Nair", "C2FS-77HM", "Cancelled"),
("Junior full-stack screen — Omar Haddad", "T8LB-45GE", "Graded"),
]
FILTERS = ["All", "Open", "In progress", "Submitted", "Graded", "Draft"]
TH = dict(padding="12px", font_size=fs(12), font_weight="600", color="var(--color-text-secondary)", background_color="var(--color-bg-sider)",
border_bottom="1px solid var(--color-border-light)")
def code_chip(c):
return box("Code", c, font_size=fs(12), line_height="16px", color="var(--color-text-secondary)", background_color="var(--color-bg-sider)",
padding="2px 8px", border_radius="var(--radius-sm)", border="1px solid var(--color-border-light)")
def _cell(tag, inner, w=None, al="left", **extra):
st = dict(padding="12px", text_align=al, border_bottom="1px solid var(--color-border-light)")
if w:
st["width"] = w
st.update(extra)
return f'<{tag} style="{css(**st)}">{inner}</{tag}>'
def table(rows, hover=None):
hd = dict(font_size=fs(12), font_weight="600", color="var(--color-text-secondary)", background_color="var(--color-bg-sider)")
head = ('<tr layer-name="Row / Header">' + _cell("th", "SESSION", **hd) + _cell("th", "CODE", "140px", **hd) +
_cell("th", "STATUS", "150px", **hd) + _cell("th", "&nbsp;", "90px", "right", **hd) + "</tr>")
body = ""
for i, (title, code, st) in enumerate(rows):
bg = dict(background_color="var(--color-bg-selected)") if hover == i else {}
bd = dict(font_size=fs(14), color="var(--color-text-primary)", **bg)
body += (f'<tr layer-name="Row / {title.split(" — ")[0]}">' +
_cell("td", f'<span style="font-weight:600">{title}</span>', **bd) + _cell("td", code_chip(code), "140px", **bd) +
_cell("td", inst(f"Tag Dot / {STATUS[st]}", "Status", Label=st), "150px", **bd) +
_cell("td", '<span style="color:var(--color-brand-primary);font-weight:500">Open →</span>', "90px", "right", **bd) + "</tr>")
return card("Data Table", f'<table layer-name="Table" style="width:100%">{head}{body}</table>')
def toolbar(active="All"):
pills = "".join(inst("Pill / Active" if f == active else "Pill / Inactive", f"Pill / {f}", Label=f) for f in FILTERS)
return row("Toolbar", row("Filters", pills, gap="8px") + inst("Search Input", "Search", style="width:240px", Placeholder="Search by title or code…"),
justify_content="space-between", width="100%")
def new_btn():
return header_action("Button / Primary", "New session")
def list_screens(A, sid):
add_screen(A, sid, "Sessions List", "/sessions",
shell("Sessions", "All interview sessions across the team", page_content(toolbar() + table(ROWS)), new_btn()))
add_screen(A, sid, "Sessions List Filtered", "/sessions (Graded filter, search active)",
shell("Sessions", "All interview sessions across the team",
page_content(toolbar("Graded") + table([r for r in ROWS if r[2] == "Graded"], hover=0)), new_btn()))
add_screen(A, sid, "Sessions List Loading", "/sessions (loading)",
shell("Sessions", "All interview sessions across the team",
page_content(toolbar() + row("Loading", inst("Spin / Large", "Spinner"), justify_content="center", padding="48px", width="100%")), new_btn()))
add_screen(A, sid, "Sessions List Empty", "/sessions (no sessions)",
shell("Sessions", "All interview sessions across the team",
page_content(toolbar() + col("Empty", inst("Empty State", "Empty State", Title="No sessions yet",
Description="Create a session to invite a candidate and start interviewing.") +
inst("Button / Primary", "New session", Label="New session"), align_items="center", gap="16px", padding="32px", width="100%")), new_btn()))
def label_row(label, required=False):
return row("Label", (T("Required Mark", "*", 14, "var(--color-error)") if required else "") + T("Label", label, 14), gap="4px")
def field(label, control, required=False, extra=None):
return col(f"Form Item / {label}", label_row(label, required) + control + (T("Extra", extra, 14, "var(--color-text-secondary)") if extra else ""), gap="8px", width="100%")
def new_screens(A, sid):
title_in = inst("Input / Large", "Title Input", style="width:100%", Value="e.g. Senior backend screen — Marcos R.")
date_in = inst("Date Picker", "Expires Picker", style="width:100%", Value="Select date")
def form(title_ctl, err=None):
err_html = box("Alert / Error", row("Alert Row", ic("x-circle", 16, "var(--color-error)") + T("Message", err, 14), gap="8px"),
padding="8px 16px", background_color="var(--color-tag-red-bg)", border="1px solid var(--color-tag-red-bd)",
border_radius="var(--radius-md)") if err else ""
inner = (T("Card Title", "New session", 24, weight=600, lh=32) + err_html +
field("Title", title_ctl, True) +
field("Invite expires", date_in, False, "Leave blank for the default (7 days from now).") +
inst("Ant Button / Primary", "Create", Label="Create"))
return page_content(card("Card", col("Card Body", inner, gap="16px", padding="24px"), box_shadow="var(--shadow-sm)"), wide=False)
add_screen(A, sid, "New Session", "/sessions/new", shell("New session", "Create an invite for a candidate", form(title_in)))
add_screen(A, sid, "New Session Error", "/sessions/new (create failed)",
shell("New session", "Create an invite for a candidate",
form(inst("Input / Large Filled", "Title Input", style="width:100%", Value="Senior backend screen — Marcos R."), "Create failed")))
def detail_card(session, status, picked):
chips = "".join(row(f"Selected / {c}", T("Label", c, 14) + ic("x", 12, "var(--color-text-secondary)"), gap="8px",
padding="0 8px", height="24px", background_color="var(--color-bg-sider)", border="1px solid var(--color-border-light)",
border_radius="var(--radius-sm)") for c in picked)
sel = row("Select (multiple)", row("Selected Items", chips, gap="4px", flex_wrap="wrap", flex_grow="1") + ic("caret-down", 12, "var(--color-text-secondary)"),
padding="4px 8px", min_height="32px", border="1px solid var(--color-border)", border_radius="var(--radius-md)",
background_color="var(--color-bg-elevated)", width="100%", align_items="center")
head = row("Card Head", row("Title Space", T("Title", session["title"], 24, weight=600, lh=32) + inst("Ant Tag / Default", "Status", Label=status) +
inst("Ant Button / Primary", "Open review", Label="Open review") +
inst("Ant Button / Default", "Open live", Label="Open live"), gap="8px"),
padding="16px 24px", border_bottom="1px solid var(--color-border-light)", width="100%")
meta = col("Meta", row("Invite Code", T("Label", "Invite code:", 14, "var(--color-text-secondary)") + T("Code", session["code"], 16, lh=24), gap="4px") +
row("Expires", T("Label", "Expires:", 14, "var(--color-text-secondary)") + T("Value", session["expires"], 14), gap="4px"), gap="8px")
body = col("Card Body", meta + col("Challenges", T("Heading", "Challenges", 14, weight=600, lh=22) + sel +
inst("Ant Button / Primary", "Save challenges", Label="Save challenges"), gap="12px", width="100%"),
gap="16px", padding="24px", width="100%")
return card("Card", head + body, box_shadow="var(--shadow-sm)", width="100%")
def detail_screens(A, sid):
s = dict(title="Senior backend screen — Marcos R.", code="K7M2-9QXA", expires="2026-10-19T09:00:00Z")
add_screen(A, sid, "Session Detail", "/sessions/[id]",
shell("Senior backend screen — Marcos R.", "Session · Open", page_content(detail_card(s, "Open", ["Two Sum (CODE)", "System design: URL shortener (DIAGRAM)", "JavaScript Closures (MCQ)"]))))
add_screen(A, sid, "Session Detail In Progress", "/sessions/[id] (candidate working)",
shell("Frontend pairing — Aisha Khan", "Session · In progress",
page_content(detail_card(dict(title="Frontend pairing — Aisha Khan", code="4HTR-B2LN", expires="2026-10-17T16:30:00Z"), "In progress", ["React state bug (CODE)", "Event loop quiz (MCQ)"]))))
add_screen(A, sid, "Session Detail Loading", "/sessions/[id] (loading)",
shell("Session", "Loading", page_content(inst("Spin", "Spinner"))))
err = row("Alert Error", ic("x-circle", 16, "var(--color-error)") + T("Message", "Load failed", 14) +
box("Spacer", "", flex_grow="1") + inst("Ant Button / Default Small", "Retry", Label="Retry"), gap="8px", padding="8px 16px",
background_color="var(--color-tag-red-bg)", border="1px solid var(--color-tag-red-bd)", border_radius="var(--radius-md)", width="100%")
add_screen(A, sid, "Session Detail Error", "/sessions/[id] (error)", shell("Session", "Could not load session", page_content(err)))
def build(A):
sid = section(A, "Section / Admin / Sessions", "Admin / Sessions")
list_screens(A, sid)
new_screens(A, sid)
detail_screens(A, sid)
return sid
if __name__ == "__main__":
fid, A = setup()
print(build(A))
+20
View File
@@ -0,0 +1,20 @@
"""Screenshot Admin screens: python3 adm_shots.py <section name> [screen name substr ...]"""
import sys; sys.path.insert(0, "/Volumes/Storage/Projects/papercraft/examples/interview")
from adm_common import *
OUT = "/private/tmp/claude-501/-Volumes-Storage-Projects-photocraft/11f4040a-b4f4-4b9e-965a-bde08b400450/scratchpad/"
fid, A = setup()
sec = next(c for c in A.c("get_children", nodeId="root")["children"] if c["name"] == sys.argv[1])
for slot in A.c("get_children", nodeId=sec["id"])["children"]:
if not slot["name"].startswith("Screen Slot"): continue
n = slot["name"][len("Screen Slot / "):]
if len(sys.argv) > 2 and not any(a.lower() in n.lower() for a in sys.argv[2:]): continue
scr = next(c for c in A.c("get_children", nodeId=slot["id"])["children"] if c["name"].startswith("Screen /"))
p = OUT + "adm_" + n.replace(" ", "_") + ".png"
tmp = A.artboard("TMP shot", "", width="1440px", height="900px", left="-3000px", top="0px")
A.c("write_html", targetNodeId=tmp, mode="insert-children", html=f'<x-paper-clone node-id="{scr["id"]}"/>')
A.screenshot(p, tmp, 1); print(p, scr["width"], scr["height"])
A.c("delete_nodes", nodeIds=[tmp])
r = A.lint(scr["id"])
iss = r.get("issues") or r.get("findings") or r
errs = [i for i in (iss if isinstance(iss, list) else []) if i.get("severity") in ("error", "warning")]
print(" lint", len(errs), {(i.get("rule") or i.get("code")) for i in errs})
+171
View File
@@ -0,0 +1,171 @@
"""Helpers shared by admin_challenges.py and admin_bank.py (Admin page, Interview project)."""
import json
import sys
sys.path.insert(0, "/Volumes/Storage/Projects/papercraft/examples/interview")
from ds_common import * # noqa: F401,F403,E402
DS_ID = None
def fg(c):
return f"var(--color-{c})"
def I(component, name=None, style=None, **ov):
"""Instance html with overrides and optional root style (dict or str)."""
o = json.dumps(ov, ensure_ascii=False).replace("'", "&#39;")
n = f' layer-name="{name}"' if name else ""
st = ""
if style:
st = " style=\"" + (style if isinstance(style, str) else ";".join(f"{k.replace('_','-')}:{v}" for k, v in style.items())) + "\""
return f"<x-paper-instance component=\"{component}\"{n}{st} overrides='{o}'/>"
def init():
fid = next(i["id"] for i in call("list_resources")["items"] if i["name"] == PROJECT)
ps = pages(fid)
call("import_components", {"fromPageId": ps["Design System"].id, "pageId": ps["Admin"].id})
return fid, ps["Admin"]
def delete_section(P, name):
ids = [c["id"] for c in P.c("get_children", nodeId="root")["children"] if c["name"] == name]
if ids:
P.c("delete_nodes", nodeIds=ids)
def write(P, parent, html):
r = P.c("write_html", targetNodeId=parent, mode="insert-children", html=html)
if r.get("ignoredStyles"):
print(" ignored:", sorted(set(r["ignoredStyles"]))[:8])
for w in r.get("warnings", []):
print(" warn:", w)
return r["createdNodeIds"]
def ensure_components(P):
"""Local main components (name `Group / Variant`): created on first run, kept afterwards."""
have = {c["name"] for c in P.c("get_components")["components"]}
specs = []
toast = lambda nm, ic_, col_, bg, bd: row(nm, ic(ic_, 16, col_, "Icon") + T("Text", "Saved", 14, fg("text-primary")), gap="8px",
padding="10px 16px", background=fg("bg-elevated"), border_radius="var(--radius-md)",
box_shadow="var(--shadow-md)")
specs.append(("Message / Success", toast("Message / Success", "check-circle", fg("success"), "", "")))
specs.append(("Message / Error", toast("Message / Error", "x-circle", fg("error"), "", "")))
hdr = row("Admin Shell Header / With Action",
col("Titles", T("Title", "Challenges", 24, fg("text-primary"), 600, lh=32) + T("Subtitle", "Subtitle", 14, fg("text-secondary"))) +
row("Actions", I("Button / Notification Bell") + I("Button / Primary", Label="New challenge"), gap="12px"),
justify_content="space-between", width="1192px", padding="var(--page-padding)", background=fg("bg-elevated"),
border="1px solid var(--color-border-light)")
specs.append(("Admin Shell Header / With Action", hdr))
for nm, h in specs:
if nm in have:
continue
ids = write(P, "root", h)
P.c("create_component", nodeId=ids[0], name=nm)
P.c("update_styles", updates=[{"nodeIds": [ids[0]], "styles": {"left": 70000 + 400 * len(have), "top": 0}}])
have.add(nm)
# --------------------------------------------------------------------------- antd-like pieces
def label(txt, required=False):
return row("Label", (T("Required Mark", "*", 14, fg("error")) if required else "") + T("Label Text", txt, 14, fg("text-primary")), gap="4px")
def field(name, lab, ctrl, required=False, help_=None, err=None, width=None):
kids = label(lab, required) + ctrl
if err:
kids += T("Error Message", err, 14, fg("error"))
elif help_:
kids += T("Help", help_, 12, fg("text-secondary"), lh=16)
return col(name, kids, gap="8px", **({"width": width} if width else {}))
def acard(name, title, body, extra_head=""):
"""antd Card size=small with a title bar."""
return col(name, row("Card Header", T("Card Title", title, 14, fg("text-primary"), 600), padding="0px 12px", height="38px", background=fg("bg-elevated")) +
box("Divider", height="1px", background=fg("border-light")) +
col("Card Body", body, padding="12px", gap="16px"),
background=fg("bg-elevated"), border="1px solid var(--color-border-light)", border_radius="var(--radius-lg)", overflow="hidden")
def pcard(name, kids, **st):
"""packages/ui Card / antd Card default (24 padding)."""
return col(name, kids, padding="var(--spacing-lg)", background=fg("bg-elevated"), border="1px solid var(--color-border-light)",
border_radius="var(--radius-lg)", **st)
def dashed_btn(label_, name="Button / Dashed", block=False, small=False):
h = 24 if small else 32
return row(name, ic("plus", 12, fg("text-primary"), "Plus") + T("Label", label_.lstrip("+ "), 14, fg("text-primary")), gap="4px", justify_content="center",
height=f"{h}px", padding="0px 12px" if small else "0px 16px", border="1px dashed var(--color-border)", border_radius="var(--radius-md)",
background=fg("bg-elevated"), **({} if block else {"width": "fit-content"}))
def x_btn(name="Remove"):
return row(name, ic("x", 14, fg("text-secondary"), "Close"), justify_content="center", width="32px", height="32px", border_radius="var(--radius-md)")
def circle_letter(txt, on=False, size=24):
return row("Letter", T("Letter Text", txt, 12, fg("text-inverse") if on else fg("text-secondary"), 600, lh=16), justify_content="center",
width=f"{size}px", height=f"{size}px", border_radius="99px", background=fg("success") if on else "#f0f0f0")
def md_editor(text_lines, height=320):
tb = row("Toolbar", "".join(ic(n, 14, fg("text-disabled"), n) for n in ["text-b", "text-italic", "text-strikethrough", "text-h", "link", "quotes", "code", "image", "list-bullets", "list-numbers", "question"]),
gap="16px", padding="0px 12px", height="32px", background=fg("code-bg"), border_bottom="1px solid #ffffff1f")
body = col("Source", "".join(T("Line", l or "&nbsp;", 12, fg("code-fg"), 400, lh=16) for l in text_lines), gap="0px", padding="12px", flex_grow="1")
return col("Markdown Editor", tb + body, height=f"{height}px", background=fg("code-bg"), border_radius="var(--radius-md)", overflow="hidden")
def code_block(path, lines, height=None):
return col("Code Preview", T("File Path", path, 12, fg("text-secondary"), lh=16, padding="6px 12px", background=fg("bg-sider"), border_bottom="1px solid var(--color-border-light)") +
col("Code", "".join(T("Line", l or "&nbsp;", 12, fg("code-fg"), 400, lh=18) for l in lines), padding="12px", background=fg("code-bg"), gap="0px",
**({"height": f"{height}px"} if height else {})),
border="1px solid var(--color-border-light)", border_radius="var(--radius-md)", overflow="hidden")
TYPE_TAG = {"Algo": "Blue", "Fullstack": "Purple", "MCQ": "Cyan", "SQL": "Gold", "Theory": "Green"}
def type_tag(t):
return I(f"Tag / {TYPE_TAG[t]}", Label=t)
# --------------------------------------------------------------------------- shell
def screen(name, route, active, title, subtitle, content, actions=False, action_label="New challenge", height=900, overlay=None, toast=None, content_gap=0):
"""Screen frame 1440 wide: dark sidebar + header + content. Returns (caption + screen) html."""
sb = col("Sidebar", I(f"Admin Sidebar / {active} Active", style="flex-shrink:0"), width="248px", background=fg("sidebar-bg"))
if actions:
hd = I("Admin Shell Header / With Action", Title=title, Subtitle=subtitle, **{"Label": action_label})
else:
hd = I("Admin Shell Header", Title=title, Subtitle=subtitle)
main = col("Main", hd + row("Content Area", col("Content", content, width="1100px", padding="var(--page-padding)", gap=f"{content_gap}px"),
justify_content="center", align_items="flex-start"), width="1192px")
extra = overlay or ""
if toast:
extra += row("Toast Layer", toast, justify_content="center", position="absolute", left="248px", top="16px", width="1192px")
return row(f"Screen / {name}", sb + main + extra, width="1440px", background=fg("bg-layout"), position="relative", overflow="hidden" if height == 900 else "visible", height=("900px" if height == 900 else "fit-content"))
def cap(route, extra=""):
return T("Route Caption", route + (f" {extra}" if extra else ""), 12, fg("text-secondary"), lh=16)
def shell(name, route, *a, caption_extra="", **kw):
return col(f"Frame / {name}", cap(route, caption_extra) + screen(name, route, *a, **kw), gap="8px")
def section(name, title, items, x, y, cols=1):
"""items: html strings (each a `Frame / ...`). Arranged in rows of `cols`."""
rows_ = ""
for i in range(0, len(items), cols):
rows_ += row(f"Row {i // cols + 1}", "".join(items[i:i + cols]), gap="var(--spacing-xl)", align_items="flex-start")
return col(name, T("Section Title", title, 24, fg("text-primary"), 700, lh=32) + col("Screens", rows_, gap="var(--spacing-xl)"),
padding="var(--spacing-xl)", gap="var(--spacing-xl)", background="transparent", position="absolute", left=f"{x}px", top=f"{y}px")
def overlay_html(inner, w=1440, h=900, name="Overlay"):
return row(name, inner, justify_content="center", align_items="center", position="absolute", left="0px", top="0px", right="0px", bottom="0px",
background=fg("bg-overlay"))
+16
View File
@@ -0,0 +1,16 @@
"""python3 admin_ch_shot.py <Section name> [screen-substring] -> PNGs in scratchpad."""
import sys
sys.path.insert(0, "/Volumes/Storage/Projects/papercraft/examples/interview")
from admin_ch_common import *
OUT = "/private/tmp/claude-501/-Volumes-Storage-Projects-photocraft/11f4040a-b4f4-4b9e-965a-bde08b400450/scratchpad/"
fid, P = init()
sec = next(c for c in P.c("get_children", nodeId="root")["children"] if c["name"] == sys.argv[1])
def walk(n, d=0):
for c in P.c("get_children", nodeId=n)["children"]:
yield c
if d < 3 and c["name"].startswith(("Screens","Row ","Frame /")): yield from walk(c["id"], d+1)
want = sys.argv[2] if len(sys.argv) > 2 else ""
for c in walk(sec["id"]):
if c["name"].startswith("Screen / ") and want in c["name"]:
print(c["name"], c["width"], c["height"])
P.screenshot(OUT + c["name"].replace(" / ", "_").replace(" ", "_") + ".png", c["id"], 1)
+362
View File
@@ -0,0 +1,362 @@
"""Admin / Challenges screens (idempotent): list states, new/edit challenge editors, templates modal.
python3 admin_challenges.py [only-screen-substring]
"""
import sys
sys.path.insert(0, "/Volumes/Storage/Projects/papercraft/examples/interview")
from admin_ch_common import * # noqa: F401,F403
SECTION = "Section / Admin / Challenges"
X0, Y0 = 0, 12000
ROWS = [
("Two Sum", "Algo", False), ("Valid Parentheses", "Algo", False), ("REST Todo API", "Fullstack", False),
("JavaScript Closures", "MCQ", False), ("Second Highest Salary", "SQL", False),
("System Design: URL Shortener", "Theory", False), ("Behavioral: Conflict", "Theory", True), ("Async / Await", "MCQ", True),
]
# ------------------------------------------------------------------ list page
def filter_bar(active="All types", query=None, archived=False):
types = ["All types", "Algo", "Fullstack", "MCQ", "SQL", "Theory"]
pills = "".join(I("Pill / Active" if t == active else "Pill / Inactive", Label=t) for t in types)
search = I("Search Input / Filled" if query else "Search Input", Placeholder=query or "Search challenges…", style="width:240px")
seg = I("Segmented / 2 Options Active 2" if archived else "Segmented / 2 Options Active 1", **{"Option 1": "Active", "Option 2": "All"})
return row("Filter Bar", row("Type Pills", pills, gap="8px") + row("Search And Scope", search + seg, gap="8px"),
justify_content="space-between", gap="var(--spacing-md)")
def table(rows_):
def th(t, w=None, right=False):
return T(f"Col / {t}", t.upper(), 12, fg("text-secondary"), 600, lh=16, letter_spacing="0.36px",
**({"width": f"{w}px"} if w else {"flex_grow": "1"}), **({"text_align": "right"} if right else {}))
head = row("Header Row", th("Title") + th("Type", 140) + th("Status", 130) + th("Actions", 170, True), gap="16px", padding="12px 16px",
background=fg("bg-sider"), border_bottom="1px solid var(--color-border-light)")
body = ""
for title, t, arch in rows_:
status = I("Tag Dot / Gray", Label="Archived") if arch else I("Tag Dot / Green", Label="Active")
acts = I("Button / Default Small", Label="Edit") + ("" if arch else I("Button / Danger Small", Label="Archive"))
body += row(f"Row / {title}", T("Cell / Title", title, 14, fg("text-primary"), 600, flex_grow="1") +
row("Cell / Type", type_tag(t), width="140px") + row("Cell / Status", status, width="130px") +
row("Cell / Actions", acts, width="170px", gap="8px", justify_content="flex-end"),
gap="16px", padding="12px 16px", border_bottom="1px solid var(--color-border-light)", background=fg("bg-elevated"))
return col("Data Table", head + body, background=fg("bg-elevated"), border="1px solid var(--color-border-light)", border_radius="var(--radius-lg)", overflow="hidden")
def list_screen(name, route, content, **kw):
return shell(name, route, "Challenges", "Challenges", "Reusable interview prompts across roles", content, actions=True, **kw)
def empty_block(title="No challenges match", desc="Adjust the filters or create a new challenge to get started."):
return I("Empty State", Title=title, Description=desc, style="width:1052px") if False else col(
"Empty State Block", I("Empty State", Title=title, Description=desc) + I("Button / Primary", Label="New challenge"), align_items="center", gap="8px",
padding="var(--spacing-xl)", width="1052px")
def loading_block():
return row("Loading", I("Spin / Large"), justify_content="center", padding="var(--spacing-xxl)", width="1052px")
def list_screens():
out = []
bar = lambda **k: filter_bar(**k)
out.append(list_screen("Challenges List", "/challenges", bar() + table(ROWS[:7][:6] + [ROWS[7]][:0]), caption_extra="populated, active only") if False else
list_screen("Challenges List", "/challenges", bar() + table(ROWS[:6]), caption_extra="populated (Active)"))
out.append(list_screen("Challenges List Filtered", "/challenges", bar(active="MCQ", query="clos") + table([ROWS[3]]), caption_extra="type pill = MCQ, search 'clos'"))
out.append(list_screen("Challenges List With Archived", "/challenges", bar(archived=True) + table(ROWS), caption_extra="scope = All, archived rows have no Archive button"))
out.append(list_screen("Challenges List Empty", "/challenges", bar(active="SQL", query="zzz") + empty_block(), caption_extra="no match"))
out.append(list_screen("Challenges List Loading", "/challenges", bar() + loading_block(), caption_extra="loading"))
return out
# ------------------------------------------------------------------ editor pieces
MD_MCQ = ["## JavaScript Closures", "", "What does the following code log?", "", "```ts", "function makeCounter() {", " let n = 0;", " return () => ++n;", "}",
"const c = makeCounter();", "console.log(c(), c(), c());", "```"]
def sel(value, w=None, disabled=False, ph=False):
comp = "Select / Filled" if value else "Select"
st = {"width": f"{w}px"} if w else {}
if disabled:
st["opacity"] = ".5"
return I(comp, Value=value or "Select…", style=st or None)
def inp(value, w=None, err=False):
st = f"width:{w}px" if w else None
return I("Input / Error" if err else ("Input / Filled" if value else "Input"), Value=value or "", style=st)
def type_details(type_label, title, tmo=5, mem=256, edit=False, title_err=False):
ctrl_w = "100%"
return acard("Card / Type & details", "Type & details",
field("Field / Type", "Type", sel(type_label, 1004 - 24 - 0 if False else 560, disabled=edit), True) +
field("Field / Title", "Title", inp(title, 560, err=title_err), True, err="Please enter Title" if title_err else None) +
row("Limits", field("Field / Time limit", "Time limit (s)", I("Input Number", Value=str(tmo), style="width:120px"), True) +
field("Field / Memory", "Memory (MB)", I("Input Number", Value=str(mem), style="width:120px"), True), gap="16px", align_items="flex-start"))
def prompt_card(lines, err=False):
return acard("Card / Prompt", "Prompt", field("Field / Prompt", "Prompt (Markdown)", md_editor(lines, 240), True))
def lang_card(lang="TypeScript", err=False):
return acard("Card / Language & limits", "Language & limits",
field("Field / Language", "Language", sel(lang or "", 560) if lang else I("Select", Value="Select…", style="width:560px") if not err else I("Select", Value="Select…", style="width:560px"), True,
err="Please enter Language" if err else None))
def switch(on, txt):
return row("Switch / " + txt, T("Switch Text", txt, 12, fg("text-inverse") if on else fg("text-inverse"), 400, lh=16), padding="0px 8px", height="22px", width="64px",
justify_content="flex-end" if not on else "flex-start", border_radius="99px", background=fg("brand-primary") if on else fg("switch-off"))
def files_card(files):
body = ""
for path, contents, hidden in files:
body += row(f"File Row / {path}", inp(path, 200) if False else I("Input / Filled", Value=path, style="width:200px") + I("Input / Filled", Value=contents, style="flex-grow:1") +
switch(hidden, "hidden" if hidden else "visible") + x_btn(), gap="8px")
return acard("Card / Files", "Files", body + dashed_btn("+ Add file", "Button / Add file"))
def tests_card(tests):
body = ""
for n, inp_, exp, w, samp in tests:
body += row(f"Test Row / {n}", I("Input / Filled", Value=n, style="width:120px") + I("Input / Filled", Value=inp_, style="flex-grow:1") +
I("Input / Filled", Value=exp, style="flex-grow:1") + I("Input Number", Value=str(w), style="width:70px") +
switch(samp, "sample" if samp else "-") + x_btn(), gap="8px")
return acard("Card / Test cases", "Test cases", body + dashed_btn("+ Add test", "Button / Add test"))
def mcq_card(questions, warn=None, hint=True):
body = T("Help", "Add as many questions as you need — each has its own options and exactly one correct answer. With a single question you can leave its text empty and let the prompt above do the talking.",
12, fg("text-secondary"), lh=18)
qs = ""
for qi, (qtext, opts) in enumerate(questions):
orow = ""
for oi, (lab, ok) in enumerate(opts):
L = "ABCDEFG"[oi]
orow += row(f"Option Row / {L}", circle_letter(L, ok) + (T("Option Text", lab, 14, fg("text-primary"), flex_grow="1") if lab else T("Option Placeholder", f"Option {L}", 14, fg("text-placeholder"), flex_grow="1")) +
I("Checkbox / Checked" if ok else "Checkbox / Unchecked", Label="Correct") + x_btn(), gap="8px", padding="4px 8px",
border=f"1px solid {'var(--color-success)' if ok else 'var(--color-border)'}", border_radius="var(--radius-md)",
background="#f6ffed" if ok else "transparent")
w = T("Warning", warn, 14, fg("warning")) if (warn and qi == 0) else ""
qs += col(f"Question {qi + 1}", row("Question Head", T("Question Label", f"Question {qi + 1}", 14, fg("text-primary"), 600) +
(I("Input / Filled", Value=qtext, style="flex-grow:1") if qtext else I("Input", Value="Question text (required)", style="flex-grow:1")) + x_btn(), gap="8px") +
orow + w + dashed_btn("+ Add option", "Button / Add option", small=True), gap="8px", padding="8px 16px",
border="1px solid var(--color-border)", border_radius="var(--radius-md)")
return acard("Card / MCQ questions", "MCQ questions", body + qs + dashed_btn("+ Add question", "Button / Add question", block=True))
def theory_card(qs, hint=False):
body = ""
for i, (cat, diff, q) in enumerate(qs, 1):
body += row(f"Theory Row {i}", circle_letter(str(i), False) +
col("Fields", row("Cat And Diff", (T("Category", cat, 14, fg("text-primary"), flex_grow="1") if cat else T("Category Placeholder", "Category (e.g. JS Closures)", 14, fg("text-placeholder"), flex_grow="1")) +
row("Diff Select", T("Diff", diff, 14, fg("text-primary")) + ic("caret-down", 12, fg("text-disabled"), "Caret"), gap="8px", width="110px", justify_content="space-between"),
gap="8px") +
(T("Question", q, 14, fg("text-primary")) if q else T("Question Placeholder", "Question the recruiter asks", 14, fg("text-placeholder"))), gap="4px", flex_grow="1") + x_btn(),
gap="8px", padding="4px 8px", border="1px solid var(--color-border)", border_radius="var(--radius-md)", align_items="flex-start")
if not qs:
body += ""
body += dashed_btn("+ Add question", "Button / Add question", block=True)
if not qs:
body += T("Hint", "Add at least one question — theory challenges require ≥1.", 14, fg("text-secondary"))
return acard("Card / Theory questions", "Theory questions", body)
def submit(label_, disabled=False):
return I("Ant Button / Primary", Label=label_, style="opacity:.5" if disabled else None)
def preview(title, prompt_nodes, body_nodes):
def sec(t):
return T("Section Label", t.upper(), 12, fg("text-secondary"), 600, lh=16, letter_spacing="0.48px")
hint = lambda t: T("Empty Hint", t, 12, fg("text-disabled"), lh=16, font_style="italic")
return col("Live Preview", T("Preview Label", "Live preview", 12, fg("text-primary"), lh=16) +
T("Preview Title", title or "Untitled challenge", 20, fg("text-primary"), 600, lh=28) +
col("Prompt Block", sec("Prompt") + prompt_nodes, gap="8px") + body_nodes,
padding="var(--spacing-lg)", gap="var(--spacing-lg)", background=fg("bg-elevated"), border="1px solid var(--color-border-light)",
border_radius="var(--radius-lg)")
def sec_label(t):
return T("Section Label", t.upper(), 12, fg("text-secondary"), 600, lh=16, letter_spacing="0.48px")
def hint(t):
return T("Empty Hint", t, 12, fg("text-disabled"), lh=16)
def md_rendered(h, para, code=None):
kids = T("Heading", h, 24, fg("text-primary"), 600, lh=32) + box("Rule", height="1px", background=fg("border-light")) + T("Paragraph", para, 14, fg("text-primary"))
if code:
kids += col("Code Block", "".join(T("Line", l or "&nbsp;", 12, fg("text-primary"), lh=16) for l in code), padding="12px", background=fg("bg-layout"), border_radius="var(--radius-md)")
return col("Rendered Markdown", kids, gap="12px")
def opt_preview(opts):
return col("Options", "".join(row(f"Option / {i}", box("Radio", width="16px", height="16px", border_radius="99px", border="2px solid var(--color-border-strong)") +
T("Label", o, 14, fg("text-primary"), flex_grow="1"), gap="8px", padding="10px 12px",
border="1px solid var(--color-border-light)", border_radius="var(--radius-md)") for i, o in enumerate(opts)), gap="8px")
def two_pane(form_html, preview_html):
return row("Two Pane", col("Form", form_html, gap="var(--spacing-lg)", width="615px") + col("Preview Pane", preview_html, width="385px"),
gap="var(--spacing-lg)", align_items="flex-start")
def editor_page(title, subtitle, inner):
return pcard("Card / Editor", T("Card Heading", title, 24, fg("text-primary"), 600, lh=32) + inner, gap="var(--spacing-md)", width="1052px")
def template_picker(applied=None):
kids = T("Label", "Starter templates", 14, fg("text-primary"), 600) + I("Ant Button / Default", Label="Browse templates…") + I("Ant Button / Text", Label="Start blank")
if applied:
kids += I("Tag / Blue", Label=f"{applied} applied")
kids += T("Help", "Templates pre-fill the form — everything stays editable.", 12, fg("text-secondary"), lh=16)
return row("Template Picker", kids, gap="8px", flex_wrap="wrap")
def algo_form(title, with_files, errs=False):
files = files_card([("solution.ts", "export function twoSum(nums: number[], target: number): number[] {…}", False)] if with_files else [])
tests = tests_card([("basic", "[2,7,11,15], 9", "[0,1]", 1, True), ("negatives", "[-3,4,3,90], 0", "[0,2]", 2, False), ("no pair", "[1,2], 7", "[]", 1, False)] if with_files else [])
return type_details("Algo", title, title_err=errs) + prompt_card(MD_MCQ[:1] and ["## Two Sum", "", "Given an array of integers nums and an integer target,", "return the indices of the two numbers that add up to target.", "", "```ts", "twoSum([2, 7, 11, 15], 9); // [0, 1]", "```"] if with_files else [""]) + \
lang_card("TypeScript" if with_files else None, err=errs) + files + tests + submit("Create")
def build_new(errs=False, applied=None, filled=True):
pv_prompt = md_rendered("Two Sum", "Given an array of integers nums and an integer target, return the indices of the two numbers that add up to target.") if filled else hint("Add a prompt to see it rendered here.")
pv_body = col("Body", sec_label("Starter file") + (code_block("solution.ts", ["export function twoSum(nums: number[], target: number): number[] {", " // TODO", " return [];", "}"]) if filled else hint("No visible files yet — add one in the Files section.")) +
sec_label("Test cases") + (T("Tests", "Tests: basic, negatives, no pair", 14, fg("text-primary")) if filled else hint("No test cases yet.")), gap="8px")
form = algo_form("Two Sum" if filled else "", filled, errs)
return editor_page("New challenge", "", template_picker(applied) + two_pane(form, preview("Two Sum" if filled else "", pv_prompt, pv_body)))
def build_edit_mcq():
form = (type_details("MCQ", "JavaScript Closures", edit=True) + prompt_card(MD_MCQ) + files_card([]) +
mcq_card([("What does the code log when run once?", [("1 1 1", False), ("1 2 3", True), ("0 1 2", False), ("3 2 1", False)]),
("Which keyword preserves n?", [("var", False), ("let", False)])], warn="Mark one option as correct."
if False else None) + submit("Save"))
pv = preview("JavaScript Closures", md_rendered("JavaScript Closures", "What does the following code log?", MD_MCQ[6:11]),
col("Questions", sec_label("Question 1") + T("Q1", "What does the code log when run once?", 14, fg("text-primary")) + opt_preview(["1 1 1", "1 2 3", "0 1 2", "3 2 1"]) +
sec_label("Question 2") + T("Q2", "Which keyword preserves n?", 14, fg("text-primary")) + opt_preview(["var", "let"]), gap="8px"))
return editor_page("Edit challenge", "", two_pane(form, pv))
def build_edit_mcq_validation():
form = (type_details("MCQ", "", edit=True, title_err=True) + prompt_card(["## JavaScript Closures"]) + files_card([]) +
mcq_card([("", [("1 1 1", False), ("1 2 3", False), ("", False)])], warn="Mark one option as correct.") + submit("Save"))
pv = preview("Untitled challenge", md_rendered("JavaScript Closures", ""), col("Questions", sec_label("Options") + opt_preview(["1 1 1", "1 2 3", "Option 3"]), gap="8px"))
return editor_page("Edit challenge", "", two_pane(form, pv))
def build_edit_theory(empty=False):
qs = [] if empty else [("System Design", "Medium", "How would you design a URL shortener that handles 10k writes per second?"),
("Databases", "Hard", "Explain how you would shard the mapping table."), ("", "Easy", "")]
form = (type_details("Theory", "System Design: URL Shortener", edit=True) + prompt_card(["## System Design", "", "Discuss the trade-offs aloud."]) + files_card([]) +
theory_card(qs) + submit("Save", disabled=empty))
if empty:
tp = hint("No questions yet — add at least one to make this a valid theory challenge.")
else:
def tq(i, cat, d, q, c):
return row(f"Question {i}", T("Num", f"{i}.", 14, fg("text-secondary"), 700) + col("Body", row("Meta", T("Category", cat, 12, fg("text-secondary"), 600, lh=16) +
row("Diff", T("Diff Text", d, 12, c, 600, lh=16), padding="1px 7px", border_radius="99px", background="#f0f0f0"), gap="6px") + T("Text", q, 14, fg("text-primary")), gap="4px", flex_grow="1"),
gap="8px", padding="10px 12px", border="1px solid var(--color-border-light)", border_radius="var(--radius-md)")
tp = col("Qs", tq(1, "System Design", "Medium", qs[0][2], fg("warning")) + tq(2, "Databases", "Hard", qs[1][2], fg("error")), gap="8px")
pv = preview("System Design: URL Shortener", md_rendered("System Design", "Discuss the trade-offs aloud."), col("Questions", sec_label("Questions") + tp, gap="8px"))
return editor_page("Edit challenge", "", two_pane(form, pv))
def build_edit_sql():
sql = ["SELECT department, SUM(amount) AS revenue", "FROM sales", "GROUP BY department", "ORDER BY revenue DESC", "LIMIT :n;"]
form = (type_details("SQL", "Top-N by Revenue", edit=True) + prompt_card(["## Top-N by Revenue", "", "Write a query returning the top N departments by revenue."]) +
files_card([("solution.sql", "SELECT department, SUM(amount) …", False), ("schema.sql", "CREATE TABLE sales (…)", True)]) + submit("Save"))
pv = preview("Top-N by Revenue", md_rendered("Top-N by Revenue", "Write a query returning the top N departments by revenue."),
col("SQL", sec_label("SQL") + code_block("solution.sql", sql), gap="8px"))
return editor_page("Edit challenge", "", two_pane(form, pv))
def build_edit_fullstack():
code = ["import express from 'express';", "const app = express();", "app.get('/todos', (_req, res) => res.json([]));", "app.listen(3000);"]
form = (type_details("Fullstack", "REST Todo API", tmo=30, mem=512, edit=True) + prompt_card(["## REST Todo API", "", "Implement GET /todos and POST /todos."]) +
lang_card("TypeScript") + files_card([("server.ts", "import express from 'express';…", False), ("server.test.ts", "describe('todos', …)", True)]) +
tests_card([("lists todos", "GET /todos", "200 []", 1, True), ("creates todo", "POST /todos", "201", 2, False)]) + submit("Save"))
pv = preview("REST Todo API", md_rendered("REST Todo API", "Implement GET /todos and POST /todos."),
col("Body", sec_label("Starter file") + code_block("server.ts", code) + sec_label("Test cases") + T("Tests", "Tests: lists todos, creates todo", 14, fg("text-primary")), gap="8px"))
return editor_page("Edit challenge", "", two_pane(form, pv))
def build_loading():
return row("Loading", I("Spin / Large"), justify_content="center", padding="var(--spacing-xxl)", width="1052px")
# ------------------------------------------------------------------ templates modal
TEMPLATES = [
("Algorithm", "Two Sum", "Classic array warm-up with a TS starter and 3 sample tests."),
("Algorithm", "Valid Parentheses", "Stack-based bracket matcher. Empty starter + sample tests."),
("Full-stack", "REST Todo API", "Express-style TS server stub with a sample happy-path test."),
("Full-stack", "Counter Component", "React counter component starter. No tests — UI focus."),
("Multiple choice", "JS Closures", "Four options, one correct — tests closure capture semantics."),
("Multiple choice", "Async / Await", "Pick the correct ordering of awaited console logs."),
("SQL", "Top-N by Revenue", "Schema note + prompt; candidate writes the ranking query."),
("SQL", "Second Highest Salary", "Classic windowing exercise. Returns null when there is none."),
("Theory", "System Design: URL Shortener", "Three questions across easy/medium/hard design depth."),
]
BADGE = {"Algorithm": ("brand-primary", "tag-blue-bg"), "Full-stack": ("avatar-purple", "tag-purple-bg"), "Multiple choice": ("avatar-teal", "tag-cyan-bg"),
"SQL": ("warning", "tag-gold-bg"), "Theory": ("avatar-pink", "tag-red-bg")}
def tcard(kind, title, blurb):
fgc, bgc = BADGE[kind]
return col(f"Template Card / {title}", row("Head", row("Type Badge", box("Dot", width="8px", height="8px", border_radius="99px", background=fg(fgc)) + T("Label", kind, 12, fg(fgc), 600, lh=16),
gap="6px", padding="2px 8px", border_radius="99px", background=fg(bgc)), justify_content="space-between") +
T("Title", title, 14, fg("text-primary"), 600) + T("Blurb", blurb, 12, fg("text-secondary"), lh=18),
gap="4px", padding="var(--spacing-md)", background=fg("bg-elevated"), border="1px solid var(--color-border-light)", border_radius="var(--radius-lg)", box_shadow="var(--shadow-sm)", width="256px")
def templates_modal():
seg = I("Segmented / 4 Options Active 1", **{"Option 1": "All", "Option 2": "Algorithm", "Option 3": "Full-stack", "Option 4": "Multiple choice"}) if False else \
row("Type Filter", "".join(T(f"Opt {o}", o, 14, fg("text-primary") if o == "All" else fg("text-secondary"), 600 if o == "All" else 400, padding="4px 12px",
**({"background": fg("bg-elevated"), "border_radius": "6px"} if o == "All" else {})) for o in ["All", "Algorithm", "Full-stack", "Multiple choice", "SQL", "Theory"]),
padding="2px", background="#0000000f", border_radius="var(--radius-md)")
grid = ""
for i in range(0, len(TEMPLATES), 3):
grid += row(f"Grid Row {i // 3 + 1}", "".join(tcard(*t) for t in TEMPLATES[i:i + 3]), gap="var(--spacing-md)")
m = col("Modal / Starter Templates", row("Modal Head", T("Title", "Starter templates", 16, fg("text-primary"), 600, lh=24) + ic("x", 16, fg("text-secondary"), "Close"), justify_content="space-between") +
row("Toolbar", I("Input / Search", Value="Search templates", style="width:280px") + seg, gap="8px") + col("Grid", grid, gap="var(--spacing-md)"),
width="860px", padding="20px 24px", gap="var(--spacing-md)", background=fg("bg-elevated"), border_radius="var(--radius-lg)", box_shadow="var(--shadow-lg)")
return overlay_html(m)
def build_all(P, only=None):
items = []
for f in list_screens():
items.append(f)
new_full = lambda **k: shell("Challenge New", "/challenges/new", "Challenges", "New challenge", "Author a reusable interview prompt", build_new(**k), wide=True) if False else None
S = lambda name, route, title, sub, content, **kw: shell(name, route, "Challenges", title, sub, content, **{"height": 0, **kw})
items += [
S("Challenge New Blank", "/challenges/new", "New challenge", "Author a reusable interview prompt", build_new(filled=False), caption_extra="blank (Algo)"),
S("Challenge New Template Applied", "/challenges/new", "New challenge", "Author a reusable interview prompt", build_new(applied="Two Sum"), caption_extra="template applied"),
S("Challenge New Validation Errors", "/challenges/new", "New challenge", "Author a reusable interview prompt", build_new(errs=True, filled=False), caption_extra="submit with empty required fields"),
S("Challenge New Templates Modal", "/challenges/new", "New challenge", "Author a reusable interview prompt", build_new(filled=False), overlay=templates_modal(), height=900, caption_extra="Browse templates…"),
S("Challenge Edit Mcq", "/challenges/[id]/edit", "Edit challenge", "Update prompt, rubric & metadata", build_edit_mcq(), caption_extra="MCQ"),
S("Challenge Edit Mcq Validation", "/challenges/[id]/edit", "Edit challenge", "Update prompt, rubric & metadata", build_edit_mcq_validation(), caption_extra="MCQ, no correct option, empty title"),
S("Challenge Edit Theory", "/challenges/[id]/edit", "Edit challenge", "Update prompt, rubric & metadata", build_edit_theory(), caption_extra="Theory"),
S("Challenge Edit Theory Empty", "/challenges/[id]/edit", "Edit challenge", "Update prompt, rubric & metadata", build_edit_theory(True), caption_extra="Theory without questions: Save disabled"),
S("Challenge Edit Sql", "/challenges/[id]/edit", "Edit challenge", "Update prompt, rubric & metadata", build_edit_sql(), caption_extra="SQL"),
S("Challenge Edit Fullstack", "/challenges/[id]/edit", "Edit challenge", "Update prompt, rubric & metadata", build_edit_fullstack(), caption_extra="Fullstack"),
S("Challenge Edit Loading", "/challenges/[id]/edit", "Edit challenge", "Loading", build_loading(), height=900, caption_extra="loading"),
]
return items
if __name__ == "__main__":
fid, P = init()
ensure_components(P)
delete_section(P, SECTION)
items = build_all(P)
sec = section(SECTION, "Admin / Challenges", items, X0, Y0, cols=2)
ids = write(P, "root", sec)
print("section", ids)
+559
View File
@@ -0,0 +1,559 @@
"""Admin app shell + Auth, Dashboard, Configuration, Criteria and Schedule screens (page `Admin`).
Idempotent: deletes its own `Section / Admin / <X>` frames and its own main components, then rebuilds.
python3 admin_core.py [auth dashboard configuration criteria schedule] (default: all)
"""
import json
import math
import sys
sys.path.insert(0, "/Volumes/Storage/Projects/papercraft/examples/interview")
from ds_common import * # noqa: F401,F403,E402
OUT = "/private/tmp/claude-501/-Volumes-Storage-Projects-photocraft/11f4040a-b4f4-4b9e-965a-bde08b400450/scratchpad/"
SECTIONS = {k: f"Section / Admin / {k.title()}" for k in ["auth", "dashboard", "configuration", "criteria", "schedule"]}
LIB_Y = 1400 # my new main components live here (above my sections)
def fg(c):
return f"var(--color-{c})"
def I(comp, name=None, style="", **ov):
"""Instance html with overrides and optional root style."""
o = json.dumps(ov, ensure_ascii=False).replace("'", "&#39;")
n = f' layer-name="{name}"' if name else ""
s = f' style="{style}"' if style else ""
return f"<x-paper-instance component=\"{comp}\"{n}{s} overrides='{o}'/>"
def card(name, kids, w=None, pad="var(--spacing-lg)", gap="16px", **st):
if w:
st["width"] = f"{w}px"
return col(name, kids, padding=pad, gap=gap, background=fg("bg-elevated"), border="1px solid var(--color-border-light)",
border_radius="var(--radius-lg)", box_shadow="var(--shadow-sm)", **st)
def div(name="Divider"):
return row(name, "", height="1px", background=fg("border-light"), align_self="stretch")
def h5(t, name="Card Title"):
return T(name, t, 16, fg("text-primary"), 600, lh=24)
def sec(t, name="Description", mb=None):
return T(name, t, 14, fg("text-secondary"))
# ------------------------------------------------------------------ shell
def screen(name, route, active, title, sub, content, wide=False, actions="", overlay="", centered=True):
"""Screen / <name> 1440x900: sidebar instance + header instance + content area."""
header = I("Admin Shell Header", "Admin Shell Header", "width:1192px", Title=title, Subtitle=sub)
if actions:
header = col("Header Wrap", header + row("Header Actions", actions, position="absolute", right="72px", top="34px"),
position="relative", width="1192px", height="102px")
inner_w = "1100px" if wide else "900px"
inner = col("Content Inner", content, width=inner_w, gap="0px")
body = col("Content", inner, align_items="center", padding="var(--page-padding)", flex_grow="1", overflow="hidden", background=fg("bg-layout"), width="1192px")
main = col("Main", header + body, width="1192px", height="900px", background=fg("bg-layout"), overflow="hidden")
kids = I(f"Admin Sidebar / {active} Active", "Admin Sidebar", "") + main
if overlay:
kids += row("Overlay", overlay, position="absolute", left="0px", top="0px", width="1440px", height="900px",
background=fg("bg-overlay"), justify_content="center", align_items="center")
frame = row(f"Screen / {name}", kids, width="1440px", height="900px", background=fg("bg-layout"), overflow="hidden", position="relative", align_items="stretch")
return col(f"Screen Wrap / {name}", T("Route Caption", route, 12, fg("text-secondary"), lh=16) + frame, gap="8px")
def raw_screen(name, route, content, bg="var(--color-bg-layout)"):
frame = col(f"Screen / {name}", content, width="1440px", height="900px", background=bg, overflow="hidden", position="relative")
return col(f"Screen Wrap / {name}", T("Route Caption", route, 12, fg("text-secondary"), lh=16) + frame, gap="8px")
def section(name, title, screens):
return row(name, col("Section Body", T("Section Title", title, 24, fg("text-primary"), 600, lh=32) + row("Screens", "".join(screens), gap="var(--spacing-xl)", align_items="flex-start"),
gap="var(--spacing-xl)"), padding="var(--spacing-xl)", background="transparent", width="fit-content")
# ------------------------------------------------------------------ new main components (to promote)
def donut_svg(segs, size=132, th=16):
r = (size - th) / 2
c = size / 2
tot = sum(v for v, _ in segs)
a0 = -math.pi / 2
paths = ""
for v, col_ in segs:
a1 = a0 + v / tot * 2 * math.pi
large = 1 if (a1 - a0) > math.pi else 0
paths += f'<path d="M{c + r * math.cos(a0):.2f} {c + r * math.sin(a0):.2f} A{r} {r} 0 {large} 1 {c + r * math.cos(a1):.2f} {c + r * math.sin(a1):.2f}" fill="none" stroke="{col_}" stroke-width="{th}"/>'
a0 = a1
ring = f'<svg layer-name="Ring" width="{size}" height="{size}" viewBox="0 0 {size} {size}"><circle cx="{c}" cy="{c}" r="{r}" fill="none" stroke="#f0f0f0" stroke-width="{th}"/>{paths}</svg>'
center = col("Center", T("Total", "11", 26, fg("text-primary"), 700, lh=32) + T("Caption", "sessions", 12, fg("text-secondary"), lh=16), align_items="center",
justify_content="center", position="absolute", left="0px", top="0px", width=f"{size}px", height=f"{size}px")
return ring + center
def avatar(initials, size, color, name="Avatar"):
fs = {30: 12, 36: 14}.get(size, 12)
return I(f"Avatar / {size}", name, f"background-color:{color}", Initials=initials)
def comp_specs():
"""[(name, html, width)] of new main components."""
L = []
L.append(("Funnel Row", row("Funnel Row", T("Label", "Applied", 12, fg("text-secondary"), lh=16, width="90px", text_align="right") +
row("Track", row("Fill", T("Count", "28", 12, fg("text-primary"), 600, lh=16), width="362px", height="26px", padding="0 10px", background="#bae0ff", border_radius="var(--radius-sm)"),
flex_grow="1", height="26px", background=fg("bg-layout"), border_radius="var(--radius-sm)", overflow="hidden"), gap="12px", width="464px")))
L.append(("Funnel Row / Inverse", row("Funnel Row / Inverse", T("Label", "Onsite", 12, fg("text-secondary"), lh=16, width="90px", text_align="right") +
row("Track", row("Fill", T("Count", "9", 12, fg("text-inverse"), 600, lh=16), width="116px", height="26px", padding="0 10px", background="#4096ff", border_radius="var(--radius-sm)"),
flex_grow="1", height="26px", background=fg("bg-layout"), border_radius="var(--radius-sm)", overflow="hidden"), gap="12px", width="464px")))
L.append(("Challenge Usage Row", col("Challenge Usage Row", row("Head", T("Title", "Two-sum with streaming input", 14, fg("text-primary"), 500, flex_grow="1") +
T("Count", "18", 12, fg("text-secondary"), 600, lh=16), justify_content="space-between", gap="8px") +
row("Track", row("Fill", "", width="196px", height="5px", background=fg("brand-primary"), border_radius="99px"), width="196px", height="5px", background=fg("border-light"), border_radius="99px", overflow="hidden"),
gap="6px", width="196px")))
L.append(("Review Item", row("Review Item", I("Avatar / 30", "Avatar", "", Initials="PI") +
col("Texts", T("Name", "Priya Iyer", 14, fg("text-primary"), 600) + T("Meta", "Live coding · 45m", 12, fg("text-secondary"), lh=16), flex_grow="1") +
I("Button / Primary Small", "Score Button", "", Label="Score"), gap="12px", padding="8px 12px", width="316px", background=fg("bg-elevated"),
border="1px solid var(--color-border-light)", border_radius="var(--radius-md)")))
for st, tone in [("Scheduled", "Blue"), ("In Progress", "Gold")]:
L.append((f"Upcoming Row / {st}", row(f"Upcoming Row / {st}", box("Accent Bar", "", width="4px", height="36px", border_radius="var(--radius-sm)", background=fg("brand-primary")) +
I("Avatar / 36", "Avatar", "", Initials="SA") +
col("Texts", T("Name", "Sara Ahmed", 14, fg("text-primary"), 600) + T("Meta", "Live coding · 45m", 12, fg("text-secondary"), lh=16), flex_grow="1") +
col("When Col", T("When", "Today · 14:00", 12, fg("text-secondary"), lh=16, text_align="right") + I(f"Tag Dot / {tone}", "Status Tag", "", Label=st), align_items="flex-end", gap="4px"),
gap="12px", padding="12px 0px", width="316px")))
L.append(("Donut / Score Distribution", box("Donut / Score Distribution", donut_svg([(3, "#52c41a"), (6, "#1677ff"), (2, "#faad14")]), position="relative", display="flex", width="132px", height="132px")))
L.append(("Legend Row", row("Legend Row", box("Swatch", "", width="10px", height="10px", border_radius="3px", background=fg("success")) +
T("Label", "Strong (4.5+)", 14, fg("text-secondary"), flex_grow="1") + T("Value", "3", 14, fg("text-primary"), 700), gap="8px", width="196px")))
L.append(("Criterion Row / Weight", row("Criterion Row / Weight", T("Label", "Problem solving", 14, fg("text-primary"), 500, flex_grow="1") +
row("Weight Input", T("Value", "30", 14, fg("text-primary"), flex_grow="1") + T("Suffix", "%", 14, fg("text-secondary")), width="80px", height="24px", padding="0 7px", gap="4px",
background=fg("bg-elevated"), border="1px solid var(--color-border)", border_radius="var(--radius-md)") +
I("Ant Button / Text Danger Small", "Remove Button", "", Label="×"), gap="16px", padding="8px 16px", width="512px", background=fg("bg-layout"), border_radius="var(--radius-md)")))
for on in (True, False):
nm = f"Setting Row / {'On' if on else 'Off'}"
L.append((nm, col(nm, row("Row", col("Texts", T("Label", "Session created", 14, fg("text-primary"), 500) + T("Desc", "When a new interview session is scheduled", 12, fg("text-secondary"), lh=16), gap="2px", flex_grow="1") +
I("Switch / On" if on else "Switch / Off", "Switch", ""), gap="16px", padding="16px 0px", width="512px"))))
for av in (True, False):
nm = f"ATS Tile / {'Available' if av else 'Soon'}"
L.append((nm, row(nm, T("Name", "Greenhouse", 14, fg("text-primary"), 600, flex_grow="1") +
(I("Ant Button / Default Small", "Connect Button", "", Label="Connect") if av else I("Ant Tag / Default", "Soon Tag", "", Label="Soon")),
justify_content="space-between", padding="16px", width="248px", border="1px solid var(--color-border-light)", border_radius="var(--radius-md)")))
L.append(("Pipeline Row / Dark", row("Pipeline Row / Dark", row("Who", box("Dot", "", width="8px", height="8px", border_radius="99px", background="#4ade80") + T("Name", "Amara Okafor", 14, fg("sidebar-text")), gap="8px") +
T("Stage", "Onsite loop", 12, fg("sidebar-text-faint"), lh=16), justify_content="space-between", padding="8px 0px", width="100%")))
return L
def build_components(P):
names = [n for n, _ in comp_specs()]
root = P.c("get_children", nodeId="root")["children"]
ids = [c["id"] for c in root if c["name"] in names]
if ids:
P.c("delete_nodes", nodeIds=ids)
x = 0
for n, html in comp_specs():
r = P.c("write_html", targetNodeId="root", mode="insert-children", html=html)
for k in ("warnings", "ignoredStyles"):
if r.get(k):
print(" ", n, k, r[k][:3] if isinstance(r[k], list) else r[k])
nid = r["createdNodeIds"][0]
P.c("create_component", nodeId=nid, name=n)
P.c("update_styles", updates=[{"nodeIds": [nid], "styles": {"left": x, "top": LIB_Y}}])
x += 360
# ------------------------------------------------------------------ placement
def place(P, name, html, top):
root = P.c("get_children", nodeId="root")["children"]
old = [c["id"] for c in root if c["name"] == name]
if old:
P.c("delete_nodes", nodeIds=old)
r = P.add("root", html)
P.c("update_styles", updates=[{"nodeIds": [r[0]], "styles": {"left": 0, "top": top}}])
info = P.c("get_node_info", nodeId=r[0])
return r[0], top + info.get("height", 1200) + 200
def start_top(P):
root = P.c("get_children", nodeId="root")["children"]
mine = set(SECTIONS.values())
tops = [c.get("y", 0) + c["height"] for c in root if c["name"].startswith("Section / ") and c["name"] not in mine and c.get("x", 0) < 20000]
return max(tops + [2400]) + 200
# ------------------------------------------------------------------ AUTH
def brand_mark():
return row("Brand", box("Brand Mark", T("Brand Mark Letter", "H", 14, fg("text-inverse"), 700, lh=18), display="flex", justify_content="center", align_items="center",
width="28px", height="28px", border_radius="var(--radius-md)", background=fg("brand-primary")) +
T("Brand Name", "Hireloop", 16, fg("text-inverse"), 700, lh=20), gap="10px")
def pipeline_card(w=None):
rows = "".join(I("Pipeline Row / Dark", f"Pipeline Row / {n}", "width:100%", Name=n, Stage=s) for n, s in
[("Amara Okafor", "Onsite loop"), ("Mei Lin Tan", "Debrief"), ("Sofia Bianchi", "Offer")])
head = row("Pipeline Header", T("Pipeline Title", "CANDIDATE PIPELINE", 12, fg("sidebar-text-faint"), 700, lh=16, letter_spacing="0.84px") +
T("Live Pill", "Live", 12, "#4ade80", 600, lh=16, padding="2px 8px", background="#16a34a2e", border_radius="99px"), justify_content="space-between")
return col("Pipeline Card", head + rows, gap="8px", padding="16px", background="#ffffff0d", border="1px solid #ffffff14", border_radius="10px", **({"width": f"{w}px"} if w else {}))
FEATURES = [("squares-four", "Team-wide visibility", "Track every candidate across the loop, from screen to offer."),
("brackets-curly", "Live coding & system design", "Watch candidates work in a real shared editor and whiteboard."),
("diamond", "Calibrated scorecards", "A shared rubric keeps every interviewer's ratings comparable.")]
def login_screen(error=False):
feats = "".join(row(f"Feature / {t}", box("Feature Icon", ic(i, 16, fg("sidebar-text")), display="flex", justify_content="center", align_items="center", width="32px", height="32px",
border_radius="6px", background="#ffffff12") +
col("Texts", T("Feature Title", t, 14, fg("text-inverse"), 600, lh=18) + T("Feature Desc", d, 12, fg("sidebar-text-muted"), lh=18), gap="2px", flex_grow="1"),
gap="16px", align_items="flex-start") for i, t, d in FEATURES)
hero = col("Hero", T("Hero Title", "Run better interviews, together.", 32, fg("text-inverse"), 800, lh=38, letter_spacing="-0.6px") +
T("Hero Sub", "Structured scorecards, live coding sessions, and calibrated hiring decisions — all in one workspace for your team.", 14, fg("sidebar-text-muted"), lh=24), gap="16px")
mid = col("Marketing Body", hero + col("Features", feats, gap="20px") + pipeline_card(), gap="32px", flex_grow="1", justify_content="center", padding="56px 0px 0px")
left = col("Marketing Panel", brand_mark() + mid + T("Footer Text", "© 2026 Hireloop. All rights reserved.", 12, "#ffffff38", lh=16), padding="32px", width="662px", height="900px", background="#0f1117")
alert = row("Alert / Error", ic("x-circle", 16, fg("score-poor"), "Alert Icon") + T("Message", "Invalid credentials", 14, fg("text-primary"), flex_grow="1"), gap="8px", width="400px",
padding="8px 12px", background=fg("tag-red-bg"), border="1px solid var(--color-tag-red-bd)", border_radius="var(--radius-md)") if error else ""
pw_label = row("Password Label", T("Label", "Password", 14, fg("text-primary"), lh=22) + T("Forgot", "Forgot password?", 13, fg("text-link"), lh=18), justify_content="space-between", width="400px")
form = col("Sign In Form", col("Heading", T("Title", "Sign in to your workspace", 30, fg("text-primary"), 700, lh=38, letter_spacing="-0.3px") +
T("Subtitle", "Enter your credentials to continue to Hireloop.", 14, fg("text-secondary")), gap="4px") + alert +
col("Fields", I("Form Field / Required", "Form Field / Work email", "width:400px", Label="Work email", Value="you@company.com") +
col("Form Field / Password", pw_label + I("Input / Password", "Input", "width:400px"), gap="8px"), gap="24px") +
I("Checkbox / Unchecked", "Remember", "", Label="Keep me signed in") +
I("Ant Button / Primary Large", "Sign In Button", "width:400px", Label="Sign in") +
row("Links", T("Request", "Don't have access? ", 13, fg("text-secondary"), lh=18) + T("Invite Link", "Ask your workspace admin for an invite", 13, fg("text-link"), lh=18), justify_content="center", width="400px"),
gap="24px", width="400px")
right = col("Form Panel", form, flex_grow="1", justify_content="center", align_items="center", background=fg("bg-elevated"), padding="48px", height="900px")
return row("Login Split", left + right, width="1440px", height="900px")
def landing_screen():
nav = row("Nav", brand_mark() + I("Ant Button / Primary", "Sign In Button", "", Label="Sign in"), justify_content="space-between")
eyebrow = row("Eyebrow", box("Dot", "", width="6px", height="6px", border_radius="99px", background="#4ade80") +
T("Eyebrow Label", "INTERVIEW PLATFORM FOR HIRING TEAMS", 12, fg("sidebar-text"), 700, lh=16, letter_spacing="1.2px"),
gap="8px", padding="6px 12px", background="#ffffff0a", border="1px solid #ffffff14", border_radius="99px", align_self="flex-start")
title = row("Hero Title", T("Title Plain", "Run better interviews, ", 44, fg("text-inverse"), 800, lh=48, letter_spacing="-1.3px") +
T("Title Accent", "together.", 44, fg("brand-secondary"), 800, lh=48, letter_spacing="-1.3px"), gap="0px", flex_wrap="wrap")
stats = row("Stats", "".join(col(f"Stat / {l}", T("Value", v, 28, fg("text-inverse"), 800, lh=32) + T("Label", l, 12, fg("sidebar-text-muted"), lh=16), gap="2px")
for v, l in [("4.1", "Avg. rubric score"), ("34%", "Offer rate"), ("12", "Active candidates")]), gap="36px")
hero = col("Hero", eyebrow + col("Title Block", title, width="580px") +
T("Hero Sub", "Structured scorecards, live coding sessions, and calibrated hiring decisions — all in one workspace for your team.", 16, fg("sidebar-text-muted"), lh=26, width="540px") +
row("CTA Row", I("Ant Button / Primary Large", "Sign In Button", "", Label="Sign in to your workspace") +
T("Hint", "No access yet? Ask your workspace admin.", 13, fg("sidebar-text-muted"), lh=18), gap="16px") + stats,
gap="20px", width="620px")
hero_row = row("Hero Row", hero + pipeline_card(320), gap="48px", flex_grow="1", align_items="center")
grid = row("Feature Grid", "".join(col(f"Feature / {t}", box("Feature Icon", ic(i, 20, fg("brand-secondary")), display="flex", justify_content="center", align_items="center",
width="36px", height="36px", border_radius="var(--radius-md)", background="#69b1ff1f") +
T("Feature Title", t, 14, fg("text-inverse"), 600, lh=20) + T("Feature Desc", d, 14, fg("sidebar-text-muted"), lh=22), gap="8px", padding="20px", flex_grow="1",
background="#ffffff0a", border="1px solid #ffffff12", border_radius="var(--radius-lg)") for i, t, d in FEATURES), gap="16px")
foot = row("Footer", T("Left", "© 2026 Hireloop. All rights reserved.", 12, "#ffffff40", lh=16) + T("Right", "Built for structured, calibrated hiring.", 12, "#ffffff40", lh=16),
justify_content="space-between", padding="16px 0px 0px", border="1px solid transparent")
glow = box("Glow", "", position="absolute", left="340px", top="-160px", width="760px", height="560px", background="radial-gradient(circle, #1677ff47 0%, #1677ff14 40%, transparent 70%)")
inner = col("Landing Inner", nav + hero_row + grid + foot, width="1080px", padding="28px 40px", gap="24px", height="900px", position="absolute", left="180px", top="0px")
return box("Landing", glow + inner, position="relative", width="1440px", height="900px", background="#0f1117", overflow="hidden")
def build_auth():
return section(SECTIONS["auth"], "Admin / Auth", [
raw_screen("Admin Login", "/login", login_screen()),
raw_screen("Admin Login Error", "/login (invalid credentials)", login_screen(True)),
raw_screen("Admin Landing", "/", landing_screen(), bg="#0f1117"),
])
# ------------------------------------------------------------------ DASHBOARD
def stats_row(vals):
comps = ["Blue", "Purple", "Orange Down", "Green", "Teal"]
labels = ["ACTIVE CANDIDATES", "INTERVIEWS SCHEDULED", "AWAITING REVIEW", "AVG. SCORE", "OFFER RATE"]
out = ""
for c, l, (v, d, t) in zip(comps, labels, vals):
arrow = "▼" if c == "Orange Down" else "▲"
out += I(f"Stat / {c}", f"Stat / {l.title()}", "width:167px", Label=l, Value=v, Delta=d, Trend=t, **{"Delta Arrow": arrow})
return row("Stats Row", out, gap="var(--spacing-md)", align_items="stretch", width="900px")
FUNNEL = [("Applied", 28, "#bae0ff"), ("Screen", 18, "#91caff"), ("Phone", 14, "#69b1ff"), ("Onsite", 9, "var(--color-button-primary-hover)"), ("Offer", 5, "var(--color-brand-primary)"), ("Hired", 3, "#0958d9")]
TOP = [("Two-sum with streaming input", 18, "var(--color-brand-primary)"), ("REST API: pagination + caching", 14, "var(--color-avatar-purple)"),
("SQL: window functions deep-dive", 11, "var(--color-avatar-teal)"), ("Build a Markdown previewer", 8, "var(--color-warning)"), ("Debug a memory leak", 5, "var(--color-success)")]
def funnel_card(empty=False):
rows = ""
for i, (s, n, c) in enumerate(FUNNEL):
nn = 0 if empty else n
w = max(0.06, nn / 28) * 362
rows += I("Funnel Row" if i < 3 else "Funnel Row / Inverse", f"Funnel Row / {s}", "", Label=s, Count=str(nn), Fill={"width": f"{w:.0f}px", "backgroundColor": c})
return card("Card / Hiring pipeline", I("Section Title", "Section Title", "width:464px", Title="Hiring pipeline", Subtitle="Across all open roles") +
col("Funnel", rows, gap="10px"), w=512, gap="16px")
def score_card(empty=False):
if empty:
body = I("Empty / Simple", "Empty", "width:196px", Description="No completed sessions")
else:
body = col("Donut Block", row("Donut Wrap", I("Donut / Score Distribution", "Donut", ""), justify_content="center", width="196px") +
col("Legend", I("Legend Row", "Legend / Strong", "", Label="Strong (4.5+)", Value="3") +
I("Legend Row", "Legend / Solid", "", Label="Solid (3.5–4.5)", Value="6", Swatch={"backgroundColor": fg("brand-primary")}) +
I("Legend Row", "Legend / Borderline", "", Label="Borderline (<3.5)", Value="2", Swatch={"backgroundColor": fg("score-warn")}), gap="12px"), gap="16px")
return card("Card / Score distribution", I("Section Title", "Section Title", "width:196px", Title="Score distribution", Subtitle="Completed sessions") + body, w=244)
def top_card(empty=False):
if empty:
body = I("Empty / Simple", "Empty", "width:196px", Description="No challenge usage yet")
else:
body = col("Usage List", "".join(I("Challenge Usage Row", f"Usage / {t}", "", Title=t, Count=str(n), Fill={"width": f"{n / 18 * 196:.0f}px", "backgroundColor": c}) for t, n, c in TOP), gap="12px")
return card("Card / Most-used challenges", I("Section Title / With Action", "Section Title", "width:196px", Title="Most-used challenges", Action="Library →", Subtitle={"display": "none"}) + body, w=244)
def review_card(empty=False):
head = row("Review Head", col("Texts", T("Title", "Awaiting review", 16, fg("text-primary"), 600, lh=22) +
T("Subtitle", "0 sessions need your scorecard" if empty else "2 sessions need your scorecard", 12, fg("text-secondary"), lh=16)) +
("" if empty else I("Tag Dot / Gold", "Action Tag", "", Label="Action needed")), justify_content="space-between", gap="8px")
if empty:
items = I("Empty State", "Empty State", "width:316px", Title="All caught up", Description="No sessions are waiting for your scorecard.")
else:
items = col("Review Items", I("Review Item", "Review Item / Priya Iyer", "", Name="Priya Iyer", Meta="Live coding · 45m", Avatar={"backgroundColor": fg("avatar-orange")}, Initials="PI") +
I("Review Item", "Review Item / Marcus Chen", "", Name="Marcus Chen", Meta="Fullstack · 60m", Avatar={"backgroundColor": fg("warning")}, Initials="MC"), gap="8px")
return card("Card / Awaiting review", head + items, w=364, background="linear-gradient(180deg, #fffdf5, #ffffff)")
UPC = [("Sara Ahmed", "SA", "Live coding", 45, "Today · 14:00", "Scheduled", "brand-primary", "avatar-teal"),
("Tomás López", "TL", "Fullstack", 60, "Today · 16:30", "Scheduled", "avatar-purple", "avatar-teal"),
("Aisha Khan", "AK", "SQL", 30, "Tomorrow · 10:00", "Scheduled", "avatar-teal", "avatar-pink"),
("Owen Park", "OP", "Live coding", 45, "Thu · 11:00", "In Progress", "brand-primary", "avatar-indigo")]
def upcoming_card(empty=False):
head = I("Section Title", "Section Title", "width:316px", Title="Upcoming interviews", Subtitle={"display": "none"})
if empty:
return card("Card / Upcoming interviews", head + I("Empty / Simple", "Empty", "width:316px", Description="No interviews scheduled"), w=364)
rows = ""
for i, (n, ini, t, m, when, st, acc, avc) in enumerate(UPC):
if i:
rows += div()
rows += I(f"Upcoming Row / {st}", f"Upcoming Row / {n}", "", Name=n, Initials=ini, Meta=f"{t} · {m}m", When=when, **{"Accent Bar": {"backgroundColor": fg(acc)}, "Avatar": {"backgroundColor": fg(avc)}})
return card("Card / Upcoming interviews", head + col("Upcoming List", rows, gap="0px"), w=364, gap="8px")
def dashboard_content(empty=False):
st = stats_row([("0", "0", "this week")] * 5 if empty else [("12", "+3", "this week"), ("8", "+2", "vs last wk"), ("2", "−1", "cleared"), ("4.1", "+0.2", "this month"), ("34%", "+5%", "this quarter")])
left = col("Left Column", funnel_card(empty) + row("Pair", score_card(empty) + top_card(empty), gap="var(--spacing-lg)", align_items="flex-start"), gap="var(--spacing-lg)")
right = col("Right Column", review_card(empty) + upcoming_card(empty), gap="var(--spacing-lg)")
return col("Dashboard", st + row("Dashboard Grid", left + right, gap="var(--spacing-lg)", align_items="flex-start"), gap="var(--spacing-lg)")
def build_dashboard():
return section(SECTIONS["dashboard"], "Admin / Dashboard", [
screen("Admin Dashboard", "/dashboard", "Dashboard", "Good afternoon", "Welcome to your dashboard", dashboard_content()),
screen("Admin Dashboard Empty", "/dashboard (no data)", "Dashboard", "Good afternoon", "Welcome to your dashboard", dashboard_content(True)),
])
# ------------------------------------------------------------------ CONFIGURATION
def field(label, control, w=512, required=False):
lab = (T("Required Mark", "*", 14, fg("error"), lh=22) if required else "") + T("Label", label, 14, fg("text-primary"), lh=22)
return col(f"Field / {label}", row("Label Row", lab, gap="4px") + control, gap="8px", width=f"{w}px")
def cfg_screen(tab_idx, name, route, content):
tabs = I(f"Tabs / Bar 5 Large Active {tab_idx}", "Tabs", "width:1100px", **{"Tab 1 Label": "Workspace", "Tab 2 Label": "Team", "Tab 3 Label": "Scoring", "Tab 4 Label": "Notifications", "Tab 5 Label": "Integrations"})
return screen(name, route, "Configuration", "Configuration", "Workspace settings, team, scoring & integrations", col("Config", tabs + col("Tab Body", content, padding="16px 0px 0px"), gap="0px"), wide=True)
def tab_workspace():
general = card("Card / General", h5("General") + col("Form", field("Workspace name", I("Input / Large Filled", "Input", "width:512px", Value="Netcracker"), required=True) +
field("Workspace URL", I("Input / With Prefix", "Input", "width:512px", Prefix="hireloop.io/", Value={"color": fg("text-primary")})) +
field("Default timezone", I("Select / Large Filled", "Select", "width:512px", Value="UTC+3 (Moscow)")) +
field("Description", I("Textarea", "Textarea", "width:512px", Value="Brief description of your team or hiring process")), gap="24px") +
I("Ant Button / Primary", "Save Button", "", Label="Save changes"), w=560)
danger = card("Card / Danger zone", col("Texts", h5("Danger zone") + T("Description", "Irreversible actions. Proceed with caution.", 14, fg("text-secondary")), gap="4px") +
I("Ant Button / Danger", "Delete Button", "", Label="Delete workspace"), w=560)
return col("Workspace Tab", general + danger, gap="20px")
MEMBERS = [("Elena Vasquez", "elena.vasquez@netcracker.com", "Admin", "Active"), ("Dmitri Sokolov", "d.sokolov@netcracker.com", "Interviewer", "Active"),
("Priya Nair", "p.nair@netcracker.com", "Interviewer", "Active"), ("James Obi", "j.obi@netcracker.com", "Viewer", "Pending")]
ROLE_TAG = {"Admin": "Tag / Blue", "Interviewer": "Tag / Purple", "Viewer": "Ant Tag / Default"}
def th_row(cols, widths, name="Header Row"):
cells = "".join(T(f"Col {i}", c, 14, fg("text-primary"), 600, **({"width": f"{w}px"} if w else {"flex_grow": "1"})) for i, (c, w) in enumerate(zip(cols, widths), 1))
return row(name, cells, gap="16px", padding="16px", background=fg("bg-sider"), align_self="stretch")
def td_row(name, cells, widths, pad="16px"):
out = "".join(row(f"Cell {i}", c, **({"width": f"{w}px"} if w else {"flex_grow": "1"})) for i, (c, w) in enumerate(zip(cells, widths), 1))
return row(name, out, gap="16px", padding=pad, align_self="stretch", background=fg("bg-elevated"))
def tab_team():
W = [None, 120, 120, 160]
rows = th_row(["Name", "Role", "Status", ""], W)
for n, e, r, s in MEMBERS:
rows += div() + td_row(f"Row / {n}", [col("Name Cell", T("Name", n, 14, fg("text-primary"), 600) + T("Email", e, 12, fg("text-secondary"), lh=16), gap="0px"),
I(ROLE_TAG[r], "Role Tag", "", Label=r), I("Ant Tag / Success" if s == "Active" else "Ant Tag / Default", "Status Tag", "", Label=s),
row("Actions", I("Ant Button / Text Small", "Edit", "", Label="Edit") + I("Ant Button / Text Danger Small", "Remove", "", Label="Remove"), gap="4px", justify_content="flex-end")], W)
table = col("Table", rows, width="700px", background=fg("bg-elevated"), border="1px solid var(--color-border-light)", border_radius="var(--radius-lg)", overflow="hidden", box_shadow="var(--shadow-sm)")
top = row("Team Head", T("Count", "4 members in this workspace", 14, fg("text-secondary")) + I("Ant Button / Primary", "Invite Button", "", Label="Invite member"), justify_content="space-between", width="700px")
return col("Team Tab", top + table, gap="20px")
CRIT = [("Problem solving", 30), ("Code quality", 25), ("Communication", 20), ("System design", 15), ("Culture fit", 10)]
def tab_scoring():
scale = card("Card / Scoring scale", col("Texts", h5("Scoring scale") + T("Description", "All scorecard ratings use this scale.", 14, fg("text-secondary")), gap="4px") +
row("Scale Form", row("Min Field", T("Label", "Min:", 14, fg("text-primary")) + I("Input / Large Filled", "Input", "width:72px", Value="1"), gap="8px") +
row("Max Field", T("Label", "Max:", 14, fg("text-primary")) + I("Input / Large Filled", "Input", "width:72px", Value="5"), gap="8px") +
row("Step Field", T("Label", "Step:", 14, fg("text-primary")) + I("Select / Large Filled", "Select", "width:88px", Value="1"), gap="8px"), gap="16px"), w=600)
items = "".join(I("Criterion Row / Weight", f"Criterion Row / {l}", "", Label=l, Value=str(w)) for l, w in CRIT)
rub = card("Card / Rubric criteria", row("Rubric Head", col("Texts", h5("Rubric criteria") + T("Description", "Weights must sum to 100%.", 14, fg("text-secondary")), gap="4px") +
I("Ant Button / Default", "Add Button", "", Label="Add criterion"), justify_content="space-between", align_items="flex-start") +
col("Criteria", items, gap="8px") + I("Ant Button / Primary", "Save Button", "", Label="Save rubric"), w=600)
return col("Scoring Tab", scale + rub, gap="20px")
NOTIFS = [("Session created", "When a new interview session is scheduled", True), ("Candidate joined", "When a candidate enters their session", True),
("Review ready", "When all scorecards are submitted for a session", True), ("Invite expired", "When a candidate invite link expires unused", False),
("Weekly digest", "Summary of sessions, scores, and pipeline activity", True)]
def tab_notifications():
rows = ""
for i, (l, d, on) in enumerate(NOTIFS):
rows += I(f"Setting Row / {'On' if on else 'Off'}", f"Setting Row / {l}", "", Label=l, Desc=d)
if i < len(NOTIFS) - 1:
rows += div()
c = card("Card / Email notifications", col("Texts", h5("Email notifications") + T("Description", "Sent to your work email. You can override per-session in session settings.", 14, fg("text-secondary")), gap="4px") +
col("Settings", rows, gap="0px") + I("Ant Button / Primary", "Save Button", "", Label="Save preferences"), w=560)
return c
def tab_integrations():
hook = card("Card / Webhook", col("Texts", h5("Webhook") + T("Description", "POST events (session created, review complete) to your endpoint.", 14, fg("text-secondary")), gap="4px") +
col("Form", field("Endpoint URL", I("Input / Large", "Input", "width:552px", Value="https://your-app.com/webhooks/hireloop"), 552) +
field("Signing secret", I("Input / Password", "Input", "width:552px"), 552), gap="24px") + I("Ant Button / Primary", "Save Button", "", Label="Save webhook"), w=600)
tiles = [("Greenhouse", True), ("Lever", True), ("Workday", False), ("BambooHR", False)]
t = "".join(I(f"ATS Tile / {'Available' if a else 'Soon'}", f"ATS Tile / {n}", "", Name=n) for n, a in tiles)
ats = card("Card / ATS integrations", col("Texts", h5("ATS integrations") + T("Description", "Sync candidates and decisions directly with your applicant tracking system.", 14, fg("text-secondary")), gap="4px") +
row("ATS Grid", t, flex_wrap="wrap", gap="12px", width="552px"), w=600)
return col("Integrations Tab", hook + ats, gap="20px")
def build_configuration():
return section(SECTIONS["configuration"], "Admin / Configuration", [
cfg_screen(1, "Admin Configuration Workspace", "/configuration (Workspace)", tab_workspace()),
cfg_screen(2, "Admin Configuration Team", "/configuration (Team)", tab_team()),
cfg_screen(3, "Admin Configuration Scoring", "/configuration (Scoring)", tab_scoring()),
cfg_screen(4, "Admin Configuration Notifications", "/configuration (Notifications)", tab_notifications()),
cfg_screen(5, "Admin Configuration Integrations", "/configuration (Integrations)", tab_integrations()),
])
# ------------------------------------------------------------------ CRITERIA
CRITERIA = [("Problem solving", 30, 0, False), ("Code quality", 25, 1, False), ("Communication", 20, 2, False), ("System design", 15, 3, False), ("Culture fit", 10, 4, False), ("Whiteboarding", 0, 5, True)]
CW = [None, 100, 100, 120, 260]
def criteria_table(rows_=CRITERIA, state="data"):
out = th_row(["Label", "Weight", "Position", "Status", "Actions"], CW)
if state == "empty":
out += div() + row("Empty Row", I("Empty / Simple", "Empty", "", Description="No data"), justify_content="center", padding="var(--spacing-xl)", align_self="stretch", background=fg("bg-elevated"))
elif state == "loading":
out += div() + row("Loading Row", I("Spin / Large", "Spin", ""), justify_content="center", padding="var(--spacing-xxl)", align_self="stretch", background=fg("bg-elevated"))
for i, (l, w, p, arch) in enumerate(rows_ if state == "data" else []):
dis = "opacity:.5"
btns = (I("Ant Button / Default Small", "Edit", "", Label="Edit") + I("Ant Button / Default Small", "Archive", "", Label="Unarchive" if arch else "Archive") +
I("Ant Button / Default Small", "Up", dis if i == 0 else "", Label="↑") + I("Ant Button / Default Small", "Down", dis if i == len(rows_) - 1 else "", Label="↓"))
out += div() + td_row(f"Row / {l}", [T("Label", l, 14, fg("text-primary")), T("Weight", f"{w}%", 14, fg("text-primary")), T("Position", str(p), 14, fg("text-primary")),
I("Ant Tag / Default" if arch else "Ant Tag / Success", "Status Tag", "", Label="Archived" if arch else "Active"), row("Actions", btns, gap="8px")], CW)
return col("Table", out, align_self="stretch", background=fg("bg-elevated"), border="1px solid var(--color-border-light)", border_radius="var(--radius-md)", overflow="hidden")
def criteria_screen(name, route, state="data", overlay=""):
c = card("Card / Criteria", T("Description", "Active criteria are shown in the interview scorecard. Archived criteria are hidden from the scorecard but retained (scores referencing them are never deleted).", 14, fg("text-secondary")) +
criteria_table(state=state), w=1100)
return screen(name, route, "Rubric", "Rubric", "Manage scorecard criteria — create, edit, archive, reorder", c, wide=True,
actions=I("Ant Button / Primary", "Add Button", "", Label="Add criterion"), overlay=overlay)
def modal_form(title, ok, vals):
def f(label, value, ph=False):
ctl = I("Input" if ph else "Input / Filled", "Input", "width:472px", Value=value)
return col(f"Field / {label}", row("Label Row", T("Required Mark", "*", 14, fg("error"), lh=22) + T("Label", label, 14, fg("text-primary"), lh=22), gap="4px") + ctl, gap="8px")
fields = col("Form", f("Label", vals[0], vals[0].startswith("e.g.")) + f("Weight (%)", vals[1]) + f("Position", vals[2]), gap="24px")
header = row("Header", T("Title", title, 16, fg("text-primary"), 600, lh=24) + ic("x", 14, fg("text-secondary"), "Close Icon"), justify_content="space-between")
footer = row("Footer", I("Ant Button / Default", "Cancel Button", "", Label="Cancel") + I("Ant Button / Primary", "OK Button", "", Label=ok), justify_content="flex-end", gap="8px")
return col("Modal", header + fields + footer, width="520px", gap="16px", padding="24px", background=fg("bg-elevated"), border_radius="var(--radius-lg)", box_shadow="var(--shadow-lg)")
def build_criteria():
return section(SECTIONS["criteria"], "Admin / Criteria", [
criteria_screen("Admin Criteria", "/criteria"),
criteria_screen("Admin Criteria Empty", "/criteria (no criteria)", "empty"),
criteria_screen("Admin Criteria Loading", "/criteria (loading)", "loading"),
criteria_screen("Admin Criteria Add Modal", "/criteria (Add criterion modal)", overlay=modal_form("Add criterion", "Create", ("e.g. Problem solving", "0", "6"))),
criteria_screen("Admin Criteria Edit Modal", "/criteria (Edit criterion modal)", overlay=modal_form("Edit criterion", "Save", ("Code quality", "25", "1"))),
])
# ------------------------------------------------------------------ SCHEDULE
SESS = [("Backend Engineer · Sara Ahmed", "K7M2QX", "Open", "Blue", "2026-07-14 10:00:00"), ("Fullstack · Tomás López", "R4TP9D", "In progress", "Gold", "2026-07-14 14:30:00"),
("SQL deep-dive · Aisha Khan", "B8WN3C", "Submitted", "Purple", "2026-07-15 09:00:00"), ("Live coding · Owen Park", "H2VZ6L", "Graded", "Green", "2026-07-16 11:00:00"),
("Frontend Engineer · Mei Lin Tan", "Q5XJ1S", "Draft", "Gray", None), ("Platform · Dmitri Sokolov", "M9AE7F", "Cancelled", "Red", None), ("Data Engineer · Priya Iyer", "T3GY8U", "Open", "Blue", None)]
SW = [None, 130, 140, 300]
def schedule_table():
head = row("Header Row", "".join(T(f"Col {i}", c, 12, fg("text-secondary"), 600, lh=16, letter_spacing="0.36px", **({"width": f"{w}px"} if w else {"flex_grow": "1"}))
for i, (c, w) in enumerate(zip(["SESSION", "CODE", "STATUS", "SCHEDULED START"], SW), 1)), gap="16px", padding="12px 16px", background=fg("bg-sider"), align_self="stretch")
out = head
for t, code, st, tone, ts in SESS:
chip = row("Code Chip", T("Code", code, 12, fg("text-secondary"), lh=16), padding="2px 8px", background=fg("bg-sider"), border="1px solid var(--color-border-light)", border_radius="var(--radius-sm)")
sched = row("Schedule Cell", I("Date Picker", "Date Picker", "width:200px", Value={"color": fg("text-primary" if ts else "text-placeholder")} if False else (ts or "Select date")) +
(I("Button / Ghost Small", "Clear Button", "", Label="Clear") if ts else ""), gap="8px")
out += div() + td_row(f"Row / {code}", [T("Title", t, 14, fg("text-primary"), 600), chip, I(f"Tag Dot / {tone}", "Status Tag", "", Label=st), sched], SW, pad="12px 16px")
return col("Data Table", out, align_self="stretch", background=fg("bg-elevated"), border="1px solid var(--color-border-light)", border_radius="var(--radius-lg)", overflow="hidden", box_shadow="var(--shadow-sm)")
def schedule_screen(name, route, state="data"):
search = row("Toolbar", I("Search Input", "Search Input", "width:240px", Placeholder="Search by title or code…"), justify_content="flex-end", align_self="stretch", padding="0px 0px 16px")
if state == "data":
body = schedule_table()
elif state == "empty":
body = I("Empty State", "Empty State", "width:1100px", Title="Nothing to schedule", Description="Sessions you create will appear here so you can plan when they run.")
else:
body = row("Loading", I("Spin / Large", "Spin", ""), justify_content="center", padding="var(--spacing-xxl)", align_self="stretch")
return screen(name, route, "Schedule", "Schedule", "Plan when sessions run", col("Schedule", search + body, gap="0px"), wide=True)
def build_schedule():
return section(SECTIONS["schedule"], "Admin / Schedule", [
schedule_screen("Admin Schedule", "/schedule"),
schedule_screen("Admin Schedule Empty", "/schedule (no sessions)", "empty"),
schedule_screen("Admin Schedule Loading", "/schedule (loading)", "loading"),
])
BUILDERS = {"auth": build_auth, "dashboard": build_dashboard, "configuration": build_configuration, "criteria": build_criteria, "schedule": build_schedule}
def main():
fid = next(i["id"] for i in call("list_resources")["items"] if i["name"] == PROJECT)
P = pages(fid)["Admin"]
call("import_components", {"fromPageId": pages(fid)["Design System"].id, "pageId": P.id})
want = sys.argv[1:] or list(BUILDERS)
# remove my old sections first (instances of my own components go with them), then rebuild components
root = P.c("get_children", nodeId="root")["children"]
old = [c["id"] for c in root if c["name"] in SECTIONS.values() and any(SECTIONS[k] == c["name"] for k in (want if want != ['comps'] else []))]
if old:
P.c("delete_nodes", nodeIds=old)
if "comps" in want or len(sys.argv) == 1 or not [c for c in comp_specs() if c[0] in {x["name"] for x in root}]:
build_components(P)
top = start_top(P)
for k in [w for w in want if w in BUILDERS]:
html = BUILDERS[k]()
nid, top = place(P, SECTIONS[k], html, top)
r = P.c("get_node_info", nodeId=nid)
print(k, nid, r.get("width"), r.get("height"))
if __name__ == "__main__":
main()
+267
View File
@@ -0,0 +1,267 @@
"""Candidate page: `Section / Candidate Access` (/, /invite/[code], /invite/[code]/verify) and
`Section / Candidate Session` (/session/[id], /setup, /review). build(P, y0) is called by candidate.py (for "access" and "session")."""
from cand_common import *
# colours of the login split / hero / system-check page that have no token (globals.css, session page)
VARS = {
"color-login-sub": "#ffffffa6", "color-login-icon-bg": "#ffffff12", "color-login-icon-fg": "#ffffff8c", "color-login-desc": "#ffffff99",
"color-login-card": "#ffffff0d", "color-login-card-line": "#ffffff14", "color-login-card-head": "#ffffff99", "color-login-row": "#ffffffc7",
"color-login-footer": "#ffffff8c", "color-hero-chip": "#ffffff33", "color-hero-text": "#ffffffeb", "color-cam-label": "#ffffff99",
"color-cam-hint": "#ffffff99", "color-cam-ring": "#ffffff40",
}
BRAND = fg("brand-primary")
INV = fg("text-inverse")
SUB = fg("text-secondary")
def card(name, kids, pad="var(--spacing-lg)", **st):
return col(name, kids, padding=pad, background=fg("bg-elevated"), border="1px solid var(--color-border-light)", border_radius="var(--radius-lg)",
box_shadow="var(--shadow-sm)", **st)
def page(name, body, bg="var(--color-bg-layout)", direction="row"):
return box(name, body, display="flex", flex_direction=direction, align_items="stretch", width="1440px", height="900px", background=bg, overflow="hidden")
def label(text_, req=False, right=""):
star = T("Required Mark", "*", 14, fg("error")) if req else ""
return row("Label Row", row("Label Group", star + T("Label", text_, 14), gap="4px") + right, justify_content="space-between", height="32px", width="100%")
def field(name, label_, ctrl, req=False, right="", mb="24px"):
return col(name, label(label_, req, right) + ctrl, width="100%", gap="0px", padding=f"0 0 {mb} 0")
def brand_mark(dark=True):
return row("Brand", row("Mark", T("Letter", "H", 14, INV, 800, lh=18), justify_content="center", width="28px", height="28px", border_radius="7px", background=BRAND) +
T("Brand Name", "Hireloop", 16, INV if dark else fg("text-primary"), 700, lh=20), gap="12px")
def alert(msg, w, icon=True):
mw = w - 26 - (24 if icon else 0)
return row("Alert / Error", (ic("x-circle", 16, fg("score-poor"), "Alert Icon") if icon else "") + T("Message", msg, 14, fg("text-primary"), lh=22, width=f"{mw}px"),
gap="8px", width=f"{w}px", padding="8px 12px", background=fg("tag-red-bg"), border="1px solid var(--color-tag-red-bd)", border_radius="var(--radius-md)")
# ------------------------------------------------------------------ / (login split)
FEATURES = [("squares-four", "Team-wide visibility", "Track every candidate across the loop, from screen to offer."),
("brackets-curly", "Live coding & system design", "Watch candidates work in a real shared editor and whiteboard."),
("diamonds-four", "Calibrated scorecards", "A shared rubric keeps every interviewer's ratings comparable.")]
PIPELINE = [("Amara Okafor", "Onsite loop"), ("Mei Lin Tan", "Debrief"), ("Sofia Bianchi", "Offer")]
def marketing():
feats = "".join(row(f"Feature / {t}", row("Feature Icon", ic(i, 16, fg("login-icon-fg"), "Feature Glyph"), justify_content="center", width="32px", height="32px",
border_radius="6px", background=fg("login-icon-bg")) +
col("Feature Text", T("Feature Title", t, 14, INV, 600, lh=18) + T("Feature Desc", d, 12, fg("login-desc"), lh=18), gap="4px", flex_grow="1", width="500px"),
gap="16px", align_items="flex-start") for i, t, d in FEATURES)
prow = "".join(row(f"Row / {n}", row("Name", box("Dot", width="8px", height="8px", border_radius="99px", background=fg("login-live-fg")) + T("Name", n, 14, fg("login-row"), lh=18), gap="8px") +
T("Stage", s, 12, fg("login-desc"), lh=16), justify_content="space-between", padding="8px 0") +
(box("Divider", width="100%", height="1px", background=fg("login-card-line")) if k < 2 else "")
for k, (n, s) in enumerate(PIPELINE))
pipe = col("Pipeline Card", row("Pipeline Header", T("Pipeline Title", "CANDIDATE PIPELINE", 12, fg("login-card-head"), 700, lh=16, letter_spacing="0.7px") +
row("Live Pill", T("Live Label", "Live", 12, fg("login-live-fg"), 600, lh=16), padding="4px 8px", border_radius="99px", background=fg("login-live-bg")),
justify_content="space-between", padding="0 0 8px 0") + prow,
padding="16px", background=fg("login-card"), border="1px solid var(--color-login-card-line)", border_radius="12px", width="100%")
hero = col("Hero", T("Hero Title", "Run better interviews, together.", 30, INV, 800, lh=36, letter_spacing="-0.6px") +
T("Hero Sub", "Structured scorecards, live coding sessions, and calibrated hiring decisions — all in one workspace for your team.", 14, fg("login-sub"), lh=22), gap="16px")
mid = col("Marketing Body", hero + col("Features", feats, gap="20px") + pipe, gap="32px", flex_grow="1", justify_content="center", padding="56px 0 0 0")
return col("Marketing Panel", brand_mark() + mid + T("Footer", "© 2026 Hireloop. All rights reserved.", 12, fg("login-footer"), lh=16),
width="662px", padding="32px 44px 32px 40px", background=fg("login-bg"))
G_LOGO = ('<svg layer-name="Google Logo" width="16" height="16" viewBox="0 0 24 24"><path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>'
'<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>'
'<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>'
'<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/></svg>')
M_LOGO = ('<svg layer-name="Microsoft Logo" width="16" height="16" viewBox="0 0 24 24"><rect x="1" y="1" width="10" height="10" fill="#F25022"/><rect x="13" y="1" width="10" height="10" fill="#7FBA00"/>'
'<rect x="1" y="13" width="10" height="10" fill="#00A4EF"/><rect x="13" y="13" width="10" height="10" fill="#FFB900"/></svg>')
def sso(name, logo):
return row(f"Button / {name}", logo + T("Label", name, 16, lh=24), justify_content="center", gap="8px", height="44px", flex_grow="1", background=fg("bg-elevated"),
border="1px solid var(--color-border)", border_radius="var(--radius-md)")
def login(error=False):
err = alert("Email/password sign-in not yet configured. Use an invite link.", 400, True) if error else ""
err = col("Alert Wrap", err, padding="0 0 20px 0", width="100%") if error else ""
forgot = T("Forgot", "Forgot password?", 14, BRAND)
form = (field("Field / Work email", "Work email", I("Input / Large", "Email", style="width:100%", Value="you@company.com"), True) +
field("Field / Password", "Password", I("Input / Password", "Password", style="width:100%", Value="" if not error else "••••••••••"), True, forgot) +
col("Remember", I("Checkbox / Unchecked", "Remember", Label="Keep me signed in"), padding="0 0 16px 0") +
I("Ant Button / Primary Large", "Sign in", style="width:100%", Label="Sign in"))
divider = row("Divider", box("Line", height="1px", flex_grow="1", background=fg("border-light")) + T("Divider Text", "or continue with", 12, SUB, lh=16) +
box("Line", height="1px", flex_grow="1", background=fg("border-light")), gap="12px", width="100%", padding="16px 0")
links = col("Links", row("Admin Link", T("Text", "Don't have access?", 14, SUB, lh=20) + T("Link", "Ask your workspace admin for an invite", 14, BRAND, lh=20), justify_content="center", gap="4px") +
row("Candidate Link", T("Text", "Interviewing for a role?", 14, SUB, lh=20) + T("Link", "Candidate sign in →", 14, SUB, lh=20), justify_content="center", gap="4px"),
gap="8px", padding="24px 0 0 0", width="100%")
inner = col("Login Form", T("Heading", "Sign in to your workspace", 30, fg("text-primary"), 700, lh=38, letter_spacing="-0.3px") +
col("Sub Wrap", T("Sub", "Enter your credentials to continue to Hireloop.", 14, SUB, lh=22), padding="4px 0 24px 0") + err + form + divider +
row("SSO Row", sso("Google", G_LOGO) + sso("Microsoft", M_LOGO), gap="12px", width="100%") + links, width="400px")
right = row("Form Panel", inner, justify_content="center", flex_grow="1", background=fg("bg-elevated"), padding="48px 40px")
return page("Screen / Login" + (" Error" if error else ""), marketing() + right)
# ------------------------------------------------------------------ /invite/[code]
def public(name, kids):
c = col("Card", kids, width="384px", padding="24px", background=fg("bg-elevated"), border="1px solid var(--color-border-light)", border_radius="var(--radius-md)")
return page(name, row("Main", c, justify_content="center", width="100%", padding="48px"))
def h3(t):
return col("Heading Wrap", T("Heading", t, 24, fg("text-primary"), 600, lh=32), padding="0 0 12px 0")
def para(t, secondary=False):
return col("Para Wrap", T("Paragraph", t, 14, SUB if secondary else fg("text-primary"), lh=22), padding="0 0 16px 0")
def invite(state):
disabled = state != "ready"
head = h3("Join interview")
head += {"ready": para("Senior Frontend Engineer — technical interview"), "loading": para("Loading…", True), "error": ""}[state]
ctrl = I("Input / Disabled" if disabled else "Input", "Email", style="width:100%", Value="")
btn = I("Ant Button / Primary", "Send code", style="width:100%" + (";opacity:.5" if disabled else ""), Label="Send code")
err = col("Alert Wrap", alert("Invite not found or expired", 336, True), padding="16px 0 0 0", width="100%") if state == "error" else ""
name = {"ready": "Invite", "loading": "Invite Loading", "error": "Invite Error"}[state]
return public(f"Screen / {name}", head + field("Field / Email", "Email", ctrl, True) + btn + err)
def verify(error=False):
err = col("Alert Wrap", alert("Invalid code", 336, False), padding="0 0 16px 0", width="100%") if error else ""
ctrl = I("Input / Error" if error else "Input", "Code", style="width:100%", Value="482913" if error else "")
return public("Screen / Verify" + (" Error" if error else ""), h3("Enter your code") + para("We sent a 6-digit code to aisha.khan@example.com.") + err +
field("Field / Code", "6-digit code", ctrl, True) + I("Ant Button / Primary Large", "Verify", style="width:100%", Label="Verify"))
# ------------------------------------------------------------------ /session/[id]
def hero():
chip = row("Up Next Chip", T("Chip Label", "UP NEXT", 12, INV, 600, lh=16), padding="4px 12px", border_radius="99px", background=fg("hero-chip"))
top = col("Hero Head", row("Hero Meta", chip + T("Meta", "· Algorithm challenge", 12, fg("hero-text"), lh=16), gap="8px") +
T("Hero Title", "Algorithm interview", 22, INV, 700, lh=28) + T("Hero Sub", "Build a debounced SearchBox", 14, fg("hero-text"), lh=22),
gap="8px", padding="24px", width="100%", background_color="var(--color-tag-blue-fg)", background_image="linear-gradient(135deg, var(--color-brand-primary), var(--color-tag-blue-fg))")
meta = "".join(col(f"Meta / {k}", T("Key", k.upper(), 12, SUB, lh=16, letter_spacing="0.5px") + T("Value", v, 14, fg("text-primary"), 600, lh=22), gap="4px")
for k, v in [("Scheduled", "Mar 14, 2026, 10:00 AM"), ("Language", "TypeScript"), ("Editor", "Run + tests built in"), ("Format", "Coding + theory")])
body = col("Hero Body", T("Intro", "You'll solve a live coding problem in a shared editor while your interviewer watches. Run tests as often as you like before submitting.", 14, SUB, lh=22) +
row("Meta Row", meta, gap="20px", width="100%") +
row("Actions", I("Button / Primary Large", "Enter session", Label="Enter session") + I("Button / Default Large", "Practice", Label="Practice problem"), gap="12px"),
gap="20px", padding="24px", width="100%")
return col("Card / Up next", top + body, width="574px", background=fg("bg-elevated"), border="1px solid var(--color-border-light)", border_radius="var(--radius-lg)",
box_shadow="var(--shadow-sm)", overflow="hidden")
def syscheck_card():
return card("Card / System check", T("Title", "System check", 16, fg("text-primary"), 600, lh=24) +
col("Desc Wrap", T("Desc", "We'll verify your setup before you start.", 12, SUB, lh=16), padding="8px 0 12px 0") +
I("Check Item / Ready", "Browser", Label="Browser compatible") + I("Check Item / Ready", "Mic", Label="Microphone access") + I("Check Item / Ready", "Camera", Label="Camera access"),
width="382px")
def home_body(state):
if state == "loading":
return I("Skeleton", "Skeleton", style="width:100%")
if state == "error":
return I("Empty / Simple", "Empty", style="width:100%", Description="Session not found")
greet = col("Greeting", T("Welcome", "Welcome back, Aisha", 26, fg("text-primary"), 700, lh=32, letter_spacing="-0.26px") +
row("Who", T("Strong", "Candidate", 14, fg("text-primary"), 600, lh=22) + T("Rest", "· Session af17f53a", 14, SUB, lh=22), gap="8px"), gap="4px")
tips = row("Tips", "".join(I(f"Tip Card / {t}", t) for t in ["Read carefully", "Think aloud", "Manage time"]), gap="16px")
return (greet + row("Two Col", hero() + syscheck_card(), gap="24px", align_items="flex-start") + I("Progress Tracker / System check", "Progress") + tips)
def home(state="ready"):
name = {"ready": "Session Home", "loading": "Session Home Loading", "error": "Session Home Error"}[state]
content = row("Page Center", col("Page", home_body(state), width="980px", gap="24px"), justify_content="center", align_items="flex-start", width="100%")
return shell(f"Screen / {name}", "Home", "Aisha Khan", "Session af17f53a", content)
# ------------------------------------------------------------------ /session/[id]/setup
CHECKS = [("Browser compatibility", "Chrome, Edge, or Firefox — up to date"), ("Camera access", "Required for onsite & behavioral rounds"),
("Microphone access", "Required so interviewers can hear you"), ("Network speed", "At least 5 Mbps recommended")]
def setup_screen(name, states, mic):
hdr = row("Header", brand_mark(False) + row("Steps", I("Syscheck Step / Done", "Step 1") + box("Sep", width="24px", height="1px", background=fg("border")) + I("Syscheck Step / Active", "Step 2"), gap="12px"),
justify_content="space-between", width="100%", height="56px", padding="0 32px", background=fg("bg-elevated")) + box("Header Line", width="100%", height="1px", background=fg("border-light"))
cam = col("Camera Preview", T("Preview Label", "preview", 12, fg("cam-label"), lh=16, position="absolute", left="12px", top="10px") +
col("Placeholder", row("Cam Icon", ic("camera", 18, fg("cam-hint"), "Camera Off"), justify_content="center", width="36px", height="36px", border_radius="99px", border="2px solid var(--color-cam-ring)") +
T("Hint", "camera preview", 12, fg("cam-hint"), lh=16), align_items="center", gap="8px"),
position="relative", align_items="center", justify_content="center", width="300px", height="225px", border_radius="var(--radius-md)", background=fg("syscheck-camera"), overflow="hidden")
miclevel = row("Mic Row", T("Mic Label", "Mic level", 12, SUB, lh=16) + row("Mic Bar", box("Mic Fill", width=f"{int(2.2 * mic)}px", height="6px", border_radius="99px", background=fg("syscheck-mic")),
flex_grow="1", height="6px", border_radius="99px", background=fg("border-light"), overflow="hidden"), gap="12px", width="100%")
cam_col = col("Camera Col", cam + miclevel, width="340px", align_self="stretch", padding="20px", gap="16px")
rows = "".join(I(f"Syscheck Row / {s}", f"Check / {t}", Title=t, Desc=d) for s, (t, d) in zip(states, CHECKS))
checks = col("Checks Col", rows, width="400px", padding="20px", gap="0px")
sc = row("Check Card", cam_col + box("Col Line", width="1px", align_self="stretch", background=fg("border-light")) + checks, width="740px", background=fg("bg-elevated"), border_radius="var(--radius-lg)", box_shadow="var(--shadow-sm)", overflow="hidden", align_items="stretch")
back = row("Back Button", T("Label", "Back", 14, fg("text-primary"), lh=22), padding="8px 24px", background=fg("bg-elevated"), border="1px solid var(--color-border)", border_radius="6px")
main = col("Main", T("Title", "Let's check your setup", 22, fg("text-primary"), 700, lh=28, text_align="center") +
col("Sub Wrap", T("Sub", "We'll verify your browser, camera, microphone, and connection before your interview begins.", 14, SUB, lh=22, text_align="center", width="480px"), padding="8px 0 28px 0") +
sc + col("Back Wrap", back, padding="28px 0 0 0"), align_items="center", justify_content="center", flex_grow="1", padding="40px 24px")
return page(name, hdr + main, "var(--color-syscheck-bg)", "column")
# ------------------------------------------------------------------ /session/[id]/review
ROWS = [("3f2a9c1e-7b4d-4e0a-9c55-1d8e2b6f40a7", "Graded", "", "2026-03-14T10:42:17Z"),
("a91c5d02-34be-4f7a-8e11-6c2d90b3e5f4", "Errored", "Runner timed out after 30s", "2026-03-14T10:58:03Z"),
("c7e48b36-0d15-42a9-b3f8-95a1e7d20c68", "Running", "", "2026-03-14T11:05:49Z")]
LINE = '<tr layer-name="Divider"><td style="padding:0;height:1px;background:var(--color-border-light)"></td></tr>'
def cell(tag, inner, w=None, **extra):
st = dict(padding="16px", border="none")
if w:
st["width"] = w
st.update(extra)
return f'<{tag} style="{css(**st)}">{inner}</{tag}>'
def review(state="ready"):
hd = dict(font_size="var(--font-size-sm)", font_weight="600", color="var(--color-text-primary)", background_color="var(--color-bg-sider)")
head = '<tr layer-name="Row / Header">' + cell("th", "Challenge", **hd) + cell("th", "Status", "160px", **hd) + cell("th", "Error", "240px", **hd) + cell("th", "Submitted at", "200px", **hd) + "</tr>" + LINE
body = ""
if state == "ready":
for cid, st, e, at in ROWS:
err = T("Error", e, 14, fg("error"), lh=22) if e else T("Error", "—", 14, lh=22)
body += (f'<tr layer-name="Row / {cid[:8]}">' + cell("td", T("Challenge Id", cid, 14, lh=22)) + cell("td", I(f"Submission Status Badge / {st}", "Status"), "160px") +
cell("td", err, "240px") + cell("td", T("Submitted", at, 14, lh=22), "200px") + "</tr>" + LINE)
tbl = f'<table layer-name="Table" style="width:100%">{head}{body}</table>'
if state == "empty":
tbl += col("Empty Wrap", I("Empty / Simple", "Empty", style="width:100%", Description="No data"), width="100%", padding="24px 0")
pager = row("Pagination", row("Page 1", T("Page Number", "1", 14, BRAND, lh=22), justify_content="center", width="32px", height="32px", border="1px solid var(--color-brand-primary)", border_radius="var(--radius-md)"),
justify_content="flex-end", width="100%", padding="16px 0 0 0") if state == "ready" else ""
err = col("Alert Wrap", alert("Failed to load submissions: network request failed", 1094, True), padding="0 0 16px 0", width="100%") if state == "error" else ""
headbar = row("Card Head", T("Title", "Review", 24, fg("text-primary"), 600, lh=32) + T("Back Link", "Back to session", 14, BRAND, lh=22), justify_content="space-between", width="100%",
padding="16px 24px") + box("Head Line", width="100%", height="1px", background=fg("border-light"))
inner = col("Card Body", err + col("Table Wrap", tbl + pager, width="100%"), padding="24px", width="100%")
c = col("Card", headbar + inner, width="100%", background=fg("bg-elevated"), border="1px solid var(--color-border-light)", border_radius="var(--radius-md)")
name = {"ready": "Review", "empty": "Review Empty", "error": "Review Error"}[state]
return shell(f"Screen / {name}", "", "Review", "Your submissions", c)
# ------------------------------------------------------------------ sections
def section(P, name, title, screens, y, per_row=4):
blocks = [col(f"Block / {c}", T("Caption", f"{c} · {r}", 14, SUB, 600) + h, gap="8px") for c, r, h in screens]
rows = "".join(row(f"Screens Row {i // per_row + 1}", "".join(blocks[i:i + per_row]), gap="var(--spacing-xl)", align_items="flex-start") for i in range(0, len(blocks), per_row))
sec = col(name, T("Section Title", title, 24, weight=700) + col("Screens", rows, gap="var(--spacing-xl)"), gap="var(--spacing-xl)", padding="var(--spacing-xl)", background="transparent")
nid = write(P, "root", sec)[0]
place(P, nid, 0, y)
return nid
def build(P, y0):
call("set_variables", {"fileId": P.file, "variables": VARS})
print("deleted", delete_named(P, lambda n: n in ("Section / Candidate Access", "Section / Candidate Session")))
a = section(P, "Section / Candidate Access", "Candidate / Access", [
("Login", "/", login()), ("Login error", "/", login(True)),
("Invite", "/invite/[code]", invite("ready")), ("Invite loading", "/invite/[code]", invite("loading")),
("Invite error", "/invite/[code]", invite("error")), ("Verify", "/invite/[code]/verify", verify()), ("Verify error", "/invite/[code]/verify", verify(True))], y0)
h = next(c["height"] for c in top_children(P) if c["id"] == a)
s = section(P, "Section / Candidate Session", "Candidate / Session", [
("Session home", "/session/[id]", home()), ("Session home loading", "/session/[id]", home("loading")), ("Session home error", "/session/[id]", home("error")),
("System check ready", "/session/[id]/setup", setup_screen("Screen / System Check Ready", ["Ready"] * 4, 60)),
("System check checking", "/session/[id]/setup", setup_screen("Screen / System Check Checking", ["Ready", "Checking", "Checking", "Pending"], 0)),
("System check failed", "/session/[id]/setup", setup_screen("Screen / System Check Failed", ["Ready", "Failed", "Failed", "Failed"], 0)),
("Review", "/session/[id]/review", review()), ("Review empty", "/session/[id]/review", review("empty")), ("Review error", "/session/[id]/review", review("error"))], int(y0 + h + 200))
return {"access": a, "session": s}
+186
View File
@@ -0,0 +1,186 @@
"""Section / Candidate Challenge: /session/[id]/challenge/[challengeId] (IDE, theory phase, submit form). build(P, y0) -> section node id."""
import cand_components as cc
from cand_common import *
D = cc.D
IDE_BG = "var(--color-code-bg)" # #1e1e1e
CENTER_W = 666 # code tab centre column (1192 - 210 - 316)
WIDE_W = 876 # diagrams / whiteboard centre column (1192 - 316)
BODY_H = 848
LOAD = "Loading challenge"
def frame(name, content):
"""Candidate shell, full bleed: sidebar + dark IDE area."""
sb = I("Candidate Sidebar / My interview Active", "Sidebar", style="height:900px")
return sb + col("Main", content, width=f"{MAIN_W}px", height="900px", background=IDE_BG)
def scr(name, content, **st):
return screen(name, frame(name, content), position="relative", **st)
def rail(collapsed=False):
return I("Problem Rail / Collapsed" if collapsed else "Problem Rail / Brief", "Problem Rail")
def code_screen(name, console, toolbar="IDE Toolbar / Code", toast=False, file="SearchBox", extra=""):
src = {"SearchBox": ("Code Editor / SearchBox", "Editor Tabs / SearchBox Active", "File Tree / SearchBox Active"),
"Debounce": ("Code Editor / useDebounce", "Editor Tabs / Debounce Active", "File Tree / Test Active")}[file]
centre = col("Editor Column", I(src[1], "Editor Tabs") + I(src[0], "Code Editor") + box("Resize Handle", width="100%", height="6px", background=D("deep")) + I(console, "Console"),
width=f"{CENTER_W}px", height=f"{BODY_H}px")
body = row("Body", I(src[2], "File Tree") + centre + rail(), height=f"{BODY_H}px", align_items="stretch")
toast_html = I("Message / Success", "Toast", style="position:absolute;left:626px;top:16px") if toast else ""
return scr(name, I(toolbar, "Toolbar") + body + toast_html + extra)
# --- diagrams
MERMAID = ["graph TD", " Client -->|HTTPS| LB[Load Balancer]", " LB --> API1[API Server 1]", " LB --> API2[API Server 2]", " API1 <--> Cache[(Redis Cache)]",
" API2 <--> Cache", " API1 --> DB[(PostgreSQL Primary)]", " API2 --> DB", " DB -.->|replication| Replica[(Read Replica)]",
" API1 --> Queue([Message Queue])", " Queue --> Worker[Background Worker]"]
def dnode(label):
return row(f"Node / {label}", T("Label", label, 12, D("text"), lh=16), padding="8px 16px", background=D("panel"), border=f"1px solid {D('text-faint')}", border_radius="var(--radius-sm)")
def darrow(label=""):
return row("Edge", ic("arrow-down", 14, D("text-muted"), "Edge Icon") + (T("Edge Label", label, 12, D("text-muted"), lh=16) if label else ""), gap="4px")
def diagram_body(tpl="System design"):
toolbar = I(f"Diagram Toolbar / {tpl}", "Diagram Toolbar", style=f"width:{WIDE_W}px")
src = "".join(T(f"Src {i + 1}", l.replace(" ", "&nbsp;"), 13, fg("code-fg"), lh=23) for i, l in enumerate(MERMAID))
left = col("Mermaid Source", T("Pane Label", "MERMAID", 12, D("text-muted"), letter_spacing="0.6px", position="absolute", right="10px", top="8px") + src,
width="50%", height="100%", padding="16px", background=D("input"), border_right=f"1px solid {D('deep')}", position="relative", gap="0px")
graph = col("Rendered Graph", dnode("Client") + darrow("HTTPS") + dnode("Load Balancer") + darrow() +
row("API Row", dnode("API Server 1") + dnode("API Server 2"), gap="32px") + darrow() +
row("Store Row", dnode("Redis Cache") + dnode("PostgreSQL Primary") + dnode("Message Queue"), gap="16px") + darrow("replication") +
row("Leaf Row", dnode("Read Replica") + dnode("Background Worker"), gap="32px"), align_items="center", gap="4px")
right = row("Rendered Preview", graph, width="50%", height="100%", justify_content="center", align_items="flex-start", padding="28px 20px", background=fg("diagram-bg"))
panes = row("Panes", left + right, width=f"{WIDE_W}px", height=f"{BODY_H - 41}px", align_items="stretch")
return row("Body", col("Diagram Editor", toolbar + panes, width=f"{WIDE_W}px", height=f"{BODY_H}px") + rail(), height=f"{BODY_H}px", align_items="stretch")
# --- whiteboard
def shape(name, x, y, w, h, color, label, r=4):
return box(f"Shape / {name}", T("Text", label, 14, color, text_align="center", width=f"{w}px"), position="absolute", left=f"{x}px", top=f"{y}px", width=f"{w}px", height=f"{h}px",
border=f"2px solid {color}", border_radius=f"{r}px", display="flex", align_items="center", justify_content="center")
def whiteboard_body(tool="Rectangle", selected=False):
W, B, G, R, O = fg("wb-white"), fg("wb-blue"), fg("ide-pass"), fg("ide-fail"), fg("wb-orange")
ar = lambda n, i, c, x, y: ic(i, 40, c, n).replace("<x-paper-icon", f'<x-paper-icon style="position:absolute;left:{x}px;top:{y}px"')
shapes = (shape("Client", 60, 70, 140, 64, W, "Client") + shape("API", 330, 70, 160, 64, B, "API Gateway") + shape("Auth", 330, 250, 160, 64, G, "Auth Service") +
shape("DB", 620, 70, 160, 64, O, "Postgres", 32) + shape("Cache", 620, 250, 160, 64, R, "Redis", 32) +
ar("Arrow / Client to API", "arrow-right", W, 220, 82) + ar("Arrow / API to DB", "arrow-right", B, 510, 82) + ar("Arrow / API to Auth", "arrow-down", B, 390, 160) +
ar("Arrow / Auth to Cache", "arrow-right", G, 510, 262) + T("Note", "cache sessions, TTL 15m", 15, fg("wb-purple"), position="absolute", left="610px", top="340px"))
canvas = box("Canvas", shapes, position="relative", width=f"{WIDE_W}px", height=f"{BODY_H - 51}px", background=fg("wb-bg"), overflow="hidden")
tb = I(f"Whiteboard Toolbar / {'Select' if selected else tool}", "Whiteboard Toolbar", style=f"width:{WIDE_W}px")
return row("Body", col("Whiteboard", tb + canvas, width=f"{WIDE_W}px", height=f"{BODY_H}px") + rail(), height=f"{BODY_H}px", align_items="stretch")
# --- theory phase
Q = ["Tell me about a time you disagreed with a teammate on a technical decision. How did you resolve it?",
"How do you prioritise when three stakeholders each say their request is urgent?"]
def theory_body(q, cat, diff, qn, n, answer, words, last=False):
chips = row("Chips", I(f"Theory Chip / {cat}", "Category") + I(f"Theory Chip / {diff}", "Difficulty") + box("Spacer", flex_grow="1") +
T("Counter", f"Question {qn} of {n}", 12, D("text-muted"), lh=16), gap="8px", width="100%")
prev = I("Theory Button / Prev Disabled" if qn == 1 else "Theory Button / Prev", "Prev")
left = row("Left", prev + (T("Word Count", f"{words} words", 12, D("text-muted"), lh=16) if words else ""), gap="8px")
if last:
right = col("Submit Group", I("Theory Button / Submit Partial", "Submit", Label="Submit (3/5 answered)") +
T("Hint", "Unanswered questions will be marked as skipped.", 12, D("text-muted"), lh=16), align_items="flex-end", gap="8px")
else:
right = I("Theory Button / Next", "Next")
page = col("Question Page", chips + col("Question Wrap", T("Question", q, 19, fg("text-inverse"), 600, lh=28, width="704px"), padding="20px 0 28px") +
I("Theory Answer / Filled" if answer else "Theory Answer / Empty", "Answer") +
row("Footer", left + right, justify_content="space-between", width="704px", padding="16px 0 28px"), width="760px", padding="40px 28px 0", align_items="flex-start")
return row("Body", page, justify_content="center", align_items="flex-start", width="100%", flex_grow="1")
# --- states and form
def skeleton():
bar = lambda i, w: box(f"Skeleton Bar {i}", width=f"{w}px", height="16px", border_radius="var(--radius-sm)", background=D("line"))
return col("Loading", bar(1, 320) + bar(2, 880) + bar(3, 760) + bar(4, 840) + bar(5, 560) + bar(6, 880) + bar(7, 640) + bar(8, 420), padding="24px", gap="16px", width="100%", height="100%")
def error_state():
return col("Error", T("Error Message", "Failed to load challenge", 14, fg("ide-fail")), padding="24px", width="100%", height="100%")
def form_body(inner):
return col("Form Page", inner, padding="24px", width=f"{MAIN_W}px", height="100%", background=fg("bg-layout"), gap="16px")
def form_screen(name, inner):
sb = I("Candidate Sidebar / My interview Active", "Sidebar", style="height:900px")
return screen(name, sb + col("Main", form_body(inner), width=f"{MAIN_W}px", height="900px", background=fg("bg-layout")))
def mcq_form(error=None):
qs = [("1. Which HTTP status code means the request succeeded and created a resource?", ["200 OK", "201 Created", "204 No Content", "302 Found"], 1),
("2. What does the CAP theorem say about a network partition?", ["You must choose consistency or availability", "You can have all three guarantees",
"Partitions never happen in practice", "Only availability is affected"], 0)]
items = ""
for qi, (q, opts, sel) in enumerate(qs, 1):
rad = "".join(I("Radio / Selected" if i == sel else "Radio / Unselected", f"Option / {o}", Label=o) for i, o in enumerate(opts))
items += col(f"Form Item / Q{qi}", T("Label", q, 14, fg("text-primary"), lh=32) + col("Options", rad, gap="8px"), gap="4px", padding="0 0 24px")
err = row("Alert", ic("x-circle", 16, fg("score-poor"), "Alert Icon") + T("Message", error, 14, fg("text-primary"), flex_grow="1"), gap="8px", width="720px", padding="8px 12px",
background=fg("tag-red-bg"), border="1px solid var(--color-tag-red-bd)", border_radius="var(--radius-md)") if error else ""
return col("ChallengeSubmitForm", err + col("Form", items + I("Ant Button / Primary", "Submit Button", Label="Submit"), gap="0px"), width="720px", gap="16px")
def text_form():
return col("ChallengeSubmitForm", col("Form Item", T("Label", "Your answer", 14, fg("text-primary"), lh=32) +
row("Textarea", T("Value", "I would split the monolith along its billing and catalogue boundaries first, because those two teams already deploy independently.", 14, fg("text-primary"), flex_grow="1"),
align_items="flex-start", width="720px", height="260px", padding="4px 12px", background=fg("bg-elevated"), border="1px solid var(--color-border)", border_radius="var(--radius-md)"),
gap="0px", padding="0 0 24px") + I("Ant Button / Primary", "Submit Button", Label="Submit"), width="720px")
def status_strip():
tags = "".join(I(f"Submission Status Badge / {s}", f"Badge / {s}") for s in ["Queued", "Running", "Graded", "Errored", "Unknown"])
return col("SubmissionStatusBadge", T("Title", "Submission status", 16, weight=600) + row("Badges", tags, gap="8px"), padding="var(--spacing-lg)", gap="12px",
background=fg("bg-elevated"), border="1px solid var(--color-border-light)", border_radius="var(--radius-lg)", width="720px")
def build(P, y0):
name = "Section / Candidate Challenge"
print("deleted", delete_named(P, lambda n: n == name))
code_idle = code_screen("Screen / Challenge Code Idle", "Console / Empty")
sets = [
("Code: idle", "/session/[id]/challenge/[challengeId]", code_idle),
("Code: running tests", "state: Run clicked, console streaming", code_screen("Screen / Challenge Code Running", "Console / Running", file="Debounce")),
("Code: tests passed", "state: Run finished", code_screen("Screen / Challenge Code Passed", "Console / Passed")),
("Code: tests failed, low time", "state: timer under 5 minutes", code_screen("Screen / Challenge Code Failed", "Console / Failed", "IDE Toolbar / Code Low Time")),
("Code: submitted", "state: Submit clicked, then redirect to /session/[id]/review", code_screen("Screen / Challenge Code Submitted", "Console / Passed", toast=True)),
("Diagrams tab", "tab: Diagrams", scr("Screen / Challenge Diagrams", I("IDE Toolbar / Diagrams", "Toolbar") + diagram_body())),
("Whiteboard tab", "tab: Whiteboard", scr("Screen / Challenge Whiteboard", I("IDE Toolbar / Whiteboard", "Toolbar") + whiteboard_body())),
("Whiteboard: shape selected", "tool: Select", scr("Screen / Challenge Whiteboard Selected", I("IDE Toolbar / Whiteboard", "Toolbar") + whiteboard_body(selected=True))),
("Theory: answered", "type: THEORY", scr("Screen / Challenge Theory", I("Theory Toolbar / Q2 of 5", "Toolbar") +
theory_body(Q[0], "Category", "Medium", 2, 5, True, 38))),
("Theory: last question", "type: THEORY, submit", scr("Screen / Challenge Theory Submit", I("Theory Toolbar / Q5 of 5", "Toolbar") +
theory_body(Q[1], "Category", "Hard", 5, 5, False, 0, last=True))),
("Loading", "state: loading", scr("Screen / Challenge Loading", skeleton())),
("Load error", "state: error", scr("Screen / Challenge Error", error_state())),
("MCQ form", "type: MCQ (ChallengeSubmitForm)", form_screen("Screen / Challenge MCQ", mcq_form())),
("MCQ form: submit error", "state: submit failed", form_screen("Screen / Challenge MCQ Error", mcq_form("Submit failed"))),
("Text form + status badges", "type: text (ChallengeSubmitForm), SubmissionStatusBadge", form_screen("Screen / Challenge Text", text_form() + status_strip())),
]
rows = ""
for i in range(0, len(sets), 2):
blocks = "".join(col(f"Block / {c}", T("Caption", f"{c} · {r}", 14, fg("text-secondary"), 600) + html, gap="8px") for c, r, html in sets[i:i + 2])
rows += row(f"Row {i // 2 + 1}", blocks, gap="var(--spacing-xl)", align_items="flex-start")
sec = col(name, T("Section Title", "Candidate Challenge", 24, weight=700) + col("Screens", rows, gap="var(--spacing-xl)"), gap="var(--spacing-xl)",
padding="var(--spacing-xl)", background="transparent")
nid = write(P, "root", sec)[0]
place(P, nid, 0, y0)
return nid
if __name__ == "__main__":
fid, P, DS = setup_cand()
y0 = int(max(c.get("y", 0) + c["height"] for c in top_children(P) if not c["name"].startswith("Section / Candidate"))) + 200
print(build(P, y0))
@@ -0,0 +1,13 @@
"""Screenshot each Candidate Challenge screen: python3 cand_challenge_shots.py [filter]"""
import sys
from cand_common import *
fid, P, DS = setup_cand()
def walk(nid, out):
for c in P.c("get_children", nodeId=nid)["children"]:
if c["name"].startswith("Screen / Challenge"): out[c["name"]] = c["id"]
elif c["name"].startswith(("Section /", "Screens", "Row ", "Block /")): walk(c["id"], out)
return out
sec = next(c["id"] for c in top_children(P) if c["name"] == "Section / Candidate Challenge")
for n, i in walk(sec, {}).items():
if len(sys.argv) < 2 or sys.argv[1] in n:
print(shot(P, i, n.replace(" / ", "_").replace(" ", "_")))
+100
View File
@@ -0,0 +1,100 @@
"""Shared helpers for the Candidate page scripts (candidate.py runs cand_components, cand_access, cand_session, cand_challenge)."""
import json, sys
sys.path.insert(0, "/Volumes/Storage/Projects/papercraft/examples/interview")
from ds_common import * # noqa: F401,F403 (pc helpers, T, ic, PROJECT)
OUT = "/private/tmp/claude-501/-Volumes-Storage-Projects-photocraft/11f4040a-b4f4-4b9e-965a-bde08b400450/scratchpad/"
SIDEBAR_W, MAIN_W = 248, 1192
# Colors that exist in the candidate app code but have no token in tokens.css (IDE dark theme, login split, system check).
CAND_VARS = {
# IDE dark theme (apps/web/src/components/ide/*)
"color-ide-panel": "#252526", "color-ide-deep": "#1a1a1a", "color-ide-console": "#181818", "color-ide-input": "#161616",
"color-ide-line": "#2a2a2a", "color-ide-active": "#2a2d2e", "color-ide-rail": "#1f1f1f",
"color-ide-text": "#cccccc", "color-ide-text-muted": "#888888", "color-ide-text-faint": "#666666", "color-ide-text-dim": "#bbbbbb",
"color-ide-pass": "#3fb950", "color-ide-fail": "#f85149", "color-ide-warn": "#d29922",
"color-ide-pass-bg": "#1a3a1a", "color-ide-warn-bg": "#2a2a14", "color-ide-fail-bg": "#3a1a1a", "color-ide-teal-bg": "#1a3540", "color-ide-blue-bg": "#1a2a3a",
"color-ide-submit-bg": "#1c2a40", "color-ide-submit-fg": "#5a80a8",
"color-syntax-comment": "#6a9955", "color-syntax-string": "#ce9178", "color-syntax-keyword": "#569cd6", "color-syntax-type": "#4ec9b0",
"color-syntax-number": "#b5cea8", "color-syntax-fn": "#dcdcaa", "color-syntax-gutter": "#5a5a5a",
"color-wb-bg": "#13131f", "color-wb-toolbar": "#1c1c2e", "color-wb-border": "#252540", "color-wb-button": "#252540", "color-wb-text": "#9999bb",
"color-diagram-bg": "#0d1117", "color-ide-css": "#c586c0", "color-ide-pass-border": "#3fb95035", "color-ide-chip": "#252525", "color-ide-sep": "#333333",
"color-wb-white": "#e0e0e0", "color-wb-blue": "#6ab0ff", "color-wb-purple": "#c792ea", "color-wb-orange": "#ff9f43", "color-ide-fail-border": "#5a2020",
# login split (globals.css .login-*)
"color-login-bg": "#0f1117", "color-login-live-bg": "#16a34a2e", "color-login-live-fg": "#4ade80",
# system check page (globals.css .syscheck-*)
"color-syscheck-bg": "#f0f0f0", "color-syscheck-ready": "#16a34a", "color-syscheck-ready-bg": "#16a34a1a", "color-syscheck-fail": "#dc2626",
"color-syscheck-camera": "#111111", "color-syscheck-mic": "#22c55e",
}
def fg(c):
return f"var(--color-{c})"
def setup_cand():
fid = next(i["id"] for i in call("list_resources")["items"] if i["name"] == PROJECT)
ps = pages(fid)
call("set_variables", {"fileId": fid, "variables": CAND_VARS})
return fid, ps["Candidate"], ps["Design System"]
def I(comp, name=None, style=None, **ov):
"""Instance html: I("Button / Primary", Label="Save", style="width:100%")."""
a = f' layer-name="{name}"' if name else ""
s = f' style="{style}"' if style else ""
o = json.dumps(ov, ensure_ascii=False).replace("'", "&#39;")
return f"<x-paper-instance component=\"{comp}\"{a}{s} overrides='{o}'/>"
def write(P, parent, html):
r = P.c("write_html", targetNodeId=parent, mode="insert-children", html=html)
for w in r.get("warnings", []):
print(" warn:", w)
if r.get("ignoredStyles"):
print(" ignored:", sorted(set(map(str, r["ignoredStyles"])))[:8])
return r.get("createdNodeIds", [])
def top_children(P):
return P.c("get_children", nodeId="root")["children"]
def delete_named(P, pred):
ids = [c["id"] for c in top_children(P) if pred(c["name"])]
if ids:
P.c("delete_nodes", nodeIds=ids)
return len(ids)
def place(P, node, x, y):
P.c("update_styles", updates=[{"nodeIds": [node], "styles": {"left": x, "top": y}}])
def make_section(P, name, title, screens, x, y):
"""screens: [(caption, route, screen html)] -> one `Section / ...` frame holding captioned screens in a row."""
blocks = "".join(col(f"Block / {c}", T("Caption", f"{c} · {r}", 14, fg("text-secondary"), 600) + html, gap="8px") for c, r, html in screens)
sec = col(name, T("Section Title", title, 24, weight=700) + row("Screens", blocks, gap="var(--spacing-xl)", align_items="flex-start"),
gap="var(--spacing-xl)", padding="var(--spacing-xl)", background="transparent")
nid = write(P, "root", sec)[0]
place(P, nid, x, y)
return nid
def screen(name, body, w=1440, h=900, **st):
return row(name, body, width=f"{w}px", height=f"{h}px", background="var(--color-bg-layout)", overflow="hidden", align_items="stretch", **st)
def shell(name, active, title, subtitle, content, sidebar_name=None):
"""Candidate shell screen: sidebar instance + header instance + padded content."""
sb = I(f"Candidate Sidebar / {active} Active", "Sidebar", style="height:900px") if active else I("Candidate Sidebar", "Sidebar")
main = col("Main", I("Candidate Shell Header", "Header", style="height:134px", Title=title, Subtitle=subtitle) +
col("Content", content, padding="var(--page-padding)", gap="var(--spacing-lg)", flex_grow="1", overflow="hidden"),
width=f"{MAIN_W}px", background="var(--color-bg-layout)")
return screen(name, sb + main)
def shot(P, node, name, scale=1):
P.screenshot(OUT + name + ".png", node, scale)
return OUT + name + ".png"
+370
View File
@@ -0,0 +1,370 @@
"""Main components of the Candidate page (IDE pieces, system-check rows, session cards). Names: `Group / Variant`."""
import html as _h
import re
from cand_common import *
SPECS, GROUPS, NOTES = [], {}, {}
def reg(group, name, html, note=""):
SPECS.append((name, html))
GROUPS.setdefault(group, []).append(name)
NOTES[name] = note
def D(c):
return f"var(--color-ide-{c})"
BRAND = fg("brand-primary")
TOOLBAR_W = MAIN_W
CENTER_W = 666 # 1192 - 210 file tree - 316 rail
BODY_H = 848 # 900 - 52 toolbar
# ------------------------------------------------------------------ IDE toolbar
def tb_tab(icon, label, active):
c = fg("text-inverse") if active else D("text-muted")
return col(f"Tab / {label}", row("Tab Body", ic(icon, 14, c, "Tab Icon") + T("Label", label, 14, c, 600 if active else 400), height="50px", padding="0 18px", gap="8px") +
box("Ink Bar", height="2px", background=BRAND if active else "transparent"))
def timer(txt="44:12", low=False):
c = D("fail") if low else D("text")
return row("Timer Pill", ic("clock", 14, c, "Timer Icon") + T("Timer", txt, 14, c, 700), gap="8px", padding="5px 12px", background=D("deep"), border_radius="6px",
border=f"1px solid {D('fail-border') if low else D('line')}")
def toolbar(name, active, low=False, timer_txt="44:12"):
left = (row("Exit", ic("arrow-left", 14, D("text-faint"), "Exit Icon") + T("Exit Label", "Exit", 14, D("text-faint")), gap="6px", padding="0 12px 0 0") +
box("Divider", width="1px", height="20px", background=D("sep")) + box("Spacer", width="16px"))
tabs = "".join(tb_tab(i, l, l == active) for i, l in [("code", "Code"), ("flow-arrow", "Diagrams"), ("selection", "Whiteboard")])
right = timer(timer_txt, low)
if active == "Code":
right += box("Gap", width="8px") + I("Button / Dark Small", "Run Button", Label="Run") + box("Gap", width="8px") + I("Button / Primary Small", "Submit Button", Label="Submit")
return row(name, left + tabs + box("Spacer", flex_grow="1") + right + box("Pad", width="4px"), width=f"{TOOLBAR_W}px", height="52px", padding="0 8px 0 16px",
background=D("panel"), border=f"1px solid {D('deep')}")
reg("IDE toolbar", "IDE Toolbar / Code", toolbar("IDE Toolbar / Code", "Code"), "challenge toolbar, Code tab, Run + Submit")
reg("IDE toolbar", "IDE Toolbar / Code Low Time", toolbar("IDE Toolbar / Code Low Time", "Code", True, "04:12"), "timer under 5 min turns red")
reg("IDE toolbar", "IDE Toolbar / Diagrams", toolbar("IDE Toolbar / Diagrams", "Diagrams"), "Diagrams tab")
reg("IDE toolbar", "IDE Toolbar / Whiteboard", toolbar("IDE Toolbar / Whiteboard", "Whiteboard"), "Whiteboard tab")
# ------------------------------------------------------------------ File tree
def tree_file(name, active, depth=1, ext_color="syntax-type"):
c = fg("text-inverse") if active else D("text-dim")
return row(f"File / {name}", ic("diamond", 10, fg(ext_color), "File Icon") + T("File Name", name, 14, c), gap="8px", padding=f"5px 8px 5px {depth * 14 + 10}px", border_radius="var(--radius-sm)",
background=D("active") if active else "transparent")
def file_tree(name, active):
files = [("SearchBox.tsx", "syntax-type"), ("SearchBox.test.tsx", "syntax-type"), ("useDebounce.ts", "syntax-type"), ("styles.css", "ide-css")]
tree = (row("Folder / src", ic("caret-down", 10, D("text-muted"), "Folder Icon") + T("Folder Name", "src", 14, D("text-muted"), 600), gap="6px", padding="5px 8px 5px 10px") +
"".join(tree_file(n, n == active, 1, c) for n, c in files))
return col(name, T("Explorer Title", "EXPLORER", 12, D("text-faint"), 600, letter_spacing="0.6px", padding="10px 14px") + col("Tree", tree, padding="8px", gap="0px"),
width="210px", height=f"{BODY_H}px", background=D("panel"), border=f"1px solid {D('deep')}")
reg("File tree", "File Tree / SearchBox Active", file_tree("File Tree / SearchBox Active", "SearchBox.tsx"), "Explorer, SearchBox.tsx selected")
reg("File tree", "File Tree / Test Active", file_tree("File Tree / Test Active", "SearchBox.test.tsx"), "Explorer, test file selected")
def editor_tab(name, active):
c = fg("text-inverse") if active else D("text-muted")
return col(f"Editor Tab / {name}", box("Ink Bar", height="2px", background=BRAND if active else "transparent") +
row("Tab Body", ic("diamond", 10, fg("syntax-type"), "File Icon") + T("File Name", name, 14, c) + (ic("x", 12, D("text-faint"), "Close Icon") if active else ""), gap="8px", padding="7px 14px 8px"),
background=fg("code-bg") if active else "transparent", border=f"1px solid {D('deep')}")
for nm, (a, b) in {"SearchBox Active": ("SearchBox.tsx", "useDebounce.ts"), "Debounce Active": ("useDebounce.ts", "SearchBox.tsx")}.items():
reg("Editor tabs", f"Editor Tabs / {nm}",
row(f"Editor Tabs / {nm}", editor_tab(a, True) + editor_tab(b, False), width=f"{CENTER_W}px", height="37px", background=D("panel"), border=f"1px solid {D('deep')}", align_items="stretch"),
"open file tabs")
# ------------------------------------------------------------------ Code editor (port of CodeEditor tokenizer)
KW = re.compile(r"^(?:import|from|export|default|const|let|var|function|return|if|else|for|while|do|switch|case|break|continue|await|async|new|class|interface|enum|extends|implements|public|private|protected|readonly|type|of|in|try|catch|finally|throw|typeof|instanceof|null|undefined|true|false|void|this|super|yield|as|static|get|set)\b")
RULES = [("comment", re.compile(r"^//.*")), ("string", re.compile(r"^`(?:[^`\\]|\\.)*`?|^\"(?:[^\"\\]|\\.)*\"?|^'(?:[^'\\]|\\.)*'?")), ("keyword", KW),
("type", re.compile(r"^[A-Z][A-Za-z0-9_]*")), ("number", re.compile(r"^\d+(?:\.\d+)?")), ("fn", re.compile(r"^[a-zA-Z_$][\w$]*(?=\s*\()")),
("ident", re.compile(r"^[a-zA-Z_$][\w$]*")), ("ws", re.compile(r"^\s+"))]
TOK_COLOR = {"comment": "syntax-comment", "string": "syntax-string", "keyword": "syntax-keyword", "type": "syntax-type", "number": "syntax-number", "fn": "syntax-fn"}
def code_line_html(line):
out, i = [], 0
while i < len(line):
s = line[i:]
for cls, rx in RULES:
m = rx.match(s)
if m:
t = m.group(0)
out.append((cls, t))
i += len(t)
break
else:
out.append(("punct", line[i]))
i += 1
return "".join(f'<span style="color:var(--color-{TOK_COLOR[c]})">{_h.escape(t)}</span>' if c in TOK_COLOR else _h.escape(t) for c, t in out)
SOLUTION = """import { useEffect, useRef, useState } from 'react';
import { useDebounce } from './useDebounce';
type Result = { id: string; title: string };
export function SearchBox({ fetchResults }: Props) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<Result[]>([]);
const [loading, setLoading] = useState(false);
const debounced = useDebounce(query, 300);
const latest = useRef(0);
useEffect(() => {
// ignore responses that arrive out of order
const ticket = ++latest.current;
if (!debounced) return setResults([]);
setLoading(true);
fetchResults(debounced)
.then((rows) => {
if (ticket === latest.current) setResults(rows);
})
.finally(() => setLoading(false));
}, [debounced, fetchResults]);
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}"""
DEBOUNCE = """import { useEffect, useState } from 'react';
export function useDebounce<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(id);
}, [value, delayMs]);
return debounced;
}"""
def code_editor(name, src, h=609, w=CENTER_W, readonly=False):
lines = src.split("\n")
lh, fs = 20, 13
gutter = col("Gutter", "".join(T(f"Ln {i}", str(i), fs, fg("syntax-gutter"), lh=lh, text_align="right", width="26px") for i in range(1, len(lines) + 1)),
padding="12px 10px 12px 0", width="40px")
body = []
for i, ln in enumerate(lines, 1):
ind = len(ln) - len(ln.lstrip(" "))
body.append(box(f"Line {i}", code_line_html(ln.strip()) or "&nbsp;", font_size=f"{fs}px", line_height=f"{lh}px", color=fg("code-fg"), font_weight=400,
padding_left=f"{ind * 4}px", height=f"{lh}px"))
return row(name, gutter + col("Code", "".join(body), padding="12px 16px", gap="0px"), width=f"{w}px", height=f"{h}px", background=D("deep"), overflow="hidden", align_items="flex-start")
reg("Code editor", "Code Editor / SearchBox", code_editor("Code Editor / SearchBox", SOLUTION), "dark code area: gutter + highlighted lines (CodeEditor.tsx tokenizer)")
reg("Code editor", "Code Editor / useDebounce", code_editor("Code Editor / useDebounce", DEBOUNCE), "second file")
reg("Code editor", "Code Editor / Empty", code_editor("Code Editor / Empty", "// Write your solution here\n"), "seeded when the challenge ships no files")
# ------------------------------------------------------------------ Console
def ctab(label, active):
c = fg("text-inverse") if active else D("text-muted")
return col(f"Console Tab / {label}", row("Tab Body", T("Label", label, 12, c), padding="8px 16px 7px") + box("Ink Bar", height="2px", background=BRAND if active else "transparent"))
CONS = {"sys": "ide-text-muted", "pass": "ide-pass", "fail": "ide-fail", "info": "code-fg", "warn": "ide-warn"}
def console(name, lines, running=False, empty=False):
head = row("Console Tabs", ctab("Console", True) + ctab("Tests", False) + ctab("Terminal", False) + box("Spacer", flex_grow="1") + T("Clear", "Clear", 12, D("text-faint"), padding="0 14px"),
background=D("panel"), border=f"1px solid {D('deep')}", height="34px")
out = "".join(T(f"Output {i}", t, 13, fg(CONS[k]), lh=20) for i, (k, t) in enumerate(lines, 1))
if empty:
out = T("Output 1", "$ Console ready — run the tests to see output.", 13, D("text-faint"), lh=20)
if running:
out += row("Running", ic("cursor-text", 12, D("text-muted"), "Cursor Icon") + T("Running Label", "running…", 13, D("text-muted"), lh=20), gap="6px")
return col(name, head + col("Output", out, padding="10px 16px", gap="0px", background=D("console"), height="162px", overflow="hidden"), width=f"{CENTER_W}px", height="196px")
RUN_HEAD = [("sys", "$ npm test"), ("sys", "vitest run")]
PASS = [("info", " ✓ debounces input by 300ms (312ms)"), ("info", " ✓ cancels stale requests (88ms)"), ("info", " ✓ shows loading state (24ms)"),
("info", " ✓ renders empty state (19ms)"), ("pass", " ✓ handles out-of-order responses (41ms)"), ("info", " ✓ cleans up on unmount (12ms)")]
reg("Console", "Console / Empty", console("Console / Empty", [], empty=True), "no output yet")
reg("Console", "Console / Running", console("Console / Running", RUN_HEAD + PASS[:3], running=True), "tests streaming")
reg("Console", "Console / Passed", console("Console / Passed", PASS[2:] + [("pass", " Test Files 1 passed (1)"), ("pass", " Tests 6 passed (6)"), ("pass", " Duration 2.05s")]), "all tests pass")
reg("Console", "Console / Failed", console("Console / Failed", PASS[:2] + [("fail", " ✗ handles out-of-order responses (41ms)"), ("fail", " AssertionError: expected 'a' to equal 'ab'"),
("info", " ✓ cleans up on unmount (12ms)"), ("fail", " Tests 1 failed | 5 passed (6)")]), "a failing test")
# ------------------------------------------------------------------ Problem rail
PROMPT_P1 = "Build a SearchBox React component that queries a remote API as the user types."
PROMPT_P2 = "Requests must be debounced by 300ms, stale in-flight requests must be ignored, and the UI must show loading and empty states. Make the provided tests pass."
def problem_rail(name, open_=True):
chips = row("Chips", T("Language Chip", "TypeScript", 12, fg("syntax-type"), lh=16, padding="2px 8px", background=D("blue-bg"), border_radius="var(--radius-sm)") +
row("Time Chip", ic("clock", 12, D("text-muted"), "Clock Icon") + T("Time Label", "45m", 12, D("text-muted"), lh=16), gap="4px", padding="2px 8px", background=D("chip"), border_radius="var(--radius-sm)"), gap="8px")
head = col("Rail Header", T("Title", "Debounced search box", 16, fg("text-inverse"), 700) + chips, gap="8px", padding="14px 16px 12px", border=f"1px solid {D('line')}")
acc_h = row("Accordion Header", ic("caret-down" if open_ else "caret-right", 12, D("text"), "Caret Icon") + T("Accordion Title", "Problem brief", 14, "var(--color-ide-text)", 600, flex_grow="1"), gap="8px", padding="12px 16px")
acc_b = col("Accordion Body", T("Brief", PROMPT_P1, 14, D("text-dim"), lh=22) + T("Brief 2", PROMPT_P2, 14, D("text-dim"), lh=22), gap="12px", padding="0 16px 16px") if open_ else ""
return col(name, head + col("Accordion", acc_h + acc_b, border=f"1px solid {D('line')}"), width="316px", height=f"{BODY_H}px", background=D("rail"))
reg("Problem rail", "Problem Rail / Brief", problem_rail("Problem Rail / Brief"), "right rail, brief open")
reg("Problem rail", "Problem Rail / Collapsed", problem_rail("Problem Rail / Collapsed", False), "brief collapsed")
# ------------------------------------------------------------------ Diagram and whiteboard toolbars
TEMPL = ["System design", "Sequence", "Class diagram", "State machine"]
for i, act in enumerate(TEMPL):
chips = "".join(T(f"Template / {t}", t, 12, fg("text-inverse") if t == act else D("text-muted"), lh=16, padding="4px 12px", border_radius="var(--radius-sm)",
background=BRAND if t == act else D("line")) for t in TEMPL)
reg("Diagram editor", f"Diagram Toolbar / {act}", row(f"Diagram Toolbar / {act}", T("Template Label", "Template:", 12, D("text-faint"), lh=16) + chips, gap="6px", width=f"{CENTER_W}px",
padding="8px 14px", background=D("panel"), border=f"1px solid {D('deep')}"), "diagram template chips")
WB_COLORS = ["wb-white", "wb-blue", "ide-pass", "ide-fail", "ide-warn", "wb-purple", "wb-orange"]
WB_TOOLS = [("Select", "cursor"), ("Rectangle", "rectangle"), ("Ellipse", "circle"), ("Arrow", "arrow-up-right"), ("Freehand", "pencil-simple"), ("Text", "text-t")]
def wb_button(label, c, bg, icon=None):
return row(f"Button / {label}", (ic(icon, 12, c, "Icon") if icon else "") + T("Label", label, 12, c, lh=16), gap="4px", padding="5px 10px", border_radius="6px", background=bg)
def wb_toolbar(name, active, color_idx=0, selected=False):
tools = "".join(row(f"Tool / {t}", ic(i, 18, fg("text-inverse") if t == active else fg("wb-text"), "Tool Icon"), justify_content="center", width="34px", height="34px", border_radius="7px",
background=BRAND if t == active else fg("wb-button")) for t, i in WB_TOOLS)
dots = "".join(box(f"Color / {c}", width="22px", height="22px", border_radius="99px", background=fg(c), border=f"2px solid {fg('text-inverse') if k == color_idx else 'transparent'}")
for k, c in enumerate(WB_COLORS))
right = (wb_button("Delete", fg("ide-fail"), D("fail-bg"), "x") if selected else "") + wb_button("Undo", fg("wb-text"), fg("wb-button"), "arrow-counter-clockwise") + wb_button("Clear", fg("wb-text"), fg("wb-button"))
return row(name, tools + box("Divider", width="1px", height="26px", background=D("sep")) + dots + box("Spacer", flex_grow="1") + right, gap="6px", width=f"{CENTER_W + 316}px",
padding="8px 12px", background=fg("wb-toolbar"), border=f"1px solid {fg('wb-border')}")
reg("Whiteboard", "Whiteboard Toolbar / Rectangle", wb_toolbar("Whiteboard Toolbar / Rectangle", "Rectangle"), "tools, colors, undo, clear")
reg("Whiteboard", "Whiteboard Toolbar / Select", wb_toolbar("Whiteboard Toolbar / Select", "Select", 0, True), "select tool with a selected shape (Delete shown)")
reg("Whiteboard", "Whiteboard Toolbar / Arrow", wb_toolbar("Whiteboard Toolbar / Arrow", "Arrow", 1), "arrow tool, blue")
# ------------------------------------------------------------------ Theory phase
def pips(states):
out = ""
for i, s in enumerate(states):
out += box(f"Pip {i + 1}", width="28px" if s == "cur" else "8px", height="6px", border_radius="3px",
background={"cur": fg("avatar-teal"), "done": fg("ide-pass"), "todo": fg("ide-line")}[s])
return out
def theory_toolbar(name, states, count, timer_txt):
return row(name, row("Exit", ic("arrow-left", 14, D("text-faint"), "Exit Icon") + T("Exit Label", "Exit", 14, D("text-faint")), gap="6px") + box("Divider", width="1px", height="20px", background=D("sep")) +
T("Phase Label", "RECRUITER SCREEN", 12, fg("avatar-teal"), 700, letter_spacing="0.8px") +
row("Progress", pips(states) + T("Count", count, 12, D("text-faint"), padding_left="6px"), gap="4px", flex_grow="1") + timer(timer_txt),
gap="12px", width=f"{TOOLBAR_W}px", height="52px", padding="0 20px", background=D("panel"), border=f"1px solid {D('deep')}")
reg("Theory", "Theory Toolbar / Q2 of 5", theory_toolbar("Theory Toolbar / Q2 of 5", ["done", "cur", "todo", "todo", "todo"], "1/5", "27:45"), "question progress pips + timer")
reg("Theory", "Theory Toolbar / Q5 of 5", theory_toolbar("Theory Toolbar / Q5 of 5", ["done", "done", "done", "done", "cur"], "4/5", "08:20"), "last question")
def chip(name, label, c, bg):
return row(name, T("Label", label, 12, fg(c), 600, lh=16), padding="3px 12px", background=bg, border_radius="99px")
reg("Theory", "Theory Chip / Category", chip("Theory Chip / Category", "Behavioral", "avatar-teal", D("teal-bg")), "category pill")
reg("Theory", "Theory Chip / Easy", chip("Theory Chip / Easy", "Easy", "ide-pass", D("pass-bg")), "difficulty")
reg("Theory", "Theory Chip / Medium", chip("Theory Chip / Medium", "Medium", "ide-warn", D("warn-bg")), "difficulty")
reg("Theory", "Theory Chip / Hard", chip("Theory Chip / Hard", "Hard", "ide-fail", D("fail-bg")), "difficulty")
def tbtn(name, label, c, bg, border="transparent", w=None, weight=500, icon=None, after=False):
ico = ic(icon, 14, c, "Icon") if icon else ""
kids = (T("Label", label, 14, c, weight) + ico) if after else (ico + T("Label", label, 14, c, weight))
return row(name, kids, gap="6px", padding="9px 22px", border_radius="6px", background=bg, border=f"1px solid {border}", justify_content="center")
reg("Theory", "Theory Button / Prev", tbtn("Theory Button / Prev", "Prev", fg("ide-text-dim"), "transparent", D("line"), icon="arrow-left"), "outlined")
reg("Theory", "Theory Button / Prev Disabled", tbtn("Theory Button / Prev Disabled", "Prev", fg("ide-line"), "transparent", D("line"), icon="arrow-left"), "first question")
reg("Theory", "Theory Button / Next", tbtn("Theory Button / Next", "Next", fg("text-inverse"), fg("avatar-teal"), weight=600, icon="arrow-right", after=True), "teal")
reg("Theory", "Theory Button / Submit Partial", tbtn("Theory Button / Submit Partial", "Submit (3/5 answered)", D("submit-fg"), D("submit-bg"), "#2a4060", weight=700), "not all answered")
reg("Theory", "Theory Button / Submit All", tbtn("Theory Button / Submit All", "Submit answers", fg("text-inverse"), BRAND, BRAND, weight=700, icon="check", after=True), "all answered")
def answer(name, txt, filled):
return col(name, T("Answer", txt, 14, fg("code-fg") if filled else D("text-faint"), lh=24), width="704px", height="260px", padding="18px", background=D("input"), border_radius="var(--radius-md)",
border=f"1px solid {D('pass-border') if filled else D('line')}")
reg("Theory", "Theory Answer / Empty", answer("Theory Answer / Empty", "Type your answer here — explain your reasoning clearly.", False), "textarea placeholder")
reg("Theory", "Theory Answer / Filled", answer("Theory Answer / Filled", "I would start by clarifying the team's goals and constraints, then walk the interviewer through a concrete example where I disagreed with a peer and how we reached a decision together.", True), "typed answer (>= 8 words: green border)")
# ------------------------------------------------------------------ Session home
STEPS = ["Sign in", "System check", "Coding", "Submit"]
def progress(name, cur):
items = ""
for i, s in enumerate(STEPS):
done, now = i < cur, i == cur
if done:
inner = ic("check", 14, fg("text-inverse"), "Check Icon")
else:
inner = T("Step Number", str(i + 1), 12, fg("text-inverse") if now else fg("text-disabled"), 700, lh=16)
dot = row("Step Dot", inner, justify_content="center", width="28px", height="28px", border_radius="99px",
background=fg("success") if done else BRAND if now else fg("bg-elevated"), border="none" if (done or now) else f"2px solid {fg('border')}")
items += col(f"Step / {s}", dot + T("Step Label", s, 12, fg("text-primary") if now else fg("text-secondary"), 600 if now else 400, lh=16, text_align="center"), align_items="center", gap="8px", flex_grow="1")
line = row("Track", box("Line", height="2px", width="100%", background=fg("border-light")), position="absolute", left="76px", right="76px", top="13px")
return col(name, T("Progress Title", "Your progress", 16, weight=600) + row("Steps", line + items, align_items="flex-start", position="relative", width="100%", gap="0px"), width="980px", gap="18px", padding="var(--spacing-lg)",
background=fg("bg-elevated"), border="1px solid var(--color-border-light)", border_radius="var(--radius-lg)", box_shadow="var(--shadow-sm)")
reg("Session home", "Progress Tracker / System check", progress("Progress Tracker / System check", 1), "step 2 current")
reg("Session home", "Progress Tracker / Coding", progress("Progress Tracker / Coding", 2), "step 3 current")
def check_item(name, label, ok):
dot = row("Dot", ic("check", 12, fg("text-inverse"), "Check Icon") if ok else "", justify_content="center", width="22px", height="22px", border_radius="99px", background=fg("success") if ok else fg("bg-layout"))
return row(name, dot + T("Label", label, 14, fg("text-primary"), flex_grow="1") + T("Status", "Ready" if ok else "Stub", 12, fg("success") if ok else fg("text-disabled"), 600, lh=16),
gap="12px", padding="12px 0", width="334px", border="1px solid transparent")
reg("Session home", "Check Item / Ready", check_item("Check Item / Ready", "Browser compatible", True), "Ready")
reg("Session home", "Check Item / Stub", check_item("Check Item / Stub", "Camera access", False), "not ready")
TIPS = {"Read carefully": ("target", "Take 5 minutes to fully understand the problem before you start typing."),
"Think aloud": ("chat-circle-dots", "Verbalize your approach. Interviewers want to hear how you reason."),
"Manage time": ("timer", "Plan to leave 10 minutes at the end for testing and cleanup.")}
for t, (icn, d) in TIPS.items():
reg("Session home", f"Tip Card / {t}", col(f"Tip Card / {t}", ic(icn, 22, fg("brand-primary"), "Tip Icon") + T("Tip Title", t, 14, weight=600) + T("Tip Desc", d, 12, fg("text-secondary"), lh=19),
width="316px", gap="8px", padding="var(--spacing-md)", background=fg("bg-elevated"), border="1px solid var(--color-border-light)",
border_radius="var(--radius-lg)", box_shadow="var(--shadow-sm)"), "tip")
# ------------------------------------------------------------------ System check (setup)
SC = {"Ready": ("syscheck-ready", "check", fg("syscheck-ready"), fg("syscheck-ready-bg")), "Checking": ("brand-primary", "dots-three", BRAND, fg("focus-ring")),
"Failed": ("syscheck-fail", "warning", fg("syscheck-fail"), "transparent"), "Pending": ("border-light", None, fg("text-disabled"), fg("border-light"))}
BADGE = {"Ready": "Ready", "Checking": "Checking…", "Failed": "Action needed", "Pending": "—"}
def sc_row(name, state, title, desc):
ind_bg, icn, bfg, bbg = SC[state]
ind = row("Indicator", ic(icn, 14, fg("text-inverse"), "State Icon") if icn else "", justify_content="center", width="26px", height="26px", border_radius="99px", background=fg(ind_bg))
badge = row("Badge", T("Badge Label", BADGE[state], 12, bfg, 600, lh=16), padding="3px 10px", border_radius="5px", background=bbg)
retry = row("Retry", T("Retry Label", "Retry", 12, fg("text-primary"), 500, lh=16), padding="3px 10px", border_radius="5px", background=fg("bg-elevated"), border=f"1px solid {fg('border')}") if state == "Failed" else ""
return row(name, ind + col("Texts", T("Title", title, 14, weight=600, lh=18) + T("Desc", desc, 12, fg("text-secondary"), lh=16), flex_grow="1", gap="2px") + row("Status", badge + retry, gap="8px"),
gap="14px", width="360px", padding="14px 0", border="1px solid transparent")
for st in SC:
reg("System check", f"Syscheck Row / {st}", sc_row(f"Syscheck Row / {st}", st, "Browser compatibility", "Chrome, Edge, or Firefox — up to date"), f"check row, {st}")
reg("System check", "Syscheck Step / Done", row("Syscheck Step / Done", row("Step Dot", ic("check", 12, fg("text-inverse"), "Check Icon"), justify_content="center", width="22px", height="22px", border_radius="99px", background=fg("syscheck-ready")) +
T("Step Label", "Account setup", 14, fg("syscheck-ready"), 500), gap="8px"), "header step done")
reg("System check", "Syscheck Step / Active", row("Syscheck Step / Active", row("Step Dot", T("Step Number", "2", 12, fg("text-inverse"), 700, lh=16), justify_content="center", width="22px", height="22px", border_radius="99px", background=BRAND) +
T("Step Label", "System check", 14, BRAND, 500), gap="8px"), "header step active")
reg("Toast", "Message / Success", row("Message / Success", ic("check-circle", 16, fg("success"), "Success Icon") + T("Message", "Submission recorded", 14), gap="8px", padding="9px 12px",
background=fg("bg-elevated"), border_radius="var(--radius-md)", box_shadow="var(--shadow-md)"), "antd message.success")
LIB_NAMES = [n for n, _ in SPECS]
def build(P, x0, y0):
ids, y = place_components(P, SPECS, x0, y0, 6000, GROUPS)
return ids, y
+17
View File
@@ -0,0 +1,17 @@
from cand_common import *
import collections
fid,P,_=setup_cand()
def walk(n,out):
for c in P.c("get_children",nodeId=n)["children"]:
if c["name"].startswith("Screen /"): out.append(c)
elif c["name"].startswith(("Screens","Block","Section / Candidate")): walk(c["id"],out)
out=[]
for c in top_children(P):
if c["name"] in ("Section / Candidate Access","Section / Candidate Session"): walk(c["id"],out)
for c in out:
r=P.lint(c["id"]); iss=r["issues"]
print(c["name"],r.get("errors"),r.get("warnings"),r.get("byRule"))
seen=set()
for i in iss:
k=(i.get("severity"),str(i.get("message"))[:70])
if i.get("severity") in ("error","warning") and k not in seen: seen.add(k); print(" ",k)
+12
View File
@@ -0,0 +1,12 @@
import sys
from cand_common import *
fid,P,_=setup_cand()
want=sys.argv[1:]
def walk(n,out):
for c in P.c("get_children",nodeId=n)["children"]:
out.append(c);
if c["name"].startswith(("Screens","Block","Section")) : walk(c["id"],out)
out=[];walk("root",out)
for c in out:
if c["name"].startswith("Screen /") and (not want or any(w in c["name"] for w in want)):
print(shot(P,c["id"],c["name"].replace(" ","_").replace("/","")))
+32
View File
@@ -0,0 +1,32 @@
"""Build the Candidate page of the Interview project (idempotent): components, then the three sections.
python3 candidate.py [components] [access] [session] [challenge] (no args = everything)
"""
import sys
sys.path.insert(0, "/Volumes/Storage/Projects/papercraft/examples/interview")
from cand_common import *
LIB_Y = 1100 # imported Design System components sit at y 0..900; ours below
def main(which):
fid, P, DS = setup_cand()
call("import_components", {"fileId": fid, "fromPageId": DS.id, "pageId": P.id})
import cand_components as cc
if "components" in which:
names = set(cc.LIB_NAMES)
print("deleted", delete_named(P, lambda n: n in names or n.startswith("Library / ")))
cc.build(P, 0, LIB_Y)
print("components", len(cc.LIB_NAMES))
# sections go below the component library
bottoms = [c.get("y", 0) + c["height"] for c in top_children(P) if not c["name"].startswith("Section / Candidate")]
y0 = int(max(bottoms)) + 200
for mod in ("access", "challenge"): # cand_access builds Access + Session
if mod in which or (mod == "access" and "session" in which):
m = __import__(f"cand_{mod}")
print(mod, m.build(P, y0))
if __name__ == "__main__":
main(set(sys.argv[1:]) or {"components", "access", "session", "challenge"})
+46
View File
@@ -0,0 +1,46 @@
"""Entry point: builds the whole `Design System` page of the Interview project (idempotent).
python3 design_system.py
Foundations (ds_foundations.py) -> main components (ds_components.py, top-level frames, laid out in the
library area right of the sections) -> Section / Components gallery (instances with captions).
"""
import ds_foundations
import ds_components as C
from ds_common import *
X_LIB = 1560
def gallery(P):
body = ""
for g, names in C.GROUPS.items():
cells = ""
for n in names:
cells += col(f"Cell / {n}", T("Caption", n, 12, "var(--color-text-secondary)", lh=16) + inst_html(n), gap="8px")
body += col(f"Group / {g}", T("Group Title", g, 20, weight=600) + row("Cells", cells, flex_wrap="wrap", gap="var(--spacing-lg)", align_items="flex-start"),
gap="var(--spacing-md)", padding="var(--spacing-lg)", background="var(--color-bg-layout)", border_radius="var(--radius-lg)", width="1376px")
html = col("Section / Components", T("Section Title", "Components", 24, weight=700) +
T("Section Intro", "Every item is an instance of a main component; main components live in the library area to the right. Use the exact names in <x-paper-instance component=...>.", 14, "var(--color-text-secondary)") +
col("Groups", body, gap="var(--spacing-xl)"), width="1440px", gap="var(--spacing-xl)", padding="var(--spacing-xl)", background="transparent")
return P.add("root", html)
def stack(P, ids):
y = 0
for i in ids:
P.c("update_styles", updates=[{"nodeIds": [i], "styles": {"left": 0, "top": y}}])
h = next(c["height"] for c in P.c("get_children", nodeId="root")["children"] if c["id"] == i)
y += h + 48
return y
if __name__ == "__main__":
fid, P = setup()
clear(P)
vals = call("get_variables", {"fileId": fid})["variables"]
f_ids = ds_foundations.build(P, vals)
ids, ylib = place_components(P, C.SPECS, X_LIB, 0, 2800, C.GROUPS)
g_ids = gallery(P)
stack(P, f_ids + g_ids)
print("components:", len(ids))
+112
View File
@@ -0,0 +1,112 @@
"""Shared helpers for the Design System page scripts (ds_foundations.py, ds_components.py, design_system.py)."""
import sys
sys.path.insert(0, "/Volumes/Storage/Projects/craft-skills/skills/papercraft/scripts")
from pc import * # noqa: F401,F403,E402
PROJECT = "Interview"
# Extra color variables (no token in tokens.css: values read from the component code / AntD default theme).
EXTRA_VARS = {
# app sidebar (globals.css .app-sidebar*)
"color-sidebar-bg": "#11161d",
"color-sidebar-border": "#ffffff12",
"color-sidebar-text": "#ffffffa6",
"color-sidebar-text-muted": "#ffffff73",
"color-sidebar-text-faint": "#ffffff59",
"color-sidebar-hover": "#ffffff0f",
"color-sidebar-badge": "#ffffff40",
"color-sidebar-tag-bg": "#1677ff40",
# Tag tones (Tag.tsx)
"color-tag-blue-fg": "#0958d9", "color-tag-blue-bg": "#e6f4ff", "color-tag-blue-bd": "#91caff",
"color-tag-green-fg": "#389e0d", "color-tag-green-bg": "#f6ffed", "color-tag-green-bd": "#b7eb8f",
"color-tag-gold-fg": "#ad6800", "color-tag-gold-bg": "#fffbe6", "color-tag-gold-bd": "#ffe58f",
"color-tag-red-fg": "#cf1322", "color-tag-red-bg": "#fff1f0", "color-tag-red-bd": "#ffccc7",
"color-tag-purple-fg": "#531dab", "color-tag-purple-bg": "#f9f0ff", "color-tag-purple-bd": "#d3adf7",
"color-tag-cyan-fg": "#08979c", "color-tag-cyan-bg": "#e6fffb", "color-tag-cyan-bd": "#87e8de",
# misc component colors
"color-button-dark": "#1f1f1f",
"color-button-primary-hover": "#4096ff",
"color-card-hover-border": "#bfdbff",
"color-star": "#faad14",
"color-score-warn": "#faad14",
"color-score-poor": "#ff4d4f",
"color-text-placeholder": "#00000040",
"color-tooltip-bg": "#000000d9",
"color-switch-off": "#00000040",
"color-focus-ring": "#1677ff1a",
# Avatar palette (Avatar.tsx PALETTE; blue/amber/green reuse brand/warning/success)
"color-avatar-purple": "#722ed1", "color-avatar-teal": "#13a8a8", "color-avatar-pink": "#eb2f96",
"color-avatar-orange": "#fa541c", "color-avatar-indigo": "#2f54eb",
}
SHADOW_BTN = "0 1px 2px rgba(5,90,235,.25)"
SHADOW_FOCUS = "0 0 0 2px var(--color-focus-ring)"
def camel(k):
k = k.replace("_", "-")
p = k.split("-")
return p[0] + "".join(x.title() for x in p[1:])
def setup():
"""Returns (file id, Design System Page) and defines the extra variables."""
fid = next(i["id"] for i in call("list_resources")["items"] if i["name"] == PROJECT)
P = pages(fid)["Design System"]
call("set_variables", {"fileId": fid, "variables": EXTRA_VARS})
return fid, P
def clear(P):
ids = [c["id"] for c in P.c("get_children", nodeId="root")["children"]]
if ids:
P.c("delete_nodes", nodeIds=ids)
def lines(h): # shorthand for line-height in px
return f"{h}px"
def T(name, t, size=14, color="var(--color-text-primary)", weight=400, lh=None, **st):
"""Text layer with px font size and a line-height (default ~1.5 rounded to 2px)."""
lh = lh or {10.5: 14, 11: 16, 12: 16, 13: 18, 14: 22, 15: 20, 16: 24, 18: 26, 20: 28, 24: 32, 30: 36}.get(size, round(size * 1.4))
return text(name, t, size=f"{size}px", color=color, weight=weight, line_height=f"{lh}px", **st)
def ic(name, size=16, color="var(--color-text-secondary)", layer=None):
return f'<x-paper-icon name="phosphor:{name}" size="{size}" color="{color}" layer-name="{layer or "Icon / " + name}"/>'
def place_components(P, specs, x0, y0, max_w, groups):
"""Create main components from [(name, html)] (html = ONE top-level element each) and lay them out in rows.
groups: ordered {group title: [component names]}; each group gets a title text and wrapped rows.
"""
ids = {}
for i in range(0, len(specs), 12):
chunk = specs[i : i + 12]
r = P.c("write_html", targetNodeId="root", mode="insert-children", html="".join(h for _, h in chunk))
for w in r.get("warnings", []):
print(" warn:", w)
if r.get("ignoredStyles"):
print(" ignored:", sorted(set(r["ignoredStyles"]))[:6])
for (name, _), nid in zip(chunk, r["createdNodeIds"]):
P.c("create_component", nodeId=nid, name=name)
ids[name] = nid
sizes = {c["name"]: (c["width"], c["height"]) for c in P.c("get_components")["components"]}
y = y0
gap = 24
for title, names in groups.items():
t = P.add("root", T(f"Library / {title}", title, 20, weight=600, position="absolute", left=f"{x0}px", top=f"{y}px"))
y += 40
x, rowh = x0, 0
for n in names:
w, h = sizes[n]
if x > x0 and x + w > x0 + max_w:
x, y, rowh = x0, y + rowh + gap, 0
P.c("update_styles", updates=[{"nodeIds": [ids[n]], "styles": {"left": x, "top": y}}])
x += w + gap
rowh = max(rowh, h)
y += rowh + 48
return ids, y
+533
View File
@@ -0,0 +1,533 @@
"""Main components of the Design System page (top-level frames) + the registry used by the gallery and the manifest."""
from ds_common import *
SPECS = [] # [(name, html)]
GROUPS = {} # group title -> [component names]
NOTES = {} # component name -> short description (gallery caption)
def reg(group, name, html, note=""):
SPECS.append((name, html))
GROUPS.setdefault(group, []).append(name)
NOTES[name] = note
def fg(c): # shorthand
return f"var(--color-{c})"
# ---------------------------------------------------------------- Buttons
BTN_SIZES = {"": (34, 16, 14), " Small": (28, 12, 12), " Large": (40, 20, 14)}
BTN_VARIANTS = {
"Primary": dict(background="var(--color-brand-primary)", border="1px solid var(--color-brand-primary)", box_shadow=SHADOW_BTN, color="var(--color-text-inverse)"),
"Default": dict(background="var(--color-bg-elevated)", border="1px solid var(--color-border)", color="var(--color-text-primary)"),
"Ghost": dict(background="transparent", border="1px solid transparent", color="var(--color-text-secondary)"),
"Danger": dict(background="var(--color-bg-elevated)", border="1px solid var(--color-error)", color="var(--color-error)"),
"Dark": dict(background="var(--color-button-dark)", border="1px solid var(--color-button-dark)", color="var(--color-text-inverse)"),
}
for v, st in BTN_VARIANTS.items():
for suf, (h, px, fs) in BTN_SIZES.items():
name = f"Button / {v}{suf}"
col_ = st["color"]
sty = {k: x for k, x in st.items() if k != "color"}
reg("Buttons (packages/ui Button)", name,
row(name, T("Label", "Button", fs, col_, 500), justify_content="center", gap="8px", height=f"{h}px",
padding=f"0 {px}px", border_radius="var(--radius-md)", **sty),
f"{h}px high, padding 0 {px}px, {fs}px label")
reg("Buttons (packages/ui Button)", "Button / Notification Bell",
row("Button / Notification Bell", ic("bell", 16, fg("text-primary"), "Bell") +
box("Unread Dot", position="absolute", left="20px", top="6px", width="8px", height="8px", border_radius="99px", background=fg("error")),
justify_content="center", position="relative", width="36px", height="36px", background=fg("bg-elevated"),
border="1px solid var(--color-border)", border_radius="var(--radius-md)"),
"AdminShell bell: 36x36, red unread dot")
# AntD buttons (type primary etc.): 32px, radius 8, 14px
ANT = {
"Primary": dict(background=fg("brand-primary"), border="1px solid var(--color-brand-primary)", color=fg("text-inverse")),
"Default": dict(background=fg("bg-elevated"), border="1px solid var(--color-border)", color=fg("text-primary")),
"Danger": dict(background=fg("bg-elevated"), border="1px solid var(--color-error)", color=fg("error")),
"Text": dict(background="transparent", border="1px solid transparent", color=fg("text-primary")),
"Text Danger": dict(background="transparent", border="1px solid transparent", color=fg("error")),
}
for v, st in ANT.items():
for suf, (h, px, fs) in {"": (32, 16, 14), " Small": (24, 8, 14), " Large": (40, 16, 16)}.items():
name = f"Ant Button / {v}{suf}"
sty = {k: x for k, x in st.items() if k != "color"}
reg("Buttons (AntD Button)", name,
row(name, T("Label", "Button", fs, st["color"]), justify_content="center", height=f"{h}px", padding=f"0 {px}px",
border_radius="var(--radius-md)" if suf != " Small" else "var(--radius-sm)", **sty), f"AntD button {h}px")
# ---------------------------------------------------------------- Avatar
AV = {"Avatar": (32, 12), "Avatar / 30": (30, 12), "Avatar / 34": (34, 13), "Avatar / 36": (36, 14), "Avatar / 40": (40, 16)}
for name, (s, fs) in AV.items():
reg("Avatar", name,
row(name, T("Initials", "EV", fs, fg("text-inverse"), 600, letter_spacing="0.3px"), justify_content="center", width=f"{s}px", height=f"{s}px",
border_radius="99px", background=fg("brand-primary")), f"{s}px circle, initials, colour by name hash")
# ---------------------------------------------------------------- Card / Stat
reg("Card", "Card",
col("Card", T("Card Title", "Card title", 16, weight=600) + T("Card Body", "Card content goes here.", 14, fg("text-secondary")),
width="360px", gap="8px", padding="var(--spacing-lg)", background=fg("bg-elevated"), border="1px solid var(--color-border-light)",
border_radius="var(--radius-lg)", box_shadow="var(--shadow-sm)"), "Card: 24px padding, radius-lg, shadow-sm")
reg("Card", "Card / Hover",
col("Card / Hover", T("Card Title", "Card title", 16, weight=600) + T("Card Body", "Card content goes here.", 14, fg("text-secondary")),
width="360px", gap="8px", padding="var(--spacing-lg)", background=fg("bg-elevated"), border="1px solid var(--color-card-hover-border)",
border_radius="var(--radius-lg)", box_shadow="var(--shadow-md)"), "hover: shadow-md, blue border")
reg("Card", "Card / Compact",
col("Card / Compact", T("Card Title", "Card title", 16, weight=600) + T("Card Body", "Card content goes here.", 14, fg("text-secondary")),
width="360px", gap="4px", padding="var(--spacing-md)", background=fg("bg-elevated"), border="1px solid var(--color-border-light)",
border_radius="var(--radius-lg)", box_shadow="var(--shadow-sm)"), "pad=md (16px)")
def spark_svg(color, pts, name="Sparkline", w=72, h=30, gid=None):
mn, mx = min(pts), max(pts)
rng = (mx - mn) or 1
n = len(pts) - 1
p = [(i / n * w, h - (d - mn) / rng * (h - 4) - 2) for i, d in enumerate(pts)]
line = " ".join(f"{x:.1f},{y:.1f}" for x, y in p)
gid = gid or "g" + color[1:]
return (f'<svg layer-name="{name}" width="{w}" height="{h}" viewBox="0 0 {w} {h}"><defs><linearGradient id="{gid}" x1="0" y1="0" x2="0" y2="1">'
f'<stop offset="0%" stop-color="{color}" stop-opacity="0.22"/><stop offset="100%" stop-color="{color}" stop-opacity="0"/></linearGradient></defs>'
f'<polygon points="0,{h} {line} {w},{h}" fill="url(#{gid})"/><polyline points="{line}" fill="none" stroke="{color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>')
UP = [3, 5, 4, 7, 6, 9, 8, 10]
DOWN = [10, 9, 9, 7, 7, 5, 5, 4]
SPARK_COLORS = {"Blue": "#1677ff", "Purple": "#722ed1", "Orange": "#d48806", "Green": "#52c41a", "Teal": "#13a8a8"}
ACCENT_VAR = {"Blue": "brand-primary", "Purple": "avatar-purple", "Orange": "warning", "Green": "success", "Teal": "avatar-teal"}
for cn, hexc in SPARK_COLORS.items():
reg("Sparkline", f"Sparkline / {cn}", box(f"Sparkline / {cn}", spark_svg(hexc, UP, "Line", gid="s" + cn), display="flex"), "72x30 line + gradient area")
reg("Sparkline", "Sparkline / Orange Down", box("Sparkline / Orange Down", spark_svg("#d48806", DOWN, "Line", gid="sod"), display="flex"), "downward trend")
def stat(name, cn, down=False, hexc=None):
hexc = hexc or SPARK_COLORS[cn]
dc = fg("error") if down else fg("success")
body = (T("Label", "ACTIVE CANDIDATES", 12, fg("text-secondary"), 500, letter_spacing="0.48px") +
row("Value Row", T("Value", "12", 30, fg("text-primary"), 700) + spark_svg(hexc, DOWN if down else UP, "Sparkline", gid="st" + name.replace(" ", "").replace("/", "")),
justify_content="space-between", gap="8px") +
row("Delta Row", T("Delta Arrow", "▼" if down else "▲", 12, dc, 600) + T("Delta", "-1" if down else "+3", 12, dc, 600) + T("Trend", "this week", 12, fg("text-disabled")), gap="4px"))
return row(name, box("Accent Bar", background=fg(ACCENT_VAR[cn]), width="4px", align_self="stretch") +
col("Stat Body", body, gap="8px", padding="var(--spacing-lg)", flex_grow="1"),
align_items="stretch", width="208px", background=fg("bg-elevated"), border="1px solid var(--color-border-light)",
border_radius="var(--radius-lg)", box_shadow="var(--shadow-sm)", overflow="hidden")
for cn in SPARK_COLORS:
reg("Stat", f"Stat / {cn}", stat(f"Stat / {cn}", cn), "label, value 30/700, sparkline, delta line")
reg("Stat", "Stat / Orange Down", stat("Stat / Orange Down", "Orange", True), "negative delta: red arrow")
# ---------------------------------------------------------------- Tag, status
TONES = {"Blue": "blue", "Green": "green", "Gold": "gold", "Red": "red", "Purple": "purple", "Cyan": "cyan"}
def tag(name, fgc, bgc, bdc, dot=False, label="Tag"):
kids = (box("Dot", width="8px", height="8px", border_radius="99px", background=fgc) if dot else "") + T("Label", label, 12, fgc, 500, lh=16)
return row(name, kids, gap="8px", height="22px", padding="0 8px", background=bgc, border=f"1px solid {bdc}", border_radius="var(--radius-sm)")
for cn, t in TONES.items():
f, b, d = (f"var(--color-tag-{t}-{x})" for x in ("fg", "bg", "bd"))
reg("Tag", f"Tag / {cn}", tag(f"Tag / {cn}", f, b, d), "22px chip, 12px/500")
reg("Tag", f"Tag Dot / {cn}", tag(f"Tag Dot / {cn}", f, b, d, True), "with status dot")
reg("Tag", "Tag / Gray", tag("Tag / Gray", fg("text-secondary"), fg("bg-sider"), fg("border")), "neutral")
reg("Tag", "Tag Dot / Gray", tag("Tag Dot / Gray", fg("text-secondary"), fg("bg-sider"), fg("border"), True), "neutral with dot")
for nm, f, b, d in [("Default", fg("text-primary"), fg("bg-sider"), fg("border")),
("Success", fg("success"), "var(--color-tag-green-bg)", "var(--color-tag-green-bd)"),
("Warning", fg("warning"), "var(--color-tag-gold-bg)", "var(--color-tag-gold-bd)"),
("Error", fg("error"), "var(--color-tag-red-bg)", "var(--color-tag-red-bd)"),
("Processing", fg("brand-primary"), "var(--color-tag-blue-bg)", "var(--color-tag-blue-bd)")]:
reg("Tag (AntD Tag status colors)", f"Ant Tag / {nm}", tag(f"Ant Tag / {nm}", f, b, d), "antd <Tag color=...>")
for st, t in [("Queued", "blue"), ("Running", "gold"), ("Graded", "green"), ("Errored", "red")]:
f, b, d = (f"var(--color-tag-{t}-{x})" for x in ("fg", "bg", "bd"))
reg("Submission Status Badge", f"Submission Status Badge / {st}", tag(f"Submission Status Badge / {st}", f, b, d, label=st), "SubmissionStatusBadge")
reg("Submission Status Badge", "Submission Status Badge / Unknown", tag("Submission Status Badge / Unknown", fg("text-primary"), fg("bg-sider"), fg("border"), label="Unknown"), "SubmissionStatusBadge")
# ---------------------------------------------------------------- Pill, Segmented, Search
for act in (True, False):
for cnt in (False, True):
nm = f"Pill / {'Active' if act else 'Inactive'}{' With Count' if cnt else ''}"
c = fg("brand-primary") if act else fg("text-secondary")
kids = T("Label", "All", 12, c, 500, lh=16) + (T("Count", "12", 12, c, 700, lh=16, opacity="0.8") if cnt else "")
reg("Pill (filter chip)", nm, row(nm, kids, gap="8px", height="30px", padding="0 12px", border_radius="99px",
background=fg("bg-selected") if act else fg("bg-elevated"),
border=f"1px solid {fg('brand-primary') if act else fg('border')}"), "filter chip, radius 99")
def search(name, state):
b = fg("brand-primary") if state == "focus" else fg("border")
txt = T("Placeholder", "Search…" if state != "filled" else "backend", 14, fg("text-disabled") if state != "filled" else fg("text-primary"))
return row(name, ic("magnifying-glass", 14, fg("text-disabled"), "Search Icon") + txt, gap="8px", width="260px", height="34px", padding="0 12px",
background=fg("bg-elevated"), border=f"1px solid {b}", border_radius="var(--radius-md)",
**({"box_shadow": SHADOW_FOCUS} if state == "focus" else {}))
reg("Search Input", "Search Input", search("Search Input", "idle"), "260x34")
reg("Search Input", "Search Input / Focused", search("Search Input / Focused", "focus"), "focus ring")
reg("Search Input", "Search Input / Filled", search("Search Input / Filled", "filled"), "with value")
def seg(n, a, small=False):
nm = f"Segmented / {n} Options Active {a}"
items = ""
for k in range(1, n + 1):
on = k == a
items += row(f"Segment {k}", T(f"Option {k}", f"Option {k}", 12, fg("text-primary") if on else fg("text-secondary"), 600 if on else 500, lh=16),
justify_content="center", height="28px", padding="0 16px", border_radius="var(--radius-sm)",
background=fg("bg-elevated") if on else "transparent", **({"box_shadow": "var(--shadow-sm)"} if on else {}))
return nm, row(nm, items, padding="4px", background=fg("border-light"), border_radius="var(--radius-md)")
for n in (2, 3, 4):
for a in range(1, n + 1):
nm, h = seg(n, a)
reg("Segmented (packages/ui)", nm, h, "track border-light, active = white + shadow-sm")
def aseg(a):
nm = f"Ant Segmented / 3 Options Active {a}"
items = ""
for k in range(1, 4):
on = k == a
items += row(f"Segment {k}", T(f"Option {k}", f"Option {k}", 14, fg("text-primary") if on else fg("text-secondary")), justify_content="center",
height="28px", padding="0 12px", border_radius="var(--radius-md)", background=fg("bg-elevated") if on else "transparent",
**({"box_shadow": "var(--shadow-sm)"} if on else {}))
return nm, row(nm, items, padding="4px", background=fg("bg-layout"), border_radius="var(--radius-md)")
for a in (1, 2, 3):
nm, h = aseg(a)
reg("Segmented (AntD)", nm, h, "antd Segmented (challenges page)")
# ---------------------------------------------------------------- Scores, stars, bar
for nm, fill, w in [("High", "success", 128), ("Mid", "warning", 96), ("Low", "error", 48)]:
reg("Score Bar / Dots / Stars", f"Score Bar / {nm}",
box(f"Score Bar / {nm}", box("Fill", width=f"{w}px", height="6px", border_radius="99px", background=fg(fill)),
display="flex", width="160px", height="6px", border_radius="99px", background=fg("border-light"), overflow="hidden"),
"160x6 track; set Fill width in px")
LABELS = {1: "Poor", 2: "Below bar", 3: "At bar", 4: "Above bar", 5: "Exceptional"}
for v in range(0, 6):
c = {5: fg("success"), 4: fg("success"), 3: fg("brand-primary"), 2: fg("score-warn"), 1: fg("score-poor"), 0: fg("score-poor")}[v]
dots = ""
for k in range(1, 6):
on = k <= v
dots += row(f"Dot {k}", T(f"Dot {k} Value", str(k) if on else "", 12, fg("text-inverse"), 600, lh=16), justify_content="center", width="26px", height="26px",
border_radius="var(--radius-sm)", background=c if on else fg("bg-elevated"), border=f"1px solid {c if on else fg('border')}")
nm = f"Score Dots / {v}"
reg("Score Bar / Dots / Stars", nm, row(nm, row("Dots", dots, gap="4px") + T("Label", LABELS.get(v, ""), 12, fg("text-primary"), 600, lh=16, width="76px"), gap="16px"), "5 squares 26px + label")
for v in range(0, 6):
nm = f"Stars / {v}"
reg("Score Bar / Dots / Stars", nm, row(nm, box("Stars", f'<span style="color:var(--color-star)">{"★" * v}</span><span style="color:var(--color-border)">{"☆" * (5 - v)}</span>',
font_size="13px", letter_spacing="1px", line_height="16px")), "★ gold / ☆ border")
# ---------------------------------------------------------------- Donut / Spin / Empty / Loading
import math
def donut(segs, size=132, th=16):
r = (size - th) / 2
c = size / 2
tot = sum(v for v, _ in segs)
a0 = -math.pi / 2
paths = ""
for v, col_ in segs:
a1 = a0 + v / tot * 2 * math.pi
large = 1 if (a1 - a0) > math.pi else 0
paths += f'<path d="M{c + r * math.cos(a0):.2f} {c + r * math.sin(a0):.2f} A{r} {r} 0 {large} 1 {c + r * math.cos(a1):.2f} {c + r * math.sin(a1):.2f}" fill="none" stroke="{col_}" stroke-width="{th}"/>'
a0 = a1
ring = f'<svg layer-name="Ring" width="{size}" height="{size}" viewBox="0 0 {size} {size}"><circle cx="{c}" cy="{c}" r="{r}" fill="none" stroke="#f0f0f0" stroke-width="{th}"/>{paths}</svg>'
center = col("Center", T("Total", "42", 26, fg("text-primary"), 700, lh=32) + T("Caption", "sessions", 12, fg("text-secondary"), lh=16), align_items="center",
justify_content="center", position="absolute", left="0px", top="0px", width=f"{size}px", height=f"{size}px")
return ring + center
reg("Donut / Spin / Empty / Loading", "Donut", box("Donut", donut([(18, "#52c41a"), (12, "#1677ff"), (8, "#faad14"), (4, "#ff4d4f")]), position="relative", display="flex", width="132px", height="132px"),
"132px ring, 16px stroke; segments = stroke colors")
def spin(name, s):
r = s * 0.2
o = s * 0.35
c = s / 2
pts = [(c, c - o, 1), (c + o, c, .3), (c, c + o, .3), (c - o, c, .3)]
cs = "".join(f'<circle cx="{x:.1f}" cy="{y:.1f}" r="{r:.1f}" fill="#1677ff" fill-opacity="{op}"/>' for x, y, op in pts)
return box(name, f'<svg layer-name="Dots" width="{s}" height="{s}" viewBox="0 0 {s} {s}">{cs}</svg>', display="flex")
reg("Donut / Spin / Empty / Loading", "Spin / Small", spin("Spin / Small", 16), "antd Spin small")
reg("Donut / Spin / Empty / Loading", "Spin", spin("Spin", 20), "antd Spin default")
reg("Donut / Spin / Empty / Loading", "Spin / Large", spin("Spin / Large", 32), "antd Spin large")
reg("Donut / Spin / Empty / Loading", "Loading Block",
row("Loading Block", spin("Spin Icon", 32) + T("Tip", "Loading…", 14, fg("text-secondary")), justify_content="center", gap="8px", padding="var(--spacing-xl)", width="360px"),
"LoadingBlock: Spin + tip")
EMPTY_SVG = ('<svg layer-name="Illustration" width="64" height="41" viewBox="0 0 64 41"><ellipse cx="32" cy="33" rx="32" ry="7" fill="#f5f5f5"/>'
'<path d="M55 12.8 44.9 1.3C44.4.5 43.7 0 42.9 0H21.1c-.7 0-1.5.5-1.9 1.3L9 12.8V22h46z" fill="#fafafa" stroke="#d9d9d9"/>'
'<path d="M41.6 16c0-1.7 1-3 2.3-3H55v18c0 2-1.5 3.5-3.4 3.5H12.4C10.5 34.5 9 33 9 31V13h11.1c1.3 0 2.3 1.3 2.3 3 0 1.6 1 3 2.3 3h14.6c1.3 0 2.3-1.4 2.3-3z" fill="#ffffff" stroke="#d9d9d9"/></svg>')
reg("Donut / Spin / Empty / Loading", "Empty / Simple",
col("Empty / Simple", EMPTY_SVG + T("Description", "No data", 14, fg("text-disabled")), align_items="center", gap="8px", padding="var(--spacing-xl)", width="360px"), "antd Empty (simple image)")
reg("Donut / Spin / Empty / Loading", "Empty State",
col("Empty State", EMPTY_SVG + col("Texts", T("Title", "No sessions yet", 14, fg("text-primary"), 500, text_align="center") +
T("Description", "Create a session to get started.", 14, fg("text-secondary"), text_align="center"), align_items="center"),
align_items="center", gap="8px", padding="var(--spacing-xl)", width="360px"), "EmptyState: title + description")
# ---------------------------------------------------------------- Page headers
HEAD_W = "1192px"
def title_block(t, s):
return col("Titles", T("Title", t, 24, fg("text-primary"), 600, lh=32) + T("Subtitle", s, 14, fg("text-secondary")), gap="0px")
reg("Page headers", "Page Header",
row("Page Header", title_block("Page title", "Page subtitle") , justify_content="space-between", width=HEAD_W, padding="var(--page-padding)",
background=fg("bg-elevated"), border="1px solid var(--color-border-light)"), "PageHeader w/o actions (1192 = 1440 - sidebar)")
reg("Page headers", "Admin Shell Header",
row("Admin Shell Header", title_block("Dashboard", "Welcome to your dashboard") +
row("Actions", "<x-paper-instance component=\"Button / Notification Bell\"/>", gap="12px"),
justify_content="space-between", width=HEAD_W, padding="var(--page-padding)", background=fg("bg-elevated"), border="1px solid var(--color-border-light)"),
"AdminShell header: title, subtitle, bell (instance of Button / Notification Bell)")
reg("Page headers", "Candidate Shell Header",
col("Candidate Shell Header", row("Header Card", title_block("Smoke Candidate", "Session af17f53a") +
row("Exit Session", ic("sign-out", 16, fg("text-primary"), "Exit Icon") + T("Exit Label", "Exit session", 14, fg("text-primary")), justify_content="center", gap="8px", height="34px",
padding="0 16px", background=fg("bg-elevated"), border="1px solid var(--color-border)", border_radius="var(--radius-md)"),
justify_content="space-between", width="1144px", padding="var(--page-padding)", background=fg("bg-elevated")),
width=HEAD_W, padding="16px 24px", background=fg("bg-layout")), "CandidateShell header (white card inset on layout bg)")
reg("Page headers", "Section Title",
row("Section Title", col("Texts", T("Title", "Hiring pipeline", 16, fg("text-primary"), 600, lh=22) + T("Subtitle", "Across all open roles", 12, fg("text-secondary"), lh=16)),
justify_content="space-between", width="360px"), "SectionTitle: 16/600 + 12 sub")
reg("Page headers", "Section Title / With Action",
row("Section Title / With Action", col("Texts", T("Title", "Upcoming interviews", 16, fg("text-primary"), 600, lh=22) + T("Subtitle", "Next 7 days", 12, fg("text-secondary"), lh=16)) +
T("Action", "Library →", 14, fg("text-secondary")), justify_content="space-between", width="360px"), "with action slot")
# ---------------------------------------------------------------- Data table
TW = "960px"
def cells(prefix, texts, **st):
return "".join(T(f"{prefix} {i}", t, st.pop("size", 14) if False else st.get("size", 14), st.get("color", fg("text-primary")), st.get("weight", 400),
flex_grow="1", **({"letter_spacing": "0.36px"} if st.get("ls") else {})) for i, t in enumerate(texts, 1))
def tcells(prefix, texts, size, color, weight, ls=None):
return "".join(T(f"{prefix} {i}", t, size, color, weight, flex="1", **({"letter_spacing": ls} if ls else {})) for i, t in enumerate(texts, 1))
reg("Data Table (packages/ui DataTable)", "Data Table / Header Row",
row("Data Table / Header Row", tcells("Col", ["TITLE", "TYPE", "STATUS", "SCORE", "UPDATED"], 12, fg("text-secondary"), 600, "0.36px"), width=TW, padding="12px 16px", gap="16px",
background=fg("bg-sider"), border="1px solid var(--color-border-light)"), "uppercase 12/600; hide unused Col n with display:none")
for nm, bgc in [("Row", "bg-elevated"), ("Row Hover", "bg-selected")]:
reg("Data Table (packages/ui DataTable)", f"Data Table / {nm}",
row(f"Data Table / {nm}", tcells("Cell", ["Two Sum", "Coding", "Active", "4.2", "Jun 12"], 14, fg("text-primary"), 400), width=TW, padding="12px 16px", gap="16px",
background=fg(bgc), border="1px solid var(--color-border-light)"), "13px vertical padding in code (comfortable)")
def page_btn(n, on=False, name=None):
return row(name or f"Page {n}", T("Page Label", n, 14, fg("brand-primary") if on else fg("text-primary")), justify_content="center", width="32px", height="32px",
border_radius="var(--radius-md)", background=fg("bg-elevated"), border=f"1px solid {fg('brand-primary') if on else fg('border')}" if on else "1px solid transparent")
PAGER = (row("Prev", ic("caret-left", 14, fg("text-secondary"), "Prev Icon"), justify_content="center", width="32px", height="32px") + page_btn("1", True) + page_btn("2") + page_btn("3") +
page_btn("…", name="Page More") + page_btn("9") + row("Next", ic("caret-right", 14, fg("text-secondary"), "Next Icon"), justify_content="center", width="32px", height="32px"))
reg("Data Table (packages/ui DataTable)", "Pagination", row("Pagination", PAGER, gap="8px"), "antd Pagination: 32px items, active = blue border")
reg("Data Table (packages/ui DataTable)", "Data Table / Footer",
row("Data Table / Footer", T("Summary", "Showing 1-5 of 42", 12, fg("text-secondary"), lh=16) + row("Pager", PAGER, gap="8px"), justify_content="space-between", width=TW,
padding="12px 16px", background=fg("bg-elevated")), "summary + pagination")
reg("Data Table (packages/ui DataTable)", "Data Table / Empty",
row("Data Table / Empty", T("Message", "No rows match your filters.", 14, fg("text-disabled"), text_align="center"), justify_content="center", width=TW, padding="var(--spacing-xxl)",
background=fg("bg-elevated")), "emptyMessage, 48px padding")
reg("Ant Table", "Ant Table / Header Row",
row("Ant Table / Header Row", tcells("Col", ["Title", "Code", "Status", "Created", "Actions"], 14, fg("text-primary"), 600), width=TW, padding="16px", gap="16px",
background=fg("bg-sider"), border="1px solid var(--color-border-light)"), "antd Table th (#fafafa, 14/600)")
reg("Ant Table", "Ant Table / Row",
row("Ant Table / Row", tcells("Cell", ["Backend Engineer", "A1B2C3", "Scheduled", "Jun 12", "View"], 14, fg("text-primary"), 400), width=TW, padding="16px", gap="16px",
background=fg("bg-elevated"), border="1px solid var(--color-border-light)"), "antd Table td, row ~55px")
# ---------------------------------------------------------------- Form controls
CTRL = dict(background=fg("bg-elevated"), border_radius="var(--radius-md)")
def inp(name, ph="", val=None, large=False, state="", w=280, prefix=None, suffix=None, disabled=False):
h, fs, pad = (40, 16, 12) if large else (32, 14, 12)
b = {"focus": fg("brand-primary"), "error": fg("error")}.get(state, fg("border"))
kids = (T("Prefix", prefix, fs, fg("text-secondary")) if prefix else "")
kids += T("Value", val if val is not None else ph, fs, fg("text-disabled") if disabled else (fg("text-primary") if val is not None else fg("text-placeholder")), flex_grow="1")
kids += suffix or ""
st = dict(CTRL)
if disabled:
st["background"] = fg("bg-layout")
if state == "focus":
st["box_shadow"] = "0 0 0 2px #1677ff33"
if state == "error":
st["box_shadow"] = "0 0 0 2px #cf132233"
return row(name, kids, gap="4px", width=f"{w}px", height=f"{h}px", padding=f"0 {pad}px", border=f"1px solid {b}", **st)
reg("Input (AntD)", "Input", inp("Input", "Placeholder"), "32px, radius 8, border #d9d9d9")
reg("Input (AntD)", "Input / Filled", inp("Input / Filled", val="Netcracker"), "with value")
reg("Input (AntD)", "Input / Focus", inp("Input / Focus", val="Netcracker", state="focus"), "focus ring")
reg("Input (AntD)", "Input / Error", inp("Input / Error", val="invalid", state="error"), "error state")
reg("Input (AntD)", "Input / Disabled", inp("Input / Disabled", val="Disabled", disabled=True), "disabled")
reg("Input (AntD)", "Input / Large", inp("Input / Large", "Placeholder", large=True), "size=large: 40px / 16px (forms)")
reg("Input (AntD)", "Input / Large Filled", inp("Input / Large Filled", val="Netcracker", large=True), "size=large with value")
reg("Input (AntD)", "Input / Large Focus", inp("Input / Large Focus", val="Netcracker", large=True, state="focus"), "size=large focus")
reg("Input (AntD)", "Input / Large Error", inp("Input / Large Error", val="invalid", large=True, state="error"), "size=large error")
reg("Input (AntD)", "Input / With Prefix", inp("Input / With Prefix", val="netcracker", large=True, prefix="hireloop.io/"), "Workspace URL field")
reg("Input (AntD)", "Input / Password", inp("Input / Password", "Password", large=True, suffix=ic("eye", 16, fg("text-disabled"), "Eye Icon")), "Input.Password large")
reg("Input (AntD)", "Input / Search", inp("Input / Search", "Search templates…", suffix=ic("magnifying-glass", 14, fg("text-disabled"), "Search Icon")), "Input.Search")
reg("Input (AntD)", "Input Number",
row("Input Number", T("Value", "60", 14, fg("text-primary"), flex_grow="1") +
col("Steppers", row("Up", ic("caret-up", 10, fg("text-disabled"), "Up Icon"), justify_content="center", height="15px") + row("Down", ic("caret-down", 10, fg("text-disabled"), "Down Icon"), justify_content="center", height="15px"), width="22px"),
gap="4px", width="120px", height="32px", padding="0 0 0 12px", border="1px solid var(--color-border)", **CTRL), "InputNumber (steppers on the right)")
reg("Input (AntD)", "Date Picker", inp("Date Picker", "Select date", w=200, suffix=ic("calendar-blank", 14, fg("text-disabled"), "Calendar Icon")), "DatePicker")
reg("Input (AntD)", "Textarea",
row("Textarea", T("Value", "Brief description of your team or hiring process", 14, fg("text-placeholder"), flex_grow="1"), align_items="flex-start", width="280px", height="76px",
padding="4px 12px", border="1px solid var(--color-border)", **CTRL), "Input.TextArea, 3 rows")
reg("Input (AntD)", "Textarea / Filled",
row("Textarea / Filled", T("Value", "Write your answer here.", 14, fg("text-primary"), flex_grow="1"), align_items="flex-start", width="280px", height="76px",
padding="4px 12px", border="1px solid var(--color-border)", **CTRL), "filled")
for large in (False, True):
for filled in (False, True):
nm = f"Select{' / Large' if large else ''}{' Filled' if filled and large else (' / Filled' if filled else '')}"
reg("Select (AntD)", nm, inp(nm, "Select…", val="UTC" if filled else None, large=large, suffix=ic("caret-down", 12, fg("text-disabled"), "Arrow Icon")), "Select closed")
reg("Select (AntD)", "Select / Dropdown",
col("Select / Dropdown", "".join(row(f"Option {i}", T(f"Option {i} Label", t, 14, fg("text-primary"), 600 if i == 2 else 400), height="32px", padding="0 12px", border_radius="var(--radius-sm)",
**({"background": fg("bg-selected")} if i == 2 else {})) for i, t in enumerate(["UTC", "UTC+3 (Moscow)", "UTC+1 (CET)"], 1)),
width="280px", padding="4px", background=fg("bg-elevated"), border_radius="var(--radius-md)", box_shadow="var(--shadow-md)"), "open options panel; selected = bg-selected + 600")
for chk in (False, True):
nm = f"Checkbox / {'Checked' if chk else 'Unchecked'}"
bx = row("Box", ic("check", 12, fg("text-inverse"), "Check Icon") if chk else "", justify_content="center", width="16px", height="16px", border_radius="var(--radius-sm)",
background=fg("brand-primary") if chk else fg("bg-elevated"), border=f"1px solid {fg('brand-primary') if chk else fg('border')}")
reg("Checkbox / Radio / Switch", nm, row(nm, bx + T("Label", "Keep me signed in", 14, fg("text-primary")), gap="8px"), "16px box")
for sel in (False, True):
nm = f"Radio / {'Selected' if sel else 'Unselected'}"
bx = row("Circle", box("Dot", width="8px", height="8px", border_radius="99px", background=fg("brand-primary")) if sel else "", justify_content="center", width="16px", height="16px",
border_radius="99px", background=fg("bg-elevated"), border=f"1px solid {fg('brand-primary') if sel else fg('border')}")
reg("Checkbox / Radio / Switch", nm, row(nm, bx + T("Label", "Option label", 14, fg("text-primary")), gap="8px"), "radio")
for on in (False, True):
nm = f"Switch / {'On' if on else 'Off'}"
reg("Checkbox / Radio / Switch", nm, row(nm, box("Knob", width="18px", height="18px", border_radius="99px", background=fg("bg-elevated")), width="44px", height="22px", padding="0 4px",
border_radius="99px", background=fg("brand-primary") if on else fg("switch-off"), justify_content="flex-end" if on else "flex-start"), "44x22")
def field(name, label, req=False, err=None, help_=None, val=None, large=True):
lab = row("Label Row", (T("Required Mark", "*", 14, fg("error")) if req else "") + T("Label", label, 14, fg("text-primary")), gap="4px", height="32px")
ctrl = inp("Input", "Placeholder", val=val, large=large, state="error" if err else "", w=480)
tail = T("Help", err or help_, 14, fg("error") if err else fg("text-secondary")) if (err or help_) else ""
return col(name, lab + ctrl + tail, gap="0px", width="480px")
reg("Form Field", "Form Field", field("Form Field", "Workspace name", val="Netcracker"), "label + large input (AntD Form.Item vertical)")
reg("Form Field", "Form Field / Required", field("Form Field / Required", "Email", req=True), "* required mark")
reg("Form Field", "Form Field / Error", field("Form Field / Error", "Email", req=True, err="Please enter a valid email", val="x@"), "error + message")
reg("Form Field", "Form Field / With Help", field("Form Field / With Help", "Workspace URL", help_="Used in invite links."), "help text")
# ---------------------------------------------------------------- Modal, Drawer, Tabs, Alert, Tooltip, Progress, Skeleton
reg("Overlays", "Modal",
col("Modal", row("Header", T("Title", "Modal title", 16, fg("text-primary"), 600) + ic("x", 14, fg("text-secondary"), "Close Icon"), justify_content="space-between") +
T("Body", "Modal body content.", 14, fg("text-primary"), height="96px") +
row("Footer", row("Cancel Button", T("Cancel Label", "Cancel", 14, fg("text-primary")), justify_content="center", height="32px", padding="0 16px", border="1px solid var(--color-border)", border_radius="var(--radius-md)", background=fg("bg-elevated")) +
row("OK Button", T("OK Label", "OK", 14, fg("text-inverse")), justify_content="center", height="32px", padding="0 16px", background=fg("brand-primary"), border_radius="var(--radius-md)"),
justify_content="flex-end", gap="8px"),
width="520px", gap="12px", padding="20px 24px", background=fg("bg-elevated"), border_radius="var(--radius-lg)", box_shadow="var(--shadow-lg)"), "antd Modal; put on a bg-overlay frame")
reg("Overlays", "Drawer",
col("Drawer", row("Header", T("Title", "Notes (markdown)", 16, fg("text-primary"), 600) + ic("x", 14, fg("text-secondary"), "Close Icon"), justify_content="space-between", padding="16px 24px", border="1px solid var(--color-border-light)") +
col("Body Wrap", T("Body", "Drawer body", 14, fg("text-primary")), padding="var(--spacing-lg)"), width="420px", height="900px", background=fg("bg-elevated"), box_shadow="var(--shadow-lg)"),
"antd Drawer (right, full height)")
reg("Overlays", "Tooltip",
col("Tooltip", row("Bubble", T("Text", "Resolve all conflicts first", 14, fg("text-inverse")), padding="6px 12px", background=fg("tooltip-bg"), border_radius="var(--radius-md)") +
'<svg layer-name="Arrow" width="16" height="6" viewBox="0 0 16 6"><polygon points="0,0 16,0 8,6" fill="#000000" fill-opacity="0.85"/></svg>', align_items="center"), "antd Tooltip")
for t, (bg_, bd_, icn, icc) in {"Info": ("tag-blue-bg", "tag-blue-bd", "info", "brand-primary"), "Success": ("tag-green-bg", "tag-green-bd", "check-circle", "success"),
"Warning": ("tag-gold-bg", "tag-gold-bd", "warning-circle", "score-warn"), "Error": ("tag-red-bg", "tag-red-bd", "x-circle", "score-poor")}.items():
for icon_on in (False, True):
nm = f"Alert / {t}{' With Icon' if icon_on else ''}"
reg("Alert", nm, row(nm, (ic(icn, 16, fg(icc), "Alert Icon") if icon_on else "") + T("Message", f"{t} message goes here.", 14, fg("text-primary"), flex_grow="1"),
gap="8px", width="400px", padding="8px 12px", background=fg(bg_), border=f"1px solid {fg(bd_)}", border_radius="var(--radius-md)"), "antd Alert")
for i, nm_ in enumerate(["Tab"], 1):
pass
for on in (True, False):
for large in (False, True):
nm = f"Tabs / Tab {'Active' if on else 'Inactive'}{' Large' if large else ''}"
reg("Tabs", nm, col(nm, row("Tab Pad", T("Label", "Workspace", 16 if large else 14, fg("brand-primary") if on else fg("text-primary")), padding="12px 0") +
box("Ink Bar", align_self="stretch", height="2px", background=fg("brand-primary") if on else "transparent"), align_items="stretch"), "antd Tabs item (ink bar when active)")
def tabbar(labels, a, large):
items = ""
for k, l in enumerate(labels, 1):
on = k == a
items += col(f"Tab {k}", row(f"Tab {k} Pad", T(f"Tab {k} Label", l, 16 if large else 14, fg("brand-primary") if on else fg("text-primary")), padding="12px 0") +
box(f"Tab {k} Ink", align_self="stretch", height="2px", background=fg("brand-primary") if on else "transparent"))
return items
for a in (1, 2, 3):
nm = f"Tabs / Bar 3 Active {a}"
reg("Tabs", nm, col(nm, row("Tabs", tabbar(["Overview", "Details", "History"], a, False), gap="32px") + box("Divider", align_self="stretch", height="1px", background=fg("border-light")), width="480px", gap="0px"), "antd Tabs bar, 14px")
L5 = ["Workspace", "Team", "Scoring", "Notifications", "Integrations"]
for a in range(1, 6):
nm = f"Tabs / Bar 5 Large Active {a}"
reg("Tabs", nm, col(nm, row("Tabs", tabbar(L5, a, True), gap="32px") + box("Divider", align_self="stretch", height="1px", background=fg("border-light")), width="720px", gap="0px"), "configuration page tabs (size=large)")
reg("Misc AntD", "Progress / Circle",
box("Progress / Circle", '<svg layer-name="Ring" width="64" height="64" viewBox="0 0 64 64"><circle cx="32" cy="32" r="29" fill="none" stroke="#f0f0f0" stroke-width="6"/>'
'<path d="M32 3 A29 29 0 1 1 8.6 49" fill="none" stroke="#52c41a" stroke-width="6" stroke-linecap="round"/></svg>' +
row("Center", T("Value", "85", 16, fg("text-primary")), justify_content="center", position="absolute", left="0px", top="0px", width="64px", height="64px"),
position="relative", display="flex", width="64px", height="64px"), "Progress type=circle size=64")
reg("Misc AntD", "Skeleton",
col("Skeleton", box("Title Bar", width="152px", height="16px", border_radius="var(--radius-sm)", background=fg("border-light")) +
"".join(box(f"Line {i}", width="400px" if i < 4 else "244px", height="16px", border_radius="var(--radius-sm)", background=fg("border-light")) for i in range(1, 5)),
gap="16px", width="400px"), "antd Skeleton active (static)")
# ---------------------------------------------------------------- App shell
NAV = {"Workspace": [("Dashboard", "squares-four"), ("Challenges", "list-bullets"), ("Question bank", "database"), ("Sessions", "clock"), ("Schedule", "calendar")],
"Manage": [("Configuration", "gear"), ("Rubric", "table")]}
def nav_item(label, icon, active=False):
c = fg("text-inverse") if active else fg("sidebar-text")
return row(f"Nav / {label}", ic(icon, 18, c, f"Nav Icon / {label}") + T(f"Nav Label / {label}", label, 14, c, 600 if active else 500, flex_grow="1"),
gap="12px", height="36px", padding="0 12px", border_radius="var(--radius-md)", background=fg("brand-primary") if active else "transparent")
def brand(mark_html, sub):
return row("Brand", row("Brand Mark", mark_html, justify_content="center", width="30px", height="30px", border_radius="var(--radius-md)", background=fg("brand-primary")) +
col("Brand Text", T("Brand Name", "Hireloop", 16, fg("text-inverse"), 700, lh=18) + T("Brand Sub", sub, 12, fg("sidebar-text-muted"), lh=16), gap="0px"),
gap="8px", padding="16px 16px 12px")
def user_footer(name, email_or_role, tag=None, admin=True):
ini = "".join(p[0] for p in name.split()[:2]).upper()
meta = row("User Name Row", T("User Name", name, 13, fg("text-inverse"), 600, lh=18) + (row("User Role Tag", T("User Role Tag Label", tag, 12, fg("brand-secondary"), 700, lh=16), padding="0 8px", border_radius="var(--radius-sm)", background=fg("sidebar-tag-bg")) if tag else ""), gap="8px") + \
T("User Email", email_or_role, 12, fg("sidebar-text-muted"), lh=16)
right = (row("Footer Actions", row("Settings Button", ic("gear", 16, fg("sidebar-text-muted"), "Settings Icon"), justify_content="center", width="28px", height="28px") +
row("Sign Out Button", ic("sign-out", 16, fg("sidebar-text-muted"), "Sign Out Icon"), justify_content="center", width="28px", height="28px")) if admin else ic("caret-down", 14, fg("sidebar-text-muted"), "Chevron Icon"))
return row("User Footer", row("User Avatar", T("User Initials", ini, 13, fg("text-inverse"), 600, lh=16), justify_content="center", width="34px", height="34px", border_radius="99px", background=fg("brand-primary")) +
col("User Meta", meta, flex_grow="1", gap="0px") + right, gap="8px", padding="var(--spacing-md)", border="1px solid var(--color-sidebar-border)")
def admin_sidebar(name, active=None):
groups = ""
for g, items in NAV.items():
groups += col(f"Group / {g}", T(f"Group Label / {g}", g.upper(), 12, fg("sidebar-text-faint"), 600, lh=16, letter_spacing="0.72px", padding="0 12px 8px") +
"".join(nav_item(l, i, l == active) for l, i in items), gap="4px", padding="0 0 16px")
return col(name, brand(T("Brand Mark Letter", "H", 14, fg("text-inverse"), 700, lh=18), "Admin workspace") + col("Nav", groups, gap="0px", padding="4px 16px 16px", flex_grow="1") + user_footer("Admin", "admin@test.local", "ADMIN"),
width="248px", height="900px", gap="0px", background=fg("sidebar-bg"), border="1px solid var(--color-sidebar-border)")
reg("App shell", "Admin Sidebar", admin_sidebar("Admin Sidebar"), "248x900 dark nav; no active item")
for g, items in NAV.items():
for l, _ in items:
reg("App shell", f"Admin Sidebar / {l} Active", admin_sidebar(f"Admin Sidebar / {l} Active", l), f"{l} highlighted")
def cand_sidebar(name, active=None):
its = [("Home", "house"), ("My interview", "code")]
return col(name, brand(ic("command", 16, fg("text-inverse"), "Brand Mark Icon"), "Candidate") +
col("Nav", "".join(nav_item(l, i, l == active) for l, i in its), gap="4px", padding="8px 16px", flex_grow="1") + user_footer("Smoke Candidate", "Candidate", admin=False),
width="248px", height="900px", gap="0px", background=fg("sidebar-bg"), border="1px solid var(--color-sidebar-border)")
reg("App shell", "Candidate Sidebar", cand_sidebar("Candidate Sidebar"), "248x900; no active item")
reg("App shell", "Candidate Sidebar / Home Active", cand_sidebar("Candidate Sidebar / Home Active", "Home"), "Home highlighted")
reg("App shell", "Candidate Sidebar / My interview Active", cand_sidebar("Candidate Sidebar / My interview Active", "My interview"), "My interview highlighted")
+123
View File
@@ -0,0 +1,123 @@
"""Foundation documentation sections: colors, typography, spacing, radius & shadow, page layout."""
from ds_common import *
W = 1440
def section(name, title, body, intro=None):
kids = T("Section Title", title, 24, weight=700) + (T("Section Intro", intro, 14, fg2) if intro else "") + body
return col(name, kids, width=f"{W}px", gap="var(--spacing-xl)", padding="var(--spacing-xl)", background="transparent")
fg2 = "var(--color-text-secondary)"
def card(name, title, body, w=None, **st):
return col(name, T("Card Title", title, 16, weight=600) + body, gap="var(--spacing-md)", padding="var(--spacing-lg)", background="var(--color-bg-elevated)",
border="1px solid var(--color-border-light)", border_radius="var(--radius-lg)", **({"width": w} if w else {}), **st)
ALPHA = {"color-bg-overlay": "rgba(0,0,0,.45)", "color-text-primary": "rgba(0,0,0,.88)", "color-text-secondary": "rgba(0,0,0,.65)", "color-text-disabled": "rgba(0,0,0,.45)",
"color-sidebar-border": "rgba(255,255,255,.07)", "color-sidebar-text": "rgba(255,255,255,.65)", "color-sidebar-text-muted": "rgba(255,255,255,.45)",
"color-sidebar-text-faint": "rgba(255,255,255,.35)", "color-sidebar-hover": "rgba(255,255,255,.06)", "color-sidebar-badge": "rgba(255,255,255,.25)",
"color-sidebar-tag-bg": "rgba(22,119,255,.25)", "color-text-placeholder": "rgba(0,0,0,.25)", "color-tooltip-bg": "rgba(0,0,0,.85)", "color-switch-off": "rgba(0,0,0,.25)",
"color-focus-ring": "rgba(22,119,255,.10)"}
COLOR_GROUPS = {
"Brand": ["color-brand-primary", "color-brand-secondary", "color-text-link", "color-info", "color-button-primary-hover"],
"Background": ["color-bg-layout", "color-bg-elevated", "color-bg-sider", "color-bg-selected", "color-bg-overlay"],
"Border": ["color-border", "color-border-light", "color-border-strong", "color-card-hover-border"],
"Text": ["color-text-primary", "color-text-secondary", "color-text-disabled", "color-text-inverse", "color-text-placeholder"],
"Status": ["color-success", "color-warning", "color-error", "color-star", "color-score-warn", "color-score-poor"],
"Diff & code": ["color-diff-added", "color-diff-removed", "color-diff-neutral", "color-code-bg", "color-code-fg"],
"Sidebar (dark shell)": ["color-sidebar-bg", "color-sidebar-border", "color-sidebar-text", "color-sidebar-text-muted", "color-sidebar-text-faint", "color-sidebar-hover", "color-sidebar-badge", "color-sidebar-tag-bg"],
"Tag tones": [f"color-tag-{t}-{x}" for t in ("blue", "green", "gold", "red", "purple", "cyan") for x in ("fg", "bg", "bd")],
"Avatar palette": ["color-brand-primary", "color-avatar-purple", "color-avatar-teal", "color-warning", "color-avatar-pink", "color-success", "color-avatar-orange", "color-avatar-indigo"],
"Controls": ["color-button-dark", "color-tooltip-bg", "color-switch-off", "color-focus-ring"],
}
def swatch(var, val):
shown = ALPHA.get(var) or val
return row(f"Swatch / {var}", row("Chip Bg", box("Chip", width="40px", height="40px", border_radius="var(--radius-md)", background=f"var(--{var})", border="1px solid var(--color-border-light)"), background="var(--color-sidebar-bg)" if "sidebar" in var and var != "color-sidebar-bg" else "transparent", border_radius="var(--radius-md)") +
col("Names", T("Token Name", var.replace("color-", ""), 14, weight=600, lh=18) + T("Token Var", f"var(--{var})", 12, fg2, lh=16), gap="0px", flex_grow="1") +
T("Token Value", shown, 12, fg2, lh=16), gap="12px")
def colors(vals):
cards = ""
for g, names in COLOR_GROUPS.items():
cards += card(f"Colors / {g}", g, col("Swatches", "".join(swatch(n, vals[n]) for n in names), gap="12px"), w="440px")
body = row("Color Groups", cards, flex_wrap="wrap", gap="var(--spacing-lg)", align_items="flex-start")
return section("Section / Colors", "Colors", body, "Every color is a PaperCraft variable named exactly like the CSS custom property in packages/ui/src/tokens.css, minus the leading --. Use var(--color-brand-primary) etc.")
FONT = [("font-size-xs", 12, "Labels, table headers (uppercase 600), Tag, Pill, captions", 16),
("font-size-sm", 14, "Body, buttons, inputs, nav items, table cells", 22),
("font-size-md", 16, "SectionTitle, card titles, large inputs, brand name", 24),
("font-size-lg", 18, "Large headings (token only)", 26),
("font-size-xl", 20, "Large headings (token only)", 28),
("font-size-xxl", 24, "Page titles (PageHeader h3, 600)", 32)]
OTHER = [(30, "Stat value (700)"), (26, "Donut total (700)"), (15, "Sidebar brand name (700)"), (13, "Sidebar user name (600), Avatar initials"), (12, "Sidebar group label, role tag (clamped from 10-11)")]
def typography():
rows = ""
for tok, px, use, lh in FONT:
rows += row(f"Type / {tok}", T("Type Token", tok, 12, fg2, width="140px", lh=16) + T("Type Size", f"{px}px / {lh}px", 12, fg2, width="100px", lh=16) +
T("Type Sample", "The quick brown fox jumps over the lazy dog", px, lh=lh, width="620px") + T("Type Usage", use, 12, fg2, flex_grow="1", lh=16), gap="var(--spacing-md)")
other = "".join(row(f"Type / {px}px", T("Type Token", "(literal)", 12, fg2, width="140px", lh=16) + T("Type Size", f"{px}px", 12, fg2, width="100px", lh=16) +
T("Type Sample", "Hireloop 12 42", px, weight=700 if px in (30, 26, 15) else 400, width="620px") + T("Type Usage", use, 12, fg2, flex_grow="1", lh=16), gap="var(--spacing-md)") for px, use in OTHER)
wts = row("Weights", "".join(col(f"Weight / {w}", T("Weight Sample", "Aa Hireloop", 20, weight=w) + T("Weight Note", note, 12, fg2, lh=16), gap="4px") for w, note in
[(400, "400 regular: body"), (500, "500 medium: buttons, tags (renders as 400: Inter regular/bold only)"), (600, "600 semibold: titles, headers (renders bold)"), (700, "700 bold: stat values, brand")]), gap="var(--spacing-xl)", align_items="flex-start")
ant = "".join(row(f"AntD / {n}", T("Type Token", n, 12, fg2, width="140px", lh=16) + T("Type Size", f"{px}px / {lh}px", 12, fg2, width="100px", lh=16) +
T("Type Sample", "Page heading", px, weight=600, lh=lh, width="620px") + T("Type Usage", u, 12, fg2, flex_grow="1", lh=16), gap="var(--spacing-md)")
for n, px, lh, u in [("Typography.Title 1", 38, 46, "unused"), ("Typography.Title 3", 24, 32, "PageHeader title, login heading"), ("Typography.Title 4", 20, 28, "card headings"), ("Typography.Title 5", 16, 24, "card section titles (600)")])
body = card("Type scale", "Type scale (font-size tokens)", col("Rows", rows, gap="var(--spacing-md)")) + card("Type literals", "Sizes used literally in the code", col("Rows", other, gap="var(--spacing-md)")) + \
card("Type weights", "Weights", wts) + card("Type AntD", "AntD Typography.Title (all text is Inter)", col("Rows", ant, gap="var(--spacing-md)"))
return section("Section / Typography", "Typography", col("Type Cards", body, gap="var(--spacing-lg)"), "Single family: Inter (the source uses the system UI stack). Line-height ~1.5 (1.35 for headings).")
def spacing():
bars = ""
for n, px in [("xs", 4), ("sm", 8), ("md", 16), ("lg", 24), ("xl", 32), ("xxl", 48)]:
bars += row(f"Space / {n}", T("Space Token", f"spacing-{n}", 14, weight=600, width="120px") + T("Space Value", f"{px}px", 12, fg2, width="48px", lh=16) +
box("Space Bar", width=f"var(--spacing-{n})", height="16px", background="var(--color-brand-primary)", border_radius="var(--radius-sm)"), gap="var(--spacing-md)")
for n, px in [("page-padding", 24), ("page-padding-lg", 48)]:
bars += row(f"Space / {n}", T("Space Token", n, 14, weight=600, width="120px") + T("Space Value", f"{px}px", 12, fg2, width="48px", lh=16) +
box("Space Bar", width=f"var(--{n})", height="16px", background="var(--color-brand-secondary)", border_radius="var(--radius-sm)"), gap="var(--spacing-md)")
return section("Section / Spacing", "Spacing", card("Spacing scale", "Spacing scale (4px grid)", col("Bars", bars, gap="var(--spacing-md)"), w="720px"),
"Tokens spacing-xs..xxl and page-padding. Off-scale values in the code: 2/5/6/7/9/10/12/14/18/20/22.")
def radius_shadow():
rad = "".join(col(f"Radius / {n}", box("Radius Sample", width="96px", height="64px", background="var(--color-bg-selected)", border="1px solid var(--color-brand-primary)", border_radius=f"var(--radius-{n})") +
T("Radius Token", f"radius-{n}", 14, weight=600, lh=18) + T("Radius Value", f"{px}px: {u}", 12, fg2, lh=16), gap="8px")
for n, px, u in [("sm", 4, "Tag, Segmented item, ScoreDots"), ("md", 8, "Button, inputs, nav items, AntD controls"), ("lg", 12, "Card, DataTable, Modal")])
rad += col("Radius / pill", box("Radius Sample", width="96px", height="64px", background="var(--color-bg-selected)", border="1px solid var(--color-brand-primary)", border_radius="99px") +
T("Radius Token", "99px (literal)", 14, weight=600, lh=18) + T("Radius Value", "Pill, ScoreBar, Switch, Avatar 50%", 12, fg2, lh=16), gap="8px")
shd = "".join(col(f"Shadow / {n}", box("Shadow Sample", width="160px", height="96px", background="var(--color-bg-elevated)", border="1px solid var(--color-border-light)", border_radius="var(--radius-lg)", box_shadow=f"var(--shadow-{n})") +
T("Shadow Token", f"shadow-{n}", 14, weight=600, lh=18) + T("Shadow Value", v, 12, fg2, lh=16), gap="8px")
for n, v in [("sm", "0 1px 2px rgba(0,0,0,.06): Card, Segmented active"), ("md", "0 4px 12px rgba(0,0,0,.08): Card hover, dropdowns"), ("lg", "0 8px 24px rgba(0,0,0,.12): Modal, Drawer")])
stage = row("Shadow Stage", shd, gap="var(--spacing-lg)", padding="var(--spacing-lg)", background="var(--color-bg-layout)", border_radius="var(--radius-lg)", align_items="flex-start")
return section("Section / Radius & Shadow", "Radius & Shadow", row("Radius Shadow Cards", card("Radius", "Radius", row("Radius Row", rad, gap="var(--spacing-lg)", align_items="flex-start")) + card("Shadow", "Shadows (on cards, over bg-layout)", stage), gap="var(--spacing-lg)", align_items="flex-start"))
def page_layout():
widths = [("xs", 480, "invite/verify card"), ("sm", 420, ""), ("md / narrow", 720, "narrow content"), ("lg / default", 900, ".app-content default"), ("xl / wide", 1100, ".app-content--wide")]
bars = "".join(row(f"Width / {n}", T("Width Token", f"page-max-width-{n}", 14, weight=600, width="220px") + T("Width Value", f"{px}px", 12, fg2, width="56px", lh=16) +
box("Width Bar", width=f"{px//2}px", height="16px", background="var(--color-brand-secondary)", border_radius="var(--radius-sm)") + T("Width Use", u, 12, fg2, lh=16), gap="var(--spacing-md)") for n, px, u in widths)
shell = col("Shell facts", "".join(T(f"Fact {i}", t, 14) for i, t in enumerate([
"App shell: sidebar 248px (flex 0 0 248) + main column; desktop frames are 1440x900.", "Page header: 24px padding, ~102px tall (title 24/600 + subtitle 14).",
"Content: padding 24 (page-padding), max-width 900 default / 1100 wide; centered.", "Candidate: white header card inset (padding 16 24) on bg-layout.", "Mobile (<=640px): sidebar hidden, padding 16."], 1)), gap="8px")
z = "".join(row(f"Z / {n}", T("Z Token", f"z-{n}", 14, weight=600, width="140px") + T("Z Value", str(v), 14, fg2, width="64px") + T("Z Use", u, 12, fg2, lh=16), gap="var(--spacing-md)")
for n, v, u in [("base", 1, "sticky table header"), ("dropdown", 1000, "Select / menus"), ("modal", 1050, "Modal, Drawer"), ("toast", 1100, "message / notifications")])
return section("Section / Page Layout", "Page Layout", row("Layout Cards", card("Max widths", "Page max-widths (bars drawn at 50%)", col("Bars", bars, gap="var(--spacing-md)")) + col("Layout Right", card("Shell", "App shell", shell) + card("Z-index", "z-index scale", col("Zs", z, gap="var(--spacing-sm)")), gap="var(--spacing-lg)"),
gap="var(--spacing-lg)", align_items="flex-start"))
def build(P, vals):
ids = []
for html in (colors(vals), typography(), spacing(), radius_shadow(), page_layout()):
ids += P.add("root", html)
return ids
+65
View File
@@ -0,0 +1,65 @@
"""Writes DESIGN_SYSTEM.md from the component registry (names, captions, text layers) + hand-written guidance."""
import re, sys
sys.path.insert(0, ".")
import ds_components as C
from ds_foundations import COLOR_GROUPS
from ds_common import EXTRA_VARS
LEAF = re.compile(r'layer-name="([^"]+)"[^>]*>([^<]+)</div>')
out = ["# Interview design system (PaperCraft page `Design System`)", "",
"Generated by `design_system.py` + `gen_manifest.py`. Main components are top-level frames on the `Design System` page (library area, right of the doc sections, x >= 1560). Documentation sections: `Section / Colors`, `Section / Typography`, `Section / Spacing`, `Section / Radius & Shadow`, `Section / Page Layout`, `Section / Components` (captioned instances of everything).", "",
"## How to use", "",
"1. On your page: `papercraft-cli call import_components '{\"fromPageId\":\"<Design System page id>\",\"pageId\":\"<your page id>\"}'` (skips names already there; if you delete a copy and re-import, its instances lose overrides).",
"2. Place: `<x-paper-instance component=\"Button / Primary\" overrides='{\"Label\":\"Save\"}'/>` or `inst_html(\"Button / Primary\", Label=\"Save\")` from pc.py.",
"3. Overrides JSON: key = exact layer name inside the component (first match), value = string (text) or object (CSS camelCase styles, e.g. `{\"Label\":{\"color\":\"var(--color-error)\"}}`). Hide a part: `{\"Col 5\":{\"display\":\"none\"}}`.",
"4. The instance root accepts a `style` attribute: `style=\"width:720px\"`, `style=\"background-color:var(--color-avatar-purple)\"`, `style=\"opacity:.5\"` (disabled).",
"5. Icons inside components cannot be recolored or swapped by overrides (vector layers). That is why the sidebars exist as one variant per active item. For anything else, compose in raw HTML with the recipes below.",
"6. Components hug their content (fixed width where noted). To stretch, set `style=\"width:Npx\"` on the instance. Uppercase labels (table headers, Stat label, sidebar group labels) are typed uppercase: text-transform is not supported.",
"7. Instances are page-local; no variants/properties exist, so every variant is a separate component.", ""]
out += ["## Components", ""]
for g, names in C.GROUPS.items():
out += [f"### {g}", "", "| Component (exact name) | Looks like | Text layers (override keys) |", "|---|---|---|"]
for n in names:
html = dict(C.SPECS)[n]
keys = []
for k, t in LEAF.findall(html):
if k not in keys:
keys.append(k)
keys = [f"`{k}`" for k in keys]
out.append(f"| `{n}` | {C.NOTES.get(n, '')} | {', '.join(keys) or '(none)'} |")
out.append("")
out += ["## Style-override tips", "",
"- **Recolor an Avatar**: `style=\"background-color:var(--color-avatar-purple)\"` on the instance (palette: brand-primary, avatar-purple, avatar-teal, warning, avatar-pink, success, avatar-orange, avatar-indigo; the app picks by a hash of the name). Set initials with `{\"Initials\":\"PI\"}`.",
"- **Active sidebar item**: use the variant `Admin Sidebar / <Item> Active` (Dashboard, Challenges, Question bank, Sessions, Schedule, Configuration, Rubric) or `Candidate Sidebar / Home Active` / `My interview Active`. Plain `Admin Sidebar` / `Candidate Sidebar` have no active item. Manual route (text/bg only; icon stays gray): `{\"Nav / Sessions\":{\"backgroundColor\":\"var(--color-brand-primary)\"},\"Nav Label / Sessions\":{\"color\":\"var(--color-text-inverse)\",\"fontWeight\":600}}`. User name/email: `User Name`, `User Email`, `User Initials`, `User Role Tag Label`.",
"- **Score Bar**: set `Fill` width in px of the 160px track: `{\"Fill\":{\"width\":\"100px\"}}`; recolor `{\"Fill\":{\"backgroundColor\":\"var(--color-avatar-purple)\"}}`; thinner: `{\"Fill\":{\"height\":\"4px\"}}` plus root `style=\"height:4px\"`.",
"- **Segmented / Ant Segmented**: pick the variant whose `Active N` matches; edit `Option 1..n`.",
"- **Tabs / Bar**: pick `Active N` variant; edit `Tab N Label`. Bars have a 1px divider under them.",
"- **Stat**: `Label` (uppercase), `Value`, `Delta`, `Trend`, `Delta Arrow` (use `Stat / Orange Down` for a negative delta). Sparkline shape is fixed.",
"- **Pill / Tag / Badge status colors**: choose the tone component. Custom: `{\"Label\":{\"color\":\"var(--color-tag-red-fg)\"}}` and root `style=\"background-color:var(--color-tag-red-bg);border:1px solid var(--color-tag-red-bd)\"`.",
"- **Data Table / Header Row, Row, Ant Table rows**: 5 equal flex cells `Col 1..5` / `Cell 1..5`; for fewer columns hide the trailing ones with display:none; width via `style`. For rows with Tags/Avatars compose raw HTML in a `<table>` and reuse `Tag / ...` instances in cells. Wrap a table in a Card-like frame: radius-lg, 1px border-light, overflow hidden.",
"- **Inputs**: `Value` is the text (placeholder color by default; use `Input / Filled` for values). Width via instance style. `Form Field*` has `Label`, `Required Mark`, `Value`, `Help`.",
"- **Disabled button**: `style=\"opacity:.5\"` on the instance. **Block button**: `style=\"width:100%\"` (add `justify-content:center`: already centered).",
"- **Card with gradient** (dashboard Awaiting review): build raw: `background:linear-gradient(180deg,#fffdf5,#ffffff)`.", "",
"## Recipes for raw HTML (when no component fits)", "",
"- Card: `display:flex;flex-direction:column;padding:var(--spacing-lg);background:var(--color-bg-elevated);border:1px solid var(--color-border-light);border-radius:var(--radius-lg);box-shadow:var(--shadow-sm)`.",
"- Button with icon: row, gap 8px, height 34px (sm 28, lg 40), padding 0 16px, radius-md; icon 14-16px via `<x-paper-icon name=\"phosphor:plus\" size=\"16\" color=\"var(--color-text-inverse)\"/>`.",
"- Page frame 1440x900: left `Admin Sidebar ...` (248x900) + main column 1192 wide: `Admin Shell Header` (about 102px tall) + content (padding 24, bg-layout, max-width 900/1100 centered).",
"- Modal overlay: full-screen frame `background:var(--color-bg-overlay)` with a `Modal` instance centered.",
"- Section titles inside cards: `Section Title` / `Section Title / With Action` instances.", "",
"## Variables (use as `var(--name)`)", "",
"Core tokens exist in the project (from tokens.css, same names minus the leading `--`): color-brand-primary, color-brand-secondary, color-bg-layout, color-bg-elevated, color-bg-sider, color-bg-selected, color-bg-overlay, color-border, color-border-light, color-border-strong, color-text-primary, color-text-secondary, color-text-disabled, color-text-inverse, color-text-link, color-success, color-warning, color-error, color-info, color-diff-added, color-diff-removed, color-diff-neutral, color-code-bg, color-code-fg; spacing-xs/sm/md/lg/xl/xxl (4/8/16/24/32/48), page-padding (24), page-padding-lg (48), page-max-width-xs/sm/md/lg/xl/narrow/default/wide (480/420/720/900/1100/720/900/1100), font-size-xs..xxl (12/14/16/18/20/24), radius-sm/md/lg (4/8/12), shadow-sm/md/lg, z-base/dropdown/modal/toast (1/1000/1050/1100).", "",
"Note: multi-value shorthands (`padding:var(--spacing-md) var(--page-padding)`) are NOT resolved by write_html: use single-value padding with var, or literal px for shorthands. Single `var(--spacing-*)`, `var(--radius-*)`, `var(--shadow-*)`, `var(--font-size-*)` work.", "",
"### Extra color variables added by the Design System page", ""]
for g, names in COLOR_GROUPS.items():
ex = [n for n in names if n in EXTRA_VARS]
if ex:
out.append(f"- **{g}**: " + ", ".join(f"`{n}` {EXTRA_VARS[n]}" for n in ex))
out += ["", "### Font stack and type scale", "",
"Inter only (regular/bold; weights 500 render regular, 600+ bold). Sizes: 12 labels/tags/table headers, 14 body, 16 section/card title, 24 page title (600), 30 stat value (700), 15/16 brand name.", "",
"## Known deviations from the source", "",
"- Pill in the code is the filter chip (active/inactive); there are no tone variants (`Pill / Success` does not exist). Tones are `Tag`.",
"- `lint_design`: remaining errors/warnings are faithful brand colors (white on #1677ff 4.1:1, success green on white, etc.), control heights under 44px (34/32px as in the source), and svg-internal names. Not changed on purpose.",
"- Text under 12px in the source (10.5/11) is rendered at 12; padding off the 4px grid was snapped (11->12, 18->16, 14->16, 9->0 with fixed 36px item height).",
"- Main components cannot be nested in the `Section / Components` frame (create_component/import_components need top-level frames), so the section shows instances; masters are in the library area.", ""]
open("DESIGN_SYSTEM.md", "w").write("\n".join(out))
print(len(out))
+44
View File
@@ -0,0 +1,44 @@
"""Create the `Interview` project: pages, color variables and design tokens.
The values are the single source of truth from the interview repo's packages/ui/src/tokens.css
(names kept identical, without the leading `--`). Run once; the other scripts in this folder build
the design system page and the app screens on top of it.
"""
import os, re, sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "craft-skills", "skills", "papercraft", "scripts"))
from pc import * # noqa: E402
TOKENS_CSS = os.environ.get("TOKENS_CSS", "/Volumes/Storage/Projects/interview/packages/ui/src/tokens.css")
def parse_tokens(path):
colors, others = {}, {}
for name, value in re.findall(r"--([\w-]+):\s*([^;]+);", open(path).read()):
v = value.strip()
if name.startswith("color-"):
colors[name] = v
else:
others[name] = v
return colors, others
def main():
colors, others = parse_tokens(TOKENS_CSS)
fid, info = open_project("Interview")
existing = {p["name"]: p["id"] for p in info["pages"]}
first = info["pages"][0]["id"]
wanted = ["Design System", "Admin", "Candidate"]
if "Page 1" in existing and "Design System" not in existing:
call("rename_pages", {"fileId": fid, "updates": [{"pageId": first, "name": "Design System"}]})
have = {p["name"] for p in call("get_basic_info", {"fileId": fid})["pages"]}
for w in wanted:
if w not in have:
pid = call("create_page", {"fileId": fid, "name": w})["pageId"]
call("set_variables", {"fileId": fid, "variables": colors})
call("set_tokens", {"fileId": fid, "tokens": others})
print("project", fid, "colors", len(colors), "tokens", len(others))
if __name__ == "__main__":
main()
+8
View File
@@ -0,0 +1,8 @@
import sys; sys.path.insert(0,"/Volumes/Storage/Projects/papercraft/examples/interview")
from ds_common import *
fid,P=setup()
out="/private/tmp/claude-501/-Volumes-Storage-Projects-photocraft/11f4040a-b4f4-4b9e-965a-bde08b400450/scratchpad/"
want=sys.argv[1:]
for c in P.c("get_children",nodeId="root")["children"]:
if c["name"] in want or (want==["all"]):
print(c["name"],c["width"],c["height"]); P.screenshot(out+c["name"].replace(" ","_").replace("/","")+".png",c["id"],1)
+342
View File
@@ -0,0 +1,342 @@
"""TaskFlow v2: variables (no hex in the file), components with instances, phosphor icons.
Run against the running app (needs `papercraft-cli icons add phosphor`): python3 examples/taskflow.py"""
import json, os, subprocess, sys
CLI = os.environ.get("PAPERCRAFT_CLI", "papercraft-cli")
ENV = {**os.environ, "PAPERCRAFT_MCP_PORT": os.environ.get("PAPERCRAFT_MCP_PORT", "29980")}
# The only place colors are written as hex: the theme, one value set per Catppuccin flavour ("mode").
# Everything else in the file is var(--x), so `papercraft-cli call set_variable_mode '{"mode":"Latte"}'`
# turns the whole design light.
def flavour(base, mantle, crust, s0, s1, s2, text, sub, muted, mauve, on_accent, green, red, peach, blue):
return {"base": base, "mantle": mantle, "crust": crust, "surface": s0, "surface-2": s1, "surface-3": s2,
"text": text, "subtext": sub, "muted": muted, "accent": mauve, "on-accent": on_accent,
"success": green, "danger": red, "warning": peach, "info": blue}
MODES = { # the first mode is the active one
"Mocha": flavour("#1e1e2e", "#181825", "#11111b", "#313244", "#45475a", "#585b70", "#cdd6f4", "#a6adc8", "#6c7086",
"#cba6f7", "#11111b", "#a6e3a1", "#f38ba8", "#fab387", "#89b4fa"),
"Macchiato": flavour("#24273a", "#1e2030", "#181926", "#363a4f", "#494d64", "#5b6078", "#cad3f5", "#a5adcb", "#6e738d",
"#c6a0f6", "#181926", "#a6da95", "#ed8796", "#f5a97f", "#8aadf4"),
"Frappé": flavour("#303446", "#292c3c", "#232634", "#414559", "#51576d", "#626880", "#c6d0f5", "#a5adce", "#737994",
"#ca9ee6", "#232634", "#a6d189", "#e78284", "#ef9f76", "#8caaee"),
"Latte": flavour("#eff1f5", "#e6e9ef", "#dce0e8", "#ccd0da", "#bcc0cc", "#acb0be", "#4c4f69", "#6c6f85", "#9ca0b0",
"#8839ef", "#eff1f5", "#40a02b", "#d20f39", "#fe640b", "#1e66f5"),
}
THEME = MODES["Mocha"]
V = {k: f"var(--{k})" for k in THEME}
def call(tool, args=None):
p = subprocess.run([CLI, "call", tool, "-"], input=json.dumps(args or {}), capture_output=True, text=True, env=ENV)
if p.returncode:
sys.exit(f"{tool} failed: {p.stdout}{p.stderr}")
try:
return json.loads(p.stdout)
except json.JSONDecodeError:
return p.stdout
def css(**k):
return ";".join(f"{a.replace('_', '-')}:{v}" for a, v in k.items())
def box(name, kids="", **style):
return f'<div layer-name="{name}" style="{css(**style)}">{kids}</div>'
def text(name, t, size=15, color=V["text"], weight=400, **style):
return box(name, t, font_size=f"{size}px", color=color, font_weight=weight, **style)
def row(name, kids="", **style):
return box(name, kids, **{"display": "flex", "align_items": "center", **style})
def col(name, kids="", **style):
return box(name, kids, **{"display": "flex", "flex_direction": "column", **style})
def icon(name, size=20, color=V["subtext"]):
return f'<x-paper-icon name="phosphor:{name}" size="{size}" color="{color}" layer-name="Icon / {name}"/>'
# --------------------------------------------------------------------------------- component HTML
def c_button(primary):
bg, fg = (V["accent"], V["on-accent"]) if primary else (V["surface"], V["text"])
return row("Content", text("Label", "Button", 16, fg, 600), justify_content="center", padding="16px",
background=bg, border_radius="14px", width="342px")
def c_chip(on):
return row("Content", text("Label", "Chip", 13, V["on-accent"] if on else V["subtext"], 600),
padding="8px 14px", background=V["accent"] if on else V["surface"], border_radius="999px")
def c_field():
return col("Content", text("Label", "Label", 12, V["subtext"], 500) +
row("Input", text("Value", "Value", 15), padding="14px", background=V["surface"], border_radius="12px",
border=f"1px solid {V['surface-2']}"), gap="6px", width="342px")
def c_task(done):
mark = row("Checkbox", icon("check", 14, V["on-accent"]) if done else "", justify_content="center",
width="24px", height="24px", border_radius="8px", background=V["success"] if done else "transparent",
border=f"2px solid {V['success'] if done else V['accent']}")
info = col("Info", text("Title", "Task title", 15, V["muted"] if done else V["text"], 600,
text_decoration="line-through" if done else "none") +
text("Meta", "Project · 9:00", 12, V["subtext"]), gap="3px", flex_grow="1")
dot = row("Priority", "", width="8px", height="8px", border_radius="99px", background=V["success"] if done else V["accent"])
return row("Content", mark + info + dot, gap="14px", padding="14px", background=V["surface"], border_radius="16px", width="350px")
def c_toggle(on):
knob = row("Knob", "", width="20px", height="20px", border_radius="99px", background=V["crust"])
track = row("Track", knob, width="46px", height="26px", padding="3px", border_radius="99px",
background=V["accent"] if on else V["surface-2"], justify_content="flex-end" if on else "flex-start")
return row("Content", text("Label", "Setting", 15) + track, justify_content="space-between", padding="14px", width="350px")
def c_flavour(selected):
sw = row("Swatch", row("Base", "", width="16px", height="16px", border_radius="99px", background=V["base"]) +
row("Accent", "", width="16px", height="16px", border_radius="99px", background=V["accent"]),
gap="4px", padding="8px", background=V["crust"], border_radius="99px")
return row("Content", sw + text("Name", "Flavour", 15, V["text"], 600, flex_grow="1") +
(icon("check", 16, V["accent"]) if selected else ""), gap="14px", padding="14px",
background=V["surface"], border_radius="16px", width="342px")
def c_status():
return row("Content", text("Time", "9:41", 14, V["text"], 600) + text("Indicators", "5G 100%", 12, V["subtext"]),
justify_content="space-between", padding="14px 8px 8px 8px", width="390px")
def c_tabbar(active):
tabs = [("Today", "house"), ("Calendar", "calendar-blank"), ("Projects", "squares-four"), ("Settings", "gear")]
kids = "".join(col(f"Tab / {n}", icon(ic, 22, V["accent"] if n == active else V["muted"]) +
text("Label", n, 11, V["accent"] if n == active else V["muted"], 600),
gap="3px", align_items="center", flex_grow="1") for n, ic in tabs)
return row("Content", kids, padding="10px 12px 22px 12px", background=V["mantle"],
border_top=f"1px solid {V['surface']}", width="390px")
# --------------------------------------------------------------------------------- project plumbing
class Project:
def __init__(self, name):
self.file = call("create_file", {"name": name})["fileId"]
info = call("open_file", {"fileId": self.file})
self.first_page = info["pages"][0]["id"]
self.used_first = False
for mode, values in MODES.items():
call("set_variables", {"fileId": self.file, "mode": mode, "variables": values})
def page(self, name):
if not self.used_first:
self.used_first, pid = True, self.first_page
else:
pid = call("create_page", {"fileId": self.file})["pageId"]
call("rename_pages", {"fileId": self.file, "updates": [{"pageId": pid, "name": name}]})
return Page(self, pid)
class Page:
def __init__(self, pr, pid):
self.pr, self.id, self.comps = pr, pid, {}
def c(self, tool, **a):
return call(tool, {"fileId": self.pr.file, "pageId": self.id, **a})
def artboard(self, name, html="", **styles):
ab = self.c("create_artboard", name=name, styles=styles)["id"]
ids = []
if html:
ids = self.c("write_html", targetNodeId=ab, mode="insert-children", html=html).get("createdNodeIds", [])
return ab, ids
def component(self, name, html):
ab, ids = self.artboard(name, html, display="flex", flexDirection="column", padding="0px", gap="0px",
width="fit-content", height="fit-content", backgroundColor="transparent")
# the content wrapper is the component: unwrap by making the artboard itself the component
self.c("create_component", nodeId=ab, name=name)
self.comps[name] = ab
return ab
def add(self, parent, html):
return self.c("write_html", targetNodeId=parent, mode="insert-children", html=html).get("createdNodeIds", [])
def inst(self, comp, parent, texts=None, styles=None, name=None, **kw):
r = self.c("create_instance", componentId=self.comps[comp], parentId=parent, **({"name": name} if name else {}), **kw)
by_name = {l["name"]: l["id"] for l in r["layers"]}
if texts:
self.c("set_text_content", updates=[{"nodeId": by_name[k], "textContent": v} for k, v in texts.items()])
ups = []
for k, st in (styles or {}).items():
ups.append({"nodeIds": [r["id"] if k == "root" else by_name[k]], "styles": st})
if ups:
self.c("update_styles", updates=ups)
return r["id"]
def screen(self, name, html=""):
return self.artboard(name, html, width="390px", height="844px", backgroundColor=V["base"], display="flex",
flexDirection="column")
def build():
pr = Project("TaskFlow")
# ------------------------------------------------------------------- Onboarding
pg = pr.page("Onboarding")
for n, h in (("Button / Primary", c_button(True)), ("Button / Secondary", c_button(False)),
("Field", c_field()), ("Status Bar", c_status())):
pg.component(n, h)
ab, _ = pg.screen("Splash")
pg.inst("Status Bar", ab, name="Status Bar")
hero = pg.add(ab, col("Hero",
row("Logo Mark", icon("check", 48, V["on-accent"]), width="96px", height="96px", justify_content="center",
background=V["accent"], border_radius="28px") +
text("App Name", "TaskFlow", 34, V["text"], 800) +
text("Tagline", "Plan the day. Finish what matters.", 15, V["subtext"], text_align="center"),
gap="16px", align_items="center", justify_content="center", flex_grow="1", width="390px"))[0]
actions = pg.add(ab, col("Actions", "", gap="12px", padding="24px 24px 36px 24px", width="390px"))[0]
pg.inst("Button / Primary", actions, {"Label": "Get started"}, name="Button / Get started")
pg.inst("Button / Secondary", actions, {"Label": "I already have an account"}, name="Button / Have account")
ab, _ = pg.screen("Sign in")
pg.inst("Status Bar", ab, name="Status Bar")
form = pg.add(ab, col("Form",
text("Title", "Welcome back", 28, V["text"], 800) + text("Subtitle", "Sign in to sync your tasks.", 14, V["subtext"]) +
col("Fields", "", gap="16px", padding="16px 0 0 0") +
row("Forgot", text("Link", "Forgot password?", 13, V["accent"], 600), justify_content="flex-end", width="342px") +
col("Buttons", "", gap="12px"), gap="14px", padding="24px", width="390px"))[0]
kids = pg.c("get_children", nodeId=form)["children"]
fields = next(k["id"] for k in kids if k["name"] == "Fields")
buttons = next(k["id"] for k in kids if k["name"] == "Buttons")
pg.inst("Field", fields, {"Label": "Email", "Value": "marcos@example.com"}, name="Field / Email")
pg.inst("Field", fields, {"Label": "Password", "Value": "••••••••••"}, name="Field / Password")
pg.inst("Button / Primary", buttons, {"Label": "Sign in"}, name="Button / Sign in")
pg.inst("Button / Secondary", buttons, {"Label": "Continue with Apple"}, name="Button / Apple")
# ------------------------------------------------------------------- Tasks
pg = pr.page("Tasks")
for n, h in (("Status Bar", c_status()), ("Task / Open", c_task(False)), ("Task / Done", c_task(True)),
("Chip / Default", c_chip(False)), ("Chip / Selected", c_chip(True)), ("Field", c_field()),
("Button / Primary", c_button(True)), ("Tab Bar / Today", c_tabbar("Today"))):
pg.component(n, h)
ab, _ = pg.screen("Today")
pg.inst("Status Bar", ab, name="Status Bar")
content = pg.add(ab, col("Content", "", gap="18px", padding="12px 20px", flex_grow="1", width="390px"))[0]
pg.add(content, row("Header", col("Greeting", text("Hello", "Good morning, Marcos", 13, V["subtext"]) +
text("Title", "Today", 30, V["text"], 800), gap="2px") +
row("Avatar", text("Initials", "MP", 14, V["on-accent"], 700), width="44px", height="44px",
justify_content="center", background=V["info"], border_radius="99px"),
justify_content="space-between", width="350px"))
pg.add(content, col("Progress Card", row("Progress Top", text("Summary", "2 of 5 done", 15, V["text"], 700) +
text("Percent", "40%", 15, V["accent"], 700), justify_content="space-between") +
row("Progress Track", row("Progress Fill", "", width="40%", height="8px", background=V["accent"],
border_radius="99px"), background=V["surface-2"], border_radius="99px", height="8px"),
gap="12px", padding="16px", background=V["surface"], border_radius="18px", width="350px"))
filters = pg.add(content, row("Filters", "", gap="8px"))[0]
pg.inst("Chip / Selected", filters, {"Label": "All"}, name="Chip / All")
pg.inst("Chip / Default", filters, {"Label": "Open"}, name="Chip / Open")
pg.inst("Chip / Default", filters, {"Label": "Done"}, name="Chip / Done")
lst = pg.add(content, col("Task List", "", gap="10px"))[0]
for title, meta, done, color in [("Write the spec", "Work · 9:00", True, "success"), ("Review pull requests", "Work · 10:30", True, "success"),
("Ship papercraft-cli", "Side project · 14:00", False, "danger"),
("Reply to emails", "Inbox · 16:00", False, "warning"), ("Plan next sprint", "Work · 17:30", False, "info")]:
pg.inst("Task / Done" if done else "Task / Open", lst, {"Title": title, "Meta": meta},
{"Priority": {"backgroundColor": V[color]}}, name=f"Task / {title}")
pg.inst("Tab Bar / Today", ab, name="Tab Bar")
ab, _ = pg.screen("New task")
pg.inst("Status Bar", ab, name="Status Bar")
sheet = pg.add(ab, col("Sheet", row("Sheet Header", text("Title", "New task", 22, V["text"], 800) +
text("Close", "Cancel", 14, V["accent"], 600), justify_content="space-between", width="342px") +
col("Fields", "", gap="18px") + col("Due", text("Label", "Due", 12, V["subtext"], 500) + row("Options", "", gap="8px"), gap="8px") +
col("Priority", text("Label", "Priority", 12, V["subtext"], 500) + row("Options", "", gap="8px"), gap="8px") +
col("Actions", ""), gap="18px", padding="16px 24px", width="390px"))[0]
kids = {k["name"]: k["id"] for k in pg.c("get_children", nodeId=sheet)["children"]}
pg.inst("Field", kids["Fields"], {"Label": "Title", "Value": "Name your task"}, {"Value": {"color": V["muted"]}}, name="Field / Title")
pg.inst("Field", kids["Fields"], {"Label": "Notes", "Value": "Add details…"}, {"Value": {"color": V["muted"]}}, name="Field / Notes")
for grp, opts, sel in (("Due", ["Today", "Tomorrow", "Pick date"], "Today"), ("Priority", ["Low", "Medium", "High"], "Medium")):
opt = next(k["id"] for k in pg.c("get_children", nodeId=kids[grp])["children"] if k["name"] == "Options")
for o in opts:
pg.inst("Chip / Selected" if o == sel else "Chip / Default", opt, {"Label": o}, name=f"Chip / {o}")
pg.inst("Field", kids["Fields"], {"Label": "Project", "Value": "Work"}, name="Field / Project")
pg.inst("Button / Primary", kids["Actions"], {"Label": "Create task"}, name="Button / Create task")
ab, _ = pg.screen("Task detail")
pg.inst("Status Bar", ab, name="Status Bar")
det = pg.add(ab, col("Detail",
row("Nav", row("Back", icon("caret-left", 18, V["accent"]) + text("Label", "Back", 15, V["accent"], 600), gap="2px") +
text("Edit", "Edit", 15, V["accent"], 600), justify_content="space-between", width="342px") +
text("Title", "Ship papercraft-cli", 28, V["text"], 800) +
col("Meta", "".join(row(f"Meta / {k}", text("Key", k, 14, V["subtext"]) + text("Value", v, 14, c, 600),
justify_content="space-between", padding="14px") for k, v, c in
[("Due", "Today, 14:00", V["text"]), ("Project", "Side project", V["text"]), ("Priority", "High", V["danger"])]),
background=V["surface"], border_radius="16px", width="342px") +
col("Subtasks", text("Heading", "SUBTASKS", 11, V["subtext"], 700, letter_spacing="1px"), gap="8px") +
col("Notes", text("Heading", "NOTES", 11, V["subtext"], 700, letter_spacing="1px") +
text("Body", "Document the CLI in the README and attach the zip to the release.", 14, V["text"], padding="14px",
background=V["surface"], border_radius="16px", line_height="20px", width="314px"), gap="8px") +
col("Actions", ""), gap="16px", padding="12px 24px", width="390px"))[0]
kids = {k["name"]: k["id"] for k in pg.c("get_children", nodeId=det)["children"]}
for t, d in (("Write CLI", True), ("Add tests", True), ("Publish release", False)):
pg.inst("Task / Done" if d else "Task / Open", kids["Subtasks"], {"Title": t, "Meta": "Code"},
{"root": {"width": "342px"}}, name=f"Subtask / {t}")
pg.inst("Button / Primary", kids["Actions"], {"Label": "Mark complete"}, name="Button / Mark complete")
# ------------------------------------------------------------------- Settings
pg = pr.page("Settings")
for n, h in (("Status Bar", c_status()), ("Toggle / On", c_toggle(True)), ("Toggle / Off", c_toggle(False)),
("Flavour / Selected", c_flavour(True)), ("Flavour / Default", c_flavour(False)),
("Tab Bar / Settings", c_tabbar("Settings"))):
pg.component(n, h)
ab, _ = pg.screen("Settings")
pg.inst("Status Bar", ab, name="Status Bar")
content = pg.add(ab, col("Content",
text("Title", "Settings", 30, V["text"], 800) +
row("Profile Card", row("Avatar", text("Initials", "MP", 16, V["on-accent"], 700), width="52px", height="52px",
justify_content="center", background=V["info"], border_radius="99px") +
col("Info", text("Name", "Marcos Paulo", 16, V["text"], 700) + text("Email", "marcos@example.com", 13, V["subtext"]), gap="2px"),
gap="14px", padding="16px", background=V["surface"], border_radius="18px", width="318px") +
col("Group / Appearance", text("Heading", "APPEARANCE", 11, V["subtext"], 700, letter_spacing="1px") +
col("Rows", row("Theme", text("Label", "Theme", 15) + row("Value", text("Name", "Catppuccin Mocha", 14, V["accent"], 600) +
icon("caret-right", 14, V["accent"]), gap="4px"),
justify_content="space-between", padding="14px", width="322px"),
background=V["surface"], border_radius="16px"), gap="8px") +
col("Group / Notifications", text("Heading", "NOTIFICATIONS", 11, V["subtext"], 700, letter_spacing="1px") +
col("Rows", "", background=V["surface"], border_radius="16px"), gap="8px"),
gap="18px", padding="12px 20px", flex_grow="1", width="390px"))[0]
kids = {k["name"]: k["id"] for k in pg.c("get_children", nodeId=content)["children"]}
rows = next(k["id"] for k in pg.c("get_children", nodeId=kids["Group / Notifications"])["children"] if k["name"] == "Rows")
for label, on in (("Daily summary", True), ("Reminders", True), ("Weekly review", False)):
pg.inst("Toggle / On" if on else "Toggle / Off", rows, {"Label": label}, name=f"Toggle / {label}")
pg.inst("Tab Bar / Settings", ab, name="Tab Bar")
ab, _ = pg.screen("Theme picker")
pg.inst("Status Bar", ab, name="Status Bar")
cont = pg.add(ab, col("Content", row("Nav", icon("caret-left", 18, V["accent"]) + text("Label", "Settings", 15, V["accent"], 600), gap="2px") +
text("Title", "Theme", 28, V["text"], 800) + col("Flavours", "", gap="10px"),
gap="16px", padding="12px 24px", width="390px"))[0]
fl = next(k["id"] for k in pg.c("get_children", nodeId=cont)["children"] if k["name"] == "Flavours")
for n in ("Latte", "Frappé", "Macchiato", "Mocha"):
look = MODES[n] # each swatch shows its own flavour, so these two colors are literal on purpose
pg.inst("Flavour / Selected" if n == "Mocha" else "Flavour / Default", fl, {"Name": f"Catppuccin {n}"},
{"Base": {"backgroundColor": look["base"]}, "Accent": {"backgroundColor": look["accent"]}},
name=f"Flavour / {n}")
# ------------------------------------------------------------------- Design system
pg = pr.page("Design system")
sw = "".join(row(f"Swatch / {n}", row("Chip", "", width="40px", height="40px", border_radius="10px", background=V[n],
border=f"1px solid {V['surface-2']}") +
col("Info", text("Name", n, 14, V["text"], 600) + text("Var", f"--{n}", 12, V["subtext"]), gap="2px"), gap="12px")
for n in THEME)
pg.artboard("Colors", col("Palette", text("Title", "Colors", 24, V["text"], 800) + col("Swatches", sw, gap="10px"), gap="18px", padding="24px"),
width="360px", height="fit-content", backgroundColor=V["base"], display="flex", flexDirection="column")
return pr
if __name__ == "__main__":
print("file", build().file)
+37
View File
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!-- Template: package.sh substitutes @VERSION@ (full) and @SHORT_VERSION@ (numeric X.Y.Z). -->
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>PaperCraft</string>
<key>CFBundleName</key>
<string>PaperCraft</string>
<key>CFBundleExecutable</key>
<string>PaperCraft</string>
<key>CFBundleIdentifier</key>
<string>dev.papercraft.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>@SHORT_VERSION@</string>
<key>CFBundleVersion</key>
<string>@SHORT_VERSION@</string>
<key>CFBundleIconFile</key>
<string>papercraft</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.graphics-design</string>
<key>LSMinimumSystemVersion</key>
<string>11.0</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSSupportsAutomaticGraphicsSwitching</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>Copyright © the PaperCraft authors. MIT OR Apache-2.0.</string>
</dict>
</plist>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!-- Hardened-runtime entitlements. Deliberately empty: pure Rust, no JIT, no unsigned code, not sandboxed. -->
<plist version="1.0">
<dict>
</dict>
</plist>
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# Build and sign (ad-hoc by default) the macOS artifacts into dist/release:
# papercraft-<version>-macos-<arch>.dmg PaperCraft.app on a drag-to-Applications DMG
# papercraft-cli-<version>-macos-<arch>.zip papercraft-cli (shell access for agents) and papercraft-mcp (stdio bridge)
# Usage: packaging/macos/package.sh [--arch universal|aarch64|x86_64] [--skip-build]
# Env: MACOS_SIGN_IDENTITY (default "-" = ad-hoc, not notarized; Gatekeeper rejects it on other Macs).
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$HERE/../.." && pwd)"
VERSION="$(awk '/^\[package\]/{p=1;next} /^\[/{p=0} p&&$1=="version"{gsub(/"/,"",$3);print $3;exit}' "$ROOT/Cargo.toml")"
DIST="${DIST:-$ROOT/dist/release}"
export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/target}"
ARCH=universal; SKIP_BUILD=0
while [ $# -gt 0 ]; do
case "$1" in
--arch) ARCH="$2"; shift 2 ;;
--skip-build) SKIP_BUILD=1; shift ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
case "$ARCH" in
universal) TARGETS=(aarch64-apple-darwin x86_64-apple-darwin) ;;
aarch64) TARGETS=(aarch64-apple-darwin) ;;
x86_64) TARGETS=(x86_64-apple-darwin) ;;
*) echo "unknown --arch $ARCH" >&2; exit 2 ;;
esac
export MACOSX_DEPLOYMENT_TARGET=11.0 # keep in sync with LSMinimumSystemVersion
IDENTITY="${MACOS_SIGN_IDENTITY:--}"
SHORT_VERSION="${VERSION%%-*}"
WORK="$CARGO_TARGET_DIR/macos-package"
APP="$WORK/PaperCraft.app"
DMG="$DIST/papercraft-$VERSION-macos-$ARCH.dmg"
MCP_ZIP="$DIST/papercraft-cli-$VERSION-macos-$ARCH.zip"
[ "$IDENTITY" = "-" ] && echo "warning: ad-hoc signing; artifacts are not notarized" >&2
if [ "$SKIP_BUILD" = 0 ]; then
args=(); for t in "${TARGETS[@]}"; do args+=(--target "$t"); done
(cd "$ROOT" && cargo build --release --locked --bins "${args[@]}")
fi
rm -rf "$WORK"; mkdir -p "$WORK/bin" "$DIST"
for bin in papercraft papercraft-mcp papercraft-cli; do
inputs=(); for t in "${TARGETS[@]}"; do inputs+=("$CARGO_TARGET_DIR/$t/release/$bin"); done
lipo -create -output "$WORK/bin/$bin" "${inputs[@]}"
done
sign() {
local ts=(--timestamp); [ "$IDENTITY" = "-" ] && ts=(--timestamp=none)
codesign --force --sign "$IDENTITY" "${ts[@]}" "$@"
}
echo "==> assembling $APP"
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
cp "$WORK/bin/papercraft" "$APP/Contents/MacOS/PaperCraft"
cp "$ROOT/assets/app-icon/papercraft.icns" "$APP/Contents/Resources/papercraft.icns"
cp "$ROOT"/LICENSE-* "$APP/Contents/Resources/"
sed -e "s/@VERSION@/$VERSION/g" -e "s/@SHORT_VERSION@/$SHORT_VERSION/g" "$HERE/Info.plist.in" >"$APP/Contents/Info.plist"
plutil -lint "$APP/Contents/Info.plist"
printf 'APPL????' >"$APP/Contents/PkgInfo"
sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP/Contents/MacOS/PaperCraft"
sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP"
codesign --verify --strict --deep --verbose=2 "$APP"
echo "==> building $DMG"
STAGE="$WORK/dmg"; mkdir -p "$STAGE"
ditto "$APP" "$STAGE/PaperCraft.app"
ln -s /Applications "$STAGE/Applications"
rm -f "$DMG" "$WORK/raw.dmg"
# makehybrid + convert needs no mounted device (create -srcfolder is flaky on CI and in sandboxes).
hdiutil makehybrid -hfs -hfs-volume-name "PaperCraft" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE"
hdiutil convert "$WORK/raw.dmg" -format UDZO -imagekey zlib-level=9 -o "$DMG"
rm -f "$WORK/raw.dmg"
sign "$DMG"; codesign --verify --strict --verbose=2 "$DMG"
echo "==> building $MCP_ZIP"
MCP_DIR="$WORK/papercraft-cli-$VERSION-macos-$ARCH"; mkdir -p "$MCP_DIR"
cp "$WORK/bin/papercraft-mcp" "$WORK/bin/papercraft-cli" "$ROOT/README.md" "$ROOT"/LICENSE-* "$MCP_DIR/"
sign --options runtime "$MCP_DIR/papercraft-mcp" "$MCP_DIR/papercraft-cli"
rm -f "$MCP_ZIP"; ditto -c -k --keepParent "$MCP_DIR" "$MCP_ZIP"
echo "==> done"; ls -lh "$DMG" "$MCP_ZIP"
+127
View File
@@ -0,0 +1,127 @@
<#
.SYNOPSIS
Build, sign and package PaperCraft for Windows.
.DESCRIPTION
Produces, in $env:DIST (default: dist/release):
papercraft-<version>-windows-<arch>.msi per-machine installer (WiX v5)
papercraft-<version>-windows-<arch>-portable.zip papercraft.exe + papercraft-cli.exe + papercraft-mcp.exe
papercraft-cli-<version>-windows-<arch>.zip papercraft-cli.exe + papercraft-mcp.exe standalone
.EXAMPLE
pwsh packaging/windows/package.ps1 -Arch x64
pwsh packaging/windows/package.ps1 -Arch x64 -SkipBuild
#>
param(
[ValidateSet('x64', 'x86', 'arm64')] [string] $Arch = 'x64',
[switch] $SkipBuild
)
$ErrorActionPreference = 'Stop'
$Root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
Set-Location $Root
function Invoke-Native([string] $What, [scriptblock] $Block) {
Write-Output "==> $What"
& $Block
if ($LASTEXITCODE -ne 0) { throw "$What failed with exit code $LASTEXITCODE" }
}
$Version = $env:PAPERCRAFT_VERSION
if (-not $Version) {
$inPkg = $false
foreach ($line in Get-Content (Join-Path $Root 'Cargo.toml')) {
if ($line -match '^\s*\[') { $inPkg = ($line.Trim() -eq '[package]'); continue }
if ($inPkg -and $line -match '^\s*version\s*=\s*"([^"]+)"') { $Version = $Matches[1]; break }
}
}
if (-not $Version) { throw 'could not read [package] version from Cargo.toml' }
$MsiVersion = ($Version -split '-')[0]
$Target = switch ($Arch) { 'x64' { 'x86_64-pc-windows-msvc' } 'x86' { 'i686-pc-windows-msvc' } 'arm64' { 'aarch64-pc-windows-msvc' } }
$Dist = if ($env:DIST) { $env:DIST } else { Join-Path $Root 'dist\release' }
$TargetDir = if ($env:CARGO_TARGET_DIR) { $env:CARGO_TARGET_DIR } else { Join-Path $Root 'target' }
New-Item -ItemType Directory -Force -Path $Dist | Out-Null
Write-Output "PaperCraft $Version for Windows $Arch ($Target)"
if (-not $SkipBuild) {
Invoke-Native "cargo build" { cargo build --release --locked --bins }
}
$Bin = Join-Path $TargetDir 'release'
if (-not (Test-Path (Join-Path $Bin 'papercraft.exe'))) {
$Bin = Join-Path $TargetDir "$Target\release"
}
function Get-PeHeader([string] $Path) {
$bytes = [System.IO.File]::ReadAllBytes($Path)
$pe = [BitConverter]::ToInt32($bytes, 0x3C)
return @{ Machine = [BitConverter]::ToUInt16($bytes, $pe + 4); Subsystem = [BitConverter]::ToUInt16($bytes, $pe + 0x5C) }
}
$Machine = switch ($Arch) { 'x64' { 0x8664 } 'x86' { 0x14C } 'arm64' { 0xAA64 } }
foreach ($name in @('papercraft.exe', 'papercraft-cli.exe', 'papercraft-mcp.exe')) {
$exePath = Join-Path $Bin $name
if (Test-Path $exePath) {
$h = Get-PeHeader $exePath
if ($h.Machine -ne $Machine) { throw "$name is for machine 0x$('{0:X}' -f $h.Machine), expected 0x$('{0:X}' -f $Machine) ($Arch)" }
Write-Output "ok $($name): $Arch (0x$('{0:X}' -f $h.Machine)), PE subsystem $($h.Subsystem)"
}
}
$Stage = Join-Path $TargetDir "windows-package\$Arch"
Remove-Item -Recurse -Force $Stage -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $Stage | Out-Null
Copy-Item (Join-Path $Bin 'papercraft.exe'), (Join-Path $Bin 'papercraft-cli.exe'), (Join-Path $Bin 'papercraft-mcp.exe') $Stage
foreach ($f in 'LICENSE-MIT', 'LICENSE-APACHE', 'ATTRIBUTION.md', 'README.md') {
$p = Join-Path $Root $f
if (Test-Path $p) { Copy-Item $p $Stage }
}
& (Join-Path $PSScriptRoot 'sign.ps1') (Join-Path $Stage 'papercraft.exe') (Join-Path $Stage 'papercraft-cli.exe') (Join-Path $Stage 'papercraft-mcp.exe')
# ---- MSI ---------------------------------------------------------------------------------------
$Msi = Join-Path $Dist "papercraft-$Version-windows-$Arch.msi"
if (Get-Command wix -ErrorAction SilentlyContinue) {
Invoke-Native 'wix build' {
wix build (Join-Path $PSScriptRoot 'papercraft.wxs') -arch $Arch `
-d "Version=$MsiVersion" -d "BinDir=$Stage" -d "IconPath=$(Join-Path $Root 'assets\app-icon\papercraft.ico')" `
-o $Msi
}
Remove-Item -Force -ErrorAction SilentlyContinue ([IO.Path]::ChangeExtension($Msi, '.wixpdb'))
& (Join-Path $PSScriptRoot 'sign.ps1') $Msi
} else {
Write-Warning "WiX toolset not found: skipping MSI creation"
}
# ---- portable zip ------------------------------------------------------------------------------
$Portable = Join-Path $TargetDir "windows-package\papercraft-$Version-windows-$Arch-portable"
Remove-Item -Recurse -Force $Portable -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $Portable | Out-Null
Copy-Item (Join-Path $Stage '*.exe') $Portable
foreach ($f in 'README.md', 'LICENSE', 'LICENSE-MIT', 'LICENSE-APACHE', 'ATTRIBUTION.md') {
$p = Join-Path $Root $f
if (Test-Path $p) { Copy-Item $p $Portable }
}
$Zip = Join-Path $Dist "papercraft-$Version-windows-$Arch-portable.zip"
Remove-Item -Force $Zip -ErrorAction SilentlyContinue
Compress-Archive -Path $Portable -DestinationPath $Zip
# ---- cli zip -----------------------------------------------------------------------------------
$CliPortable = Join-Path $TargetDir "windows-package\papercraft-cli-$Version-windows-$Arch"
Remove-Item -Recurse -Force $CliPortable -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $CliPortable | Out-Null
Copy-Item (Join-Path $Stage 'papercraft-cli.exe'), (Join-Path $Stage 'papercraft-mcp.exe') $CliPortable
foreach ($f in 'README.md', 'LICENSE', 'LICENSE-MIT', 'LICENSE-APACHE', 'ATTRIBUTION.md') {
$p = Join-Path $Root $f
if (Test-Path $p) { Copy-Item $p $CliPortable }
}
$CliZip = Join-Path $Dist "papercraft-cli-$Version-windows-$Arch.zip"
Remove-Item -Force $CliZip -ErrorAction SilentlyContinue
Compress-Archive -Path $CliPortable -DestinationPath $CliZip
# Smoke test
Invoke-Native 'papercraft-cli --help' { & (Join-Path $Stage 'papercraft-cli.exe') --help | Out-Null }
$artifacts = @($Zip, $CliZip)
if (Test-Path $Msi) { $artifacts += $Msi }
Get-Item $artifacts | Format-Table Name, Length
+60
View File
@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
PaperCraft MSI (WiX Toolset v5: `dotnet tool install -g wix`). Built by package.ps1:
wix build papercraft.wxs -arch x64 -d Version=1.2.3 -d BinDir=... -d IconPath=... -o out.msi
Per-machine install into Program Files, a Start Menu shortcut, and PaperCraft offered under
App Paths.
-->
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
<Package
Name="PaperCraft"
Manufacturer="PaperCraft Authors"
Version="$(var.Version)"
UpgradeCode="8A4C5E6D-4B2A-4F8C-9E1D-7C3B2A1E0F5A"
Scope="perMachine"
Compressed="yes"
Language="1033">
<SummaryInformation Description="PaperCraft infinite canvas design tool" Manufacturer="PaperCraft Authors" />
<MajorUpgrade AllowSameVersionUpgrades="yes"
DowngradeErrorMessage="A newer version of PaperCraft is already installed." />
<MediaTemplate EmbedCab="yes" CompressionLevel="high" />
<Icon Id="PapercraftIcon.ico" SourceFile="$(var.IconPath)" />
<Property Id="ARPPRODUCTICON" Value="PapercraftIcon.ico" />
<Property Id="ARPURLINFOABOUT" Value="https://git.marcospaulo.dev.br/masi/papercraft" />
<Property Id="ARPURLUPDATEINFO" Value="https://git.marcospaulo.dev.br/masi/papercraft" />
<StandardDirectory Id="ProgramFiles6432Folder">
<Directory Id="INSTALLFOLDER" Name="PaperCraft" />
</StandardDirectory>
<StandardDirectory Id="ProgramMenuFolder" />
<Feature Id="Main" Title="PaperCraft" Level="1">
<ComponentGroupRef Id="PapercraftFiles" />
</Feature>
</Package>
<Fragment>
<ComponentGroup Id="PapercraftFiles" Directory="INSTALLFOLDER">
<Component Id="PapercraftApp" Guid="6E7D8C9B-0A1B-4C3D-8E5F-1A2B3C4D5E6F">
<File Id="PapercraftExe" Source="$(var.BinDir)\papercraft.exe" KeyPath="yes">
<Shortcut Id="StartMenuShortcut" Directory="ProgramMenuFolder" Name="PaperCraft"
Description="PaperCraft infinite canvas design tool" WorkingDirectory="INSTALLFOLDER" Advertise="yes"
Icon="PapercraftIcon.ico" IconIndex="0" />
</File>
<RegistryValue Root="HKLM" Key="Software\Microsoft\Windows\CurrentVersion\App Paths\papercraft.exe" Value="[#PapercraftExe]" Type="string" />
</Component>
<Component Id="PapercraftCli" Guid="9F8E7D6C-5B4A-3C2D-1E0F-A1B2C3D4E5F6">
<File Id="PapercraftCliExe" Source="$(var.BinDir)\papercraft-cli.exe" KeyPath="yes" />
</Component>
<Component Id="PapercraftMcp" Guid="A1B2C3D4-E5F6-7A8B-9C0D-1E2F3A4B5C6D">
<File Id="PapercraftMcpExe" Source="$(var.BinDir)\papercraft-mcp.exe" KeyPath="yes" />
</Component>
</ComponentGroup>
</Fragment>
</Wix>
+97
View File
@@ -0,0 +1,97 @@
<#
.SYNOPSIS
Authenticode-sign files with signtool, using whichever signing material is configured.
.DESCRIPTION
Tries, in order:
1. A code-signing certificate file: WINDOWS_CERTIFICATE (base64 .pfx), WINDOWS_CERTIFICATE_PASSWORD
2. Azure Trusted Signing: AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET,
AZURE_SIGNING_ENDPOINT (e.g. https://eus.codesigning.azure.net),
AZURE_SIGNING_ACCOUNT, AZURE_CERT_PROFILE
With neither, it prints a warning and leaves the files unsigned (exit code 0), so test builds
still produce installers.
.EXAMPLE
pwsh packaging/windows/sign.ps1 dist/papercraft.exe dist/Papercraft.msi
#>
param(
[Parameter(Mandatory = $true, ValueFromRemainingArguments = $true)]
[string[]] $Files
)
$ErrorActionPreference = 'Stop'
function Write-Warn([string] $Message) {
if ($env:GITHUB_ACTIONS) { Write-Output "::warning::$Message" } else { Write-Warning $Message }
}
function Find-SignTool {
if ($env:SIGNTOOL) { return $env:SIGNTOOL }
$onPath = Get-Command signtool.exe -ErrorAction SilentlyContinue
if ($onPath) { return $onPath.Source }
$kits = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\bin'
$found = Get-ChildItem -Path $kits -Recurse -Filter signtool.exe -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -match '\\x64\\signtool\.exe$' } |
Sort-Object FullName -Descending | Select-Object -First 1
if (-not $found) { throw 'signtool.exe not found (install the Windows SDK)' }
return $found.FullName
}
function Invoke-SignTool([string[]] $Arguments) {
& $script:SignTool @Arguments
if ($LASTEXITCODE -ne 0) { throw "signtool $($Arguments[0]) failed with exit code $LASTEXITCODE" }
}
$Files = $Files | ForEach-Object { (Resolve-Path $_).Path }
$haveCert = [bool]$env:WINDOWS_CERTIFICATE
$azureVars = 'AZURE_TENANT_ID', 'AZURE_CLIENT_ID', 'AZURE_CLIENT_SECRET', 'AZURE_SIGNING_ENDPOINT', 'AZURE_SIGNING_ACCOUNT', 'AZURE_CERT_PROFILE'
$haveAzure = -not ($azureVars | Where-Object { -not [Environment]::GetEnvironmentVariable($_) })
if (-not $haveCert -and -not $haveAzure) {
Write-Warn "Windows signing secrets not set (WINDOWS_CERTIFICATE or AZURE_*): leaving unsigned: $($Files -join ', ')"
exit 0
}
$script:SignTool = Find-SignTool
$common = @('sign', '/v', '/fd', 'SHA256', '/td', 'SHA256', '/d', 'PaperCraft', '/du', 'https://git.marcospaulo.dev.br/masi/papercraft')
$tmp = Join-Path ([IO.Path]::GetTempPath()) "papercraft-sign-$([IO.Path]::GetRandomFileName())"
New-Item -ItemType Directory -Path $tmp | Out-Null
try {
if ($haveCert) {
Write-Output "Signing with WINDOWS_CERTIFICATE: $($Files -join ', ')"
$pfx = Join-Path $tmp 'cert.pfx'
[IO.File]::WriteAllBytes($pfx, [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE))
$ts = if ($env:WINDOWS_TIMESTAMP_URL) { $env:WINDOWS_TIMESTAMP_URL } else { 'http://timestamp.digicert.com' }
$signArgs = $common + @('/tr', $ts, '/f', $pfx)
if ($env:WINDOWS_CERTIFICATE_PASSWORD) { $signArgs += @('/p', $env:WINDOWS_CERTIFICATE_PASSWORD) }
Invoke-SignTool ($signArgs + $Files)
}
else {
Write-Output "Signing with Azure Trusted Signing ($env:AZURE_SIGNING_ACCOUNT / $env:AZURE_CERT_PROFILE): $($Files -join ', ')"
$trustedSigningVersion = '1.0.95'
$trustedSigningSha256 = '3bfcf1e0a3cb42af1692f0a8ed45c15de070c2de86f28a59b2795d904d8a920f'
$pkg = Join-Path $tmp 'trusted-signing.zip'
$uri = "https://api.nuget.org/v3-flatcontainer/microsoft.trusted.signing.client/$trustedSigningVersion/microsoft.trusted.signing.client.$trustedSigningVersion.nupkg"
Invoke-WebRequest -Uri $uri -OutFile $pkg
$actualHash = (Get-FileHash -Path $pkg -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actualHash -ne $trustedSigningSha256) {
throw "Microsoft.Trusted.Signing.Client $trustedSigningVersion checksum mismatch"
}
Expand-Archive -Path $pkg -DestinationPath (Join-Path $tmp 'client') -Force
$dlib = Get-ChildItem -Path (Join-Path $tmp 'client') -Recurse -Filter Azure.CodeSigning.Dlib.dll |
Where-Object { $_.FullName -match '\\x64\\' } | Select-Object -First 1
if (-not $dlib) { throw 'Azure.CodeSigning.Dlib.dll not found in Microsoft.Trusted.Signing.Client' }
$metadata = Join-Path $tmp 'metadata.json'
@{
Endpoint = $env:AZURE_SIGNING_ENDPOINT
CodeSigningAccountName = $env:AZURE_SIGNING_ACCOUNT
CertificateProfileName = $env:AZURE_CERT_PROFILE
} | ConvertTo-Json | Set-Content -Path $metadata -Encoding utf8
$ts = if ($env:WINDOWS_TIMESTAMP_URL) { $env:WINDOWS_TIMESTAMP_URL } else { 'http://timestamp.acs.microsoft.com' }
Invoke-SignTool ($common + @('/tr', $ts, '/dlib', $dlib.FullName, '/dmdf', $metadata) + $Files)
}
Invoke-SignTool (@('verify', '/pa', '/v') + $Files)
}
finally {
Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue
}
+112
View File
@@ -0,0 +1,112 @@
---
name: papercraft-design
description: Design-quality rules for work made in PaperCraft (or any flexbox-based design tool): commit to an aesthetic direction, tokens before pixels, a 4/8pt spacing scale, a type scale, contrast and touch-target minimums, visual hierarchy, consistent radii, components, and every screen state. Use when designing or reviewing screens, landing pages or dashboards in PaperCraft, when a design looks generic, or before finishing a design. Use together with the papercraft skill, which has the tool mechanics.
---
# papercraft-design
Rules for making designs that look intentional, adapted to what PaperCraft can do: flexbox layout, Inter as the only font, color variables, components, `lint_design`. Mechanics (tools, loop, limits) are in the `papercraft` skill. Sources: Figma's agent skills (variables, auto layout, components, design-system audits, spacing snapped to a scale), Anthropic's frontend-design guidance (commit to a direction, avoid generic AI aesthetics) and common UI practice (4/8pt grid, WCAG contrast, 44px targets).
## 0. Before you draw anything: commit to a direction
Write down, in a few lines, before the first tool call:
* **Purpose and audience.** What is this screen for, who uses it, what is the one thing they must do?
* **Tone.** Pick one and mean it: calm and quiet, dense and technical, playful, editorial, luxe, brutalist, soft and friendly. "Clean and modern" is not a direction.
* **The memorable thing.** One idea a person would remember: a huge number, a bold color block, an unusual rhythm of whitespace, a strong grid.
* **Palette.** One dominant neutral family, one accent used sparingly, one semantic set (success, warning, danger). Define them as variables (section 2). A confident accent on a restrained neutral beats five evenly weighted colors.
Avoid the default AI look: purple-to-blue gradient on white, a row of three identical rounded cards with an icon and two lines, centered everything, generic stock phrases ("Seamless. Powerful. Effortless."), emoji as icons, drop shadows everywhere. If your draft would pass for any SaaS template, change one structural thing (layout, scale contrast, color block) until it would not.
**Inter is the only font.** You cannot pair typefaces, so hierarchy has to come from size, weight, color and space. Use real scale contrast (a 40 or 64px headline against 16px body), bold only for what matters, and `text-muted` for secondary text. Tight negative letter-spacing on large headlines and uppercase 12px labels with positive letter-spacing give Inter more character. Do not fake a second font.
## 1. Spacing: a 4/8pt scale
Allowed spacing values: **4, 8, 12, 16, 24, 32, 48, 64** (and 96 for page sections). Everything is a multiple of 4: `gap`, every side of `padding`, sizes of boxes and icons.
* Related things are close, unrelated things are far. Inside a group: 4 to 12. Between groups: 24 to 48. Between page sections: 64 to 96. If everything has the same gap, nothing reads as a group.
* Padding of a container is at least its inner gap. Card padding 16 (mobile) to 24 (desktop); screen padding 16 or 24.
* Use `gap` on the parent instead of per-child spacing (there is no margin).
* Icons 16, 20, 24 (32 for feature icons). Avatars 32, 40, 48.
* `lint_design` warns `spacing-grid` on any gap or padding off the 4px grid. Fix by snapping to the nearest allowed value.
## 2. Tokens first (variables)
* All colors are variables (`var(--name)`), named by role: `bg`, `surface`, `border`, `text`, `text-muted`, `accent`, `on-accent`, `success`, `warning`, `danger`. Two to three surface levels at most (`bg` < `surface` < `surface-2`).
* Define at least two modes if the product may be dark or light (`set_variables` with `mode`), and check both: lint contrast in each mode after `set_variable_mode`.
* Semantic colors carry meaning only: red means error or destructive, green means success. Do not use them decoratively.
* If a color you need is not a variable, add the variable, then use it. `lint_design` reports hard-coded colors that equal a variable.
* Radii, spacing and type sizes cannot be variables yet, so keep them on the lists in this skill and define them once on the design-system page.
## 3. Type scale
Sizes: **12, 14, 16, 20, 24, 32, 40, 48, 64.** `lint_design` flags anything else as `type-scale` and under 12px as `text-size`.
| Use | Size / weight | Line height |
|---|---|---|
| Display, hero | 48 to 64 / 700 to 800 | 1.1 to 1.2 (set `line-height` in px) |
| Page title | 32 to 40 / 700 | 1.2 |
| Section title | 20 to 24 / 600 | 1.3 |
| Body | 16 / 400 (mobile never below 16) | 1.4 to 1.6 (24px) |
| Secondary, captions, table text | 14 / 400 | 20px |
| Labels, overlines, footnotes | 12 / 600, uppercase with letter-spacing 1px | 16px |
* One or two weights per screen (400 and 600/700). Do not use a different size for every element: three to five sizes per screen.
* Line lengths: 45 to 75 characters for paragraphs (set a `width`, around 560 to 720px on desktop).
* Left-align body text. Center only short headings and hero blocks.
* Numbers that matter (stats) get size, not just weight: 32 to 48.
## 4. Color and contrast
* Text contrast **4.5:1** for normal text, **3:1** for large text (24px, or 18.66px bold) and for UI components (borders of inputs, icons that carry meaning). `lint_design` reports `contrast` errors; fix the color, not the check.
* `text-muted` must still pass 4.5:1 on its background. Placeholder text too.
* Never rely on color alone: pair error red with an icon or text.
* Accent is for the primary action, the active state and one or two highlights. If more than about 10% of the screen is accent, it stops meaning anything.
* Dark themes: raise surfaces by lightening (`bg` < `surface` < `surface-2`), not by shadows (PaperCraft has none).
## 5. Hierarchy and composition
* **One primary action per screen.** One filled accent button; others are secondary (outline or `surface`) or text links.
* Squint test: blur the screenshot mentally. You should see three levels: the thing that matters, the supporting content, the chrome. If everything has equal weight, enlarge one thing and quiet the rest.
* Align to a few strong edges. Every element should line up with something. Prefer a left edge and consistent column widths over centering everything.
* Group with containers (surface + padding + radius) or with space, not both everywhere. Not everything needs a card.
* Use whitespace as a material. Generous outer padding makes a screen feel designed; cramped content feels like a form dump.
* Consistent radii: pick one family and stay in it, for example 8 (inputs, small), 12 (buttons), 16 (cards), 999 (pills, avatars). Nested corners: inner radius = outer radius minus padding.
* Borders: 1px `border` color, used consistently on inputs and dividers.
* Icons: one set per design, one size per context, `text-muted` unless active. Text labels beat icon-only buttons.
## 6. Touch targets and density
* Interactive targets are **at least 44px** tall and wide on touch (mobile, tablet). Make them 44 with padding (12 + 20 + 12), not just a bigger font. On dense desktop UIs 32 to 40 is acceptable; say so, and keep spacing between targets at 8 or more.
* `lint_design` checks frames named `Button…`, `Tab /…` and `Tab Bar /…` for height under 44, so **name interactive layers that way**.
* Mobile: body 16px or larger, side padding 16 to 24, bottom navigation within thumb reach, safe areas (status bar 44, home indicator 34 at the bottom).
## 7. Components and states
* Anything that appears twice is a component, built once, named `Component / Variant`, placed with `create_instance`. Change the main component, not the copies.
* Components first, screens second: build the button, field, chip, row, card, tab bar; then compose screens from instances; override only text.
* **Design every state, not only the happy path.** For each interactive component make a variant per state: default, hover, pressed, focused, disabled, selected, error. For each screen: empty, loading, error, partial, full, and long text (names that wrap, big numbers).
* Empty states explain what goes here and offer the next action.
* Disabled controls stay legible (contrast of text still 3:1 or better) but look inert.
* Error: message under the field in `danger`, with an icon or text, not color alone.
* Loading: skeleton blocks in `surface-2` in the shape of the content.
* Variants are separate components in PaperCraft (`Button / Primary`, `Button / Disabled`); keep the names parallel so they sort together.
* Keep a design-system page (swatches from the variables, the type scale, every component with its states). See the recipe in the `papercraft` skill.
## 8. Naming
* Layer names are real: `Hero`, `Stat Card / Revenue`, `Nav / Settings`, `Icon / gear`. No `Frame 12`. `lint_design` warns on default names.
* The name says what it is, not how it looks (`Primary Action`, not `Purple Box`).
## 9. Finish checklist
Run it before `finish_working_on_nodes`:
1. Direction written down; the screen has one memorable idea and one primary action.
2. Every color is a variable; both modes checked if there are two.
3. Spacing only 4, 8, 12, 16, 24, 32, 48, 64, 96; type only from the scale; radii from one family.
4. `lint_design` has zero errors and zero warnings, and the `info` items were considered.
5. Screenshot at scale 2 looked at: hierarchy, alignment, wrapping, clipping, empty areas, contrast by eye.
6. Interactive things at least 44px on touch and named `Button…` / `Tab /…`.
7. Repeated parts are components; states exist for every interactive component and for empty, loading and error screens.
8. Layers named; design-system page current.
+112
View File
@@ -0,0 +1,112 @@
---
name: papercraft
description: Drive the PaperCraft design app (native infinite-canvas design tool) through its MCP tools or the `papercraft-cli` shell. Use when asked to design screens, landing pages, dashboards or whiteboards in PaperCraft, to edit an open PaperCraft project, to build or change its variables, components and icons, or to lint and screenshot a design. Covers the build, screenshot, lint, fix loop, naming, layout recipes, tool cheat sheet, limits and troubleshooting. Pair with the papercraft-design skill for visual quality.
---
# papercraft
PaperCraft is a native Rust design app. You edit the project that is open in the running app: every tool call is one undo step and the window repaints live. Same tools over MCP (`http://127.0.0.1:29980/mcp` or the `papercraft-mcp` stdio bridge) and over the shell (`papercraft-cli`). Look and taste live in the `papercraft-design` skill; this one is the mechanics.
References: [tools.md](references/tools.md) (every tool and argument), [html-subset.md](references/html-subset.md) (what `write_html` accepts), [recipes.md](references/recipes.md) (mobile flow, landing page, dashboard, design-system page).
## 1. Connect and orient
```sh
papercraft-cli call get_basic_info # fails with exit 1 if the app is not running
```
* Not running: ask the user to start PaperCraft and open a project (or `create_file` then `open_file`). `PAPERCRAFT_MCP_PORT` must match if the port was changed. Do not try to launch the GUI yourself unless asked.
* Read `get_basic_info` (pages, artboards, sizes, fonts), then `get_selection` (what the user is looking at) and `get_variables` and `get_components`. Reuse what exists before creating anything.
* `pageId` defaults to the page the user is viewing, which can change between calls: pass it explicitly. Node ids look like `3fa9c1:14`; never show them to the user.
* With MCP tools you do not need the CLI; with a shell, `papercraft-cli list` and `papercraft-cli schema <tool>` give the schemas on demand. Pass big JSON on stdin: `... | papercraft-cli call write_html -`.
## 2. The loop: build, look, lint, fix
1. **Tokens first.** `set_variables` once (roles: `bg`, `surface`, `border`, `text`, `text-muted`, `accent`, `on-accent`, `danger`). Add a second mode for dark or light. No hex in the design afterwards.
2. **Components first.** Anything that appears twice (button, field, chip, row, tab bar) is built once as an artboard, `create_component`, then placed with `create_instance`.
3. **Build in small steps.** `create_artboard`, then one `write_html` per visual group (header, card, row) so the user sees progress. Name every layer with `layer-name`.
4. **Look.** `get_screenshot` of the artboard after each group or two. Scale 2 to read small text. Read it as a designer: hierarchy, alignment, spacing rhythm, clipping, wrapping.
5. **Lint.** `lint_design` on the artboard. Fix every error and warning; consider the `info` items. Run it again until errors and warnings are zero.
6. **Fix with the smallest edit.** `update_styles` and `set_text_content` on the existing nodes, not a rewrite. Rewrite a group with `write_html` mode `replace` only when its structure is wrong.
7. **Finish.** `finish_working_on_nodes` (saves). Tell the user what you made in a sentence or two, with artboard names, not ids.
Lint passes are necessary, not sufficient: contrast and grids say nothing about composition. The screenshot is the real test.
## 3. Naming
* Layers: `Noun` or `Component / Variant` (`Button / Primary`, `Tab / Today`, `Card / Task`, `Icon / gear`). Slashes group the layers panel.
* Screens (artboards): the screen's name (`Sign in`, `Task detail`), not `Frame 12`. Add a size suffix only when you have several breakpoints (`Landing / 1440`).
* Variables: roles, not hues (`surface`, `text-muted`, `on-accent`), kebab-case.
* Lint flags default names (`Frame`, `Rectangle`, `Group`) on layers that have children.
## 4. Layout recipes (flexbox is the only layout)
```html
<!-- row, vertically centered, 12px gap -->
<div layer-name="Row" style="display:flex;align-items:center;gap:12px">…</div>
<!-- column with 16px rhythm and 24px padding -->
<div layer-name="Stack" style="display:flex;flex-direction:column;gap:16px;padding:24px">…</div>
<!-- label left, value right -->
<div layer-name="Meta Row" style="display:flex;justify-content:space-between;align-items:center">…</div>
<!-- fill the remaining width or height -->
<div layer-name="Main" style="flex-grow:1">…</div>
<!-- pill button, 44px tall: 12 + 20 line height + 12 -->
<div layer-name="Button / Primary" style="display:flex;justify-content:center;padding:12px 24px;background:var(--accent);border-radius:12px"><div layer-name="Label" style="font-size:16px;font-weight:600;line-height:20px;color:var(--on-accent)">Save</div></div>
<!-- icon + label -->
<div layer-name="Nav / Home" style="display:flex;align-items:center;gap:12px"><x-paper-icon name="phosphor:house" size="20" color="var(--text-muted)"/><div layer-name="Label" style="font-size:16px;color:var(--text-muted)">Home</div></div>
```
Artboards: mobile 390×844, tablet 768×1024, desktop 1440×900. Create with `height:"fit-content"` when the content decides the height. Replace margin with `gap` and `padding`.
Full flows: [recipes.md](references/recipes.md).
## 5. Tools cheat sheet
| I want to | Tool |
|---|---|
| Know the project, pages, artboards | `get_basic_info`, `get_tree_summary`, `get_children` |
| Know what is selected | `get_selection` |
| Inspect one node | `get_node_info`, `get_computed_styles`, `get_jsx` |
| Find nodes by text or style | `find_nodes` |
| See it | `get_screenshot` (`nodeId`, `scale` 1 or 2) |
| New screen | `create_artboard` (`width` and `height` required) |
| Add content | `write_html` (`insert-children` or `replace`) |
| Change styles or text | `update_styles`, `set_text_content`, `rename_nodes` |
| Whiteboard shapes, sticky notes, arrows | `create_shape` |
| Copy, move, delete | `duplicate_nodes`, `move_nodes`, `delete_nodes` |
| Tokens | `get_variables` (`format` css or json), `set_variables` (`mode`), `set_variable_mode` |
| Reuse | `create_component`, `create_instance`, `get_components`, `<x-paper-instance>` |
| Icons | `search_icons`, `<x-paper-icon name="set:name">`; CLI: `papercraft-cli icons add\|list\|search` |
| Check quality | `lint_design` |
| Export | `export` (PNG, SVG, PDF to files), `export_combined_pdf` |
| Point at things for the user | `select_nodes` (`zoom: true`) |
| Take back | `undo`, `redo` |
| Save | `finish_working_on_nodes` |
## 6. Known limits (design around them)
* **Font is Inter only**, regular and bold plus italic. No other families, no weights between. Build hierarchy from size, weight, color and space.
* **Variables are colors only**, for fills and text color. Strokes and gradients keep fixed colors. Spacing, radius and type sizes are plain numbers: keep them on the scale by discipline and `lint_design`.
* **No margin, grid or tables; no shadows, filters or transforms** (reported under `ignoredStyles`). Fake elevation with a border or a lighter surface.
* **No rotation.** Nodes are axis-aligned.
* **Components and their instances live on one page.** Put shared components on the page where they are used, or duplicate the component per page.
* **No variants or properties**: one component per variant (`Button / Primary`, `Button / Secondary`). No swap or detach tool.
* **Remote images are not fetched.** Use a local path or `data:` URI.
* **One project open at a time**; moving layers between pages is not supported.
* **Text boxes with fixed widths can wrap earlier than in a browser.** Check headlines in the screenshot and widen.
* **Instances are skipped by lint**; lint the main component.
## 7. Troubleshooting
| Symptom | Cause and fix |
|---|---|
| `papercraft-cli` exits 1, "isn't running" | The app is closed, no project is open, or the port differs. Start it and open a project; check `PAPERCRAFT_MCP_PORT`. |
| Call fails with an unknown node id | The id is from another page or was deleted. Re-read with `get_children`; pass `pageId`. |
| `write_html` reports `ignoredStyles` | You used a CSS property that has no equivalent (`margin`, `box-shadow`, `transform`...). Replace it, see [html-subset.md](references/html-subset.md). |
| Content is cut off | The artboard height is fixed. `update_styles` with `height: "fit-content"` or a larger number. |
| A color did not change with the theme | It was written as hex, or it is a stroke or gradient. Use `var(--name)` on fills and text; strokes need `update_styles` after a mode switch. |
| `create_instance` fails | The component is on another page. Instances must be on the component's page. |
| Icon does not appear | The set is not installed: `papercraft-cli icons add phosphor`, then `papercraft-cli icons search <words>` for the exact `set:name`. |
| Layer panel is a pile of `Frame` | You skipped `layer-name`. Fix with `rename_nodes` (batch) before finishing. |
| Text looks tiny in the screenshot | Screenshots default to scale 1. Use `scale: 2`. |
| The user's view jumped | You switched pages. Always pass `pageId` instead of opening other pages. |
@@ -0,0 +1,56 @@
# What `write_html` supports and ignores
`write_html` parses an HTML fragment with inline `style` attributes into layers. It is a forgiving parser (unquoted attributes, void tags, entities such as `&middot;` and `&rarr;`, mismatched closing tags), not a browser: there is no cascade, no `<style>`, no classes, no scripts. Write each element's styles inline. Anything it cannot map comes back in the result as `ignoredStyles`: read that list after every call.
## Call shape
```json
{"targetNodeId":"<artboard or frame id, or \"root\">","mode":"insert-children","html":"<div ...>...</div>"}
```
`insert-children` appends inside the target. `replace` swaps the target for the new HTML. The result lists `createdNodeIds` (top-level layers created), so write one visual group per call and keep the ids.
## Elements
| Markup | Becomes |
|---|---|
| `<div>`, `<section>`, `<p>`... with child elements | a frame (auto layout when `display:flex`; block elements stack vertically) |
| an element containing only text | a text layer |
| `<b>`, `<i>`, `<u>`, `<s>`, `<span style="...">` inside text | rich-text runs (bold, italic, underline, strike, size, color) |
| `<img src>` | an image fill. `src` is an absolute path, a `file://` URL, `paper-asset:///abs/path` or a `data:` URI. `http(s)` is not fetched |
| `<svg>...</svg>` | vector layers (paths, gradients, strokes) |
| `<x-paper-icon name="set:name" size="20" color="var(--x)"/>` | an icon from an installed set (`phosphor:gear`) |
| `<x-paper-instance component-id="ID"/>` | an instance of a component |
| `<x-paper-clone node-id="ID" style="..."/>` | a deep copy of an existing node |
| `layer-name="..."` on any of the above | the layer's name (names are truncated to 50 characters) |
## Layout
Supported: `display:flex` (and `inline-flex`), `flex-direction`, `flex-wrap`, `gap`, `row-gap`, `column-gap`, `padding` (shorthand, per side, `padding-inline`, `padding-block`), `align-items`, `justify-content` (including `space-between`), `align-self`, `flex-grow` / `flex: 1`, `width`, `height` (px, %, `fit-content`), `min-`/`max-` sizes (accepted), `overflow:hidden` (clips), `position:absolute` with `left`/`top`/`right`/`bottom`, `display:none`, `visibility`.
Not supported (check `ignoredStyles` to be sure): `margin` (use `gap` and `padding`), `display:grid`, tables, `float`, `calc()`, viewport units, `transform` and rotation.
Tips:
* Artboards are `display:flex; flex-direction:column`. Children of a column stretch to the full width unless they have a width or `align-items` says otherwise.
* To center something, put it in a flex parent with `align-items:center; justify-content:center`.
* To make a row fill the remaining space, give the child `flex-grow:1`.
* Use `height:"fit-content"` on artboards that should grow with content; fixed heights clip.
## Text
Supported: `font-size` (px), `font-weight` (600 and above renders bold; otherwise regular), `font-style:italic`, `color`, `line-height` (px or unitless), `letter-spacing`, `text-align`, `text-decoration`. The font is always **Inter**; `font-family` is ignored, and there are only regular and bold weights (plus italic), so hierarchy comes from size, weight, color and space.
## Boxes
Supported: `background` and `background-color` (hex, `rgb()`, `hsl()`, `var(--name)`), gradients (`linear-gradient`, `radial-gradient`), `background-image: url()`, `background-size` (`contain`, `100% 100%`), `border` (width, style, color; one stroke per node), `border-radius` (shorthand and per corner; `999px` makes a pill), `opacity`, `mix-blend-mode`. Strokes and gradients keep their resolved colors; they do not follow variables later.
Not supported (reported as ignored): `box-shadow`, `filter`, `backdrop-filter`, `text-shadow`, `outline-offset`, CSS animations and transitions.
## Colors and variables
Write colors as `var(--name)` for `background`, `background-color` and `color` and the layer stays linked to the variable. Define the variable with `set_variables` first. Hex is fine for one-off colors in an image or an illustration, but `lint_design` will flag a hex that equals a variable.
## `update_styles`
Takes the same properties as camelCase JSON (`{"backgroundColor":"var(--accent)","paddingTop":16}`; numbers become px for lengths). Extra keys for the whiteboard: `sketch`, `roughness`, `fillStyle`, `strokeStyle`, `seed`. Setting `left` / `top` on an artboard moves it.
+155
View File
@@ -0,0 +1,155 @@
# Recipes
Every snippet here was run against PaperCraft and passes `lint_design` with no errors or warnings. They assume the variables from recipe 0 and `jq` on the PATH. Wrap HTML files into a `write_html` call with:
```sh
jq -Rs --arg t "$ARTBOARD_ID" '{targetNodeId:$t,mode:"insert-children",html:.}' file.html | papercraft-cli call write_html -
```
## 0. Tokens first (do this once per project)
Two modes, so one call can flip the whole design between dark and light. Roles, not hues.
```sh
papercraft-cli call set_variables '{"mode":"Dark","variables":{"bg":"#1e1e2e","surface":"#313244","border":"#45475a","text":"#cdd6f4","text-muted":"#a6adc8","accent":"#cba6f7","on-accent":"#11111b","danger":"#f38ba8"}}'
papercraft-cli call set_variables '{"mode":"Light","variables":{"bg":"#eff1f5","surface":"#e6e9ef","border":"#bcc0cc","text":"#4c4f69","text-muted":"#5c5f77","accent":"#8839ef","on-accent":"#eff1f5","danger":"#d20f39"}}'
papercraft-cli call set_variable_mode '{"mode":"Dark"}'
```
The first mode you define stays active until you switch. Check contrast in both modes with `lint_design` after each switch.
## 1. Mobile app flow
Build the parts once as components, then compose screens from instances.
```sh
# a. The button: the artboard IS the component (44px tall: 12 + 20 line + 12)
BTN=$(papercraft-cli call create_artboard '{"name":"Button / Primary","styles":{"width":"fit-content","height":"fit-content","display":"flex","backgroundColor":"var(--accent)","borderRadius":"12px","padding":"12px 24px","justifyContent":"center","alignItems":"center"}}' | jq -r .id)
echo '{"targetNodeId":"'$BTN'","mode":"insert-children","html":"<div layer-name=\"Label\" style=\"font-size:16px;font-weight:600;line-height:20px;color:var(--on-accent)\">Button</div>"}' | papercraft-cli call write_html -
papercraft-cli call create_component '{"nodeId":"'$BTN'"}'
# b. The screen: 390x844, content centered, 24px padding
SCREEN=$(papercraft-cli call create_artboard '{"name":"Sign in","styles":{"width":"390px","height":"844px","backgroundColor":"var(--bg)","padding":"24px","gap":"16px","justifyContent":"center"}}' | jq -r .id)
cat > /tmp/signin.html <<'HTML'
<div layer-name="Header" style="display:flex;flex-direction:column;gap:8px">
<div layer-name="Title" style="font-size:32px;font-weight:700;color:var(--text)">Welcome back</div>
<div layer-name="Subtitle" style="font-size:16px;line-height:24px;color:var(--text-muted)">Sign in to sync your tasks.</div>
</div>
<div layer-name="Field / Email" style="display:flex;align-items:center;gap:12px;padding:12px 16px;background:var(--surface);border:1px solid var(--border);border-radius:12px">
<x-paper-icon name="phosphor:envelope-simple" size="20" color="var(--text-muted)"/>
<div layer-name="Value" style="font-size:16px;color:var(--text)">marcos@example.com</div>
</div>
<div layer-name="Actions" style="display:flex;flex-direction:column;gap:12px"></div>
HTML
jq -Rs --arg t "$SCREEN" '{targetNodeId:$t,mode:"insert-children",html:.}' /tmp/signin.html | papercraft-cli call write_html -
# c. Place the instance in Actions (find its id with get_children), then set its text and width
ACTIONS=$(papercraft-cli call get_children '{"nodeId":"'$SCREEN'"}' | jq -r '.children[]|select(.name=="Actions").id')
INST=$(papercraft-cli call create_instance '{"componentId":"'$BTN'","parentId":"'$ACTIONS'","name":"Button / Sign in"}')
LABEL=$(echo "$INST" | jq -r '.textLayers[0].id'); ID=$(echo "$INST" | jq -r .id)
papercraft-cli call set_text_content '{"updates":[{"nodeId":"'$LABEL'","textContent":"Sign in"}]}'
papercraft-cli call update_styles '{"updates":[{"nodeIds":["'$ID'"],"styles":{"width":"342px"}}]}'
papercraft-cli call lint_design '{"nodeId":"'$SCREEN'"}'
papercraft-cli call get_screenshot '{"nodeId":"'$SCREEN'"}' --out signin.png
```
For the next screens, create each artboard at 390x844, reuse the components by `create_instance`, and give every screen a status bar and a tab bar component so they stay identical. Name screens as nouns (`Sign in`, `Today`, `Task detail`). Put each flow's screens left to right: a new artboard goes to an empty spot, or set `left` and `top` in `styles`. `examples/taskflow.py` in the PaperCraft repository is a complete 8-screen reference.
## 2. Landing page (1440 wide, height grows with content)
Create the artboard with `height:"fit-content"` so it never clips, then add one section per call.
```sh
AB=$(papercraft-cli call create_artboard '{"name":"Landing","styles":{"width":"1440px","height":"fit-content","backgroundColor":"var(--bg)","display":"flex","flexDirection":"column","paddingBottom":"96px"}}' | jq -r .id)
jq -Rs --arg t "$AB" '{targetNodeId:$t,mode:"insert-children",html:.}' landing.html | papercraft-cli call write_html -
```
`landing.html` (nav, hero with one primary action, three feature cards):
```html
<div layer-name="Nav" style="display:flex;justify-content:space-between;align-items:center;padding:24px 64px">
<div layer-name="Logo" style="font-size:20px;font-weight:700;color:var(--text)">Acme</div>
<div layer-name="Links" style="display:flex;align-items:center;gap:32px">
<div layer-name="Link / Product" style="font-size:16px;color:var(--text-muted)">Product</div>
<div layer-name="Link / Pricing" style="font-size:16px;color:var(--text-muted)">Pricing</div>
<div layer-name="Button / Nav" style="display:flex;padding:12px 24px;background:var(--accent);border-radius:12px"><div layer-name="Label" style="font-size:16px;font-weight:600;line-height:20px;color:var(--on-accent)">Sign up</div></div>
</div>
</div>
<div layer-name="Hero" style="display:flex;flex-direction:column;align-items:center;gap:24px;padding:96px 64px">
<div layer-name="Eyebrow" style="font-size:14px;font-weight:600;letter-spacing:1px;color:var(--accent)">NOW IN BETA</div>
<div layer-name="Headline" style="font-size:64px;font-weight:800;line-height:72px;letter-spacing:-2px;text-align:center;color:var(--text);width:960px">Plan the day. Finish what matters.</div>
<div layer-name="Subhead" style="font-size:20px;line-height:32px;text-align:center;color:var(--text-muted);width:640px">One calm list for everything on your plate, synced across your devices.</div>
<div layer-name="Actions" style="display:flex;gap:16px">
<div layer-name="Button / Primary" style="display:flex;padding:16px 32px;background:var(--accent);border-radius:12px"><div layer-name="Label" style="font-size:16px;font-weight:600;line-height:20px;color:var(--on-accent)">Get started</div></div>
<div layer-name="Button / Secondary" style="display:flex;padding:16px 32px;border:1px solid var(--border);border-radius:12px"><div layer-name="Label" style="font-size:16px;font-weight:600;line-height:20px;color:var(--text)">See how it works</div></div>
</div>
</div>
<div layer-name="Features" style="display:flex;gap:24px;padding:0 64px">
<div layer-name="Feature Card / Capture" style="display:flex;flex-direction:column;gap:16px;padding:32px;background:var(--surface);border-radius:16px;flex-grow:1"><x-paper-icon name="phosphor:lightning" size="32" color="var(--accent)"/><div layer-name="Title" style="font-size:20px;font-weight:600;color:var(--text)">Capture fast</div><div layer-name="Body" style="font-size:16px;line-height:24px;color:var(--text-muted)">Type a task and move on.</div></div>
<div layer-name="Feature Card / Focus" style="display:flex;flex-direction:column;gap:16px;padding:32px;background:var(--surface);border-radius:16px;flex-grow:1"><x-paper-icon name="phosphor:target" size="32" color="var(--accent)"/><div layer-name="Title" style="font-size:20px;font-weight:600;color:var(--text)">Stay focused</div><div layer-name="Body" style="font-size:16px;line-height:24px;color:var(--text-muted)">See only what is due today.</div></div>
<div layer-name="Feature Card / Sync" style="display:flex;flex-direction:column;gap:16px;padding:32px;background:var(--surface);border-radius:16px;flex-grow:1"><x-paper-icon name="phosphor:arrows-clockwise" size="32" color="var(--accent)"/><div layer-name="Title" style="font-size:20px;font-weight:600;color:var(--text)">Always in sync</div><div layer-name="Body" style="font-size:16px;line-height:24px;color:var(--text-muted)">Phone, tablet and desktop agree.</div></div>
</div>
```
Check the headline in the screenshot: text boxes with a fixed `width` can wrap earlier than in a browser, so give long headlines generous widths (960px above) and verify.
## 3. Dashboard (sidebar, stat cards, chart)
A row artboard (`flexDirection:"row"`) with a fixed 240px sidebar and a `flex-grow:1` main area.
```sh
AB=$(papercraft-cli call create_artboard '{"name":"Dashboard","styles":{"width":"1440px","height":"900px","backgroundColor":"var(--bg)","display":"flex","flexDirection":"row"}}' | jq -r .id)
jq -Rs --arg t "$AB" '{targetNodeId:$t,mode:"insert-children",html:.}' dashboard.html | papercraft-cli call write_html -
```
`dashboard.html`:
```html
<div layer-name="Sidebar" style="display:flex;flex-direction:column;gap:8px;padding:24px 16px;width:240px;background:var(--surface)">
<div layer-name="Brand" style="font-size:20px;font-weight:700;color:var(--text);padding:8px 12px">Acme</div>
<div layer-name="Nav / Overview" style="display:flex;align-items:center;gap:12px;padding:12px;border-radius:12px;background:var(--accent)"><x-paper-icon name="phosphor:squares-four" size="20" color="var(--on-accent)"/><div layer-name="Label" style="font-size:16px;font-weight:600;color:var(--on-accent)">Overview</div></div>
<div layer-name="Nav / Customers" style="display:flex;align-items:center;gap:12px;padding:12px;border-radius:12px"><x-paper-icon name="phosphor:users" size="20" color="var(--text-muted)"/><div layer-name="Label" style="font-size:16px;color:var(--text-muted)">Customers</div></div>
<div layer-name="Nav / Reports" style="display:flex;align-items:center;gap:12px;padding:12px;border-radius:12px"><x-paper-icon name="phosphor:chart-line-up" size="20" color="var(--text-muted)"/><div layer-name="Label" style="font-size:16px;color:var(--text-muted)">Reports</div></div>
</div>
<div layer-name="Main" style="display:flex;flex-direction:column;gap:24px;padding:32px;flex-grow:1">
<div layer-name="Title" style="font-size:32px;font-weight:700;color:var(--text)">Overview</div>
<div layer-name="Stat Row" style="display:flex;gap:16px">
<div layer-name="Stat Card / Revenue" style="display:flex;flex-direction:column;gap:8px;padding:24px;background:var(--surface);border-radius:16px;flex-grow:1"><div layer-name="Label" style="font-size:14px;color:var(--text-muted)">Revenue</div><div layer-name="Value" style="font-size:32px;font-weight:700;color:var(--text)">$48,200</div></div>
<div layer-name="Stat Card / Orders" style="display:flex;flex-direction:column;gap:8px;padding:24px;background:var(--surface);border-radius:16px;flex-grow:1"><div layer-name="Label" style="font-size:14px;color:var(--text-muted)">Orders</div><div layer-name="Value" style="font-size:32px;font-weight:700;color:var(--text)">1,204</div></div>
<div layer-name="Stat Card / Refunds" style="display:flex;flex-direction:column;gap:8px;padding:24px;background:var(--surface);border-radius:16px;flex-grow:1"><div layer-name="Label" style="font-size:14px;color:var(--text-muted)">Refunds</div><div layer-name="Value" style="font-size:32px;font-weight:700;color:var(--danger)">18</div></div>
</div>
<div layer-name="Chart Card" style="display:flex;flex-direction:column;gap:16px;padding:24px;background:var(--surface);border-radius:16px;flex-grow:1">
<div layer-name="Heading" style="font-size:20px;font-weight:600;color:var(--text)">Revenue, Monday to Thursday</div>
<div layer-name="Bars" style="display:flex;align-items:flex-end;gap:16px;flex-grow:1">
<div layer-name="Bar / Mon" style="width:48px;height:96px;background:var(--accent);border-radius:8px"></div>
<div layer-name="Bar / Tue" style="width:48px;height:144px;background:var(--accent);border-radius:8px"></div>
<div layer-name="Bar / Wed" style="width:48px;height:120px;background:var(--accent);border-radius:8px"></div>
<div layer-name="Bar / Thu" style="width:48px;height:208px;background:var(--accent);border-radius:8px"></div>
</div>
</div>
</div>
```
Charts are just boxes: size the bars from the data (`height = value / max * plot height`). Add axis labels at 12px. Always label what the bars mean; color alone is not information.
## 4. Design-system page
Generate swatches from the project's own variables and a type-scale specimen, so the page cannot drift from the tokens:
```sh
AB=$(papercraft-cli call create_artboard '{"name":"Design system","styles":{"width":"720px","height":"fit-content","backgroundColor":"var(--bg)","display":"flex","flexDirection":"column","padding":"32px","gap":"32px"}}' | jq -r .id)
# Colors: one swatch per variable, built from the project's own variables
SWATCHES=$(papercraft-cli call get_variables | jq -r '.variables | keys[]' | while read -r n; do
printf '<div layer-name="Swatch / %s" style="display:flex;align-items:center;gap:12px;width:208px"><div layer-name="Chip" style="width:40px;height:40px;border-radius:8px;background:var(--%s);border:1px solid var(--border)"></div><div layer-name="Name" style="font-size:14px;color:var(--text)">--%s</div></div>' "$n" "$n" "$n"
done)
jq -n --arg t "$AB" --arg s "$SWATCHES" '{targetNodeId:$t,mode:"insert-children",html:("<div layer-name=\"Colors\" style=\"display:flex;flex-direction:column;gap:16px\"><div layer-name=\"Heading\" style=\"font-size:24px;font-weight:700;color:var(--text)\">Colors</div><div layer-name=\"Swatches\" style=\"display:flex;flex-wrap:wrap;gap:16px\">"+$s+"</div></div>")}' | papercraft-cli call write_html -
# Type scale: one row per size
ROWS=""; for s in 12 14 16 20 24 32 40; do
ROWS+="<div layer-name=\"Type / ${s}\" style=\"display:flex;align-items:baseline;gap:16px\"><div layer-name=\"Size\" style=\"font-size:12px;color:var(--text-muted);width:48px\">${s}px</div><div layer-name=\"Sample\" style=\"font-size:${s}px;color:var(--text)\">The quick brown fox</div></div>"
done
jq -n --arg t "$AB" --arg r "$ROWS" '{targetNodeId:$t,mode:"insert-children",html:("<div layer-name=\"Type scale\" style=\"display:flex;flex-direction:column;gap:12px\"><div layer-name=\"Heading\" style=\"font-size:24px;font-weight:700;color:var(--text)\">Type scale</div>"+$r+"</div>")}' | papercraft-cli call write_html -
```
Add component specimens by placing instances of every component on the same page, in a row per component with each state (default, hover, disabled, selected, error) next to it. Keep this page when you finish: it is the contract for later edits.
+438
View File
@@ -0,0 +1,438 @@
# Tool reference
Generated from `papercraft-cli schema <tool>`; run that command for the authoritative, current text. `fileId` and `pageId` are accepted by almost every tool: omit `fileId` to use the open project, pass `pageId` to work on a page without switching the user's view. Node ids are opaque strings such as `3fa9c1:14`; never show them to the user.
## Projects and pages
### `list_resources` (read-only)
Lists the user's papercraft projects (local folders), most recently modified first. Each item has id, name, path, page count, updatedAt, and `open` for the project open in the app.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `limit` | integer | | Maximum projects to return. Defaults to 50. |
### `open_file`
Opens a project in the app (saving the current one) and returns a get_basic_info response. Optional pageId switches the user to that page.
### `create_file`
Creates a new empty project and returns its id. Call open_file with the id to work in it.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `name` | string | | Optional project name. |
### `create_page`
Creates a new page at the end of the page list and returns its id, without switching the user's page. Use the returned pageId in later calls.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `name` | string | | Optional page name. Defaults to "Page N". |
### `rename_pages`
Rename one or more pages. Does not switch the user's page.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `updates` | array of object | yes | Page renames. |
## Read
### `get_basic_info` (read-only)
Essential context about the open project: project name, the page, node count, artboards (top-level frames) with ids, sizes and world positions, other top-level layers, fonts, and `pages` with the active one marked isActive.
- worldX/worldY are world positions; x/y are relative to the parent.
- Pass pageId to inspect another page without switching the user's view.
### `get_selection` (read-only)
The selected layers on the page the user is viewing: ids, names, types, sizes, positions and the artboard each belongs to.
### `get_node_info` (read-only)
Details of one node: type, name, size, visibility, lock state, parent, children ids, text content, x/y (relative to parent) and worldX/worldY. Errors if it doesn't exist.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `nodeId` | string | yes | The node to inspect. |
### `get_children` (read-only)
Direct children of a node (or of the page root when nodeId is "root"), with ids, names, types, child counts and positions.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `nodeId` | string | yes | The parent node, or "root". |
### `get_tree_summary` (read-only)
Compact indented text tree of a subtree: type, name, id and size per node. Much cheaper than get_jsx for orientation. nodeId "root" summarizes the whole page.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `depth` | number | | Maximum depth (default 3, max 10). |
| `nodeId` | string | yes | Root of the summary, or "root". |
### `get_jsx` (read-only)
JSX for a node and its descendants with inline styles (frames become flex divs, text becomes spans, vectors become inline SVG). Use it to export a design to code.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `format` | inline-styles/tailwind | | Only inline styles are produced; "tailwind" is accepted for compatibility. |
| `nodeId` | string | yes | The node to convert. |
### `get_computed_styles` (read-only)
CSS styles (camelCase) of one or more nodes, as {nodeId: styles}.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `nodeIds` | array of string | yes | Nodes to read. |
### `find_nodes` (read-only)
Find nodes by text content and/or computed style. Searches the whole project by default; pass pageId for one page or nodeId for a subtree. `textValue` matches text case-insensitively with * wildcards anchored to the whole value. `filters` are {styleName, styleValue} pairs (AND), values may use * wildcards; colors match by value.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `filters` | array of object | | |
| `nodeId` | string | | Optional subtree root; takes priority over pageId. |
| `textValue` | string | | |
### `get_fill_image` (read-only)
The image used by a node's image fill, as image content.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `nodeId` | string | yes | A node with an image fill. |
### `get_font_family_info` (read-only)
Whether font families are available and their weights/styles. papercraft renders text with Inter (400, 700, normal and italic); other families fall back to Inter.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `familyNames` | array of string | yes | |
### `get_guide` (read-only)
Read a guide. Topics: "papercraft-mcp-instructions" (read first), "html" (what write_html supports), "whiteboard" (shapes, arrows, stickies, hand-drawn style).
| Argument | Type | Required | Notes |
|---|---|---|---|
| `topic` | string | yes | |
## See and export
### `get_screenshot` (read-only)
Render a node (or the whole page when nodeId is omitted or "root") to a PNG and return it as an image. Scale 1 (default) is enough for layout checks; use 2 to read small text. Large images are scaled down to fit API limits.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `nodeId` | string | | The node to capture; omit for the whole page. |
| `scale` | number | | Render scale: 1 (default) or 2. |
### `export`
Export nodes to files. `nodes` maps node ids to export settings ([{format: png|svg|pdf, scale: "2x" | "512w" | "512h" | "720p"}]; [] = 2x PNG). Files go to `directory` (default: the Downloads folder). Returns the paths written.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `directory` | string | | Output folder (absolute path). |
| `nodes` | object | yes | |
### `export_combined_pdf`
Export several nodes into one PDF, one page per node, ordered top-to-bottom then left-to-right. Returns the file path.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `nodeIds` | array of string | yes | Nodes to put on the pages. |
| `path` | string | | Output file (default: Downloads/<project>.pdf). |
## Write
### `create_artboard`
Creates an artboard (top-level frame) and returns its id; then add content with write_html({mode: "insert-children"}). `styles` are camelCase CSS; width and height are required (e.g. "1440px"). Artboards default to display:flex, flexDirection:column and a white background, and are placed in an empty spot unless left/top are given. Default sizes: desktop 1440×900, tablet 768×1024, mobile 390×844.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `name` | string | yes | Layer name. |
| `styles` | object | yes | CSS styles, e.g. {"width": "390px", "height": "844px", "backgroundColor": "#f5f5f5", "padding": "24px", "gap": "16px"} |
### `write_html`
Parse HTML with inline styles into layers. Write incrementally: each call should add one visual group (a header, one list row, a card) so the user sees progress.
- mode "insert-children" appends the HTML inside targetNodeId ("root" = the page); "replace" replaces targetNodeId with it.
- Inline styles only. Flexbox (display:flex, flex-direction, gap, padding, align-items, justify-content, flex-grow, flex-wrap) is the layout model; block elements stack vertically. position:absolute with left/top is supported. No margin, grid or tables.
- Text: font-size, font-weight (≥600 = bold), font-style, color, line-height, letter-spacing, text-align, text-decoration; <b>/<i>/<u>/<s>/<span style> make rich text. Font family is always Inter.
- Boxes: background(-color/-image: colors, linear/radial gradients, url()), border, border-radius, opacity, overflow:hidden, mix-blend-mode, width/height (px, %, fit-content).
- <img src> takes paper-asset:///abs/path, file:// URLs, absolute paths or data: URIs. <svg> markup is imported as vector layers.
- <x-paper-icon name="set:name" size="20" color="var(--accent)"/> places an installed icon (search_icons); <x-paper-instance component-id="ID" layer-name="..."/> places a component instance; colors may be var(--name) of set_variables.
- layer-name="..." names layers; <x-paper-clone node-id="ID" style="..."/> clones an existing node.
Returns the ids of the created top-level layers and the styles that were ignored.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `html` | string | yes | HTML to parse into layers. |
| `mode` | insert-children/replace | yes | |
| `targetNodeId` | string | yes | Target node id, or "root" for the page. |
### `update_styles`
Update styles on one or more nodes (camelCase CSS, like React.CSSProperties). Setting left/top on an artboard moves it. Hand-drawn keys: sketch, roughness, fillStyle, strokeStyle, seed. Styles with no effect are returned under ignoredStyles.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `updates` | array of object | yes | Each item applies styles to nodeIds. |
### `set_text_content`
Set the text of one or more Text or Sticky nodes (batch).
| Argument | Type | Required | Notes |
|---|---|---|---|
| `updates` | array of object | yes | |
### `rename_nodes`
Rename layers (batch). Names are truncated to 50 characters.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `updates` | array of object | yes | |
### `delete_nodes`
Delete nodes and their descendants. Verify parents with get_node_info first when unsure.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `nodeIds` | array of string | yes | Nodes to delete. |
### `duplicate_nodes`
Deep-clone nodes. Each copy goes under parentId when given, else next to its source; duplicated artboards are placed in an empty spot. Returns new ids and a descendantIdMap from every original descendant id to its copy.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `nodes` | array of object | yes | |
### `move_nodes`
Move nodes keeping their ids. Each move is {nodeId, before: siblingId}, {nodeId, after: siblingId} or {nodeId, parentId, index?} (parentId "root" = the page root). Moves apply in order. In auto-layout parents this changes the visual order; in free parents it changes stacking and keeps the world position.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `moves` | array of object | yes | |
### `create_shape`
Create a whiteboard element: rectangle, ellipse, diamond, triangle, polygon, star, line, arrow, sticky (sticky note with text) or text. Coordinates are world units, or relative to parentId when given. Lines and arrows take `points` ([[x,y],...], at least two) and arrows may bind to layers with startNodeId/endNodeId (the arrow then follows them). `styles` takes the same CSS as update_styles plus the hand-drawn keys sketch, roughness (0 architect, 1 artist, 2 cartoonist), fillStyle (hachure, cross-hatch, solid, zigzag, dots), strokeStyle (solid, dashed, dotted). Arrows also take route (straight, curved, elbow) and startHead/endHead (none, arrow, triangle, dot, bar, diamond).
| Argument | Type | Required | Notes |
|---|---|---|---|
| `endHead` | string | | |
| `endNodeId` | string | | |
| `height` | number | | |
| `name` | string | | |
| `parentId` | string | | Frame to place the shape in. |
| `points` | array of array | | |
| `route` | straight/curved/elbow | | |
| `sides` | integer | | Polygon sides / star points. |
| `startHead` | string | | |
| `startNodeId` | string | | |
| `styles` | object | | CSS and hand-drawn styles. |
| `text` | string | | Text for sticky and text. |
| `type` | rectangle/ellipse/diamond/triangle/polygon/star/line/arrow/sticky/text | yes | |
| `width` | number | | |
| `x` | number | | |
| `y` | number | | |
## Design system
### `get_variables` (read-only)
Color variables of the page's project (name -> hex). Fills that follow a variable were written with `var(--name)`; set_variables moves all of them at once. `format` css returns a `:root { --name: #hex }` block, json a design-tokens object.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `format` | css/json | | |
### `set_variables`
Define or change color variables (names like `surface`, `accent-text`), on every page. Use them as `var(--name)` in write_html / update_styles / create_artboard colors (backgroundColor, background, color) instead of hex literals, so a theme change is one call. Fills already following a changed variable are updated. Variables apply to fills and text color; strokes and gradients keep fixed colors.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `mode` | string | | Store the values for this mode (e.g. "Light", "Dark"); they apply when it is the active mode. The first mode you define becomes active. |
| `variables` | object | yes | {"accent": "#cba6f7", "surface": "#313244"} |
### `set_variable_mode`
Switch the active variable mode (defined with set_variables {mode}): every variable takes that mode's value and all fills following them change, on every page. One call turns a whole design from light to dark.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `mode` | string | yes | |
### `get_components` (read-only)
Main components on a page (id, name, size) with how many instances each has.
### `create_component`
Turn a frame or artboard into a main component. Build reusable parts (button, row, chip, tab) once as components, then place them with create_instance or <x-paper-instance>. Components and instances live on one page.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `name` | string | | Optional new layer name. |
| `nodeId` | string | yes | The frame to convert. |
### `create_instance`
Place an instance of a component. Edit text and styles on the instance's descendants with set_text_content / update_styles (they become overrides); edits to the main component flow to every instance. Returns the instance id and its text layers.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `componentId` | string | yes | |
| `index` | number | | Position among the parent's children. |
| `name` | string | | |
| `parentId` | string | | Parent frame; omit or "root" for the page. |
| `x` | number | | |
| `y` | number | | |
### `search_icons` (read-only)
Search installed open icon sets (Lucide, Tabler, Phosphor, Heroicons, Material). Returns `set:name` references for <x-paper-icon name="lucide:check" size="20" color="var(--text)"/> in write_html. Without a query it lists installed and installable sets; install with `papercraft-cli icons add lucide`.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `limit` | number | | Default 20. |
| `query` | string | | |
| `set` | string | | |
### `lint_design` (read-only)
Check a page or subtree for common design mistakes and get a fix for each: text contrast (WCAG 4.5:1, 3:1 for large text), text under 12px, font sizes off the type scale, gap/padding off the 4px grid, buttons and tabs under 44px tall, default layer names (Frame, Rectangle), and hard-coded colors that equal a variable. Run it before finish_working_on_nodes and fix errors and warnings; `info` items are suggestions.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `nodeId` | string | | Subtree to check; omit for the whole page. |
| `scale` | array of number | | Allowed font sizes. Default 12, 14, 16, 20, 24, 32, 40, 48, 64. |
## Session
### `select_nodes`
Select nodes on the page the user is viewing so they can see what you mean; zoom: true also frames them in the viewport. An empty list clears the selection.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `nodeIds` | array of string | yes | Nodes to select. |
| `zoom` | boolean | | |
### `undo`
Undo the last change on a page (the user's or an agent's).
### `redo`
Redo the last undone change on a page.
### `finish_working_on_nodes`
Call when done creating or editing. Saves the project.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `nodeIds` | array of string | | Optional nodes you worked on. |
+209
View File
@@ -0,0 +1,209 @@
# Web build: remote agent relay + closing the browser gaps
Audience: a coding agent (pi, MiniMax) with no prior context. Everything needed is here.
Work in the git worktree you were started in. Do not commit; leave changes in the working tree.
## 1. What PaperCraft is
PaperCraft is a native infinite-canvas design tool in Rust (edition 2024, rustc >= 1.95).
Crate `papercraft` = library (`src/lib.rs`) + bins (`papercraft`, `papercraft-cli`, `papercraft-mcp`).
UI is egui 0.36 + egui-wgpu; the canvas is drawn by our own wgpu renderer (`src/render/`).
AI agents drive the app over MCP (`src/mcp/`): tool catalogue in `mcp/tools.rs`, executor in
`mcp/exec.rs` (trait `Host`), rmcp HTTP server in `mcp/server.rs` (native only).
## 2. What exists on this branch (already working, do not redo)
A browser build exists (`wasm32-unknown-unknown`):
- `src/web.rs`: browser shell. Canvas + wgpu surface (WebGPU, WebGL fallback), DOM events
translated to egui `RawInput` and to editor `InputEvent`s, requestAnimationFrame loop, home
screen (projects), autosave, `pagehide` save. `Web` struct owns `Editor`, `UiState`, `Session`.
`#demo` in the URL opens the sample board.
- `src/vfs/`: file-system shim. Native = `std::fs` re-export. Wasm (`vfs/mem.rs`) = in-memory tree
mirrored to IndexedDB (`idb_put`/`idb_delete` fire-and-forget; `vfs::load().await` at startup).
Projects live under `/papercraft` (`WEB_ROOT` in web.rs). All code paths that touch files must go
through `crate::vfs`, never `std::fs` / `image::open` / `File::open` (those do not work in wasm).
- Native-only things are gated with `#[cfg(not(target_arch = "wasm32"))]`: `app.rs` (winit shell),
`mcp::server`, arboard clipboard, `rmcp`/`axum`/`tokio` net features (see `Cargo.toml` target tables).
- `web/index.html`, `web/build.sh` (builds `web/pkg`). `web_time::Instant` is used instead of std.
Build/verify commands (ALWAYS put rustup first: Homebrew's cargo is too old):
export PATH="$HOME/.cargo/bin:$PATH"
cargo +stable test --lib # must stay 112+ passing
cargo +stable check --all-targets # native
cargo +stable check --lib --target wasm32-unknown-unknown # browser
cargo +stable check --target x86_64-pc-windows-msvc # windows
./web/build.sh # -> web/pkg (needs wasm-bindgen-cli 0.2.129, installed)
cargo +stable fmt -- <only the files you changed> # do NOT run plain `cargo fmt`; then `git checkout build.rs`
No browser is available in your environment, so you cannot run the web build. Verify with the
checks above plus native tests. Write the logic so it is unit-testable natively where possible
(pure functions, protocol structs, zip writer, byte-based importers).
## 3. Goal
A. Let a remote AI agent drive a PaperCraft tab running in someone's browser (hosted deployment).
B. Close the browser gaps listed in section 5.
## 4. Part A: relay for remote agents
### Architecture
agent --(MCP streamable HTTP)--> relay <--(WebSocket)-- browser tab
POST /s/{token}/mcp GET /tab/{token}
The tab already contains the full executor (`mcp::exec::call(host, name, args)`), so the relay
only forwards tool calls; it holds no document state.
### A1. `src/bin/papercraft-relay.rs` (native binary)
- Add it to `Cargo.toml` as a `[[bin]]`. Needs axum `ws` feature (extend the existing axum entry
in the NON-wasm target table; keep default-features = false). Prefer reusing `mcp::server`
(`PaperServer`, `StreamableHttpService`, `Dispatch`, `Call`) so the tool list and instructions
stay identical to the desktop server. Study `mcp/server.rs::spawn` first; if the service cannot be
mounted under a dynamic path `/s/{token}/mcp`, create one service per token lazily and route by
token, or expose a small refactor in `mcp/server.rs` (keep desktop behaviour unchanged).
- CLI: `papercraft-relay [--listen 0.0.0.0:8787] [--static <dir>]` (env `PAPERCRAFT_RELAY_LISTEN`).
`--static` serves the built web app (`web/index.html`, `web/pkg/*`) from the same origin with
correct MIME types (`.wasm` = `application/wasm`, `.js`, `.html`, `.css`, `.png`, `.svg`), so one
process hosts everything. Implement with `tokio::fs` + a path-traversal check; no new crate unless
unavoidable.
- `GET /tab/{token}`: WebSocket for the browser. One tab per token; a new connection replaces the
old one (close the old with a reason).
- `POST|GET|DELETE /s/{token}/mcp`: streamable-HTTP MCP endpoint for agents.
- Token = 32..64 lowercase hex chars chosen by the tab. Reject anything else with 404. It is a
bearer secret: never log it, never put it in error messages. The relay stores nothing on disk.
- Origin policy: `/tab/*` accepts browser origins (optionally restricted by `--allow-origin`);
`/s/*` rejects requests that carry an `Origin` header (agents are not browsers).
- A tool call is forwarded to the tab, awaited with a 120 s timeout (same as `CALL_TIMEOUT`).
No tab connected => the tool result is an error text:
"No PaperCraft tab is connected for this link. Open PaperCraft and use Agent > Connect."
- Limits: max WebSocket message 16 MiB; ping every 20 s; drop dead sockets; max 8 concurrent
in-flight calls per token (others wait).
### A2. Wire protocol (JSON text frames, UTF-8)
relay -> tab: `{"id": 7, "name": "get_screenshot", "args": { ... }}`
tab -> relay (success): `{"id": 7, "ok": true, "out": [{"type":"text","text":"..."},{"type":"image","mime":"image/png","data":"<base64>"}]}`
tab -> relay (failure): `{"id": 7, "ok": false, "error": "message"}`
Keep-alive: WebSocket ping/pong. Define the frame structs once in a module compiled for both
native and wasm (e.g. `src/mcp/relay_proto.rs`, serde only) and unit-test the (de)serialisation.
### A3. Tab side (`src/web.rs`; split into `src/web/mod.rs` + `src/web/relay.rs` if it helps)
- `web-sys` WebSocket (add features `WebSocket`, `MessageEvent`, `CloseEvent`, `ErrorEvent`,
`Storage`, plus whatever else you need) with auto-reconnect (backoff 1 s -> 15 s).
- Token: generated once with `crypto.getRandomValues` (16 bytes -> 32 hex), stored in
`localStorage["papercraft.relay.token"]`; "Regenerate" action replaces it and reconnects.
- Relay base URL: query param `?relay=wss://host` > `window.PAPERCRAFT_RELAY` > same origin
(`wss://` when the page is https, else `ws://`).
- Incoming call => run `mcp::exec::call(&mut web_host, name, &args)` on the main thread inside the
frame loop (queue them; process at the start of `frame()` so edits and repaint stay in one place;
set `dirty = true`). Implement `mcp::exec::Host` for `Web` mirroring `impl Host for App` in
`src/app.rs` (open_project, save, project, canvas_open, store, editor).
- `Host::gpu()` returns `Option<Headless>` and the native renderer blocks on GPU readback, which is
impossible in wasm. Tools that need pixels (`get_screenshot`, PNG export) must work on the web:
design an async path (e.g. `Host::gpu()` returns None on web and the executor returns a
"needs render" marker; the shell then renders the requested frame/region offscreen, reads it back
with `wgpu::Buffer::map_async` + a JS Promise or via `canvas.toBlob`, and answers the call
later). Keep the desktop code path and its tests unchanged. Document the design in a comment.
- UI: new top-level menu "Agent" (web only) with: "Connect…" opening a small egui window showing the
full agent URL `https://<host>/s/<token>/mcp` with a Copy button, connection state
(Connected / Connecting / Offline), "Regenerate link", and ready-to-paste snippets for
Claude Code (`claude mcp add --transport http papercraft <url>`) and a generic MCP JSON config.
A small status dot in the toolbar when connected is enough. Menus are built in
`src/ui/mod.rs::menu_bar` / `editor/shortcuts.rs::menus()`; the tab needs a way to add an item
without touching the native menus (e.g. a `UiState` flag set only by `web.rs`).
### A4. Acceptance for Part A
- `cargo +stable test` has an integration test (`tests/relay.rs`) that starts the relay on port 0,
connects a fake tab (tokio-tungstenite as dev-dependency is fine), calls a tool through the MCP
HTTP endpoint (use rmcp client as `tests/` already does for the desktop server if present, else raw
JSON-RPC), and asserts the round trip, the no-tab error, token validation, and Origin rejection.
- Wasm and native and windows checks pass; no new warnings.
- `docs/` or `web/README.md` is NOT required (do not create README files).
## 5. Part B: close the browser gaps
Currently these show "Not available in the browser yet" or are broken in the browser:
B1. **Import** (`HomeAction::Import`, `AppRequest::PlaceImage`, `AppRequest::ImageFill`, drag-drop):
use a hidden `<input type=file>` and `dragover/drop` on the canvas. `io::import::import_as_pages`
takes a `&Path`; add a bytes-based entry (`import_bytes(name, bytes)`) used by both the native
wrapper and the web, supporting `.papercraft`, `.excalidraw`, `.svg`, and images (png/jpg/gif/webp).
Native behaviour must not change. Also add importing a project backup zip (see B6).
B2. **Export** (`AppRequest::Export`, `ExportFormat`, inspector Export section): PNG, SVG, PDF built
by the existing `io::export` code, delivered as a browser download (Blob + temporary
`<a download>`). PNG needs the async GPU readback from A3: share that implementation.
B3. **Clipboard**: `Editor.clipboard_io: Option<Box<dyn io::clipboard::Provider>>` is a sync trait.
Implement `WebClipboard` as an in-memory mirror: on copy/cut write text to the mirror AND
`navigator.clipboard.writeText` (fire and forget); on the DOM `paste` event (don't
preventDefault on Ctrl/Cmd+V) fill the mirror from `clipboardData` (text/plain, and image files
decoded with the `image` crate), then trigger the editor's paste action. `paste` text already
reaches egui text fields via `egui::Event::Paste`; keep that working. Also wire `copy`/`cut` DOM
events. `AppRequest::CopyAsPng` = render + `navigator.clipboard.write(ClipboardItem)`.
B4. **Thumbnails**: `Session::write_thumbnail(&Headless, &Editor)` blocks on GPU readback. Produce
the home-screen thumbnail on the web with the async path, write `thumbnail.png` into the project
folder via `vfs`. Also fix `ui/home.rs::thumb` (line ~64) which uses `image::open(path)`: read
bytes with `vfs::read` and decode with `image::load_from_memory`. Grep the whole tree for other
`image::open`, `File::open`, `std::fs`, `std::path::Path::{exists,is_file,is_dir}` uses on
paths that the web build reaches, and route them through `vfs`.
B5. **Settings persistence**: `theme::Choice::path()` returns None on wasm (`dirs::config_dir`). On
wasm use the fixed vfs path `/config/theme` so the theme choice survives reloads. Likewise the
shape library: native loads `Library::load(Library::path_in(&store.root))` in `App::new`; the web
shell currently never loads or saves it (`st.library.library` stays default). Do the same in web.rs
(load at start; save when `st.library.dirty`, see `app.rs`).
B6. **"Open projects folder"** has no meaning in a browser. On wasm, `HomeAction::RevealRoot` becomes
"Download backup (.zip)": a zip of the whole `/papercraft` tree built in memory. Write a small
store-only (no compression) zip writer (pure function over `Vec<(String, Vec<u8>)>`, CRC-32
implemented locally or via an installed crate; `miniz_oxide` is already a dependency) and a
matching reader used by B1's backup import. Label the sidebar item per platform
(`ui/home.rs`: "Open projects folder" native, "Download backup" web).
B7. **Errors**: the web shell only logs and sets `ed.status`. Show errors to the user (the status
bar already renders `ed.status`; make sure it is visible and cleared sensibly), and surface
"Not saved" persistence failures (IndexedDB write errors currently ignored in `vfs/mem.rs`:
keep a shared "last persistence error" and show it).
B8. **Bundle size**: release wasm is ~17 MB. In `web/build.sh` run `wasm-opt -Oz` when available
(skip silently otherwise) and enable `lto = true`, `codegen-units = 1`, `panic = "abort"`
only for a `[profile.web]` that inherits release (do not change the native release profile).
### Acceptance for Part B
- Everything above works through code paths that compile for wasm; native behaviour and all
existing tests unchanged (`cargo +stable test --lib` passes, same or higher count).
- New native unit tests for the pure parts: bytes importer, zip writer/reader round trip, relay
protocol frames, any path/format helpers.
- No remaining user-visible "Not available in the browser yet" for B1-B6 features. Features that
still cannot work (`HomeAction::RevealRoot` native-only semantics) must be explained in code
comments only where non-obvious.
## 6. Constraints (project rules)
- Do exactly this spec. No unrelated refactors, renames, formatting sweeps, or new README/docs.
- Smallest correct diff; reuse existing code (`mcp::exec`, `io::export`, `io::import`, `Session`,
`HomeView`) before writing new code. New dependencies only if unavoidable; list each in your report
with the reason. Never add dependencies to the native-only table that the wasm build needs, and
vice versa.
- Rust style of the surrounding code: named items, early returns, no `unwrap()` on user/IO input,
no magic numbers (named consts), comments only for non-obvious "why".
- `std::time::Instant/SystemTime` panic on wasm: use `web_time` / `crate::project::now_ms`.
- Blocking calls (`pollster::block_on`, `device.poll(Wait)`, `std::thread`, sockets) are forbidden in
code that compiles for wasm.
- No secrets in code or logs. The relay token is a secret.
- Write files in chunks of at most ~300 lines per tool call (your output limit per turn is small).
- Do not touch `build.rs` formatting; do not edit `Cargo.lock` by hand (cargo updates it).
- Do not commit.
## 7. Work order (stop and report on failure)
1. A2 protocol module + tests. 2. A1 relay + `tests/relay.rs`. 3. A3 tab side (without async
screenshot). 4. Async GPU readback (shared by A3, B2, B4). 5. B1, B3, B5, B6, B7. 6. B2, B4.
7. B8. After each step run the three `cargo check` commands and `cargo +stable test --lib`.
## 8. Final report (print at the end)
List: files changed/added, new dependencies and why, each acceptance item with pass/fail evidence
(command output summaries), anything not done and why, and what must be tested manually in a real
browser (exact steps).
+88 -25
View File
@@ -12,7 +12,8 @@ use crate::ui::home::HomeAction;
use crate::ui::{self, UiState};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use std::time::Duration;
use web_time::Instant;
use winit::application::ApplicationHandler;
use winit::event::{ElementState, MouseButton, MouseScrollDelta, TouchPhase, WindowEvent};
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
@@ -39,6 +40,8 @@ pub struct App {
start: Instant,
canvas: egui::Rect,
captured: bool,
/// Title bar close button pressed; handled like `CloseRequested` in `about_to_wait`.
close_requested: bool,
title: String,
/// When egui asked for a delayed repaint (tooltips, caret blink, animations).
next_repaint: Option<Instant>,
@@ -84,6 +87,7 @@ impl App {
start: Instant::now(),
canvas: egui::Rect::NOTHING,
captured: false,
close_requested: false,
title: String::new(),
next_repaint: None,
#[cfg(target_os = "macos")]
@@ -142,7 +146,7 @@ impl App {
self.ed.status = msg.clone();
rfd::MessageDialog::new()
.set_level(rfd::MessageLevel::Error)
.set_title("paper-rs")
.set_title("PaperCraft")
.set_description(msg)
.show();
}
@@ -153,7 +157,7 @@ impl App {
return self.save_project(true);
}
let Some(p) = rfd::FileDialog::new()
.add_filter("paper-rs document", &[file::EXTENSION])
.add_filter("PaperCraft document", &[file::EXTENSION])
.set_file_name(format!("{}.{}", self.ed.doc.name, file::EXTENSION))
.save_file()
else {
@@ -267,8 +271,14 @@ impl App {
/// Import a file into a new project and open it.
fn import_dialog(&mut self) {
let Some(p) = rfd::FileDialog::new()
.add_filter("Supported files", &[file::EXTENSION, "excalidraw", "svg"])
.add_filter("paper-rs 0.1 document", &[file::EXTENSION])
.add_filter(
"Supported files",
&[file::EXTENSION, file::LEGACY_EXTENSION, "excalidraw", "svg"],
)
.add_filter(
"PaperCraft document",
&[file::EXTENSION, file::LEGACY_EXTENSION],
)
.add_filter("Excalidraw scene", &["excalidraw"])
.add_filter("SVG", &["svg"])
.pick_file()
@@ -328,6 +338,21 @@ impl App {
store.delete_forever(&dir).map_err(|e| e.to_string())
}
HomeAction::EmptyTrash => store.empty_trash().map_err(|e| e.to_string()),
HomeAction::RevealRoot => {
let opener = if cfg!(windows) {
"explorer"
} else if cfg!(target_os = "macos") {
"open"
} else {
"xdg-open"
};
// explorer exits non-zero even on success, so only spawn failure counts.
std::process::Command::new(opener)
.arg(&store.root)
.spawn()
.map(|_| ())
.map_err(|e| format!("cannot open {}: {e}", store.root.display()))
}
};
if let Err(e) = r {
self.error(e);
@@ -402,7 +427,7 @@ impl App {
}
/// Bring a file into the current page: SVG, Excalidraw scene/library, image, or a
/// paper-rs page. Without an open project, a new project is created from it.
/// papercraft page. Without an open project, a new project is created from it.
fn import_into_page(&mut self, p: &std::path::Path, at: Option<V2>) {
let ext = p
.extension()
@@ -464,7 +489,7 @@ impl App {
self.import_file(p);
return;
}
// A paper-rs page or project: add its pages to this project.
// A papercraft page or project: add its pages to this project.
crate::io::import::import_as_pages(p).map(|(_, pages)| {
let n = pages.len();
for pg in pages {
@@ -559,6 +584,7 @@ impl App {
"gif",
"webp",
file::EXTENSION,
file::LEGACY_EXTENSION,
],
)
.pick_file()
@@ -611,6 +637,28 @@ impl App {
}
}
fn try_exit(&mut self, event_loop: &ActiveEventLoop) {
if self.session.project.is_some() {
self.save_project(true);
}
if self.confirm_discard() {
event_loop.exit();
}
}
fn handle_win_cmd(&mut self) {
let Some(cmd) = self.st.win_cmd.take() else {
return;
};
let Some(g) = self.gfx.as_ref() else { return };
match cmd {
ui::WinCmd::Drag => _ = g.window.drag_window(),
ui::WinCmd::Minimize => g.window.set_minimized(true),
ui::WinCmd::ToggleMaximize => g.window.set_maximized(!g.window.is_maximized()),
ui::WinCmd::Close => self.close_requested = true,
}
}
fn redraw(&mut self) {
let now = self.now();
self.ed.tick(now);
@@ -670,16 +718,16 @@ impl App {
g.queue.present(frame);
let title = if self.st.home.is_some() {
"paper-rs".to_string()
"PaperCraft".to_string()
} else if self.session.project.is_some() {
format!(
"{} — {} — paper-rs",
"{} — {} — PaperCraft",
self.st.project_name,
self.ed.page_name()
)
} else {
format!(
"{}{} — paper-rs",
"{}{} — PaperCraft",
self.ed.doc.name,
if self.ed.is_dirty() { " •" } else { "" }
)
@@ -691,6 +739,7 @@ impl App {
let pending = !self.ed.requests.is_empty()
|| self.st.home.as_ref().is_some_and(|h| !h.actions.is_empty());
self.handle_requests();
self.handle_win_cmd();
self.handle_home_actions();
self.tick_autosave();
if self.st.library.dirty {
@@ -822,18 +871,27 @@ fn map_key(k: &WKey) -> Option<Key> {
})
}
fn app_icon() -> Option<winit::window::Icon> {
let png = include_bytes!("../assets/app-icon/papercraft-macos-512.png");
let img = image::load_from_memory(png).ok()?.to_rgba8();
let img = image::imageops::resize(&img, 256, 256, image::imageops::FilterType::Lanczos3);
winit::window::Icon::from_rgba(img.into_raw(), 256, 256).ok()
}
impl ApplicationHandler<UserEvent> for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.gfx.is_some() {
return;
}
let icon = app_icon();
let attrs = Window::default_attributes()
.with_title("paper-rs")
.with_title("PaperCraft")
.with_window_icon(icon.clone())
.with_inner_size(winit::dpi::LogicalSize::new(1440.0, 900.0))
.with_min_inner_size(winit::dpi::LogicalSize::new(720.0, 480.0));
// PAPER_RS_OPAQUE=1 turns off vibrancy (and window transparency) on macOS.
// PAPERCRAFT_OPAQUE=1 turns off vibrancy (and window transparency) on macOS.
#[cfg(target_os = "macos")]
let translucent = std::env::var_os("PAPER_RS_OPAQUE").is_none();
let translucent = std::env::var_os("PAPERCRAFT_OPAQUE").is_none();
#[cfg(target_os = "macos")]
let attrs = {
use winit::platform::macos::WindowAttributesExtMacOS;
@@ -844,7 +902,13 @@ impl ApplicationHandler<UserEvent> for App {
.with_title_hidden(true)
.with_transparent(translucent)
};
#[cfg(windows)]
let attrs = {
use winit::platform::windows::WindowAttributesExtWindows;
attrs.with_decorations(false).with_taskbar_icon(icon)
};
let window = Arc::new(event_loop.create_window(attrs).expect("create window"));
self.st.custom_titlebar = cfg!(windows);
window.set_ime_allowed(true);
#[cfg(target_os = "macos")]
{
@@ -867,7 +931,7 @@ impl ApplicationHandler<UserEvent> for App {
let adapter_info = adapter.get_info();
log::info!("adapter: {adapter_info:?}");
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
label: Some("paper-rs"),
label: Some("PaperCraft"),
required_limits: wgpu::Limits::downlevel_defaults().using_resolution(adapter.limits()),
..Default::default()
}))
@@ -927,14 +991,7 @@ impl ApplicationHandler<UserEvent> for App {
let mut redraw = resp.repaint;
let now = self.now();
match event {
WindowEvent::CloseRequested => {
if self.session.project.is_some() {
self.save_project(true);
}
if self.confirm_discard() {
event_loop.exit();
}
}
WindowEvent::CloseRequested => self.try_exit(event_loop),
WindowEvent::Resized(s) => {
let g = self.gfx.as_mut().unwrap();
g.config.width = s.width.max(1);
@@ -1174,7 +1231,9 @@ impl ApplicationHandler<UserEvent> for App {
{
let at = self.ed.camera.screen_to_world(self.ed.pointer);
self.import_into_page(&p, Some(at));
} else if p.extension().is_some_and(|e| e == file::EXTENSION)
} else if p
.extension()
.is_some_and(|e| e == file::EXTENSION || e == file::LEGACY_EXTENSION)
&& self.confirm_discard()
{
match file::load(&p) {
@@ -1204,6 +1263,9 @@ impl ApplicationHandler<UserEvent> for App {
}
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
if std::mem::take(&mut self.close_requested) {
self.try_exit(event_loop);
}
// Autosave runs off a timer so it also fires while the window sits idle.
if self
.session
@@ -1319,6 +1381,7 @@ impl App {
return;
}
for c in calls {
self.st.agent = Some((c.name.clone(), std::time::Instant::now()));
let r = crate::mcp::exec::call(self, &c.name, &c.args);
if let Err(e) = &r {
log::info!("mcp {}: {e}", c.name);
@@ -1379,14 +1442,14 @@ impl crate::mcp::exec::Host for App {
}
}
/// Start the MCP server unless `PAPER_RS_MCP=0`. Calls wake the event loop through `proxy`.
/// Start the MCP server unless `PAPERCRAFT_MCP=0`. Calls wake the event loop through `proxy`.
fn start_mcp(
proxy: winit::event_loop::EventLoopProxy<UserEvent>,
) -> Option<(
std::sync::mpsc::Receiver<crate::mcp::Call>,
crate::mcp::server::Running,
)> {
if std::env::var("PAPER_RS_MCP").is_ok_and(|v| matches!(v.as_str(), "0" | "off" | "false")) {
if std::env::var("PAPERCRAFT_MCP").is_ok_and(|v| matches!(v.as_str(), "0" | "off" | "false")) {
return None;
}
let (tx, rx) = std::sync::mpsc::channel::<crate::mcp::Call>();
+4 -4
View File
@@ -90,11 +90,11 @@ pub fn get(id: &str) -> Option<Arc<ImageAsset>> {
/// Register every image in a project's `assets/` folder.
pub fn load_dir(dir: &Path) {
let Ok(rd) = std::fs::read_dir(dir) else {
let Ok(rd) = crate::vfs::read_dir(dir) else {
return;
};
for e in rd.flatten() {
if let Ok(bytes) = std::fs::read(e.path())
if let Ok(bytes) = crate::vfs::read(e.path())
&& let Err(err) = register(&bytes)
{
log::warn!("{}: {err}", e.path().display());
@@ -107,8 +107,8 @@ pub fn save_to_dir(dir: &Path, ids: impl IntoIterator<Item = String>) -> std::io
for id in ids {
let Some(a) = get(&id) else { continue };
let path = dir.join(format!("{id}.{}", a.ext));
if !path.exists() {
std::fs::create_dir_all(dir)?;
if !crate::vfs::exists(&path) {
crate::vfs::create_dir_all(dir)?;
crate::io::atomic::write(&path, &a.bytes)?;
}
}
+292
View File
@@ -0,0 +1,292 @@
//! `papercraft-cli`: drive the running papercraft app from a shell, with no MCP client or tool
//! schemas in an agent's context. Same tools as the MCP server, over the same endpoint
//! (`http://127.0.0.1:29980/mcp`, or `$PAPERCRAFT_MCP_PORT`).
use base64::Engine;
use papercraft::icons;
use papercraft::mcp::{self, server};
use rmcp::ServiceExt;
use rmcp::model::CallToolRequestParams;
use rmcp::transport::StreamableHttpClientTransport;
use serde_json::Value;
use std::io::Read;
use std::process::ExitCode;
const USAGE: &str = "\
papercraft-cli: call the running papercraft app's tools from a shell
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)
papercraft-cli icons list installed and installable open icon sets
papercraft-cli icons add <set>... download sets (lucide tabler phosphor heroicons material)
papercraft-cli icons search <words> [--set S] find `set:name` references (works offline)
papercraft-cli --url print the endpoint it talks to
Icons are placed with <x-paper-icon name='lucide:check' size='20' color='var(--text)'/> in
write_html. Sets are fetched from the npm registry with curl and tar into the icons dir
($PAPERCRAFT_ICONS, else the user data dir) together with their licence file.
Text results go to stdout; image results are saved to a file and the path is printed.
Exit status is 1 when the tool reports an error or the app isn't running.
Start the app and open a project first: it serves the tools while open.
";
fn fail(msg: impl std::fmt::Display) -> ExitCode {
eprintln!("papercraft-cli: {msg}");
ExitCode::FAILURE
}
fn list() {
for t in server::tool_list() {
let d = t.description.as_deref().unwrap_or("");
let first = d.split(['.', '\n']).next().unwrap_or("");
println!("{}\t{first}", t.name);
}
}
fn run(cmd: &mut std::process::Command, what: &str) -> Result<Vec<u8>, String> {
let out = cmd
.output()
.map_err(|e| format!("{what}: cannot run {:?}: {e}", cmd.get_program()))?;
if !out.status.success() {
return Err(format!(
"{what} failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
));
}
Ok(out.stdout)
}
/// Download one icon set from the npm registry into the icons dir. Returns the icon count.
fn add_set(id: &str) -> Result<usize, String> {
let spec = icons::spec(id).ok_or_else(|| {
format!(
"unknown set `{id}`; sets: {}",
icons::SETS
.iter()
.map(|s| s.id)
.collect::<Vec<_>>()
.join(", ")
)
})?;
let meta = run(
std::process::Command::new("curl").args([
"-fsSL",
&format!("https://registry.npmjs.org/{}/latest", spec.package),
]),
"looking up the package",
)?;
let meta: Value = serde_json::from_slice(&meta).map_err(|e| e.to_string())?;
let tarball = meta["dist"]["tarball"]
.as_str()
.ok_or("the registry reply has no tarball")?
.to_string();
let tmp = std::env::temp_dir().join(format!("papercraft-icons-{id}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).map_err(|e| e.to_string())?;
let archive = tmp.join("pkg.tgz");
let result = (|| {
run(
std::process::Command::new("curl")
.args(["-fsSL", "-o"])
.arg(&archive)
.arg(&tarball),
"downloading",
)?;
run(
std::process::Command::new("tar")
.arg("-xzf")
.arg(&archive)
.arg("-C")
.arg(&tmp),
"unpacking",
)?;
let dest = icons::dir().join(id);
std::fs::create_dir_all(&dest).map_err(|e| e.to_string())?;
let mut n = 0;
let src = tmp.join("package").join(spec.dir);
for e in std::fs::read_dir(&src)
.map_err(|e| format!("{}: {e}", src.display()))?
.flatten()
{
let p = e.path();
if p.extension().is_some_and(|x| x == "svg") {
std::fs::copy(&p, dest.join(e.file_name())).map_err(|e| e.to_string())?;
n += 1;
}
}
for lic in ["LICENSE", "LICENSE.md", "LICENSE.txt", "license"] {
let p = tmp.join("package").join(lic);
if p.is_file() {
let _ = std::fs::copy(&p, dest.join("LICENSE"));
break;
}
}
Ok(n)
})();
let _ = std::fs::remove_dir_all(&tmp);
result
}
fn icons_cmd(args: &[String]) -> ExitCode {
let root = icons::dir();
match args.first().map(String::as_str) {
Some("list") => {
println!("icons dir: {}", root.display());
let have = icons::installed(&root);
for s in icons::SETS {
let n = have.iter().find(|(h, _)| h == s.id).map_or(0, |h| h.1);
let mark = if n > 0 {
format!("{n} installed")
} else {
"not installed".into()
};
println!("{}\t{}\t{}\t{mark}", s.id, s.license, s.about);
}
ExitCode::SUCCESS
}
Some("add") if args.len() > 1 => {
let mut ok = true;
for id in &args[1..] {
match add_set(id) {
Ok(n) => println!("{id}: {n} icons in {}", root.join(id).display()),
Err(e) => {
eprintln!("papercraft-cli: {id}: {e}");
ok = false;
}
}
}
if ok {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
}
}
Some("search") if args.len() > 1 => {
let set = args
.iter()
.position(|a| a == "--set")
.and_then(|i| args.get(i + 1));
let words: Vec<&str> = args[1..]
.iter()
.map(String::as_str)
.filter(|a| *a != "--set" && Some(*a) != set.map(String::as_str))
.collect();
for r in icons::search(&root, &words.join(" "), set.map(String::as_str), 40) {
println!("{r}");
}
ExitCode::SUCCESS
}
_ => fail("usage: icons list | icons add <set>... | icons search <words> [--set S]"),
}
}
fn schema(name: &str) -> ExitCode {
let Some(t) = server::tool_list().into_iter().find(|t| t.name == name) else {
return fail(format!("no tool named {name} (see `papercraft-cli list`)"));
};
println!(
"{}",
serde_json::to_string_pretty(&serde_json::to_value(&t).unwrap_or(Value::Null))
.unwrap_or_default()
);
ExitCode::SUCCESS
}
async fn call(name: String, args: Value, out: Option<String>) -> Result<bool, String> {
let url = mcp::url(mcp::port());
let client = ()
.serve(StreamableHttpClientTransport::from_uri(url.clone()))
.await
.map_err(|e| format!("cannot reach papercraft at {url}: {e}. Is the app running?"))?;
let mut p = CallToolRequestParams::new(name.clone());
match args {
Value::Object(m) => p.arguments = Some(m),
Value::Null => {}
_ => return Err("arguments must be a JSON object".into()),
}
let r = client.call_tool(p).await.map_err(|e| e.to_string())?;
// Blocks are read through their JSON form, so this doesn't depend on rmcp's enum layout.
for block in r.content {
let v = serde_json::to_value(&block).map_err(|e| e.to_string())?;
match v.get("type").and_then(Value::as_str) {
Some("text") => println!("{}", v["text"].as_str().unwrap_or("")),
Some("image") => {
let data = base64::engine::general_purpose::STANDARD
.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"));
std::fs::write(&path, data).map_err(|e| format!("{path}: {e}"))?;
println!("{path}");
}
_ => println!("{v}"),
}
}
Ok(r.is_error != Some(true))
}
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();
match args.first().map(String::as_str) {
Some("list") => list(),
Some("schema") => match args.get(1) {
Some(n) => return schema(n),
None => return fail("schema needs a tool name"),
},
Some("icons") => return icons_cmd(&args[1..]),
Some("--url") => println!("{}", mcp::url(mcp::port())),
Some("call") => {
let Some(name) = args.get(1).cloned() else {
return fail("call needs a tool name");
};
let json = match args.get(2).map(String::as_str) {
None => String::new(),
Some("-") => {
let mut s = String::new();
if let Err(e) = std::io::stdin().read_to_string(&mut s) {
return fail(e);
}
s
}
Some(s) => s.to_string(),
};
let value = if json.trim().is_empty() {
Value::Null
} else {
match serde_json::from_str(&json) {
Ok(v) => v,
Err(e) => return fail(format!("arguments are not valid JSON: {e}")),
}
};
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => return fail(e),
};
return match rt.block_on(call(name, value, out)) {
Ok(true) => ExitCode::SUCCESS,
Ok(false) => ExitCode::FAILURE,
Err(e) => fail(e),
};
}
_ => {
print!("{USAGE}");
return if args.is_empty() || args[0] == "--help" || args[0] == "-h" {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
};
}
}
ExitCode::SUCCESS
}
@@ -1,11 +1,11 @@
//! `paper-rs-mcp`: MCP over stdio for clients that can't speak HTTP, relayed to the running
//! paper-rs app (`http://127.0.0.1:29980/mcp`, or `$PAPER_RS_MCP_PORT`).
//! `papercraft-mcp`: MCP over stdio for clients that can't speak HTTP, relayed to the running
//! papercraft app (`http://127.0.0.1:29980/mcp`, or `$PAPERCRAFT_MCP_PORT`).
//!
//! The tool list is answered locally, so the agent sees every tool even before the app is
//! started; calls are forwarded to the app and report a clear error while it isn't running.
//! The connection is opened lazily and re-opened after the app restarts.
use paper_rs::mcp::{self, server};
use papercraft::mcp::{self, server};
use rmcp::model::{
CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ListToolsResult,
PaginatedRequestParams, ResultType, ServerConfig,
@@ -17,14 +17,14 @@ use std::sync::Arc;
use tokio::sync::Mutex;
const USAGE: &str = "\
paper-rs-mcp — stdio MCP bridge to the running paper-rs app
papercraft-mcp — stdio MCP bridge to the running papercraft app
USAGE:
paper-rs-mcp serve MCP on stdin/stdout (what agent harnesses run)
paper-rs-mcp --url print the HTTP endpoint it relays to
paper-rs-mcp --help
papercraft-mcp serve MCP on stdin/stdout (what agent harnesses run)
papercraft-mcp --url print the HTTP endpoint it relays to
papercraft-mcp --help
The app serves MCP itself at http://127.0.0.1:29980/mcp; set PAPER_RS_MCP_PORT to change
The app serves MCP itself at http://127.0.0.1:29980/mcp; set PAPERCRAFT_MCP_PORT to change
the port (in both the app's and the bridge's environment).
";
@@ -42,7 +42,7 @@ impl Bridge {
let client = ()
.serve(transport)
.await
.map_err(|e| format!("cannot reach paper-rs at {}: {e}", self.url))?;
.map_err(|e| format!("cannot reach papercraft at {}: {e}", self.url))?;
*self.client.lock().await = Some(client);
Ok(())
}
@@ -60,7 +60,7 @@ impl Bridge {
match res {
Ok(r) => return Ok(r),
Err(e) if attempt == 0 => {
log::info!("paper-rs-mcp: reconnecting after {e}");
log::info!("papercraft-mcp: reconnecting after {e}");
*self.client.lock().await = None;
}
Err(e) => return Err(e.to_string()),
@@ -98,8 +98,8 @@ impl ServerHandler for Bridge {
r
}
Err(e) => CallToolResult::error(vec![ContentBlock::text(format!(
"{e}\nThe paper-rs app is not running or its MCP server is off. Ask the user to \
start paper-rs (it serves MCP while open), then try again."
"{e}\nThe papercraft app is not running or its MCP server is off. Ask the user to \
start papercraft (it serves MCP while open), then try again."
))]),
};
Ok(r.into())
@@ -128,7 +128,7 @@ fn main() -> std::process::ExitCode {
{
Ok(rt) => rt,
Err(e) => {
eprintln!("paper-rs-mcp: {e}");
eprintln!("papercraft-mcp: {e}");
return std::process::ExitCode::FAILURE;
}
};
@@ -147,7 +147,7 @@ fn main() -> std::process::ExitCode {
match r {
Ok(()) => std::process::ExitCode::SUCCESS,
Err(e) => {
eprintln!("paper-rs-mcp: {e}");
eprintln!("papercraft-mcp: {e}");
std::process::ExitCode::FAILURE
}
}
+102
View File
@@ -0,0 +1,102 @@
//! `papercraft-relay`: serves MCP for a PaperCraft tab running in a browser.
//!
//! The tab connects to `GET /tab/<token>` and an agent to `POST /s/<token>/mcp`; the relay
//! forwards tool calls between them and holds no document state. `--static` also serves the
//! built web app (`web/index.html` + `web/pkg`) from the same origin, so one process hosts it.
use papercraft::mcp::relay;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::process::ExitCode;
const USAGE: &str = "\
papercraft-relay — MCP relay for a PaperCraft tab in a browser
USAGE:
papercraft-relay [--listen 0.0.0.0:8787] [--static <dir>] [--allow-origin <origin>]
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)
--help print this text
The tab picks a 32..64 hex character token and shows the agent URL
http://<host>:<port>/s/<token>/mcp
";
/// Address used when neither `--listen` nor `$PAPERCRAFT_RELAY_LISTEN` says otherwise.
const DEFAULT_LISTEN: &str = "0.0.0.0:8787";
const ENV_LISTEN: &str = "PAPERCRAFT_RELAY_LISTEN";
struct Args {
listen: String,
static_dir: Option<PathBuf>,
allow_origin: Option<String>,
}
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 args = std::env::args().skip(1);
while let Some(arg) = args.next() {
let mut value = || args.next().ok_or_else(|| format!("{arg} needs a value"));
match arg.as_str() {
"--listen" => listen = value()?,
"--static" => static_dir = Some(PathBuf::from(value()?)),
"--allow-origin" => allow_origin = Some(value()?),
other => return Err(format!("unknown argument {other}\n\n{USAGE}")),
}
}
Ok(Args {
listen,
static_dir,
allow_origin,
})
}
fn main() -> ExitCode {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
if std::env::args().any(|a| a == "--help" || a == "-h") {
print!("{USAGE}");
return ExitCode::SUCCESS;
}
let args = match parse_args() {
Ok(a) => a,
Err(e) => {
eprintln!("papercraft-relay: {e}");
return ExitCode::FAILURE;
}
};
let listen: SocketAddr = match args.listen.parse() {
Ok(a) => a,
Err(_) => {
eprintln!(
"papercraft-relay: '{}' is not an address like 0.0.0.0:8787 (set {ENV_LISTEN} to change it)",
args.listen
);
return ExitCode::FAILURE;
}
};
let running = match relay::spawn(listen, args.static_dir.clone(), args.allow_origin.clone()) {
Ok(r) => r,
Err(e) => {
eprintln!("papercraft-relay: {e}");
return ExitCode::FAILURE;
}
};
// The port is the real one, which matters when the request asked for 0.
println!("papercraft-relay: listening on port {}", running.port());
if let Some(dir) = &args.static_dir {
println!("papercraft-relay: serving {}", dir.display());
}
println!(
"papercraft-relay: agent endpoints are http://<this host>:{}/s/<token>/mcp",
running.port()
);
// Serve until the process is signalled; `running` then stops the server on the way out.
loop {
std::thread::park();
}
}
+4 -1
View File
@@ -104,6 +104,9 @@ impl Tool {
}
}
/// Ctrl+wheel zoom rate: one 40px wheel notch is about 13%.
const ZOOM_PER_SCROLL_PX: f64 = 0.003;
/// Requests the editor can't fulfil itself (dialogs, file system, clipboard).
#[derive(Debug, Clone, PartialEq)]
pub enum AppRequest {
@@ -633,7 +636,7 @@ impl Editor {
}
InputEvent::Scroll { delta, pos } => {
if self.mods.ctrl || self.mods.logo {
let factor = (-delta.y * 0.01).exp();
let factor = (-delta.y * ZOOM_PER_SCROLL_PX).exp();
self.camera.zoom_about(pos, self.camera.zoom * factor);
} else {
self.camera.pan_by_screen(-delta);
+22 -1
View File
@@ -55,6 +55,7 @@ fn copy_props(main: &Node, inst: &mut Node, overrides: &[String], is_root: bool)
if !ov("opacity") {
inst.style.opacity = s.opacity;
inst.style.blend = s.blend;
inst.style.shadow = s.shadow;
}
inst.style.rough = s.rough;
match (&main.kind, &mut inst.kind) {
@@ -108,7 +109,7 @@ fn copy_props(main: &Node, inst: &mut Node, overrides: &[String], is_root: bool)
}
/// Copy a subtree as a new instance subtree under `parent` (ids are fresh).
fn instantiate(
pub fn instantiate(
tx: &mut Tx,
main: NodeId,
parent: Option<NodeId>,
@@ -121,6 +122,7 @@ fn instantiate(
n.id = id;
n.children = Vec::new();
n.component = false;
n.comments.clear();
n.instance = Some(InstanceLink {
main,
overrides: Vec::new(),
@@ -135,6 +137,25 @@ fn instantiate(
id
}
/// Mark property groups of an instance node as overridden, so edits to the main component
/// stop overwriting them. Does nothing for nodes that aren't part of an instance.
pub fn mark_overrides(tx: &mut Tx, id: NodeId, keys: &[&str]) {
let Some(link) = tx.doc().get(id).and_then(|n| n.instance.as_ref()) else {
return;
};
let missing: Vec<String> = keys
.iter()
.filter(|k| !link.overrides.iter().any(|o| o == *k))
.map(|k| k.to_string())
.collect();
if missing.is_empty() {
return;
}
if let Some(link) = tx.node_mut(id).and_then(|n| n.instance.as_mut()) {
link.overrides.extend(missing);
}
}
/// Bring one instance subtree in line with its main counterpart.
fn sync_node(tx: &mut Tx, inst: NodeId, is_root: bool) {
let Some(n) = tx.doc().get(inst) else { return };
+1 -1
View File
@@ -82,7 +82,7 @@ fn wheel_pans_and_ctrl_wheel_zooms_about_cursor() {
..Modifiers::NONE
}));
e.handle(InputEvent::Scroll {
delta: v2(0.0, -100.0),
delta: v2(0.0, -400.0),
pos: anchor,
});
assert!(e.camera.zoom > 2.5);
+131 -2
View File
@@ -5,7 +5,7 @@
//! and pushes a [`Change`] that can be applied in either direction.
use crate::geom::V2;
use crate::model::{Document, Node, NodeId};
use crate::model::{Color, Document, Node, NodeId, VarState};
use std::collections::HashMap;
const MAX_DEPTH: usize = 500;
@@ -22,6 +22,7 @@ pub struct Change {
pub label: String,
entries: Vec<Entry>,
root: Option<(Vec<NodeId>, Vec<NodeId>)>,
variables: Option<(VarState, VarState)>,
next_id: (u64, u64),
coalesce_key: Option<String>,
}
@@ -46,6 +47,9 @@ impl Change {
before.clone()
};
}
if let Some((before, after)) = &self.variables {
doc.restore_vars(if forward { after } else { before });
}
doc.next_id = if forward {
self.next_id.1
} else {
@@ -61,6 +65,7 @@ pub struct Recording {
before: HashMap<NodeId, Option<Node>>,
order: Vec<NodeId>,
root_before: Option<Vec<NodeId>>,
variables_before: Option<VarState>,
next_id_before: u64,
}
@@ -115,6 +120,120 @@ impl<'a> Tx<'a> {
self.doc
}
/// Define or change a document variable and move every fill that follows it. Returns the
/// number of fills updated.
pub fn set_variable(&mut self, name: &str, color: Color) -> usize {
self.snapshot_variables();
self.doc.variables.insert(name.to_string(), color);
if let Some(mode) = self.doc.mode.clone() {
self.doc
.modes
.entry(mode)
.or_default()
.insert(name.to_string(), color);
}
self.apply_variable(name, color)
}
/// Delete a variable: fills that followed it keep their color but stop following.
pub fn remove_variable(&mut self, name: &str) {
self.snapshot_variables();
self.doc.variables.remove(name);
for m in self.doc.modes.values_mut() {
m.remove(name);
}
let users: Vec<NodeId> = self
.doc
.nodes
.values()
.filter(|n| n.style.fills.iter().any(|p| p.var() == Some(name)))
.map(|n| n.id)
.collect();
for id in users {
if let Some(n) = self.node_mut(id) {
for p in &mut n.style.fills {
if p.var() == Some(name) {
p.unlink_var();
}
}
}
}
}
/// 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());
}
fn snapshot_variables(&mut self) {
if self.rec.variables_before.is_none() {
self.rec.variables_before = Some(self.doc.var_state());
}
}
/// Store `name = color` for `mode`; when that mode is the active one (or none is active
/// yet) the live value and every fill following it change too. Returns the fills updated.
pub fn set_variable_in_mode(&mut self, mode: &str, name: &str, color: Color) -> usize {
self.snapshot_variables();
if self.doc.mode.is_none() {
self.doc.mode = Some(mode.to_string());
}
self.doc
.modes
.entry(mode.to_string())
.or_default()
.insert(name.to_string(), color);
if self.doc.mode.as_deref() == Some(mode) {
self.doc.variables.insert(name.to_string(), color);
self.apply_variable(name, color)
} else {
0
}
}
/// Switch the active mode: every variable takes its value for `mode` (variables a mode does
/// not define keep their current value) and the fills following them move. Returns the
/// fills updated.
pub fn set_mode(&mut self, mode: &str) -> usize {
self.snapshot_variables();
let values: Vec<(String, Color)> = self
.doc
.modes
.get(mode)
.map(|m| m.iter().map(|(k, v)| (k.clone(), *v)).collect())
.unwrap_or_default();
self.doc.mode = Some(mode.to_string());
let mut fills = 0;
for (name, c) in values {
self.doc.variables.insert(name.clone(), c);
fills += self.apply_variable(&name, c);
}
fills
}
/// Move every fill following `name` to `color`.
fn apply_variable(&mut self, name: &str, color: Color) -> usize {
let users: Vec<NodeId> = self
.doc
.nodes
.values()
.filter(|n| n.style.fills.iter().any(|p| p.var() == Some(name)))
.map(|n| n.id)
.collect();
let mut fills = 0;
for id in users {
let Some(n) = self.node_mut(id) else { continue };
for p in &mut n.style.fills {
if p.var() == Some(name) {
p.set_solid_color(color);
fills += 1;
}
}
}
fills
}
pub fn node_mut(&mut self, id: NodeId) -> Option<&mut Node> {
if !self.doc.nodes.contains_key(&id) {
return None;
@@ -229,13 +348,18 @@ impl History {
.root_before
.filter(|b| *b != doc.root)
.map(|b| (b, doc.root.clone()));
if entries.is_empty() && root.is_none() {
let variables = rec
.variables_before
.filter(|b| *b != doc.var_state())
.map(|b| (b, doc.var_state()));
if entries.is_empty() && root.is_none() && variables.is_none() {
return false;
}
let change = Change {
label: rec.label,
entries,
root,
variables,
next_id: (rec.next_id_before, doc.next_id),
coalesce_key,
};
@@ -311,6 +435,11 @@ fn merge(older: &mut Change, newer: Change) {
(r @ None, Some(nr)) => *r = Some(nr),
_ => {}
}
match (&mut older.variables, newer.variables) {
(Some((_, after)), Some((_, new_after))) => *after = new_after,
(v @ None, Some(nv)) => *v = Some(nv),
_ => {}
}
older.next_id.1 = newer.next_id.1;
}
+210
View File
@@ -0,0 +1,210 @@
//! Open icon libraries on disk: `<icons dir>/<set>/<name>.svg`.
//!
//! The app never bundles or downloads icons: `papercraft-cli icons add <set>` fetches a set from
//! the npm registry into the icons dir (`$PAPERCRAFT_ICONS`, else `<data dir>/papercraft/icons`), and
//! `write_html` places them with `<x-paper-icon name="lucide:check">`. Each set keeps the
//! licence file of the package it came from next to the icons.
use std::path::{Path, PathBuf};
/// A downloadable icon set: the npm package, the folder inside it, and its licence.
pub struct SetSpec {
pub id: &'static str,
pub package: &'static str,
pub dir: &'static str,
pub license: &'static str,
pub about: &'static str,
}
pub const SETS: &[SetSpec] = &[
SetSpec {
id: "lucide",
package: "lucide-static",
dir: "icons",
license: "ISC",
about: "Lucide: ~1500 clean 24px stroke icons",
},
SetSpec {
id: "tabler",
package: "@tabler/icons",
dir: "icons/outline",
license: "MIT",
about: "Tabler: ~5000 24px stroke icons",
},
SetSpec {
id: "phosphor",
package: "@phosphor-icons/core",
dir: "assets/regular",
license: "MIT",
about: "Phosphor (regular weight): ~1500 filled-outline icons",
},
SetSpec {
id: "heroicons",
package: "heroicons",
dir: "24/outline",
license: "MIT",
about: "Heroicons 24px outline: ~300 icons by the Tailwind team",
},
SetSpec {
id: "material",
package: "@material-symbols/svg-400",
dir: "outlined",
license: "Apache-2.0",
about: "Material Symbols (outlined, weight 400): ~3000 icons",
},
];
pub fn spec(id: &str) -> Option<&'static SetSpec> {
SETS.iter().find(|s| s.id == id)
}
/// Where the sets live.
pub fn dir() -> PathBuf {
std::env::var_os("PAPERCRAFT_ICONS")
.map(PathBuf::from)
.or_else(|| {
let data = dirs::data_dir()?;
let new = data.join("papercraft").join("icons");
let legacy = data.join("paper-rs").join("icons");
Some(
if crate::vfs::is_dir(&legacy) && !crate::vfs::exists(&new) {
legacy
} else {
new
},
)
})
.unwrap_or_else(|| PathBuf::from("icons"))
}
/// Installed sets with their icon counts.
pub fn installed(root: &Path) -> Vec<(String, usize)> {
let mut out: Vec<(String, usize)> = crate::vfs::read_dir(root)
.into_iter()
.flatten()
.flatten()
.filter(|e| crate::vfs::is_dir(&e.path()))
.map(|e| {
let n = names(root, &e.file_name().to_string_lossy()).len();
(e.file_name().to_string_lossy().into_owned(), n)
})
.collect();
out.sort();
out
}
/// Icon names (without `.svg`) in a set, sorted.
pub fn names(root: &Path, set: &str) -> Vec<String> {
let mut v: Vec<String> = crate::vfs::read_dir(root.join(set))
.into_iter()
.flatten()
.flatten()
.filter_map(|e| {
let p = e.path();
(p.extension()? == "svg").then(|| p.file_stem()?.to_str().map(String::from))?
})
.collect();
v.sort();
v
}
/// `set:name` for every icon whose name contains all words of `query` (all installed sets, or
/// just `set`). Exact names first, then prefix matches, then the rest, shortest first.
pub fn search(root: &Path, query: &str, set: Option<&str>, limit: usize) -> Vec<String> {
let words: Vec<String> = query
.split(|c: char| c.is_whitespace() || c == '-' || c == '_')
.filter(|w| !w.is_empty())
.map(str::to_lowercase)
.collect();
let sets: Vec<String> = match set {
Some(s) => vec![s.to_string()],
None => installed(root).into_iter().map(|(s, _)| s).collect(),
};
let mut hits: Vec<(u8, usize, String)> = Vec::new();
for s in sets {
for n in names(root, &s) {
let lower = n.to_lowercase();
if !words.iter().all(|w| lower.contains(w.as_str())) {
continue;
}
let q = words.join("-");
let rank = if lower == q {
0
} else if lower.starts_with(&q) {
1
} else {
2
};
hits.push((rank, n.len(), format!("{s}:{n}")));
}
}
hits.sort();
hits.into_iter().take(limit).map(|h| h.2).collect()
}
/// The SVG markup of `set:name` (`name` alone looks in every installed set, first match).
pub fn load(root: &Path, reference: &str) -> Result<String, String> {
let (set, name) = match reference.split_once(':') {
Some((s, n)) => (Some(s), n),
None => (None, reference),
};
if name.is_empty() || name.contains(['/', '\\']) || name.starts_with('.') {
return Err(format!("`{reference}` is not an icon name"));
}
let sets: Vec<String> = match set {
Some(s) if !s.contains(['/', '\\']) && !s.starts_with('.') => vec![s.to_string()],
Some(s) => return Err(format!("`{s}` is not an icon set")),
None => installed(root).into_iter().map(|(s, _)| s).collect(),
};
for s in &sets {
if let Ok(svg) = crate::vfs::read_to_string(root.join(s).join(format!("{name}.svg"))) {
return Ok(svg);
}
}
if installed(root).is_empty() {
return Err(
"no icon sets installed; run `papercraft-cli icons add lucide` (see `papercraft-cli icons list`)"
.into(),
);
}
let hint = search(root, name, set, 5);
Err(format!(
"icon `{reference}` not found{}",
if hint.is_empty() {
String::new()
} else {
format!("; similar: {}", hint.join(", "))
}
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_path_tricks_and_ranks_exact_names_first() {
let root =
std::env::temp_dir().join(format!("papercraft-icons-{:016x}", fastrand::u64(..)));
let set = root.join("t");
crate::vfs::create_dir_all(&set).unwrap();
for n in ["check", "check-circle", "arrow-check", "x"] {
crate::vfs::write(set.join(format!("{n}.svg")), "<svg/>").unwrap();
}
assert!(load(&root, "../etc/passwd").is_err());
assert!(load(&root, "t:../x").is_err());
assert!(load(&root, "..:x").is_err());
assert_eq!(
search(&root, "check", None, 10),
["t:check", "t:check-circle", "t:arrow-check"]
);
assert_eq!(load(&root, "t:x").unwrap(), "<svg/>");
assert!(load(&root, "t:nope").unwrap_err().contains("not found"));
assert!(
load(&root.join("missing"), "x")
.unwrap_err()
.contains("no icon sets")
);
crate::vfs::remove_dir_all(root).ok();
}
}
+17 -6
View File
@@ -5,9 +5,13 @@
//! platform we target, so readers see either the old file or the new one, never a torn write.
//! On Unix the directory is fsynced too, so the rename itself survives a power loss.
#[cfg(not(target_arch = "wasm32"))]
use std::io::Write;
use std::path::{Path, PathBuf};
use std::path::Path;
#[cfg(not(target_arch = "wasm32"))]
use std::path::PathBuf;
#[cfg(not(target_arch = "wasm32"))]
fn temp_path(path: &Path) -> PathBuf {
let name = path
.file_name()
@@ -20,7 +24,14 @@ fn temp_path(path: &Path) -> PathBuf {
))
}
/// The in-memory tree has no torn writes.
#[cfg(target_arch = "wasm32")]
pub fn write(path: &Path, data: &[u8]) -> std::io::Result<()> {
crate::vfs::write(path, data)
}
/// Atomically replace `path` with `data`.
#[cfg(not(target_arch = "wasm32"))]
pub fn write(path: &Path, data: &[u8]) -> std::io::Result<()> {
let tmp = temp_path(path);
let result = (|| {
@@ -38,25 +49,25 @@ pub fn write(path: &Path, data: &[u8]) -> std::io::Result<()> {
result
}
#[cfg(unix)]
#[cfg(all(unix, not(target_arch = "wasm32")))]
fn sync_dir(dir: &Path) {
if let Ok(d) = std::fs::File::open(dir) {
let _ = d.sync_all();
}
}
#[cfg(not(unix))]
#[cfg(all(not(unix), not(target_arch = "wasm32")))]
fn sync_dir(_dir: &Path) {}
/// Remove temp files left behind by a crash mid-write (best effort).
pub fn clean_stale_temps(dir: &Path) {
let Ok(rd) = std::fs::read_dir(dir) else {
let Ok(rd) = crate::vfs::read_dir(dir) else {
return;
};
for e in rd.flatten() {
let name = e.file_name().to_string_lossy().to_string();
if name.starts_with('.') && name.ends_with(".tmp") {
let _ = std::fs::remove_file(e.path());
let _ = crate::vfs::remove_file(e.path());
}
}
}
@@ -67,7 +78,7 @@ mod tests {
#[test]
fn replaces_file_and_leaves_no_temp() {
let dir = std::env::temp_dir().join(format!("paper-rs-atomic-{}", fastrand::u64(..)));
let dir = std::env::temp_dir().join(format!("papercraft-atomic-{}", fastrand::u64(..)));
std::fs::create_dir_all(&dir).unwrap();
let p = dir.join("a.json");
write(&p, b"one").unwrap();
+139
View File
@@ -0,0 +1,139 @@
//! Whole-library backups as a zip of the store folder, so a backup made in the browser (where
//! there is no folder to open) can be restored there or anywhere else. Restoring brings back
//! the projects only; the trash and the shape library are left as they are.
use super::zip::{self, Entry};
use crate::project::TRASH_DIR;
use std::collections::HashSet;
use std::path::{Component, Path, PathBuf};
fn collect(dir: &Path, prefix: &str, out: &mut Vec<Entry>) -> std::io::Result<()> {
let mut entries: Vec<_> = crate::vfs::read_dir(dir)?.collect::<Result<_, _>>()?;
entries.sort_by_key(|e| e.file_name());
for e in entries {
let name = format!("{prefix}{}", e.file_name().to_string_lossy());
if e.file_type()?.is_dir() {
collect(&e.path(), &format!("{name}/"), out)?;
} else {
out.push((name, crate::vfs::read(e.path())?));
}
}
Ok(())
}
/// A zip of the whole store folder: every project, the trash and the shape library.
pub fn pack(root: &Path) -> Result<Vec<u8>, String> {
let mut files = Vec::new();
collect(root, "", &mut files).map_err(|e| e.to_string())?;
if files.is_empty() {
return Err("there are no projects to back up".into());
}
Ok(zip::write(&files))
}
/// Whether `bytes` look like a zip (the import path then treats the file as a backup).
pub fn is_zip(bytes: &[u8]) -> bool {
bytes.starts_with(b"PK\x03\x04") || bytes.starts_with(b"PK\x05\x06")
}
/// A relative path made only of normal components, so an archive cannot write outside `root`.
fn safe_relative(name: &str) -> Option<PathBuf> {
let p = Path::new(name);
p.components()
.all(|c| matches!(c, Component::Normal(_)))
.then(|| p.to_path_buf())
}
/// Restore the projects in a backup next to the existing ones (never overwriting: a folder
/// that already exists gets a numbered sibling). Returns the folders created.
pub fn unpack(bytes: &[u8], root: &Path) -> Result<Vec<PathBuf>, String> {
let entries = zip::read(bytes)?;
let mut tops: Vec<String> = Vec::new();
for (name, _) in &entries {
let Some(rel) = safe_relative(name) else {
return Err(format!("unsafe path in the backup: {name}"));
};
let mut parts = rel.components();
let (Some(top), Some(_)) = (parts.next(), parts.next()) else {
continue; // loose file at the top level
};
let top = top.as_os_str().to_string_lossy().into_owned();
if top != TRASH_DIR && !tops.contains(&top) {
tops.push(top);
}
}
let mut taken: HashSet<String> = HashSet::new();
let mut created = Vec::new();
for top in tops {
let has_project = entries
.iter()
.any(|(n, _)| n == &format!("{top}/{}", crate::project::MANIFEST));
if !has_project {
continue;
}
let mut target = top.clone();
let mut i = 2;
while taken.contains(&target) || crate::vfs::exists(&root.join(&target)) {
target = format!("{top} {i}");
i += 1;
}
taken.insert(target.clone());
for (name, data) in &entries {
let Some(rest) = name.strip_prefix(&format!("{top}/")) else {
continue;
};
let path = root.join(&target).join(rest);
if let Some(dir) = path.parent() {
crate::vfs::create_dir_all(dir).map_err(|e| e.to_string())?;
}
crate::vfs::write(&path, data).map_err(|e| e.to_string())?;
}
created.push(root.join(target));
}
if created.is_empty() {
return Err("the zip holds no PaperCraft projects".into());
}
Ok(created)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::project::Store;
fn store() -> Store {
Store::open(
std::env::temp_dir().join(format!("papercraft-backup-{:016x}", fastrand::u64(..))),
)
.unwrap()
}
#[test]
fn backup_round_trips_into_another_store() {
let a = store();
a.create("One").unwrap();
a.create("Two").unwrap();
let zip = pack(&a.root).unwrap();
assert!(is_zip(&zip));
let b = store();
let made = unpack(&zip, &b.root).unwrap();
assert_eq!(made.len(), 2);
let mut names: Vec<_> = b.list().into_iter().map(|e| e.manifest.name).collect();
names.sort();
assert_eq!(names, ["One", "Two"]);
// Restoring again never overwrites.
unpack(&zip, &b.root).unwrap();
assert_eq!(b.list().len(), 4);
}
#[test]
fn refuses_path_traversal() {
let zip = zip::write(&[("../evil/project.json".into(), b"{}".to_vec())]);
assert!(unpack(&zip, &store().root).is_err());
}
#[test]
fn empty_store_has_nothing_to_back_up() {
assert!(pack(&store().root).is_err());
}
}
+3
View File
@@ -46,14 +46,17 @@ impl Provider for Memory {
}
/// The OS clipboard via arboard (None when no clipboard is available, e.g. headless).
#[cfg(not(target_arch = "wasm32"))]
pub struct System(arboard::Clipboard);
#[cfg(not(target_arch = "wasm32"))]
impl System {
pub fn new() -> Option<System> {
arboard::Clipboard::new().ok().map(System)
}
}
#[cfg(not(target_arch = "wasm32"))]
impl Provider for System {
fn read_text(&mut self) -> Option<String> {
self.0.get_text().ok()
+1 -1
View File
@@ -4,7 +4,7 @@
//! Field meanings, defaults and conversions follow Excalidraw's `restore.ts` and element
//! types (Copyright (c) 2020 Excalidraw, MIT; see THIRD_PARTY.md and
//! `analysis/EXCALIDRAW_NOTES.md` §1, §6, §8). Rotated shapes are baked into vector paths
//! (paper-rs has no rotation); groups become group layers; frames clip their children.
//! (papercraft has no rotation); groups become group layers; frames clip their children.
use crate::geom::{Rect, V2, v2};
use crate::history::{Recording, Tx};
+60 -1
View File
@@ -192,7 +192,7 @@ pub fn write(
i += 1;
}
used.push(name.clone());
std::fs::create_dir_all(out).map_err(|e| e.to_string())?;
crate::vfs::create_dir_all(out).map_err(|e| e.to_string())?;
out.join(format!("{name}.{}", spec.format.ext()))
};
match spec.format {
@@ -222,3 +222,62 @@ pub fn write(
}
Ok(written)
}
/// The PNG bytes of an image.
pub fn encode_png(img: &image::RgbaImage) -> Result<Vec<u8>, String> {
let mut buf = std::io::Cursor::new(Vec::new());
img.write_to(&mut buf, image::ImageFormat::Png)
.map_err(|e| e.to_string())?;
Ok(buf.into_inner())
}
/// [`write`] into memory for the browser, which downloads instead of writing paths. Returns
/// `(file name, bytes)` per output file; `base` names a single-file export. PNG goes through
/// the non-blocking GPU readback, so this is the only export entry point that works in wasm.
pub async fn render_files(
spec: &Spec,
items: &[Item<'_>],
base: &str,
gpu: &Headless,
) -> Result<Vec<(String, Vec<u8>)>, String> {
if items.is_empty() {
return Err("nothing to export".into());
}
let bg = (!spec.transparent).then_some(Color::WHITE);
let ext = spec.format.ext();
if spec.format == Format::Pdf {
let frames = spec.scope == Scope::Frames;
let pages: Vec<_> = items
.iter()
.flat_map(|it| crate::io::pdf::pages_for(it.doc, Some(&it.roots), frames, bg))
.collect();
if pages.is_empty() {
return Err("nothing to export".into());
}
return Ok(vec![(
format!("{}.{ext}", safe(base)),
crate::io::pdf::export(&pages),
)]);
}
let single = spec.single_file(items.len());
let mut used = std::collections::HashSet::new();
let mut files = Vec::new();
for it in items {
let name = if single {
format!("{}.{ext}", safe(base))
} else {
crate::io::zip::unique_name(&format!("{}.{ext}", safe(&it.name)), &mut used)
};
let bytes = match spec.format {
Format::Svg => crate::io::svg::export(it.doc, Some(&it.roots), bg)
.ok_or("nothing to export")?
.into_bytes(),
Format::Png => encode_png(
&crate::io::png::render_async(gpu, it.doc, Some(&it.roots), spec.scale, bg).await?,
)?,
Format::Pdf => unreachable!(),
};
files.push((name, bytes));
}
Ok(files)
}
+15 -9
View File
@@ -1,13 +1,16 @@
//! The `.paper-rs` document format: versioned, pretty-printed JSON.
use crate::geom::Camera;
use crate::model::{Document, Node, NodeId};
use crate::model::{Color, Document, Node, NodeId};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::Path;
pub const FORMAT: &str = "paper-rs";
pub const VERSION: u32 = 1;
pub const EXTENSION: &str = "paper-rs";
pub const EXTENSION: &str = "papercraft";
/// The extension before the rename; still opened.
pub const LEGACY_EXTENSION: &str = "paper-rs";
#[derive(Serialize, Deserialize)]
struct FileV1 {
@@ -19,6 +22,8 @@ struct FileV1 {
next_id: u64,
root: Vec<NodeId>,
nodes: Vec<Node>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
variables: BTreeMap<String, Color>,
}
#[derive(Debug)]
@@ -60,6 +65,7 @@ pub fn to_string(doc: &Document, camera: Option<&Camera>) -> String {
next_id: doc.next_id,
root: doc.root.clone(),
nodes,
variables: doc.variables.clone(),
};
serde_json::to_string_pretty(&f).expect("document serializes")
}
@@ -78,13 +84,13 @@ pub fn from_str(s: &str) -> Result<(Document, Option<Camera>), FileError> {
/// Write atomically (temp file + rename) so a crash never leaves a truncated document.
pub fn save(path: &Path, doc: &Document, camera: Option<&Camera>) -> Result<(), FileError> {
let tmp = path.with_extension("paper-rs.tmp");
std::fs::write(&tmp, to_string(doc, camera)).map_err(FileError::Io)?;
std::fs::rename(&tmp, path).map_err(FileError::Io)
let tmp = path.with_extension("papercraft.tmp");
crate::vfs::write(&tmp, to_string(doc, camera)).map_err(FileError::Io)?;
crate::vfs::rename(&tmp, path).map_err(FileError::Io)
}
pub fn load(path: &Path) -> Result<(Document, Option<Camera>), FileError> {
let s = std::fs::read_to_string(path).map_err(FileError::Io)?;
let s = crate::vfs::read_to_string(path).map_err(FileError::Io)?;
from_str(&s)
}
@@ -129,14 +135,14 @@ mod tests {
#[test]
fn save_and_load_file() {
let dir = std::env::temp_dir().join(format!("paper-rs-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let dir = std::env::temp_dir().join(format!("papercraft-test-{}", std::process::id()));
crate::vfs::create_dir_all(&dir).unwrap();
let path = dir.join("doc.paper-rs");
let ed = demo::showcase();
save(&path, &ed.doc, None).unwrap();
let (doc, cam) = load(&path).unwrap();
assert_eq!(doc, ed.doc);
assert!(cam.is_none());
std::fs::remove_dir_all(dir).ok();
crate::vfs::remove_dir_all(dir).ok();
}
}
+120 -4
View File
@@ -1,5 +1,7 @@
//! Importing files into projects.
use crate::library::LibItem;
use crate::model::Node;
use crate::project::{PageData, Project, format};
use std::path::Path;
@@ -9,22 +11,136 @@ pub fn import_as_pages(path: &Path) -> Result<(String, Vec<PageData>), String> {
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "Imported".into());
if path.is_dir() {
if crate::vfs::is_dir(path) {
let p = Project::open(path).map_err(|e| e.to_string())?;
let pages = p.load_pages().map_err(|e| e.to_string())?;
return Ok((p.manifest.name, pages));
}
let text = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
let page = format::page_from_json(&text).map_err(|e| e.to_string())?;
let text = crate::vfs::read_to_string(path).map_err(|e| e.to_string())?;
page_from_text(&stem, &text)
}
fn page_from_text(stem: &str, text: &str) -> Result<(String, Vec<PageData>), String> {
let page = format::page_from_json(text).map_err(|e| e.to_string())?;
let name = if page.name.is_empty() || page.name == "Untitled" {
stem
stem.to_string()
} else {
page.name.clone()
};
Ok((name, vec![page]))
}
/// What a dropped or picked file turned out to be.
#[derive(Debug)]
pub enum Imported {
/// A PaperCraft document: pages for a new project, with a suggested name.
Pages(String, Vec<PageData>),
/// Loose layers from an SVG (`group` names the group they stay together in) or an
/// Excalidraw scene.
Scene {
nodes: Vec<Node>,
group: Option<String>,
},
/// Encoded raster image bytes, to be registered as an asset.
Image(Vec<u8>),
/// Shapes for the library (`.excalidrawlib`).
Library(Vec<LibItem>),
/// A project backup zip (see [`crate::io::backup`]); the caller unpacks it.
Backup,
}
/// Lowercase extension of a file name, without the dot.
pub fn extension(name: &str) -> String {
Path::new(name)
.extension()
.map(|e| e.to_string_lossy().to_lowercase())
.unwrap_or_default()
}
/// Classify and parse a file given only its name and bytes (the browser has no paths).
pub fn import_bytes(name: &str, bytes: &[u8]) -> Result<Imported, String> {
let stem = Path::new(name)
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "Imported".into());
let ext = extension(name);
if ext == "zip" || super::backup::is_zip(bytes) {
return Ok(Imported::Backup);
}
let text = || std::str::from_utf8(bytes).map_err(|_| format!("{name} is not a text file"));
match ext.as_str() {
"png" | "jpg" | "jpeg" | "gif" | "webp" => Ok(Imported::Image(bytes.to_vec())),
"excalidrawlib" => crate::io::excalidraw::import_library(text()?).map(Imported::Library),
"svg" => Ok(Imported::Scene {
nodes: crate::io::svg_import::parse(text()?)?,
group: Some(stem),
}),
"excalidraw" | "json" => {
let doc = crate::io::excalidraw::import_scene(text()?)?;
let nodes = doc
.paint_order()
.into_iter()
.filter_map(|id| doc.get(id).cloned())
.collect();
Ok(Imported::Scene { nodes, group: None })
}
_ => page_from_text(&stem, text()?).map(|(n, p)| Imported::Pages(n, p)),
}
}
/// Load the pages of a project folder or of a single document file (CLI export).
pub fn load_any(path: &Path) -> Result<Vec<PageData>, String> {
import_as_pages(path).map(|(_, pages)| pages)
}
#[cfg(test)]
mod tests {
use super::*;
const SVG: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10"/></svg>"#;
#[test]
fn svg_becomes_a_named_scene() {
let Ok(Imported::Scene { nodes, group }) = import_bytes("Logo.SVG", SVG.as_bytes()) else {
panic!("svg should import as a scene");
};
assert!(!nodes.is_empty());
assert_eq!(group.as_deref(), Some("Logo"));
}
#[test]
fn images_pass_through_untouched() {
let Ok(Imported::Image(b)) = import_bytes("a.png", b"bytes") else {
panic!("png should import as an image");
};
assert_eq!(b, b"bytes");
}
#[test]
fn zips_are_backups_whatever_their_name() {
let zip = crate::io::zip::write(&[("a/project.json".into(), vec![])]);
assert!(matches!(import_bytes("x.bin", &zip), Ok(Imported::Backup)));
}
#[test]
fn papercraft_document_round_trips() {
let page = PageData {
id: "p".into(),
name: "Untitled".into(),
doc: Default::default(),
camera: None,
};
let json = format::page_to_json(&page);
let Ok(Imported::Pages(name, pages)) = import_bytes("My doc.papercraft", json.as_bytes())
else {
panic!("document should import as pages");
};
assert_eq!((name.as_str(), pages.len()), ("My doc", 1));
}
#[test]
fn garbage_is_an_error_not_a_panic() {
assert!(import_bytes("x.papercraft", b"\xff\xfe").is_err());
assert!(import_bytes("x.svg", b"not svg").is_err());
}
}
+2
View File
@@ -1,4 +1,5 @@
pub mod atomic;
pub mod backup;
pub mod clipboard;
pub mod excalidraw;
pub mod export;
@@ -8,3 +9,4 @@ pub mod pdf;
pub mod png;
pub mod svg;
pub mod svg_import;
pub mod zip;
+1 -1
View File
@@ -531,7 +531,7 @@ pub fn export(pages: &[PdfPage]) -> Vec<u8> {
}
let _ = write!(
x,
"trailer\n<< /Size {} /Root {catalog} 0 R /Info << /Producer (paper-rs) >> >>\nstartxref\n{xref}\n%%EOF\n",
"trailer\n<< /Size {} /Root {catalog} 0 R /Info << /Producer (papercraft) >> >>\nstartxref\n{xref}\n%%EOF\n",
objs.objs.len() + 1
);
out.extend_from_slice(x.as_bytes());
+47 -6
View File
@@ -9,14 +9,20 @@ use crate::render::scene::{self, View};
/// Largest exported edge, in pixels.
pub const MAX_EDGE: f64 = 16384.0;
/// Render `roots` (or all top-level nodes) at `scale` px per unit.
pub fn render(
gpu: &Headless,
/// What the GPU has to draw for a PNG: the frame, its pixel size and the clear colour.
struct Plan {
frame: scene::CanvasFrame,
size: [u32; 2],
clear: [f64; 4],
transparent: bool,
}
fn plan(
doc: &Document,
roots: Option<&[NodeId]>,
scale: f64,
background: Option<Color>,
) -> Result<image::RgbaImage, String> {
) -> Result<Plan, String> {
let roots: Vec<NodeId> = roots
.map(|r| r.to_vec())
.unwrap_or_else(|| doc.root.clone());
@@ -44,8 +50,43 @@ pub fn render(
}
None => [0.0; 4],
};
let mut img = gpu.render_frame(&frame, size, 1.0, clear);
if background.is_none() {
Ok(Plan {
frame,
size,
clear,
transparent: background.is_none(),
})
}
/// Render `roots` (or all top-level nodes) at `scale` px per unit.
pub fn render(
gpu: &Headless,
doc: &Document,
roots: Option<&[NodeId]>,
scale: f64,
background: Option<Color>,
) -> Result<image::RgbaImage, String> {
let p = plan(doc, roots, scale, background)?;
let mut img = gpu.render_frame(&p.frame, p.size, 1.0, p.clear);
if p.transparent {
unpremultiply_edges(&mut img);
}
Ok(img)
}
/// [`render`] for the browser, where the GPU readback cannot block.
pub async fn render_async(
gpu: &Headless,
doc: &Document,
roots: Option<&[NodeId]>,
scale: f64,
background: Option<Color>,
) -> Result<image::RgbaImage, String> {
let p = plan(doc, roots, scale, background)?;
let mut img = gpu
.render_frame_async(&p.frame, p.size, 1.0, p.clear)
.await?;
if p.transparent {
unpremultiply_edges(&mut img);
}
Ok(img)
+232
View File
@@ -0,0 +1,232 @@
//! Minimal zip files: a store-only writer (project backups, multi-file exports in the browser)
//! and a reader that understands what the writer and common tools produce (stored or deflated
//! entries, no encryption, no zip64).
use std::collections::HashSet;
const LOCAL_SIG: u32 = 0x0403_4b50;
const CENTRAL_SIG: u32 = 0x0201_4b50;
const END_SIG: u32 = 0x0605_4b50;
const LOCAL_LEN: usize = 30;
const CENTRAL_LEN: usize = 46;
const END_LEN: usize = 22;
/// Zip version needed to extract (2.0: deflate, folders).
const VERSION: u16 = 20;
/// General purpose flag bit 11: names are UTF-8.
const UTF8_FLAG: u16 = 1 << 11;
const METHOD_STORED: u16 = 0;
const METHOD_DEFLATE: u16 = 8;
/// 1980-01-01, the earliest DOS date; entries carry no real timestamp.
const DOS_DATE: u16 = 0x0021;
/// Largest entry the reader inflates, against zip bombs.
const MAX_ENTRY: usize = 512 << 20;
pub type Entry = (String, Vec<u8>);
pub fn crc32(data: &[u8]) -> u32 {
let mut crc = !0u32;
for &b in data {
crc ^= b as u32;
for _ in 0..8 {
crc = (crc >> 1) ^ (0xEDB8_8320 & (crc & 1).wrapping_neg());
}
}
!crc
}
/// A store-only zip of `entries` (`name` uses `/` separators).
pub fn write(entries: &[Entry]) -> Vec<u8> {
let mut out = Vec::new();
let mut central = Vec::new();
for (name, data) in entries {
let offset = out.len() as u32;
let crc = crc32(data);
let size = data.len() as u32;
let name = name.as_bytes();
out.extend(LOCAL_SIG.to_le_bytes());
out.extend(VERSION.to_le_bytes());
out.extend(UTF8_FLAG.to_le_bytes());
out.extend(METHOD_STORED.to_le_bytes());
out.extend(0u16.to_le_bytes()); // time
out.extend(DOS_DATE.to_le_bytes());
out.extend(crc.to_le_bytes());
out.extend(size.to_le_bytes()); // compressed
out.extend(size.to_le_bytes()); // uncompressed
out.extend((name.len() as u16).to_le_bytes());
out.extend(0u16.to_le_bytes()); // extra
out.extend(name);
out.extend(data);
central.extend(CENTRAL_SIG.to_le_bytes());
central.extend(VERSION.to_le_bytes()); // made by
central.extend(VERSION.to_le_bytes()); // needed
central.extend(UTF8_FLAG.to_le_bytes());
central.extend(METHOD_STORED.to_le_bytes());
central.extend(0u16.to_le_bytes());
central.extend(DOS_DATE.to_le_bytes());
central.extend(crc.to_le_bytes());
central.extend(size.to_le_bytes());
central.extend(size.to_le_bytes());
central.extend((name.len() as u16).to_le_bytes());
central.extend([0u8; 12]); // extra len, comment len, disk, internal attrs, external attrs
central.extend(offset.to_le_bytes());
central.extend(name);
}
let central_offset = out.len() as u32;
let central_len = central.len() as u32;
out.extend(central);
out.extend(END_SIG.to_le_bytes());
out.extend([0u8; 4]); // disk numbers
out.extend((entries.len() as u16).to_le_bytes());
out.extend((entries.len() as u16).to_le_bytes());
out.extend(central_len.to_le_bytes());
out.extend(central_offset.to_le_bytes());
out.extend(0u16.to_le_bytes()); // comment
out
}
fn u16_at(b: &[u8], i: usize) -> Result<u16, String> {
b.get(i..i + 2)
.map(|s| u16::from_le_bytes([s[0], s[1]]))
.ok_or_else(|| "truncated zip".to_string())
}
fn u32_at(b: &[u8], i: usize) -> Result<u32, String> {
b.get(i..i + 4)
.map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]]))
.ok_or_else(|| "truncated zip".to_string())
}
/// Every file in the archive (folders are skipped), verifying CRCs.
pub fn read(bytes: &[u8]) -> Result<Vec<Entry>, String> {
let end = (0..=bytes.len().saturating_sub(END_LEN))
.rev()
.find(|&i| u32_at(bytes, i).is_ok_and(|s| s == END_SIG))
.ok_or("not a zip file")?;
let count = u16_at(bytes, end + 10)? as usize;
let mut at = u32_at(bytes, end + 16)? as usize;
let mut out = Vec::with_capacity(count);
for _ in 0..count {
if u32_at(bytes, at)? != CENTRAL_SIG {
return Err("corrupt zip directory".into());
}
let method = u16_at(bytes, at + 10)?;
let crc = u32_at(bytes, at + 16)?;
let csize = u32_at(bytes, at + 20)? as usize;
let usize_ = u32_at(bytes, at + 24)? as usize;
let name_len = u16_at(bytes, at + 28)? as usize;
let extra_len = u16_at(bytes, at + 30)? as usize;
let comment_len = u16_at(bytes, at + 32)? as usize;
let local = u32_at(bytes, at + 42)? as usize;
let name = bytes
.get(at + CENTRAL_LEN..at + CENTRAL_LEN + name_len)
.ok_or("truncated zip")?;
let name = String::from_utf8_lossy(name).into_owned();
at += CENTRAL_LEN + name_len + extra_len + comment_len;
if name.ends_with('/') {
continue;
}
if u32_at(bytes, local)? != LOCAL_SIG {
return Err(format!("corrupt zip entry {name}"));
}
let start = local
+ LOCAL_LEN
+ u16_at(bytes, local + 26)? as usize
+ u16_at(bytes, local + 28)? as usize;
let raw = bytes.get(start..start + csize).ok_or("truncated zip")?;
let data = match method {
METHOD_STORED => raw.to_vec(),
METHOD_DEFLATE => miniz_oxide::inflate::decompress_to_vec_with_limit(raw, MAX_ENTRY)
.map_err(|e| format!("cannot inflate {name}: {e:?}"))?,
m => return Err(format!("{name}: unsupported zip method {m}")),
};
if data.len() != usize_ || crc32(&data) != crc {
return Err(format!("{name}: checksum mismatch"));
}
out.push((name, data));
}
Ok(out)
}
/// `name` made unique among `used` by appending " 2", " 3"… before the extension.
pub fn unique_name(name: &str, used: &mut HashSet<String>) -> String {
let (stem, ext) = name.rsplit_once('.').unwrap_or((name, ""));
let dot = if ext.is_empty() { "" } else { "." };
let mut candidate = name.to_string();
let mut i = 2;
while !used.insert(candidate.clone()) {
candidate = format!("{stem} {i}{dot}{ext}");
i += 1;
}
candidate
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn crc_matches_the_standard_check_value() {
assert_eq!(crc32(b"123456789"), 0xCBF4_3926);
assert_eq!(crc32(b""), 0);
}
#[test]
fn round_trip() {
let entries = vec![
("a/manifest.json".to_string(), b"{\"x\":1}".to_vec()),
("a/pages/p1.json".to_string(), vec![0u8; 1000]),
("empty".to_string(), vec![]),
("ünï.txt".to_string(), vec![1, 2, 3]),
];
assert_eq!(read(&write(&entries)).unwrap(), entries);
}
#[test]
fn empty_archive() {
assert!(read(&write(&[])).unwrap().is_empty());
}
#[test]
fn reads_deflated_entries() {
let data = b"hello hello hello hello".to_vec();
let packed = miniz_oxide::deflate::compress_to_vec(&data, 6);
let mut zip = write(&[("x".into(), data.clone())]);
// Rewrite the single stored entry as deflated by patching both headers.
let name_len = 1;
let local_data = LOCAL_LEN + name_len;
let mut patched = zip[..local_data].to_vec();
patched[8..10].copy_from_slice(&METHOD_DEFLATE.to_le_bytes());
patched[18..22].copy_from_slice(&(packed.len() as u32).to_le_bytes());
patched.extend(&packed);
let central_at = local_data + data.len();
let mut central = zip.split_off(central_at);
central[10..12].copy_from_slice(&METHOD_DEFLATE.to_le_bytes());
central[20..24].copy_from_slice(&(packed.len() as u32).to_le_bytes());
let end = central.len() - END_LEN;
let dir_offset = patched.len() as u32;
let dir_len = (end) as u32;
patched.extend(&central);
let n = patched.len();
patched[n - 6..n - 2].copy_from_slice(&dir_offset.to_le_bytes());
patched[n - 10..n - 6].copy_from_slice(&dir_len.to_le_bytes());
assert_eq!(read(&patched).unwrap(), vec![("x".to_string(), data)]);
}
#[test]
fn rejects_garbage_and_corruption() {
assert!(read(b"nope").is_err());
let mut z = write(&[("f".into(), b"abc".to_vec())]);
z[LOCAL_LEN + 1] ^= 0xff; // flip a data byte
assert!(read(&z).is_err());
}
#[test]
fn unique_names() {
let mut used = HashSet::new();
assert_eq!(unique_name("a.png", &mut used), "a.png");
assert_eq!(unique_name("a.png", &mut used), "a 2.png");
assert_eq!(unique_name("b", &mut used), "b");
assert_eq!(unique_name("b", &mut used), "b 2");
}
}
+6 -1
View File
@@ -1,5 +1,6 @@
//! paper-rs: a native infinite canvas for planning, working and sketching.
//! papercraft: a native infinite canvas for planning, working and sketching.
#[cfg(not(target_arch = "wasm32"))]
pub mod app;
pub mod assets;
pub mod connector;
@@ -7,6 +8,7 @@ pub mod demo;
pub mod editor;
pub mod geom;
pub mod history;
pub mod icons;
pub mod ink;
pub mod io;
pub mod layout;
@@ -22,3 +24,6 @@ pub mod text;
pub mod theme;
pub mod ui;
pub mod vector;
pub mod vfs;
#[cfg(target_arch = "wasm32")]
pub mod web;
+4 -4
View File
@@ -92,7 +92,7 @@ impl Library {
/// Load the library, seeding it with starter shapes on first use.
pub fn load(path: &Path) -> Library {
let mut lib = std::fs::read_to_string(path)
let mut lib = crate::vfs::read_to_string(path)
.ok()
.and_then(|s| serde_json::from_str::<Library>(&s).ok())
.filter(|l| l.format == FORMAT && l.version <= VERSION)
@@ -104,7 +104,7 @@ impl Library {
pub fn save(&self) -> std::io::Result<()> {
let Some(p) = &self.path else { return Ok(()) };
if let Some(dir) = p.parent() {
std::fs::create_dir_all(dir)?;
crate::vfs::create_dir_all(dir)?;
}
crate::io::atomic::write(
p,
@@ -258,14 +258,14 @@ mod tests {
#[test]
fn save_load_roundtrip_and_starter() {
let dir = std::env::temp_dir().join(format!("paper-rs-lib-{:016x}", fastrand::u64(..)));
let dir = std::env::temp_dir().join(format!("papercraft-lib-{:016x}", fastrand::u64(..)));
let path = dir.join(FILE);
let lib = Library::load(&path);
assert!(lib.items.len() >= 5, "starter shapes on first use");
lib.save().unwrap();
let back = Library::load(&path);
assert_eq!(back.items, lib.items);
std::fs::remove_dir_all(dir).ok();
crate::vfs::remove_dir_all(dir).ok();
}
#[test]
+3 -3
View File
@@ -242,7 +242,7 @@ pub fn install_menu() -> NativeMenu {
idx
}
let quit = MenuItem::new("Quit paper-rs", true, acc(cmd, Code::KeyQ));
let quit = MenuItem::new("Quit PaperCraft", true, acc(cmd, Code::KeyQ));
cmds.insert(quit.id().clone(), MenuCmd::Quit);
let _ = (A::Undo, Run(A::Undo));
let snapping = CheckMenuItem::new("Snapping", true, true, acc(cmd, Code::Quote));
@@ -268,10 +268,10 @@ pub fn install_menu() -> NativeMenu {
let sep = PredefinedMenuItem::separator;
let app_menu = Submenu::with_items(
"paper-rs",
"PaperCraft",
true,
&[
&PredefinedMenuItem::about(Some("About paper-rs"), None),
&PredefinedMenuItem::about(Some("About PaperCraft"), None),
&sep(),
&PredefinedMenuItem::services(None),
&sep(),
+41 -41
View File
@@ -1,36 +1,36 @@
use paper_rs::app::Start;
use paper_rs::editor::Editor;
use paper_rs::io::{export, file, import, png, svg};
use paper_rs::model::Color;
use paper_rs::project::{MANIFEST, Store};
use paper_rs::render::{frame, gpu::Headless};
use paper_rs::session::Session;
use paper_rs::ui::UiState;
use paper_rs::ui::home::HomeView;
use papercraft::app::Start;
use papercraft::editor::Editor;
use papercraft::io::{export, file, import, png, svg};
use papercraft::model::Color;
use papercraft::project::{MANIFEST, Store};
use papercraft::render::{frame, gpu::Headless};
use papercraft::session::Session;
use papercraft::ui::UiState;
use papercraft::ui::home::HomeView;
use std::path::PathBuf;
use std::process::ExitCode;
const USAGE: &str = "\
paper-rs — infinite canvas for planning, working and sketching
papercraft — infinite canvas for planning, working and sketching
USAGE:
paper-rs open the projects home screen
paper-rs PROJECT_DIR open a project folder
paper-rs FILE.paper-rs open a paper-rs 0.1 document (not autosaved)
paper-rs --demo open the app with a sample board
paper-rs --screenshot OUT.png [--home | PROJECT|FILE|--demo] [--size WxH] [--scale S]
papercraft open the projects home screen
papercraft PROJECT_DIR open a project folder
papercraft FILE.papercraft open a PaperCraft document (not autosaved)
papercraft --demo open the app with a sample board
papercraft --screenshot OUT.png [--home | PROJECT|FILE|--demo] [--size WxH] [--scale S]
render the full UI headlessly to a PNG
paper-rs --export-png OUT.png PROJECT|FILE [--page N] [--scale S] [--transparent]
paper-rs --export-svg OUT.svg PROJECT|FILE [--page N]
paper-rs --export-pdf OUT.pdf PROJECT|FILE [--page N] [--frames | --all-pages]
paper-rs --export OUT PROJECT|FILE --format png|svg|pdf [--scope page|frames|all]
papercraft --export-png OUT.png PROJECT|FILE [--page N] [--scale S] [--transparent]
papercraft --export-svg OUT.svg PROJECT|FILE [--page N]
papercraft --export-pdf OUT.pdf PROJECT|FILE [--page N] [--frames | --all-pages]
papercraft --export OUT PROJECT|FILE --format png|svg|pdf [--scope page|frames|all]
[--page N] [--scale S] [--transparent]
(frames/all with PNG/SVG write a folder)
paper-rs --import FILE create a project from .excalidraw, .svg or
.paper-rs and print its folder
paper-rs --help
papercraft --import FILE create a project from .excalidraw, .svg or
.papercraft and print its folder
papercraft --help
Projects live in ~/Documents/paper-rs (override with PAPER_RS_HOME).
Projects live in ~/Documents/papercraft (override with PAPERCRAFT_HOME).
";
fn is_project(p: &std::path::Path) -> bool {
@@ -39,7 +39,7 @@ fn is_project(p: &std::path::Path) -> bool {
fn load_editor(path: Option<&str>, demo: bool) -> Result<Editor, String> {
if demo {
return Ok(paper_rs::demo::showcase());
return Ok(papercraft::demo::showcase());
}
let Some(p) = path else {
return Ok(Editor::default());
@@ -128,7 +128,7 @@ fn run(args: Vec<String>) -> Result<(), String> {
let mut st = UiState::default();
if flag("--home") {
let store = Store::open(Store::default_root()).map_err(|e| e.to_string())?;
st.home = Some(HomeView::load(&store, paper_rs::project::now_ms()));
st.home = Some(HomeView::load(&store, papercraft::project::now_ms()));
}
let (w, h) = match value("--size") {
Some(s) => {
@@ -153,9 +153,9 @@ fn run(args: Vec<String>) -> Result<(), String> {
);
return Ok(());
}
let page_doc = |input: Option<&str>| -> Result<paper_rs::model::Document, String> {
let page_doc = |input: Option<&str>| -> Result<papercraft::model::Document, String> {
if flag("--demo") {
return Ok(paper_rs::demo::showcase().doc);
return Ok(papercraft::demo::showcase().doc);
}
let path = input.ok_or("missing input file")?;
let pages = import::load_any(path.as_ref())?;
@@ -181,11 +181,11 @@ fn run(args: Vec<String>) -> Result<(), String> {
}
let export_with =
|out: &str, format: export::Format, scope: export::Scope| -> Result<(), String> {
let pages: Vec<paper_rs::project::PageData> = if flag("--demo") {
vec![paper_rs::project::PageData {
let pages: Vec<papercraft::project::PageData> = if flag("--demo") {
vec![papercraft::project::PageData {
id: "demo".into(),
name: "Launch plan".into(),
doc: paper_rs::demo::showcase().doc,
doc: papercraft::demo::showcase().doc,
camera: None,
}]
} else {
@@ -194,7 +194,7 @@ fn run(args: Vec<String>) -> Result<(), String> {
if page >= pages.len() {
return Err(format!("no page {page}"));
}
let refs: Vec<(&str, &paper_rs::model::Document)> =
let refs: Vec<(&str, &papercraft::model::Document)> =
pages.iter().map(|p| (p.name.as_str(), &p.doc)).collect();
let spec = export::Spec {
format,
@@ -248,19 +248,19 @@ fn run(args: Vec<String>) -> Result<(), String> {
let pages = match ext.as_str() {
"excalidraw" | "json" => {
let t = std::fs::read_to_string(&p).map_err(|e| e.to_string())?;
vec![paper_rs::project::PageData {
id: paper_rs::project::new_id(),
vec![papercraft::project::PageData {
id: papercraft::project::new_id(),
name: "Page 1".into(),
doc: paper_rs::io::excalidraw::import_scene(&t)?,
doc: papercraft::io::excalidraw::import_scene(&t)?,
camera: None,
}]
}
"svg" => {
let t = std::fs::read_to_string(&p).map_err(|e| e.to_string())?;
vec![paper_rs::project::PageData {
id: paper_rs::project::new_id(),
vec![papercraft::project::PageData {
id: papercraft::project::new_id(),
name: "Page 1".into(),
doc: paper_rs::io::svg_import::import_page(&t, &name)?,
doc: papercraft::io::svg_import::import_page(&t, &name)?,
camera: None,
}]
}
@@ -270,9 +270,9 @@ fn run(args: Vec<String>) -> Result<(), String> {
.create_with_pages(&name, pages)
.map_err(|e| e.to_string())?;
for pg in proj.load_pages().map_err(|e| e.to_string())? {
paper_rs::assets::save_to_dir(
&proj.dir.join(paper_rs::project::ASSETS_DIR),
paper_rs::assets::used_by(&pg.doc),
papercraft::assets::save_to_dir(
&proj.dir.join(papercraft::project::ASSETS_DIR),
papercraft::assets::used_by(&pg.doc),
)
.map_err(|e| e.to_string())?;
}
@@ -285,5 +285,5 @@ fn run(args: Vec<String>) -> Result<(), String> {
Some(p) if is_project(p.as_ref()) => Start::Project(PathBuf::from(p)),
Some(p) => Start::Scratch(Box::new(load_editor(Some(p), false)?)),
};
paper_rs::app::run(start)
papercraft::app::run(start)
}
+304 -5
View File
@@ -2,7 +2,7 @@
//!
//! Agents describe styles the way Paper's MCP server expects them: React-style camelCase CSS
//! property maps (`{"backgroundColor": "#fff", "padding": "20px"}`) or inline `style="..."`
//! attributes. This module maps the subset that has a paper-rs equivalent onto `Style`,
//! attributes. This module maps the subset that has a papercraft equivalent onto `Style`,
//! `TextStyle`, `AutoLayout` and `LayoutChild`, reports the rest as ignored, and turns nodes
//! back into CSS for `get_computed_styles` / `get_jsx`.
@@ -501,7 +501,7 @@ pub struct Context {
/// What `apply` did besides changing the node.
#[derive(Debug, Default)]
pub struct Applied {
/// Properties with no effect in paper-rs (reported back to the agent).
/// Properties with no effect in papercraft (reported back to the agent).
pub ignored: Vec<String>,
/// Image URL to resolve and use as the top fill (resolved by the caller).
pub image: Option<(String, ImageMode)>,
@@ -611,6 +611,260 @@ fn rough_mut(n: &mut Node) -> &mut Rough {
/// Apply styles to a node. Layout-dependent properties use `ctx`; text properties apply to
/// text and sticky nodes; box properties to everything else.
pub fn apply(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> Applied {
// `var(--name)` color values resolve against the document's variables (set per tool call by
// `set_variables_in_scope`); the fill they produce keeps the link so later changes follow.
let mut linked: Vec<&str> = Vec::new();
let mut unknown: Vec<String> = Vec::new();
let resolved: Vec<(String, String)> = styles
.iter()
.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()) {
linked.push(name);
}
(k.clone(), color_css(c))
}
None if TOKENS.with(|m| m.borrow().contains_key(name)) => (k.clone(), v.clone()),
None => match fallback {
Some(f) => (k.clone(), f.to_string()),
None => {
unknown.push(format!("{k}: {v} (no variable --{name})"));
(k.clone(), String::new())
}
},
},
None => (k.clone(), v.clone()),
})
.filter(|(_, v)| !v.is_empty())
.collect();
// Whatever var(--token) is left inside a value (spacing, radii, shadows) is replaced by text.
let resolved: Vec<(String, String)> = resolved
.into_iter()
.filter_map(|(k, v)| {
if !v.contains("var(--") {
return Some((k, v));
}
let (text, missing) = substitute_vars(&v);
unknown.extend(
missing
.iter()
.map(|m| format!("{k}: {v} (no variable or token --{m})")),
);
missing.is_empty().then_some((k, text))
})
.collect();
let mut a = apply_resolved(n, &resolved, ctx);
a.ignored.extend(unknown);
for name in linked {
if let Some(Paint::Solid { var, .. }) = n.style.fills.last_mut() {
*var = Some(name.to_string());
}
}
a
}
const COLOR_PROPS: [&str; 4] = ["backgroundColor", "background", "color", "fill"];
thread_local! {
static VARS: std::cell::RefCell<std::collections::BTreeMap<String, Color>> = const { std::cell::RefCell::new(std::collections::BTreeMap::new()) };
static TOKENS: std::cell::RefCell<std::collections::BTreeMap<String, String>> = const { std::cell::RefCell::new(std::collections::BTreeMap::new()) };
}
/// Make `vars` and `tokens` what `var(--name)` resolves against on this thread.
pub fn set_variables_in_scope(
vars: &std::collections::BTreeMap<String, Color>,
tokens: &std::collections::BTreeMap<String, String>,
) {
VARS.with(|m| *m.borrow_mut() = vars.clone());
TOKENS.with(|m| *m.borrow_mut() = tokens.clone());
}
/// Replace every `var(--name)` inside `v` with its token (or color) value, or its fallback.
/// Returns the text and the names that could not be resolved.
pub fn substitute_vars(v: &str) -> (String, Vec<String>) {
let mut out = String::new();
let mut missing = Vec::new();
let mut rest = v;
while let Some(i) = rest.find("var(--") {
out.push_str(&rest[..i]);
let tail = &rest[i..];
// The matching close paren (fallbacks may contain nested parens).
let mut depth = 0;
let mut end = None;
for (j, ch) in tail.char_indices() {
match ch {
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
end = Some(j);
break;
}
}
_ => {}
}
}
let Some(end) = end else {
out.push_str(tail);
rest = "";
break;
};
let whole = &tail[..=end];
match var_name(whole) {
Some((name, fallback)) => {
let value = TOKENS
.with(|m| m.borrow().get(name).cloned())
.or_else(|| VARS.with(|m| m.borrow().get(name).map(|c| color_css(*c))))
.or_else(|| fallback.map(String::from));
match value {
Some(t) => out.push_str(&t),
None => {
missing.push(name.to_string());
out.push_str(whole);
}
}
}
None => out.push_str(whole),
}
rest = &tail[end + 1..];
}
out.push_str(rest);
(out, missing)
}
/// `box-shadow`: `none`, or `x y [blur [spread]] color` (first layer; `inset` is not supported).
/// Outer None means unsupported.
fn parse_shadow(v: &str) -> Option<Option<crate::model::Shadow>> {
let v = v.trim();
if v.eq_ignore_ascii_case("none") {
return Some(None);
}
if v.contains("inset") {
return None;
}
// First layer: split at the first comma outside parentheses.
let mut depth = 0;
let mut end = v.len();
for (i, ch) in v.char_indices() {
match ch {
'(' => depth += 1,
')' => depth -= 1,
',' if depth == 0 => {
end = i;
break;
}
_ => {}
}
}
let first = &v[..end];
// Split on whitespace outside parentheses.
let mut parts: Vec<&str> = Vec::new();
let (mut depth, mut start) = (0, None);
for (i, ch) in first.char_indices() {
match ch {
'(' => depth += 1,
')' => depth -= 1,
_ => {}
}
match (ch.is_whitespace() && depth == 0, start) {
(false, None) => start = Some(i),
(true, Some(s)) => {
parts.push(&first[s..i]);
start = None;
}
_ => {}
}
}
if let Some(s) = start {
parts.push(&first[s..]);
}
let mut nums: Vec<f64> = Vec::new();
let mut col = None;
for p in parts {
match length(p, 16.0) {
Some(n)
if p.chars()
.next()
.is_some_and(|c| c.is_ascii_digit() || c == '-' || c == '.') =>
{
nums.push(n)
}
_ => col = color(p).or(col),
}
}
if nums.len() < 2 {
return None;
}
Some(Some(crate::model::Shadow {
x: nums[0],
y: nums[1],
blur: nums.get(2).copied().unwrap_or(0.0).max(0.0),
spread: nums.get(3).copied().unwrap_or(0.0),
color: col.unwrap_or(Color::rgba(0, 0, 0, 64)),
}))
}
/// A color value that may be `var(--name)`: the color and the variable it came from.
pub fn resolve_color_value(v: &str) -> Option<(Color, Option<String>)> {
match var_name(v) {
Some((name, fallback)) => match VARS.with(|m| m.borrow().get(name).copied()) {
Some(c) => Some((c, Some(name.to_string()))),
None => fallback.and_then(color).map(|c| (c, None)),
},
None => color(v).map(|c| (c, None)),
}
}
/// Style properties' override groups, for edits made to a part of an instance.
pub fn override_keys(styles: &[(String, String)]) -> Vec<&'static str> {
let mut keys: Vec<&'static str> = Vec::new();
for (k, _) in styles {
let key = match k.as_str() {
"backgroundColor" | "background" | "backgroundImage" | "color" | "fill" => "fills",
"width" | "height" | "minWidth" | "maxWidth" | "minHeight" | "maxHeight" => "size",
"left" | "top" | "right" | "bottom" | "x" | "y" => "position",
"borderRadius" => "radius",
"opacity" | "mixBlendMode" => "opacity",
"fontSize" | "fontWeight" | "fontStyle" | "lineHeight" | "letterSpacing"
| "textAlign" => "font",
"visibility" => "visible",
k if k.starts_with("border") || k == "stroke" => "stroke",
_ => "layout",
};
if !keys.contains(&key) {
keys.push(key);
}
}
keys
}
/// `var(--name)` or `var(--name, fallback)` as (name, fallback).
pub fn var_name(v: &str) -> Option<(&str, Option<&str>)> {
let v = v.trim();
let inner = v.strip_prefix("var(")?.strip_suffix(')')?;
// One call only: `var(--a) var(--b)` is two.
let mut depth = 1;
for ch in inner.chars() {
match ch {
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
return None;
}
}
_ => {}
}
}
let (name, fallback) = match inner.split_once(',') {
Some((n, f)) => (n, Some(f.trim())),
None => (inner, None),
};
Some((name.trim().strip_prefix("--")?, fallback))
}
fn apply_resolved(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> Applied {
let mut a = Applied::default();
// Font size first: `em` values elsewhere depend on it.
if let Some((_, v)) = styles.iter().rev().find(|(k, _)| k == "fontSize") {
@@ -838,6 +1092,35 @@ pub fn apply(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> Applie
}
_ => false,
},
"textTransform" if is_text => {
if let Some(t) = n.kind.text_mut() {
match lower.as_str() {
"uppercase" => *t = t.to_uppercase(),
"lowercase" => *t = t.to_lowercase(),
"capitalize" => {
*t = t
.split(' ')
.map(|w| {
let mut c = w.chars();
c.next().map_or(String::new(), |f| {
f.to_uppercase().collect::<String>() + c.as_str()
})
})
.collect::<Vec<_>>()
.join(" ")
}
_ => {}
}
}
true
}
"boxShadow" => match parse_shadow(v) {
Some(s) => {
n.style.shadow = s;
true
}
None => false,
},
"flexShrink" | "flexBasis" | "boxSizing" | "minWidth" | "minHeight" | "maxWidth"
| "maxHeight" | "zIndex" | "cursor" | "whiteSpace" | "fontFamily" | "wordBreak"
| "overflowWrap" => {
@@ -1039,7 +1322,7 @@ pub fn apply(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> Applie
}
None => false,
},
// Excalidraw-style hand-drawn rendering (paper-rs extensions).
// Excalidraw-style hand-drawn rendering (papercraft extensions).
"sketch" | "handDrawn" => {
if matches!(lower.as_str(), "true" | "1" | "on" | "yes") {
rough_mut(n);
@@ -1327,7 +1610,10 @@ pub fn computed(n: &Node, parent_layout: Option<&AutoLayout>, is_root: bool) ->
.iter()
.rev()
.filter_map(|p| match p {
Paint::Solid { color } => Some(color_css(*color)),
Paint::Solid { color, var } => Some(
var.as_ref()
.map_or_else(|| color_css(*color), |v| format!("var(--{v})")),
),
Paint::Image { asset, .. } => Some(format!("url(paper-asset://{asset})")),
g => gradient_css(g),
})
@@ -1362,6 +1648,19 @@ pub fn computed(n: &Node, parent_layout: Option<&AutoLayout>, is_root: bool) ->
} else if n.style.corner_radius > 0.0 {
put("borderRadius", px(n.style.corner_radius));
}
if let Some(s) = n.style.shadow {
put(
"boxShadow",
format!(
"{} {} {} {} {}",
px(s.x),
px(s.y),
px(s.blur),
px(s.spread),
color_css(s.color)
),
);
}
if n.style.opacity < 1.0 {
put(
"opacity",
@@ -1452,7 +1751,7 @@ mod tests {
assert_eq!(n.style.stroke_width, 2.0);
assert!(!n.style.dash.is_empty());
assert_eq!(n.style.corner_radius, 12.0);
assert_eq!(a.ignored, vec!["boxShadow".to_string()]);
assert!(a.ignored.is_empty(), "{:?}", a.ignored);
let back = computed(&n, None, true);
assert_eq!(back["flexDirection"], "column");
assert_eq!(back["borderRadius"], "12px");
+990 -30
View File
File diff suppressed because it is too large Load Diff
+271
View File
@@ -0,0 +1,271 @@
//! `lint_design`: checks a design for the mistakes agents make most, with a concrete fix for each.
//!
//! The rules are conventions, not law: WCAG 2.x contrast, a 4px spacing grid, a type scale, 44px
//! hit areas for buttons and tabs, real layer names, and colors that should be variables. Each
//! issue says which node, what is wrong, and what to change.
use crate::model::{Color, Document, Node, NodeId, Paint};
use std::collections::BTreeMap;
#[derive(Debug, Clone, PartialEq)]
pub struct Issue {
pub rule: &'static str,
pub severity: &'static str,
pub node: NodeId,
pub message: String,
pub fix: String,
}
/// Sizes the type-scale rule accepts by default.
pub const DEFAULT_SCALE: [f64; 9] = [12.0, 14.0, 16.0, 20.0, 24.0, 32.0, 40.0, 48.0, 64.0];
/// WCAG relative luminance of an sRGB color.
fn luminance(c: Color) -> f64 {
let lin = |v: u8| {
let v = v as f64 / 255.0;
if v <= 0.03928 {
v / 12.92
} else {
((v + 0.055) / 1.055).powf(2.4)
}
};
0.2126 * lin(c.r) + 0.7152 * lin(c.g) + 0.0722 * lin(c.b)
}
/// WCAG contrast ratio, 1..=21.
pub fn contrast(a: Color, b: Color) -> f64 {
let (la, lb) = (luminance(a), luminance(b));
(la.max(lb) + 0.05) / (la.min(lb) + 0.05)
}
/// `over` composited on an opaque `base`.
fn blend(base: Color, over: Color) -> Color {
let a = over.a as f64 / 255.0;
let mix = |b: u8, o: u8| (b as f64 * (1.0 - a) + o as f64 * a).round() as u8;
Color::rgb(
mix(base.r, over.r),
mix(base.g, over.g),
mix(base.b, over.b),
)
}
fn solid(n: &Node) -> Option<(Color, Option<&str>)> {
match n.style.fills.last() {
Some(Paint::Solid { color, var }) if color.a > 0 => Some((*color, var.as_deref())),
_ => None,
}
}
/// The color text sits on: the nearest opaque fill up the tree (translucent ones blend over
/// what is behind them); white when nothing is filled.
fn backdrop(doc: &Document, id: NodeId) -> Color {
let mut layers: Vec<Color> = Vec::new();
let mut cur = doc.get(id).and_then(|n| n.parent);
while let Some(p) = cur {
let Some(n) = doc.get(p) else { break };
if n.visible
&& let Some((c, _)) = solid(n)
{
layers.push(c);
if c.a == 255 {
break;
}
}
cur = n.parent;
}
layers
.into_iter()
.rev()
.fold(Color::rgb(255, 255, 255), blend)
}
fn is_default_name(name: &str) -> bool {
let base = name.trim_end_matches(|c: char| c.is_ascii_digit() || c == ' ');
matches!(
base,
"Frame"
| "Rectangle"
| "Ellipse"
| "Group"
| "Path"
| "Line"
| "Arrow"
| "Layer"
| "Vector"
| "Polygon"
| "Star"
| "Diamond"
| "Triangle"
| "Image"
| "Icon"
) && name.len() < 14
}
fn off_grid(v: f64) -> bool {
v > 0.0 && (v / 4.0 - (v / 4.0).round()).abs() > 1e-6
}
/// Lint the subtrees under `roots` (instances are skipped: their component is linted instead).
pub fn lint(doc: &Document, roots: &[NodeId], scale: &[f64]) -> Vec<Issue> {
let mut out: Vec<Issue> = Vec::new();
let mut ids: Vec<NodeId> = Vec::new();
for r in roots {
ids.extend(doc.subtree(*r));
}
let vars: BTreeMap<String, Color> = doc.variables.clone();
// Colors used literally, with the first node using each, for the variables report.
let mut literal: BTreeMap<String, (usize, NodeId)> = BTreeMap::new();
for id in ids {
let Some(n) = doc.get(id) else { continue };
if !n.visible || n.instance.is_some() {
continue;
}
let mut add = |rule: &'static str, severity: &'static str, message: String, fix: String| {
out.push(Issue {
rule,
severity,
node: id,
message,
fix,
});
};
// ---- text
if let (Some(size), Some(_)) = (n.kind.font_size(), n.kind.text()) {
if let Some((fg, _)) = solid(n) {
let bg = backdrop(doc, id);
let fg = blend(bg, fg);
let ratio = contrast(fg, bg);
let large = size >= 24.0 || (size >= 18.66 && n.text_style.bold);
let need = if large { 3.0 } else { 4.5 };
if ratio < need {
add(
"contrast",
"error",
format!(
"text {} on {} is {ratio:.1}:1 (needs {need}:1)",
fg.to_hex(),
bg.to_hex()
),
"use a lighter or darker text color, or change the background".into(),
);
}
}
if size < 12.0 {
add(
"text-size",
"warning",
format!("{size}px text is hard to read"),
"use 12px or more (14px+ for body copy)".into(),
);
} else if !scale.is_empty() && !scale.iter().any(|s| (s - size).abs() < 0.01) {
let near = scale
.iter()
.min_by(|a, b| (*a - size).abs().total_cmp(&(*b - size).abs()))
.copied()
.unwrap_or(size);
add(
"type-scale",
"info",
format!("{size}px is not on the type scale"),
format!("use {near}px (scale: {})", join(scale)),
);
}
}
// ---- spacing on the 4px grid
if let Some(l) = &n.layout {
let mut bad: Vec<String> = Vec::new();
if off_grid(l.gap) {
bad.push(format!("gap {}", l.gap));
}
for (side, p) in ["top", "right", "bottom", "left"].iter().zip(l.padding) {
if off_grid(p) {
bad.push(format!("padding-{side} {p}"));
}
}
if !bad.is_empty() {
add(
"spacing-grid",
"warning",
format!("off the 4px grid: {}", bad.join(", ")),
"use multiples of 4 (4, 8, 12, 16, 24, 32, 48)".into(),
);
}
}
// ---- hit areas
let lower = n.name.to_lowercase();
let tap = lower.starts_with("button")
|| lower.starts_with("tab /")
|| lower.starts_with("tab bar /");
if tap && n.is_frame() && n.rect.h < 44.0 && n.rect.h > 0.0 && !lower.contains("icon") {
add(
"touch-target",
"warning",
format!("`{}` is {:.0}px tall", n.name, n.rect.h),
"make interactive targets at least 44px tall (padding, not just text size)".into(),
);
}
// ---- names
if !n.component
&& n.kind.text().is_none()
&& is_default_name(&n.name)
&& !doc.children(Some(id)).is_empty()
{
add(
"layer-name",
"warning",
format!("`{}` is a default name", n.name),
"name it for what it is (layer-name=\"Task Row\"), `Component / Variant` for parts"
.into(),
);
}
// ---- colors that should be variables
if let Some((c, var)) = solid(n)
&& var.is_none()
&& c.a == 255
{
if let Some((name, _)) = vars.iter().find(|(_, v)| **v == c) {
add(
"hard-coded-color",
"warning",
format!("{} is the value of --{name}", c.to_hex()),
format!("write var(--{name}) so a theme change reaches it"),
);
} else if !vars.is_empty() {
let e = literal.entry(c.to_hex()).or_insert((0, id));
e.0 += 1;
}
}
}
for (hex, (count, first)) in literal {
out.push(Issue {
rule: "hard-coded-color",
severity: "info",
node: first,
message: format!("{hex} is used {count}× and is not a variable"),
fix: "add it with set_variables, or use an existing variable".into(),
});
}
out
}
fn join(v: &[f64]) -> String {
v.iter()
.map(|s| format!("{s}"))
.collect::<Vec<_>>()
.join(", ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn contrast_matches_wcag_reference_values() {
assert!((contrast(Color::rgb(0, 0, 0), Color::rgb(255, 255, 255)) - 21.0).abs() < 0.01);
assert!(
(contrast(Color::rgb(119, 119, 119), Color::rgb(255, 255, 255)) - 4.48).abs() < 0.05
);
assert!(off_grid(13.0) && !off_grid(12.0) && !off_grid(0.0));
assert!(is_default_name("Frame 12") && !is_default_name("Task Row"));
}
}
+11 -4
View File
@@ -1,7 +1,7 @@
//! MCP server: AI agents read and edit the canvas of the running app.
//!
//! Modeled on Paper's MCP server (behaviour notes in `analysis/paper-mcp.md`): the app serves
//! MCP over streamable HTTP on loopback, and `paper-rs-mcp` bridges stdio clients to it. Tool
//! MCP over streamable HTTP on loopback, and `papercraft-mcp` bridges stdio clients to it. Tool
//! calls are handed to the thread that owns the editor, so every change goes through the
//! normal transaction / undo path and repaints the window.
//!
@@ -9,20 +9,27 @@
//! * [`exec`]: runs one tool call against a [`exec::Host`] (the app, or a headless session).
//! * [`tools`]: tool names, descriptions and schemas; [`css`] and [`html`]: the style and
//! markup mapping used by `write_html` / `update_styles`.
//! * [`relay_proto`]: JSON frames between the relay and a browser tab; [`relay`]: the
//! relay server itself (native only).
pub mod css;
pub mod exec;
pub mod html;
pub mod lint;
#[cfg(not(target_arch = "wasm32"))]
pub mod relay;
pub mod relay_proto;
#[cfg(not(target_arch = "wasm32"))]
pub mod server;
pub mod tools;
/// Paper uses 29979; paper-rs listens next door so both apps can run side by side.
/// Paper uses 29979; papercraft listens next door so both apps can run side by side.
pub const DEFAULT_PORT: u16 = 29_980;
pub const ENDPOINT: &str = "/mcp";
/// The port to serve on / connect to: `$PAPER_RS_MCP_PORT`, else [`DEFAULT_PORT`].
/// The port to serve on / connect to: `$PAPERCRAFT_MCP_PORT`, else [`DEFAULT_PORT`].
pub fn port() -> u16 {
std::env::var("PAPER_RS_MCP_PORT")
std::env::var("PAPERCRAFT_MCP_PORT")
.ok()
.and_then(|p| p.trim().parse().ok())
.unwrap_or(DEFAULT_PORT)
+544
View File
@@ -0,0 +1,544 @@
//! The relay: lets a remote agent drive a PaperCraft tab running in someone's browser.
//!
//! ```text
//! agent --(MCP streamable HTTP)--> relay <--(WebSocket)-- browser tab
//! POST /s/{token}/mcp GET /tab/{token}
//! ```
//!
//! The relay holds no document state: it serves the same tool list and instructions as the
//! desktop server and forwards each call to the tab as a [`relay_proto::Call`] frame. The token
//! in the path is the only credential, so it is never logged and never appears in an error.
use super::relay_proto::{self, Call as CallFrame, Reply};
use super::server::{CALL_TIMEOUT, Dispatch, PaperServer};
use super::{Call, ToolResult};
use axum::extract::ws::{CloseFrame, Message, WebSocket, WebSocketUpgrade};
use axum::extract::{FromRequestParts, Path, Request, State};
use axum::http::{HeaderMap, StatusCode, Uri, header};
use axum::response::{IntoResponse, Response};
use axum::routing::any;
use rmcp::transport::streamable_http_server::{
StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
};
use serde_json::Value;
use std::collections::HashMap;
use std::net::SocketAddr;
use std::path::{Component, Path as FsPath, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::Duration;
use tokio::sync::{mpsc, oneshot};
use tokio::time::Instant;
use tokio_util::sync::CancellationToken;
/// Biggest frame we accept from (or send to) a tab.
pub const MAX_WS_MESSAGE_BYTES: usize = 16 << 20;
/// How often we ping a tab; a socket that stops answering is dropped.
pub const PING_INTERVAL: Duration = Duration::from_secs(20);
/// A socket that has not ponged within this many ping intervals is considered dead.
const PONG_GRACE_INTERVALS: u32 = 3;
/// Tool calls allowed to be in flight for one link; the rest wait their turn.
pub const MAX_INFLIGHT_CALLS: usize = 8;
/// Cap on an MCP request body (screenshots and assets are big).
const MAX_MCP_BODY_BYTES: usize = 64 << 20;
/// What an agent sees when nothing is connected to the link.
pub const NO_TAB_ERROR: &str =
"No PaperCraft tab is connected for this link. Open PaperCraft and use Agent > Connect.";
/// Close reason sent to the previous socket when a newer tab claims the link.
const TAB_REPLACED: &str = "replaced by a newer tab";
/// Shown for any request the token rules out; deliberately identical for bad tokens and
/// unknown links so a wrong token cannot be told apart from a wrong path.
const NOT_FOUND: &str = "not found";
/// Answered for a route the relay does not serve.
const UNKNOWN_ROUTE: &str = "not found: this relay serves /tab/{token} and /s/{token}/mcp";
type Service = StreamableHttpService<PaperServer, LocalSessionManager>;
/// Frames the relay pushes to a tab.
enum ToTab {
/// A tool call to run.
Call(String),
/// Close the socket with this reason.
Close(&'static str),
}
/// The live connection of one tab, plus the calls waiting for its answers.
struct Tab {
out: mpsc::UnboundedSender<ToTab>,
pending: Mutex<HashMap<u64, oneshot::Sender<Reply>>>,
}
impl Tab {
/// Shorthand for a poisoned lock: a panic cannot leave the map half-written, and a stale
/// entry only costs one abandoned wait.
fn pending(&self) -> std::sync::MutexGuard<'_, HashMap<u64, oneshot::Sender<Reply>>> {
self.pending.lock().unwrap_or_else(PoisonError::into_inner)
}
/// Answer every call still waiting with `error`, so agents stop waiting on a dead socket.
fn fail_all(&self, error: &str) {
for (id, reply) in self.pending().drain() {
let _ = reply.send(Reply::err(id, error));
}
}
}
/// Per-link state shared by the MCP service and the socket: the connected tab and the
/// in-flight limit.
struct Shared {
tab: tokio::sync::Mutex<Option<Arc<Tab>>>,
calls: Arc<tokio::sync::Semaphore>,
next_id: AtomicU64,
}
impl Shared {
fn new() -> Shared {
Shared {
tab: tokio::sync::Mutex::new(None),
calls: Arc::new(tokio::sync::Semaphore::new(MAX_INFLIGHT_CALLS)),
next_id: AtomicU64::new(1),
}
}
/// Hand a tool call to the tab and wait for its answer.
async fn forward(&self, name: String, args: Value) -> ToolResult {
let Some(tab) = self.tab.lock().await.clone() else {
return Err(NO_TAB_ERROR.to_string());
};
// Acquired before the frame goes out, so a link never asks the tab to answer more
// calls at once than it is allowed to hold.
let Ok(_permit) = self.calls.clone().acquire_owned().await else {
return Err("the relay is shutting down".into());
};
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let frame = CallFrame { id, name, args };
let json = match serde_json::to_string(&frame) {
Ok(j) => j,
Err(e) => return Err(format!("cannot encode the call: {e}")),
};
let (reply_tx, reply_rx) = oneshot::channel();
// Registered before the frame goes out: a tab may answer before we get there.
tab.pending().insert(id, reply_tx);
if tab.out.send(ToTab::Call(json)).is_err() {
tab.pending().remove(&id);
return Err(NO_TAB_ERROR.to_string());
}
let answered = tokio::time::timeout(CALL_TIMEOUT, reply_rx).await;
tab.pending().remove(&id);
match answered {
Ok(Ok(reply)) => reply.into_result(),
Ok(Err(_)) => Err(NO_TAB_ERROR.to_string()),
Err(_) => Err("the PaperCraft tab did not answer in time".into()),
}
}
}
/// Everything one link owns: the cached MCP service plus its [`Shared`].
struct Link {
service: Service,
shared: Arc<Shared>,
}
impl Link {
fn new(shared: Arc<Shared>, ct: CancellationToken) -> Link {
let dispatch = dispatch(shared.clone());
let config = StreamableHttpServerConfig::default()
.enforce_origin_validation()
.with_max_request_body_bytes(MAX_MCP_BODY_BYTES)
.with_cancellation_token(ct);
let service: Service = StreamableHttpService::new(
move || Ok(PaperServer::new(dispatch.clone())),
Default::default(),
config,
);
Link { service, shared }
}
}
/// The relay holds no editor thread, so a call runs on its own task and the caller's oneshot
/// gets the answer that arrives over the WebSocket.
fn dispatch(shared: Arc<Shared>) -> Dispatch {
Arc::new(move |call: Call| {
let shared = shared.clone();
tokio::spawn(async move {
let r = shared.forward(call.name, call.args).await;
let _ = call.reply.send(r);
});
true
})
}
struct Relay {
links: tokio::sync::Mutex<HashMap<String, Arc<Link>>>,
allow_origin: Option<String>,
static_dir: Option<PathBuf>,
ct: CancellationToken,
}
impl Relay {
/// The link for `token`, created on first use. An invalid token never gets one.
async fn link(&self, token: &str) -> Option<Arc<Link>> {
if !relay_proto::valid_token(token) {
return None;
}
let mut links = self.links.lock().await;
if let Some(link) = links.get(token) {
return Some(link.clone());
}
let link = Arc::new(Link::new(Arc::new(Shared::new()), self.ct.child_token()));
links.insert(token.to_string(), link.clone());
Some(link)
}
/// `/tab/*` is a browser route: with `--allow-origin` only that exact origin may connect.
fn origin_ok(&self, headers: &HeaderMap) -> bool {
let Some(want) = &self.allow_origin else {
return true;
};
match headers.get(header::ORIGIN) {
Some(v) => v.to_str().map(|o| o == want).unwrap_or(false),
None => true,
}
}
}
fn not_found(body: &'static str) -> Response {
(StatusCode::NOT_FOUND, body).into_response()
}
fn forbidden(body: &'static str) -> Response {
(StatusCode::FORBIDDEN, body).into_response()
}
/// `POST|GET|DELETE /s/{token}/mcp`: the agent's streamable-HTTP endpoint for one link.
async fn mcp_endpoint(
State(relay): State<Arc<Relay>>,
Path(token): Path<String>,
req: Request,
) -> Response {
// rmcp's `enforce_origin_validation` already turns browsers away; checking here keeps the
// policy ours, and it runs before the link lookup so a browser cannot create one.
if req.headers().contains_key(header::ORIGIN) {
return forbidden("this endpoint does not accept browser requests");
}
let Some(link) = relay.link(&token).await else {
return not_found(NOT_FOUND);
};
// axum can only nest a service under a path known when the router is built, and tokens
// arrive at runtime; the service only needs the request, so it is called directly.
link.service.handle(req).await.into_response()
}
/// `GET /tab/{token}`: the browser's socket for one link.
async fn tab_socket(
State(relay): State<Arc<Relay>>,
Path(token): Path<String>,
req: Request,
) -> Response {
let Some(link) = relay.link(&token).await else {
return not_found(NOT_FOUND);
};
if !relay.origin_ok(req.headers()) {
return forbidden("this origin may not connect a tab");
}
// The token is checked before the upgrade: a `WebSocketUpgrade` extractor would answer a
// plain request with 400 before we could refuse a bad token.
let (mut parts, _) = req.into_parts();
let upgrade = match WebSocketUpgrade::from_request_parts(&mut parts, &()).await {
Ok(u) => u,
Err(rejection) => return rejection.into_response(),
};
upgrade
.max_message_size(MAX_WS_MESSAGE_BYTES)
.on_upgrade(move |socket| serve_tab(socket, link))
.into_response()
}
/// What the socket loop is waiting for. Only one branch touches the socket, so the borrow
/// from `recv()` is gone by the time we write to it.
enum Event {
Frame(Result<Message, axum::Error>),
Outgoing(Option<ToTab>),
Ping,
Closed,
}
async fn next_event(
socket: &mut WebSocket,
out: &mut mpsc::UnboundedReceiver<ToTab>,
ping: &mut tokio::time::Interval,
) -> Event {
tokio::select! {
m = socket.recv() => match m {
Some(m) => Event::Frame(m),
None => Event::Closed,
},
o = out.recv() => Event::Outgoing(o),
_ = ping.tick() => Event::Ping,
}
}
/// Answer the call waiting on this frame's id; frames for no known call are ignored.
fn handle_reply(tab: &Tab, text: &str) {
let reply = match serde_json::from_str::<Reply>(text) {
Ok(r) => r,
Err(_) => {
log::debug!("relay: ignoring a frame that is not a reply");
return;
}
};
match tab.pending().remove(&reply.id) {
Some(waiter) => {
let _ = waiter.send(reply);
}
None => log::debug!("relay: ignoring a reply with no pending call"),
}
}
/// One tab's socket: forwards tool calls out, routes replies back to their calls, pings to
/// keep the connection (and any proxy in front of it) alive.
async fn serve_tab(mut socket: WebSocket, link: Arc<Link>) {
let (out, mut out_rx) = mpsc::unbounded_channel::<ToTab>();
let tab = Arc::new(Tab {
out,
pending: Mutex::new(HashMap::new()),
});
// One tab per link: a newer connection takes over and the older socket is closed.
let previous = link.shared.tab.lock().await.replace(tab.clone());
if let Some(old) = previous {
let _ = old.out.send(ToTab::Close(TAB_REPLACED));
}
// The tab is live as of here: say so with a ping, so a client that waits for one never
// races this registration.
let _ = socket.send(Message::Ping(axum::body::Bytes::new())).await;
let mut ping = tokio::time::interval_at(Instant::now() + PING_INTERVAL, PING_INTERVAL);
let mut last_pong = Instant::now();
loop {
match next_event(&mut socket, &mut out_rx, &mut ping).await {
Event::Closed | Event::Frame(Err(_)) => break,
Event::Frame(Ok(Message::Close(_))) => break,
Event::Frame(Ok(Message::Text(t))) => handle_reply(&tab, &t),
Event::Frame(Ok(Message::Pong(_))) => last_pong = Instant::now(),
// Ping replies are sent by the protocol layer; other frame types are not ours.
Event::Frame(Ok(_)) => {}
Event::Outgoing(None) => break,
Event::Outgoing(Some(ToTab::Call(json))) => {
if socket.send(Message::Text(json.into())).await.is_err() {
break;
}
}
Event::Outgoing(Some(ToTab::Close(reason))) => {
let frame = CloseFrame {
code: axum::extract::ws::close_code::AWAY,
reason: reason.into(),
};
let _ = socket.send(Message::Close(Some(frame))).await;
break;
}
Event::Ping => {
// No pong for several intervals means the socket is gone even if TCP has not
// noticed yet.
if last_pong.elapsed() > PING_INTERVAL * PONG_GRACE_INTERVALS {
break;
}
if socket
.send(Message::Ping(axum::body::Bytes::new()))
.await
.is_err()
{
break;
}
}
}
}
let mut current = link.shared.tab.lock().await;
if let Some(t) = current.as_ref()
&& Arc::ptr_eq(t, &tab)
{
*current = None;
}
drop(current);
tab.fail_all(NO_TAB_ERROR);
}
/// The MIME types the built web app needs; anything else is an opaque download.
const MIME_TYPES: &[(&str, &str)] = &[
("wasm", "application/wasm"),
("js", "text/javascript"),
("html", "text/html"),
("css", "text/css"),
("png", "image/png"),
("svg", "image/svg+xml"),
];
/// Fallback for anything not in [`MIME_TYPES`].
const DEFAULT_MIME: &str = "application/octet-stream";
fn mime_of(path: &FsPath) -> &'static str {
let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
return DEFAULT_MIME;
};
MIME_TYPES
.iter()
.find(|(e, _)| e.eq_ignore_ascii_case(ext))
.map_or(DEFAULT_MIME, |(_, m)| *m)
}
/// `<dir>/index.html` for `/`, else `<dir>/<path>`; `None` for anything that would read
/// outside `root` (`..`, absolute paths, `.`).
fn static_path(root: &FsPath, request_path: &str) -> Option<PathBuf> {
let rel = request_path.trim_start_matches('/');
let rel = FsPath::new(if rel.is_empty() { "index.html" } else { rel });
if rel.components().any(|c| !matches!(c, Component::Normal(_))) {
return None;
}
Some(root.join(rel))
}
/// The `--static` tree, and the answer for every route the relay does not serve.
async fn serve_static(State(relay): State<Arc<Relay>>, uri: Uri) -> Response {
let Some(dir) = relay.static_dir.as_deref() else {
return not_found(UNKNOWN_ROUTE);
};
let Some(path) = static_path(dir, uri.path()) else {
return not_found(NOT_FOUND);
};
// Read whole: the files are a wasm bundle, and a streaming body would need another
// dependency for no benefit here.
match tokio::fs::read(&path).await {
Ok(bytes) => ([(header::CONTENT_TYPE, mime_of(&path))], bytes).into_response(),
Err(_) => not_found(NOT_FOUND),
}
}
fn router(relay: Arc<Relay>) -> axum::Router {
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)
}
/// A running relay; stops when dropped.
pub struct Running {
port: u16,
ct: CancellationToken,
thread: Option<std::thread::JoinHandle<()>>,
}
impl Running {
/// The port actually bound (0 in the request means "any free port").
pub fn port(&self) -> u16 {
self.port
}
pub fn stop(mut self) {
self.shutdown();
}
fn shutdown(&mut self) {
self.ct.cancel();
if let Some(t) = self.thread.take() {
let _ = t.join();
}
}
}
impl Drop for Running {
fn drop(&mut self) {
self.shutdown();
}
}
/// Serve the relay from a background thread. `static_dir` serves the built web app from the
/// same origin; `allow_origin` restricts which browser may connect a tab.
pub fn spawn(
listen: SocketAddr,
static_dir: Option<PathBuf>,
allow_origin: Option<String>,
) -> Result<Running, String> {
let listener = std::net::TcpListener::bind(listen)
.map_err(|e| format!("cannot listen on {listen}: {e}"))?;
listener.set_nonblocking(true).map_err(|e| e.to_string())?;
let port = listener.local_addr().map_err(|e| e.to_string())?.port();
let ct = CancellationToken::new();
let thread_ct = ct.clone();
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.thread_name("papercraft-relay")
.enable_all()
.build()
.map_err(|e| e.to_string())?;
let thread = std::thread::Builder::new()
.name("papercraft-relay-http".into())
.spawn(move || {
rt.block_on(async move {
let listener = match tokio::net::TcpListener::from_std(listener) {
Ok(l) => l,
Err(e) => {
log::error!("relay: {e}");
return;
}
};
let relay = Arc::new(Relay {
links: tokio::sync::Mutex::new(HashMap::new()),
allow_origin,
static_dir,
ct: thread_ct.child_token(),
});
if let Err(e) = axum::serve(listener, router(relay))
.with_graceful_shutdown(thread_ct.cancelled_owned())
.await
{
log::error!("relay: server stopped: {e}");
}
});
})
.map_err(|e| e.to_string())?;
Ok(Running {
port,
ct,
thread: Some(thread),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn static_paths_stay_inside_the_root() {
let root = FsPath::new("/srv/web");
assert_eq!(
static_path(root, "/").as_deref(),
Some(FsPath::new("/srv/web/index.html"))
);
assert_eq!(
static_path(root, "/pkg/papercraft_bg.wasm").as_deref(),
Some(FsPath::new("/srv/web/pkg/papercraft_bg.wasm"))
);
for bad in [
"/../Cargo.toml",
"/pkg/../../etc/passwd",
"/./index.html",
"..",
] {
assert_eq!(static_path(root, bad), None, "{bad}");
}
// Extra leading slashes are stripped, not resolved: still inside the root.
assert_eq!(
static_path(root, "//etc/passwd").as_deref(),
Some(FsPath::new("/srv/web/etc/passwd"))
);
}
#[test]
fn mime_types_cover_the_built_web_app() {
assert_eq!(mime_of(FsPath::new("a/b.wasm")), "application/wasm");
assert_eq!(mime_of(FsPath::new("a/b.js")), "text/javascript");
assert_eq!(mime_of(FsPath::new("index.html")), "text/html");
assert_eq!(mime_of(FsPath::new("style.css")), "text/css");
assert_eq!(mime_of(FsPath::new("icon.png")), "image/png");
assert_eq!(mime_of(FsPath::new("logo.svg")), "image/svg+xml");
assert_eq!(mime_of(FsPath::new("LICENSE")), DEFAULT_MIME);
}
}
+291
View File
@@ -0,0 +1,291 @@
//! Wire protocol between the relay and a PaperCraft tab running in a browser.
//!
//! JSON text frames, one tool call per exchange: the relay sends a [`Call`], the tab answers
//! with a [`Reply`] carrying the same `id`. Compiled for native and wasm alike: the tab side
//! speaks it too.
use crate::mcp::{Out, ToolResult};
use serde::{Deserialize, Serialize};
/// Shortest link token a tab may generate.
pub const MIN_TOKEN_LEN: usize = 32;
/// Longest link token a tab may generate.
pub const MAX_TOKEN_LEN: usize = 64;
/// relay -> tab: run `name` with `args`, answer frame `id`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Call {
pub id: u64,
pub name: String,
pub args: serde_json::Value,
}
/// One output block, tagged by `type` so the tab can switch on it.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum OutFrame {
Text {
text: String,
},
/// `data` is base64 (standard alphabet, padded).
Image {
mime: String,
data: String,
},
}
/// tab -> relay: the answer to [`Call::id`]. `out` and `error` are mutually exclusive.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Reply {
pub id: u64,
pub ok: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub out: Option<Vec<OutFrame>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
impl Reply {
pub fn ok(id: u64, out: Vec<Out>) -> Reply {
Reply {
id,
ok: true,
out: Some(out.into_iter().map(OutFrame::from).collect()),
error: None,
}
}
pub fn err(id: u64, error: impl Into<String>) -> Reply {
Reply {
id,
ok: false,
out: None,
error: Some(error.into()),
}
}
/// The tool result this reply carries; a malformed image block is an error, not a panic.
pub fn into_result(self) -> ToolResult {
if !self.ok {
return Err(self
.error
.unwrap_or_else(|| "the PaperCraft tab reported an error".into()));
}
let blocks = match self.out {
Some(b) => b,
None => return Err("the PaperCraft tab sent an empty reply".into()),
};
let mut out = Vec::with_capacity(blocks.len());
for b in blocks {
match b.into() {
Some(o) => out.push(o),
None => return Err("the PaperCraft tab sent an image that is not base64".into()),
}
}
Ok(out)
}
}
impl From<Out> for OutFrame {
fn from(o: Out) -> OutFrame {
match o {
Out::Text(text) => OutFrame::Text { text },
Out::Image { data, mime } => OutFrame::Image {
mime,
data: crate::assets::base64(&data),
},
}
}
}
impl From<OutFrame> for Option<Out> {
fn from(f: OutFrame) -> Option<Out> {
match f {
OutFrame::Text { text } => Some(Out::Text(text)),
OutFrame::Image { mime, data } => {
crate::assets::base64_decode(&data).map(|d| Out::Image { data: d, mime })
}
}
}
}
/// A link token is a bearer secret the tab generates: 32..=64 lowercase hex chars, nothing else.
pub fn valid_token(token: &str) -> bool {
(MIN_TOKEN_LEN..=MAX_TOKEN_LEN).contains(&token.len())
&& token
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
/// Bytes in a freshly generated link token (hex-encoded: [`MIN_TOKEN_LEN`] characters).
pub const TOKEN_BYTES: usize = MIN_TOKEN_LEN / 2;
/// Lowercase hex of `bytes`, the token alphabet.
pub fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
/// Where the tab's WebSocket connects: `base` is a `ws(s)://host` origin.
pub fn tab_url(base: &str, token: &str) -> String {
format!("{}/tab/{token}", base.trim_end_matches('/'))
}
/// The URL an agent is given: the relay's HTTP origin plus the token's MCP endpoint.
pub fn agent_url(origin: &str, token: &str) -> String {
format!("{}/s/{token}/mcp", origin.trim_end_matches('/'))
}
/// The relay's WebSocket base: `?relay=` in the page URL, else `window.PAPERCRAFT_RELAY`, else the
/// page's own origin. `search` is `location.search`; `page_origin` is `location.origin`.
pub fn relay_base(search: &str, global: Option<&str>, page_origin: &str) -> String {
let from_query = search
.trim_start_matches('?')
.split('&')
.find_map(|kv| kv.strip_prefix("relay="))
.filter(|v| !v.is_empty());
match from_query.or(global.filter(|g| !g.is_empty())) {
Some(url) => url.to_string(),
None => page_origin
.replacen("https://", "wss://", 1)
.replacen("http://", "ws://", 1),
}
}
/// The HTTP origin matching a relay WebSocket base (what the agent connects to).
pub fn http_origin(ws_base: &str) -> String {
ws_base
.replacen("wss://", "https://", 1)
.replacen("ws://", "http://", 1)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn png() -> Vec<u8> {
// 1x1 transparent PNG, encoded in the test so the payload stays tiny.
crate::assets::base64_decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
)
.expect("valid base64 fixture")
}
#[test]
fn success_reply_round_trips_through_json() {
let reply = Reply::ok(
7,
vec![
Out::Text("{\"nodes\":1}".into()),
Out::Image {
data: png(),
mime: "image/png".into(),
},
],
);
let json = serde_json::to_string(&reply).unwrap();
assert_eq!(
json,
format!(
r#"{{"id":7,"ok":true,"out":[{{"type":"text","text":"{{\"nodes\":1}}"}},{{"type":"image","mime":"image/png","data":"{}"}}]}}"#,
crate::assets::base64(&png())
)
);
let back: Reply = serde_json::from_str(&json).unwrap();
assert_eq!(back, reply);
let outs = back.into_result().unwrap();
assert_eq!(outs[0], Out::Text("{\"nodes\":1}".into()));
assert_eq!(
outs[1],
Out::Image {
data: png(),
mime: "image/png".into()
}
);
}
#[test]
fn failure_reply_is_exactly_id_ok_error() {
let reply = Reply::err(42, "no artboard");
let json = serde_json::to_string(&reply).unwrap();
assert_eq!(json, r#"{"id":42,"ok":false,"error":"no artboard"}"#);
assert_eq!(serde_json::from_str::<Reply>(&json).unwrap(), reply);
assert_eq!(reply.into_result().unwrap_err(), "no artboard");
}
#[test]
fn a_reply_that_is_not_a_valid_image_is_an_error() {
let bad: Reply = serde_json::from_value(json!({
"id": 1, "ok": true,
"out": [{"type": "image", "mime": "image/png", "data": "not base64!"}]
}))
.unwrap();
assert!(bad.into_result().is_err());
}
#[test]
fn call_round_trips_through_json() {
let call = Call {
id: 7,
name: "get_screenshot".into(),
args: json!({"nodeId": "n1:3"}),
};
let json = serde_json::to_string(&call).unwrap();
assert_eq!(
json,
r#"{"id":7,"name":"get_screenshot","args":{"nodeId":"n1:3"}}"#
);
assert_eq!(serde_json::from_str::<Call>(&json).unwrap(), call);
}
#[test]
fn tokens_are_lowercase_hex_of_the_documented_length() {
let n32 = "a".repeat(MIN_TOKEN_LEN);
let n64 = "0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a69788796a5b4c3d2e1f0";
assert_eq!(n64.len(), MAX_TOKEN_LEN);
assert!(valid_token(&n32));
assert!(valid_token(&n64));
assert!(valid_token(&"0123456789abcdef".repeat(2)));
assert!(!valid_token(""));
assert!(!valid_token(&"a".repeat(MIN_TOKEN_LEN - 1)));
assert!(!valid_token(&"a".repeat(MAX_TOKEN_LEN + 1)));
assert!(!valid_token(&"A".repeat(MIN_TOKEN_LEN)), "uppercase");
assert!(!valid_token(&"g".repeat(MIN_TOKEN_LEN)), "non-hex");
assert!(!valid_token(&format!(
"{}-{}",
"a".repeat(MIN_TOKEN_LEN - 1),
"b"
)));
}
#[test]
fn urls_and_hex() {
assert_eq!(hex(&[0, 15, 255]), "000fff");
assert!(valid_token(&hex(&[0xab; TOKEN_BYTES])));
assert_eq!(tab_url("wss://h.example/", "t"), "wss://h.example/tab/t");
assert_eq!(
agent_url("https://h.example", "t"),
"https://h.example/s/t/mcp"
);
}
#[test]
fn relay_base_prefers_query_then_global_then_origin() {
let o = "https://app.example";
assert_eq!(
relay_base("?relay=wss://r.example&x=1", Some("wss://g"), o),
"wss://r.example"
);
assert_eq!(
relay_base("?x=1", Some("wss://g.example"), o),
"wss://g.example"
);
assert_eq!(relay_base("", None, o), "wss://app.example");
assert_eq!(
relay_base("", Some(""), "http://localhost:8787"),
"ws://localhost:8787"
);
assert_eq!(http_origin("wss://r.example"), "https://r.example");
assert_eq!(http_origin("ws://localhost:1"), "http://localhost:1");
}
}
+10 -9
View File
@@ -23,7 +23,7 @@ use tokio_util::sync::CancellationToken;
pub type Dispatch = Arc<dyn Fn(Call) -> bool + Send + Sync>;
/// Longest a tool call may take on the editor thread.
const CALL_TIMEOUT: Duration = Duration::from_secs(120);
pub const CALL_TIMEOUT: Duration = Duration::from_secs(120);
/// The tool catalogue as rmcp tools.
pub fn tool_list() -> Vec<Tool> {
@@ -36,7 +36,7 @@ pub fn tool_list() -> Vec<Tool> {
/// Server identity, capabilities and instructions.
pub fn info() -> ServerConfig {
ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(Implementation::new("paper-rs", env!("CARGO_PKG_VERSION")))
.with_server_info(Implementation::new("PaperCraft", env!("CARGO_PKG_VERSION")))
.with_instructions(tools::INSTRUCTIONS)
}
@@ -94,12 +94,12 @@ impl ServerHandler for PaperServer {
reply,
};
let r = if !(self.dispatch)(call) {
Err("paper-rs is shutting down".to_string())
Err("papercraft is shutting down".to_string())
} else {
match tokio::time::timeout(CALL_TIMEOUT, rx).await {
Ok(Ok(r)) => r,
Ok(Err(_)) => Err("paper-rs dropped the call (is it closing?)".into()),
Err(_) => Err("paper-rs did not answer in time (is a modal dialog open?)".into()),
Ok(Err(_)) => Err("papercraft dropped the call (is it closing?)".into()),
Err(_) => Err("papercraft did not answer in time (is a modal dialog open?)".into()),
}
};
Ok(to_result(r).into())
@@ -107,7 +107,8 @@ impl ServerHandler for PaperServer {
}
/// Add the `Accept` header the streamable HTTP transport requires when a client omits it.
async fn fix_accept(
/// Shared with the relay, which serves the same transport to remote agents.
pub(crate) async fn fix_accept(
mut req: axum::extract::Request,
next: axum::middleware::Next,
) -> axum::response::Response {
@@ -187,7 +188,7 @@ pub fn spawn(port: u16, dispatch: Dispatch) -> Result<Running, String> {
let listener = std::net::TcpListener::bind(("127.0.0.1", port)).map_err(|e| {
if e.kind() == std::io::ErrorKind::AddrInUse {
format!(
"port {port} is in use (another paper-rs instance?); set PAPER_RS_MCP_PORT to use another"
"port {port} is in use (another papercraft instance?); set PAPERCRAFT_MCP_PORT to use another"
)
} else {
format!("cannot listen on 127.0.0.1:{port}: {e}")
@@ -199,12 +200,12 @@ pub fn spawn(port: u16, dispatch: Dispatch) -> Result<Running, String> {
let thread_ct = ct.clone();
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.thread_name("paper-rs-mcp")
.thread_name("papercraft-mcp")
.enable_all()
.build()
.map_err(|e| e.to_string())?;
let thread = std::thread::Builder::new()
.name("paper-rs-mcp-http".into())
.name("papercraft-mcp-http".into())
.spawn(move || {
rt.block_on(async move {
let listener = match tokio::net::TcpListener::from_std(listener) {
+167 -10
View File
@@ -1,18 +1,19 @@
//! Tool catalogue: names, descriptions and input schemas.
//!
//! Names and argument shapes follow Paper's MCP server where paper-rs has an equivalent
//! (`analysis/paper-mcp.md` §2), so agent prompts written for Paper carry over. paper-rs
//! Names and argument shapes follow Paper's MCP server where papercraft has an equivalent
//! (`analysis/paper-mcp.md` §2), so agent prompts written for Paper carry over. papercraft
//! additions: `create_shape` (whiteboard shapes and arrows), `select_nodes`, `undo`, `redo`.
use serde_json::{Value, json};
pub const INSTRUCTIONS: &str = "\
paper-rs is a local design and whiteboard app with an infinite canvas. These tools read and \
papercraft is a local design and whiteboard app with an infinite canvas. These tools read and \
edit the project open in the running app; every change is one undo step and shows up live.
- Start with get_basic_info (pages, artboards, sizes) and get_selection (what the user is \
looking at). Use list_resources / open_file / create_file to pick a project.
- For details read get_guide({ topic: \"paper-rs-mcp-instructions\" }) once per session.
- Start with get_basic_info (pages, artboards, sizes; `openFeedback` counts the user's notes) \
and get_selection (what the user is looking at); when openFeedback > 0 call get_feedback and \
fix each note, then resolve_feedback with a reply. Use list_resources / open_file / create_file to pick a project.
- For details read get_guide({ topic: \"papercraft-mcp-instructions\" }) once per session.
- pageId defaults to the page the user is viewing; pass it to work on another page without \
switching the user's view. Node ids are opaque strings; never show them to the user.
- Build UI with create_artboard, then write_html into it, one visual group per call (a header, \
@@ -22,6 +23,11 @@ padding, align-items, justify-content) is the layout model; no margin, grid or t
- Edit with update_styles, set_text_content, rename_nodes, duplicate_nodes, move_nodes, \
delete_nodes. Whiteboard shapes, sticky notes and arrows: create_shape. Hand-drawn style: \
update_styles with {\"sketch\": true, \"roughness\": 1, \"fillStyle\": \"hachure\"}.
- Don't write colors as hex in designs: set_variables once (surface, text, accent, ...), then \
use var(--name). Build repeated parts (button, row, chip) once as components \
(create_artboard + create_component) and place them with create_instance, overriding text \
with set_text_content. Icons: search_icons, then <x-paper-icon name=\"phosphor:gear\"/> in \
write_html. Name every layer (layer-name) like a real file.\n\
- Check your work with get_screenshot. Artboard height is a starting point: when content \
clips set height: \"fit-content\" with update_styles.
- Call finish_working_on_nodes when done.";
@@ -81,7 +87,7 @@ pub fn definitions() -> Vec<Value> {
// ------------------------------------------------------------ projects and pages
tool(
"list_resources",
"Lists the user's paper-rs projects (local folders), most recently modified first. \
"Lists the user's papercraft projects (local folders), most recently modified first. \
Each item has id, name, path, page count, updatedAt, and `open` for the project open \
in the app.",
json!({"limit": {"type": "integer", "description": "Maximum projects to return. Defaults to 50."}}),
@@ -229,7 +235,7 @@ pub fn definitions() -> Vec<Value> {
),
tool(
"get_font_family_info",
"Whether font families are available and their weights/styles. paper-rs renders text \
"Whether font families are available and their weights/styles. papercraft renders text \
with Inter (400, 700, normal and italic); other families fall back to Inter.",
json!({"familyNames": {"type": "array", "items": {"type": "string"}}}),
&["familyNames"],
@@ -237,7 +243,7 @@ pub fn definitions() -> Vec<Value> {
),
tool(
"get_guide",
"Read a guide. Topics: \"paper-rs-mcp-instructions\" (read first), \"html\" (what \
"Read a guide. Topics: \"papercraft-mcp-instructions\" (read first), \"html\" (what \
write_html supports), \"whiteboard\" (shapes, arrows, stickies, hand-drawn style).",
json!({"topic": {"type": "string"}}),
&["topic"],
@@ -314,6 +320,9 @@ pub fn definitions() -> Vec<Value> {
fit-content).\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; \
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\
Returns the ids of the created top-level layers and the styles that were ignored.",
@@ -393,6 +402,154 @@ pub fn definitions() -> Vec<Value> {
&["updates"],
false,
),
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.",
json!({"nodeId": {"type": "string", "description": "Only this layer."},
"pageId": page_id(),
"includeResolved": {"type": "boolean"}}),
&[],
true,
),
tool(
"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.",
json!({"nodeId": {"type": "string"}, "text": {"type": "string"},
"author": {"type": "string"}, "pageId": page_id()}),
&["nodeId", "text"],
false,
),
tool(
"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.",
json!({"nodeId": {"type": "string"}, "commentId": {"type": "number"},
"reply": {"type": "string"}, "delete": {"type": "boolean"},
"pageId": page_id()}),
&["nodeId"],
false,
),
tool(
"lint_design",
"Check a page or subtree for common design mistakes and get a fix for each: text \
contrast (WCAG 4.5:1, 3:1 for large text), text under 12px, font sizes off the type \
scale, gap/padding off the 4px grid, buttons and tabs under 44px tall, default layer \
names (Frame, Rectangle), and hard-coded colors that equal a variable. Run it before \
finish_working_on_nodes and fix errors and warnings; `info` items are suggestions.",
json!({"nodeId": {"type": "string", "description": "Subtree to check; omit for the whole page."},
"scale": {"type": "array", "items": {"type": "number"},
"description": "Allowed font sizes. Default 12, 14, 16, 20, 24, 32, 40, 48, 64."},
"pageId": page_id()}),
&[],
true,
),
tool(
"get_variables",
"Color variables of the page's project (name -> hex). Fills that follow a variable \
were written with `var(--name)`; set_variables moves all of them at once. \
`format` css returns a `:root { --name: #hex }` block, json a design-tokens object.",
json!({"pageId": page_id(),
"format": {"type": "string", "enum": ["css", "json"]}}),
&[],
true,
),
tool(
"set_variables",
"Define or change color variables (names like `surface`, `accent-text`), on every \
page. Use them as `var(--name)` in write_html / update_styles / create_artboard \
colors (backgroundColor, background, color) instead of hex literals, so a theme \
change is one call. Fills already following a changed variable are updated. \
Variables apply to fills and text color; strokes and gradients keep fixed colors.",
json!({"variables": {"type": "object", "propertyNames": {"type": "string"},
"additionalProperties": {"type": "string"},
"description": "{\"accent\": \"#cba6f7\", \"surface\": \"#313244\"}"},
"mode": {"type": "string", "description": "Store the values for this mode (e.g. \"Light\", \"Dark\"); they apply when it is the active mode. The first mode you define becomes active."}}),
&["variables"],
false,
),
tool(
"set_tokens",
"Define non-color design tokens (spacing, radius, font size, shadows, widths), on every \
page: {\"spacing-md\": \"16px\", \"radius-md\": \"8px\", \"shadow-md\": \"0 4px 12px \
rgba(0,0,0,0.08)\"}. Use `var(--spacing-md)` in any style value (padding: \
var(--spacing-sm) var(--spacing-md), boxShadow: var(--shadow-md)); it is replaced by the \
value when written, so tokens are a shared vocabulary, not live links. Listed by \
get_variables.",
json!({"tokens": {"type": "object", "propertyNames": {"type": "string"},
"additionalProperties": {"anyOf": [{"type": "string"}, {"type": "number"}]}}}),
&["tokens"],
false,
),
tool(
"set_variable_mode",
"Switch the active variable mode (defined with set_variables {mode}): every variable \
takes that mode's value and all fills following them change, on every page. One call \
turns a whole design from light to dark.",
json!({"mode": {"type": "string"}}),
&["mode"],
false,
),
tool(
"get_components",
"Main components on a page (id, name, size) with how many instances each has.",
json!({"pageId": page_id()}),
&[],
true,
),
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.",
json!({"fromPageId": {"type": "string"}, "pageId": page_id(),
"names": {"type": "array", "items": {"type": "string"},
"description": "Only these components (default all)."}}),
&["fromPageId"],
false,
),
tool(
"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.",
json!({"nodeId": {"type": "string", "description": "The frame to convert."},
"name": {"type": "string", "description": "Optional new layer name."},
"pageId": page_id()}),
&["nodeId"],
false,
),
tool(
"create_instance",
"Place an instance of a component. Edit text and styles on the instance's descendants \
with set_text_content / update_styles (they become overrides); edits to the main \
component flow to every instance. Returns the instance id and its text layers.",
json!({"componentId": {"type": "string"},
"parentId": {"type": "string", "description": "Parent frame; omit or \"root\" for the page."},
"index": {"type": "number", "description": "Position among the parent's children."},
"x": {"type": "number"}, "y": {"type": "number"},
"name": {"type": "string"},
"pageId": page_id()}),
&["componentId"],
false,
),
tool(
"search_icons",
"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`.",
json!({"query": {"type": "string"}, "set": {"type": "string"},
"limit": {"type": "number", "description": "Default 20."}}),
&[],
true,
),
tool(
"delete_nodes",
"Delete nodes and their descendants. Verify parents with get_node_info first when unsure.",
@@ -464,7 +621,7 @@ pub fn definitions() -> Vec<Value> {
}
pub const GUIDE_INSTRUCTIONS: &str = "\
# Designing in paper-rs over MCP
# Designing in papercraft over MCP
1. Orient: get_basic_info → pages, artboards, sizes; get_selection → what the user points at.
2. Plan a brief before a new design: palette (3–5 colors), type scale (e.g. 12/14/16/20/28/40),
+44 -2
View File
@@ -1,9 +1,9 @@
//! The document arena: nodes keyed by id plus ordered child lists.
use super::node::{Node, NodeId};
use super::node::{Color, Node, NodeId};
use crate::geom::{Rect, V2};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::collections::{BTreeMap, HashMap};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Document {
@@ -12,6 +12,19 @@ pub struct Document {
/// Top-level nodes in paint order, bottom to top.
pub root: Vec<NodeId>,
pub next_id: u64,
/// Named colors that fills can follow (`var(--name)` in CSS). Empty for most documents.
#[serde(default)]
pub variables: BTreeMap<String, Color>,
/// Values of the variables per mode (e.g. "Light", "Dark"); `variables` holds the active one.
#[serde(default)]
pub modes: BTreeMap<String, BTreeMap<String, Color>>,
/// The active mode, if modes are in use.
#[serde(default)]
pub mode: Option<String>,
/// Non-color design tokens (`spacing-md` = `16px`, `shadow-sm` = `0 1px 2px rgba(0,0,0,.06)`),
/// substituted for `var(--name)` when styles are written.
#[serde(default)]
pub tokens: BTreeMap<String, String>,
}
impl Default for Document {
@@ -21,11 +34,40 @@ impl Default for Document {
nodes: HashMap::new(),
root: Vec::new(),
next_id: 1,
variables: BTreeMap::new(),
modes: BTreeMap::new(),
mode: None,
tokens: BTreeMap::new(),
}
}
}
/// Everything about variables that undo has to restore together.
#[derive(Debug, Clone, PartialEq)]
pub struct VarState {
pub values: BTreeMap<String, Color>,
pub modes: BTreeMap<String, BTreeMap<String, Color>>,
pub mode: Option<String>,
pub tokens: BTreeMap<String, String>,
}
impl Document {
pub fn var_state(&self) -> VarState {
VarState {
values: self.variables.clone(),
modes: self.modes.clone(),
mode: self.mode.clone(),
tokens: self.tokens.clone(),
}
}
pub fn restore_vars(&mut self, s: &VarState) {
self.variables = s.values.clone();
self.modes = s.modes.clone();
self.mode = s.mode.clone();
self.tokens = s.tokens.clone();
}
pub fn get(&self, id: NodeId) -> Option<&Node> {
self.nodes.get(&id)
}
+3 -3
View File
@@ -3,9 +3,9 @@ pub mod node;
pub mod paint;
pub mod rough;
pub use document::Document;
pub use document::{Document, VarState};
pub use node::{
Binding, Color, FillRule, Head, Node, NodeId, NodeKind, PathPoint, Route, StrokePoint, Style,
SubPath, TextAlign,
Binding, Color, Comment, FillRule, Head, Node, NodeId, NodeKind, PathPoint, Route, Shadow,
StrokePoint, Style, SubPath, TextAlign,
};
pub use paint::*;
+40 -1
View File
@@ -108,6 +108,19 @@ pub struct Style {
/// Hand-drawn (rough.js style) rendering.
#[serde(skip_serializing_if = "Option::is_none")]
pub rough: Option<super::rough::Rough>,
/// Drop shadow (CSS `box-shadow`, first layer only).
#[serde(skip_serializing_if = "Option::is_none")]
pub shadow: Option<Shadow>,
}
/// A soft drop shadow behind a box.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Shadow {
pub x: f64,
pub y: f64,
pub blur: f64,
pub spread: f64,
pub color: Color,
}
fn is_default<T: Default + PartialEq>(t: &T) -> bool {
@@ -129,6 +142,7 @@ impl Default for Style {
opacity: 1.0,
blend: BlendMode::Normal,
rough: None,
shadow: None,
}
}
}
@@ -143,7 +157,7 @@ impl Style {
/// The top solid fill color, if the top fill is solid.
pub fn fill(&self) -> Option<Color> {
match self.fills.last() {
Some(Paint::Solid { color }) => Some(*color),
Some(Paint::Solid { color, .. }) => Some(*color),
_ => None,
}
}
@@ -396,6 +410,30 @@ pub struct Node {
/// Instance link (instance roots and every node inside an instance).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub instance: Option<InstanceLink>,
/// Feedback left on this layer by the user or an agent (see `get_feedback`).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub comments: Vec<Comment>,
}
/// A note attached to a layer: what to change, from the user to an agent or back.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Comment {
/// Unique within the layer.
pub id: u32,
pub text: String,
/// "user" or "agent".
pub author: String,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub resolved: bool,
/// What was done about it, set when resolved.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reply: Option<String>,
}
impl Node {
pub fn open_comments(&self) -> usize {
self.comments.iter().filter(|c| !c.resolved).count()
}
}
/// One anchor of a vector path, with optional bezier handles (node-local, absolute).
@@ -472,6 +510,7 @@ impl Node {
layout_child: LayoutChild::default(),
component: false,
instance: None,
comments: Vec::new(),
}
}
+34 -2
View File
@@ -37,6 +37,9 @@ pub enum ImageMode {
pub enum Paint {
Solid {
color: Color,
/// The document variable this color follows (`Document::variables`), if any.
#[serde(default, skip_serializing_if = "Option::is_none")]
var: Option<String>,
},
Linear {
stops: Vec<Stop>,
@@ -61,7 +64,10 @@ pub enum Paint {
impl Paint {
pub fn solid(c: Color) -> Paint {
Paint::Solid { color: c }
Paint::Solid {
color: c,
var: None,
}
}
pub fn linear(a: Color, b: Color) -> Paint {
Paint::Linear {
@@ -77,6 +83,32 @@ impl Paint {
radius: v2(0.5, 0.5),
}
}
/// The document variable a solid fill follows.
pub fn var(&self) -> Option<&str> {
match self {
Paint::Solid { var, .. } => var.as_deref(),
_ => None,
}
}
/// Change a solid fill's color (keeps its variable link).
pub fn set_solid_color(&mut self, c: Color) {
if let Paint::Solid { color, .. } = self {
*color = c;
}
}
/// Stop following a variable (keeps the current color).
pub fn unlink_var(&mut self) {
if let Paint::Solid { var, .. } = self {
*var = None;
}
}
/// A solid fill that follows document variable `name`.
pub fn solid_var(c: Color, name: &str) -> Paint {
Paint::Solid {
color: c,
var: Some(name.to_string()),
}
}
pub fn kind_name(&self) -> &'static str {
match self {
Paint::Solid { .. } => "Solid",
@@ -88,7 +120,7 @@ impl Paint {
/// A representative color (for swatches and simplified renderers).
pub fn preview_color(&self) -> Color {
match self {
Paint::Solid { color } => *color,
Paint::Solid { color, .. } => *color,
Paint::Linear { stops, .. } | Paint::Radial { stops, .. } => {
stops.first().map_or(Color::BLACK, |s| s.color)
}
+26 -4
View File
@@ -6,14 +6,14 @@
//! unit-testable on its own.
use crate::geom::Camera;
use crate::model::{Document, Node, NodeId};
use crate::model::{Color, Document, Node, NodeId};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::HashMap;
use std::collections::{BTreeMap, HashMap};
pub const PROJECT_FORMAT: &str = "paper-rs-project";
pub const PAGE_FORMAT: &str = "paper-rs-page";
/// The single-file format of paper-rs 0.1 (`.paper-rs`), version 1. It migrates to a page.
/// The single-file format of papercraft 0.1 (`.paper-rs`), version 1. It migrates to a page.
pub const LEGACY_FORMAT: &str = "paper-rs";
pub const PROJECT_VERSION: u32 = 1;
@@ -34,7 +34,7 @@ impl std::fmt::Display for FormatError {
match self {
FormatError::Io(e) => write!(f, "I/O error: {e}"),
FormatError::Json(e) => write!(f, "malformed file: {e}"),
FormatError::WrongFormat(s) => write!(f, "not a paper-rs file (format {s:?})"),
FormatError::WrongFormat(s) => write!(f, "not a papercraft file (format {s:?})"),
FormatError::TooNew { version, supported } => write!(
f,
"file version {version} is newer than this app supports ({supported})"
@@ -126,6 +126,14 @@ struct PageFile {
next_id: u64,
root: Vec<NodeId>,
nodes: Vec<Node>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
variables: BTreeMap<String, Color>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
modes: BTreeMap<String, BTreeMap<String, Color>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
mode: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
tokens: BTreeMap<String, String>,
}
/// Page migrations, indexed by source version: `PAGE_STEPS[0]` turns v1 into v2.
@@ -174,6 +182,10 @@ pub fn page_to_json(p: &PageData) -> String {
next_id: p.doc.next_id,
root: p.doc.root.clone(),
nodes,
variables: p.doc.variables.clone(),
modes: p.doc.modes.clone(),
mode: p.doc.mode.clone(),
tokens: p.doc.tokens.clone(),
};
serde_json::to_string_pretty(&f).expect("page serializes")
}
@@ -190,6 +202,10 @@ pub fn page_from_json(s: &str) -> Result<PageData, FormatError> {
nodes,
root: f.root,
next_id: f.next_id.max(max_id + 1),
variables: f.variables,
modes: f.modes,
mode: f.mode,
tokens: f.tokens,
};
doc.validate().map_err(FormatError::Invalid)?;
Ok(PageData {
@@ -236,6 +252,12 @@ fn migrate(
// ---------------------------------------------------------------------------------- helpers
#[cfg(target_arch = "wasm32")]
pub fn now_ms() -> u64 {
js_sys::Date::now() as u64
}
#[cfg(not(target_arch = "wasm32"))]
pub fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
+39 -31
View File
@@ -73,20 +73,27 @@ pub fn folder_name(name: &str) -> String {
}
impl Store {
/// `$PAPER_RS_HOME`, else `~/Documents/paper-rs`.
/// `$PAPERCRAFT_HOME`, else `~/Documents/papercraft` (`~/Documents/paper-rs` when only that exists).
pub fn default_root() -> PathBuf {
if let Some(p) = std::env::var_os("PAPER_RS_HOME") {
if let Some(p) = std::env::var_os("PAPERCRAFT_HOME") {
return PathBuf::from(p);
}
dirs::document_dir()
let docs = dirs::document_dir()
.or_else(dirs::home_dir)
.unwrap_or_else(|| PathBuf::from("."))
.join("paper-rs")
.unwrap_or_else(|| PathBuf::from("."));
// Projects made before the rename (paper-rs) stay where they are.
let legacy = docs.join("paper-rs");
let root = docs.join("papercraft");
if legacy.is_dir() && !root.exists() {
legacy
} else {
root
}
}
pub fn open(root: impl Into<PathBuf>) -> std::io::Result<Store> {
let root = root.into();
std::fs::create_dir_all(&root)?;
crate::vfs::create_dir_all(&root)?;
Ok(Store { root })
}
@@ -96,7 +103,7 @@ impl Store {
/// Projects, most recently modified first. Unreadable folders are skipped.
pub fn list(&self) -> Vec<Entry> {
let mut out: Vec<Entry> = std::fs::read_dir(&self.root)
let mut out: Vec<Entry> = crate::vfs::read_dir(&self.root)
.into_iter()
.flatten()
.flatten()
@@ -106,7 +113,7 @@ impl Store {
let manifest = read_manifest(&dir).ok()?;
let thumb = dir.join(THUMBNAIL);
Some(Entry {
thumbnail: thumb.is_file().then_some(thumb),
thumbnail: crate::vfs::is_file(&thumb).then_some(thumb),
dir,
manifest,
})
@@ -126,7 +133,7 @@ impl Store {
let base = folder_name(name);
let mut p = parent.join(&base);
let mut i = 2;
while p.exists() {
while crate::vfs::exists(&p) {
p = parent.join(format!("{base} {i}"));
i += 1;
}
@@ -138,7 +145,7 @@ impl Store {
let taken: Vec<String> = self.list().into_iter().map(|e| e.manifest.name).collect();
let mut n = name.to_string();
let mut i = 2;
while taken.contains(&n) || self.root.join(folder_name(&n)).exists() {
while taken.contains(&n) || crate::vfs::exists(&self.root.join(folder_name(&n))) {
n = format!("{name} {i}");
i += 1;
}
@@ -149,7 +156,7 @@ impl Store {
pub fn create(&self, name: &str) -> Result<(Project, PageData), FormatError> {
let name = self.unique_name(name);
let dir = self.unique_dir(&self.root, &name);
std::fs::create_dir_all(dir.join(PAGES_DIR))?;
crate::vfs::create_dir_all(dir.join(PAGES_DIR))?;
let manifest = Manifest::new(&name);
let page = PageData {
id: manifest.pages[0].id.clone(),
@@ -174,7 +181,7 @@ impl Store {
) -> Result<Project, FormatError> {
let name = self.unique_name(name);
let dir = self.unique_dir(&self.root, &name);
std::fs::create_dir_all(dir.join(PAGES_DIR))?;
crate::vfs::create_dir_all(dir.join(PAGES_DIR))?;
let mut manifest = Manifest::new(&name);
manifest.pages = pages
.iter()
@@ -207,12 +214,12 @@ impl Store {
if wanted == dir {
return Ok(dir.to_path_buf());
}
let target = if wanted.exists() {
let target = if crate::vfs::exists(&wanted) {
self.unique_dir(&self.root, new_name)
} else {
wanted
};
std::fs::rename(dir, &target)?;
crate::vfs::rename(dir, &target)?;
Ok(target)
}
@@ -237,26 +244,26 @@ impl Store {
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
std::fs::create_dir_all(self.trash_root())?;
crate::vfs::create_dir_all(self.trash_root())?;
let deleted = now_ms();
let target = self
.trash_root()
.join(format!("{}~{deleted}", folder_name(&original)));
std::fs::rename(dir, &target)?;
crate::vfs::rename(dir, &target)?;
let info = serde_json::json!({ "deleted": deleted, "original": original });
atomic::write(&target.join(TRASH_INFO), info.to_string().as_bytes())?;
Ok(target)
}
pub fn list_trash(&self) -> Vec<TrashEntry> {
let mut out: Vec<TrashEntry> = std::fs::read_dir(self.trash_root())
let mut out: Vec<TrashEntry> = crate::vfs::read_dir(self.trash_root())
.into_iter()
.flatten()
.flatten()
.filter_map(|e| {
let dir = e.path();
let manifest = read_manifest(&dir).ok()?;
let info: serde_json::Value = std::fs::read_to_string(dir.join(TRASH_INFO))
let info: serde_json::Value = crate::vfs::read_to_string(dir.join(TRASH_INFO))
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
@@ -283,8 +290,8 @@ impl Store {
.find(|e| e.dir == trash_dir)
.ok_or_else(|| FormatError::Invalid("not in trash".into()))?;
let target = self.unique_dir(&self.root, &entry.original);
std::fs::rename(trash_dir, &target)?;
let _ = std::fs::remove_file(target.join(TRASH_INFO));
crate::vfs::rename(trash_dir, &target)?;
let _ = crate::vfs::remove_file(target.join(TRASH_INFO));
Ok(target)
}
@@ -294,7 +301,7 @@ impl Store {
"refusing to delete outside the trash",
));
}
std::fs::remove_dir_all(trash_dir)
crate::vfs::remove_dir_all(trash_dir)
}
pub fn empty_trash(&self) -> std::io::Result<()> {
@@ -306,18 +313,18 @@ impl Store {
}
fn read_manifest(dir: &Path) -> Result<Manifest, FormatError> {
Manifest::from_json(&std::fs::read_to_string(dir.join(MANIFEST))?)
Manifest::from_json(&crate::vfs::read_to_string(dir.join(MANIFEST))?)
}
fn copy_dir(from: &Path, to: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(to)?;
for e in std::fs::read_dir(from)? {
crate::vfs::create_dir_all(to)?;
for e in crate::vfs::read_dir(from)? {
let e = e?;
let target = to.join(e.file_name());
if e.file_type()?.is_dir() {
copy_dir(&e.path(), &target)?;
} else {
std::fs::copy(e.path(), target)?;
crate::vfs::copy(e.path(), target)?;
}
}
Ok(())
@@ -339,7 +346,7 @@ impl Project {
}
pub fn load_page(&self, id: &str) -> Result<PageData, FormatError> {
let mut p = format::page_from_json(&std::fs::read_to_string(self.page_path(id))?)?;
let mut p = format::page_from_json(&crate::vfs::read_to_string(self.page_path(id))?)?;
if let Some(r) = self.manifest.pages.iter().find(|r| r.id == id) {
// The manifest owns page names (renames touch only the manifest).
p.name = r.name.clone();
@@ -372,7 +379,7 @@ impl Project {
}
pub fn save_page(&self, page: &PageData) -> Result<(), FormatError> {
std::fs::create_dir_all(self.dir.join(PAGES_DIR))?;
crate::vfs::create_dir_all(self.dir.join(PAGES_DIR))?;
atomic::write(
&self.page_path(&page.id),
format::page_to_json(page).as_bytes(),
@@ -381,7 +388,7 @@ impl Project {
}
pub fn delete_page_file(&self, id: &str) {
let _ = std::fs::remove_file(self.page_path(id));
let _ = crate::vfs::remove_file(self.page_path(id));
}
pub fn write_manifest(&self) -> Result<(), FormatError> {
@@ -399,7 +406,8 @@ mod tests {
use super::*;
pub fn temp_store() -> Store {
let root = std::env::temp_dir().join(format!("paper-rs-store-{:016x}", fastrand::u64(..)));
let root =
std::env::temp_dir().join(format!("papercraft-store-{:016x}", fastrand::u64(..)));
Store::open(root).unwrap()
}
@@ -439,7 +447,7 @@ mod tests {
s.empty_trash().unwrap();
assert!(s.list_trash().is_empty());
assert!(s.delete_forever(&s.root.join("Alpha")).is_err());
std::fs::remove_dir_all(&s.root).ok();
crate::vfs::remove_dir_all(&s.root).ok();
}
#[test]
@@ -466,7 +474,7 @@ mod tests {
assert_eq!(pages[0], page);
assert!(pages[1].doc.nodes.is_empty());
assert_eq!(pages[1].name, "Ghost");
std::fs::remove_dir_all(&s.root).ok();
crate::vfs::remove_dir_all(&s.root).ok();
}
#[test]
+32 -1
View File
@@ -173,7 +173,7 @@ pub fn image_place(mode: ImageMode, wr: Rect, iw: f64, ih: f64) -> Rect {
pub fn paint_spec(p: &Paint, wr: Rect, opacity: f64) -> Option<PaintSpec> {
let at = |u: V2| v2(wr.x + u.x * wr.w, wr.y + u.y * wr.h);
Some(match p {
Paint::Solid { color } => PaintSpec::Solid(rgba(*color, opacity)),
Paint::Solid { color, .. } => PaintSpec::Solid(rgba(*color, opacity)),
Paint::Linear { stops, from, to } => PaintSpec::Linear {
p0: at(*from),
p1: at(*to),
@@ -223,6 +223,36 @@ fn sorted(stops: &[Stop]) -> Vec<Stop> {
s
}
/// A soft shadow as stacked translucent shapes: each layer is a little larger and the layers add up to
/// the shadow color's alpha under the box, fading to nothing across the blur radius.
fn shadow_ops(n: &Node, wr: Rect, opacity: f64, ops: &mut Vec<DrawOp>) {
const LAYERS: usize = 10;
let Some(s) = n.style.shadow else { return };
if s.color.a == 0 || !is_area(n) || wr.w <= 0.0 || wr.h <= 0.0 {
return;
}
let base = wr.translate(v2(s.x, s.y));
let radii = if matches!(n.kind, NodeKind::Ellipse) {
[wr.w.min(wr.h) * 0.5; 4]
} else {
n.style.corner_radii()
};
let solid = Color::rgb(s.color.r, s.color.g, s.color.b);
let each = opacity * (s.color.a as f64 / 255.0) / LAYERS as f64;
for i in 0..LAYERS {
let e = s.spread + s.blur * ((i as f64 + 0.5) / LAYERS as f64 - 0.5);
let r = base.inflate(e);
if r.w <= 0.0 || r.h <= 0.0 {
continue;
}
ops.push(DrawOp::Fill {
path: vector::rounded_rect(r, radii.map(|x| (x + e).max(0.0))),
paint: PaintSpec::Solid(rgba(solid, each)),
even_odd: false,
});
}
}
/// Draw ops for one node (not its children), in world space.
pub fn node_draw(n: &Node, wr: Rect, opacity: f64) -> NodeDraw {
let mut ops = Vec::new();
@@ -233,6 +263,7 @@ pub fn node_draw(n: &Node, wr: Rect, opacity: f64) -> NodeDraw {
blend: n.style.blend,
};
}
shadow_ops(n, wr, opacity, &mut ops);
match &n.kind {
NodeKind::Group => {}
NodeKind::Text { .. } => text_ops(n, wr, opacity, &mut ops),
+1 -1
View File
@@ -74,7 +74,7 @@ impl Painter {
let bg = if self.transparent {
[0.0; 4]
} else {
scene::CANVAS_BG.to_f32(1.0)
scene::canvas_colors().0.to_f32(1.0)
};
self.canvas.render(
device,
+105 -13
View File
@@ -742,7 +742,7 @@ impl Headless {
.map_err(|e| format!("no GPU adapter available: {e}"))?;
let adapter_info = adapter.get_info();
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
label: Some("paper-rs headless"),
label: Some("papercraft headless"),
required_limits: wgpu::Limits::downlevel_defaults().using_resolution(adapter.limits()),
..Default::default()
}))
@@ -772,8 +772,8 @@ impl Headless {
})
}
/// Copy a texture back to the CPU as RGBA8.
pub fn read(&self, tex: &wgpu::Texture) -> image::RgbaImage {
/// Copy `tex` into a mappable buffer and submit; returns the buffer and its row stride.
fn start_readback(&self, tex: &wgpu::Texture) -> (wgpu::Buffer, u32) {
let (w, h) = (tex.width(), tex.height());
let row = (w * 4).div_ceil(256) * 256;
let buf = self.device.create_buffer(&wgpu::BufferDescriptor {
@@ -805,12 +805,15 @@ impl Headless {
},
);
self.queue.submit([enc.finish()]);
let slice = buf.slice(..);
slice.map_async(wgpu::MapMode::Read, |_| {});
self.device
.poll(wgpu::PollType::wait_indefinitely())
.expect("poll");
let data = slice.get_mapped_range().expect("mapped readback buffer");
(buf, row)
}
/// Strip the row padding off a mapped readback buffer.
fn unpad(buf: &wgpu::Buffer, row: u32, w: u32, h: u32) -> image::RgbaImage {
let data = buf
.slice(..)
.get_mapped_range()
.expect("mapped readback buffer");
let mut img = image::RgbaImage::new(w, h);
for y in 0..h {
let src = &data[(y * row) as usize..(y * row + w * 4) as usize];
@@ -819,14 +822,38 @@ impl Headless {
img
}
/// Render a canvas frame into a new image of `size` physical px.
pub fn render_frame(
/// Copy a texture back to the CPU as RGBA8.
pub fn read(&self, tex: &wgpu::Texture) -> image::RgbaImage {
let (buf, row) = self.start_readback(tex);
buf.slice(..).map_async(wgpu::MapMode::Read, |_| {});
self.device
.poll(wgpu::PollType::wait_indefinitely())
.expect("poll");
Self::unpad(&buf, row, tex.width(), tex.height())
}
/// [`Headless::read`] without blocking: the browser cannot wait on the GPU, so the
/// buffer is mapped with a callback and the caller awaits it (the JS event loop drives it).
pub async fn read_async(&self, tex: &wgpu::Texture) -> Result<image::RgbaImage, String> {
let (buf, row) = self.start_readback(tex);
let (tx, rx) = oneshot();
buf.slice(..)
.map_async(wgpu::MapMode::Read, move |r| tx.send(r));
// WebGPU resolves the mapping from the JS event loop; the WebGL backend only delivers
// callbacks when polled, so nudge it (never blocks).
let _ = self.device.poll(wgpu::PollType::Poll);
rx.await.map_err(|e| format!("GPU readback failed: {e}"))?;
Ok(Self::unpad(&buf, row, tex.width(), tex.height()))
}
/// Draw a canvas frame into a new offscreen texture (not yet read back).
fn draw_frame(
&self,
frame: &CanvasFrame,
size: [u32; 2],
scale: f32,
clear: [f64; 4],
) -> image::RgbaImage {
) -> wgpu::Texture {
let tex = self.target(size);
let view = tex.create_view(&Default::default());
let mut r = CanvasRenderer::new(&self.device, OFFSCREEN_FORMAT);
@@ -842,6 +869,71 @@ impl Headless {
clear,
);
self.queue.submit([enc.finish()]);
self.read(&tex)
tex
}
/// Render a canvas frame into a new image of `size` physical px.
pub fn render_frame(
&self,
frame: &CanvasFrame,
size: [u32; 2],
scale: f32,
clear: [f64; 4],
) -> image::RgbaImage {
self.read(&self.draw_frame(frame, size, scale, clear))
}
/// [`Headless::render_frame`] for the browser (see [`Headless::read_async`]).
pub async fn render_frame_async(
&self,
frame: &CanvasFrame,
size: [u32; 2],
scale: f32,
clear: [f64; 4],
) -> Result<image::RgbaImage, String> {
self.read_async(&self.draw_frame(frame, size, scale, clear))
.await
}
}
/// Minimal one-shot channel: wgpu's map callback on one end, an awaiting task on the other.
/// (No async runtime exists in the browser build, and `tokio::sync::oneshot` is native-only.)
struct Slot<T> {
value: Option<T>,
waker: Option<std::task::Waker>,
}
struct Sender<T>(std::sync::Arc<std::sync::Mutex<Slot<T>>>);
struct Receiver<T>(std::sync::Arc<std::sync::Mutex<Slot<T>>>);
fn oneshot<T>() -> (Sender<T>, Receiver<T>) {
let slot = std::sync::Arc::new(std::sync::Mutex::new(Slot {
value: None,
waker: None,
}));
(Sender(slot.clone()), Receiver(slot))
}
impl<T> Sender<T> {
fn send(self, v: T) {
let mut s = self.0.lock().expect("oneshot lock");
s.value = Some(v);
if let Some(w) = s.waker.take() {
w.wake();
}
}
}
impl<T> std::future::Future for Receiver<T> {
type Output = T;
fn poll(self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> std::task::Poll<T> {
let mut s = self.0.lock().expect("oneshot lock");
match s.value.take() {
Some(v) => std::task::Poll::Ready(v),
None => {
s.waker = Some(cx.waker().clone());
std::task::Poll::Pending
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More