36 Commits

Author SHA1 Message Date
Marcos Paulo 24c2c2ad5f chore(release): 0.9.0
release / test (push) Has been cancelled
release / windows (push) Has been cancelled
release / macos (push) Has been cancelled
release / linux (push) Has been cancelled
release / publish (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:47:12 -03:00
Marcos Paulo 1b056f0f01 perf: cut chat and image memory, wrap long answer chips
Chat screenshots move to files (kept 14 days), the history list holds no
messages, thumbnails stay small, canvas images decode only for the GPU and
leave it when idle, and long answer options wrap instead of widening the chat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:47:02 -03:00
Marcos Paulo 13503d4c19 feat(mcp): wire the new tools and document them
export_presentation, critique_design, design_lessons, claim_nodes,
release_claims, list_claims and undo/redo scope; 94 tools.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:47:02 -03:00
Marcos Paulo a9a719f1ef feat(ui): page icons, a searchable pages list, sliding layer buttons
Pages can carry an icon, the pages popover filters by name, a layer row's eye
and lock slide in only while shown, and agent outlines fade sooner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:47:02 -03:00
Marcos Paulo e4639054e7 feat(mcp): let several agents share one project safely
Named CLI agents (--as, --parent), conflict reports and ifRev on writes,
claims that fence layers off, delegate build scopes and per-agent undo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:47:02 -03:00
Marcos Paulo 264629ed47 feat(mcp): critique designs through reviewer personas and keep approved lessons
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:47:02 -03:00
Marcos Paulo 5428122609 feat(screens): keep a screen's device height and add sections
A screen made at a device width keeps that device's height and scrolls what
does not fit. Sections group screens on the canvas, drawn as a faint outline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:47:02 -03:00
Marcos Paulo 8d3306ea1f feat(ui-kits): add shadcn, MUI, Mantine, Bootstrap and Chakra kits
The four derived kits reuse the shadcn markup with their own tokens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:47:02 -03:00
Marcos Paulo b64a779619 feat(mcp): make write_html follow the browser more closely
Rewrites common unsupported CSS into what the canvas has, makes room when a
frame grows into the ones below it, keeps growing text in hugging rows at its
width, and lints what still differs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:47:02 -03:00
Marcos Paulo 975dcfe023 feat(io): export the screens as a presentation deck
A PDF with a cover, contents and a slide per screen, from the File menu, the
palette, the inspector's Export section and `--format presentation`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:47:02 -03:00
Marcos Paulo 74ba94b66f fix(web): give config_dir a wasm variant so the web build compiles
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:47:02 -03:00
Marcos Paulo 580fa9ecc3 docs(skills): teach the incremental check loop in the papercraft skill
The skill's lint step now says to lint only what changed between edits
(rev, changedOnly, since), to read geometry with get_bounds and changes with
get_changes, and to finish with one full lint and a screenshot per mode. The
several-agents section uses get_changes {exclude: "self"}, and the cheat
sheet lists both new tools and the overflow rule. This replaces the pending
snippet file.

The reference and docs/agents.md now match the tools as merged: lint_design
reports coverage under `covered`, and get_bounds returns columns and rows
with continueWith when truncated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 18:53:27 -03:00
Marcos Paulo 27e6765a43 docs: mind the build machine and keep full test runs on CI
RULES.md gains "The machine you build on": check memory, load and running
builds first, cap Cargo jobs and test threads, run at low priority, one heavy
job at a time, stop when the person says the machine is slow, and clean up
scratch targets. Locally only the test targets a change touches run; the full
suite runs on GitHub before a release. AGENTS.md, CONTRIBUTING.md and the PR
checklist say the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 18:50:31 -03:00
Marcos Paulo 9de7c4d8ae ci: run the full test suite before every release
test.yml runs cargo test and the wasm check on a Windows runner. The release
workflow calls it and publishes only when it passes; it can also be run on
its own with `gh workflow run test.yml -f ref=<branch>`. Like the release
jobs, it only runs on github.com.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 18:50:31 -03:00
Marcos Paulo a98bed0da7 test: pass tool arguments to log_agent in the UI smoke test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 18:50:07 -03:00
Marcos Paulo e876035fc6 fix: reset the change log on every project switch
Editor::load replaced the change log with a fresh one, so rev restarted at 0
when a project was opened from the app. Once the new project caught up, an
agent's old `since` looked current and get_changes reported a partial list
instead of `truncated`. The log now carries across load and resets there,
which covers the app, the session and MCP alike.

The MCP-only reset after create_file goes: that tool creates a project
without opening it. Tests now open the project with open_file and read the
rev before writing instead of assuming the log starts at 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 18:50:07 -03:00
Marcos Paulo f4fd3a8607 docs: count 88 tools after get_changes and get_bounds
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 18:10:05 -03:00
Marcos Paulo 83c644d2bf Merge branch 'worktree-agent-a8158d642a885966f' into integration/incremental-lint 2026-10-28 18:09:56 -03:00
Marcos Paulo 1d4eb97d98 Merge branch 'worktree-agent-aaa0fa0d394a3b41c' into integration/incremental-lint 2026-10-28 18:09:55 -03:00
Marcos Paulo a7302326f2 Merge branch 'worktree-agent-afce846aed3239911' into integration/incremental-lint
# Conflicts:
#	PROGRESS.md
2026-10-28 18:09:55 -03:00
Marcos Paulo 72931c5220 Merge branch 'worktree-agent-ae1f23fd3f4aeb50f' into integration/incremental-lint 2026-10-28 18:09:46 -03:00
Marcos Paulo b848eabdf3 feat: incremental lint with a change log and get_changes
A bounded log (10000 layer changes, each tagged with the client that made it)
records what every commit, undo and redo touched; variable edits count as
touching the layers bound to them. Write tools return rev, get_basic_info
reports it, get_changes lists changes (exclude: self, by), and lint_design
takes since / changedOnly to check only changed layers plus what they affect.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 18:09:27 -03:00
Marcos Paulo cd2913d6f7 feat: add the overflow lint rule and the get_bounds tool
lint_design flags layers past their artboard or clipping parent and text
taller than its box (scrolling frames exempt). get_bounds returns the
rects of whole subtrees in one read.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 18:05:54 -03:00
Marcos Paulo ec6da1d383 fix: treat agent reads as reads in the pill, outlines and follow camera
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 17:43:14 -03:00
Marcos Paulo ccc6da2e8c docs(specs): design swarm safety for concurrent agents
Identity, optimistic concurrency, soft claims, per-client undo, offline multi-process locking, test harness and phases.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 17:42:45 -03:00
Marcos Paulo 39933d3c74 docs: add incremental checks workflow (rev, get_changes, scoped lint, get_bounds)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 17:34:24 -03:00
Marcos Paulo 5ee037a7c3 docs: add an AI usage policy, AGENTS.md and CODEOWNERS
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 16:33:24 -03:00
Marcos Paulo d97b889455 docs: add contributing guide, project rules, code of conduct and security policy
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 16:19:19 -03:00
Marcos Paulo 65d160af3b ci: install libdbus and the Apple silicon target for release builds
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 16:01:32 -03:00
Marcos Paulo 837c4448b0 ci: build and publish releases on GitHub only
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 14:27:54 -03:00
Marcos Paulo db650b3ea6 docs: bring the README status and known limits up to date
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 14:20:08 -03:00
Marcos Paulo 76afc98890 docs: drop the paper-rs fork credit and upstream references
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 12:40:25 -03:00
Marcos Paulo 8d46a01a10 chore(release): 0.8.4
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 12:30:22 -03:00
Marcos Paulo 7a6d664c42 docs(readme): add demo video and refresh screenshots
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 12:25:45 -03:00
Marcos Paulo 70830478d9 fix(ui): read the performance overlay's prefs from the project config dir
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 12:25:44 -03:00
Marcos Paulo c575b7e7a7 feat(ui): menu icons and offsets, panel grips, / to hide panels, narrow-window fixes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 10:04:38 -03:00
123 changed files with 8908 additions and 919 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# Upstream (michaelshimeles/paper-rs) carries no ArtCraft/Discord branding; keep it that way. Run: perl tools/craft-skills/skills/debrand/debrand.pl --check .
# The tree carries no ArtCraft/Discord branding; keep it that way. Run: perl tools/craft-skills/skills/debrand/debrand.pl --check .
keep ^(LICENSE-(MIT|APACHE)|\.debrand)$
forbid (?i)artcraft
forbid (?i)discord
+1
View File
@@ -0,0 +1 @@
* @nxtime
+19
View File
@@ -0,0 +1,19 @@
---
name: Bug report
about: Something does not work as expected
labels: bug
---
**What happened**
**What you expected**
**Steps to reproduce**
1.
2.
**Version and platform**
PaperCraft version (the release you installed), OS, and the GPU if it is a rendering problem.
**Screenshot, recording or project**
Attach one if you can. Remove anything private first.
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: true
contact_links:
- name: Security vulnerability
url: https://github.com/nxtime/papercraft/security/advisories/new
about: Report security problems privately, not as an issue.
+14
View File
@@ -0,0 +1,14 @@
---
name: Feature request
about: Suggest an idea or a missing capability
labels: enhancement
---
**The problem**
What are you trying to do, and what gets in the way?
**What you would like**
**Alternatives you considered**
**Is it already in ROADMAP.md?**
+24
View File
@@ -0,0 +1,24 @@
## What and why
<!-- What changed, and the problem it solves. Link the issue: Fixes #123 -->
## How I tested it
<!-- Tests added, commands run, platforms tried. -->
## AI assistance
<!-- Which tool helped and how much, or "none". See AI_POLICY.md. You must understand every line. -->
## Screenshots
<!-- For UI changes: before and after, at scale 2, in each theme. -->
## Checklist
- [ ] Tests first; new code is covered, error paths included
- [ ] `cargo fmt --all`, `cargo clippy --all-targets` and the affected `cargo test --test` targets pass
- [ ] Follows [RULES.md](../RULES.md) (no overflow, data safety, one undo step per tool call)
- [ ] Docs, tool descriptions and `PROGRESS.md` updated where needed
- [ ] Conventional commit messages
- [ ] I read and understand every line of this change (AI-assisted or not)
+95
View File
@@ -0,0 +1,95 @@
# Builds Windows, macOS and Linux on GitHub's runners when a v* tag is pushed, then publishes a
# GitHub release with the files once the full test suite (test.yml) passes. Gitea reads .github/workflows too, so every job is gated on the
# server URL: this pipeline only ever runs on github.com.
name: release
on:
push:
tags: ["v*"]
workflow_dispatch:
inputs:
tag:
description: Existing tag to build and release (e.g. v0.8.4)
required: true
permissions:
contents: write
env:
CARGO_TERM_COLOR: always
TAG: ${{ inputs.tag || github.ref_name }}
jobs:
test:
uses: ./.github/workflows/test.yml
with: { ref: "${{ inputs.tag || github.ref }}" }
windows:
if: github.server_url == 'https://github.com'
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
with: { ref: "${{ inputs.tag || github.ref }}" }
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Build and zip
shell: pwsh
run: |
cargo build --release --locked --bins
$v = $env:TAG.TrimStart('v')
New-Item -ItemType Directory -Force dist | Out-Null
$exes = 'papercraft','papercraft-cli','papercraft-mcp','papercraft-relay' | ForEach-Object { "target/release/$_.exe" }
Compress-Archive -Path $exes -DestinationPath "dist/papercraft-$v-windows-x86_64.zip"
- uses: actions/upload-artifact@v4
with: { name: windows, path: dist/* }
macos:
if: github.server_url == 'https://github.com'
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
with: { ref: "${{ inputs.tag || github.ref }}" }
- uses: dtolnay/rust-toolchain@stable
with: { targets: aarch64-apple-darwin }
- uses: Swatinem/rust-cache@v2
- name: Package (Apple silicon, ad-hoc signed, not notarized)
run: packaging/macos/package.sh --arch aarch64
- uses: actions/upload-artifact@v4
with: { name: macos, path: dist/release/* }
linux:
if: github.server_url == 'https://github.com'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { ref: "${{ inputs.tag || github.ref }}" }
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Build dependencies
run: |
sudo apt-get update
sudo apt-get install -y pkg-config libssl-dev libxkbcommon-x11-dev libxcb-xkb-dev libx11-xcb-dev libwayland-dev libvulkan-dev libdbus-1-dev
- name: Build and tar
run: |
cargo build --release --locked --bins
v="${TAG#v}"
mkdir -p dist stage
cp target/release/papercraft target/release/papercraft-cli target/release/papercraft-mcp target/release/papercraft-relay stage/
tar -C stage -czf "dist/papercraft-$v-linux-x86_64.tar.gz" .
tar -C stage -czf "dist/papercraft-cli-$v-linux-x86_64.tar.gz" papercraft-cli papercraft-mcp
- uses: actions/upload-artifact@v4
with: { name: linux, path: dist/* }
publish:
if: github.server_url == 'https://github.com'
needs: [test, windows, macos, linux]
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with: { path: files, merge-multiple: true }
- name: Create the release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "$TAG" files/* --repo "$GITHUB_REPOSITORY" --title "PaperCraft ${TAG#v}" \
--generate-notes --verify-tag || gh release upload "$TAG" files/* --repo "$GITHUB_REPOSITORY" --clobber
+34
View File
@@ -0,0 +1,34 @@
# The full test suite. Local runs cover only the targets a change touches (RULES.md, "The machine
# you build on"); this is where everything runs, before a release can publish. Run it alone with
# `gh workflow run test.yml -f ref=<branch or tag>`. Gitea reads .github/workflows too, so the job
# is gated on the server URL.
name: test
on:
workflow_call:
inputs:
ref:
type: string
required: false
workflow_dispatch:
inputs:
ref:
description: Branch or tag to test (default the one you run from)
required: false
env:
CARGO_TERM_COLOR: always
jobs:
test:
if: github.server_url == 'https://github.com'
# Windows: the platform the app is developed on, and its runners render with a software GPU.
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
with: { ref: "${{ inputs.ref || github.ref }}" }
- uses: dtolnay/rust-toolchain@stable
with: { targets: wasm32-unknown-unknown }
- uses: Swatinem/rust-cache@v2
- run: cargo test --locked --no-fail-fast
- run: cargo check --locked --lib --target wasm32-unknown-unknown
+77
View File
@@ -0,0 +1,77 @@
# Guidance for coding agents
PaperCraft is a native Rust design and whiteboard canvas (egui, wgpu, winit) that AI agents drive
over MCP and a CLI. Read [CONTRIBUTING.md](CONTRIBUTING.md) and [RULES.md](RULES.md) first; this
file is the short version for agents, and [AI_POLICY.md](AI_POLICY.md) says what the person using
you is accountable for.
## Commands
```sh
cargo build --release
cargo fmt --all
cargo clippy --all-targets
cargo test --test incremental # only the targets your change touches
cargo test --lib editor::changes # unit tests of one module
cargo check --lib --target wasm32-unknown-unknown
```
Do not run the full suite locally: it builds dozens of test binaries. It runs on GitHub before
each release (`.github/workflows/test.yml`). `tests/update.rs` cannot run on Windows outside CI
(it needs elevation); that failure is known.
Builds run on the person's own machine, so mind its resources (RULES.md, "The machine you build
on"): check free memory and running builds first, cap jobs at no more than half the cores
(`cargo test -j 8 -- --test-threads=8` on 16 cores), run at low priority, run one heavy job at a
time, and stop at once if they say it is slow.
## Running the app safely
Never run a test copy against the person's real projects or MCP port, and never close or restart
their running PaperCraft. Give yours its own folder and port, and keep its PID so you can stop only
your own process:
```sh
PAPERCRAFT_HOME=/tmp/pc PAPERCRAFT_MCP_PORT=29998 ./target/release/papercraft
```
Do not stop processes by name.
## Layout
`src/model` (document), `src/editor` (tools and edits), `src/render` (wgpu), `src/mcp` (tools,
HTML/CSS mapping, lint), `src/ui` (egui panels), `src/io` (import and export), `src/project`
(store and formats), `tests/` (scenario tests), `specs/` (feature specs), `skills/` (agent
skills). The README's project layout lists the rest.
## Conventions
* Tests first; match the style of the tests next to yours, and check the behaviour is not already
covered.
* Changes to the document go through `Editor::edit`: one transaction, one undo step.
* Handle errors; do not `unwrap` user data and do not discard errors with `let _ =`.
* Comments explain *why*, never summarise the code. No dead code, debug prints or
commented-out blocks.
* Files stay under about 800 lines; split by feature.
* Reuse the shared UI kit and theme instead of one-off values. Nothing may overflow its container.
* Writes to projects are atomic and format changes ship a migration (see RULES.md, "Data safety").
* Adding or removing an MCP tool means a test in `tests/mcp.rs`, a precise description, and the
tool count updated in the README and ROADMAP.
* Commits are conventional (`feat(scope): summary`) and focused. Do not commit, push or tag unless
the person asked you to.
## Designing with PaperCraft (skills, examples, screenshots)
* Themes are modes, never copies: one artboard per screen, a value per mode on each variable, and
`set_variable_mode` to switch. Never duplicate artboards for light and dark.
* Every colour on a themed screen is a variable.
* Check each artboard for overflow, programmatically and by screenshot in each mode, and run
`lint_design`.
* Release design locks when you finish.
## Avoid
* Creative additions nobody asked for; stay inside the task.
* Trivial tests, wide or optional types added to appease the compiler, and swallowed errors.
* Editing generated files, lockfiles or release artifacts by hand, or touching files unrelated to
the task. Leave other people's uncommitted work alone.
+40
View File
@@ -0,0 +1,40 @@
# AI usage policy
PaperCraft is built to be driven by AI agents, and many of its contributors use them too. AI tools
are welcome here. What we ask is that a person stays accountable for everything submitted.
## For contributors
* **You own what you submit.** Read every line of your pull request before asking for review. If
you cannot explain what a change does and how it fits the rest of the system without asking an
AI, do not submit it yet.
* **Disclose meaningful AI help.** Say which tool you used and roughly how much it wrote in the
pull request description (for example "drafted with Claude Code, reviewed and tested by me").
Autocomplete and spell-checking do not need a note.
* **Write issues yourself.** An agent can help you research, but the report should be in your own
words, trimmed to what matters, with reproduction steps you ran. Long generated text that nobody
checked puts the burden on maintainers.
* **Think before you generate.** Agents jump to code and make their own job easy: trivial tests,
wide or optional types, swallowed errors, redundant comments, copied local patterns that do not
fit. Push back on all of it. [RULES.md](RULES.md) lists the standards to hold the output to.
* **Verify.** Run the checks in [CONTRIBUTING.md](CONTRIBUTING.md), use the app, and attach
screenshots for UI changes. "The agent said it works" is not a test result.
* **Answer automated review.** If a bot reviews your pull request, fix its findings or reply with
a one-line reason a finding does not apply.
* **No bulk or drive-by pull requests.** Many small generated changes across the tree, or changes
to something nobody asked for, will be closed. Open an issue first for anything beyond a small
fix.
Pull requests that clearly skip these steps may be closed without a detailed review. Repeated
low-effort submissions can lead to a block, under the [Code of Conduct](CODE_OF_CONDUCT.md).
## For coding agents
Read [AGENTS.md](AGENTS.md). It holds the commands, conventions and guardrails an agent working in
this repository should follow.
## Why
Every issue and pull request is read by a person. Review time is the scarce resource, and a change
nobody understood costs more than it saves. AI that is reviewed, tested and understood by the
person submitting it is a good way to build PaperCraft; AI used to skip that work is not.
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+31
View File
@@ -0,0 +1,31 @@
# Code of Conduct
PaperCraft is built by people with different backgrounds and experience. We want everyone to be
able to contribute without harassment.
## Our standard
Be respectful and constructive. Assume good intent, give specific and kind feedback, and accept
it the same way. Disagree about the work, not the person.
Not acceptable:
* harassment, insults, slurs, or personal or political attacks;
* sexualised language or imagery, or unwelcome attention;
* threats, doxxing, or publishing someone's private information;
* sustained disruption of discussions, or pressure to merge or answer;
* anything else a reasonable person would consider inappropriate in a professional setting.
## Scope
This applies in issues, pull requests, discussions, commit messages and any space where someone
represents the project.
## Enforcement
Maintainers may edit or remove comments, close or lock threads, and block people who break these
rules. To report a problem, use GitHub's "Report content" on the comment, or contact a maintainer
through a private channel on their GitHub profile. Reports are handled privately and in good
faith, and retaliation against a reporter is itself a violation.
The spirit of this text follows the [Contributor Covenant](https://www.contributor-covenant.org).
+129
View File
@@ -0,0 +1,129 @@
# Contributing to PaperCraft
Thanks for wanting to help. PaperCraft is an alpha, so there is plenty to do and the bar for a
first contribution is low: a typo fix, a clearer error message, a missing test or a one-line bug
fix are all welcome.
Please read [RULES.md](RULES.md) before writing code: it holds the standards a change is judged by.
By taking part you agree to the [Code of Conduct](CODE_OF_CONDUCT.md). Using an AI tool is fine;
the [AI usage policy](AI_POLICY.md) says what you stay responsible for, and coding agents should read
[AGENTS.md](AGENTS.md).
## Ways to help
* **Report a bug** or **suggest a feature** with an [issue](https://github.com/nxtime/papercraft/issues/new/choose).
A screenshot or a short recording helps; so does the project folder (or a page of it) that
triggers the bug.
* **Pick something from [ROADMAP.md](ROADMAP.md).** Rows marked `todo` or `partial` are open. Say
which one you are taking in an issue first, so two people do not build the same thing.
* **Improve the docs, skills or screenshots.** [docs/](docs), [skills/](skills) and the README are
as important as code.
* **Review a pull request.** A second pair of eyes is always useful.
Security problems do not go in public issues: see [SECURITY.md](SECURITY.md).
## Before you start
For anything bigger than a small fix, open an issue (or comment on one) describing the problem and
how you plan to solve it. Large features start as a short spec in [specs/](specs): a `spec.md`
(why, what, what is out of scope) and a `plan.md`. [specs/component-props](specs/component-props)
shows the shape. A spec agreed up front saves a rewrite later.
## Set up
You need Rust 1.95 or newer (`rust-version` in `Cargo.toml`) and the platform libraries listed in
the README's [Build from source](README.md#build-from-source).
```sh
git clone https://github.com/nxtime/papercraft
cd papercraft
cargo build --release
```
Run a test copy next to your own PaperCraft so it cannot touch your real projects or collide with
the running app's MCP port:
```sh
PAPERCRAFT_HOME=/tmp/pc PAPERCRAFT_MCP_PORT=29998 ./target/release/papercraft
```
`PAPERCRAFT_HOME` moves the project root and the config folder; `PAPERCRAFT_MCP_PORT` moves the
MCP server. Always set both when developing.
The code depends on [`craft-ui`](https://git.marcospaulo.dev.br/masi/craft-libs), which Cargo
fetches from a public git repository at the revision pinned in `Cargo.toml`.
## Make the change
1. Fork the repository and branch from `main` (`fix/short-name`, `feat/short-name`).
2. **Write the test first.** Fix a bug by adding a test that fails, then make it pass. See
[Tests](#tests).
3. Keep the change focused. One concern per pull request; unrelated cleanups go in their own.
4. Run the checks below and fix what they report.
5. Update the docs the change touches: the README, `docs/`, the tool description for an MCP tool,
the skills if agents should now behave differently.
6. Add a short entry to [PROGRESS.md](PROGRESS.md) for user-visible changes.
### Checks
```sh
cargo fmt --all
cargo clippy --all-targets
cargo test --test <target> # the targets your change touches
cargo check --lib --target wasm32-unknown-unknown # the web build
```
Each file in `tests/` is its own binary linking the whole app, so the full suite is slow and heavy
on a desktop. It runs on GitHub instead: before every release (`.github/workflows/test.yml`), or
on demand with `gh workflow run test.yml -f ref=<branch>`.
Keep the tree free of the third-party branding patterns listed in `.debrand`.
### Tests
* Unit tests sit next to the code in `#[cfg(test)]` modules.
* Scenario tests live in [tests/](tests). They drive the editor with synthetic pointer and
keyboard events; `tests/mcp.rs` covers every MCP tool on a headless session.
* Aim for **80% coverage** on code you add or change, including the error paths.
* GPU-dependent tests skip when no adapter exists. Set `PAPERCRAFT_REQUIRE_GPU=1` to make them
fail instead.
* `tests/update.rs` cannot run on Windows (it needs elevation); that failure is known and not
yours.
* For UI work, run the app, look at it, and attach a screenshot. Headless rendering needs no
display: `papercraft --screenshot ui.png --demo --size 1440x900 --scale 2`.
## Commits
[Conventional commits](https://www.conventionalcommits.org): `type(scope): summary`, with the
types `feat`, `fix`, `refactor`, `docs`, `test`, `chore`, `perf` and `ci`.
```
fix(ui): keep the review row's revert button off the hover edge
feat(mcp): add set_screens
docs(readme): add the demo video
```
Write the summary in the imperative, under about 72 characters, and use the body to say *why*.
Reference the issue (`Fixes #123`).
## Pull requests
* Fill in the template: what changed, why, how you tested it, screenshots for UI changes.
* Keep it reviewable. If it grows past a few hundred lines of non-generated code, split it.
* The branch must be up to date with `main`; rebase rather than merge.
* Expect review comments; they are about the code, not you. Push fixes as new commits and we
squash on merge.
* A maintainer merges once a review is approved.
## Releases
Maintainers cut releases. A `v*` tag pushed to GitHub builds Windows, macOS and Linux and
publishes them (`.github/workflows/release.yml`). Contributors do not need to bump versions.
## Licensing
PaperCraft is dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE). Unless you
state otherwise, anything you submit is licensed under the same terms, with no extra conditions.
Only submit work you wrote or have the right to submit. Code ported from another project needs a
compatible license and an entry in [THIRD_PARTY.md](THIRD_PARTY.md); bundled assets go in
[ATTRIBUTION.md](ATTRIBUTION.md).
Generated
+1 -1
View File
@@ -3128,7 +3128,7 @@ dependencies = [
[[package]]
name = "papercraft"
version = "0.8.2"
version = "0.9.0"
dependencies = [
"arboard",
"axum",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "papercraft"
version = "0.8.2"
version = "0.9.0"
edition = "2024"
rust-version = "1.95"
description = "A native infinite-canvas app for planning, working and sketching"
+43
View File
@@ -76,6 +76,38 @@ are credited in `THIRD_PARTY.md`.
* **Not carried over**: comments, design tokens, AI image generation, video export, teams;
moving layers between pages; remote image URLs (see README Known gaps).
### 2026-10-28: overflow lint rule and `get_bounds`
* `lint_design` has an `overflow` rule (`src/mcp/lint_overflow.rs`): layers past their artboard or clipping parent, and text taller than its box. Scrolling frames and what is pinned in them are exempt; it is never auto-fixed. It is a pure function over a node set, so a scope filter can pass any subset.
* New tool `get_bounds {nodeIds?, pageId?, depth?}` (`src/mcp/bounds.rs`): every layer's rect in one read (about 2000 rows per call, then `continueWith`). Tool count is now 87 (one more than before; if another branch also adds a tool, recount). Tests: `tests/mcp_bounds.rs`.
### 2026-10-28: agent reads no longer look like edits
* Read-only tool calls (`get_node_info`, `get_children`, ...) have honest verbs ("Looking at the design") and light the "Working" pill for 1.5 s instead of 6 s, so it goes out right after a sweep ends.
* A sweep of reads draws at most one layer outline (a read outlines only when no other read ran in the last 2 s) and no longer pans the follow-agent camera; edits behave as before. "Is this a read" comes from `mcp::tools::edits_doc`.
### 2026-10-28: 0.9.0 (presentations, critique, several agents, lighter chats)
* Export the screens as a presentation PDF: cover, contents and a slide per screen (File menu, palette, `--format presentation`, `export_presentation`).
* `critique_design` reviews finished work through personas (vision, UX, flow, QA, visual, accessibility, copy, end-user); `design_lessons` keeps the findings the person approved for every later design.
* Several agents on one project: named CLI agents (`--as`), conflict reports and `ifRev` on writes, `claim_nodes` / `release_claims` / `list_claims`, scoped delegate builds and per-agent undo. 94 tools.
* Screens keep their device height and scroll what does not fit; sections group screens on the canvas; pages can carry icons.
* UI kits for shadcn, MUI, Mantine, Bootstrap and Chakra; `write_html` follows the browser more closely.
* Memory: chat screenshots move to files (kept 14 days), thumbnails stay small, canvas images decode only for the GPU. A long chat dropped from about 1 GB to about 400 MB.
* Fix: long answer options in a question card wrap instead of widening the whole chat.
### 2026-10-28: 0.8.4 (demo video, fresh screenshots)
* README opens with a time-lapse of designing a habit-tracker app (`docs/demo.gif`, full video `docs/demo.mp4`), and every screenshot is retaken from that design.
* Fix: the Performance overlay reads its settings from the project config folder, so `PAPERCRAFT_HOME` isolates it too.
### 2026-10-28: 0.8.3 (menus, panel grips, narrow windows)
* Menus open 6 px below their button, are at least 180 px wide and show an icon beside each item. Below 1100 px the hamburger opens every menu in one popup.
* Side panels: a slim rounded grip in the gap resizes them (double click resets) and `/` hides or shows both. Panels size to their content and step aside when the window is too narrow for the canvas.
* Sharper window buttons (pixel-snapped glyphs); the hamburger morphs into a back arrow while the File / Edit strip slides open and shut.
* Fixes: lint Fix buttons no longer cover the issue text; review rows keep their badge and revert button off the hover edge; the Export button stays on one line.
### 2026-10-28: 0.8.2 (refresh-rate cap, accurate RAM/CPU, toolbar)
* Settings > Performance > Refresh rate is now a real frame cap applied live (30 to 360 Hz, 60 by default); **Auto** draws as fast as the display allows and is the default on macOS.
@@ -219,6 +251,17 @@ are credited in `THIRD_PARTY.md`.
* Agent view lists runs and supports review, whole-run revert, per-change undo and accept all. Review adds canvas outlines and a finish toast; palette commands review or revert the last run.
* Id-paired page diffs power review; History version compare UI remains todo. Completed with full restore (including variables, modes and tokens), tool edit classification, and wasm support.
### 2026-10-28: Incremental lint
Agents re-linted whole pages after every edit. A bounded change log (`src/editor/changes.rs`, the
last 10000 layer changes, each with the client that made it) now records which layers every
commit, undo and redo touched; variable edits count as touching the layers bound to them. Write
tools return `rev`, `get_basic_info` reports it, `get_changes {since, page?, exclude?, by?}` lists
the changes, and `lint_design {since | changedOnly}` checks only those layers plus their
ancestors, descendants and siblings (`src/mcp/incremental.rs`), with `covered` saying what ran.
Tests: `tests/incremental.rs`, unit tests next to the code, the attribution check in
`tests/mcp.rs::parallel_sessions_get_their_own_runs`.
### 2026-10-21: Design libraries and offline CLI
* Variables are now collections with modes, slash groups, four types and aliases, stored in `design.json` (project version 2; 0.4.0 refuses migrated projects). Each page keeps a derived cache; every edit is one undo step per page, and the page-only removals are gone. Per-page active mode per collection.
+57 -16
View File
@@ -12,9 +12,17 @@
</p>
<p align="center">
<img src="docs/screenshots/readme/hero.png" alt="PaperCraft with the Redesign page of its own design file open: layers on the left, the canvas in the middle, the inspector on the right" width="100%">
<a href="docs/demo.mp4">
<img src="docs/demo.gif" alt="PaperCraft's editor breaking apart into its panels on a 3D stage, then chat mode with an agent, annotations pinned to a sign-in screen, the variables and libraries modals, and the home screen" width="100%">
</a>
<br>
<sub>PaperCraft editing its own design file (the Redesign page).</sub>
<sub>A 40-second tour: the editor, chat mode, annotations and design systems. <a href="docs/demo.mp4">Watch it in full quality</a> (MP4, 1080p60). Rendered from the app's own design with <a href="tools/showcase/README.md">tools/showcase</a>, not screen-recorded.</sub>
</p>
<p align="center">
<img src="docs/screenshots/readme/hero.png" alt="PaperCraft with the Pulse habit-tracker design open: layers on the left, three annotated screens on the canvas, the inspector on the right" width="100%">
<br>
<sub>The finished design: three screens, a component library and annotations.</sub>
</p>
<p align="center">
@@ -25,6 +33,7 @@
<a href="#keyboard-shortcuts">Shortcuts</a> ·
<a href="#status-and-known-limits">Status</a> ·
<a href="#project-layout">Layout</a> ·
<a href="#contributing">Contributing</a> ·
<a href="#license-and-credits">License</a>
</p>
@@ -54,11 +63,11 @@ It is an alpha. It is good for building screens, design systems and sketches, an
<table>
<tr>
<td width="50%" valign="top">
<img src="docs/screenshots/readme/variables.png" alt="The Variables modal listing the Colors collection with four Catppuccin modes" width="100%">
<br><sub>Variables modal: collections, modes, groups and types.</sub>
<img src="docs/screenshots/readme/variables.png" alt="The Variables modal listing the Colors collection with Light and Dark values for brand, foreground and background" width="100%">
<br><sub>Variables modal: collections, modes (Light and Dark here), groups and types.</sub>
</td>
<td width="50%" valign="top">
<img src="docs/screenshots/readme/assets.png" alt="The Assets side view with components grouped into Banner, Button, Checkbox and Chip" width="100%">
<img src="docs/screenshots/readme/assets.png" alt="The Assets side view with the Habit row component and the shadcn Button kit" width="100%">
<br><sub>Assets view: components grouped by name, from this file and enabled libraries.</sub>
</td>
</tr>
@@ -68,12 +77,32 @@ It is an alpha. It is good for building screens, design systems and sketches, an
<br><sub>Libraries modal: publish this file, enable libraries from the store.</sub>
</td>
<td width="50%" valign="top">
<img src="docs/screenshots/readme/agent-review.png" alt="Agent run review: a list of changes on the left and green, yellow and red outlines on the canvas" width="100%">
<br><sub>Review of an agent run, with the changes outlined on the canvas.</sub>
<img src="docs/screenshots/readme/agent-review.png" alt="The Agent side view: a finished run with Review and Revert, above the list of earlier runs" width="100%">
<br><sub>The Agent view: runs, with Review and Revert for the latest.</sub>
</td>
</tr>
</table>
<table>
<tr>
<td width="50%" valign="top">
<img src="docs/screenshots/readme/toolbar-panels.png" alt="The toolbar with the menu button folded into a back arrow, the File to Theme strip, and the floating left panel with a slim grip beside it" width="100%">
<br><sub>Toolbar and panels: the menu button shows or hides the File / Edit strip, and the slim grip between a panel and the canvas resizes it (double click resets). <kbd>/</kbd> hides both side panels.</sub>
</td>
<td width="50%" valign="top">
<img src="docs/screenshots/readme/menus.png" alt="The Tools menu open below its button, each tool with an icon and its shortcut" width="100%">
<br><sub>Menus open a little below their button, are never narrower than 180 px, and carry an icon next to each item.</sub>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<img src="docs/screenshots/readme/annotations.png" alt="The Annotations side view listing five notes grouped by screen, with their category chips" width="100%">
<br><sub>Annotations: rule, behaviour, content and data notes, kept with the layers they describe.</sub>
</td>
<td width="50%" valign="top"></td>
</tr>
</table>
**Prototyping and review**
* Prototype links between frames and a **Present** mode that follows them with a camera glide.
* **Version history:** named and automatic saved versions per page; rename, restore, delete.
@@ -90,7 +119,7 @@ Projects live in `~/Documents/papercraft/<name>/` (`project.json`, one JSON file
## Agents
PaperCraft exposes **70 tools** (`src/mcp/tools.rs`) three ways. They run the same code inside the app.
PaperCraft exposes **94 tools** (`src/mcp/tools.rs`) three ways. They run the same code inside the app.
| Way | Notes |
|---|---|
@@ -141,7 +170,7 @@ EOF
Chat mode (⌘J, or `Design | Chat` in the top bar) puts a conversation next to the canvas. It runs Claude Code and Codex through their CLIs, any agent that speaks the Agent Client Protocol (ACP, such as OpenCode, Gemini CLI or Cursor) found on your PATH, or the Anthropic API with your own key. It shows tool calls, checkpoints and run review in the thread. Chats are saved in the project's `chats/` folder. See [docs/chat.md](docs/chat.md).
<p align="center">
<img src="docs/screenshots/readme/chat.png" alt="Chat mode: the chat column on the left shows a finished agent run with its Review, Revert and Accept actions, and the canvas on the right" width="100%">
<img src="docs/screenshots/readme/chat.png" alt="Chat mode: the chat column on the left with its starter prompts and input, and the annotated habit-tracker screens on the canvas" width="100%">
<br>
<sub>Chat mode with a finished agent run: review, revert or accept from the thread.</sub>
</p>
@@ -162,12 +191,13 @@ Releases are at [git.marcospaulo.dev.br/masi/papercraft/releases](https://git.ma
| Build | File |
|---|---|
| Windows (x86_64) | `papercraft-<version>-windows-x86_64.zip`: `papercraft.exe`, `papercraft-cli.exe`, `papercraft-mcp.exe` and `papercraft-relay.exe` |
| macOS app (Apple silicon) | `papercraft-<version>-macos-aarch64.dmg`, containing PaperCraft.app |
| Linux (x86_64) | `papercraft-<version>-linux-x86_64.tar.gz` |
| CLI tools, macOS | `papercraft-cli-<version>-macos-aarch64.zip` (`papercraft-cli` and `papercraft-mcp`) |
| CLI tools, Linux | `papercraft-cli-<version>-linux-x86_64.tar.gz` |
macOS builds are ad-hoc signed and not notarized: on another Mac, open the app with right-click, Open the first time. Other platforms: build from source.
The Windows zip is unsigned: SmartScreen may ask you to confirm the first run. macOS builds are ad-hoc signed and not notarized: on another Mac, open the app with right-click, Open the first time. Other platforms: build from source.
### Build from source
@@ -179,9 +209,10 @@ cargo build --release
./target/release/papercraft ~/Documents/papercraft/Plan # open a project folder
./target/release/papercraft --demo # sample board, not saved
packaging/macos/package.sh # PaperCraft.app on a DMG, plus the CLI zip
pwsh packaging/windows/package.ps1 -Arch x64 # Windows MSI, portable zip and CLI zip
```
**macOS:** `xcode-select --install` for the linker. It renders with Metal through wgpu. **Linux:** install the runtime libraries (`sudo apt install libxkbcommon-x11-0 libxcb-xkb1 libvulkan1 mesa-vulkan-drivers` on Debian or Ubuntu); it runs on X11 and Wayland through Vulkan, with GL as a fallback, and file dialogs use the XDG desktop portal.
**Windows:** the MSVC build tools (Visual Studio Build Tools with the C++ workload); it renders through wgpu on Direct3D 12 or Vulkan. **macOS:** `xcode-select --install` for the linker. It renders with Metal through wgpu. **Linux:** install the runtime libraries (`sudo apt install libxkbcommon-x11-0 libxcb-xkb1 libvulkan1 mesa-vulkan-drivers` on Debian or Ubuntu); it runs on X11 and Wayland through Vulkan, with GL as a fallback, and file dialogs use the XDG desktop portal.
To run a test copy next to your own PaperCraft, give it its own folder and port: `PAPERCRAFT_HOME=/tmp/pc PAPERCRAFT_MCP_PORT=29998 ./target/release/papercraft`.
@@ -228,24 +259,32 @@ The scenario tests drive the editor with synthetic pointer and keyboard events;
| PageUp / PageDown | Previous / next page | ⌘\\ | Show / hide UI |
| ⌘J | Design / Chat mode | ⌘L | Add selection to chat (Chat mode) |
| N | Annotate tool | ⌘⇧A | Annotations: full, badges, hidden |
| / | Show / hide the side panels | | |
Draw a frame around existing shapes and it adopts them; dragging a layer into or out of a frame re-parents it. Hold ⌘ while dragging to suspend snapping.
## Status and known limits
> [!IMPORTANT]
> PaperCraft is an alpha. [ROADMAP.md](ROADMAP.md) lists what is missing, in priority order; [PROGRESS.md](PROGRESS.md) is the milestone history.
> PaperCraft is an alpha (0.8.x). It is used daily on Windows, macOS and Linux, and its project format migrates forward, but expect rough edges. [ROADMAP.md](ROADMAP.md) lists what is missing, in priority order; [PROGRESS.md](PROGRESS.md) is the milestone history.
**What works today:** local projects with pages and an infinite canvas; vector, text, auto layout, constraints, components with properties and variants, variables with modes, shared libraries and screens; annotations, lint and Present mode with prototype links; PNG, SVG and PDF export, Figma, Excalidraw and SVG import; the MCP server, CLI (online and offline) and in-app Chat mode; versions, agent runs with review and revert; optional relay sync.
**Known limits:**
* **No rotation.** Nodes are axis-aligned boxes; rotated imports are baked into paths.
* **Text** is Inter only, with no shaping (no ligatures, complex scripts or RTL) and no font picker. IME preedit is not displayed.
* **Rendering approximations.** Frame clipping is a rectangle, so rounded frames do not clip their corners. Overlay, dodge, burn, light, difference, exclusion and the HSL blend modes draw as Normal on the canvas (they are exact in SVG and PDF export). There is no layer or background blur yet.
* **Variables:** gradients keep fixed colors; there are no text styles and no component variants or properties.
* **Variables:** gradients keep fixed colors, and there are no text styles (named type styles that update every text layer).
* **Components:** properties (text, bool, icon, enum) and `merge_variants` cover variants, but there are no variant sets with several axes, and no responsive per-breakpoint variants or breakpoint preview.
* **Prototyping:** links and flows work in Present mode; smart transitions and hover states do not exist yet.
* **Booleans are destructive:** the result is a new vector path with flattened curves.
* **`write_html`** ignores margin, tables and transforms (it reports them as `ignoredStyles`) and does not fetch remote images; pass a local path or a `data:` URI.
* **Agents:** one project is open at a time in the app, and `move_nodes` stays on one page. The in-app Chat mode runs Claude Code, Codex, ACP agents or your own Anthropic key. Agents that are not in Chat connect over MCP or the CLI.
* **Sharing** has no live presence (cursors, selections) yet.
* **Scale:** cached frames take about 3 ms at 10k visible nodes; it is not tuned for 100k.
* **Pen pressure** is simulated from stroke velocity on macOS tablets, because winit 0.30 does not expose it for mouse-like pen events.
* **Releases** cover macOS (Apple silicon) and Linux x86_64. Windows packaging scripts exist but are not part of releases.
* **Releases:** the Gitea releases ship Windows x86_64. macOS (Apple silicon) and Linux x86_64 builds come from the packaging scripts or from source; the Linux build is not part of the current releases.
* **PDF** text is outlined, not selectable.
## Project layout
@@ -280,12 +319,14 @@ analysis/ behaviour studies and design notes
docs/ guides and screenshots
```
## Contributing
Bug reports, ideas, docs and code are welcome. Start with [CONTRIBUTING.md](CONTRIBUTING.md) (setup, checks, commits, pull requests) and [RULES.md](RULES.md) (the standards a change is reviewed against). AI tools are welcome under the [AI usage policy](AI_POLICY.md), and coding agents should read [AGENTS.md](AGENTS.md). Security problems go through [SECURITY.md](SECURITY.md), and everyone follows the [Code of Conduct](CODE_OF_CONDUCT.md).
## License and credits
Dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE), at your option.
Bundled fonts, icons and other assets keep their own licenses, listed in [ATTRIBUTION.md](ATTRIBUTION.md); Inter is © The Inter Project Authors under the SIL Open Font License 1.1. Some algorithms are ported from MIT-licensed projects (rough.js, hachure-fill, Excalidraw), with their notices in [THIRD_PARTY.md](THIRD_PARTY.md).
PaperCraft started as a fork of [storytold/paper-rs](https://git.marcospaulo.dev.br/storytold/paper-rs); thanks to its contributors.
PaperCraft is not affiliated with Paper or Figma. Only the behavior of other tools was studied (see `analysis/`); no code, shaders, icons, fonts or assets from them are used.
+3 -3
View File
@@ -22,7 +22,7 @@ Agents can already build a design system through the tools; people cannot yet ma
| 7 | Number variables (spacing, radius, font size) | **done** | Typed number, string and boolean variables in collections with groups and aliases; fields bind (size, gap, padding, radius, font size, line height, opacity, visible) in the inspector and `bind_variable`. |
| 8 | Stroke and gradient variables | partial | Strokes and text color bind to variables. 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. |
| 10 | Component variants and properties | partial | Main components carry text, bool, icon and enum properties; `merge_variants` folds per-state components into one. Multi-axis variant sets todo. |
| 11 | Swap instance, detach by tool | **done** | `swap_instance` and `detach_instance` MCP tools + UI controls in Design tab and Inspector. |
| 12 | Cross-page and shared libraries | **done** | Instances of a main on any page; publish a project as a library, enable, update and disable it in other projects (Libraries modal, `*_library` tools). |
| 13 | Export design tokens | **done** | `get_variables` with `format: "css"`, `"json"` (W3C DTCG + modes), or `"tailwind"` (`@theme`), plus Copy CSS / Copy JSON in Design tab. |
@@ -32,7 +32,7 @@ Agents can already build a design system through the tools; people cannot yet ma
| # | Item | Status | Notes |
|---|---|---|---|
| 15 | MCP and CLI tool surface | **done** | 67 tools, one undo step per call, `papercraft-cli list / schema / call`. |
| 15 | MCP and CLI tool surface | **done** | 94 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 | **done** | `get_jsx` supports `format: "inline"`, `"tailwind"` (Tailwind utility classes), and `"html"` (HTML + CSS rules with `:root` variables). |
@@ -63,7 +63,7 @@ Agents can already build a design system through the tools; people cannot yet ma
| 32 | Version history | **done** | Named and automatic saved versions per page: rename, restore, delete (`src/versions.rs`). Restore now includes variables and tokens; compare todo. |
| 33 | Figma import | **done** | `.fig` binary (Kiwi wire decoder + zip container) and Figma REST API JSON import in `src/io/fig.rs`. |
| 34 | Multiplayer and cloud sync | partial | Optional shared projects through the relay, with merge of concurrent edits to one page. Presence (cursors, selections) todo. Stays 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. |
| 35 | Release builds for Windows and Linux | partial | Windows x86_64 zips are released and `packaging/windows` builds an MSI; Linux x86_64 is not in the current releases. |
## 5. Quality
+124
View File
@@ -0,0 +1,124 @@
# Project rules
The standards a change to PaperCraft is reviewed against. [CONTRIBUTING.md](CONTRIBUTING.md) says
*how* to contribute; this file says what "good" means once you do.
## Code
* **Immutability by default.** Return new values rather than mutating shared ones. The document
changes only through `Editor::edit` transactions, so every change is one undo step and is
logged for the renderer's cache.
* **Small, cohesive files.** 200 to 400 lines is typical, 800 is the ceiling. Split a module
when it grows past that. Organise by feature, not by type.
* **Small functions** (under about 50 lines) and **shallow nesting** (4 levels at most); prefer
early returns.
* **Handle errors explicitly.** No `unwrap` or `expect` on user data, file contents or network
responses; return an error with context. User-facing messages are plain language. Never swallow
an error silently.
* **Validate at the boundaries**: MCP and CLI arguments, imported files (Figma, Excalidraw, SVG),
project folders and anything from the relay. Treat all of it as untrusted.
* **No magic numbers.** Name thresholds, sizes and delays as constants; UI measures come from
the theme, not literals.
* **No dead code, debug prints or commented-out blocks.**
* **Match the surrounding code**: naming, comment density and idiom. Comments say *why*, not
*what*.
* **Cross-platform.** Code must build on Windows, macOS and Linux and for the web build. Gate
platform code with `cfg`, never assume a path separator, and keep
`cargo check --lib --target wasm32-unknown-unknown` passing.
* **No new heavy dependencies** without discussion. Licenses must be compatible with
MIT OR Apache-2.0.
## Data safety
PaperCraft stores people's work. Treat that as the top priority.
* Writes to projects are **atomic** (`src/io/atomic.rs`); never write a page in place.
* A format change bumps the project format version and ships a **migration**. Older data must
keep loading; if a newer build writes something an older build cannot read, the older build
must refuse it rather than corrupt it.
* Agent and sync paths must never silently drop a layer. If a merge cannot keep something, it
says so.
* Destructive operations are undoable or confirmed.
## The machine you build on
A full build links dozens of test binaries and can take every core and most of the memory of a
desktop. The person is still using that machine, often with PaperCraft open.
* **Look before you start a heavy job.** Check free memory, CPU load and what is already running
(the person's app, another build, other agents) before a build, a test run or clippy. If memory
is short or another build is running, wait or ask.
* **Leave room.** Cap Cargo at no more than half the logical cores (`-j 8` on a 16-core machine,
or `CARGO_BUILD_JOBS`) and the test harness too (`-- --test-threads=8`); go lower when memory is
tight, since linking is the peak. Run heavy jobs at low priority (`nice` on Unix, Below Normal
on Windows).
* **One heavy job at a time.** Never run two builds side by side; each target folder is a full
build's worth of compiling and linking. Parallel agents share one build slot.
* **Run only what your change touches**: `cargo check` before `cargo build`, and the test
targets that cover the change (`cargo test --test incremental`). The full suite runs on GitHub
before a release (`.github/workflows/test.yml`), not on a desktop.
* **Stop at once when the person says the machine is slow**, then check that no compiler, linker
or test process of yours is left over.
* **Clean up.** Delete scratch target folders and worktrees when the work is merged; they grow to
tens of gigabytes.
## UI
* **Use the shared kit.** Controls, spacing, radii and colours come from `craft-ui`,
`src/ui/kit.rs` and the theme, not one-off values.
* **Themes are modes.** Never branch on a theme to pick a colour; read it from the palette.
* **Nothing overflows.** Text truncates with an ellipsis, rows keep their controls inside, and
panels step aside when the window is narrow. Test a narrow window and a long name.
* **Targets are at least 44 px** where a person taps or clicks something primary, and everything
stays reachable from the keyboard.
* **Animation is quick**: eased and under about 200 ms.
* **Look at it.** Run the app or take a headless screenshot at scale 2 in each theme before
asking for review.
## MCP tools and the CLI
* Every tool has a precise one-paragraph description, a JSON schema, and a test in
`tests/mcp.rs`. Descriptions are what agents read; write them for that reader.
* One tool call is **one undo step**.
* Names follow the existing style (`verb_noun`); reuse Paper-compatible names and argument shapes
where a tool has an equivalent.
* Reads are cheap and bounded. Apply limits, say when you truncated, and never dump the whole
document by default.
* The server listens on `127.0.0.1` only and rejects browser origins. Do not weaken that.
* Update the tool count in the README and ROADMAP when you add or remove tools.
## Designs made with PaperCraft
These apply to example projects, templates, screenshots and skill content, and are what the
skills teach agents.
* **Themes are modes, never copies.** One artboard per screen. Give each variable a value per
mode and switch with `set_variable_mode`. Do not duplicate artboards as light and dark
versions, and do not rebuild a screen with other hex values.
* **Every colour on a themed screen is a variable** (`var(--name)` on fills and text,
`bind_variable` for strokes, opacity and visibility).
* **No overflow.** Nothing extends past its artboard or clipping parent. Scrolling content is
clipped inside its viewport and ends above floating bars with a fade and enough bottom padding.
Check each artboard programmatically *and* by screenshot in each mode.
* Run `lint_design` and fix what it finds: contrast, the 4 px grid, type scale and 44 px targets.
* Release design locks when you are done so the next person can inspect.
## Skills and docs
* Skills in [skills/](skills) are instructions for agents: keep them short, concrete and tested
against a real agent run.
* Docs describe what exists. Do not document a feature that is not built; put plans in `specs/`
and `ROADMAP.md`.
* Screenshots come from the real app, in a generic project (no personal data, local paths or
real names), at scale 2.
## Branding
The tree carries no third-party product branding; `.debrand` lists the forbidden patterns and
the check runs in review. Describe PaperCraft on its own terms.
## Review
A pull request is approved when it has no unresolved **critical** (security, data loss) or
**high** (bug, significant quality) issues. Medium and low comments are suggestions the author
may take or decline with a reason.
+29
View File
@@ -0,0 +1,29 @@
# Security policy
## Supported versions
PaperCraft is an alpha. Security fixes go into the latest release and `main`.
## Reporting a vulnerability
**Do not open a public issue.** Use GitHub's private reporting: the repository's **Security** tab,
then **Report a vulnerability**. Include what you found, how to reproduce it, the version and
platform, and the impact you see.
A maintainer will acknowledge the report as soon as they can and keep you updated until it is
fixed. Please give us reasonable time to ship a fix before you disclose it.
## What counts
Examples of in-scope problems:
* the MCP server (`127.0.0.1:29980`) or the relay accepting requests it should reject (browser
origins, DNS rebinding, another user);
* path traversal or code execution from a project folder, a design-system or library package, or
an imported Figma, Excalidraw or SVG file;
* the updater or the agent integrations running something the person did not approve;
* secrets (an Anthropic key, relay tokens) written to disk unprotected or sent anywhere
unexpected.
PaperCraft is local-first: it opens no network port beyond the loopback MCP server unless you
share a project through a relay you run.
+13 -1
View File
@@ -1,6 +1,6 @@
# Driving PaperCraft with agents
PaperCraft exposes one set of 70 tools 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.
PaperCraft exposes one set of 88 tools 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 |
|---|---|---|
@@ -108,6 +108,18 @@ write_html {
EOF
```
## Checking only what changed
On a big page a full `lint_design` after every edit repeats the same output, and a `get_node_info` call per layer is slow (about 53 s for 397 layers). Check the change, then check everything once.
* Every write tool result includes `rev`, the document revision after the call. `get_basic_info` reports the current `rev`. Reads never change it.
* `get_changes {since, page?}` returns `{rev, since, truncated, changes: [{page, ids}]}`: what the edits since `since` touched. `truncated: true` means the log no longer reaches that far back, so do a full pass.
* `lint_design {changedOnly: true}` lints since this client's last lint; `{since: rev}` lints since a revision you name. A scoped lint checks the touched layers plus their ancestors, descendants, siblings and every layer bound to a changed variable, and reports its coverage in `covered`, for example `{checked: 12, total: 397, since: 40, partial: true}`. Plain `lint_design` is a full pass (`partial: false`). The `overflow` rule flags a layer that extends past its parent or clipping artboard (scroll frames are excluded).
* `get_bounds {nodeIds?, pageId?, depth?}` returns `columns` `[id, parent, x, y, w, h, absX, absY, clip]` and one row per layer for a subtree in one call (at most 2000 rows; then `truncated` and `continueWith`, the nodeIds for the next call). Use it instead of looping `get_node_info`.
* Always finish with one full `lint_design` (no arguments) and a screenshot per artboard in each theme mode before reporting. A partial lint speeds up the loop; it does not replace that pass.
In the app, read-only calls no longer draw per-layer outlines or keep the "Working" pill lit for long. The agent workflow, with an example, is in [incremental-checks.md](../skills/papercraft/references/incremental-checks.md).
## Design-system tools
* Variables: `get_variables` and `set_variables` take collections, groups (`color/brand/primary`), types (`color`, `number`, `string`, `boolean`), `mode`, `alias`, `description` and `hidden`; the old `{"name":"#hex"}` call still works and lands in the first collection. `create_collection` makes a collection with modes, `delete_variables` removes by name (refuses aliased ones unless `force`), `bind_variable` binds or detaches a field of layers (`stroke`, `text-color`, `width`, `height`, `gap`, `padding`, `radius`, `font-size`, `line-height`, `opacity`, `visible`).
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 MiB

BIN
View File
Binary file not shown.
+2
View File
@@ -93,6 +93,8 @@ Fill-based sets (Phosphor, Material) follow variables; Lucide is stroke-based an
| `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) |
| `overflow` | error | a layer extending past its artboard or clipping parent (by how many px, on which side), or text taller than its box. Scrolling frames and layers pinned in them are exempt. Never auto-fixed by `fix: true`: resize, move or let the parent grow. `get_bounds` returns every rect in one call |
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.
## Libraries
Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 385 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 387 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 355 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 416 KiB

+83 -153
View File
@@ -1,180 +1,110 @@
---
name: papercraft-critique
description: Pre-emit critique gate for work built in PaperCraft. Five panels (designer, critic, brand, accessibility, copy) score a screen 0-10 against PaperCraft's own objective evidence (lint_design, verify_code, get_screenshot, get_design_spec, get_variables), the weighted composite must reach 8.0, and a failed gate ships the best round instead of the last one. Use when an agent is about to hand over a PaperCraft screen and wants a tool-backed review instead of a self-praise.
description: Critique finished PaperCraft work the way a design team does - against the person's objective, through independent reviewers (the person's vision, UX heuristics, flow across screens, QA states, visual, accessibility, copy, an end user from the audience), each finding rated 0-4 and backed by tool evidence, then fixed, reported and, when the person approves, kept as a lesson. Use when finish_working_on_nodes offers a critique, when asked to review or critique a design, or before handing over a screen or flow.
---
# papercraft-critique
A gate, not a compliment. Before a screen leaves the app it goes through five panels. Each panel is a rubric pass over the same artifact, and each panel may only cite what a PaperCraft tool actually returned. The panels produce five scores, the scores produce one composite, and the composite decides whether the work ships. Mechanics live in the `papercraft` skill, taste in `papercraft-design`; this skill is the gate.
A critique judges a design against what it is for, not against taste. The tool `critique_design`
hands you the evidence and the reviewers; this skill is how to run them. Mechanics live in the
`papercraft` skill, taste in `papercraft-design`. Per-reviewer checklists:
[personas.md](references/personas.md).
Reference: [panels.md](references/panels.md) (what each panel inspects, which tool backs it, its 0–10 bands).
## 1. When
## 1. When the gate runs
* `finish_working_on_nodes` returns `next`: ask the person (`ask_user`) whether to critique now.
* The person asks "review this", "is it ready?", "critique it".
* Before handing over a whole screen or flow. Not for one component, an early draft or a copy tweak.
Run it when the resolver (section 2) says on, or when the request is a review of finished work:
## 2. Objective first
* a whole screen is about to be handed over: landing page, dashboard, sign-in, settings, an onboarding flow, a whiteboard plan;
* a redesign of an existing screen, after the edits and a clean `lint_design`;
* the user asked "does this look right?", "review this", "critique it", "is it ready to ship?".
Critique without an objective is opinion. Before judging, have:
Do not run it for: a single component or icon, an exploratory draft before a direction is chosen (the `papercraft-design` section 0 step), or a one-line copy tweak. The gate costs a lint and a screenshot per round; spend it on work that is finished.
* **Objective**: what the work must achieve ("a returning customer reorders in two taps").
* **Audience**: who uses it, in what context (phone, in a hurry, first visit, low vision).
* **Top tasks**: the two to five things people come to do.
If the gate is on for a run, it must produce a status. Never skip it silently: report `Degraded` with the reason.
Take them from the brief, the spec, the person's notes (`get_feedback`) and references. Missing?
Ask with `ask_user` and pass them to `critique_design {objective, audience}`.
## 2. Enable resolver
## 3. The reviewers
Four levels, highest priority first. Take the first level that gives an explicit answer; "unset" falls through to the next one. A higher level saying *off* is final — a lower level cannot turn the gate back on.
| Order | Level | PaperCraft equivalent | How to read it |
|---|---|---|---|
| 1 | Per-task policy | the instruction the agent was given for this run | "run the critique gate before you finish", "review this screen" |
| 2 | Per-project setting | a toggle stored in the project's own metadata | **not implemented today** — PaperCraft has no project-level flag. Treat this level as unset and fall through to the environment flag or the per-task instruction |
| 3 | Environment flag | `PAPERCRAFT_CRITIQUE_ENABLED` | `1` / `true` / `always` = on, `0` / `false` / `never` = off, unset = fall through |
| 4 | Rollout phase | the release phase of the skill itself | off by default: the gate ships disabled until a run or a project turns it on |
So today the gate turns on in one of two ways: the user asks for it in the run's instructions, or `PAPERCRAFT_CRITIQUE_ENABLED=1` is in the environment. A future project setting — stored with the project, not with the user — would sit between those two and win over the environment, so a project could opt in permanently without an env var.
## 3. Order of operations
Objective checks first, subjective panels second. A panel never scores above evidence that contradicts it.
One round is: **evidence → repair → judge → score**.
1. **Snapshot.** Note the `pageId` and the artboard `nodeId` under review and keep them for the whole gate. A page switch between rounds invalidates the evidence.
2. **Evidence.** `lint_design` (`nodeId`, `pageId`, no `fix` yet) for the raw findings — the payload counts `errors`, `warnings` and `info`, groups them in `byRule`, and lists each issue with its `rule`, `severity`, `node`, `message` and `fix`; `get_screenshot` of the same node (`scale: 2` to read small text); `get_variables` for the token set and the active mode; `get_design_spec` (`nodeId`, `target`) when the design is headed for code. If there is code to check, `verify_code` (`nodeId`, `target`, `code`).
3. **Repair the hard evidence.** Fix every `lint_design` error and warning and re-run the lint until it is clean; fix every `verify_code` violation until `passed` is true. A panel cannot be scored on a design that still carries a lint error.
4. **Judge the five panels** on the evidence from steps 2–3 plus the screenshot. Every finding cites the tool result or the screenshot region behind it.
5. **Score.** Five scores 0–10, then the composite (section 5). Composite ≥ 8.0 → status `Shipped`, stop, `finish_working_on_nodes`.
6. **Otherwise fix and go again.** Take the findings in composite-impact order, fix them, run round N+1. Maximum 3 rounds.
7. **Ship the best round** if round 3 is still below the threshold (section 6).
The panels are rubric passes by the agent that holds the tools: one agent, five passes, five different rubrics. A runtime that can spawn subagents may run the panels in parallel, but the evidence vetoes, the composite and the round counter belong to the coordinator.
## 4. Hard evidence vetoes a panel
`lint_design` and `verify_code` are not suggestions. Each finding belongs to one panel, and that panel cannot score above the band its evidence forces.
| Evidence | Rule | Owner panel | Cap |
|---|---|---|---|
| `lint_design` error | `contrast` | Accessibility (the Critic reads it too) | 3.9 |
| `lint_design` warning | `text-size` (under 12px) | Accessibility | 6.9 |
| `lint_design` warning | `spacing-grid` (off the 4px grid) | Designer | 6.9 |
| `lint_design` warning | `touch-target` (under 44px) | Accessibility | 6.9 |
| `lint_design` warning | `layer-name` (default names) | Designer | 6.9 |
| `lint_design` warning | `hard-coded-color` | Brand | 6.9 |
| `lint_design` info | `type-scale` | Designer | none — must be listed |
| `verify_code` error | `missing-text` | Copy | 3.9 |
| `verify_code` error | `hardcoded-color` | Brand | 3.9 |
| `verify_code` error | `forbidden-absolute` | Designer | 3.9 |
| `verify_code` error | `missing-layout` | Designer | 3.9 |
| `verify_code` warning | `missing-component` | Brand | 6.9 |
| `verify_code` warning | `spacing-drift` | Designer | 6.9 |
An error caps the owning panel at 3.9 (Blocker band), a warning at 6.9 (Weak band), an `info` item caps nothing but must appear in the panel's notes. `hard-coded-color` arrives in both flavours: a warning when the color equals a variable that exists, an info when it is a literal used several times — the warning caps Brand, the info does not. The cap lifts as soon as the finding is gone; the next round re-scores the panel freely.
A finding is evidence for its owner and a hint for the others: `contrast` also informs the Critic, `spacing-grid` also informs the Brand (was it the token or the layout?), but only the owner is capped.
## 5. The panels and the composite
| Panel | Judges | Weight |
| Persona | Asks | Method |
|---|---|---|
| Designer | layout, composition, hierarchy, spacing rhythm, type scale in use | **0 in v1** — it still scores and its findings still drive the next round |
| Critic | does it meet the brief; contrast, weight, readability | 0.4 |
| Brand | token compliance, on-brand color, voice | 0.2 |
| Accessibility | WCAG, focus rings, semantic structure, alt text | 0.2 |
| Copy | voice, tone, terseness, error-message quality | 0.2 |
| `vision` | Does it achieve the objective, as the person imagined it? | Objective, element, effect, why (Connor and Irizarry) |
| `ux` | Can people use it without help? | Nielsen's 10 heuristics |
| `flow` | Do the screens make one coherent journey per task? | Task walkthrough across screens, 4 questions per step |
| `qa` | What breaks with real data and states? | State and edge-case matrix, pass/fail/n.a. |
| `visual` | Does the eye land right, does it hold together? | Hierarchy, balance, contrast, gestalt, rhythm, tokens |
| `accessibility` | Can everyone use it? | W3C POUR applied to a design |
| `copy` | Is every word clear and specific? | Plain language, labels, errors, empty states |
| `end-user` | Where would this user get stuck or give up? | Persona walkthrough of the top task |
```
composite = critic x 0.4 + brand x 0.2 + accessibility x 0.2 + copy x 0.2
```
`vision` always runs: the person's intent is the yardstick for the others. Pick the rest with
`personas` (default: all). The person's own words outrank any reviewer.
The weights sum to 1.0, so the composite is also 0–10. The Designer carries weight 0 in v1 on purpose: it is the panel most likely to argue with itself, so its score is reported and its findings are acted on, but it does not move the composite.
## 4. Independence
Each panel scores 0–10 in half-point steps. The composite is the weighted sum, rounded to two decimals.
Several reviewers find far more than one, but only when they work apart (Nielsen: one
evaluator finds about a third of the problems, three to five most of them). Reviewers who
discuss or debate converge and amplify each other's bias.
| Composite | Verdict |
* **With subagents**: one subagent per persona, given only its persona brief, the objective and
the evidence. No reviewer sees another's findings before rating.
* **Alone**: one pass per persona, in order, writing that persona's findings before reading the
next brief. Do not revise earlier personas' findings during later passes.
* **You coordinate**: you merge and judge; you never let reviewers argue a finding out.
## 5. Evidence
From `critique_design`: lint (fix every error and warning first), the `flow` graph (arrows between
screens, `deadEnds`, `unreached`), the lessons. Then `get_screenshot` each artboard at scale 1
(2 for small text) in every variable mode, `get_bounds` for overflow, `get_feedback` for the
person's notes. Every finding cites one of these: a tool result or a screenshot region. No
evidence, no finding.
## 6. A finding
`persona`, `heuristic or check`, `element` (layer name), `seen` (the observation), `effect` (on
the objective), `evidence`, `severity`, `suggestion`. Keep the observation and the suggestion
apart: critique is analysis, not brainstorming. Each persona also names at least one thing that
works and should stay.
Severity (Nielsen), from frequency, impact and persistence:
| | |
|---|---|
| ≥ 8.0 | Ship |
| < 8.0 | Not shippable as is: fix and re-run, or ship the best round after round 3 |
| 0 | not a problem |
| 1 | cosmetic: fix if time allows |
| 2 | minor: low priority |
| 3 | major: fix before handing over |
| 4 | catastrophe: blocks the task, fix now |
The band legend is the same for every panel:
Every broken lesson (`lessons`) is a finding of at least 3.
| Band | Range | Means |
|---|---|---|
| Blocker | 0.0–2.9 | Broken: the panel's own evidence is violated. Cannot ship |
| Poor | 3.0–4.9 | Works against the brief or the brand; ships visible damage |
| Weak | 5.0–6.9 | Passable but noticeably off; expect a second round |
| Acceptable | 7.0–7.9 | No blocker, but a reviewer would ask for one change |
| Good | 8.0–9.4 | Ships as is |
| Excellent | 9.5–10.0 | Reference quality for this project |
## 7. Merge, fix, report
What each panel inspects, which tool backs it and where its scores land: [panels.md](references/panels.md).
1. **Merge** into one list: join duplicates, count the personas that found each (two or more is
confirmed), severity is the mean of their independent ratings.
2. **Fix** every 3 and 4 with the smallest edit (`update_styles`, `set_text_content`), then check
that finding again with the same evidence. The work is ready when no 3 or 4 is open and lint is
clean. Three fix rounds at most; then hand over with the open findings stated.
3. **Report** to the person: what works, then findings by severity, each with what was seen, the
evidence, the suggestion and whether it was fixed. Phrase open ones as questions about the
objective ("How does a first-time user find Reorder from here?"), not verdicts.
## 6. Rounds and the fallback
## 8. Lessons
* At most **3 rounds** per gate. The counter is per gate, not per panel.
* A round ends when the composite is computed.
* Rounds 2 and 3 exist to fix what the panels found. Fix in composite-impact order: the panel with the largest `weight x (10 - score)` first.
* **`fallbackPolicy: ship_best`.** If the third round is still below 8.0, the status is `Below threshold` and what ships is the best round, not the last one. Best = highest composite; a tie goes to the earlier round (fewer changes). A later round edited the canvas, so put the canvas back into the best round's state before handing over — undo back to it, or re-apply that round's fix list in order.
* Ship the best round's artifact and report the failing score. `Below threshold` is a status, not a licence to hide the number.
Ask the person with `ask_user` which findings to remember, one question per finding: for every
project, for this project, or not. Save only what they approve, with
`design_lessons {add: {rule, why, scope}}`: the rule is one line written as an instruction
("Screens keep their device height; the main column scrolls"), `why` is what went wrong. Never
save a lesson the person did not approve, and never save praise. Lessons come back in
`get_basic_info` and in the next `critique_design`; `design_lessons {remove: "L3"}` drops one.
## 7. Status vocabulary
## Sources
Every gate ends with exactly one status.
| Status | When | What ships |
|---|---|---|
| `Shipped` | composite ≥ 8.0 | the reviewed artifact, unchanged |
| `Below threshold` | still < 8.0 after round 3 | the best-scoring round's artifact (`ship_best`), with the score reported |
| `Timed out` | the gate ran out of its budget (rounds or minutes) before a verdict | the best round so far, with the timeout stated |
| `Interrupted` | the user or the session stopped the gate mid-run | the last complete round's artifact |
| `Degraded` | a panel could not be judged properly (section 8) | the artifact, but the verdict is provisional and the reason is named |
`Degraded` outranks `Shipped`: a degraded panel is capped at 6.9, so a degraded run reports `Degraded`, not `Shipped`, even when the composite clears 8.0.
## 8. Degraded reasons
A panel that cannot be judged on real evidence is not scored as if it were fine. It is capped at 6.9, the composite is computed with the cap, and the run reports `Degraded` with the reason and the panel it hit.
| Reason | Cause | Handling |
|---|---|---|
| `malformed_block` | a tool returned something the gate could not parse (lint payload in an unexpected shape, a truncated screenshot answer) | retry the tool once; if it still fails, judge the panel on the evidence you do have and name the missing part |
| `oversize_block` | the evidence was too big to read whole (a screenshot scaled down past legibility, a tree deeper than the context, a lint payload whose `truncated` flag is set) | narrow the scope: screenshot or lint a subtree, split the screen into regions and judge region by region |
| `adapter_unsupported` | the requested target or format is not supported here (a `verify_code` target the project does not build) | drop that check; the panel falls back to the screenshot plus the design rules |
| `protocol_version_mismatch` | the MCP server exposes a different tool set than the gate expects (a tool renamed, an argument changed) | stop using the broken tool, finish what you can, report `Degraded`; do not guess the schema |
| `missing_artifact` | the thing under review is gone (artboard deleted, page switched, stale node id) | re-snapshot with `get_basic_info` and restart the gate; if the artifact is truly gone, report `Degraded` |
Degradation is a report, not an excuse: name the reason, the panel and the evidence you lost.
## 9. What the gate reports
End the gate with the status, the numbers and the findings, in that order:
```
Critique gate - Sign in (page p1, artboard 3fa9c1:7)
Round 2 of 3 - composite 8.44 / threshold 8.00 - status Shipped
Designer 7.5 (weight 0) | Critic 8.5 | Brand 8.5 | Accessibility 8.5 | Copy 8.2
Composite = 8.5x0.4 + 8.5x0.2 + 8.5x0.2 + 8.2x0.2 = 8.44
```
Then, per finding: the panel, what was seen, the evidence behind it (tool and finding, or the region of the screenshot), the fix, and whether it was fixed in this round or is still open.
Machine-readable form, when the caller wants JSON:
```json
{
"status": "Shipped",
"threshold": 8.0,
"composite": 8.44,
"round": 2,
"rounds": [{ "round": 1, "composite": 7.35 }, { "round": 2, "composite": 8.44 }],
"scores": { "designer": 7.5, "critic": 8.5, "brand": 8.5, "accessibility": 8.5, "copy": 8.2 },
"weights": { "designer": 0.0, "critic": 0.4, "brand": 0.2, "accessibility": 0.2, "copy": 0.2 },
"degraded": [],
"findings": [{ "panel": "critic", "evidence": "get_screenshot: headline at 16px over a photo", "severity": "warning", "state": "fixed" }]
}
```
## 10. Attribution
The five-panel critique contract — the roles, the weights, the composite, the 8.0 threshold, the three rounds and the `ship_best` fallback — is inspired by OpenDesign's Critique Theater (https://github.com/nexu-io/open-design, Apache-2.0). The contract is re-implemented here in PaperCraft's own words and against PaperCraft's own tools; no text was copied.
Adam Connor and Aaron Irizarry, *Discussing Design* (objective, element, effect, rationale);
Nielsen Norman Group: 10 usability heuristics, severity ratings, cognitive walkthroughs, design
critiques, visual design principles; W3C WAI accessibility principles (POUR); work on panels of
independent LLM judges (debate amplifies bias, a coordinating judge resists it).
@@ -1,182 +0,0 @@
# Panel rubrics
Five panels, one artifact, one set of evidence. A panel is a pass with a fixed rubric: what it inspects, which tool output backs it, what it does **not** judge, and the band each finding lands in. The panels share the band legend in [../SKILL.md](../SKILL.md) (Blocker 0–2.9, Poor 3–4.9, Weak 5–6.9, Acceptable 7–7.9, Good 8–9.4, Excellent 9.5–10).
## How to run one panel
1. Read the evidence you were handed: the lint payload, the screenshot, the design spec, the variables, the code result.
2. Write findings. Every finding cites its evidence — the lint rule and node, the region of the screenshot, the spec's MUST rule, the `verify_code` violation.
3. Score the panel 0–10. The score is the panel's verdict on the whole screen, not an average of its findings: two serious defects on one region cost more than two trivial ones on two regions, but a screen with three medium defects is not "Good" either.
4. Apply the caps. Hard evidence caps the owning panel (SKILL.md section 4); degraded evidence caps it at 6.9 (SKILL.md section 8).
Evidence a panel may not use: taste, guesses about a user's preferences, or a claim about something no tool showed. When a panel wants to judge what no tool exposes — whether an image is meaningful, whether a flow makes sense — it raises the question as an open item instead of scoring it as a defect.
Tool arguments used below are the current names. Note that `lint_design`'s `scale` is the list of allowed font sizes, not a zoom; the zoom argument of `get_screenshot` is `scale: 1 | 2`.
---
## Designer — weight 0 in v1
**Judges:** layout and composition, alignment, spacing rhythm, hierarchy, the type scale in use, how things are grouped, screen states, overflow, density.
**Evidence it uses**
| Tool | How |
|---|---|
| `get_screenshot` (`nodeId`, `scale: 2`) | the primary evidence: alignment, edges, rhythm, hierarchy, clipping, empty areas |
| `lint_design` (`nodeId`, `pageId`) | `spacing-grid`, `type-scale`, `layer-name` findings |
| `get_design_spec` (`nodeId`, `target`) | flow and sizing (`fixed`, `hug`, `fill`), grouping, where the spacing rules were broken |
| `get_tree_summary` (`nodeId`, `depth`) | structure depth, unnamed frames, accidental nesting |
**Does not judge:** contrast and WCAG (Accessibility), token and palette choices (Brand), wording (Copy).
**Bands**
| Band | What the Designer saw |
|---|---|
| 0.0–2.9 | Broken layout: frames overlap, content sits outside the artboard, text is clipped, the screen reads as a pile |
| 3.0–4.9 | Poor composition: everything on the default grid, equal weight everywhere, no grouping, ragged edges |
| 5.0–6.9 | Weak: spacing off the 4px grid in places, sizes off the type scale, mixed grouping (space *and* containers everywhere), a state missing |
| 7.0–7.9 | Acceptable: coherent, but one region is crowded or one rhythm is broken |
| 8.0–9.4 | Good: clear hierarchy, aligned to a few strong edges, consistent rhythm, states present |
| 9.5–10.0 | Excellent: deliberate rhythm, one memorable idea, whitespace used as a material |
**Fixes that move the score:** snap spacing to the 4/8 scale (`lint_design` with `fix: true`), pull one size step per level, give each group either a container or space — not both, align to one left edge, add the missing empty/loading/error state.
---
## Critic — weight 0.4
**Judges:** whether the screen meets the brief (the request this run is executing), whether visual weight matches the priority, readability as a human experiences it, and whether the real cases are covered.
**Evidence it uses**
| Tool | How |
|---|---|
| the brief | the user's request and the `papercraft-design` section 0 direction, read against the screenshot |
| `get_screenshot` (`nodeId`, `scale: 2`) | one primary action, reading order, what is above the fold, legibility by eye |
| `lint_design` (`nodeId`, `pageId`) | `contrast`, `text-size`, `type-scale` findings as the objective part of readability |
| `get_design_spec` (`nodeId`, `target`) | what the screen actually contains, against what was asked for |
| `verify_code` (`nodeId`, `target`, `code`) | when the design is code-bound: whether the implementation kept the intent |
**Does not judge:** which palette (Brand), line-level wording (Copy), implementation details beyond fidelity to the design.
**Bands**
| Band | What the Critic saw |
|---|---|
| 0.0–2.9 | Misses the brief: wrong screen or wrong content, the primary action is absent or ambiguous |
| 3.0–4.9 | Fights the brief: right elements, wrong order or emphasis, or hard to read (contrast errors, tiny type) |
| 5.0–6.9 | Weak: it does the job, but the weight is flat and something important sits below the fold or is buried |
| 7.0–7.9 | Acceptable: meets the brief; one area stays unclear or the priority is not obvious |
| 8.0–9.4 | Good: the brief is met, the primary action is obvious, readability is solid |
| 9.5–10.0 | Excellent: unmistakable hierarchy, the screen reads in one pass, the edge cases are covered |
**Fixes that move the score:** make one thing clearly primary and quiet the rest, move the important content up, cut a competing element, re-read the brief line by line and check each requirement against the screenshot.
---
## Brand — weight 0.2
**Judges:** token compliance, on-brand color, semantic color use, mode coverage, product voice consistency, component reuse.
**Evidence it uses**
| Tool | How |
|---|---|
| `get_variables` (`pageId`, `format`) | the variables, tokens, active `mode` and available `modes` — the reference palette |
| `lint_design` (`nodeId`, `pageId`) | `hard-coded-color` findings |
| `get_computed_styles` (`nodeIds`) / `find_nodes` (`filters`) | fills and text colors that are not following a variable |
| `get_design_spec` (`nodeId`, `target`) | which tokens the design already binds |
| `get_components` / `get_tree_summary` | components built and instanced versus copies |
| `verify_code` (`nodeId`, `target`, `code`) | `hardcoded-color`, `missing-component` in the implementation |
**Does not judge:** composition (Designer), wording line by line (Copy), whether the layout serves the brief (Critic).
**Bands**
| Band | What the Brand saw |
|---|---|
| 0.0–2.9 | Off-system: hard-coded colors where tokens belong, a foreign palette, no modes |
| 3.0–4.9 | Off-brand: technically on-token but the accent is used everywhere, or the screens read as different products |
| 5.0–6.9 | Weak: one or two hard-coded colors, the accent covers much of the screen, components copied instead of instanced |
| 7.0–7.9 | Acceptable: on-system, with one token or component inconsistency |
| 8.0–9.4 | Good: every color is a token, the accent marks the primary action only, modes defined and checked, components reused |
| 9.5–10.0 | Excellent: coherent across screens and modes, the palette carries meaning |
**Fixes that move the score:** `lint_design` with `fix: true` relinks hard-coded colors that match a variable, add the missing variable before using the color, cut accent back to the primary action, build the repeated part once and place instances, check the second mode with `set_variable_mode`.
---
## Accessibility — weight 0.2
**Judges:** WCAG contrast, the text-size floor, touch-target size, focus and hover states, semantic structure, alt text, and signalling that does not rely on color alone.
**Evidence it uses**
| Tool | How |
|---|---|
| `lint_design` (`nodeId`, `pageId`) | `contrast`, `text-size`, `touch-target` findings |
| `get_screenshot` (`nodeId`, `scale: 2`) | whether a visible focus or hover state was designed at all, and how the error state reads |
| `get_design_spec` (`nodeId`, `target`) | which text is a title, how the screen is grouped, what each control is |
| `find_nodes` (`textValue`, `filters`) | image and icon nodes, unlabeled icon buttons |
| `get_tree_summary` (`nodeId`, `depth`) | semantic structure: heading levels, groups, control order |
**Does not judge:** visual taste (Designer), palette taste (Brand), voice (Copy).
**Bands**
| Band | What Accessibility saw |
|---|---|
| 0.0–2.9 | Fails WCAG: contrast errors, text under 12px, targets under 44px, meaning carried by color alone |
| 3.0–4.9 | Poor: borderline contrast, unlabeled controls, an error state that is red text and nothing else |
| 5.0–6.9 | Weak: passes on the desktop artboard but fails on the mobile one, one image without alt text |
| 7.0–7.9 | Acceptable: compliant baseline, one gap (no visible focus state, one icon-only button) |
| 8.0–9.4 | Good: WCAG clean, targets pass, semantics and labels present, focus and hover states designed |
| 9.5–10.0 | Excellent: every state specified, sensible keyboard order, no meaning by color alone |
**Fixes that move the score:** fix the color, not the check, until `lint_design` reports no contrast error, name interactive layers `Button…` / `Tab /…` so the target rule can see them and grow them to 44px, pair every error color with an icon or a word, design the focus and hover variants as components, give every image an alt text.
PaperCraft has no focus-ring or alt-text checker: judge those from the design itself (a `Button / Focused` variant, alt text in the spec) and report what is missing as a gap, not as a tool finding.
---
## Copy — weight 0.2
**Judges:** voice and tone against the product's voice, terseness, labels and microcopy, error-message quality, empty states, placeholders versus labels, the absence of filler.
**Evidence it uses**
| Tool | How |
|---|---|
| `get_design_spec` (`nodeId`, `target`) | the authoritative copy: every text node's content, in order |
| `get_screenshot` (`nodeId`, `scale: 2`) | how the copy reads in context: line breaks, truncation, all-caps abuse |
| `find_nodes` (`textValue`) | repeated strings, leftover placeholders, "Lorem ipsum", inconsistent labels for the same action |
| `verify_code` (`nodeId`, `target`, `code`) | `missing-text` — copy that was lost between the design and the code |
**Does not judge:** typography and layout (Designer), color (Brand), whether the screen meets the brief (Critic).
**Bands**
| Band | What Copy saw |
|---|---|
| 0.0–2.9 | Wrong: placeholder text, lorem ipsum, or copy that contradicts the brief |
| 3.0–4.9 | Poor: filler adjectives, two voices on one screen, errors that blame the user |
| 5.0–6.9 | Weak: wordy, mixed formality, vague labels ("Submit" where "Save changes" is meant) |
| 7.0–7.9 | Acceptable: clear but plain; one error message says only "Error" |
| 8.0–9.4 | Good: terse, one voice, every action labeled with a verb, errors say what happened and what to do next |
| 9.5–10.0 | Excellent: reads like a product that shipped, not like a mockup |
**Fixes that move the score:** cut every word that does not inform, give each action a verb label ("Save changes", not "OK"), write errors as *what happened + what to do next*, fill empty states with an explanation and the next action, replace placeholder copy with real content, keep one formality level per screen.
PaperCraft has no spell-checker or copy linter: the copy panel reads the spec and the screenshot, and it flags what a human reviewer would flag — it does not invent grammar rules.
---
## Scoring discipline
* Half-point steps only: 7.5, 8.0, 8.5. Never 8.37.
* The score is a verdict on the screen, not an average of findings.
* A panel that finds nothing wrong and cites no evidence scores 7.0–7.9, not 10. A 10 means the screen is a reference for the next one.
* Caps bind: hard evidence (SKILL.md section 4) and degraded evidence (section 8) are applied after the verdict, never negotiated with.
* No panel may raise another panel's score. If the Critic likes the hierarchy, that stays a Designer finding.
@@ -0,0 +1,110 @@
# Reviewer checklists
Each reviewer works from its own list, alone, and cites evidence for every finding. Mark each
item pass, fail or n/a; a state or screen that was never designed is an open question, not a pass.
## vision: the person's vision
For each key element on each screen, four questions (Connor and Irizarry):
1. **Objective**: which part of the objective is this element for?
2. **Element**: which elements serve it?
3. **Effect**: do they achieve it, for this audience?
4. **Why** or why not?
Also: everything the brief, references and feedback notes asked for is there; nothing contradicts
an open or resolved note (`get_feedback`); the tone matches the references. Unknown product facts
are questions for the person, never guesses.
## ux: usability heuristics (Nielsen)
1. Visibility of system status: progress, saving, selection shown at once.
2. Match with the real world: the users' words and order, not the system's.
3. User control and freedom: a marked exit, undo, cancel.
4. Consistency and standards: one word and one look per thing, platform conventions.
5. Error prevention: risky actions confirmed, impossible choices removed.
6. Recognition rather than recall: options visible, no remembering across screens.
7. Flexibility and efficiency: shortcuts for experts that novices need not see.
8. Aesthetic and minimalist design: nothing that competes with what matters.
9. Recognize, diagnose, recover from errors: plain language, the problem, the fix.
10. Help and documentation: needed rarely, and then where the task is.
## flow: coherent journeys
Walk each top task across screens (the `flow` graph when arrows connect screens, else the screen
order and names; ask the person when the intended order is unclear). At each step, the four
walkthrough questions: will they try to do this, will they notice the control, will they connect
it to their goal, will they see progress afterwards?
* Every task has an entry point (including first run and empty state) and a clear end
(success screen or state).
* Every screen has a way forward and a way back or out; no dead ends (`deadEnds`), no screen
nothing leads to (`unreached`) unless it is an entry.
* Each action that can fail has its error state; each destructive one a confirmation and undo.
* Navigation, back and primary actions sit in the same place with the same labels on every screen.
* What the user entered or chose shows on the following screens (the cart count, the name typed).
* Steps a user must remember between screens are shown instead (recognition over recall).
* The number of steps fits the objective ("two taps" means two).
## qa: states and edge cases
Per screen and component, pass, fail or n/a:
* States: default, hover, focus, pressed, disabled, selected, loading, empty, error, success.
* Content: the longest realistic text and names, minimum content, zero, one and many items,
missing images and avatars, large numbers, special characters.
* Modes and sizes: every variable mode (screenshot each); the narrow and wide screen sizes when the
design has them.
* Layout: nothing overflows or clips (`get_bounds`, lint `overflow`), screens keep their device
height and scroll inside, fixed bars stay fixed.
* Consistency: repeated pieces are instances of the same component (lint `not-an-instance`).
## visual
* One focal point per screen; the primary action is the most prominent control.
* Scale and hierarchy: size, weight and colour follow importance.
* Balance and alignment: shared edges, a steady grid.
* Gestalt proximity: related items closer together than unrelated ones.
* Rhythm: spacing from the 4px scale, the same gaps for the same relationships.
* Type: sizes on the scale, at most three levels per screen.
* Tokens: every colour a variable, consistent radii, components reused.
## accessibility (W3C POUR, for a design)
**Perceivable**
* Text contrast 4.5:1, 3:1 for large text (18px, or 14px bold) and for control borders and icons.
* Colour is never the only signal (errors also have an icon or text; links are underlined or bold).
* Text is real text, not pictures of text; icons that act have a label or tooltip.
* The layout holds at 320px wide and with text at 200% (a narrow artboard shows it).
**Operable**
* Touch targets 44px (24px minimum with spacing).
* A visible focus state is designed for every interactive component.
* Headings, a page title and a visible current location (active nav item).
* Destructive actions confirmed or undoable; every gesture has a button alternative.
* Nothing flashes; motion can be reduced.
**Understandable**
* Fields keep a visible label (a placeholder alone disappears).
* Errors say what went wrong and how to fix it, next to the field.
* Navigation and labels are the same across screens.
* Plain language; abbreviations explained.
**Robust**
* Controls are named for their role (layer names, code links to real components), so the code
hand-off keeps them buttons and inputs, not divs.
## copy
* The users' words, not internal jargon; verbs on buttons ("Save changes", not "OK").
* No placeholder or lorem text, no "Item 1".
* Errors: what happened and what to do. Empty states: what this is and the first action.
* Neutral wording about people whose gender is unknown (their name, or they).
* Consistent terms: one word per concept across all screens.
## end-user: a walkthrough as the audience
Take the `audience` (else ask). Write as that person, in the first person, for each top task:
what I see first, what I think it does, what I try, where I hesitate, and the first point where I
would give up. One persona per run; launch another critique with a different `audience` for a
second user type.
+1 -1
View File
@@ -105,7 +105,7 @@ 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.
4. A full `lint_design` (no arguments) has zero errors and zero warnings, and the `info` items were considered. Between edits, lint only what changed: see `papercraft/references/incremental-checks.md`.
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.
+16 -8
View File
@@ -17,7 +17,7 @@ papercraft-cli call get_basic_info # fails with exit 1 if the app is not ru
* **Check the project is the right one.** `get_basic_info` returns `fileName` and `path`. Build only in a project that belongs to the work (a NotesCraft design file is for NotesCraft screens, not for redesigning PaperCraft). For a different product: `create_file {name}`, then `open_file {fileId}`, and say so. `open_file` swaps the project the user is looking at, so tell them. Pass `fileId` on later calls when more than one project is in play.
* 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.
* Read `get_basic_info` (pages, artboards, sizes, fonts, and `lessons`: the person's rules from past critiques; follow every one), 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.
* App closed (server, CI)? `papercraft-cli call --offline ... --project <name>` runs the same tools on the project store and saves after every editing call; `papercraft-cli shell` keeps one session for many calls and reads scripts from stdin. It is refused while the app has that project open. See `docs/agents.md`.
* 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 -`.
@@ -33,8 +33,10 @@ papercraft-cli call get_basic_info # fails with exit 1 if the app is not ru
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.
Between edits, lint only what changed: keep the `rev` each write returns and run `lint_design {changedOnly: true}` (or `{since: rev}`). `get_changes {since}` shows what an edit touched; `get_bounds` measures a whole subtree in one call instead of looping `get_node_info`.
Always finish with one full `lint_design` (no `since` or `changedOnly`: `covered.partial` is false) and a screenshot per artboard in each theme mode before reporting. Details: [incremental-checks.md](references/incremental-checks.md).
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.
7. **Finish.** `finish_working_on_nodes` (saves). Tell the user what you made in a sentence or two, with artboard names, not ids. When its result has `next`, offer a critique with `ask_user`; on yes run `critique_design` and follow its steps (the `papercraft-critique` skill has the full gate). Keep a finding as a lesson (`design_lessons {add}`) only when the person approved it.
Lint passes are necessary, not sufficient: contrast and grids say nothing about composition. The screenshot is the real test.
@@ -44,6 +46,10 @@ Lessons from redesigning a 3-page, 130-screen project with up to six agents at o
* **Split by section, not by page.** Give each agent named sections (frame ids) and forbid the rest. New sections go below the current lowest one; re-read positions right before creating, since another agent may have just added one.
* **Section layout**: a section frame holds a title and rows of screen slots (a route label above a fixed-size screen). Keep 640 px between screens and rows and 1600 px between sections, so notes fit beside screens. Stack sections in one column.
* **See what the others changed** before building on their work: `get_changes {since: <your last rev>, exclude: "self"}` lists layers by the client that changed them. `truncated: true` means re-read everything.
* **Name yourself and fence your area.** With the CLI pass `--as <name>` (or `PAPERCRAFT_AGENT`), so writes, conflicts and claims carry your name. `claim_nodes {ids, note}` the sections you will rewrite; others' writes there are refused until you `finish_working_on_nodes` or `release_claims`. A denial names the holder: work elsewhere, never wait in a loop.
* **Writes report conflicts.** A result with a `swarm` block lists layers another agent changed since you last looked: read them with `get_changes` before going on. Pass `ifRev: <your last rev>` to have the write refused instead (`"error": "conflict"`).
* **Undo takes back only your own steps** (`undo`, scope `mine`); it is refused with `undo_blocked` when someone changed the same layers since. Fix forward then; `scope: "page"` undoes whoever was last.
* **Recreate, then delete.** Build the new screen next to the old one, finish it, then delete the old one, and record the deleted ids and names in the report. A cleanup agent once deleted buttons and lost icons by editing in place.
* **Reuse the design system across pages**: `create_instance` / `swap_instance` / `<x-paper-instance>` take a main from any page and stay linked. Never `import_components` to use a component: that copies mains onto the page, and instances made from the copies stop following the design system. `lint_design` flags hand-made look-alikes as `not-an-instance` (a different icon is a different component); fix with `write_html` mode replace and the `<x-paper-instance>` it names.
* **QA pass after a build** (screen by screen at scale 1, one call at a time): text layers with stale fixed widths clip labels ("In prog", "New sess"): set `width: fit-content`. Sidebars and panels fixed at 900 px stop short on taller screens: make them fill the height and let the screen hug with a 900 px minimum. Tags and chips overflowing their box, cards overlapping buttons, footers not pinned, leftover `Text` / `Frame` names, gendered copy about people whose gender is unknown (use their name or neutral wording).
@@ -89,7 +95,7 @@ For a screen with dozens of parts, drive the CLI from a short Python helper (`ca
```
Artboards: mobile 390×844, tablet 768×1024, desktop 1440×900, large desktop 2560×1440 (a 1440p display; ask which the user uses and design that first). A narrow window (1280×800) is its own artboard: collapse the sidebar to an icon rail, make secondary actions icon-only with a tooltip, keep one labelled primary action.
Dot-grid canvas (no pattern fill exists): one `<svg>` with a single path of tiny round-capped dashes, absolute at 0,0 inside the canvas frame: `<svg style="position:absolute;left:0px;top:0px" width="W" height="H" viewBox="0 0 W H"><path d="M16 16h0.01M48 16h0.01..." stroke="var(--surface-2)" stroke-width="2.5" stroke-linecap="round" fill="none"/></svg>`. One vector layer, about 3,000 dots at a 32px step. Its colour is frozen at write time. Create with `height:"fit-content"` when the content decides the height. Replace margin with `gap` and `padding`.
Dot-grid canvas (no pattern fill exists): one `<svg>` with a single path of tiny round-capped dashes, absolute at 0,0 inside the canvas frame: `<svg style="position:absolute;left:0px;top:0px" width="W" height="H" viewBox="0 0 W H"><path d="M16 16h0.01M48 16h0.01..." stroke="var(--surface-2)" stroke-width="2.5" stroke-linecap="round" fill="none"/></svg>`. One vector layer, about 3,000 dots at a 32px step. Its colour is frozen at write time. A screen keeps its device's size; at a device width (390, 1440...) `height:"fit-content"` becomes the device height. Content taller than the screen scrolls by itself: the main column under a fixed sidebar and header, else the screen (write_html says which). Pass `overflow:"visible"` to let a board grow (a landing page drawn in full). Replace margin with `gap` and `padding`.
Full flows: [recipes.md](references/recipes.md).
@@ -100,6 +106,8 @@ Full flows: [recipes.md](references/recipes.md).
| Know the project, pages, artboards | `get_basic_info`, `get_tree_summary`, `get_children` |
| Know what is selected | `get_selection` |
| Inspect one node | `get_node_info`, `get_computed_styles`, `get_jsx` |
| Sizes and positions of a subtree | `get_bounds` (one call, `columns` + `rows`; `continueWith` when `truncated`) |
| What changed since a revision | `get_changes {since}` (`exclude: "self"` for other agents' edits) |
| Find nodes by text or style | `find_nodes` (text contains the phrase, any case, every page) |
| Read all copy in a subtree | `get_tree_summary {nodeId, fullText: true}` (depth counts from the node: rows under a "Screens" frame need depth 3) |
| See it | `get_screenshot` (`nodeId`, `scale` 1 or 2, `annotations: true` to include notes, `mode` to preview a theme without switching) |
@@ -115,19 +123,19 @@ Full flows: [recipes.md](references/recipes.md).
| Component properties | One component with properties instead of one per state: `set_component_props` (`text`, `bool`, `icon`, `enum` options in the overrides format; templates `text`, `active` for tab bars and navs, `status` with [{value, color}]), then `set_instance_props {nodeIds, values}` or `<x-paper-instance component="Badge" props='{"Status":"Rejeitada"}'/>`; `get_components` lists them |
| Merge per-state components | `merge_variants {componentIds, name}` turns `Tab bar / Hoje`, `Tab bar / Lojas`... into one `Tab bar` with an enum, moves every instance and deletes the emptied variants |
| Prototype | `set_prototype_links {links:[{nodeId, to}]}`: back arrows, rows and tabs go to their screens in Present mode |
| Screens | Fixed size (390x844, 1440x900...); content longer than the screen: `overflow: auto` on the screen frame, so it keeps its size and the overflow scrolls on the canvas and in Present (inspector: Scroll overflow; page: Screens scroll) |
| Screens | Fixed size (390x844, 1440x900...); content longer than the screen scrolls by itself (the main column under a fixed sidebar and header, else the screen), or set `overflow: auto` on the frame that should scroll; it keeps its size and the overflow scrolls on the canvas and in Present (inspector: Scroll overflow; page: Screens scroll) |
| Project | `rename_file {name}` (open project stays open), `move_nodes {toPageId}` keeps instances linked and notes attached |
| Shared libraries | `publish_library`, `list_libraries`, `enable_library`, `update_library`, `disable_library` |
| Icons | `search_icons`, `<x-paper-icon name="set:name">`; CLI: `papercraft-cli icons add\|list\|search` |
| Design in a library's look, hand off its code | `apply_ui_kit` (shadcn, antd, reactbits, egui), then `<x-paper-instance>` of its components; `set_code_link` for a component of your own; `get_jsx` / `get_design_spec` then write `<Button variant="outline">` instead of divs |
| Design in a library's look, hand off its code | `apply_ui_kit` (shadcn, mui, mantine, bootstrap, chakra: ~48 components and 5 screen layouts each, re-colour through their variables; antd, reactbits, egui), then `<x-paper-instance>` of its components; `set_code_link` for a component of your own; `get_jsx` / `get_design_spec` then write `<Button variant="outline">` instead of divs |
| Avatars, more icons, photos, flags, QR | `<x-plugin use="avataaars\|dicebear\|iconify\|picsum\|flags\|qr" …/>` inside `write_html` (plugins the user turned on) |
| References and art | `fetch_reference_images`, `zoom_image`, `generate_image` (when the user turned image generation on) |
| Ask the user | `ask_user` (clickable answers; end your turn after it) |
| Hand work to the helper agent | `delegate` (`role` qa, read or build, a self-contained `task`; only when Settings, Chat, Helper is set) |
| Design rules (design-playbook) | `design_rules` (`query` or `rule` id; needs the `playbook` CLI; cite ids in the report) |
| Report a PaperCraft problem | `report_issue` (a draft the user sends; synthetic repro only) |
| Check quality | `lint_design` |
| Export | `export` (PNG, SVG, PDF to files), `export_combined_pdf` |
| Check quality | `lint_design` (`changedOnly: true` or `since` between edits, a full pass at the end; `overflow` flags layers past their artboard or clipping parent) |
| Export | `export` (PNG, SVG, PDF to files), `export_combined_pdf`, `export_presentation` (a client deck: cover, contents, a slide per screen) |
| Point at things for the user | `select_nodes` (`zoom: true`) |
| Take back | `undo`, `redo` |
| Save | `finish_working_on_nodes` |
@@ -160,7 +168,7 @@ Full flows: [recipes.md](references/recipes.md).
| `papercraft-cli` exits 1, "isn't running" | The app is closed, no project is open, or the port differs. Start it and open a project; check `PAPERCRAFT_MCP_PORT`. |
| Call fails with an unknown node id | The id is from another page or was deleted. Re-read with `get_children`; pass `pageId`. |
| `write_html` reports `ignoredStyles` | You used a CSS property that has no equivalent (`margin`, `box-shadow`, `transform`...). Replace it, see [html-subset.md](references/html-subset.md). |
| Content is cut off | The artboard height is fixed. `update_styles` with `height: "fit-content"` or a larger number. |
| Content is cut off | A frame inside the screen clips it (`overflow: hidden`, or a fixed height under a hugging parent). Give the column that should scroll `overflow: auto`; for a board that should grow, `overflow: "visible"` and `height: "fit-content"`. |
| 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 | Wrong `componentId` / `componentKey` (read `get_components`). Mains on other pages and enabled libraries work; no import needed. |
| A page is full of copied components at y=0 | Something imported mains onto the page (`import_components`, or old `write_html` builds). Swap their instances to the design-system mains (`swap_instance {componentKey}`), check a screenshot, then delete the unused copies. |
@@ -37,6 +37,6 @@ How to keep designs small, keep design and code in step, and spend few tokens do
## Sharing and several agents
* Several agents may work on one open project through one MCP server: calls run one at a time, each is one undo step, so they do not corrupt each other. Give each agent its own page or artboard prefix and say so in the brief, so they do not restyle each other's nodes.
* Several agents may work on one open project through one MCP server: calls run one at a time, each is one undo step, so they do not corrupt each other. Give each agent its own page or artboard prefix and say so in the brief, so they do not restyle each other's nodes. Each agent names itself (`papercraft-cli --as <name>`), claims what it rewrites (`claim_nodes`), reads the conflicts its writes report, and undoes only its own steps.
* Several people on a shared project: pages sync as files and are merged when both sides changed the same page (nodes by id, child lists as sets). Edits to different nodes both survive; the same property changed twice keeps the local value. Ask people to keep a page per person when restyling the same frame.
* While an agent works the app shows an animated "working" pill and a ring around the canvas; call `finish_working_on_nodes` when done so it stops.
+4 -2
View File
@@ -28,14 +28,16 @@
Supported: `display:flex` (and `inline-flex`), `flex-direction`, `flex-wrap`, `gap`, `row-gap`, `column-gap`, `padding` (shorthand, per side, `padding-inline`, `padding-block`), `align-items`, `justify-content` (including `space-between`), `align-self` (`start`, `center`, `end`, `stretch`), `flex-grow` / `flex: 1`, `width`, `height` (px, %, `fit-content`), `min-width`, `max-width`, `min-height`, `max-height`, `overflow:hidden` (clips), `position:absolute` with `left`/`top`/`right`/`bottom`, `display:none`, `visibility`.
Not supported (check `ignoredStyles` to be sure): `margin` (use `gap` and `padding`), `display:grid`, tables, `float`, `calc()`, viewport units, `transform` and rotation.
Rewritten before layers are made, each reported in `warnings`: `margin` becomes the parent's `gap` (when uniform) or spacer layers, `margin-*:auto` a growing spacer and `margin:0 auto` centring; `display:grid` becomes rows of cells (`fr` tracks grow, px tracks keep their width, `repeat(auto-fill, minmax())` counts columns from the width); inline boxes (chips, badges) become boxes in a wrapping row; `<input>` and `<textarea>` become a box showing their placeholder or value. Prefer `gap` and `padding` anyway: the result is the same and shorter.
Not supported (check `ignoredStyles` to be sure): 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` / `align-self` says otherwise.
* To center something, put it in a flex parent with `align-items:center; justify-content:center`.
* To make a row fill the remaining space, give the child `flex-grow:1`.
* Use `height:"fit-content"` on artboards that should grow with content; fixed heights clip.
* A screen keeps its device's size; at a device width (390, 1440...) `height:"fit-content"` becomes the device height. Content taller than the screen scrolls by itself: the main column under a fixed sidebar and header, else the screen (write_html says which). Pass `overflow:"visible"` to let a board grow (a landing page drawn in full).
## Text
@@ -0,0 +1,50 @@
# Incremental checks
Check what you just changed between edits, and check everything once at the end. This keeps the loop fast and the context small without skipping the final full pass.
## Why
* A full `lint_design` on a big page walks every layer and returns every finding. Run after each edit, it repeats the same output and burns tokens.
* Looping `get_node_info` over nodes is slower still: one measured loop over 397 nodes took about 53 s. One `get_bounds` call returns the same geometry for a whole subtree in milliseconds, in one result.
* Reads never change the document, so they never change `rev`. Only writes do.
## The flow
1. **Note `rev`.** Every write tool result includes `rev`, the document revision after the call. `get_basic_info` reports the current one. Keep the last value.
2. **Edit.** Make the change (`write_html`, `update_styles`, `set_text_content`, ...). Note the new `rev` from the result.
3. **Lint what changed.** `lint_design {"changedOnly": true}` checks since this client's last lint. `lint_design {"since": <rev>}` checks since a revision you name. A scoped lint covers the touched layers plus their ancestors, descendants and siblings, and every layer bound to a changed variable. The result says how much it covered, for example `{"checked": 12, "total": 397, "since": 40, "partial": true}`.
4. **See what an edit really touched.** `get_changes {"since": <rev>}` returns `{rev, since, truncated, changes: [{page, ids}]}`. Add `page` to limit it to one page. If `truncated` is `true`, the log no longer reaches that far back: do a full pass instead of trusting the list.
5. **Measure in one call.** `get_bounds {"nodeIds": [...]}` (or `pageId`, with `depth`) returns `columns` `[id, parent, x, y, w, h, absX, absY, clip]` and `rows`, one array per layer, parents first. If `truncated` is `true`, call it again with `continueWith` as `nodeIds` (same `depth`). Never loop `get_node_info` to read sizes or positions.
6. **Finish with the full check.** Before you report, always:
* run `lint_design` with no arguments (a full pass, `partial: false`) and fix every error and warning, including `overflow`;
* take a `get_screenshot` of every artboard you touched in each theme mode (`mode`, scale 2).
A partial lint is a speed-up, not a verdict. Only the final full pass can say the design is clean.
## Example
```json
get_basic_info {}
// {"rev": 40, ...}
write_html {"targetNodeId": "a1:1", "mode": "insert-children", "html": "<div class='Card'>...</div>"}
// {"rev": 41, ...}
lint_design {"changedOnly": true}
// {"errors": 0, "warnings": 1, "covered": {"checked": 12, "total": 397, "since": 40, "partial": true}, ...}
update_styles {"nodeIds": ["a1:7"], "styles": {"width": "320px"}}
// {"rev": 42, ...}
get_changes {"since": 41}
// {"rev": 42, "since": 41, "truncated": false, "changes": [{"page": "p1", "ids": ["a1:7"]}]}
get_bounds {"nodeIds": ["a1:1"], "depth": 3}
// {"columns": ["id", "parent", "x", "y", "w", "h", "absX", "absY", "clip"],
// "rows": [["a1:1", null, 0, 0, 390, 844, 0, 0, true], ...]}
lint_design {}
// full pass, "partial": false, zero errors and warnings
get_screenshot {"nodeId": "a1:1", "scale": 2, "mode": "light"}
get_screenshot {"nodeId": "a1:1", "scale": 2, "mode": "dark"}
```
+1 -1
View File
@@ -57,7 +57,7 @@ For the next screens, create each artboard at 390x844, reuse the components by `
## 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.
Create the artboard with `height:"fit-content", overflow:"visible"` so it grows with its sections instead of keeping the 900px screen height, 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)
+87 -5
View File
@@ -79,6 +79,17 @@ Details of one node: type, name, size, visibility, lock state, parent, children
| `nodeId` | string | yes | The node to inspect. |
### `get_bounds` (read-only)
Rects of a whole subtree in one read, instead of one get_node_info per layer. Returns `columns` [id, parent, x, y, w, h, absX, absY, clip] and `rows` (one array per layer, parents before children): x/y/w/h are relative to the parent, absX/absY are page coordinates, clip is true when the layer clips its content (clipping or scrolling frame), parent is null for a top-level layer. At most 2000 rows per call: then `truncated` is true and `continueWith` lists the node ids to pass as nodeIds. Use it to verify nothing leaves its artboard, and `lint_design`'s `overflow` rule to list the offenders.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `depth` | number | | Levels returned, counting each given node as 1 (default: all). |
| `nodeIds` | string[] | | Subtrees to measure; omit for all artboards on the page. |
| `pageId` | string | | Page to use (default: the active page). |
### `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.
@@ -233,11 +244,24 @@ Export several nodes into one PDF, one page per node, ordered top-to-bottom then
| `path` | string | | Output file (default: Downloads/<project>.pdf). |
### `export_presentation`
Export pages as a presentation PDF, the way a designer sends work to a client: a cover, contents, a divider per page, one 1920×1080 slide per screen (its name and size; narrow side panels share a slide) and a closing slide, numbered. Screens are the page's declared screens, else its top-level frames of 300×300 or more; a section holding no screen (a design-system board) gets a slide of its own. They stay vector. Returns the file path.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `pageIds` | array of string | | Pages to include, in this order (default: every page). |
| `path` | string | | Output file (default: Downloads/<title>.pdf). |
| `title` | string | | Deck title (default: the project name). |
| `subtitle` | string | | Cover subtitle (default: screen and page counts). |
| `author` | string | | Shown on the cover after the date. |
## 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.
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. At a device width, height "fit-content" becomes the device height; content taller than a screen scrolls in its main column (else the screen). overflow "visible" lets the board grow instead.
| Argument | Type | Required | Notes |
|---|---|---|---|
@@ -249,7 +273,7 @@ Creates an artboard (top-level frame) and returns its id; then add content with
Parse HTML with inline styles into layers. Write incrementally: each call should add one visual group (a header, one list row, a card) so the user sees progress.
- mode "insert-children" appends the HTML inside targetNodeId ("root" = the page); "replace" replaces targetNodeId with it.
- Inline styles only. Flexbox (display:flex, flex-direction, gap, padding, align-items, justify-content, align-self, flex-grow, flex-wrap) is the layout model; block elements stack vertically. position:absolute with left/top is supported. No margin, grid or tables.
- Inline styles only. Flexbox (display:flex, flex-direction, gap, padding, align-items, justify-content, align-self, flex-grow, flex-wrap) is the layout model; block elements stack vertically. position:absolute with left/top is supported. Margins become gaps or spacers, display:grid becomes rows of cells, text inputs show their placeholder (each reported in warnings). No tables.
- Text: font-size, font-weight (≥600 = bold), font-style, color, line-height, letter-spacing, text-align, text-decoration (including wavy and text-decoration-color); <b>/<i>/<u>/<s>/<span style> make rich text. Font family is always Inter.
- Boxes: background(-color/-image: colors, linear/radial gradients, url()), border (and border-top/right/bottom/left), border-radius, opacity, overflow:hidden, mix-blend-mode, width/height (px, %, fit-content), min-width/max-width/min-height/max-height.
- <img src> takes paper-asset:///abs/path, file:// URLs, absolute paths or data: URIs. <svg> markup is imported as vector layers.
@@ -503,9 +527,31 @@ Search installed open icon sets (Lucide, Tabler, Phosphor, Heroicons, Material).
| `set` | string | | |
### `critique_design`
Critique finished work the way a design team does (offer it with ask_user when finish_working_on_nodes says the design changed). Judges against the person's objective, through independent reviewers: vision (the person's brief and notes), ux (Nielsen's heuristics), flow (coherent journeys across screens), qa (states and edge cases), visual, accessibility (W3C POUR), copy and end-user (a walkthrough as the audience). Returns the reviewers' briefs, the finding format and 0-4 severity scale, lint, the arrows between screens (dead ends, unreached screens), the person's lessons and the steps: review each persona independently, merge, fix severity 3-4, report, then ask which findings to keep as lessons.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `nodeId` | string | | Artboard or subtree to critique; omit for the whole page. |
| `objective` | string | | What the design must achieve, for whom, and its top tasks (from the brief or the person). |
| `audience` | string | | Who the end-user reviewer plays ("first-time shopper on a phone"). |
| `personas` | array of string | | Reviewers to run (default: all): vision, ux, flow, qa, visual, accessibility, copy, end-user. |
### `design_lessons`
The person's design lessons: rules from past critiques that every design must follow (get_basic_info lists them too; read them before designing). Without arguments, lists those that apply in the open project. `add` stores one ONLY after the person approved it in this conversation (ask_user); never on your own. `remove` deletes one by id.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `add` | object | | `{rule, why, scope}`: rule is one line, an instruction; why is what went wrong; scope `all` (default) or `project`. |
| `remove` | string | | Id of a lesson to delete ("L3"). |
### `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. Pass `fix: true` to automatically snap spacing/padding to 4px, relink matching color variables, snap text sizes, and enlarge small touch targets.
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), layers or text overflowing their artboard or clipping parent (rule `overflow`, never auto-fixed; scrolling frames are exempt), text too narrow for its words (text-collapsed), and hard-coded colors that equal a variable. Run it before finish_working_on_nodes and fix errors and warnings; `info` items are suggestions. Pass `fix: true` to automatically snap spacing/padding to 4px, relink matching color variables, snap text sizes, and enlarge small touch targets.
| Argument | Type | Required | Notes |
|---|---|---|---|
@@ -674,12 +720,48 @@ Move the user's view: switch to pageId (as clicking it in the page list does) an
### `undo`
Undo the last change on a page (the user's or an agent's).
Undo a change. An agent's undo (scope `mine`, its default) takes back its own newest step and leaves later steps by others in place; it is refused (`undo_blocked`, with the steps in the way) when one of them touched the same layers. Scope `page` undoes the page's last step, whoever made it.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `scope` | string | | `mine` or `page`. |
### `redo`
Redo the last undone change on a page.
Redo the last undone change on a page. With scope `mine` (an agent's default) only when that step was the caller's own.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `scope` | string | | `mine` or `page`. |
### `claim_nodes`
Several agents on one project: claim the layers you will rewrite (each with everything under it) or a shared resource, so other agents' writes there are refused while you work. All or nothing; a denial names the holder and when it expires (never wait in a loop: do other work). The claim lasts ttlSeconds after your last call and goes with finish_working_on_nodes or release_claims. The person is never blocked. Needs the running app.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `ids` | array of string | | Layer ids. |
| `resources` | array of string | | `variables`, `components`, `pages`. |
| `ttlSeconds` | integer | | Default 300 (10 to 1800); renewed by each of your calls. |
| `note` | string | | What you are doing, shown to others. |
### `release_claims`
Release your claims: one by claimId, those on ids or resources, or all of them when nothing is given.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `claimId` | string | | |
| `ids` | array of string | | |
| `resources` | array of string | | |
### `list_claims` (read-only)
Who is working where: every live claim with its holder, layers, resources, note and seconds left.
### `finish_working_on_nodes`
+42
View File
@@ -0,0 +1,42 @@
# Design critique and lessons
## Problem
When an agent finishes a design it says it is done; nobody reviews the work unless the person asks,
and the `papercraft-critique` skill only reaches agents that load skills (not the in-app chat). The
same mistakes come back in the next design (a page that grows instead of scrolling, gendered copy,
clipped labels) because nothing the person corrected is remembered.
## Goal
After a design is finished the person is offered a critique; the corrections they approve are
remembered and shown to every agent before its next design, so the same mistake is not repeated.
## Non-Goals
- Scoring by a model inside the app: the agent does the judging, the tool gathers the evidence.
- Saving anything the person did not approve.
- A settings screen for lessons (the tool lists and removes them).
- Syncing lessons between machines.
## User Stories
- As a designer, I want to be offered a critique when an agent finishes, so that I don't have to remember to ask.
- As a designer, I want to choose which critique findings become lessons, so that only what I agree with sticks.
- As a designer, I want agents to follow my lessons in later designs, so that I don't correct the same thing twice.
- As a designer, I want lessons for one product to stay in that product, so that a brand rule does not leak elsewhere.
- As a designer, I want to remove a lesson, so that a rule I no longer want stops applying.
## Acceptance Criteria
- [ ] Given edits since the last critique, when an agent calls `finish_working_on_nodes`, then the result tells it to offer the person a critique (`ask_user`), and it does not after a critique with no edits since.
- [ ] Given a page or artboard, when `critique_design` runs, then it returns the objective (or that it is missing and must be asked), the chosen reviewers (vision, ux, flow, qa, visual, accessibility, copy, end-user) with their methods, the finding format and 0-4 severity scale, the lint, the flow between screens and the lessons that apply.
- [ ] Given screens joined by arrows, when `critique_design` runs, then the flow lists the links, the dead ends (reached, leading nowhere) and the screens nothing leads to or from.
- [ ] Given several reviewers, the steps have them review independently (one subagent each, or one pass each) and a coordinator merge, never debate.
- [ ] Given the person approved a finding, when `design_lessons` adds it, then it is stored in the project store and listed by `get_basic_info` and `critique_design` from then on, in every session.
- [ ] Given a lesson saved with scope `project`, when another project is open, then it is not listed.
- [ ] Given a lesson id, when `design_lessons` removes it, then it is no longer listed.
- [ ] The tool descriptions say a lesson is saved only after the person approved it.
## Constraints
- Stack: Rust, MCP tools in `src/mcp`; lessons in a JSON file at the project store root, written atomically.
- Compat: missing or unreadable lessons file means no lessons, never an error for other tools.
- Security: lessons are plain text the person approved; no secrets.
## Open Questions
- None.
+20
View File
@@ -0,0 +1,20 @@
# Presentation export v2: plan
All in `src/io/deck.rs` unless noted; the PDF writer and tools stay as they are.
## Tasks (each with a test in `deck.rs`)
1. **Notes per screen.** `notes_of(doc, screen)`: attached notes whose target is the screen or inside it, in reading order of their targets. Category colour from the project's categories, else the built-ins.
2. **Annotated screen slide.** `Slide::Screen` with notes: the screen fitted in the left 64% of the stage, numbered accent markers at each noted layer's top-left, the note column on the right (number, title bold, first body line muted, wrapped). `NOTES_PER_SLIDE` = 6; more continue on a repeat slide titled `… (notes 2/2)`.
3. **States to an appendix.** Main flow: the first state, its caption `+ N states`; the rest collected per page into Appendix slides after the last page (a divider `Appendix`, rows of two titled `Page · Screen`).
4. **Map slide.** After each divider: up to 12 key screens (one per state group, boards excluded) as a grid of thumbnails with names; more pages of the map when needed.
5. **Open questions.** Notes containing `?`, listed with their screen's name, before the end slide; split at 8 per slide.
6. **Counts.** Contents and dividers say `N screens · M boards`.
## Risks
- Notes on tiny layers: the marker sits on the layer's top-left, clamped inside the screen.
- Long note bodies: one line in the column, cut with an ellipsis (text wrap at fixed width, max rows 2).
- Projects without notes: nothing changes for their slides.
## Data (needs the owner's go-ahead, not code)
- Interview: about 40 screens are named without ` / ` (`Challenges List No Match`), so they don't group as states; rename to `Screen / Challenges / No match`.
- Interview: 76 detached notes (their targets were deleted in the v1 cleanup).
+44
View File
@@ -0,0 +1,44 @@
# Presentation export
## Problem
Exporting a project as PDF gives one bare page per frame: no cover, no titles, no order beyond the canvas, and boards whose content sits on top of them as sibling layers came out empty. Sending designs to a client or team means rebuilding the deck by hand in another tool.
## Goal
One export produces a client-ready PDF deck of a project's screens, the way a freelance designer presents work.
## Non-Goals
- Authored narrative (problem statements, research, rationale paragraphs): the deck uses what the project knows (names, pages, sizes).
- Editable slide documents (Keynote, PowerPoint) or slide editing in the app.
- Theming the deck from the project's variables.
## User Stories
- As a designer, I want to export a page or the whole project as a presentation, so that I can send it to a client without rebuilding slides.
- As an agent, I want a tool and a CLI flag for the same export, so that I can deliver designs as a deck.
- As a reviewer, I want each screen on its own slide with its name, so that I can refer to it in feedback.
## Acceptance Criteria
- [ ] Given the Export section, when the format is Presentation and the scope is All pages, then one PDF is written with a cover, a contents slide, a divider per page that has screens, one slide per screen, and a closing slide.
- [ ] Given a page with declared screens, then the deck shows those screens; otherwise its top-level frames of at least 300 × 300.
- [ ] Given narrow, tall screens (side panels), then up to five share a slide, each with its name.
- [ ] Given a board frame with layers laid over it as siblings, then its slide shows those layers (as the canvas does).
- [ ] Every slide but the cover has the deck title and a page number; screen slides show the page name, the screen name and its size.
- [ ] Screens stay vector in the PDF (scaled, not rasterised).
- [ ] `papercraft --export deck.pdf --format presentation --scope all <project>` and the MCP tool `export_presentation {pageIds?, path?, title?, subtitle?, author?}` produce the same deck.
## v2: presentable like a designer's deck
Research (client-presentation guides, the pptx skill's slide rules): show the big picture before details, annotate key decisions, keep filler (states, look-alike screens) out of the main flow, close on open questions, keep one visual system.
- [x] Given a page, after its divider, then a map slide shows its key screens as a captioned thumbnail grid in reading order.
- [x] Given a screen with notes on it (or on its layers), then its slide shows the screen with numbered markers on the noted layers and a column of the numbered notes (title, first line of the body, the category's colour); more notes than fit continue on a second slide of the same screen.
- [x] Given states of one screen (`Sessions / List`, `Sessions / Empty`), then the main flow shows the first, captioned with how many states it has, and the others are in an Appendix section at the end, two per slide.
- [x] Given notes that ask a question (a `?`), then an Open questions slide before the end lists them with their screen.
- [x] Given a page with boards, then the contents and its divider count screens and boards apart.
- [x] Detached notes (their layer was deleted) are left out.
## Constraints
- Stack: Rust, the existing PDF writer (`io/pdf.rs`); no new dependencies, no browser.
- Performance: a 120-screen project exports in seconds.
- Compat: PDF 1.7, readable in Preview, Acrobat and browsers.
## Open Questions
None: defaults chosen (dark slides, 1920 × 1080, deck title from the file name in the app and CLI).
+85
View File
@@ -0,0 +1,85 @@
# Plan
Read [spec.md](spec.md) first. Phase 0 is the change log and `rev` that another change is adding; nothing here starts before it merges (the harness skeleton and identity can start against its interface).
## Architecture in one paragraph
A new module tree `src/mcp/swarm/` holds the target-agnostic logic (compiles for wasm): `mod.rs` (the `Swarm` struct: claims, `seen` map, settings, and `Swarm::check(...) -> Gate` / `Swarm::finish(...)` / `swarm::attach(client, result)`), `targets.rs` (tool -> targets, `reach`, resources), `conflicts.rs` (the rule over the change log), `claims.rs` (state machine, TTL, overlap), `gate.rs` (the ordered checks, so claims and conflicts are separate files). `App::run_mcp_calls` and `cli::Offline::call` wrap their existing `Tracker::call` with `Swarm::check` before and `swarm::attach` after; nothing else in `exec.rs` changes, so the tools stay oblivious. Per-client undo lives in `history.rs`/`editor` with a thin change in `exec::undo_redo`. Offline and save-time work is in `cli.rs`, `session.rs`, `project/`, `merge.rs`.
## Phases
Size: S under 150 changed lines, M 150-500, L over 500, tests included. "Needs" lists real dependencies; phases with no overlap in files can run in parallel (wave column).
| # | Phase | Needs | Wave | Size | Files touched |
|---|---|---|---|---|---|
| 0 | Change log, `rev`, `get_changes`, scoped lint (other agent, external) | | - | | `src/mcp/changes*`, `exec.rs`, `tools.rs` |
| 1a | Identity (protocol) | 0 | 1 | M | `src/mcp/mod.rs` (`Client.parent`), `src/mcp/server.rs` (`agent_of`), `src/bin/papercraft-cli.rs` (`--as`, `--parent`, env, URL query), `src/cli.rs` (`Offline::new(.., client)`), `src/mcp/delegate.rs` (env), `src/runs.rs` (label for named sessions, run id in log entries) |
| 1b | Harness skeleton + gate skeleton | 0 | 1 | M | `tests/common/swarm.rs`, `tests/swarm.rs`, `src/mcp/swarm/{mod,gate}.rs` (empty `Gate` chain, `attach`), call sites in `src/app.rs` and `src/cli.rs` |
| 2 | Conflicts: `ifRev`, rule, warn/strict, settings | 1a, 1b | 2 | L | `src/mcp/swarm/{conflicts,targets}.rs`, `src/mcp/tools.rs` (`ifRev`/`onConflict` on write tools, `reach` test), `src/ui/settings.rs` + `src/settings`-side struct, `src/ui/chat_msgs.rs` (warning chip) |
| 3 | Claims: tools, state machine, auto-release, delegate scope | 1a, 1b | 2 | L | `src/mcp/swarm/claims.rs`, `src/mcp/tools.rs` (3 tools, `delegate.scope`), `src/mcp/exec.rs` (3 tool arms call `Swarm`), `src/app.rs` (claim before delegate spawn, turn end, project switch), `src/mcp/delegate.rs` (release on exit) |
| 4 | UI: agent colours, presence pin, claim outlines, Agent view, Break | 1a, 3 (claims part) | 3 | L | `src/theme.rs` (`agent_1..8`), `src/ui/mod.rs` (`mark_agent`, `AgentMark`), `src/ui/panels.rs` (Agent view), canvas painter, `src/ui/palette.rs` (Break all claims) |
| 5 | Per-client undo and run revert under concurrency | 0, 1a | 2 | M | `src/history.rs` (`author`, `undo_by`, root set-diff), `src/editor/mod.rs`, `src/mcp/exec.rs::undo_redo`, `src/mcp/tools.rs` (`scope`), `src/runs.rs` (`blocker`, `revert`, `foreign_changed`), `src/ui/panels.rs` (Undo this step, kept-layers list) |
| 6 | Offline: per-call lease, page refresh, app takes the lock, holder info, `Tracker` in `Offline`, `activity.json`, offline claims file | 0, 1a | 2 | L | `src/cli.rs`, `src/session.rs` (`open`), `src/project/mod.rs` (stamp), `src/bin/papercraft-cli.rs`, changes persistence file |
| 7 | Save-time stamp check, three-way merge, merge report | none (6 helps) | 1 | M | `src/session.rs::save`, `src/merge.rs` (`Merged`/notes), `src/project/packed.rs` (keep bytes), `src/editor/pages.rs` (`restore_snapshot` reuse) |
| 8 | Skill, docs, tool counts | 2, 3 | 4 | S | `skills/papercraft/SKILL.md`, `docs/agents.md`, `README.md`, `ROADMAP.md`, `src/mcp/tools.rs::INSTRUCTIONS`, chat system prompt (`src/ui/chat.rs`) |
| 9 | Swarm scenarios and coverage gate | per phase | with each | M | `tests/swarm.rs` grows with each phase |
**Order and parallelism.**
* Wave 1: 1a, 1b, 7 in parallel (disjoint files: 7 touches only session/merge/packed).
* Wave 2 (after 1a and 1b merge): 2, 3, 5, 6 in parallel. They share only `src/mcp/tools.rs` (different tool entries: add each in its own `fn` group to avoid conflicts) and `src/mcp/exec.rs` (3 and 5 add arms in different places). 2 and 3 plug into the `Gate` chain from 1b as separate files, so they do not edit each other's code. 5 and 6 are independent of 2 and 3.
* Wave 3: 4 (UI), once 3 exposes the claims API; the colour/pin half of 4 only needs 1a and can start in wave 2 if split (`4a` colours and pin, `4b` claims UI).
* Wave 4: 8.
* Merge conflict hot spots to coordinate: `tools.rs` (tool list and the `edits_doc` classification: new tools `claim_nodes`/`release_claims` edit nothing; `list_claims` read-only), `app.rs::run_mcp_calls` (1b owns the call-site edit; 3 adds the delegate claim in its own function), `panels.rs` (4 and 5 add different sections).
## Test plan
**Harness (Phase 1b, grows per phase).** `tests/common/swarm.rs`:
* `Sim { host: SessionHost, tracker: Tracker, swarm: Swarm, clock: FakeClock }` over a temp store (`temp_store`, as `tests/cli_offline.rs`), headless (no GPU unless a step needs a screenshot), single-threaded: determinism comes from scripted order, not threads.
* `SimClient { client: Client }`; `Step { at_ms, client, tool, args, expect }`; `Sim::run(script) -> Vec<Outcome>`. Every step goes through the **same entry the app uses** (`swarm::call(host, tracker, swarm, client, name, args, now)`, the function `App::run_mcp_calls` and `Offline::call` both call), so the harness cannot drift from production.
* `Sim::human(edit)` applies a canvas edit through `Editor::edit` with the human author, to test `You`.
* Scenario builders: `disjoint(n)` (N clients, own artboards), `overlap(n)` (shared frame), `shared_resources()`, `chain()` (delegate parent/child), `churn(seed)` (random ops from `fastrand::Rng::with_seed`).
* **Oracle.** A deliberately naive reference (`Oracle`) re-implements the section 2 table over the list of executed ops (per-node last-writer map, ancestor walk, per-tool `reach`) in O(n^2) and is compared with the product's `conflicts` and with the final document: every accepted write's value is present unless a later accepted write to the same property exists (no lost update). `churn` runs 200 seeds in CI, 5000 under `--ignored`.
* Offline: `OfflineSim` runs 3 `Offline` instances on threads against one folder with real locks (different `File` handles conflict on every platform), each doing N edits to its own artboard plus M to a shared one; asserts 3N + M edits in a fresh `Project::load_all`, no torn file, no panic, and that a held idle lock (shell between lines) does not block.
**Per-phase unit tests**
* 1a: `agent_of` parsing and sanitizing, reserved names, `Client` equality with `parent`, env -> flags precedence, `delegate::run` passes env (existing unix test extended with `env | grep`), stable session for `--as`, run label for named sessions.
* 2: each relation row positive and negative (self, ancestor own-fields vs `children`, descendant only for `subtree`, `children` reach not conflicting on sibling append, variable set vs bind, component, page); implicit reference per `(client, artboard)`; `ifRev` > current -> `history_reset`; strict leaves `rev`, doc, history, runs untouched (assert equality of all four); mode precedence table; human edit reported; conflict cap 20; every tool classified.
* 3: grant, deny with each `relation`, atomic denial (nothing claimed), own-claim merge, TTL renewal by reads, expiry with fake clock, each auto-release row, limits, `Block` vs `Warn`, parent-chain exemption, human exempt, resource names and `Claim required`, delegate scope required for `build`, claim exists before spawn and is gone after helper exit (stub command).
* 4: `agent_color` stable and in range; screenshot tests at scale 2 in both themes (`tests/agent_view.rs` style) for outlines, claims tags, Agent view, long names; Break removes the claim and the holder gets the notice.
* 5: `undo_by` independence matrix (disjoint ok; same node blocked; same parent children blocked; root set-diff ok; design/variables/page_modes/annotations; main component blocked); redo mine; default scope; human Ctrl+Z unchanged; run revert partial with a foreign edit, all-foreign, "Revert anyway".
* 6: lease around each call; interleaved shells; page refresh keeps other pages' history; app `Session::open` waits and reports the holder; holder info in the lock file; `activity.json` roundtrip, bounded, absent file; offline run/version created; `claims.json` roundtrip and expiry.
* 7: unchanged stamp writes; changed stamp merges and reports notes; undecodable or newer-format file refuses; merged result is one undo step; no layer dropped (property test over `merge::merge`: node count of merged >= union of kept ids).
**Coverage.** `cargo llvm-cov` (or `cargo tarpaulin` where the maintainer already uses it) on `src/mcp/swarm/`, `history.rs` changes and `cli.rs`; gate at 80% lines for the new modules. UI painting is covered by screenshots, not counted.
## Risks
| Risk | Mitigation |
|---|---|
| Conflict noise: agents ignore `conflicts` that fire on every call | `children` is a separate field so sibling appends do not fire; the implicit reference is per artboard; `reach` limits `descendant` to subtree tools; conflicts cap at 20; measure on the harness `churn` scenario (target: no conflict on pure disjoint work, by construction) |
| Under-reporting from the implicit reference (coarse, artboard-level) | `ifRev` is exact; the skill tells swarm agents to use it |
| Default `undo` scope change breaks single-agent habits | `scope: "page"` keeps the old behaviour; the tool description says so; only differs when someone else edited in between |
| Claims fence off the canvas (a stalled agent) | TTL with renewal only by the holder's calls, per-client and per-project limits, human Break, parent exemption |
| MCP session close not observable with rmcp's `LocalSessionManager` | TTL fallback; Phase 3 checks whether the manager can notify, else adds a `Drop`-guard on the per-session service handle |
| Per-call offline lease and page refresh cost on large projects | refresh is by stamp, only changed pages; `verify_large.rs`/`perf.rs` get a case |
| `PageBase` memory (compressed bytes of every saved page) | bytes already exist at save time; keep only for pages saved in this session; evict when the page is unloaded by `lazy_load` |
| Advisory locks on network folders | unsupported, documented; holder info and stamp check (Phase 7) still catch lost updates after the fact |
| Merge hides a semantic clash (both changed the same scalar, local wins) | the merge report surfaces each such scalar; the human decides |
| Change log assumptions differ from what Phase 0 ships | all dependence is in `conflicts.rs` and the entry struct; reconcile before wave 2 |
| `tools.rs` / `app.rs` merge conflicts between parallel agents | file ownership table above; each phase adds code in new files and one-line call sites |
## Open questions for the maintainer
1. **Default for claims:** `Block` (proposed) or `Warn`? Block makes claims meaningful but can stop an agent in a way the user sees as "stuck"; the denial message names the holder and time.
2. **`undo` default scope `mine`:** accept the behaviour change for single-agent use, or keep `page` as the default and make agents opt in through the skill? (Proposed: `mine`.)
3. **Session close hook:** is it acceptable to patch or wrap rmcp's session manager to learn of a closed session, or is TTL expiry enough?
4. **Human `locked` flag vs agents:** should agent writes honour `Node.locked` (reject with a clear error)? It is a one-line check in `swarm::targets` and would make "lock this for me" work with agents; it also changes today's behaviour where agents edit locked layers. Out of scope here unless you say otherwise.
5. **`delegate` `scope` required for `build`:** is a hard error acceptable, or should it default to the caller's current claims?
6. **Offline runs:** is it right that offline edits now create runs and auto-versions (disk cost per CLI invocation), or should they be opt-in (`--run`)?
7. **Merge on save (Phase 7):** worth its memory and complexity now, or defer until a sync-less second-writer case actually occurs? It is independent and can be dropped without affecting the rest.
8. **Notice/conflict placement:** trailing `swarm` content block (proposed) vs adding keys to the tool's JSON when it is an object. The block is uniform for text-only and image results; keys are easier for some clients to parse.
9. **Naming collision:** `rev` (log) vs `Editor::revision` (redraw counter). Rename the editor field (touches many files) or keep both and document?
10. **Where does the human's mid-edit text field count?** Proposed: nowhere (committed on blur). Confirm.
+354
View File
@@ -0,0 +1,354 @@
# Swarm safety: several agents on one project
## Why
One project can now have many writers at once: the in-app Chat agent, one or more external MCP clients (Claude Code, Codex), `papercraft-cli` calls online and offline, `delegate`d helpers, the person at the canvas, and (through the relay) other machines. Today the *transport* is safe: every online call is serialized on the UI thread and is one undo step, so nothing tears. What is missing is the *semantics*: an agent that read a frame, thinks for 40 s and then overwrites it does not learn that another agent rebuilt it meanwhile; two agents can both edit `variables`; `undo` undoes whoever was last; an offline CLI and a second process can race on the folder. This spec makes those cases visible, mostly non-blocking, and recoverable.
Design stance: **cooperative and fail-fast**. No tool call ever waits on another agent (no deadlock is possible). Conflicts are reported by default and rejected only on request. The human always wins and is never blocked.
## Builds on (do not redesign)
A change log with a document revision is being added by another change. This spec assumes exactly this interface and names it `rev`:
* `rev: u64`, bumped once per committed write. Every write result returns `rev`. It is **not** `Editor::revision` (`src/editor/mod.rs:253`), which is a redraw counter that `App::run_mcp_calls` bumps after every call.
* A bounded in-memory log of entries `{rev, client, tool, page, nodes[], fields[], variables?, at}`; `client` is the label of the caller; the human is `You`.
* `get_changes {since, exclude:"self"}` and `lint_design {since | changedOnly}`.
Two small additions are needed from that log and are tracked as Phase 0 follow-ups below: entries must say **which fields** of a node changed and keep `children` as its own field name (so "another agent added a child" is distinguishable from "another agent restyled the frame"), and the log must survive restart (see 6.4). If the other change lands differently, only `src/mcp/swarm/conflicts.rs` adapts.
## What exists today (checked 2026-10-28)
* **One chokepoint, online.** `server.rs::call_tool` builds a `Call {name, args, client, reply}`, `Dispatch` sends it down an mpsc channel and wakes the loop with `UserEvent::Mcp`; `App::run_mcp_calls` (`src/app.rs:1562`) runs each call in order via `Tracker::call` (`src/runs.rs:522`), then `UiState::mark_agent`, `log_agent`, and `steer::attach`. The chat's API backend uses the same channel (`App::tool_dispatch`), and its CLI backends reach the same HTTP server with `?chat=<id>` (`server.rs::client_of`, `chat_of`). `refs`, `imagegen` and `delegate` are the exception: they run on a spawned thread (`app.rs:1573`), because the helper calls back into the app through `papercraft-cli`.
* **Identity.** `Client {name, session}` (`src/mcp/mod.rs`). `name` is the MCP `clientInfo` title or name, or `CHAT_CLIENT`; `session` is the `mcp-session-id` header or the chat id. `Tracker` keeps one open run per `Client` (`Tracker.open`, `open_of`) and labels the second session of a name "Claude Code · 2" (`Tracker::label`). `papercraft-cli` connects with `().serve(...)` (`bin/papercraft-cli.rs:309`): default client info, a **new MCP session per process**, so each `papercraft-cli call` is a different `Client` and opens its own run. A delegated helper is just another such CLI process; nothing links it to its parent.
* **Touched layers.** `Editor.touch_log` is filled by `log_touched` on every commit while `Tracker::call` has it open; `take_touched` hands (page id, node) pairs to `UiState::mark_agent`, which keeps the topmost ones as `AgentMark {page_id, node, verb, client, at}` (cap `AGENT_MARKS_MAX`, fade `AGENT_MARK_SECS`). `RunPage.nodes` persists the node set per run and page; `runs::blocker` / `keep_own` / `revert` already use it to refuse or narrow a revert when a *later run* touched the same nodes.
* **Layer `lock`** is a human flag (`Node.locked`, `is_effectively_locked`): it stops canvas hit-testing and canvas editing. No tool in `src/mcp/exec.rs` checks it, so agents write through locked layers today. This spec does not change that (open question 4).
* **Undo** is per page (`ed.with_page(page, ..)`, `History.undo/redo` stacks of `Change`), global across authors. The `undo` / `redo` tools (`exec.rs::undo_redo`) pop the top `Change` of the page whoever made it. A `Change` has no author. A design-system edit puts the same `design` before/after in a step on **every** page (`history.rs`, `Change.design`).
* **Offline.** `cli::Offline` (`src/cli.rs`) wraps `SessionHost` and calls `exec::call` directly: no `Tracker`, no runs, no versions, identity-less. It already has two locks: `APP_LOCK` (`.papercraft-app.lock` in the projects root, held by the app for its whole life from `app.rs:1714`) and `PROJECT_LOCK` (`.papercraft-cli.lock` in the project folder, taken by `lock_for_write` before the first editing call, with a 5 s wait, then a **reload** of the project from disk). `refuse_if_open` refuses `--offline` on a project the app reports as `open` in `list_resources`. All are OS advisory locks (`File::try_lock`), so a crash releases them. Gaps are listed in section 6.
* **Saving.** `Session::save` (`src/session.rs:217`) writes every dirty page, `design.json` and the manifest atomically (`io::atomic::write`, temp + rename) but **never looks at what is on disk**: it is last-writer-wins per file. Only shared projects have a three-way page merge (`sync.rs` + `merge::merge`, base copy in `.sync-base/`).
* **Skills.** `skills/papercraft/SKILL.md` teaches single-agent use; nothing about sharing a project.
## Scope
### 1. Identity
Every call is attributed to an `AgentId`:
```rust
pub struct Client { // src/mcp/mod.rs, extended; stays Eq + Hash (Tracker key)
pub name: String, // "Claude Code", "Codex", "Chat", "You", "Actions", or --as value
pub session: String, // MCP session id, chat id, or stable agent session (below)
pub parent: Option<String>,// session of the agent that delegated this one
}
```
`run` is not a field of `Client`: the run is derived (`Tracker.open_of(client)`), and the change-log entry stores `run_id` when one is open so Review can group them.
**Sources of identity**
| Caller | name | session | parent |
|---|---|---|---|
| MCP client | `clientInfo` title or name (as today) | `mcp-session-id` | none |
| Chat turn (API or CLI backend) | `Chat` | chat id | none |
| `papercraft-cli` online | `--as <name>` > `$PAPERCRAFT_AGENT` > `cli` | `agent:<name>` (stable across invocations) | `--parent` / `$PAPERCRAFT_PARENT` |
| `papercraft-cli` offline / `shell` | same as online | same | same |
| `delegate` helper | `<parent label> / <role>` e.g. `Chat / build` | `d<n>-<chat id>` | the caller's session |
| Actions menu | `Actions` | `""` | none |
| The person at the canvas | `You` (reserved, cannot be claimed by `--as`) | `""` | none |
* **Why a query string, not `clientInfo`.** `papercraft-cli` has no per-call client info and a fresh MCP session per invocation. The chat already proves the pattern: `?chat=<id>`. The CLI appends `?agent=<name>&parent=<session>` to the MCP URL; `server.rs::client_of` reads it (`agent_of(query)` next to `chat_of`) and builds the stable session `agent:<name>`. Names are sanitized (`[A-Za-z0-9 ._/-]{1,40}`); `You`, `Chat` and `Actions` are rejected for `--as`. An unnamed CLI is `cli` and prints one stderr hint: "set --as <name> so other agents can tell you apart".
* **Delegation.** `delegate::run` sets `PAPERCRAFT_AGENT` and `PAPERCRAFT_PARENT` on the helper process (it already sets `PAPERCRAFT_PROMPT` and `PATH`), and the preamble tells the helper to pass nothing else: `papercraft-cli` reads the env. Grandchildren inherit the env, so chains work (`parent` is the immediate delegator).
* **Offline.** `Offline::new(store, project, client)` takes the same `Client`; the CLI binary builds it from the flags and env above. Offline gets a `Tracker` (Phase 6) so offline work also produces runs, versions and attributed log entries, not an anonymous blob.
* **Run labels.** `Tracker::label` keeps its numbering for MCP clients with no stable name. A named CLI agent is not numbered (its session is the name).
**Where it shows**
* *Canvas.* `mark_agent` already carries `client` (the label). The outline and its tag are drawn in the agent's colour: `agent_color(label) -> theme slot`, a stable hash of the label into 8 theme tokens `agent_1..agent_8` (added to `src/theme.rs` next to the other palette entries; both themes; no hex in UI code). The tag reads `Codex . Styling` (label, then verb); a delegate reads `Chat / build`. A *presence pin* (a small coloured dot plus the label, no text editing cursor exists for agents) sits on the top-left corner of the last touched layer for `AGENT_MARK_SECS`. The human's own edits get no mark.
* *Agent view.* One group per `Client` with its colour dot, label, state (working per `AGENT_WORKING_SECS`, idle, finished), runs, and its claims (section 3). Delegates are nested under their parent. Rows are one line, truncate with an ellipsis, and have 44 px targets for actions.
* *Change log and `get_changes`.* Entries carry `{client, session, parent, run}`; `exclude:"self"` matches on `(name, session)`.
### 2. Optimistic concurrency
**Reference rev.** The point in history the caller's picture of a layer is from.
* Explicit: any write tool accepts `ifRev` (integer, the `rev` the caller last synced to).
* Implicit (default, no `ifRev`): the server remembers `seen[(client, page, artboard)] = rev at the end of the client's last call that read or wrote anything under that artboard` (any tool whose resolved node ids sit under it; `get_screenshot`, `get_tree_summary`, `get_jsx`, writes, ...). A client with no history on an artboard has reference = the rev when its session first called. Tradeoff: coarse (artboard, not node) so a read of the header does not hide a stale body, in the direction of *over*-reporting; it costs one small map per client, dropped when the client has been silent for an hour.
**The rule.** A write call has a target set `T` (resolved before the call from `nodeId`, `nodeIds`, `targetNodeId`, parent, page and variable arguments by `swarm::targets::of(name, args, ed)`, the same resolution `runs::target_pages` already does for pages). It conflicts when the log has an entry with `rev > ref` by a **different client** (including `You`) whose node set matches one of:
| relation | an entry conflicts when it ... | applies to |
|---|---|---|
| `self` | changed a node in `T` (any field) | all writes |
| `ancestor` | changed the **own fields** (not `children`) of an ancestor of a node in `T`, or deleted or moved one | all writes (size, layout, clip, visibility of a parent change what a child edit means) |
| `descendant` | changed any node under a node in `T` | writes with `reach: subtree` (see below) |
| `variable` | changed (value, rename, delete) a variable the call **sets**, or deleted/renamed one the call **binds** with `var(--x)` or `bind_variable` | variable tools; node writes that bind variables |
| `component` | changed the main component of an instance in `T`, or an instance's main when `T` is a main | writes to instances and mains |
| `page` | created, deleted, renamed or reordered pages | page tools |
Per-tool `reach` (table in `src/mcp/swarm/targets.rs`, one test asserts every tool in `tools::definitions()` is classified, like the `edits_doc` test):
* `node`: `update_styles`, `set_text_content`, `rename_nodes`, `set_code_link`, `bind_variable`, ... (own fields only; descendants are not affected).
* `subtree`: `write_html` in replace mode, `delete_nodes`, `move_nodes` (also its source and destination parents' `children`), `detach_instance`, `apply_ui_kit`, anything that rebuilds children.
* `children`: `write_html` `insert-children`, `create_shape` under a parent, `duplicate_nodes`: adds below the target; conflicts only through `self` / `ancestor` (target deleted or moved), so two agents appending to the same frame do not conflict.
* `none`: `create_artboard`, `create_page` (no existing target; page relation only).
**Reading the result.** Conflicts are computed *before* the call runs, against the log, on the UI thread, so there is no check-then-act gap.
```json
// warn (default): the call is applied; trailing content block of the result
{"swarm": {"rev": 58, "refRev": 51, "applied": true,
"conflicts": [
{"node": "1:12", "name": "Header", "relation": "self",
"by": {"client": "Codex", "session": "agent:Codex", "label": "Codex"},
"rev": 55, "tool": "update_styles", "at": "2026-10-28T10:02:11Z",
"changed": ["fill", "paddingTop"]}
],
"hint": "Codex changed 1 layer you just wrote. get_changes {since: 51, exclude: \"self\"}, re-read those layers, adjust."}}
```
```json
// strict: nothing applied; isError result, text is this JSON
{"error": "conflict", "applied": false, "rev": 57, "refRev": 51,
"conflicts": [ ... same objects ... ],
"hint": "Re-read the layers above (get_changes {since: 51, exclude: \"self\"}), then retry with ifRev 57."}
```
The result stays a `ToolResult = Result<Vec<Out>, String>`: strict errors are the JSON string above (stable `error` codes: `conflict`, `claimed`, `needs_claim`, `undo_blocked`); warnings ride in a trailing `Out::Text` block, exactly how `steer::attach` appends its note, so tools' own JSON is untouched and image results keep working. `conflicts` is capped at 20 with `"more": n`.
**Modes**
| Setting | How set | Behaviour |
|---|---|---|
| `warn` (default) | nothing | apply; report conflicts against the implicit or explicit reference |
| `strict` per call | `ifRev` present (and no `onConflict: "warn"`) | reject if any conflict against `ifRev`; nothing applied |
| `strict` global | Settings, Agents, "Reject conflicting writes"; env `PAPERCRAFT_CONFLICTS=strict` | like strict per call, using the implicit reference when `ifRev` is absent |
| `warn` per call | `onConflict: "warn"` | override the above for this call |
Precedence: `onConflict` > presence of `ifRev` > env > setting > default.
`ifRev` newer than the current `rev` (the log was reset, for example the app restarted without a persisted log) is reported as one conflict `{"relation": "history_reset"}`: reported in warn, rejected in strict.
**Recovery protocol** (what the tool hint and the skill say): on `conflict` or a non-empty `conflicts`, call `get_changes {since: <refRev>, exclude: "self"}`, `get_tree_summary` / `get_screenshot` on only the listed nodes, decide whether the other change is compatible, redo your edit if not, and continue with `ifRev` = the `rev` you got back. Never retry the same call blindly.
**The human.** Canvas edits are recorded in the log as `You` through the editor commit path (a `HUMAN` author when no tool call is running), never checked, never blocked, and reported to agents by the same `conflicts` structure (`by.client == "You"`). An agent write is not blocked by a selection or an open text edit; the in-progress text edit is committed on Esc/blur as a normal human write and conflicts back in the other direction.
### 3. Soft claims
A claim is a short lease that says "I am working here". It is advisory by default and enforced for other agents (not the human) when the setting says so.
```json
// claim_nodes
{"name": "claim_nodes", "inputSchema": {"type": "object", "properties": {
"ids": {"type": "array", "items": {"type": "string"}, "description": "Layer ids (\"<page>:<n>\"). Each claims the layer and everything under it."},
"resources": {"type": "array", "items": {"enum": ["variables", "components", "pages", "settings"]}, "description": "Shared resources that cannot be split by layer."},
"ttlSeconds": {"type": "integer", "minimum": 10, "maximum": 1800, "default": 300},
"note": {"type": "string", "maxLength": 120, "description": "Shown to the person and to other agents."}},
"anyOf": [{"required": ["ids"]}, {"required": ["resources"]}]}}
// -> {"claimId": "c7", "claimed": ["1:12"], "resources": [], "expiresAt": "...", "ttlSeconds": 300, "rev": 58}
// release_claims {"claimId"?: "c7", "ids"?: [...], "resources"?: [...]} (nothing = all of mine)
// -> {"released": ["c7"]}
// list_claims {} (read-only) -> {"claims": [{claimId, holder, ids, resources, note, expiresInSeconds}]}
```
Resource names are the "named shared resource" form of a claim: `variables` (collections, variables, modes, tokens, the design system), `components` (creating or editing main components), `pages` (create, delete, rename, reorder, group), `settings` (project-level settings, libraries).
**Semantics**
* **Subtree.** A claim on a layer covers it and all descendants. It does not cover ancestors' own fields.
* **Overlap.** Another agent's claim `X` overlaps a request `Y` when `X` is the same layer, an ancestor, or a descendant of `Y`. A request overlapping *another client's* live claim is denied. Overlap with the caller's own claims merges (the existing claim is extended and renewed).
* **Atomic.** `claim_nodes` takes all of `ids` and `resources` or none. A denial is a normal result, not a thrown error:
```json
{"claimed": [], "denied": [
{"id": "1:12", "relation": "ancestor", "heldBy": "Codex", "claimId": "c3", "ids": ["1:3"],
"note": "rebuilding Settings screen", "expiresInSeconds": 212, "retryAfterSeconds": 212}]}
```
* **Limits.** At most 16 live claims per client and 64 per project (`MAX_CLAIMS_PER_CLIENT`, `MAX_CLAIMS`), so a runaway agent cannot fence off the canvas.
* **Lease, not lock.** The expiry is `now + ttl` and is renewed by any call from the holder (read or write) while the claim lives, so an active agent never loses a claim and a stalled one loses it `ttl` after its last call. Expiry is checked lazily on every claim check and by `Tracker::tick`; there is no timer thread.
* **What a claim does for others** (Settings, Agents, "Claimed layers": `Block` (default) or `Warn`; per call `onClaim` is not offered, the holder decides by claiming):
* `Block`: a write by another agent whose resolved targets (with `reach`) overlap a claim is rejected before it runs: `{"error": "claimed", "applied": false, "claims": [{claimId, heldBy, ids, note, expiresInSeconds}], "hint": "Work elsewhere or retry after expiresInSeconds; ask the holder through your orchestrator."}`.
* `Warn`: applied, with `claimWarnings` in the trailing `swarm` block.
* Reads and screenshots are never blocked. The parent chain of a delegate (delegator, its delegator, ...) is never blocked by the delegate's claim, only warned, so an orchestrator can always step in.
* The human is never blocked. A human edit inside a claim is logged as `You`, so the holder sees it as a conflict on its next call, and the canvas shows the claim as "edited".
* **Resources.** Writes that touch a shared resource map to a resource name by `swarm::targets::resources_of(name, args)`: `set_variable*`, `create_variable*`, `set_variable_mode`, token tools -> `variables`; component tools and writes whose target is a main component or inside one -> `components`; `create_page`, `delete_page`, `rename_page`, reorder and group tools -> `pages`; project settings and library tools -> `settings`. A resource claim held by someone else blocks (or warns) like a layer claim. Setting "Shared resources" (Settings, Agents): `Open` (default: no claim needed) or `Claim required` (a write to a shared resource without holding its claim is rejected with `needs_claim`; this is how an orchestrator reserves them for itself: it claims and holds them, and helpers cannot).
* **Auto-release**
| Event | Effect |
|---|---|
| `release_claims` | released |
| `finish_working_on_nodes {nodeIds}` | the caller's claims on those layers (or all of its claims when no ids) are released, then the run ends as today |
| Chat turn ends (`Tracker::finish`, `app.rs:772`) | all claims of that chat |
| `delegate` helper process ends, times out or is stopped | all claims of the helper |
| Run ends by idle (`RUN_IDLE_SECS` = 30) | **kept**: an agent thinking for 45 s is not finished; the TTL covers a dead one |
| MCP session closes (transport DELETE) | all claims of that session, if the session manager reports it (open question 3); else TTL |
| TTL passes with no call from the holder | expired |
| Project switch (`Tracker::sync_project` sees another dir) | all claims |
| App restart | gone (claims are memory-only online; MCP session ids do not survive a restart) |
| Human "Break claim" | removed; the holder's next result carries `notices: [{"type": "claim_broken", "by": "You", "claimId": "c7"}]` |
* **State machine of a claim**
| State | On | Next |
|---|---|---|
| (none) | `claim_nodes`, no overlap with others | Active |
| (none) | `claim_nodes`, overlap with another client | denied, no state created |
| Active | holder calls any tool | Active (expiry renewed) |
| Active | holder `claim_nodes` overlapping its own | Active (merged, renewed, one id) |
| Active | `release_claims`, `finish_working_on_nodes`, turn/helper end, project switch | Released |
| Active | no call from holder for `ttl` | Expired |
| Active | human Break | Broken |
| Expired / Released / Broken | any | removed at once; the change log keeps `claim_*` entries |
* **Deadlock freedom by construction.** No tool waits. A claim attempt returns immediately (granted or denied with holder and expiry). A claim is all-or-nothing, so no agent holds a partial set while asking for more; claims always expire, so no chain of holders can be unbounded. The remaining failure is *starvation*, not deadlock; the denial tells the caller `retryAfterSeconds` and the protocol (section 4) tells it to do other work first.
* **Delegation.** The `delegate` tool gains `scope` (`{"ids": [...], "resources": [...]}`), required for role `build`, ignored for `qa` and `read` (they do not edit). `run_mcp_calls` takes the claim **on the UI thread before spawning the helper** (the spawn happens at `app.rs:1573`), with holder = the helper's `Client` (name `<parent> / build`, `parent` = caller session) and a TTL of 15 min (the helper's timeout is `TIMEOUT` = 900 s), released when the process ends. If the scope is denied, the delegate call returns the `denied` list and no process starts. The parent can read and write inside the child's claim (warned, not blocked).
**Seeing and breaking claims (human)**
* *Canvas:* a claimed layer gets a dashed outline in the holder's colour and a small tag `Codex . 4:12 left` (a painted lock-and-clock icon, truncated note in the tooltip). Right-click on a claimed layer: "Claimed by Codex (4:12 left, rebuilding Settings)", "Break claim".
* *Agent view:* a `Claims` section per agent: target names, note, remaining time, a "Break" button (44 px). Resources show as chips (`variables`).
* *Editing under a claim:* allowed; the first human edit inside a claim shows a one-line status ("Codex has claimed this; it will be told") and no modal.
### 4. Swarm partitioning guidance
For the orchestrator (a person's prompt, or a lead agent). Prefer to split by things that do not share state, in this order:
| Split by | Why it is safe | Notes |
|---|---|---|
| **Page** | pages have their own `Document`, history and file; the only shared state is the design system and component sources | best default for large work |
| **Artboard** | one artboard = one subtree; claims and conflicts are cheap | the usual unit inside a page |
| **Component** | a main component and its instances | claim `components` unless every agent owns a different main |
| **Variables** | one agent owns the design system for the swarm | do this *first*, then fan out |
| **Annotations** | stored per page | partition by page like layers |
Shared resources that cannot be partitioned (each of these is a single value in the project: `design.json`, the manifest page list, component sources shared across pages via `flush_cross_pages`, project settings):
| Resource | Rule |
|---|---|
| Variables, modes, tokens | Orchestrator defines them up front. Later changes: `claim_nodes {resources:["variables"]}`, change, release, in a short window. Never switch the document mode (`set_variable_mode`) while others take screenshots: it changes what every agent sees. Do it last or have the orchestrator do it |
| Components | One owner per main component. Anyone may place instances; only the owner edits the main. Use `components` claim for new components |
| Page list and order | Orchestrator creates pages first (`pages` claim for later changes) |
| Project settings, libraries | Orchestrator only (`settings` claim held for the swarm, setting "Claim required") |
**Recommended agent protocol** (the block for `skills/papercraft/SKILL.md`, about 20 lines):
```
Sharing a project with other agents
1. Identify yourself: papercraft-cli --as <short name> (or set PAPERCRAFT_AGENT). MCP clients are named by the client.
2. Read before you write: note `rev` (get_basic_info). Work only in the layers you were assigned.
3. Claim what you will rewrite: claim_nodes {ids:[<your artboard>], ttlSeconds:600, note:"<what you do>"}.
If denied, do other work or report back; never wait in a loop.
4. Edit with ifRev = the rev your last result returned.
5. If a result has `conflicts` or error `conflict`: get_changes {since: <refRev>, exclude:"self"}, re-read only those layers, redo what is no longer right. Do not blindly retry.
6. After a batch: lint_design {changedOnly:true}, fix findings in your layers only.
7. Shared things (variables, components, pages, settings): claim the resource, change, release in the same minute.
8. Finish: finish_working_on_nodes {nodeIds:[...]} (releases your claims) and report which layers you changed, which conflicts you saw and how you resolved them.
9. Never undo with scope "page"; use undo {scope:"mine"}.
Orchestrators: give each helper a disjoint `scope` in `delegate`; create pages and variables before fanning out.
```
### 5. Undo and revert under concurrency
**Per-client undo.** `undo` and `redo` get `scope`:
```json
{"name": "undo", "inputSchema": {"type": "object", "properties": {
"pageId": {"type": "string"},
"scope": {"enum": ["mine", "page"], "default": "mine",
"description": "mine: undo only your latest step, if no later step by someone else touched the same layers. page: undo the page's latest step whoever made it."}}}}
// -> {"done": true, "what": "write_html", "step": "s41", "rev": 61}
// -> refused: {"done": false, "reason": "undo_blocked", "step": "s41", "blockedBy": [{"client": "Codex", "rev": 59, "tool": "update_styles", "nodes": ["1:12"]}]}
```
* **Authorship.** `History` stores per `Change` an `author: Arc<str>` (the client label, `You` for canvas edits), its `rev` and a step id. Coalesced canvas steps (`coalesce_key`) keep the human author.
* **Algorithm** (`History::undo_by(doc, author) -> Result<Option<Undone>, Blocked>`): find the newest `Change` `m` by the caller on the page. Let `later` be the changes above it. `m` is *independent* when, for every `l` in `later` by anyone: entry ids of `l` and `m` are disjoint, `m.design`/`m.variables`/`m.page_modes`/`m.annotations` are `None` or the same field is `None` in `l`, and `m` touched no main component (a `touched_mains` step may rewrite instances on other pages). If independent, apply `m` backwards, splice it out of the undo stack, and push it to the redo stack. If not, return `Blocked` with the offending `l`s. Conservative on purpose: if both changed `children` of the same parent they both have an entry for the parent, so it is refused. The root list is handled by set difference (remove ids `m` added, re-insert ids `m` removed after their old predecessor), so two agents each creating an artboard do not block each other's undo.
* **Redo** `scope: "mine"` re-applies the caller's last undone step if the same independence holds in reverse; as always, any new commit on the page clears the redo stack, so an agent cannot redo across someone else's later edit (documented, not worked around).
* **Default.** `scope` defaults to `mine` for every client except `You`, because "undo" from an agent should never remove a person's or another agent's work. The old behaviour is `scope: "page"`. This is a behaviour change for single-agent use when the human edits between agent steps; it is deliberate (open question 2).
* **The human's Ctrl+Z / Ctrl+Shift+Z** is unchanged: it undoes the newest step on the page whatever its author, because that is what the canvas shows and what the person expects. The status line says whose step it was ("Undid Codex: write_html"). The Agent view run rows get "Undo this step" using the same independence check, with the refusal explained in a toast.
* **Cross-page steps.** A design-system edit adds a step on every page; its independence check is against later `design` changes only, so undoing one's variable edit works while others edit layers.
**Run revert** (`runs::revert`, `revert_change`, "Restore to here") under concurrency:
* The existing `blocker(runs, run)` refuses when a *later run* touched the same nodes. Extend it with the change log: a node of `RunPage.nodes` that has a log entry by another client (including `You` and clients with no run, like offline CLI before Phase 6) with `rev` after the run's first call is **foreign-changed**.
* Outcomes of "Revert run":
1. *No foreign-changed nodes:* as today (full page restore when the run is the only writer, node-level when `shared`).
2. *Some:* revert only the clean nodes (the existing node-level path used for `shared` pages, now chosen whenever *any* foreign entry exists on the page since the run started, not only when another run exists), skip the foreign-changed ones, and say so: "Reverted 14 layers. Kept 3 that Codex or you changed afterwards." with a list and a per-row "Revert anyway" (restores that node to the snapshot, overwriting the later change; one undo step, shows what will be lost).
3. *Everything is foreign-changed:* nothing is reverted; the state stays `Review`.
* An agent-driven revert (a future tool) is never forced; only the person can use "Revert anyway".
* The auto-version `Before agent run N` is a whole-page snapshot taken when *that client's* run starts, so it contains other agents' in-flight work. `RunPage.nodes` / `keep_own` already restrict review and revert to the run's own nodes; the change log makes that precise for clients that never had a run.
### 6. Offline CLI and multi-process
**6.1 What races today**
| Race | Today | Verdict |
|---|---|---|
| Offline A vs offline B, same project | `PROJECT_LOCK` taken at first edit, held until the process exits; B waits 5 s then fails; A reloads at lock time | Safe but unfair: a long `shell` starves everyone (G1) |
| Offline vs running app, project open in the app | `--offline` refused via `list_resources.open` for `open_file` / `--project` | Safe |
| Offline vs running app, project **not** open in the app, app opens it later | the app's `Session::open` never looks at `PROJECT_LOCK`; it loads mid-edit state, then saves over the CLI's later edits | Race (G2) |
| Save over a changed file (Dropbox, a second machine's copy, a manual copy) | blind overwrite | Last writer wins (G3) |
| Offline writes carry no identity, runs, versions or log entries | `Offline::call` bypasses `Tracker` | Gap (G4) |
**6.2 Per-call lease (G1).** `Offline::call` takes the project lock **around each editing call**, not for the process lifetime: `lock -> refresh -> exec -> save -> unlock`. Waits stay `PROJECT_LOCK_WAIT` (5 s), but now a held lock lasts one call (milliseconds to a second), so shells and scripts interleave. `refresh` compares the on-disk stamp (`manifest.modified` plus `len + mtime` of each page file and `design.json`) with the stamp recorded at the last load/save; for each page whose stamp changed it reloads **that page only** (replacing its `Document` and resetting only its history); unchanged pages keep their history, so `:undo` in a shell works across interleaving as long as that page was not changed meanwhile. Because every offline editing call saves before unlocking, the process has no dirty pages at refresh time, so the refresh never needs a merge.
**6.3 The app takes the lock too (G2).** `Session::open` (native) takes `PROJECT_LOCK` with a 2 s wait before loading and keeps it while the project is open (released on close or switch, and by the OS on crash). An offline process that finds it held by the app reports "PaperCraft has this project open" (same text as `refuse_if_open`) instead of "another papercraft-cli is writing". The lock file's content records the holder (`{"pid", "client", "since"}`) so errors can say "held by papercraft-cli --as Codex (pid 4120, 12 s)". The windows between app open and offline lease are one call long, thanks to 6.2.
**6.4 Identity and the log offline (G4).** `Offline` gets a `Client` and a `Tracker`; the run closes when the process ends or after `RUN_IDLE_SECS`, and the auto-version rules apply, so Review/Revert cover offline work. The change log (Phase 0) is persisted as `activity.json` in the project folder (bounded: `ACTIVITY_KEEP` = 500 entries, plus `epoch` and `rev`), written atomically by the process holding the lock after each editing call, and loaded by the app on open and by offline processes at lease time. This is what makes `ifRev` and `get_changes` meaningful across offline processes and across an app restart. A project without the file starts at `rev = 0` with a new epoch (`history_reset` for older `ifRev`). Format: additive, optional, ignored by older builds.
**6.5 Claims offline.** With no app running, claims live in `claims.json` in the project folder, read and written under the lease (expired ones are dropped on load); the holder is the stable `agent:<name>` session, so a claim survives across that agent's CLI invocations. With the app running, offline is only used on projects the app does not hold, and the app discards `claims.json` when it opens the project (agents using the app are on MCP sessions that the file cannot represent). Every offline process reads the file at lease time, so claims are enforced between offline processes too.
**6.6 Save-time check, merge or refuse (G3).** `Session::save` keeps per page the packed bytes it last read or wrote (`PageBase`, zstd, about the size of the file) and the file stamp. Before writing a dirty page it compares the stamp on disk. Unchanged (the normal case): write. Changed: three-way merge with `merge::merge(base, local, disk)` (decoded JSON), write the result atomically, and install it in the editor as one undo step "Merged changes from disk" (via `Editor::restore_snapshot`, which already exists for versions). `merge` is extended to return a report (`Merged { value, notes }`, notes for each scalar where both sides changed and local won, and each renumbered id) because RULES.md says a merge that cannot keep something must say so; the notes go to the status line and the Agent view. Refuse (save skipped, status "Disk changed and cannot be merged: ...") when either side fails to decode or the page file has a newer format version. `design.json` and the manifest are small objects; they use the same merge with `base` = last written. The shared-project sync engine keeps its own `.sync-base/` merge; this check runs first and does not replace it.
**6.7 App running, offline requested.** Unchanged: plain `call` goes through the app; `--offline` on a project the app has open is refused; `--offline` on another project works under the lease. A CLI that finds `APP_LOCK` held but no server yet keeps waiting (`app_starting`).
### 7. Chat vs MCP
Both reach `App::run_mcp_calls`, so identity, conflicts, claims, rev, runs and the log are identical. Differences, all unchanged by this spec:
| Aspect | Chat | External MCP / CLI |
|---|---|---|
| Identity | `Chat` + chat id, from `?chat=` or the API backend's dispatch | client info / `--as` |
| Steering | `steer::attach` appends the user's mid-turn messages to the next tool result | none |
| Run end | `Tracker::finish` when the turn ends; also releases claims | idle 30 s or `finish_working_on_nodes` |
| Auto mode | answers `ask_user` with the first option; unaffected | not applicable |
| Sub-agents | `delegate` (off the UI thread) | an MCP client's own sub-agents are not visible; they appear as the same or new sessions |
The swarm block must surface in chat the same way: `swarm::attach(client, r)` runs for every client right before `steer::attach`, so the chat model sees `conflicts`, `claimWarnings` and `notices` in its tool results. In the chat UI, `chat_msgs` renders a result with `swarm.conflicts` or a `claimed` / `conflict` error as a warning chip on the tool card ("Codex changed Header at rev 55") with a "Show" action that selects the node; the chat system prompt gets the protocol block of section 4 (the chat agent is a swarm member, and its `delegate` calls carry `scope`).
### 8. Relay and other machines
Out of scope: real-time cross-machine claims, and cross-machine `rev` (each machine has its own counter). In scope by reference only:
* A relay sync is **page granularity** (`sync.rs`): when both sides changed a page it merges it with `merge::merge` against `.sync-base/` and the folder wins ties; the open project reloads (`Session::sync_poll`) unless it has unsaved edits (those win and go up on the next save).
* A synced merge lands in the editor as a reload, so it is recorded in the log as one entry by `client: "Sync"`, `nodes` = the pages' changed nodes if cheap to compute, else `page: <id>, whole: true`. Agents then see it as a conflict (`by: Sync`) on their next write to that page. `Tracker` already flags "Includes changes synced from others" in Review; this reuses that signal.
* Claims are local to the machine that holds the app; a remote editor is like the human: never blocked, always reported (as `Sync`).
* Remote agents on the relay (`mcp/relay.rs`) arrive through the same MCP path on the host machine and are ordinary clients there.
## Not in scope
* Real-time collaborative cursors for humans.
* Automatic semantic merge of conflicting edits on the same property (the agent decides).
* Enforcing the human `Node.locked` flag against agents (open question 4).
* Blocking a read while another agent is writing (reads see the committed state).
* Per-property claims; layer granularity only.
* Cross-machine claims or `rev`.
* Cross-page atomic undo.
* Locking on network filesystems where advisory locks are unreliable: documented as unsupported, not detected.
## Acceptance
* Two simulated clients editing disjoint artboards produce no `conflicts`; overlapping edits produce exactly the conflicts the brute-force oracle computes (section 2 table), in `warn` and `strict`, for every relation.
* A strict rejection leaves the document, the log (`rev`), the undo stack and runs unchanged.
* `claim_nodes` on a layer makes another client's `update_styles` on it or a descendant fail with `claimed` (`Block`) or succeed with `claimWarnings` (`Warn`); the holder is unaffected; the human is never blocked. A claim expires after `ttl` of holder silence (fake clock), is released by `finish_working_on_nodes`, by turn end, by helper end, by project switch, and by Break; overlapping and ancestor/descendant requests are denied atomically.
* `delegate` of role `build` without `scope` is an error; with `scope` the helper's claim exists before the process starts and is gone after it ends.
* `undo {scope:"mine"}` undoes the caller's newest step when later steps by others touch other layers and returns `undo_blocked` with `blockedBy` when they touch the same ones; Ctrl+Z still undoes the newest step.
* Run revert with a later foreign edit to one of its nodes reverts the others and reports the kept one; revert never changes a layer the run did not touch.
* Two offline `Offline` instances on one folder (threads, real locks) each doing N edits end with 2N edits on disk and in a fresh load; the shell with an idle prompt does not block the other.
* `Session::open` on a project whose `PROJECT_LOCK` is held waits, then reports the holder. Saving a page whose file changed on disk merges and reports notes, or refuses; a layer is never silently dropped.
* A project without `activity.json`, `claims.json` or the new run fields loads unchanged; an older build ignores them.
* Tool schemas and descriptions exist for `claim_nodes`, `release_claims`, `list_claims`, the new `ifRev` / `onConflict` / `scope` arguments, each tested in `tests/mcp.rs`; README and ROADMAP tool counts are updated.
* Screenshot inspection at scale 2 in both themes of: agent colours on outlines, claim outline and tag, Agent view with two agents and a delegate, Claims section, a long agent name and a long note (no overflow, 44 px targets).
* `cargo test --no-fail-fast`, `cargo check --lib --target wasm32-unknown-unknown` (swarm logic is target-agnostic; file locks are native only) and the Windows check pass. 80% line coverage on the new `src/mcp/swarm/` modules and the changed lines elsewhere.
## Open questions for the maintainer
See `plan.md`.
+44
View File
@@ -0,0 +1,44 @@
# Tasks
Each task is independently testable. Tests first. Stop on failure. Tasks in the same wave touch different files and can run in parallel; a wave starts when the previous one passes `cargo test`. Phase 0 (change log, `rev`, `get_changes`, scoped lint) must be merged first.
## Wave 1: foundations (parallel)
- [x] 1. **Client identity** (`src/mcp/mod.rs`, `src/mcp/server.rs`). `Client.parent: Option<String>`; `agent_of(query) -> Option<AgentQuery{name,parent}>` next to `chat_of`; `client_of` builds `Client{name, session:"agent:<name>", parent}`; sanitize names, reject `You`/`Chat`/`Actions`. Test: parsing, sanitizing, reserved names, equality, `chat=` still wins.
- [ ] 2. **CLI identity** (`src/bin/papercraft-cli.rs`, `src/cli.rs`, `src/mcp/delegate.rs`). `--as`, `--parent`, `PAPERCRAFT_AGENT`, `PAPERCRAFT_PARENT` (flag > env > `cli`); online appends the query to the MCP URL; `Offline::new(store, project, client)`; `delegate::run` sets both env vars on the helper; one-time stderr hint for unnamed. Test: precedence, URL, offline carries the client, helper env visible (unix test), shell keeps one identity across lines. Depends on 1. *Partly done: `--as`/`--parent`, env and helper env; offline client and stderr hint open.*
- [x] 3. **Gate skeleton** (new `src/mcp/swarm/{mod,gate}.rs`, `src/lib.rs` or `src/mcp/mod.rs` registration; call sites in `src/app.rs::run_mcp_calls` and `cli::Offline::call`). `Swarm`, `Gate::{Allow, Reject(String)}`, `swarm::call(host, tracker, swarm, client, name, args, now)`, `swarm::attach(client, r)` (before `steer::attach`). Empty check chain; behaviour unchanged. Test: all existing `tests/mcp.rs` and `tests/cli_offline.rs` pass; a stub check can reject and annotate.
- [ ] 4. **Harness skeleton** (`tests/common/swarm.rs`, `tests/swarm.rs`). `Sim`, `SimClient`, `Step`, `FakeClock`, `Oracle` stub, `disjoint(n)` scenario: N clients write own artboards, assert every write present and ordered `rev`s. Depends on 3. *Partly done: `Sim` in `tests/swarm.rs` with a fake clock; oracle and seeds open.*
- [ ] 5. **Save-time stamp check and merge** (`src/session.rs::save`, `src/merge.rs`, `src/project/packed.rs`, `src/editor/pages.rs`). `PageBase`, stamp per page file, `merge::merge_report -> Merged{value, notes}` (keep `merge` as a wrapper), changed file -> merge -> write -> `restore_snapshot` as "Merged changes from disk"; undecodable or newer format -> refuse with a message. Test: unchanged writes; changed merges and reports; refusal; one undo step; property test: no kept id lost.
## Wave 2: behaviour (after wave 1; 6, 8, 10, 11 parallel)
- [ ] 6. **Targets and reach** (`src/mcp/swarm/targets.rs`). `of(name, args, ed) -> Targets{nodes, pages, variables, resources, reach}`, `Reach::{None,Node,Children,Subtree}`, `resources_of`. Test: every tool in `tools::definitions()` classified (fails on a new unclassified tool); resolution of `nodeId`, `nodeIds`, `targetNodeId`, parent, page, `var(--x)` in html/styles. *Partly done: `swarm::reach` and `exec::named_layers`; variable and page targets open.*
- [ ] 7. **Conflict rule** (`src/mcp/swarm/conflicts.rs`, `tools.rs` schemas, settings). `ifRev`, `onConflict`, `seen[(client,page,artboard)]`, relations table, warn trailing block, strict `conflict` error before execution, `history_reset`, cap 20, Settings entry + `PAPERCRAFT_CONFLICTS`. Test: each relation +/-, mode precedence, strict changes nothing (doc, rev, history, runs), human reported, oracle comparison in `tests/swarm.rs` (`overlap`, `churn` 200 seeds). Depends on 6. *Partly done: `ifRev`, `onConflict`, `PAPERCRAFT_CONFLICTS`, warn block, strict error, cap 20; settings entry open.*
- [x] 8. **Claims core** (`src/mcp/swarm/claims.rs`). State machine, overlap (same/ancestor/descendant), atomic all-or-nothing, own-claim merge, sliding TTL, limits, lazy expiry plus `tick`, parent-chain exemption, `Block`/`Warn`, resources, `Claim required`. Pure logic over ids and a clock, no UI. Test: the full state table and each auto-release row except those needing the app (project switch is a `Swarm::reset`). Depends on 3.
- [x] 9. **Claim tools and delegate scope** (`src/mcp/tools.rs`, `src/mcp/exec.rs`, `src/app.rs`, `src/mcp/delegate.rs`). `claim_nodes`, `release_claims`, `list_claims`; `finish_working_on_nodes` releases; turn end (`app.rs:772` neighbourhood) releases chat claims; delegate `scope` required for `build`, claim taken before spawn on the UI thread, released when the helper ends; `claimed` / `needs_claim` errors. Update README and ROADMAP tool counts. Test in `tests/mcp.rs` per tool; stub-helper test for claim lifetime. Depends on 8.
- [ ] 10. **Per-client undo** (`src/history.rs`, `src/editor/mod.rs`, `src/mcp/exec.rs::undo_redo`, `tools.rs`). `Change.author/rev/step`, `History::undo_by/redo_by`, independence check, root set-difference, `scope` argument, default `mine` for agents, `undo_blocked` result, author in the status text for Ctrl+Z. Test: independence matrix, redo, defaults, Ctrl+Z unchanged, cross-page design steps. *Partly done: `undo_by`, `scope`, `undo_blocked`, redo of own step; author in Ctrl+Z status open.*
- [ ] 11. **Offline lease and app lock** (`src/cli.rs`, `src/session.rs::open`, `src/project/mod.rs`, `src/bin/papercraft-cli.rs`). Lock around each editing call; stamp-based per-page refresh; lock file records holder; `Session::open` takes the project lock with a 2 s wait and keeps it; error text distinguishes app from CLI. Test: 3-thread `OfflineSim` (3N + M edits), idle shell does not block, app open waits then reports the holder, other pages keep history.
- [ ] 12. **Offline runs, log and claims file** (`src/cli.rs`, change-log persistence, `src/runs.rs`). `Tracker` in `Offline`, run closed at exit; `activity.json` (bounded, epoch) written under the lease and loaded by app and CLI; `claims.json` offline claims; app discards the file on open. Test: offline run and version exist and revert; roundtrip; bound; absent file. Depends on 2, 8, 11.
## Wave 3: runs and UI (after wave 2)
- [ ] 13. **Run revert under concurrency** (`src/runs.rs`, `src/ui/panels.rs`). `foreign_changed` from the log, outcomes 1 to 3, "Revert anyway" row action, kept-layers list, "Undo this step" in run rows. Test: partial revert, all-foreign, offline-client edits count as foreign, other layers untouched. Depends on 10.
- [ ] 14. **Agent colours and presence pin** (`src/theme.rs`, `src/ui/mod.rs`, canvas painter). Theme tokens `agent_1..8` in both themes, `agent_color(label)`, tinted outlines and tags, pin, `Chat / build` label. Screenshot at scale 2 in each theme (`tests/agent_view.rs` style), long label truncates. Depends on 2.
- [ ] 15. **Claims UI** (`src/ui/panels.rs`, canvas painter, `src/ui/palette.rs`). Dashed claim outline and tag, right-click "Break claim", Agent view `Claims` section and delegate nesting, resource chips, notice to the holder on Break, "Break all claims" palette entry. Screenshots both themes, 1440p and 1280x800, long note and name, 44 px targets. Depends on 9, 14.
- [ ] 16. **Chat surface** (`src/ui/chat_msgs.rs`, `src/ui/chat.rs`). Warning chip for `swarm.conflicts` / `claimed` / `conflict` on tool cards with Show; protocol block added to the chat system prompt. Test: parse of the `swarm` block, chip text, prompt contains the protocol. Depends on 7, 9.
## Wave 4: finish
- [ ] 17. **Skill and docs**. `skills/papercraft/SKILL.md` gets the section 4 protocol and the partitioning table (kept short; run it with two real agents against a test project on a private port, per AGENTS.md "Running the app safely"); `docs/agents.md` (identity, `--as`, conflicts, claims, undo scope, offline lease); `src/mcp/tools.rs::INSTRUCTIONS`; README and ROADMAP tool counts; `ROADMAP.md` row.
- [ ] 18. **Swarm scenarios complete** (`tests/swarm.rs`). Adds `shared_resources`, `chain` (delegate), claim expiry, per-client undo, offline sim, revert-with-foreign scenarios; `churn` 200 seeds default, 5000 `--ignored`. Coverage gate 80% on `src/mcp/swarm/` and changed lines.
- [ ] 19. **Verify**. Full `cargo test --no-fail-fast` (known `tests/update.rs` failure on Windows), `cargo check --lib --target wasm32-unknown-unknown`, Windows check, `cargo clippy --all-targets`, `cargo fmt --all`. Manual: two MCP clients and the chat on one project on a private `PAPERCRAFT_HOME` and `PAPERCRAFT_MCP_PORT`, never the person's running app.
## Independence summary
* Fully independent: 5 (save merge), 10 (undo), 11 (offline lease).
* Share only the gate skeleton: 7 and 8/9.
* UI tasks 14 to 16 touch `panels.rs` in separate sections; land 14 first.
## Model per task (subagents)
Claude Sonnet: 1, 2, 3, 4, 7, 8, 9, 10, 12, 13. Claude Haiku: 6 (table), 17. Codex coding model: 5, 11, 14, 15, 16, and a read-only review after each wave. Only one editing agent per file at a time (see the hot spots in `plan.md`).
+37 -5
View File
@@ -89,6 +89,9 @@ pub struct App {
)>,
/// Groups the agent's editing calls into reviewable runs.
runs: crate::runs::Tracker,
/// Claims and what each agent last saw; shared with delegate threads, which release their
/// helper's claims when it ends.
swarm: std::sync::Arc<std::sync::Mutex<crate::mcp::swarm::Swarm>>,
}
/// Events delivered to the winit loop from other sources.
@@ -140,6 +143,7 @@ impl App {
menu: None,
mcp: None,
runs: Default::default(),
swarm: Default::default(),
};
app.st.library.library = library;
app.st.perf.prefs = crate::ui::perf::PerfPrefs::load();
@@ -483,7 +487,8 @@ impl App {
}
let ext = spec.format.ext();
let out = if spec.single_file(items.len()) {
let base = if items.len() == 1 {
// A presentation is named after the project: the name is its title.
let base = if items.len() == 1 && spec.format != export::Format::Presentation {
items[0].name.clone()
} else {
self.st.project_name.clone()
@@ -700,8 +705,15 @@ impl App {
}
}
AppRequest::ExportFormat(f) => {
// From the menu, a presentation is of the whole project.
let scope = if f == crate::io::export::Format::Presentation {
crate::io::export::Scope::AllPages
} else {
self.st.export.scope
};
let spec = crate::io::export::Spec {
format: f,
scope,
..self.st.export
};
self.export(spec);
@@ -770,6 +782,7 @@ impl App {
if self.st.chat.take_finish_run() {
// A chat turn that edited is over: close its run now, not after the 30 s idle timeout.
self.runs.finish(crate::project::format::now_ms(), &self.ed);
self.swarm.lock().unwrap().release_chats();
}
self.st.sync_runs(&self.ctx, &mut self.runs, &self.ed);
self.st.chat.tools = self.tool_dispatch().map(crate::chat::events::ToolDispatch);
@@ -1575,9 +1588,27 @@ impl App {
let chat = (c.client.name == crate::mcp::CHAT_CLIENT).then(|| c.client.session.clone());
let provider = self.st.settings.chat.image_provider();
let helper = self.st.settings.chat.helper_command.clone();
// A helper is its own agent: named after its caller, with its scope claimed before
// it starts and released when it ends.
let agent = if c.name == crate::mcp::delegate::TOOL {
let role = c.args["role"].as_str().unwrap_or_default();
let claimed = self.swarm.lock().unwrap().claim_for_helper(&mut self.runs, &mut self.ed, &c.client, role, &c.args["scope"], crate::project::format::now_ms());
match claimed {
Ok(a) => Some(a),
Err(e) => {
let _ = c.reply.send(Err(e));
continue;
}
}
} else {
None
};
let swarm = self.swarm.clone();
std::thread::spawn(move || {
let r = if c.name == crate::mcp::delegate::TOOL {
crate::mcp::delegate::run(&helper, chat.as_deref(), &c.args)
let r = if let Some(a) = &agent {
let r = crate::mcp::delegate::run(&helper, chat.as_deref(), (&a.name, a.parent.as_deref().unwrap_or_default()), &c.args);
swarm.lock().unwrap().release_client(a);
r
} else if c.name == crate::mcp::refs::TOOL {
crate::mcp::refs::fetch(dir.as_deref(), chat.as_deref(), &c.args)
} else {
@@ -1588,14 +1619,15 @@ impl App {
continue;
}
let mut runs = std::mem::take(&mut self.runs);
let r = runs.call(self, &c.client, &c.name, &c.args, crate::project::format::now_ms());
let swarm = self.swarm.clone();
let r = swarm.lock().unwrap().call(&mut runs, self, &c.client, &c.name, &c.args, crate::project::format::now_ms());
let touched = runs.take_touched();
if r.is_ok() {
let who = runs.label(&c.client);
self.st.mark_agent(&self.ed, who, &c.name, &c.args, &touched);
}
self.runs = runs;
self.st.log_agent(&c.name, r.as_ref().err().cloned());
self.st.log_agent(&c.name, &c.args, r.as_ref().err().cloned());
if let Err(e) = &r {
log::info!("mcp {}: {e}", c.name);
}
+14 -7
View File
@@ -1,7 +1,8 @@
//! Image assets, registered process-wide by content hash.
//!
//! Image fills reference an asset id (a hash of the encoded bytes). The registry keeps the
//! decoded pixels for rendering and the original bytes for saving and export. Projects store
//! original bytes and the size; pixels are decoded only when an image is uploaded to the GPU or
//! exported, since decoded images are several times their file size and are never dropped. Projects store
//! their assets as `assets/<id>.<ext>`; copy/paste between projects carries only the id, and
//! the destination project writes the file on its next save.
@@ -13,7 +14,6 @@ pub struct ImageAsset {
pub id: String,
pub width: u32,
pub height: u32,
pub rgba: image::RgbaImage,
/// The original encoded file.
pub bytes: Vec<u8>,
/// "png", "jpg", ...
@@ -21,6 +21,11 @@ pub struct ImageAsset {
}
impl ImageAsset {
/// The decoded pixels. Decodes every call: the callers keep the result as long as they need.
pub fn rgba(&self) -> Option<image::RgbaImage> {
image::load_from_memory(&self.bytes).ok().map(|i| i.to_rgba8())
}
pub fn mime(&self) -> &'static str {
match self.ext {
"jpg" => "image/jpeg",
@@ -61,14 +66,15 @@ pub fn register(bytes: &[u8]) -> Result<String, String> {
if registry().lock().unwrap().contains_key(&id) {
return Ok(id);
}
let img = image::load_from_memory(bytes)
let (width, height) = image::ImageReader::new(std::io::Cursor::new(bytes))
.with_guessed_format()
.map_err(|e| format!("unsupported image: {e}"))?
.to_rgba8();
.into_dimensions()
.map_err(|e| format!("unsupported image: {e}"))?;
let a = ImageAsset {
id: id.clone(),
width: img.width(),
height: img.height(),
rgba: img,
width,
height,
bytes: bytes.to_vec(),
ext: ext_of(bytes),
};
@@ -208,5 +214,6 @@ mod tests {
let asset = get(&a).unwrap();
assert_eq!((asset.width, asset.height), (3, 2));
assert!(register(b"not an image").is_err());
assert_eq!(asset.rgba().map(|p| p.get_pixel(2, 1).0), Some([255, 0, 0, 255]), "decoded on demand");
}
}
+23 -1
View File
@@ -306,8 +306,26 @@ fn runtime() -> Result<tokio::runtime::Runtime, String> {
.map_err(|e| e.to_string())
}
/// Who this CLI is to other agents (`--as`, `--parent`, else `$PAPERCRAFT_AGENT`,
/// `$PAPERCRAFT_PARENT`): a named agent keeps one session across its calls and claims.
static AGENT: std::sync::OnceLock<(Option<String>, Option<String>)> = std::sync::OnceLock::new();
/// Percent-encoding for a query value.
fn encode(s: &str) -> String {
s.bytes()
.map(|b| if b.is_ascii_alphanumeric() || b"._-".contains(&b) { (b as char).to_string() } else { format!("%{b:02X}") })
.collect()
}
fn connect(rt: &tokio::runtime::Runtime) -> Result<Client, String> {
let url = mcp::url(mcp::port());
let mut url = mcp::url(mcp::port());
let (name, parent) = AGENT.get().cloned().unwrap_or_default();
if let Some(name) = name {
url = format!("{url}?agent={}", encode(&name));
if let Some(p) = parent {
url = format!("{url}&parent={}", encode(&p));
}
}
rt.block_on(async { ().serve(StreamableHttpClientTransport::from_uri(url.clone())).await })
.map_err(|e| format!("cannot reach papercraft at {url}: {e}. Is the app running?"))
}
@@ -742,6 +760,10 @@ fn main() -> ExitCode {
let mut args: Vec<String> = std::env::args().skip(1).collect();
let out = take_flag(&mut args, "--out", Some("-o"));
let project = take_flag(&mut args, "--project", None);
let env = |k: &str| std::env::var(k).ok().filter(|v| !v.trim().is_empty());
let agent = take_flag(&mut args, "--as", None).or_else(|| env("PAPERCRAFT_AGENT"));
let parent = take_flag(&mut args, "--parent", None).or_else(|| env("PAPERCRAFT_PARENT"));
AGENT.get_or_init(|| (agent, parent));
let offline = args.iter().any(|a| a == "--offline");
ONLINE_ONLY.store(args.iter().any(|a| a == "--online"), Ordering::Relaxed);
args.retain(|a| a != "--offline" && a != "--online");
+34 -4
View File
@@ -32,10 +32,13 @@ const IMAGE_MAX_EDGE: f64 = ZOOM_MAX_EDGE as f64;
const IMAGE_MAX_PIXELS: f64 = 1_150_000.0;
/// Only used when a JPEG had to be scaled down: high enough that re-encoding is invisible.
const IMAGE_JPEG_QUALITY: u8 = 95;
/// An opaque PNG (a screenshot, a photo) is also tried as a JPEG this good; text stays legible.
const IMAGE_LOSSY_QUALITY: u8 = 82;
/// Scales an image down to the send cap, then re-encodes it: PNG at the best lossless
/// compression, JPEG stays JPEG (a PNG of a photo is often bigger than the original). Keeps the
/// original bytes when they are already smaller. The third value is the original when scaled.
/// compression, JPEG stays JPEG (a PNG of a photo is often bigger than the original), and an
/// opaque PNG becomes a JPEG when that is smaller. Keeps the original bytes when they are
/// already smaller. The third value is the original when scaled.
pub fn shrink_image(name: String, img: events::ImageAttachment) -> (String, events::ImageAttachment, Option<Vec<u8>>) {
use image::codecs::{jpeg::JpegEncoder, png};
let Ok(decoded) = image::load_from_memory(&img.data) else { return (name, img, None) };
@@ -45,13 +48,20 @@ pub fn shrink_image(name: String, img: events::ImageAttachment) -> (String, even
return (name, img, None); // re-encoding a JPEG loses detail for little gain
}
let decoded = if k < 1.0 { decoded.resize((w * k) as u32, (h * k) as u32, image::imageops::FilterType::Lanczos3) } else { decoded };
let jpeg = img.mime == "image/jpeg";
let mut jpeg = img.mime == "image/jpeg";
let mut out = Vec::new();
let encoded = if jpeg {
let mut encoded = if jpeg {
decoded.to_rgb8().write_with_encoder(JpegEncoder::new_with_quality(&mut out, IMAGE_JPEG_QUALITY))
} else {
decoded.write_with_encoder(png::PngEncoder::new_with_quality(&mut out, png::CompressionType::Best, png::FilterType::Adaptive))
};
let opaque = || !decoded.color().has_alpha() || decoded.to_rgba8().pixels().all(|px| px[3] == u8::MAX);
if !jpeg && opaque() {
let mut lossy = Vec::new();
if decoded.to_rgb8().write_with_encoder(JpegEncoder::new_with_quality(&mut lossy, IMAGE_LOSSY_QUALITY)).is_ok() && (encoded.is_err() || lossy.len() < out.len()) {
(out, encoded, jpeg) = (lossy, Ok(()), true);
}
}
if encoded.is_err() || (k >= 1.0 && out.len() >= img.data.len()) {
return (name, img, None);
}
@@ -191,6 +201,26 @@ pub struct Chat {
pub issues: std::collections::BTreeMap<String, IssueOutcome>,
}
/// A chat as the history list shows it, without its messages: a long chat's screenshots run to
/// hundreds of MB, and the list must not hold a copy of every chat.
#[derive(Debug, Clone, PartialEq, Default, Deserialize)]
pub struct ChatSummary {
#[serde(default)]
pub id: String,
#[serde(default)]
pub title: String,
#[serde(default)]
pub updated: u64,
#[serde(default)]
pub backend: Backend,
}
impl ChatSummary {
pub fn of(c: &Chat) -> Self {
Self { id: c.id.clone(), title: c.title.clone(), updated: c.updated, backend: c.backend.clone() }
}
}
/// A drafted issue the user sent or dropped.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "lowercase")]
+137 -6
View File
@@ -1,7 +1,9 @@
//! Chat files in the project folder: `chats/<id>.json`, plus `chats/<id>.api.json` for the raw
//! Anthropic API messages (exact replay, thinking blocks included).
use super::{valid_id, Chat, TMP_DIR};
use super::{valid_id, Chat, ChatSummary, TMP_DIR};
use base64::{engine::general_purpose::STANDARD, Engine};
use serde_json::Value;
use crate::vfs;
use std::path::{Path, PathBuf};
@@ -27,23 +29,114 @@ pub fn save(project: &Path, chat: &Chat) -> std::io::Result<()> {
vfs::write(file_of(project, &chat.id), bytes)
}
/// Reads chat `id`. Screenshots still inline (older chats) move to files, and the slimmer chat
/// is written back at once, so the move happens only once.
pub fn load(project: &Path, id: &str) -> Option<Chat> {
if !valid_id(id) {
return None;
}
serde_json::from_slice(&vfs::read(file_of(project, id)).ok()?).ok()
let mut chat: Chat = serde_json::from_slice(&vfs::read(file_of(project, id)).ok()?).ok()?;
let mut moved = false;
for t in chat.messages.iter_mut().flat_map(|m| &mut m.tools) {
if let Some(r) = &mut t.result {
moved |= offload_images(project, id, &t.id, r);
}
}
if moved && let Err(e) = save(project, &chat) {
log::warn!("could not save chat {id}: {e}");
}
Some(chat)
}
/// Every readable chat, newest updated first. Unreadable files are skipped.
pub fn list(project: &Path) -> Vec<Chat> {
/// How long agents' screenshots are kept; an older one shows no thumbnail in its chat.
pub const RESULT_IMAGES_KEEP: std::time::Duration = std::time::Duration::from_secs(14 * 24 * 60 * 60);
const RESULT_PREFIX: &str = "result-";
/// Deletes the agents' screenshots (not the person's own images) older than `keep` from every
/// chat's image folder of `project`, so long chats do not pile up on disk.
pub fn prune_result_images(project: &Path, keep: std::time::Duration) {
let Ok(chats) = vfs::read_dir(project.join(TMP_DIR)) else { return };
for folder in chats.flatten().filter(|e| e.file_type().is_ok_and(|t| t.is_dir())) {
let Ok(files) = vfs::read_dir(folder.path()) else { continue };
for f in files.flatten() {
let old = f.metadata().and_then(|m| m.modified()).is_ok_and(|t| t.elapsed().is_ok_and(|age| age > keep));
if old && f.file_name().to_string_lossy().starts_with(RESULT_PREFIX) && let Err(e) = vfs::remove_file(f.path()) {
log::warn!("could not remove {}: {e}", f.path().display());
}
}
}
}
/// Inline images shorter than this stay in the chat.
const OFFLOAD_MIN: usize = 4096;
/// An image block of a tool result: its mime type and base64 data (inline), or its file.
pub fn image_data(block: &Value) -> Option<(&str, ImageSrc<'_>)> {
if block["type"] != "image" {
return None;
}
let mime = block["source"]["media_type"].as_str().or_else(|| block["mimeType"].as_str()).unwrap_or("image/png");
if let Some(path) = block["path"].as_str() {
return Some((mime, ImageSrc::File(path)));
}
let data = block["source"]["data"].as_str().or_else(|| block["data"].as_str())?;
Some((mime, ImageSrc::Base64(data)))
}
pub enum ImageSrc<'a> {
Base64(&'a str),
File(&'a str),
}
impl ImageSrc<'_> {
pub fn bytes(&self) -> Option<Vec<u8>> {
match self {
ImageSrc::Base64(d) => STANDARD.decode(d.as_bytes()).ok(),
ImageSrc::File(p) => vfs::read(p).ok(),
}
}
}
/// Moves the inline images of a tool result into the chat's image folder, leaving their path:
/// a long chat's screenshots would otherwise stay in memory and be rewritten with every save.
/// An image that cannot be written stays inline. True when any image moved.
pub fn offload_images(project: &Path, chat_id: &str, tool_id: &str, result: &mut Value) -> bool {
let (Some(blocks), Some(folder)) = (result.as_array_mut(), tmp_of(project, chat_id)) else { return false };
let mut moved = false;
let name: String = tool_id.chars().filter(|c| c.is_ascii_alphanumeric() || ['-', '_'].contains(c)).collect();
for (n, b) in blocks.iter_mut().enumerate() {
let Some((mime, ImageSrc::Base64(data))) = image_data(b) else { continue };
if data.len() < OFFLOAD_MIN {
continue;
}
let Ok(bytes) = STANDARD.decode(data.as_bytes()) else { continue };
let ext = mime.strip_prefix("image/").filter(|e| ["png", "jpeg", "webp", "gif"].contains(e)).unwrap_or("png");
let path = folder.join(format!("{RESULT_PREFIX}{name}-{n}.{ext}"));
let mime = mime.to_owned();
// Named by tool id and position, so a file already there is this image.
if !vfs::exists(&path)
&& let Err(e) = vfs::create_dir_all(&folder).and_then(|()| vfs::write(&path, &bytes))
{
log::warn!("could not save a chat image: {e}");
continue;
}
*b = serde_json::json!({"type": "image", "mimeType": mime, "path": path.to_string_lossy()});
moved = true;
}
moved
}
/// Every readable chat, newest updated first, without messages (`load` reads one whole).
/// Unreadable files are skipped.
pub fn list(project: &Path) -> Vec<ChatSummary> {
let Ok(entries) = vfs::read_dir(dir(project)) else {
return Vec::new();
};
let mut out: Vec<Chat> = entries
let mut out: Vec<ChatSummary> = entries
.flatten()
.filter(|e| e.path().extension().is_some_and(|x| x == "json"))
.filter(|e| !e.path().to_string_lossy().ends_with(API_SUFFIX))
.filter_map(|e| serde_json::from_slice::<Chat>(&vfs::read(e.path()).ok()?).ok())
.filter_map(|e| serde_json::from_slice::<ChatSummary>(&vfs::read(e.path()).ok()?).ok())
.filter(|c| valid_id(&c.id))
.collect();
out.sort_by_key(|c| std::cmp::Reverse(c.updated));
@@ -124,6 +217,43 @@ mod tests {
}
}
#[test]
fn screenshots_move_out_of_the_chat_into_files() {
let dir = temp_project();
let png = vec![7u8; OFFLOAD_MIN];
let mut c = chat("shots", 1);
c.messages[0].tools[0].result = Some(serde_json::json!([{"type": "image", "mimeType": "image/png", "data": STANDARD.encode(&png)}, {"type": "text", "text": "ok"}]));
save(&dir, &c).unwrap();
let loaded = load(&dir, "shots").unwrap();
let r = loaded.messages[0].tools[0].result.as_ref().unwrap();
let Some((mime, ImageSrc::File(path))) = image_data(&r[0]) else { panic!("still inline: {r}") };
let on_disk = std::fs::read_to_string(file_of(&dir, "shots")).unwrap();
assert!(on_disk.contains(path) && !on_disk.contains(&STANDARD.encode(&png)), "the slimmer chat was written back");
assert_eq!((mime, ImageSrc::File(path).bytes()), ("image/png", Some(png)));
assert_eq!(r[1]["text"], "ok");
remove_tmp(&dir, "shots");
std::fs::remove_dir_all(dir).ok();
}
#[test]
fn old_agent_screenshots_are_pruned_and_the_persons_images_kept() {
let dir = temp_project();
let folder = dir.join(TMP_DIR).join("c1");
std::fs::create_dir_all(&folder).unwrap();
let month_ago = std::time::SystemTime::now() - std::time::Duration::from_secs(30 * 24 * 60 * 60);
for name in ["result-t1-0.png", "result-t2-0.png", "1792910692197-crop.png"] {
std::fs::write(folder.join(name), b"x").unwrap();
}
for name in ["result-t1-0.png", "1792910692197-crop.png"] {
std::fs::File::options().write(true).open(folder.join(name)).unwrap().set_modified(month_ago).unwrap();
}
prune_result_images(&dir, RESULT_IMAGES_KEEP);
let mut left: Vec<String> = std::fs::read_dir(&folder).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect();
left.sort();
assert_eq!(left, ["1792910692197-crop.png", "result-t2-0.png"]);
std::fs::remove_dir_all(dir).ok();
}
#[test]
fn chat_round_trips() {
let dir = temp_project();
@@ -202,3 +332,4 @@ mod tests {
std::fs::remove_dir_all(dir).ok();
}
}
+356
View File
@@ -0,0 +1,356 @@
//! A bounded log of which layers changed at which revision, so an agent can ask "what changed
//! since I last looked" (`get_changes`) and lint only that (`lint_design {since}`).
//!
//! The revision here is the log's own counter, not `Editor::revision`: that one also moves when
//! the camera does and once per batch of MCP calls, so a read would bump it. This one moves only
//! when a committed edit, undo or redo changes layers.
use super::Editor;
use crate::model::{Document, NodeId};
use std::collections::{BTreeSet, HashMap, VecDeque};
use std::sync::Arc;
/// Entries kept; a caller older than the oldest one gets `truncated` and does a full pass.
pub const MAX_ENTRIES: usize = 10_000;
/// The MCP client behind a change.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Who {
/// Name and session: two agents with the same client name stay apart.
pub key: String,
/// What the canvas tag shows ("Claude Code", "Claude Code \u{b7} 2").
pub label: String,
}
/// One layer changed at one revision.
#[derive(Debug, Clone, PartialEq)]
struct Entry {
rev: u64,
page: String,
id: NodeId,
/// None for the person using the app (and for calls made without a client).
by: Option<Arc<Who>>,
}
/// Which changes `since` returns.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Filter<'a> {
All,
/// Everything but this client key's own changes.
Exclude(&'a str),
/// Only this client's changes, by label or key.
Only(&'a str),
}
/// Layers one client changed on one page since the asked revision.
#[derive(Debug, Clone, PartialEq)]
pub struct Group {
pub page: String,
pub by: Option<String>,
/// The latest revision in the group.
pub rev: u64,
/// Each layer once, in first-change order.
pub ids: Vec<NodeId>,
}
/// The answer to "what changed since revision N".
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Since {
/// The log no longer reaches back to the asked revision (or it is from the future): the
/// caller cannot trust `changes` and should look at everything.
pub truncated: bool,
pub changes: Vec<Group>,
}
#[derive(Debug, Clone)]
pub struct ChangeLog {
rev: u64,
entries: VecDeque<Entry>,
/// Highest revision of an entry dropped for space: asking since less than this is incomplete.
floor: u64,
max: usize,
/// The MCP client whose call is running, set by the run tracker around each call.
pub client: Option<Arc<Who>>,
/// Revision of the last whole-page lint per (client key, page id), for `changedOnly`.
linted: HashMap<(String, String), u64>,
}
impl Default for ChangeLog {
fn default() -> Self {
ChangeLog::with_capacity(MAX_ENTRIES)
}
}
impl ChangeLog {
pub fn with_capacity(max: usize) -> ChangeLog {
ChangeLog { rev: 0, entries: VecDeque::new(), floor: 0, max, client: None, linted: HashMap::new() }
}
/// The revision after the latest change.
pub fn rev(&self) -> u64 {
self.rev
}
/// Logs `ids` of `page` as changed in a new revision by the running client. Nothing
/// changed, no new revision.
pub fn record(&mut self, page: &str, ids: impl IntoIterator<Item = NodeId>) {
let mut ids = ids.into_iter().peekable();
if ids.peek().is_none() {
return;
}
self.rev += 1;
for id in ids {
self.entries.push_back(Entry { rev: self.rev, page: page.to_string(), id, by: self.client.clone() });
}
while self.entries.len() > self.max {
if let Some(dropped) = self.entries.pop_front() {
self.floor = self.floor.max(dropped.rev);
}
}
}
/// Forgets everything (another project was opened): every caller must start over.
pub fn reset(&mut self) {
self.rev += 1;
self.floor = self.rev;
self.entries.clear();
self.linted.clear();
}
/// Layers changed after revision `since`, optionally only on `page`, grouped by page and
/// client in first-change order.
pub fn since(&self, since: u64, page: Option<&str>, filter: Filter) -> Since {
if since > self.rev || since < self.floor {
return Since { truncated: true, changes: Vec::new() };
}
let keep = |e: &Entry| match filter {
Filter::All => true,
Filter::Exclude(k) => e.by.as_ref().is_none_or(|w| w.key != k),
Filter::Only(k) => e.by.as_ref().is_some_and(|w| w.key == k || w.label == k),
};
let mut changes: Vec<Group> = Vec::new();
for e in self.entries.iter().filter(|e| e.rev > since && page.is_none_or(|p| p == e.page) && keep(e)) {
let by = e.by.as_ref().map(|w| w.label.clone());
let at = changes.iter().position(|g| g.page == e.page && g.by == by);
let i = at.unwrap_or_else(|| {
changes.push(Group { page: e.page.clone(), by, rev: e.rev, ids: Vec::new() });
changes.len() - 1
});
let g = &mut changes[i];
g.rev = g.rev.max(e.rev);
if !g.ids.contains(&e.id) {
g.ids.push(e.id);
}
}
Since { truncated: false, changes }
}
/// Notes that the running client linted all of `page` as of the current revision.
pub fn note_lint(&mut self, page: &str) {
if let Some(c) = &self.client {
self.linted.insert((c.key.clone(), page.to_string()), self.rev);
}
}
/// Revision of the running client's last whole-page lint of `page`.
pub fn last_lint(&self, page: &str) -> Option<u64> {
let key = self.client.as_ref()?.key.clone();
self.linted.get(&(key, page.to_string())).copied()
}
}
/// Layers of `doc` bound to a variable in `names` (CSS names), or to any variable when `names`
/// is None: through a fill that follows it or a field binding. Sorted by id.
pub fn bound_layers(doc: &Document, names: Option<&BTreeSet<String>>) -> Vec<NodeId> {
let hit = |v: &str| names.is_none_or(|n| n.contains(v));
let mut out: Vec<NodeId> = doc
.nodes
.values()
.filter(|n| n.style.fills.iter().filter_map(|p| p.var()).any(hit) || n.bindings.values().any(|v| hit(v)))
.map(|n| n.id)
.collect();
out.sort();
out
}
impl Editor {
/// Logs the layers bound to the variables `names` (all variables when None) on every page:
/// an edit to a variable changes how they look or lay out even when no layer was written.
pub fn log_variable_users(&mut self, names: Option<&BTreeSet<String>>) {
self.finish_loading();
for i in 0..self.pages.len() {
let ids = bound_layers(self.page_doc(i), names);
let page = self.pages[i].id.clone();
self.changes.record(&page, ids);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::geom::Rect;
use crate::model::{Color, Node, NodeKind, Paint};
#[test]
fn bound_layers_follow_fills_and_field_bindings() {
let mut doc = Document::default();
let mut add = |f: &dyn Fn(&mut Node)| {
let id = doc.alloc_id();
let mut n = Node::new(id, NodeKind::Frame { clip: false }, Rect::new(0.0, 0.0, 10.0, 10.0));
f(&mut n);
doc.nodes.insert(id, n);
id
};
let fill = add(&|n| n.style.fills = vec![Paint::solid_var(Color::rgb(1, 2, 3), "brand")]);
let gap = add(&|n| {
n.bindings.insert("gap".into(), "space-4".into());
});
let _plain = add(&|n| n.style.fills = vec![Paint::solid(Color::rgb(1, 2, 3))]);
let some = |s: &str| Some(BTreeSet::from([s.to_string()]));
assert_eq!(bound_layers(&doc, some("brand").as_ref()), vec![fill]);
assert_eq!(bound_layers(&doc, some("space-4").as_ref()), vec![gap]);
assert!(bound_layers(&doc, some("other").as_ref()).is_empty());
assert_eq!(bound_layers(&doc, None), vec![fill, gap]);
}
fn ids(v: &[u64]) -> Vec<NodeId> {
v.iter().map(|n| NodeId(*n)).collect()
}
fn who(key: &str, label: &str) -> Option<Arc<Who>> {
Some(Arc::new(Who { key: key.into(), label: label.into() }))
}
fn all(log: &ChangeLog, since: u64) -> Since {
log.since(since, None, Filter::All)
}
#[test]
fn each_recorded_edit_is_a_revision() {
let mut log = ChangeLog::default();
assert_eq!(log.rev(), 0);
log.record("p1", ids(&[1, 2]));
log.record("p1", ids(&[3]));
assert_eq!(log.rev(), 2);
let g = &all(&log, 0).changes;
assert_eq!((g.len(), g[0].ids.clone(), g[0].rev), (1, ids(&[1, 2, 3]), 2));
assert_eq!(all(&log, 1).changes[0].ids, ids(&[3]));
assert!(all(&log, 2).changes.is_empty());
}
#[test]
fn an_empty_edit_keeps_the_revision() {
let mut log = ChangeLog::default();
log.record("p1", []);
assert_eq!(log.rev(), 0);
}
#[test]
fn a_layer_changed_twice_is_listed_once() {
let mut log = ChangeLog::default();
log.record("p1", ids(&[1]));
log.record("p1", ids(&[1, 2]));
assert_eq!(all(&log, 0).changes[0].ids, ids(&[1, 2]));
}
#[test]
fn changes_can_be_limited_to_a_page() {
let mut log = ChangeLog::default();
log.record("p1", ids(&[1]));
log.record("p2", ids(&[1]));
let s = log.since(0, Some("p2"), Filter::All);
assert_eq!((s.changes.len(), s.changes[0].page.as_str()), (1, "p2"));
assert_eq!(all(&log, 0).changes.len(), 2);
}
#[test]
fn asking_older_than_the_oldest_entry_is_truncated() {
let mut log = ChangeLog::with_capacity(3);
log.record("p", ids(&[1])); // rev 1
log.record("p", ids(&[2, 3])); // rev 2
assert!(!all(&log, 0).truncated);
log.record("p", ids(&[4])); // rev 3 drops the rev 1 entry
assert!(all(&log, 0).truncated);
// Everything after the dropped revision is still complete.
let s = all(&log, 1);
assert!(!s.truncated);
assert_eq!(s.changes[0].ids, ids(&[2, 3, 4]));
}
#[test]
fn the_log_stays_bounded() {
let mut log = ChangeLog::with_capacity(5);
for n in 0..20 {
log.record("p", ids(&[n]));
}
assert_eq!(log.entries.len(), 5);
assert_eq!(all(&log, 15).changes[0].ids, ids(&[15, 16, 17, 18, 19]));
assert!(all(&log, 14).truncated);
}
#[test]
fn a_future_revision_is_truncated() {
let mut log = ChangeLog::default();
log.record("p", ids(&[1]));
assert!(all(&log, 9).truncated);
}
#[test]
fn reset_forces_a_full_pass() {
let mut log = ChangeLog::default();
log.record("p", ids(&[1]));
let old = log.rev();
log.reset();
assert!(all(&log, old).truncated);
assert!(!all(&log, log.rev()).truncated);
}
#[test]
fn lint_revisions_are_per_client_session_and_page() {
let mut log = ChangeLog::default();
log.record("p", ids(&[1]));
assert_eq!(log.last_lint("p"), None, "no client, nothing remembered");
log.client = who("rmcp#1", "rmcp");
log.note_lint("p");
assert_eq!(log.last_lint("p"), Some(1));
assert_eq!(log.last_lint("q"), None);
// Same client name, another session.
log.client = who("rmcp#2", "rmcp \u{b7} 2");
assert_eq!(log.last_lint("p"), None);
}
#[test]
fn two_clients_changes_are_attributed_separately() {
let mut log = ChangeLog::default();
log.client = who("rmcp#1", "rmcp");
log.record("p", ids(&[1, 2]));
log.client = who("rmcp#2", "rmcp \u{b7} 2");
log.record("p", ids(&[2, 3]));
log.client = None;
log.record("p", ids(&[4]));
let g = all(&log, 0).changes;
assert_eq!(g.len(), 3);
assert_eq!((g[0].by.as_deref(), g[0].ids.clone(), g[0].rev), (Some("rmcp"), ids(&[1, 2]), 1));
assert_eq!((g[1].by.as_deref(), g[1].ids.clone(), g[1].rev), (Some("rmcp \u{b7} 2"), ids(&[2, 3]), 2));
assert_eq!((g[2].by.clone(), g[2].ids.clone()), (None, ids(&[4])));
}
#[test]
fn filters_pick_one_client_or_everyone_else() {
let mut log = ChangeLog::default();
log.client = who("a#1", "A");
log.record("p", ids(&[1]));
log.client = who("b#1", "B");
log.record("p", ids(&[2]));
log.client = None;
log.record("p", ids(&[3]));
let others = log.since(0, None, Filter::Exclude("a#1"));
let labels: Vec<_> = others.changes.iter().map(|g| g.by.as_deref()).collect();
assert_eq!(labels, vec![Some("B"), None], "the person's edits are someone else's too");
let only = log.since(0, None, Filter::Only("B"));
assert_eq!(only.changes.len(), 1);
assert_eq!(only.changes[0].ids, ids(&[2]));
assert_eq!(log.since(0, None, Filter::Only("b#1")).changes, only.changes);
}
}
+1
View File
@@ -12,6 +12,7 @@ pub enum UiEvent {
TogglePalette,
Rename(NodeId),
ToggleUi,
TogglePanels,
ToggleChat,
ToggleHelp,
ToggleGrid,
+1 -1
View File
@@ -168,7 +168,7 @@ impl Editor {
}
match self.take_rec() {
Some((rec, key)) if keep => {
if self.log_touched(&rec) && self.history.commit(&self.doc, rec, key) {
if self.log_touched(&rec) && self.history.commit_by(&self.doc, rec, key, self.changes.client.as_ref().map(|w| w.key.clone())) {
self.revision += 1;
}
}
+62 -23
View File
@@ -3,6 +3,7 @@
//! It consumes platform-agnostic [`InputEvent`]s, so every interaction can be scripted in tests.
pub mod annotations;
pub mod changes;
pub mod commands;
pub mod design;
pub mod hit;
@@ -17,7 +18,7 @@ pub mod vector_ops;
pub mod whiteboard;
use crate::geom::{Camera, Rect, V2, next_zoom_step, v2};
use crate::history::{History, Recording, Tx};
use crate::history::{History, Recording, Tx, Undone};
use crate::ink::{self, InkBuilder};
use crate::model::{Color, Document, Node, NodeId, NodeKind, StrokePoint, TextAlign};
use crate::text;
@@ -269,6 +270,8 @@ pub struct Editor {
pub active_page: usize,
/// Page groups (folders): group name -> page ids, in page order.
pub page_groups: BTreeMap<String, Vec<String>>,
/// Page id -> icon (`set:name`), saved in the manifest.
pub page_icons: BTreeMap<String, String>,
/// Page list or names changed (not tracked by undo history).
pub meta_dirty: bool,
/// The open project's design system (`design.json`).
@@ -317,6 +320,8 @@ pub struct Editor {
/// While set, every committed change appends the (page id, node) pairs it changed: the agent
/// run tracker attributes nodes to the run that made the call.
pub touch_log: Option<Vec<(String, NodeId)>>,
/// Which layers changed at which revision (`get_changes`, incremental lint).
pub changes: changes::ChangeLog,
}
impl Default for Editor {
@@ -370,6 +375,7 @@ impl Editor {
pages: vec![PageSlot::new(&name)],
active_page: 0,
page_groups: BTreeMap::new(),
page_icons: BTreeMap::new(),
meta_dirty: false,
design: Default::default(),
library_docs: BTreeMap::new(),
@@ -393,6 +399,7 @@ impl Editor {
clipboard_style: None,
cam_moved: None,
touch_log: None,
changes: changes::ChangeLog::default(),
clipboard_io: None,
clipboard_written: None,
}
@@ -426,7 +433,7 @@ impl Editor {
let mains = std::mem::take(&mut self.touched_mains);
if let Some((rec, key)) = self.take_rec()
&& self.log_touched(&rec)
&& self.history.commit(&self.doc, rec, key)
&& self.history.commit_by(&self.doc, rec, key, self.changes.client.as_ref().map(|w| w.key.clone()))
{
self.revision += 1;
if !mains.is_empty() {
@@ -436,24 +443,40 @@ impl Editor {
self.flush_cross_pages();
}
/// Appends the nodes `rec` changed to `touch_log`. Always true, so it chains before a commit.
/// Appends the nodes `rec` changed to `touch_log` and the change log. Always true, so it
/// chains before a commit.
pub(crate) fn log_touched(&mut self, rec: &Recording) -> bool {
let Some(page) = self.touch_log.as_ref().and_then(|_| self.pages.get(self.edit_page_index())) else {
let Some(page) = self.pages.get(self.edit_page_index()).map(|p| p.id.clone()) else {
return true;
};
let page = page.id.clone();
let changed: Vec<_> = rec
let changed: Vec<NodeId> = rec
.touched()
.iter()
.filter(|id| rec.before(**id).map(Option::as_ref) != Some(self.doc.nodes.get(id)))
.map(|id| (page.clone(), *id))
.copied()
.collect();
self.changes.record(&page, changed.iter().copied());
if let Some(log) = &mut self.touch_log {
log.extend(changed);
log.extend(changed.into_iter().map(|id| (page.clone(), id)));
}
true
}
/// A restored design system moves variables under their layers, which the step's own
/// layers do not list.
fn log_history_design(&mut self, design_restored: bool) {
if design_restored {
self.log_variable_users(None);
}
}
/// Logs the layers an undo or redo restored.
fn log_history(&mut self, nodes: &[NodeId]) {
if let Some(page) = self.pages.get(self.edit_page_index()).map(|p| p.id.clone()) {
self.changes.record(&page, nodes.iter().copied());
}
}
/// Run one edit as a complete transaction.
pub fn edit<R>(&mut self, label: &str, f: impl FnOnce(&mut Tx) -> R) -> R {
self.begin(label);
@@ -468,7 +491,7 @@ impl Editor {
let result = f(&mut self.tx());
if let Some((rec, key)) = self.take_rec()
&& self.log_touched(&rec)
&& self.history.commit(&self.doc, rec, key)
&& self.history.commit_by(&self.doc, rec, key, self.changes.client.as_ref().map(|w| w.key.clone()))
{
self.revision += 1;
}
@@ -488,29 +511,38 @@ impl Editor {
}
pub fn undo(&mut self) {
self.cancel_gesture();
self.end_text_edit();
self.commit();
let mut changed = Changed::Nodes(Arc::new([]));
if let Some(mut u) = self.history.undo(&mut self.doc) {
self.status = format!("Undo {}", u.label);
changed = Changed::Nodes(std::mem::take(&mut u.nodes).into());
self.restore_project_state(u);
}
self.after_history(changed);
let Ok(()) = self.step_history("Undo", |h, doc| Ok::<_, std::convert::Infallible>(h.undo(doc)));
}
pub fn redo(&mut self) {
let Ok(()) = self.step_history("Redo", |h, doc| Ok::<_, std::convert::Infallible>(h.redo(doc)));
}
/// Undoes `author`'s newest step under later steps by others (see [`History::undo_by`]).
/// Err: the labels of the later steps that touched the same things.
pub fn undo_by(&mut self, author: &str) -> Result<(), Vec<String>> {
self.step_history("Undo", |h, doc| h.undo_by(doc, author))
}
fn step_history<E>(
&mut self,
verb: &str,
step: impl FnOnce(&mut History, &mut Document) -> Result<Option<Undone>, E>,
) -> Result<(), E> {
self.cancel_gesture();
self.end_text_edit();
self.commit();
let mut changed = Changed::Nodes(Arc::new([]));
if let Some(mut u) = self.history.redo(&mut self.doc) {
self.status = format!("Redo {}", u.label);
if let Some(mut u) = step(&mut self.history, &mut self.doc)? {
self.status = format!("{verb} {}", u.label);
self.log_history(&u.nodes);
let design = u.design.is_some();
changed = Changed::Nodes(std::mem::take(&mut u.nodes).into());
self.restore_project_state(u);
self.log_history_design(design);
}
self.after_history(changed);
Ok(())
}
fn after_history(&mut self, changed: Changed) {
@@ -558,8 +590,11 @@ impl Editor {
sticky_color: self.sticky_color,
clipboard_io: self.clipboard_io.take(),
clipboard_written: self.clipboard_written.take(),
changes: std::mem::take(&mut self.changes),
..Editor::new(doc)
};
// A fresh log would restart rev at 0 and agents' old `since` values would look current.
self.changes.reset();
self.file_path = path;
match camera {
Some(c) => self.camera = c,
@@ -733,8 +768,8 @@ impl Editor {
/// zoomed-out page shows a few clean names instead of a pile.
pub fn frame_labels(&self) -> Vec<(NodeId, Rect)> {
let screen = Rect::new(0.0, 0.0, self.viewport.x, self.viewport.y);
// Top-level frames, then declared screens nested in sections.
let nested = screens::declared(&self.doc).into_iter().filter(|id| self.doc.get(*id).is_some_and(|n| n.parent.is_some()));
// Top-level frames, then declared screens and sections nested in sections.
let nested = self.doc.paint_order().into_iter().filter(|id| self.doc.get(*id).is_some_and(|n| n.parent.is_some() && (n.screen || n.section)));
let bodies: Vec<(usize, NodeId, Rect)> = self
.doc
.root
@@ -2155,6 +2190,9 @@ impl Editor {
A::ExportPdf => self
.requests
.push(AppRequest::ExportFormat(crate::io::export::Format::Pdf)),
A::ExportPresentation => self
.requests
.push(AppRequest::ExportFormat(crate::io::export::Format::Presentation)),
A::CopyAsPng => self.requests.push(AppRequest::CopyAsPng),
A::CopyAsSvg => self.copy_as_svg(),
A::ToggleSnapping => {
@@ -2186,6 +2224,7 @@ impl Editor {
}
}
A::ToggleUi => self.ui_events.push(commands::UiEvent::ToggleUi),
A::TogglePanels => self.ui_events.push(commands::UiEvent::TogglePanels),
A::ToggleChat => self.ui_events.push(commands::UiEvent::ToggleChat),
A::ToggleHelp => self.ui_events.push(commands::UiEvent::ToggleHelp),
A::ToggleGrid => self.ui_events.push(commands::UiEvent::ToggleGrid),
+16
View File
@@ -98,6 +98,7 @@ impl Editor {
self.meta_dirty = false;
// The groups of the previous project are gone; the session sets the new ones.
self.page_groups.clear();
self.page_icons.clear();
}
pub fn active_page_id(&self) -> &str {
@@ -323,6 +324,7 @@ impl Editor {
{
members.retain(|id| id != &removed.id);
}
self.page_icons.remove(&removed.id);
if self.active_page > i {
self.active_page -= 1;
}
@@ -656,6 +658,20 @@ impl Editor {
/// Page groups (folders). Groups are single level: a page belongs to at most one group,
/// and a group keeps its pages in the order of the page list.
impl Editor {
/// A page's icon (`set:name`), or none to show the default page icon.
pub fn set_page_icon(&mut self, page_id: &str, icon: Option<&str>) -> bool {
if !self.pages.iter().any(|p| p.id == page_id) || self.page_icons.get(page_id).map(String::as_str) == icon {
return false;
}
match icon {
Some(i) => self.page_icons.insert(page_id.to_string(), i.to_string()),
None => self.page_icons.remove(page_id),
};
self.meta_dirty = true;
self.revision += 1;
true
}
/// Create an empty group. Fails on an empty or already used name.
pub fn create_page_group(&mut self, name: &str) -> bool {
let name = name.trim();
+51 -1
View File
@@ -25,6 +25,7 @@ const OPAQUE: f64 = 0.95;
pub enum Role {
Screen,
Overlay,
Section,
None,
}
@@ -58,9 +59,29 @@ pub fn refusal(doc: &Document, id: NodeId, role: Role) -> Option<&'static str> {
Role::Screen => None,
Role::Overlay if screen_of(doc, id).is_none() => Some("an overlay (drawer, modal, sheet) must be inside a screen"),
Role::Overlay => None,
Role::Section if !n.is_frame() => Some("only a frame can be a section"),
Role::Section if in_component() => Some("a component main, an instance or a layer inside a main can't be a section"),
Role::Section if screen_of(doc, id).is_some() => Some("it is inside a screen"),
Role::Section => None,
}
}
/// Frames that look like sections: top-level frames named `Section / ...` or holding declared
/// screens, not declared yet.
pub fn section_candidates(doc: &Document) -> Vec<NodeId> {
doc.root
.iter()
.copied()
.filter(|id| {
doc.get(*id).is_some_and(|n| {
!n.section
&& !n.screen
&& (n.name.starts_with("Section /") || doc.subtree(*id).iter().skip(1).any(|d| doc.get(*d).is_some_and(|c| c.screen)))
}) && refusal(doc, *id, Role::Section).is_none()
})
.collect()
}
/// Frames that look like screens: named `Screen / ...`, the frame shown in a `Slot / ...`
/// frame, or a top-level frame at exactly a device size (a tall section at a device width
/// is not one). The outermost of nested candidates wins,
@@ -191,11 +212,17 @@ impl Editor {
pub fn set_role(&mut self, ids: &[NodeId], role: Role) -> Result<(), String> {
if let Some((id, why)) = ids.iter().find_map(|id| refusal(&self.doc, *id, role).map(|w| (*id, w))) {
let name = self.doc.get(id).map_or("?", |n| n.name.as_str());
return Err(format!("`{name}` can't be a {}: {why}", if role == Role::Overlay { "overlay" } else { "screen" }));
let what = match role {
Role::Overlay => "overlay",
Role::Section => "section",
_ => "screen",
};
return Err(format!("`{name}` can't be a {what}: {why}"));
}
let label = match role {
Role::Screen => "Declare screens",
Role::Overlay => "Mark overlays",
Role::Section => "Declare sections",
Role::None => "Clear screen roles",
};
self.edit(label, |tx| {
@@ -203,6 +230,11 @@ impl Editor {
if let Some(n) = tx.node_mut(*id) {
n.screen = role == Role::Screen;
n.overlay = role == Role::Overlay;
// A section is drawn by the canvas (outline and name), not by its own fill.
if role == Role::Section && !n.section {
n.style.fills.clear();
}
n.section = role == Role::Section;
}
}
});
@@ -275,4 +307,22 @@ mod tests {
let b = Rect::new(5.0, 5.0, 10.0, 10.0);
assert!((union_area(&[a, b]) - 175.0).abs() < 1e-9);
}
#[test]
fn sections_lose_their_fill_and_stay_out_of_screens() {
let mut ed = Editor::default();
let white = Some(Color { r: 255, g: 255, b: 255, a: 255 });
let section = frame(&mut ed, None, Rect::new(0.0, 0.0, 3000.0, 3000.0), white);
let screen = frame(&mut ed, Some(section), Rect::new(0.0, 0.0, 1440.0, 900.0), None);
let inner = frame(&mut ed, Some(screen), Rect::new(0.0, 0.0, 100.0, 100.0), None);
ed.set_role(&[screen], Role::Screen).unwrap();
assert_eq!(section_candidates(&ed.doc), vec![section], "holds a screen");
ed.set_role(&[section], Role::Section).unwrap();
let n = ed.doc.get(section).unwrap();
assert!(n.section && n.style.fills.is_empty());
assert!(section_candidates(&ed.doc).is_empty());
assert!(ed.set_role(&[inner], Role::Section).is_err(), "inside a screen");
ed.undo();
assert_eq!(ed.doc.get(section).unwrap().style.fills.len(), 1, "one undo step");
}
}
+8 -1
View File
@@ -59,6 +59,7 @@ pub enum Action {
Ask,
Rename,
ToggleUi,
TogglePanels,
ToggleChat,
ToggleHelp,
ToggleGrid,
@@ -75,6 +76,7 @@ pub enum Action {
PasteProps,
Home,
ExportPdf,
ExportPresentation,
CopyAsPng,
CopyAsSvg,
}
@@ -168,6 +170,7 @@ fn bindings() -> Vec<(Key, Chord, Action, &'static str)> {
(Char('/'), CMD_SHIFT, A::CommandPalette, "Command palette"),
(Char('r'), CMD, A::Rename, "Rename layer"),
(Char('\\'), CMD, A::ToggleUi, "Show / hide UI"),
(Char('/'), PLAIN, A::TogglePanels, "Show / hide the side panels"),
(Char('/'), CMD, A::ToggleHelp, "Keyboard shortcuts"),
(Char('?'), SHIFT, A::ToggleHelp, "Keyboard shortcuts"),
(Char('\''), CMD_SHIFT, A::ToggleGrid, "Show / hide dot grid"),
@@ -404,6 +407,7 @@ pub fn category(a: Action) -> &'static str {
| A::SaveAs
| A::ExportPng
| A::ExportSvg
| A::ExportPresentation
| A::PlaceImage
| A::Home => "File",
A::Undo
@@ -434,6 +438,7 @@ pub fn category(a: Action) -> &'static str {
| A::ToggleAnnotations
| A::OpenVariables
| A::ToggleUi
| A::TogglePanels
| A::ToggleChat
| A::ToggleHelp
| A::NextPage
@@ -511,6 +516,7 @@ pub fn menus() -> Vec<(&'static str, Vec<MenuEntry>)> {
I("Export PNG…", A::ExportPng),
I("Export SVG…", A::ExportSvg),
I("Export PDF…", A::ExportPdf),
I("Export Presentation…", A::ExportPresentation),
],
),
(
@@ -618,6 +624,7 @@ pub fn menus() -> Vec<(&'static str, Vec<MenuEntry>)> {
I("Dot Grid", A::ToggleGrid),
I("Annotations", A::ToggleAnnotations),
I("Show/Hide UI", A::ToggleUi),
I("Show/Hide Panels", A::TogglePanels),
I("Design / Chat Mode", A::ToggleChat),
I("Keyboard Shortcuts", A::ToggleHelp),
],
@@ -671,7 +678,7 @@ mod tests {
// Delete is bound to the Delete key; everything should be reachable.
assert!(
super::chord_of(*a).is_some()
|| matches!(a, super::Action::ExportSvg),
|| matches!(a, super::Action::ExportSvg | super::Action::ExportPresentation),
"{label} has no shortcut"
);
}
+47
View File
@@ -33,6 +33,8 @@ pub struct Change {
annotations: Option<(Vec<Annotation>, Vec<Annotation>)>,
next_id: (u64, u64),
coalesce_key: Option<String>,
/// The agent that made it (its client key); None for the person.
author: Option<String>,
}
/// Project-level state a step carries besides the page document; the editor puts it back.
@@ -538,6 +540,11 @@ pub struct History {
impl History {
/// Finalize a recording into a change. Returns false when nothing changed.
pub fn commit(&mut self, doc: &Document, rec: Recording, coalesce_key: Option<String>) -> bool {
self.commit_by(doc, rec, coalesce_key, None)
}
/// [`History::commit`] for a change made by `author` (an agent's client key).
pub fn commit_by(&mut self, doc: &Document, rec: Recording, coalesce_key: Option<String>, author: Option<String>) -> bool {
let entries: Vec<Entry> = rec
.order
.iter()
@@ -584,11 +591,13 @@ impl History {
annotations,
next_id: (rec.next_id_before, doc.next_id),
coalesce_key,
author,
};
self.generation += 1;
if let (Some(key), Some(top)) = (&change.coalesce_key, self.undo.last_mut())
&& self.redo.is_empty()
&& top.coalesce_key.as_ref() == Some(key)
&& top.author == change.author
{
merge(top, change);
return true;
@@ -619,6 +628,34 @@ impl History {
Some(u)
}
/// Undoes `author`'s newest step only, leaving later steps by others in place, when none
/// of them touched what it touched (the same layers, the top-level order, variables, the
/// design system, modes or notes). Blocked: the labels of the later steps in the way.
pub fn undo_by(&mut self, doc: &mut Document, author: &str) -> Result<Option<Undone>, Vec<String>> {
let Some(i) = self.undo.iter().rposition(|c| c.author.as_deref() == Some(author)) else {
return Ok(None);
};
let mine = &self.undo[i];
let blocked: Vec<String> = self.undo[i + 1..].iter().filter(|l| overlaps(mine, l)).map(|l| l.label.clone()).collect();
if !blocked.is_empty() {
return Err(blocked);
}
let c = self.undo.remove(i);
// Later steps may have made layers since: new ids keep counting up from there.
let next = doc.next_id;
c.apply(doc, false);
doc.next_id = doc.next_id.max(next);
self.generation += 1;
let u = c.undone(false);
self.redo.push(c);
Ok(Some(u))
}
/// Who made the step `redo` would bring back (None: the person, or nothing to redo).
pub fn redo_author(&self) -> Option<&str> {
self.redo.last().and_then(|c| c.author.as_deref())
}
pub fn generation(&self) -> u64 {
self.generation
}
@@ -666,6 +703,16 @@ impl History {
}
}
/// Two steps touched something in common, so one cannot be undone under the other.
fn overlaps(a: &Change, b: &Change) -> bool {
a.entries.iter().any(|e| b.entries.iter().any(|x| x.id == e.id))
|| (a.root.is_some() && b.root.is_some())
|| (a.variables.is_some() && b.variables.is_some())
|| (a.design.is_some() && b.design.is_some())
|| (a.page_modes.is_some() && b.page_modes.is_some())
|| (a.annotations.is_some() && b.annotations.is_some())
}
/// Fold `newer` into `older`, keeping the oldest before-state of each node.
fn merge(older: &mut Change, newer: Change) {
for e in newer.entries {
+785
View File
@@ -0,0 +1,785 @@
//! Presentation export: a PDF deck of a project's screens, the way a designer sends work to a
//! client. A cover, contents, a divider per page, one slide per screen (narrow side panels share
//! one), a closing slide; every slide numbered. Slides are drawn from a generated document
//! (text and cards); the screens are drawn into them scaled, so they stay vector.
//! Spec: `specs/presentation-export/spec.md`.
use super::pdf::{Insert, PdfPage};
use crate::editor::Editor;
use crate::geom::{Rect, V2, v2};
use crate::model::{Color, Document, Node, NodeId, NodeKind, TextAlign};
/// A page of the project in the deck.
pub struct Section<'a> {
pub name: String,
pub doc: &'a Document,
pub screens: Vec<NodeId>,
}
/// What the cover says.
pub struct Meta {
pub title: String,
pub subtitle: Option<String>,
pub author: Option<String>,
/// Unix milliseconds, for the date on the cover.
pub now_ms: u64,
}
const W: f64 = 1920.0;
const H: f64 = 1080.0;
const MARGIN: f64 = 96.0;
/// Slides are laid out in the generated document one under the other with this gap.
const SLIDE_GAP: f64 = 200.0;
/// Frames smaller than this on either side are parts (components), not screens.
const MIN_SCREEN: f64 = 300.0;
/// Screens narrower than this share of their height are side panels, shown side by side.
const NARROW: f64 = 0.45;
const PANELS_PER_SLIDE: usize = 5;
const PANEL_GAP: f64 = 48.0;
/// A board fitted smaller than this scale is split top to bottom over several slides, each
/// part at the stage's width (at most 1:1). Narrower boards are panels and stay whole.
const READABLE: f64 = 0.5;
const MIN_SPLIT_W: f64 = 600.0;
/// Where a screen goes on its slide (under the title, above the footer).
const STAGE: Rect = Rect { x: MARGIN, y: 196.0, w: W - 2.0 * MARGIN, h: H - 196.0 - 110.0 };
const CAPTION_H: f64 = 44.0;
const BG: Color = Color { r: 22, g: 22, b: 30, a: 255 };
/// A screen's frame: light enough to edge a dark screen on the dark slide.
const CARD_EDGE: Color = Color { r: 84, g: 84, b: 104, a: 255 };
const CARD_EDGE_W: f64 = 2.0;
const INK: Color = Color { r: 232, g: 232, b: 242, a: 255 };
const MUTED: Color = Color { r: 150, g: 150, b: 170, a: 255 };
const ACCENT: Color = Color { r: 180, g: 160, b: 250, a: 255 };
const MONTHS: [&str; 12] =
["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
/// The screens of a page in reading order (top to bottom, then left to right): its declared
/// screens, else its visible top-level frames (not component mains) of at least [`MIN_SCREEN`]
/// on both sides. A
/// section holding no screen (a design-system board) is shown whole.
pub fn screens_of(doc: &Document) -> Vec<NodeId> {
let declared = crate::editor::screens::declared(doc);
let mut ids: Vec<NodeId> = if declared.is_empty() {
doc.root
.iter()
.copied()
.filter(|id| doc.get(*id).is_some_and(|n| n.is_frame() && n.visible && !n.section && !n.component && n.rect.w >= MIN_SCREEN && n.rect.h >= MIN_SCREEN))
.collect()
} else {
declared.iter().copied().filter(|id| doc.get(*id).is_some_and(|n| n.visible)).collect()
};
let holds_screen = |id: NodeId| declared.iter().any(|s| doc.ancestors(*s).contains(&id));
ids.extend(doc.root.iter().copied().filter(|id| doc.get(*id).is_some_and(|n| n.section && n.visible) && !holds_screen(*id)));
let at = |id: &NodeId| doc.world_rect(*id).map_or((0.0, 0.0), |r| (r.y, r.x));
ids.sort_by(|a, b| at(a).partial_cmp(&at(b)).unwrap_or(std::cmp::Ordering::Equal));
ids
}
enum Slide {
Cover,
Contents,
Divider(usize),
/// One screen, whole, with its notes (numbered from 1 across its slides), which of its note
/// slides this is (`k` of `n`), and how many more states of it the appendix shows.
Screen { i: usize, id: NodeId, notes: Vec<(usize, Note)>, part: Option<(usize, usize)>, states: usize },
/// The appendix's divider: the states of screens, after the last page.
Appendix,
/// A page's key screens as captioned thumbnails, after its divider.
Map(usize, Vec<NodeId>),
/// A page's notes that ask a question, with their screen's name.
Questions(usize, Vec<(String, String)>),
/// What is left to do: the open questions, the review, the appendix.
NextSteps,
/// Screens side by side with captions, `n` to a slide: side panels, or the other states
/// of a screen.
Row(usize, String, Vec<NodeId>, usize),
/// An area of a layer: a board's cards, or a part of a tall screen. Title, source rect.
Crop(usize, NodeId, String, Rect),
End,
}
/// Layer-name prefixes that say what a layer is, not what it shows; left out of titles.
const KIND_PREFIXES: [&str; 4] = ["Section", "Screen", "Group", "Slot"];
/// States in the appendix (a reference): a 3 × 2 grid to a slide.
const APPENDIX_PER_SLIDE: usize = 6;
const APPENDIX_COLS: usize = 3;
/// Characters kept of an appendix slide's title (the groups on it).
const APPENDIX_TITLE_CHARS: usize = 70;
/// Cards pack onto a slide down to this share of their part scale.
const PACK_MIN_SCALE: f64 = 0.8;
/// A part of a tall card shrinks down to this share of its scale to keep blocks (a row of
/// cells) whole rather than cut through them.
const CUT_MIN_SCALE: f64 = 0.7;
/// Room around a board's cards on their slide.
const CARD_PAD: f64 = 24.0;
/// Notes shown beside a screen per slide; more continue on a repeat of the slide.
const NOTES_PER_SLIDE: usize = 8;
/// The share of the stage an annotated screen takes; its notes fill the rest.
const NOTED_SCREEN: f64 = 0.62;
const MARKER: f64 = 28.0;
/// Characters kept of a note's title and of its first body line.
const NOTE_TITLE_CHARS: usize = 80;
const NOTE_BODY_CHARS: usize = 120;
/// About how many title characters fit on one line of the note column.
const NOTE_TITLE_LINE_CHARS: usize = 50;
/// Thumbnails on a map slide (four across, three down).
const MAP_COLS: usize = 4;
const MAP_ROWS: usize = 3;
/// A map for fewer key screens than this adds nothing: the slides follow right after it.
const MAP_MIN: usize = 3;
const QUESTIONS_PER_SLIDE: usize = 8;
/// "12 screens · 6 boards": a page's screens and boards (sections shown by their cards).
fn tally(s: &Section) -> String {
let boards = s.screens.iter().filter(|id| s.doc.get(**id).is_some_and(|n| n.section)).count();
let screens = count(s.screens.len() - boards, "screen");
if boards == 0 { screens } else { format!("{screens} \u{b7} {}", count(boards, "board")) }
}
/// A design note shown on a slide.
#[derive(Clone)]
struct Note {
target: NodeId,
title: String,
body: String,
/// The line of the note that asks something (`?`), if any.
question: Option<String>,
color: Color,
}
/// The notes on screen `id`: attached notes whose layer is the screen, inside it or laid over
/// it, in reading order of their layers. Detached notes (their layer is gone) are left out.
fn notes_of(doc: &Document, id: NodeId) -> Vec<Note> {
let roots = super::export::with_layers_on_top(doc, &[id]);
let cats = crate::model::annotation::default_categories();
let mut notes: Vec<(Rect, Note)> = doc
.annotations
.iter()
.filter(|a| !a.detached)
.filter_map(|a| {
let target = a.target?;
if !(roots.contains(&target) || doc.ancestors(target).iter().any(|p| roots.contains(p))) {
return None;
}
let color = crate::model::annotation::category_by_key(&cats, &a.category).map_or(ACCENT, |c| c.color);
let body = a.body.lines().next().unwrap_or("").to_owned();
let question = std::iter::once(a.label.as_str()).chain(a.body.lines()).find(|l| l.contains('?')).map(str::to_owned);
Some((doc.world_rect(target)?, Note { target, title: a.label.clone(), body, question, color }))
})
.collect();
notes.sort_by(|a, b| (a.0.y, a.0.x).partial_cmp(&(b.0.y, b.0.x)).unwrap_or(std::cmp::Ordering::Equal));
notes.into_iter().map(|(_, n)| n).collect()
}
/// The slides of one screen: one, or one per [`NOTES_PER_SLIDE`] of its notes.
fn screen_slides(i: usize, doc: &Document, id: NodeId, states: usize) -> Vec<Slide> {
let notes: Vec<(usize, Note)> = notes_of(doc, id).into_iter().enumerate().map(|(k, n)| (k + 1, n)).collect();
if notes.is_empty() {
return vec![Slide::Screen { i, id, notes, part: None, states }];
}
let chunks: Vec<&[(usize, Note)]> = notes.chunks(NOTES_PER_SLIDE).collect();
let n = chunks.len();
chunks
.into_iter()
.enumerate()
.map(|(k, c)| Slide::Screen { i, id, notes: c.to_vec(), part: (n > 1).then_some((k + 1, n)), states })
.collect()
}
/// `s` cut to `n` characters, with an ellipsis when cut.
fn clip(s: &str, n: usize) -> String {
if s.chars().count() <= n {
return s.to_owned();
}
format!("{}\u{2026}", s.chars().take(n - 1).collect::<String>().trim_end())
}
/// A layer name's parts without its kind prefix: `Screen / Sessions / Empty` is
/// `[Sessions, Empty]`.
fn name_parts(name: &str) -> Vec<&str> {
let mut parts: Vec<&str> = name.split(" / ").collect();
if parts.len() > 1 && KIND_PREFIXES.contains(&parts[0]) {
parts.remove(0);
}
parts
}
/// A layer name as a slide title: `Screen / Sessions / Empty` reads `Sessions · Empty`.
fn clean(name: &str) -> String {
name_parts(name).join(" \u{b7} ")
}
fn name_of(doc: &Document, id: NodeId) -> &str {
doc.get(id).map_or("", |n| n.name.as_str())
}
/// The slides of one page in reading order, and its slides for the appendix. A screen whose
/// name has states (`Sessions / List`, `Sessions / Empty`) is shown once, its other states in
/// the appendix; narrow panels share rows; boards are shown by their cards; tall screens in parts.
fn section_slides(i: usize, s: &Section) -> (Vec<Slide>, Vec<Slide>) {
enum Item {
Done(Slide),
States(usize),
}
let doc = s.doc;
let mut items = Vec::new();
let mut groups: Vec<(String, Vec<NodeId>)> = Vec::new();
let mut panels = Vec::new();
let flush = |panels: &mut Vec<NodeId>, items: &mut Vec<Item>| {
if panels.is_empty() {
return;
}
let mut heads: Vec<&str> = Vec::new();
for id in panels.iter() {
let head = name_parts(name_of(doc, *id))[0];
if !heads.contains(&head) {
heads.push(head);
}
}
items.push(Item::Done(Slide::Row(i, heads.join(" \u{b7} "), std::mem::take(panels), PANELS_PER_SLIDE)));
};
for id in &s.screens {
let Some(n) = doc.get(*id) else { continue };
if n.section {
flush(&mut panels, &mut items);
items.extend(board_slides(i, doc, *id).into_iter().map(Item::Done));
continue;
}
if let Some(parts) = tall_parts(doc, *id) {
flush(&mut panels, &mut items);
let k = parts.len();
let title = clean(&n.name);
items.extend(parts.into_iter().enumerate().map(|(j, from)| Item::Done(Slide::Crop(i, *id, format!("{title} ({}/{k})", j + 1), from))));
continue;
}
if n.rect.w < n.rect.h * NARROW {
panels.push(*id);
if panels.len() == PANELS_PER_SLIDE {
flush(&mut panels, &mut items);
}
continue;
}
flush(&mut panels, &mut items);
let parts = name_parts(&n.name);
// `Sessions / Empty` is a state of `Sessions`; a name without parts
// (`Challenges List Archived`) is grouped by its first two words.
let words: Vec<&str> = parts[0].split_whitespace().collect();
let base = if parts.len() > 1 {
parts[..parts.len() - 1].join(" \u{b7} ")
} else if words.len() > 1 {
words[..2].join(" ")
} else {
items.extend(screen_slides(i, doc, *id, 0).into_iter().map(Item::Done));
continue;
};
match groups.iter().position(|(b, _)| *b == base) {
Some(g) => groups[g].1.push(*id),
None => {
items.push(Item::States(groups.len()));
groups.push((base, vec![*id]));
}
}
}
flush(&mut panels, &mut items);
let mut slides = Vec::new();
let mut states: Vec<(&str, NodeId)> = Vec::new();
for item in items {
match item {
Item::Done(slide) => slides.push(slide),
Item::States(g) => {
let (base, ids) = &groups[g];
slides.extend(screen_slides(i, doc, ids[0], ids.len() - 1));
states.extend(ids[1..].iter().map(|id| (base.as_str(), *id)));
}
}
}
// The page's other states fill the appendix's grids in order, groups sharing slides.
let appendix = states
.chunks(APPENDIX_PER_SLIDE)
.map(|c| {
let mut bases: Vec<&str> = Vec::new();
for (b, _) in c {
if !bases.contains(b) {
bases.push(b);
}
}
let title = if bases.len() == 1 { bases[0].to_owned() } else { clip(&bases.join(", "), APPENDIX_TITLE_CHARS) };
Slide::Row(i, title, c.iter().map(|(_, id)| *id).collect(), APPENDIX_PER_SLIDE)
})
.collect();
(slides, appendix)
}
/// A board (a section without screens) on slides. One that fits at 1:1 is shown whole;
/// otherwise its cards (the children of its widest card container) are packed in order onto
/// slides at 1:1, titled by their names. A card too tall for a slide is cut at gaps.
fn board_slides(i: usize, doc: &Document, id: NodeId) -> Vec<Slide> {
let Some(b) = crate::io::svg::export_bounds(doc, &[id]) else { return Vec::new() };
if fits(b) {
return screen_slides(i, doc, id, 0);
}
let board = clean(name_of(doc, id));
let parts_of = |title: &str, r: Rect| {
let parts = cut(doc, id, r);
let k = parts.len();
parts.into_iter().enumerate().map(move |(j, from)| Slide::Crop(i, id, format!("{title} ({}/{k})", j + 1), from)).collect::<Vec<_>>()
};
let Some(cards) = cards_of(doc, id) else { return parts_of(&board, b) };
let mut slides = Vec::new();
let mut pack: Option<(Rect, Vec<String>)> = None;
for c in cards {
let Some(r) = doc.world_rect(c) else { continue };
let r = Rect::new(r.x - CARD_PAD, r.y - CARD_PAD, r.w + 2.0 * CARD_PAD, r.h + 2.0 * CARD_PAD);
let title = clean(name_of(doc, c));
let label = title.strip_prefix(&format!("{board} \u{b7} ")).map_or(title.clone(), str::to_owned);
if let Some((u, mut names)) = pack.take() {
if fits(u.union(&r)) {
names.push(label);
pack = Some((u.union(&r), names));
continue;
}
slides.push(Slide::Crop(i, id, names.join(" \u{b7} "), u));
}
if fits(r) {
pack = Some((r, vec![label]));
} else if (STAGE.w / r.w).min(STAGE.h / r.h) >= READABLE {
slides.push(Slide::Crop(i, id, label, r));
} else {
slides.extend(parts_of(&label, r));
}
}
if let Some((u, names)) = pack {
slides.push(Slide::Crop(i, id, names.join(" \u{b7} "), u));
}
slides
}
/// Whether `r` fits on a slide at the scale parts are shown at.
fn fits(r: Rect) -> bool {
r.h * part_scale(r) * PACK_MIN_SCALE <= STAGE.h
}
/// A board's cards: the children of whichever of the board and its child frames holds the
/// most frames (at least two). Text and single wrappers (a title, an intro) are not cards.
fn cards_of(doc: &Document, board: NodeId) -> Option<Vec<NodeId>> {
let frames = |id: NodeId| -> Vec<NodeId> {
doc.get(id).map_or_else(Vec::new, |n| n.children.iter().copied().filter(|c| doc.get(*c).is_some_and(|c| c.visible && c.is_frame())).collect())
};
std::iter::once(board).chain(frames(board)).map(frames).filter(|f| f.len() >= 2).max_by_key(Vec::len)
}
/// The deck as PDF bytes. Sections without screens are left out.
pub fn build(meta: &Meta, sections: &[Section]) -> Vec<u8> {
let sections: Vec<&Section> = sections.iter().filter(|s| !s.screens.is_empty()).collect();
let mut slides = vec![Slide::Cover, Slide::Contents];
let mut starts = Vec::new();
let mut appendix = Vec::new();
let mut questions = Vec::new();
for (i, s) in sections.iter().enumerate() {
starts.push(slides.len() + 1);
slides.push(Slide::Divider(i));
let (main, extra) = section_slides(i, s);
// The big picture first: the page's screens (one per state group) at a glance.
let mut key: Vec<NodeId> = Vec::new();
for slide in &main {
if let Slide::Screen { id, .. } = slide
&& !key.contains(id)
&& s.doc.get(*id).is_some_and(|n| !n.section)
{
key.push(*id);
}
}
if key.len() >= MAP_MIN {
// Even maps: 17 screens are 9 and 8, not 12 and 5.
let per = key.len().div_ceil(key.len().div_ceil(MAP_COLS * MAP_ROWS));
slides.extend(key.chunks(per).map(|c| Slide::Map(i, c.to_vec())));
}
let mut asked = Vec::new();
for id in s.screens.iter().filter(|id| s.doc.get(**id).is_some_and(|n| !n.section)) {
for n in notes_of(s.doc, *id) {
if let Some(line) = n.question {
let q = if line == n.title { ask(&line) } else { format!("{}: {}", n.title, ask(&line)) };
asked.push((clean(name_of(s.doc, *id)), q));
}
}
}
questions.extend(asked.chunks(QUESTIONS_PER_SLIDE).map(|c| Slide::Questions(i, c.to_vec())));
slides.extend(main);
appendix.extend(extra);
}
let questions_start = (!questions.is_empty()).then(|| slides.len() + 1);
let asked: usize = questions.iter().map(|q| if let Slide::Questions(_, qs) = q { qs.len() } else { 0 }).sum();
slides.extend(questions);
slides.push(Slide::NextSteps);
let appendix_start = (!appendix.is_empty()).then(|| slides.len() + 1);
if appendix_start.is_some() {
slides.push(Slide::Appendix);
slides.extend(appendix);
}
slides.push(Slide::End);
let total = slides.len();
let screens: usize = sections.iter().map(|s| s.screens.iter().filter(|id| s.doc.get(**id).is_some_and(|n| !n.section)).count()).sum();
let mut ed = Editor::default();
// Each slide, and its overlay (drawn above the screens: note markers).
let mut pages: Vec<(Rect, NodeId, Option<NodeId>, Vec<Insert>)> = Vec::new();
for (k, slide) in slides.iter().enumerate() {
let r = Rect::new(0.0, k as f64 * (H + SLIDE_GAP), W, H);
let mut frame = Node::new(NodeId(0), NodeKind::Frame { clip: true }, r);
frame.style.set_fill(Some(BG));
let f = ed.add_node(frame, None);
let mut inserts = Vec::new();
let mut overlay = None;
match slide {
Slide::Cover => {
t(&mut ed, f, "DESIGN PRESENTATION", 22.0, true, ACCENT, v2(MARGIN, 330.0));
text(&mut ed, f, &meta.title, 88.0, true, INK, v2(MARGIN, 372.0), Some((800.0, TextAlign::Left)));
let sub = meta.subtitle.clone().unwrap_or_else(|| format!("{} \u{b7} {}", count(screens, "screen"), count(sections.len(), "page")));
text(&mut ed, f, &sub, 30.0, false, MUTED, v2(MARGIN, 520.0), Some((800.0, TextAlign::Left)));
let by = match &meta.author {
Some(a) => format!("{} \u{b7} {a}", date(meta.now_ms)),
None => date(meta.now_ms),
};
text(&mut ed, f, &by, 22.0, false, MUTED, v2(MARGIN, H - MARGIN - 30.0), None);
// The hero: the largest regular screen of the deck (the first of equals).
let hero = sections
.iter()
.flat_map(|s| s.screens.iter().filter_map(|id| Some((s.doc, *id, s.doc.world_rect(*id)?))))
.filter(|(doc, id, b)| b.w >= b.h * NARROW && doc.get(*id).is_some_and(|n| !n.section))
.fold(None::<(&Document, NodeId, Rect)>, |best, c| match best {
Some(b) if b.2.w * b.2.h >= c.2.w * c.2.h => Some(b),
_ => Some(c),
})
.map(|(doc, id, _)| (doc, id));
if let Some((doc, id)) = hero {
inserts.extend(place(&mut ed, f, doc, id, None, Rect::new(960.0, 140.0, W - 960.0 - MARGIN + 40.0, H - 280.0), r));
}
}
Slide::Contents => {
t(&mut ed, f, "Contents", 56.0, true, INK, v2(MARGIN, MARGIN));
for (i, s) in sections.iter().enumerate() {
let y = 230.0 + i as f64 * 64.0;
t(&mut ed, f, &format!("{:02}", i + 1), 28.0, true, ACCENT, v2(MARGIN, y));
t(&mut ed, f, &s.name, 28.0, false, INK, v2(MARGIN + 84.0, y));
t(&mut ed, f, &tally(s), 22.0, false, MUTED, v2(1200.0, y + 4.0));
text(&mut ed, f, &starts[i].to_string(), 22.0, false, MUTED, v2(W - MARGIN - 200.0, y + 4.0), Some((200.0, TextAlign::Right)));
}
let extras = [("?", "Open questions", questions_start), ("A", "Appendix \u{b7} states", appendix_start)];
for (j, (mark, name, start)) in extras.iter().filter_map(|(m, n, s)| Some((*m, *n, (*s)?))).enumerate() {
let y = 230.0 + (sections.len() + j) as f64 * 64.0;
t(&mut ed, f, mark, 28.0, true, ACCENT, v2(MARGIN, y));
t(&mut ed, f, name, 28.0, false, INK, v2(MARGIN + 84.0, y));
text(&mut ed, f, &start.to_string(), 22.0, false, MUTED, v2(W - MARGIN - 200.0, y + 4.0), Some((200.0, TextAlign::Right)));
}
}
Slide::Map(i, ids) => {
let s = sections[*i];
t(&mut ed, f, &s.name.to_uppercase(), 20.0, true, ACCENT, v2(MARGIN, 64.0));
t(&mut ed, f, "At a glance", 44.0, true, INK, v2(MARGIN, 96.0));
const GAP: f64 = 32.0;
let cw = (STAGE.w - GAP * (MAP_COLS - 1) as f64) / MAP_COLS as f64;
let ch = (STAGE.h - GAP * (MAP_ROWS - 1) as f64) / MAP_ROWS as f64;
for (j, id) in ids.iter().enumerate() {
let (cx, cy) = ((j % MAP_COLS) as f64, (j / MAP_COLS) as f64);
let cell = Rect::new(STAGE.x + cx * (cw + GAP), STAGE.y + cy * (ch + GAP), cw, ch - CAPTION_H + 12.0);
let placed = place(&mut ed, f, s.doc, *id, None, cell, r);
let under = placed.as_ref().map_or(cell.bottom(), |p| p.to.bottom() - r.y);
text(&mut ed, f, &clip(&clean(name_of(s.doc, *id)), 48), 16.0, false, MUTED, v2(cell.x, under + 8.0), Some((cw, TextAlign::Center)));
inserts.extend(placed);
}
}
Slide::Questions(i, qs) => {
t(&mut ed, f, &sections[*i].name.to_uppercase(), 20.0, true, ACCENT, v2(MARGIN, 64.0));
t(&mut ed, f, "Open questions", 44.0, true, INK, v2(MARGIN, 96.0));
let slot = STAGE.h / QUESTIONS_PER_SLIDE as f64;
for (j, (screen, q)) in qs.iter().enumerate() {
let y = STAGE.y + j as f64 * slot;
t(&mut ed, f, "?", 28.0, true, ACCENT, v2(MARGIN, y));
text(&mut ed, f, &clip(q, 150), 22.0, false, INK, v2(MARGIN + 56.0, y), Some((STAGE.w - 56.0, TextAlign::Left)));
t(&mut ed, f, screen, 16.0, false, MUTED, v2(MARGIN + 56.0, y + 40.0));
}
}
Slide::NextSteps => {
t(&mut ed, f, "Next steps", 56.0, true, INK, v2(MARGIN, MARGIN));
let boards: usize = sections.iter().map(|s| s.screens.len()).sum::<usize>() - screens;
let mut steps = Vec::new();
if let Some(q) = questions_start {
steps.push((format!("Answer {}", count(asked, "open question")), format!("From the notes on the screens; slide {q} on")));
}
let reviewed = if boards == 0 { count(screens, "screen") } else { format!("{} and {}", count(screens, "screen"), count(boards, "board")) };
steps.push((format!("Review the {reviewed}"), "Feedback on a screen by its name, as titled on its slide".to_owned()));
if let Some(a) = appendix_start {
steps.push(("Check the other states".to_owned(), format!("The states kept out of the main flow; appendix, slide {a} on")));
}
if let Some(a) = &meta.author {
steps.push(("Send feedback".to_owned(), format!("To {a}")));
}
for (j, (step, detail)) in steps.iter().enumerate() {
let y = 260.0 + j as f64 * 130.0;
t(&mut ed, f, &format!("{:02}", j + 1), 32.0, true, ACCENT, v2(MARGIN, y));
t(&mut ed, f, step, 32.0, true, INK, v2(MARGIN + 96.0, y));
t(&mut ed, f, detail, 22.0, false, MUTED, v2(MARGIN + 96.0, y + 50.0));
}
}
Slide::Appendix => {
t(&mut ed, f, "A", 160.0, true, ACCENT, v2(MARGIN, 300.0));
t(&mut ed, f, "Appendix", 80.0, true, INK, v2(MARGIN, 500.0));
t(&mut ed, f, "Other states of the screens", 28.0, false, MUTED, v2(MARGIN, 620.0));
}
Slide::Divider(i) => {
let s = sections[*i];
t(&mut ed, f, &format!("{:02}", i + 1), 160.0, true, ACCENT, v2(MARGIN, 300.0));
text(&mut ed, f, &s.name, 80.0, true, INK, v2(MARGIN, 500.0), Some((W - 2.0 * MARGIN, TextAlign::Left)));
t(&mut ed, f, &tally(s), 28.0, false, MUTED, v2(MARGIN, 620.0));
}
Slide::Screen { i, id, notes, part, states } => {
let s = sections[*i];
kicker(&mut ed, f, s, *id);
let mut title = clean(name_of(s.doc, *id));
if let Some((k, n)) = part {
title = format!("{title} (notes {k}/{n})");
}
t(&mut ed, f, &title, 44.0, true, INK, v2(MARGIN, 96.0));
let mut aside = Vec::new();
if *states > 0 {
aside.push(format!("+ {} in the appendix", count(*states, "state")));
}
if let Some(b) = s.doc.world_rect(*id) {
aside.push(format!("{} \u{d7} {}", b.w.round(), b.h.round()));
}
text(&mut ed, f, &aside.join(" \u{b7} "), 22.0, false, MUTED, v2(W - MARGIN - 700.0, 116.0), Some((700.0, TextAlign::Right)));
if notes.is_empty() {
inserts.extend(place(&mut ed, f, s.doc, *id, None, STAGE, r));
} else {
let area = Rect::new(STAGE.x, STAGE.y, STAGE.w * NOTED_SCREEN, STAGE.h);
let placed = place(&mut ed, f, s.doc, *id, None, area, r);
let mut ov = Node::new(NodeId(0), NodeKind::Frame { clip: false }, r);
ov.style.set_fill(None);
let ov = ed.add_node(ov, None);
if let Some(p) = &placed {
let sc = p.to.w / p.from.w;
let to = p.to.translate(v2(0.0, -r.y));
for (num, note) in notes {
let Some(tr) = s.doc.world_rect(note.target) else { continue };
// Centred on the layer's top-left corner, so it points without hiding it.
let x = (to.x + (tr.x - p.from.x) * sc - MARKER / 2.0).clamp(to.x - MARKER / 2.0, to.right() - MARKER / 2.0);
let y = (to.y + (tr.y - p.from.y) * sc - MARKER / 2.0).clamp(to.y - MARKER / 2.0, to.bottom() - MARKER / 2.0);
marker(&mut ed, ov, *num, note.color, v2(x, y));
}
}
inserts.extend(placed);
overlay = Some(ov);
let col_x = area.right() + 56.0;
let col_w = W - MARGIN - col_x;
let slot = STAGE.h / NOTES_PER_SLIDE as f64;
for (j, (num, note)) in notes.iter().enumerate() {
let y = STAGE.y + j as f64 * slot;
marker(&mut ed, f, *num, note.color, v2(col_x, y));
let tx = col_x + MARKER + 14.0;
let title = clip(&note.title, NOTE_TITLE_CHARS);
// The body sits under the title's one or two lines.
let title_lines = if title.chars().count() > NOTE_TITLE_LINE_CHARS { 2.0 } else { 1.0 };
text(&mut ed, f, &title, 20.0, true, INK, v2(tx, y + 2.0), Some((col_w - MARKER - 14.0, TextAlign::Left)));
text(&mut ed, f, &clip(&note.body, NOTE_BODY_CHARS), 16.0, false, MUTED, v2(tx, y + 10.0 + title_lines * 26.0), Some((col_w - MARKER - 14.0, TextAlign::Left)));
}
}
}
Slide::Row(i, title, ids, per) => {
let s = sections[*i];
kicker(&mut ed, f, s, ids[0]);
t(&mut ed, f, title, 44.0, true, INK, v2(MARGIN, 96.0));
// An appendix grid sized by what is on it: a last slide of two is not a grid of six.
let (cols, rows) = if *per == APPENDIX_PER_SLIDE {
let cols = ids.len().min(APPENDIX_COLS);
(cols, ids.len().div_ceil(cols))
} else {
(*per, 1)
};
let cw = (STAGE.w - PANEL_GAP * (cols - 1) as f64) / cols as f64;
let ch = (STAGE.h - PANEL_GAP * (rows - 1) as f64) / rows as f64;
let across = ids.len().min(cols);
let used = cw * across as f64 + PANEL_GAP * (across - 1) as f64;
let x0 = STAGE.x + (STAGE.w - used) / 2.0;
for (j, id) in ids.iter().enumerate() {
let (cx, cy) = ((j % cols) as f64, (j / cols) as f64);
let col = Rect::new(x0 + cx * (cw + PANEL_GAP), STAGE.y + cy * (ch + PANEL_GAP), cw, ch - CAPTION_H);
let placed = place(&mut ed, f, s.doc, *id, None, col, r);
let under = placed.as_ref().map_or(col.bottom(), |p| p.to.bottom() - r.y);
// A state reads by what differs: `Empty` under a `Sessions` title.
let name = clean(name_of(s.doc, *id));
let caption = name.strip_prefix(&format!("{title} \u{b7} ")).or_else(|| name.strip_prefix(&format!("{title} "))).unwrap_or(&name);
text(&mut ed, f, caption, 18.0, false, MUTED, v2(col.x, under + 14.0), Some((cw, TextAlign::Center)));
inserts.extend(placed);
}
}
Slide::Crop(i, id, title, from) => {
let s = sections[*i];
kicker(&mut ed, f, s, *id);
t(&mut ed, f, title, 44.0, true, INK, v2(MARGIN, 96.0));
// Top-aligned at the scale of the other parts, however short this one is.
let area = Rect::new(STAGE.x, STAGE.y, STAGE.w, (from.h * part_scale(*from)).min(STAGE.h));
inserts.extend(place(&mut ed, f, s.doc, *id, Some(*from), area, r));
}
Slide::End => {
t(&mut ed, f, "Thank you", 96.0, true, INK, v2(MARGIN, 380.0));
t(&mut ed, f, &meta.title, 30.0, false, MUTED, v2(MARGIN, 520.0));
t(&mut ed, f, &format!("{} \u{b7} {}", count(screens, "screen"), date(meta.now_ms)), 22.0, false, MUTED, v2(MARGIN, 570.0));
}
}
if k > 0 {
text(&mut ed, f, &meta.title, 18.0, false, MUTED, v2(MARGIN, H - 64.0), None);
text(&mut ed, f, &format!("{} / {total}", k + 1), 18.0, false, MUTED, v2(W - MARGIN - 200.0, H - 64.0), Some((200.0, TextAlign::Right)));
}
pages.push((r, f, overlay, inserts));
}
let doc = &ed.doc;
let pdf_pages: Vec<PdfPage> = pages
.into_iter()
.map(|(r, f, overlay, mut inserts)| {
inserts.extend(overlay.map(|ov| Insert { doc, roots: vec![ov], from: r, to: r }));
PdfPage { doc, roots: vec![f], bounds: r, background: Some(BG), inserts }
})
.collect();
super::pdf::export(&pdf_pages)
}
/// [`text`] that sizes to its content.
fn t(ed: &mut Editor, f: NodeId, s: &str, size: f64, bold: bool, color: Color, at: V2) -> NodeId {
text(ed, f, s, size, bold, color, at, None)
}
/// A text layer on slide `f` at `at` (slide units). `width` fixes the box (wrapping, aligned).
#[allow(clippy::too_many_arguments)]
fn text(ed: &mut Editor, f: NodeId, s: &str, size: f64, bold: bool, color: Color, at: V2, width: Option<(f64, TextAlign)>) -> NodeId {
let (w, align) = width.unwrap_or((1.0, TextAlign::Left));
let mut n = Node::new(NodeId(0), NodeKind::Text { text: s.into(), font_size: size, auto_width: width.is_none(), align }, Rect::new(at.x, at.y, w, 1.0));
n.style.set_fill(Some(color));
n.text_style.bold = bold;
ed.add_node(n, Some(f))
}
/// The source rects of a screen too tall to read fitted on one slide, top to bottom; None for
/// one that fits.
fn tall_parts(doc: &Document, id: NodeId) -> Option<Vec<Rect>> {
let b = crate::io::svg::export_bounds(doc, &[id])?;
if b.w < MIN_SPLIT_W || (STAGE.w / b.w).min(STAGE.h / b.h) >= READABLE {
return None;
}
Some(cut(doc, id, b))
}
/// `b` (an area of layer `id`) cut top to bottom into parts of about a slide each. Each part
/// ends at the lowest line within a slide's reach that crosses no block small enough to fit
/// on a slide (a card, a row), so blocks are not split between slides.
fn cut(doc: &Document, id: NodeId, b: Rect) -> Vec<Rect> {
/// A part is at least this share of a slide, else it is cut through a block.
const MIN_PART: f64 = 0.4;
let rects: Vec<Rect> = super::export::with_layers_on_top(doc, &[id])
.iter()
.flat_map(|r| doc.subtree(*r))
.filter_map(|d| doc.world_rect(d))
.filter(|r| r.h > 0.0 && r.intersects(&b) && r.h < b.h)
.collect();
let step = STAGE.h / part_scale(b);
// How far a part may run, shown smaller (down to CUT_MIN_SCALE), to end in a gap.
let reach = step / CUT_MIN_SCALE;
let blocks: Vec<(f64, f64)> = rects.iter().filter(|r| r.h <= reach).map(|r| (r.y, r.bottom())).collect();
let crosses = |y: f64| blocks.iter().any(|(top, bottom)| *top < y && y < *bottom);
let mut parts = Vec::new();
let mut top = b.y;
// What is left fits on one last slide, shown a little smaller.
while b.bottom() - top > reach {
let gaps: Vec<f64> = blocks.iter().map(|(_, bottom)| *bottom).filter(|y| *y > top + step * MIN_PART && !crosses(*y)).collect();
// The lowest gap within a slide; else the nearest one within reach; else cut at a slide.
let y = gaps
.iter()
.copied()
.filter(|y| *y <= top + step)
.reduce(f64::max)
.or_else(|| gaps.iter().copied().filter(|y| *y <= top + reach).reduce(f64::min))
.unwrap_or(top + step);
parts.push(Rect::new(b.x, top, b.w, y - top));
top = y;
}
parts.push(Rect::new(b.x, top, b.w, b.bottom() - top));
parts
}
/// A numbered note marker on `f` with its top-left at `at` (slide units).
fn marker(ed: &mut Editor, f: NodeId, num: usize, color: Color, at: V2) {
let mut dot = Node::new(NodeId(0), NodeKind::Ellipse, Rect::new(at.x, at.y, MARKER, MARKER));
dot.style.set_fill(Some(color));
ed.add_node(dot, Some(f));
text(ed, f, &num.to_string(), 15.0, true, Color { r: 255, g: 255, b: 255, a: 255 }, v2(at.x, at.y + 5.0), Some((MARKER, TextAlign::Center)));
}
/// The page name over a slide's title, and the board's for a board's cards.
fn kicker(ed: &mut Editor, f: NodeId, s: &Section, id: NodeId) {
let mut k = s.name.to_uppercase();
if s.doc.get(id).is_some_and(|n| n.section) {
k = format!("{k} \u{b7} {}", clean(name_of(s.doc, id)).to_uppercase());
}
t(ed, f, &k, 20.0, true, ACCENT, v2(MARGIN, 64.0));
}
/// The scale every part of a tall board is shown at: the stage's width, at most 1:1.
fn part_scale(board: Rect) -> f64 {
(STAGE.w / board.w).min(1.0)
}
/// Screen `id` (or the `crop` of it) fitted into `area` (slide units) on a hairline card;
/// `slide` is the slide's rect in the generated document. None when the screen has no area.
fn place<'d>(ed: &mut Editor, f: NodeId, doc: &'d Document, id: NodeId, crop: Option<Rect>, area: Rect, slide: Rect) -> Option<Insert<'d>> {
let from = crop.or_else(|| crate::io::svg::export_bounds(doc, &[id]))?;
let s = (area.w / from.w).min(area.h / from.h);
let (w, h) = (from.w * s, from.h * s);
let to = Rect::new(area.x + (area.w - w) / 2.0, area.y + (area.h - h) / 2.0, w, h);
let mut card = Node::new(NodeId(0), NodeKind::Rect, Rect::new(to.x - CARD_EDGE_W, to.y - CARD_EDGE_W, w + 2.0 * CARD_EDGE_W, h + 2.0 * CARD_EDGE_W));
card.style.set_fill(Some(CARD_EDGE));
ed.add_node(card, Some(f));
Some(Insert {
doc,
roots: super::export::with_layers_on_top(doc, &[id]),
from,
to: to.translate(v2(slide.x, slide.y)),
})
}
/// A question line without the team markers the notes use: `Days ? (team)` is `Days?`, and
/// `(? team: limit)` is `(limit?)`.
fn ask(line: &str) -> String {
const ASK: &str = "(? team: ";
let mut s = line.to_owned();
while let Some(a) = s.find(ASK) {
let Some(b) = s[a..].find(')') else { break };
let topic = s[a + ASK.len()..a + b].to_owned();
s.replace_range(a..=a + b, &format!("({topic}?)"));
}
let s = s.replace("(? team)", "").replace("? (team)", "?").replace(": ?", "?").replace(" ?", "?").replace("??", "?");
let s = s.trim();
if s.contains('?') { s.to_owned() } else { format!("{s}?") }
}
fn count(n: usize, what: &str) -> String {
format!("{n} {what}{}", if n == 1 { "" } else { "s" })
}
/// "28 October 2026" for Unix milliseconds (UTC; civil-from-days).
fn date(ms: u64) -> String {
let z = (ms / 86_400_000) as i64 + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + i64::from(m <= 2);
format!("{d} {} {y}", MONTHS[(m - 1) as usize])
}
#[cfg(test)]
mod tests;
+157
View File
@@ -0,0 +1,157 @@
use super::*;
use crate::editor::screens::Role;
fn named(ed: &mut Editor, name: &str, r: Rect, parent: Option<NodeId>) -> NodeId {
let mut n = Node::new(NodeId(0), NodeKind::Frame { clip: true }, r);
n.name = name.into();
ed.add_node(n, parent)
}
#[test]
fn titles_drop_the_kind_and_states_follow_their_screen() {
assert_eq!(clean("Screen / Sessions / Empty"), "Sessions \u{b7} Empty");
assert_eq!(clean("Section"), "Section");
let mut ed = Editor::default();
let list = named(&mut ed, "Screen / Sessions / List", Rect::new(0.0, 0.0, 1440.0, 900.0), None);
let home = named(&mut ed, "Screen / Home", Rect::new(2000.0, 0.0, 1440.0, 900.0), None);
let states: Vec<NodeId> = ["Empty", "Loading", "Error", "Offline"]
.iter()
.enumerate()
.map(|(k, st)| named(&mut ed, &format!("Screen / Sessions / {st}"), Rect::new(k as f64 * 2000.0, 1200.0, 1440.0, 900.0), None))
.collect();
let s = Section { name: "Admin".into(), doc: &ed.doc, screens: screens_of(&ed.doc) };
let (slides, appendix) = section_slides(0, &s);
let shape = |slides: &[Slide]| -> Vec<String> {
slides
.iter()
.map(|sl| match sl {
Slide::Screen { id, states, .. } => format!("screen {} +{states}", name_of(&ed.doc, *id)),
Slide::Row(_, title, ids, _) => format!("row {title} {}", ids.len()),
_ => "other".into(),
})
.collect()
};
assert_eq!(shape(&slides), ["screen Screen / Sessions / List +4", "screen Screen / Home +0"]);
assert_eq!(shape(&appendix), ["row Sessions 4"], "the other states wait in the appendix");
assert!(matches!(&appendix[0], Slide::Row(_, _, ids, _) if ids[0] == states[0]) && list != home);
}
#[test]
fn notes_on_a_screen_number_its_layers_and_continue_on_a_second_slide() {
use crate::model::annotation::Annotation;
let mut ed = Editor::default();
let screen = named(&mut ed, "Screen / Sessions", Rect::new(0.0, 0.0, 1440.0, 900.0), None);
let rows: Vec<NodeId> = (0..10).map(|k| named(&mut ed, "Row", Rect::new(0.0, 820.0 - k as f64 * 90.0, 1440.0, 80.0), Some(screen))).collect();
let other = named(&mut ed, "Screen / Elsewhere", Rect::new(2000.0, 0.0, 1440.0, 900.0), None);
for (k, row) in rows.iter().enumerate() {
ed.doc.annotations.push(Annotation::new(Some(*row), &format!("Rule {k}"), "data"));
}
ed.doc.annotations.push(Annotation::new(Some(other), "Not here", "rule"));
let mut gone = Annotation::new(Some(rows[0]), "Detached", "rule");
gone.detached = true;
ed.doc.annotations.push(gone);
let notes = notes_of(&ed.doc, screen);
assert_eq!(notes.len(), 10, "this screen's attached notes only");
assert_eq!(notes[0].title, "Rule 9", "top to bottom");
let slides = screen_slides(0, &ed.doc, screen, 0);
assert_eq!(slides.len(), 2);
assert!(matches!(&slides[1], Slide::Screen { notes, part: Some((2, 2)), .. } if notes[0].0 == 9));
let pdf = build(&Meta { title: "T".into(), subtitle: None, author: None, now_ms: 0 }, &[Section { name: "P".into(), doc: &ed.doc, screens: vec![screen] }]);
assert!(String::from_utf8_lossy(&pdf).contains("/Count 7"), "cover, contents, divider, 2 note slides, next steps, end");
}
#[test]
fn a_page_opens_with_a_map_and_the_deck_closes_on_its_questions() {
use crate::model::annotation::Annotation;
let mut ed = Editor::default();
let a = named(&mut ed, "Screen / Home", Rect::new(0.0, 0.0, 1440.0, 900.0), None);
let b = named(&mut ed, "Screen / Settings", Rect::new(2000.0, 0.0, 1440.0, 900.0), None);
ed.doc.annotations.push(Annotation::new(Some(b), "Who can change the plan?", "rule"));
let pdf = build(&Meta { title: "T".into(), subtitle: None, author: None, now_ms: 0 }, &[Section { name: "P".into(), doc: &ed.doc, screens: vec![a, b] }]);
// Cover, contents, divider, map, Home, Settings (noted), open questions, end.
assert!(String::from_utf8_lossy(&pdf).contains("/Count 8"));
}
#[test]
fn a_tall_card_is_cut_between_its_cells_not_through_them() {
let mut ed = Editor::default();
let card = named(&mut ed, "Group / App shell", Rect::new(0.0, 0.0, 1376.0, 2900.0), None);
for row in 0..3 {
for col in 0..5 {
named(&mut ed, "Cell", Rect::new(col as f64 * 270.0, 20.0 + row as f64 * 960.0, 248.0, 924.0), Some(card));
}
}
let parts = cut(&ed.doc, card, ed.doc.world_rect(card).unwrap());
assert_eq!(parts.len(), 3, "a row of cells to a slide, shown a little smaller");
assert!(parts.iter().all(|p| (0..3).any(|row| (p.bottom() - (944.0 + row as f64 * 960.0)).abs() < 40.0 || p.bottom() == 2900.0)), "{parts:?}");
}
#[test]
fn a_tall_board_is_shown_by_its_cards() {
let mut ed = Editor::default();
let board = named(&mut ed, "Section / Colors", Rect::new(0.0, 0.0, 1440.0, 2000.0), None);
named(&mut ed, "Section Intro", Rect::new(32.0, 32.0, 1376.0, 40.0), Some(board));
let groups = named(&mut ed, "Groups", Rect::new(32.0, 100.0, 1376.0, 1880.0), Some(board));
for (k, h) in [300.0, 300.0, 1700.0].iter().enumerate() {
named(&mut ed, &format!("Colors / Card {k}"), Rect::new(0.0, k as f64 * 340.0, 1376.0, *h), Some(groups));
}
ed.set_role(&[board], Role::Section).unwrap();
let titles: Vec<String> = board_slides(0, &ed.doc, board)
.iter()
.map(|sl| match sl {
Slide::Crop(_, _, title, _) => title.clone(),
_ => "other".into(),
})
.collect();
assert_eq!(titles[0], "Card 0 \u{b7} Card 1", "small cards share a slide, named without the board");
assert!(titles[1..].iter().all(|t| t.starts_with("Card 2 (")), "{titles:?}");
}
#[test]
fn questions_drop_the_team_markers() {
assert_eq!(ask("Days ? (team)"), "Days?");
assert_eq!(ask("Other roles (Interviewer): ? (team)"), "Other roles (Interviewer)?");
assert_eq!(ask("Editable? ? (team)"), "Editable?");
assert_eq!(ask("Lock after 5 (? team: limit)"), "Lock after 5 (limit?)");
assert_eq!(ask("Hiring panel only (? team)"), "Hiring panel only?");
}
#[test]
fn dates_read_as_days() {
assert_eq!(date(0), "1 January 1970");
assert_eq!(date(1_793_145_600_000), "28 October 2026");
}
#[test]
fn deck_has_cover_contents_divider_screens_panels_and_end() {
let mut ed = Editor::default();
let mut frame = |r: Rect| ed.add_node(Node::new(NodeId(0), NodeKind::Frame { clip: true }, r), None);
let wide = frame(Rect::new(0.0, 0.0, 1440.0, 900.0));
let panels: Vec<NodeId> = (0..6).map(|i| frame(Rect::new(2000.0 + i as f64 * 400.0, 0.0, 344.0, 1440.0))).collect();
frame(Rect::new(0.0, 2000.0, 80.0, 40.0)); // a component part, not a screen
let main = frame(Rect::new(0.0, 2500.0, 400.0, 400.0));
let board = frame(Rect::new(0.0, 3000.0, 1440.0, 200.0));
ed.doc.nodes.get_mut(&main).unwrap().component = true; // a component main, not a screen
let screens = screens_of(&ed.doc);
assert_eq!(screens.len(), 7);
assert_eq!(screens[0], wide);
ed.set_role(&[board], Role::Section).unwrap();
assert_eq!(screens_of(&ed.doc).last(), Some(&board), "a section is shown whole, whatever its size");
let long = ed.add_node(Node::new(NodeId(0), NodeKind::Frame { clip: true }, Rect::new(0.0, 4000.0, 1440.0, 3000.0)), None);
for k in 0..4 {
let card = Node::new(NodeId(0), NodeKind::Frame { clip: true }, Rect::new(0.0, k as f64 * 720.0, 1440.0, 700.0));
ed.add_node(card, Some(long));
}
let parts = tall_parts(&ed.doc, long).unwrap();
let cuts: Vec<f64> = parts.iter().map(|p| p.bottom()).collect();
assert_eq!(cuts, vec![4700.0, 5420.0, 6140.0, 7000.0], "cut in the gaps between cards, not through them");
assert!(tall_parts(&ed.doc, wide).is_none() && tall_parts(&ed.doc, panels[0]).is_none());
ed.set_role(&[wide], Role::Screen).unwrap();
assert_eq!(screens_of(&ed.doc), vec![wide, board], "a section without screens is shown beside declared screens");
ed.set_role(&[wide, board], Role::None).unwrap();
let meta = Meta { title: "Deck".into(), subtitle: None, author: None, now_ms: 0 };
let pdf = build(&meta, &[Section { name: "Page".into(), doc: &ed.doc, screens }]);
// Cover, contents, divider (no map for one key screen), the wide screen, 5 + 1 panels,
// next steps, end.
assert!(String::from_utf8_lossy(&pdf).contains("/Count 8"), "{} panels", panels.len());
}
+65 -10
View File
@@ -11,6 +11,8 @@ pub enum Format {
Png,
Svg,
Pdf,
/// A PDF deck: cover, contents, a slide per screen (`io::deck`).
Presentation,
}
impl Format {
@@ -18,10 +20,10 @@ impl Format {
match self {
Format::Png => "png",
Format::Svg => "svg",
Format::Pdf => "pdf",
Format::Pdf | Format::Presentation => "pdf",
}
}
pub const ALL: [Format; 3] = [Format::Png, Format::Svg, Format::Pdf];
pub const ALL: [Format; 4] = [Format::Png, Format::Svg, Format::Pdf, Format::Presentation];
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
@@ -77,7 +79,7 @@ impl Default for Spec {
impl Spec {
/// One output file, or a folder of files?
pub fn single_file(&self, items: usize) -> bool {
self.format == Format::Pdf || items <= 1
[Format::Pdf, Format::Presentation].contains(&self.format) || items <= 1
}
}
@@ -97,6 +99,11 @@ pub fn items<'a>(
selection: &[NodeId],
) -> Vec<Item<'a>> {
let (pname, doc) = pages[active];
// A presentation takes whole pages: each frame is already a slide of it.
let scope = match spec.scope {
Scope::Frames if spec.format == Format::Presentation => Scope::Page,
s => s,
};
let frames_of = |doc: &'a Document, among: &[NodeId]| -> Vec<Item<'a>> {
among
.iter()
@@ -108,7 +115,7 @@ pub fn items<'a>(
})
.collect()
};
match spec.scope {
match scope {
Scope::Selection if !selection.is_empty() => vec![Item {
name: if selection.len() == 1 {
doc.get(selection[0])
@@ -143,6 +150,44 @@ pub fn items<'a>(
}
}
/// `roots` plus, for each top-level frame among them, the top-level layers painted after it
/// that lie inside it: what the canvas shows on the frame (component mains laid out over a
/// board frame are its siblings, not its children).
pub fn with_layers_on_top(doc: &Document, roots: &[NodeId]) -> Vec<NodeId> {
let mut out = roots.to_vec();
for root in roots {
let Some(at) = doc.root.iter().position(|id| id == root) else { continue };
let Some(frame) = doc.get(*root).filter(|n| n.is_frame()).and_then(|_| doc.world_rect(*root)) else { continue };
for id in &doc.root[at + 1..] {
let on_top = doc.get(*id).is_some_and(|n| n.visible) && doc.world_rect(*id).is_some_and(|r| frame.contains_rect(&r));
if on_top && !out.contains(id) {
out.push(*id);
}
}
}
out
}
/// The items as a presentation deck titled `title`: a page's own roots mean its screens, a
/// selection is shown as it is.
pub fn presentation(items: &[Item], title: &str) -> Vec<u8> {
let sections: Vec<crate::io::deck::Section> = items
.iter()
.map(|it| crate::io::deck::Section {
name: it.name.clone(),
doc: it.doc,
screens: if it.roots == it.doc.root { crate::io::deck::screens_of(it.doc) } else { it.roots.clone() },
})
.collect();
let meta = crate::io::deck::Meta {
title: title.to_string(),
subtitle: None,
author: None,
now_ms: crate::project::format::now_ms(),
};
crate::io::deck::build(&meta, &sections)
}
fn safe(name: &str) -> String {
let s = crate::project::folder_name(name);
if s.is_empty() { "export".into() } else { s }
@@ -160,6 +205,11 @@ pub fn write(
if items.is_empty() {
return Err("nothing to export".into());
}
if spec.format == Format::Presentation {
let title = out.file_stem().map_or("Presentation".into(), |s| s.to_string_lossy().into_owned());
crate::io::atomic::write(out, &presentation(items, &title)).map_err(|e| e.to_string())?;
return Ok(vec![out.to_path_buf()]);
}
if spec.format == Format::Pdf {
let mut pages = Vec::new();
for it in items {
@@ -195,9 +245,10 @@ pub fn write(
crate::vfs::create_dir_all(out).map_err(|e| e.to_string())?;
out.join(format!("{name}.{}", spec.format.ext()))
};
let roots = with_layers_on_top(it.doc, &it.roots);
match spec.format {
Format::Svg => {
let s = crate::io::svg::export(it.doc, Some(&it.roots), bg)
let s = crate::io::svg::export(it.doc, Some(&roots), bg)
.ok_or("nothing to export")?;
crate::io::atomic::write(&path, s.as_bytes()).map_err(|e| e.to_string())?;
}
@@ -210,13 +261,13 @@ pub fn write(
&owned
}
};
let img = crate::io::png::render(g, it.doc, Some(&it.roots), spec.scale, bg)?;
let img = crate::io::png::render(g, it.doc, Some(&roots), spec.scale, bg)?;
let mut buf = std::io::Cursor::new(Vec::new());
img.write_to(&mut buf, image::ImageFormat::Png)
.map_err(|e| e.to_string())?;
crate::io::atomic::write(&path, &buf.into_inner()).map_err(|e| e.to_string())?;
}
Format::Pdf => unreachable!(),
Format::Pdf | Format::Presentation => unreachable!(),
}
written.push(path);
}
@@ -245,6 +296,9 @@ pub async fn render_files(
}
let bg = (!spec.transparent).then_some(Color::WHITE);
let ext = spec.format.ext();
if spec.format == Format::Presentation {
return Ok(vec![(format!("{}.{ext}", safe(base)), presentation(items, base))]);
}
if spec.format == Format::Pdf {
let frames = spec.scope == Scope::Frames;
let pages: Vec<_> = items
@@ -268,14 +322,15 @@ pub async fn render_files(
} else {
crate::io::zip::unique_name(&format!("{}.{ext}", safe(&it.name)), &mut used)
};
let roots = with_layers_on_top(it.doc, &it.roots);
let bytes = match spec.format {
Format::Svg => crate::io::svg::export(it.doc, Some(&it.roots), bg)
Format::Svg => crate::io::svg::export(it.doc, Some(&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?,
&crate::io::png::render_async(gpu, it.doc, Some(&roots), spec.scale, bg).await?,
)?,
Format::Pdf => unreachable!(),
Format::Pdf | Format::Presentation => unreachable!(),
};
files.push((name, bytes));
}
+1
View File
@@ -6,6 +6,7 @@ pub mod export;
pub mod fig;
pub mod file;
pub mod import;
pub mod deck;
pub mod pdf;
pub mod png;
pub mod svg;
+101 -7
View File
@@ -20,6 +20,17 @@ pub struct PdfPage<'a> {
pub roots: Vec<NodeId>,
pub bounds: Rect,
pub background: Option<Color>,
/// Drawn after `roots`, each scaled into its own area (a presentation's screens).
pub inserts: Vec<Insert<'a>>,
}
/// Layers of another document drawn scaled: `from` (world units of `doc`) maps onto `to`
/// (the page's units), uniformly by width, clipped to `to`. Stays vector.
pub struct Insert<'a> {
pub doc: &'a Document,
pub roots: Vec<NodeId>,
pub from: Rect,
pub to: Rect,
}
fn num(x: f64) -> String {
@@ -96,6 +107,8 @@ struct PageWriter<'a> {
objs: &'a mut Objects,
res: Res,
out: String,
/// While drawing an insert: its source area. Layers outside it are left out of the file.
cull: Option<Rect>,
}
impl PageWriter<'_> {
@@ -243,11 +256,11 @@ impl PageWriter<'_> {
if let Some((n, _)) = self.res.images.get(asset) {
return Some(n.clone());
}
let a = assets::get(asset)?;
let (w, h) = (a.width, a.height);
let rgba = assets::get(asset)?.rgba()?;
let (w, h) = (rgba.width(), rgba.height());
let mut rgb = Vec::with_capacity((w * h * 3) as usize);
let mut alpha = Vec::with_capacity((w * h) as usize);
for p in a.rgba.pixels() {
for p in rgba.pixels() {
rgb.extend_from_slice(&p.0[..3]);
alpha.push(p.0[3]);
}
@@ -426,12 +439,19 @@ impl PageWriter<'_> {
}
let wr = doc.world_rect(id).unwrap();
let op = opacity * n.style.opacity;
let d = draw::node_draw(n, wr, op);
self.ops(&d.ops, d.blend);
let clip = matches!(n.kind, NodeKind::Frame { clip: true });
// Outside the insert's area: nothing of it shows, unless an unclipped child pokes in.
let outside = self.cull.is_some_and(|c| !wr.intersects(&c));
if outside && (clip || n.children.is_empty()) {
return;
}
if !outside {
let d = draw::node_draw(n, wr, op);
self.ops(&d.ops, d.blend);
}
if n.children.is_empty() {
return;
}
let clip = matches!(n.kind, NodeKind::Frame { clip: true });
if clip {
let p = self.emit_path(&draw::shape_path(n, wr).unwrap_or_default());
let _ = writeln!(self.out, "q {p}W n");
@@ -457,6 +477,7 @@ pub fn export(pages: &[PdfPage]) -> Vec<u8> {
objs: &mut objs,
res: Res::default(),
out: String::new(),
cull: None,
};
// y-down user space with the page's top-left at the area's top-left.
let _ = writeln!(w.out, "1 0 0 -1 {} {} cm", num(-b.x), num(b.y + b.h));
@@ -476,6 +497,31 @@ pub fn export(pages: &[PdfPage]) -> Vec<u8> {
let op = draw::inherited_opacity(pg.doc, *r);
w.node(pg.doc, *r, op);
}
for ins in &pg.inserts {
let s = ins.to.w / ins.from.w.max(f64::EPSILON);
let (t, f) = (ins.to, ins.from);
// Full precision: the scale multiplies world coordinates in the tens of thousands, so
// rounding it to num()'s 3 decimals shifts the screen inside its window.
let _ = writeln!(
w.out,
"q {} {} {} {} re W n {s:.9} 0 0 {s:.9} {:.4} {:.4} cm",
num(t.x),
num(t.y),
num(t.w),
num(t.h),
t.x - f.x * s,
t.y - f.y * s
);
// A margin for strokes drawn outside their layer.
const CULL_MARGIN: f64 = 16.0;
w.cull = Some(Rect::new(f.x - CULL_MARGIN, f.y - CULL_MARGIN, f.w + 2.0 * CULL_MARGIN, f.h + 2.0 * CULL_MARGIN));
for r in &ins.roots {
let op = draw::inherited_opacity(ins.doc, *r);
w.node(ins.doc, *r, op);
}
w.cull = None;
w.out.push_str("Q\n");
}
let content = std::mem::take(&mut w.out);
let res = std::mem::take(&mut w.res);
let content_id = objs.stream("", content.as_bytes());
@@ -561,19 +607,22 @@ pub fn pages_for<'a>(
Some(PdfPage {
doc,
bounds: crate::io::svg::export_bounds(doc, &[f])?,
roots: vec![f],
roots: crate::io::export::with_layers_on_top(doc, &[f]),
background,
inserts: Vec::new(),
})
})
.collect();
}
}
let roots = crate::io::export::with_layers_on_top(doc, &roots);
match crate::io::svg::export_bounds(doc, &roots) {
Some(b) => vec![PdfPage {
doc,
roots,
bounds: b,
background,
inserts: Vec::new(),
}],
None => vec![],
}
@@ -610,4 +659,49 @@ mod tests {
let first_off: usize = table.lines().nth(3).unwrap()[..10].parse().unwrap();
assert!(pdf[first_off..].starts_with(b"1 0 obj"));
}
#[test]
fn an_insert_far_from_the_origin_lands_where_it_is_placed() {
use crate::geom::Rect;
use crate::model::{Node, NodeKind};
let mut ed = crate::editor::Editor::default();
let from = Rect::new(4192.0, 25412.0, 1440.0, 900.0);
let screen = ed.edit("Add", |tx| {
let id = tx.alloc_id();
tx.insert(Node::new(id, NodeKind::Frame { clip: true }, from), None, None);
id
});
let to = Rect::new(100.0, 100.0, 1440.0 * 0.3361, 900.0 * 0.3361);
let page = PdfPage { doc: &ed.doc, roots: Vec::new(), bounds: Rect::new(0.0, 0.0, 1920.0, 1080.0), background: None, inserts: vec![Insert { doc: &ed.doc, roots: vec![screen], from, to }] };
let pdf = export(&[page]);
let content = pdf
.windows(7)
.position(|w| w == b"stream\n")
.and_then(|at| miniz_oxide::inflate::decompress_to_vec_zlib(&pdf[at + 7..]).ok())
.expect("a page content stream");
let line = String::from_utf8_lossy(&content).lines().find(|l| l.ends_with(" cm") && l.contains("re W n")).unwrap().to_owned();
let n: Vec<f64> = line.split_whitespace().filter_map(|t| t.parse().ok()).collect();
let (s, ty) = (n[4], n[9]);
assert!((from.y * s + ty - to.y).abs() < 0.01, "the screen's top maps onto the window's top: {line}");
}
#[test]
fn a_frame_page_includes_layers_laid_over_it() {
use crate::geom::Rect;
use crate::model::{Node, NodeKind};
let mut ed = crate::editor::Editor::default();
let mut add = |r: Rect| {
ed.edit("Add", |tx| {
let id = tx.alloc_id();
tx.insert(Node::new(id, NodeKind::Frame { clip: true }, r), None, None);
id
})
};
let board = add(Rect::new(0.0, 0.0, 400.0, 300.0));
let main = add(Rect::new(20.0, 20.0, 100.0, 40.0));
let outside = add(Rect::new(500.0, 0.0, 100.0, 40.0));
let pages = pages_for(&ed.doc, Some(&[board]), true, None);
assert_eq!(pages.len(), 1);
assert_eq!(pages[0].roots, vec![board, main], "the main on the board, not the one beside it ({outside:?})");
}
}
+36 -2
View File
@@ -53,7 +53,7 @@ pub fn flow_children(doc: &Document, id: NodeId) -> Vec<NodeId> {
}
/// Intrinsic (hug) size of a node along an axis, without changing anything.
fn hug_size(doc: &Document, id: NodeId, axis: usize) -> f64 {
pub(crate) fn hug_size(doc: &Document, id: NodeId, axis: usize) -> f64 {
let n = doc.get(id).unwrap();
let (_, cur) = get(&n.rect, axis);
let Some(l) = n.layout else {
@@ -74,7 +74,7 @@ fn hug_size(doc: &Document, id: NodeId, axis: usize) -> f64 {
// A growing child still brings its content (CSS flex-basis auto); its current
// size is what it grew to, so it is not used.
Sizing::Fill if kn.layout.is_some() => clamp_size(&kn.layout_child, axis, hug_size(doc, *k, axis)),
Sizing::Fill => clamp_size(&kn.layout_child, axis, 0.0),
Sizing::Fill => clamp_size(&kn.layout_child, axis, text_content(kn, axis)),
Sizing::Fixed => clamp_size(&kn.layout_child, axis, get(&kn.rect, axis).1),
}
})
@@ -112,6 +112,19 @@ fn hug_size(doc: &Document, id: NodeId, axis: usize) -> f64 {
clamp_size(&n.layout_child, axis, result)
}
/// What a growing text brings to a parent that hugs it: its one-line width and its height, as
/// a browser sizes a fit-content box by its content. Without it, a text that fills a hugging
/// row gets no width and wraps a letter per line. Other leaves bring nothing.
fn text_content(n: &crate::model::Node, axis: usize) -> f64 {
let NodeKind::Text { text, font_size, align, .. } = &n.kind else {
return 0.0;
};
if axis == 1 {
return n.rect.h;
}
crate::text::layout_rich(text, *font_size, crate::model::Color::INK, &n.text_style, None, *align).width
}
fn wrap_lines(
doc: &Document,
_id: NodeId,
@@ -710,6 +723,27 @@ mod tests {
const FX: [Sizing; 2] = [Sizing::Fixed, Sizing::Fixed];
#[test]
fn a_growing_text_in_a_hugging_row_keeps_its_width() {
let mut doc = Document::default();
let mut rec = Recording::new("t", &doc);
let mut tx = Tx::new(&mut doc, &mut rec);
let row = tx.alloc_id();
let mut f = Node::new(row, NodeKind::Frame { clip: true }, Rect::new(0.0, 0.0, 10.0, 10.0));
f.layout = Some(AutoLayout::default());
f.layout_child.sizing = [Sizing::Hug; 2];
tx.insert(f, None, None);
let id = tx.alloc_id();
let kind = NodeKind::Text { text: "Two Sum".into(), font_size: 14.0, auto_width: false, align: crate::model::TextAlign::Left };
let mut t = Node::new(id, kind, Rect::new(0.0, 0.0, 0.0, 17.0));
t.layout_child.sizing = [Sizing::Fill, Sizing::Hug];
tx.insert(t, Some(row), None);
layout_frame(&mut tx, row);
let w = doc.get(id).unwrap().rect.w;
assert!(w > 40.0, "one line wide, not 0: {w}");
assert!(doc.get(id).unwrap().rect.h < 20.0, "one line tall");
}
#[test]
fn horizontal_hug_with_gap_and_padding() {
let l = AutoLayout {
+2 -1
View File
@@ -23,7 +23,7 @@ USAGE:
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]
papercraft --export OUT PROJECT|FILE --format png|svg|pdf|presentation [--scope page|frames|all]
[--page N] [--scale S] [--transparent]
(frames/all with PNG/SVG write a folder)
papercraft --import FILE create a project from .excalidraw, .svg or
@@ -240,6 +240,7 @@ fn run(args: Vec<String>) -> Result<(), String> {
"png" => export::Format::Png,
"svg" => export::Format::Svg,
"pdf" => export::Format::Pdf,
"presentation" => export::Format::Presentation,
f => return Err(format!("unknown --format {f}")),
};
let scope = match value("--scope").as_deref().unwrap_or("page") {
+127
View File
@@ -0,0 +1,127 @@
//! `get_bounds`: the rects of a whole subtree in one read, so an agent can check layout (overflow,
//! alignment, overlap) without one `get_node_info` call per layer.
use crate::model::{Document, NodeId, NodeKind};
use serde_json::{Value, json};
/// Rows returned per call; the rest is reported as `truncated` with where to continue.
pub const MAX_ROWS: usize = 2000;
/// Column names of a row, in order.
pub const COLUMNS: [&str; 9] = ["id", "parent", "x", "y", "w", "h", "absX", "absY", "clip"];
/// Rows for the subtrees under `roots`, depth first (parents before children).
pub struct Collected {
pub rows: Vec<Value>,
/// Layers not reached because of the row cap, in the order they would have come.
pub pending: Vec<NodeId>,
}
/// One decimal is far finer than a pixel and keeps rows short.
fn tenth(v: f64) -> f64 {
(v * 10.0).round() / 10.0
}
/// Whether the layer clips what is drawn outside it (a clipping frame, or a scrolling one).
fn clips(doc: &Document, id: NodeId) -> bool {
doc.get(id)
.is_some_and(|n| n.scroll || matches!(n.kind, NodeKind::Frame { clip: true }))
}
/// Walk `roots` and emit at most `cap` rows. `depth` counts the roots as level 1 (`None` = all
/// levels). `key` turns a node id into the id string tools accept.
pub fn collect(doc: &Document, roots: &[NodeId], depth: Option<usize>, cap: usize, key: &dyn Fn(NodeId) -> String) -> Collected {
let mut stack: Vec<(NodeId, usize)> = roots.iter().rev().map(|r| (*r, 1)).collect();
let mut rows = Vec::new();
while let Some((id, level)) = stack.pop() {
let (Some(n), Some(w)) = (doc.get(id), doc.world_rect(id)) else { continue };
if rows.len() >= cap {
stack.push((id, level));
break;
}
let parent = n.parent.map_or(Value::Null, |p| json!(key(p)));
rows.push(json!([
key(id),
parent,
tenth(n.rect.x),
tenth(n.rect.y),
tenth(n.rect.w),
tenth(n.rect.h),
tenth(w.x),
tenth(w.y),
clips(doc, id)
]));
if depth.is_none_or(|d| level < d) {
stack.extend(n.children.iter().rev().map(|c| (*c, level + 1)));
}
}
Collected { rows, pending: stack.into_iter().rev().map(|(id, _)| id).collect() }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::geom::Rect;
use crate::model::Node;
fn add(doc: &mut Document, parent: Option<NodeId>, kind: NodeKind, r: Rect) -> NodeId {
let id = doc.alloc_id();
let mut n = Node::new(id, kind, r);
n.parent = parent;
doc.nodes.insert(id, n);
match parent {
Some(p) => doc.nodes.get_mut(&p).unwrap().children.push(id),
None => doc.root.push(id),
}
id
}
fn tree() -> (Document, NodeId, NodeId, NodeId) {
let mut d = Document::default();
let board = add(&mut d, None, NodeKind::Frame { clip: true }, Rect::new(100.0, 50.0, 300.0, 200.0));
let card = add(&mut d, Some(board), NodeKind::Frame { clip: false }, Rect::new(10.0, 20.0, 100.0, 80.0));
let dot = add(&mut d, Some(card), NodeKind::Rect, Rect::new(5.04, 6.0, 8.0, 8.0));
(d, board, card, dot)
}
fn key(id: NodeId) -> String {
format!("p:{}", id.0)
}
#[test]
fn rows_carry_local_and_world_positions_in_paint_order() {
let (d, board, card, dot) = tree();
let c = collect(&d, &[board], None, MAX_ROWS, &key);
assert_eq!(c.rows.len(), 3);
assert!(c.pending.is_empty());
assert_eq!(c.rows[0], json!([key(board), null, 100.0, 50.0, 300.0, 200.0, 100.0, 50.0, true]));
assert_eq!(c.rows[1], json!([key(card), key(board), 10.0, 20.0, 100.0, 80.0, 110.0, 70.0, false]));
assert_eq!(c.rows[2][0], json!(key(dot)));
assert_eq!(c.rows[2][2], json!(5.0), "rounded to a tenth");
assert_eq!(c.rows[2][6], json!(115.0));
}
#[test]
fn depth_counts_the_roots_as_level_one() {
let (d, board, ..) = tree();
assert_eq!(collect(&d, &[board], Some(1), MAX_ROWS, &key).rows.len(), 1);
assert_eq!(collect(&d, &[board], Some(2), MAX_ROWS, &key).rows.len(), 2);
}
#[test]
fn the_cap_stops_the_walk_and_names_what_is_left() {
let (d, board, card, dot) = tree();
let c = collect(&d, &[board], None, 1, &key);
assert_eq!(c.rows.len(), 1);
assert_eq!(c.pending, vec![card]);
let rest = collect(&d, &c.pending, None, 1, &key);
assert_eq!(rest.pending, vec![dot]);
}
#[test]
fn a_scrolling_frame_counts_as_clipping() {
let (mut d, _, card, _) = tree();
d.nodes.get_mut(&card).unwrap().scroll = true;
assert!(clips(&d, card));
}
}
+224
View File
@@ -0,0 +1,224 @@
//! A critique offered when a design is finished, and the lessons the person keeps from it
//! (`specs/design-critique/spec.md`). `critique_design` gathers the evidence the agent judges
//! by; `design_lessons` stores what the person approved, in the project store, so every later
//! session sees it in `get_basic_info` and in the next critique.
use crate::io::atomic;
use crate::project::Store;
use crate::model::{Document, NodeId, NodeKind};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::path::PathBuf;
const FILE: &str = ".design-lessons.json";
/// Lessons are rules read before every design: short enough to scan.
const MAX_RULE: usize = 160;
/// Lint issues listed in a critique; the counts cover the rest.
pub const MAX_ISSUES: usize = 20;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Lesson {
pub id: String,
pub rule: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub why: String,
/// The project (file id) it belongs to; none for every project.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project: Option<String>,
}
fn path(store: &Store) -> PathBuf {
store.root.join(FILE)
}
/// Every stored lesson; none when the file is missing or unreadable.
pub fn load(store: &Store) -> Vec<Lesson> {
crate::vfs::read(path(store)).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
}
/// The lessons that apply in `project` (its own and the global ones).
pub fn applying(store: &Store, project: Option<&str>) -> Vec<Lesson> {
load(store).into_iter().filter(|l| l.project.is_none() || l.project.as_deref() == project).collect()
}
fn save(store: &Store, lessons: &[Lesson]) -> Result<(), String> {
let json = serde_json::to_vec_pretty(lessons).map_err(|e| e.to_string())?;
atomic::write(&path(store), &json).map_err(|e| format!("could not save lessons: {e}"))
}
/// Store a lesson the person approved and return it.
pub fn add(store: &Store, rule: &str, why: &str, project: Option<String>) -> Result<Lesson, String> {
let rule = rule.trim();
if rule.is_empty() {
return Err("a lesson needs a rule".into());
}
if rule.chars().count() > MAX_RULE {
return Err(format!("keep the rule under {MAX_RULE} characters; put the reason in `why`"));
}
let mut all = load(store);
if let Some(same) = all.iter().find(|l| l.rule.eq_ignore_ascii_case(rule) && l.project == project) {
return Ok(same.clone());
}
let next = all.iter().filter_map(|l| l.id.strip_prefix('L')?.parse::<u32>().ok()).max().unwrap_or(0) + 1;
let lesson = Lesson { id: format!("L{next}"), rule: rule.into(), why: why.trim().into(), project };
all.push(lesson.clone());
save(store, &all)?;
Ok(lesson)
}
/// Remove a lesson; false when no lesson has that id.
pub fn remove(store: &Store, id: &str) -> Result<bool, String> {
let mut all = load(store);
let before = all.len();
all.retain(|l| l.id != id);
if all.len() == before {
return Ok(false);
}
save(store, &all)?;
Ok(true)
}
/// A reviewer's point of view: who they are, what they ask, and how they look.
pub struct Persona {
pub id: &'static str,
pub role: &'static str,
pub asks: &'static str,
pub method: &'static str,
}
/// The reviewers, the person's vision first: a critique judges the design against what it is
/// for. Sources: Connor and Irizarry's objective/element/effect/rationale critique, Nielsen's
/// heuristics, severity scale and cognitive walkthrough (NN/g), W3C's POUR principles.
pub const PERSONAS: &[Persona] = &[
Persona {
id: "vision",
role: "The person's vision (product owner)",
asks: "Does each screen achieve the objective, for the audience, as the brief, references and feedback notes describe?",
method: "For each key element: the objective it serves, the element, its effect on that objective, and why. Read the person's notes (get_feedback) and references first; flag anything the brief asked for that is missing.",
},
Persona {
id: "ux",
role: "UX designer",
asks: "Can people understand and use it without help?",
method: "Nielsen's 10 heuristics: system status, real-world language, control and freedom (undo, exit), consistency, error prevention, recognition over recall, efficiency, minimalism, error recovery, help. Cite the heuristic for each finding.",
},
Persona {
id: "flow",
role: "Flow reviewer",
asks: "Do the screens make one coherent journey for each top task?",
method: "Walk each top task across screens (the `flow` graph, else screen order and names): a clear entry, a way forward and a way back or out on every screen, no dead ends or orphan screens, the same navigation and labels in the same place throughout, what is entered on one screen shown on the next, a confirmation, success and error screen for each action, a first-run or empty entry. At each step ask: will they try this, notice the control, connect it to their goal, see progress?",
},
Persona {
id: "qa",
role: "QA tester",
asks: "What breaks with real data and real states?",
method: "A matrix per screen, each item pass, fail or n/a: empty, loading, error, disabled, focus, long text and names, minimum content, zero, one and many items, missing images, every variable mode, the narrow and wide screen sizes. Overflow and clipping from get_bounds or lint. A state with no design is an open question, not a pass.",
},
Persona {
id: "visual",
role: "Visual designer",
asks: "Does the eye go to the right thing first, and does it hold together?",
method: "Scale and hierarchy (one focal point per screen), balance, contrast between roles, gestalt proximity and alignment, a steady spacing rhythm on the 4px grid, the type scale, every colour a variable, consistent radii and components.",
},
Persona {
id: "accessibility",
role: "Accessibility reviewer",
asks: "Can people with low vision, colour blindness, motor or cognitive limits and keyboard or screen-reader users use it?",
method: "W3C POUR for a design. Perceivable: text contrast 4.5:1 (3:1 for large text and for control borders and icons), colour never the only signal, real text, the layout holds at 320px wide and 200% text. Operable: targets 44px (24px minimum), a focus state designed for every control, headings and a visible current location, undo or confirm on destructive actions, an alternative to every gesture. Understandable: fields keep a visible label (not only a placeholder), errors say how to fix, the same navigation and labels across screens. Robust: controls named for their role in layers and code links.",
},
Persona {
id: "copy",
role: "Content designer",
asks: "Is every word clear, specific and kind?",
method: "Plain language in the users' words, specific buttons (verbs, not OK), no placeholder or lorem text, errors that name the problem and the fix, empty states that say what to do next, neutral wording about people whose gender is unknown.",
},
Persona {
id: "end-user",
role: "A user from the audience",
asks: "As this user, doing my top task, where do I get stuck or give up?",
method: "Take the audience given (else ask): their goal, context and constraints (first visit, in a hurry, on a phone, low vision). Walk the top task screen by screen: what you see, what you think, where you hesitate, the first point you would give up.",
},
];
/// Severity of a finding (Nielsen): rate on frequency, impact and persistence.
pub const SEVERITY: &[&str] = &[
"0 not a problem",
"1 cosmetic: fix if time allows",
"2 minor: low priority",
"3 major: fix before handing over",
"4 catastrophe: blocks the task, fix now",
];
pub const FINDING: &str = "persona, heuristic or check, element (layer name), what was seen, its effect on the objective, evidence (the tool result or screenshot region), severity 0-4, suggestion (kept apart from the observation)";
/// What the agent does with a critique's evidence, in order.
pub const STEPS: &[&str] = &[
"Objective: without `objective` (what this must achieve, for whom, the top tasks) ask the person first; a critique without one is opinion.",
"Evidence: fix every lint error and warning above and lint again; get_screenshot each artboard at scale 1 (2 for small text) in every variable mode; read the person's notes (get_feedback) and the `flow` graph.",
"Review each persona independently: with subagents, one per persona given only its brief and the evidence; alone, one pass per persona, writing its findings before starting the next. Never let reviewers see or debate each other's findings before rating.",
"Each persona lists findings in the finding format and at least one thing that works. Check every lesson; a broken lesson is a finding.",
"Merge: one list, duplicates joined, how many personas found each (two or more is confirmed), severity the mean of their independent ratings.",
"Fix every severity 3 and 4 finding with the smallest edit and check it again; the work is ready when none is open and lint is clean.",
"Report to the person: what works, then findings by severity (observation, evidence, suggestion, fixed or open). Phrase open ones as questions about the objective.",
"Ask the person with ask_user which findings to remember (one question per finding: every project, this project, or not). Save only the approved ones with design_lessons {add}: a one-line rule and its reason.",
];
/// The screens of a page and the arrows drawn between them: the flow a critique walks.
pub fn flow(doc: &Document) -> Value {
let mut screens = crate::editor::screens::declared(doc);
if screens.is_empty() {
screens = doc
.root
.iter()
.copied()
.filter(|id| doc.get(*id).is_some_and(|n| n.is_frame() && crate::editor::screens::device_of(n.rect.w).is_some()))
.collect();
}
let screen_of = |id: NodeId| std::iter::once(id).chain(doc.ancestors(id)).find(|a| screens.contains(a));
let mut links: Vec<(NodeId, NodeId)> = doc
.nodes
.values()
.filter_map(|n| match &n.kind {
NodeKind::Connector { start: Some(s), end: Some(e), .. } => Some((screen_of(s.node)?, screen_of(e.node)?)),
_ => None,
})
.filter(|(a, b)| a != b)
.collect();
links.sort_by_key(|(a, b)| (a.0, b.0));
links.dedup();
let name = |id: &NodeId| doc.get(*id).map_or(String::new(), |n| n.name.clone());
let mut v = json!({
"screens": screens.iter().map(name).collect::<Vec<_>>(),
"links": links.iter().map(|(a, b)| [name(a), name(b)]).collect::<Vec<_>>(),
});
if links.is_empty() {
v["note"] = json!("no arrows between screens: walk the flow by screen order and names, and ask the person for the intended flow");
} else {
let from = |id: &NodeId| links.iter().any(|(a, _)| a == id);
let to = |id: &NodeId| links.iter().any(|(_, b)| b == id);
v["deadEnds"] = json!(screens.iter().filter(|s| to(s) && !from(s)).map(name).collect::<Vec<_>>());
v["unreached"] = json!(screens.iter().filter(|s| !to(s) && !from(s)).map(name).collect::<Vec<_>>());
}
v
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lessons_are_kept_per_project_and_removed_by_id() {
let store = Store { root: std::env::temp_dir().join(format!("papercraft-lessons-{:016x}", fastrand::u64(..))) };
std::fs::create_dir_all(&store.root).unwrap();
let all = add(&store, "Screens scroll; they never grow", "", None).unwrap();
let brand = add(&store, "Headlines use the accent colour", "brand book", Some("p1".into())).unwrap();
assert_eq!(add(&store, "screens scroll; they never grow", "", None).unwrap().id, all.id, "a repeat is not stored twice");
assert_eq!(applying(&store, Some("p1")).len(), 2);
assert_eq!(applying(&store, Some("p2")).len(), 1, "a project's lesson stays in it");
assert!(remove(&store, &brand.id).unwrap());
assert!(!remove(&store, &brand.id).unwrap());
assert_eq!(load(&store).len(), 1);
assert!(add(&store, &"x".repeat(MAX_RULE + 1), "", None).is_err());
std::fs::remove_dir_all(&store.root).unwrap();
}
}
+25 -3
View File
@@ -533,7 +533,7 @@ fn set_text_flag(n: &mut Node, f: impl Fn(&mut crate::model::TextSpan)) {
fn set_size(n: &mut Node, axis: usize, v: &str, ctx: &Context, a: &mut Applied, key: &str) {
let fs = n.kind.font_size().unwrap_or(16.0);
let v = v.trim();
let v = if full_viewport(v) { "100%" } else { v.trim() };
let is_text = matches!(n.kind, NodeKind::Text { .. });
match v {
"fit-content" | "auto" | "max-content" | "min-content" => {
@@ -574,6 +574,11 @@ fn set_size(n: &mut Node, axis: usize, v: &str, ctx: &Context, a: &mut Applied,
}
}
/// `100vh` and its kin: the whole screen, which is the frame a page is drawn in.
fn full_viewport(v: &str) -> bool {
matches!(v.trim(), "100vh" | "100dvh" | "100svh" | "100lvh" | "100vw" | "100dvw" | "100svw" | "100lvw")
}
fn resize_axis(n: &mut Node, axis: usize, size: f64) {
let mut r = n.rect;
if axis == 0 {
@@ -1090,7 +1095,8 @@ fn apply_resolved(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> A
None => false,
},
"flexGrow" | "flex" => {
let grow = split_ws(v)
let parts = split_ws(v);
let grow = parts
.first()
.and_then(|g| g.parse::<f64>().ok())
.unwrap_or(0.0);
@@ -1101,6 +1107,12 @@ fn apply_resolved(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> A
} else {
1
};
// `flex: 0 0 120px`: a fixed 120 on the main axis.
if grow == 0.0
&& let Some(basis) = parts.get(2).filter(|b| length(b, fs).is_some())
{
set_size(n, ma, basis, ctx, &mut a, k);
}
if grow > 0.0 {
axis_set(n, ma, Sizing::Fill);
if ma == 0
@@ -1188,7 +1200,11 @@ fn apply_resolved(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> A
},
"minWidth" | "minHeight" => {
let axis = usize::from(k == "minHeight");
if matches!(lower.as_str(), "none" | "auto" | "0") {
if full_viewport(&lower) && ctx.parent_layout.is_some() {
// `min-height: 100vh` on a page shell: fill the screen.
axis_set(n, axis, Sizing::Fill);
true
} else if matches!(lower.as_str(), "none" | "auto" | "0") {
n.layout_child.min_size[axis] = None;
true
} else if let Some(px) = length(v, fs) {
@@ -1220,6 +1236,12 @@ fn apply_resolved(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> A
false
}
}
// A length basis is the size on the parent's main axis (`auto`, `0%` change nothing).
"flexBasis" if ctx.parent_layout.is_some() && length(v, fs).is_some() => {
let horizontal = ctx.parent_layout.is_some_and(|pl| pl.direction == Direction::Horizontal);
set_size(n, if horizontal { 0 } else { 1 }, v, ctx, &mut a, k);
true
}
"flexShrink" | "flexBasis" | "boxSizing" | "zIndex" | "cursor" | "whiteSpace"
| "fontFamily" | "wordBreak" | "overflowWrap" => {
// Accepted without an equivalent; harmless. fontFamily: Inter is the only face.
+13 -6
View File
@@ -34,7 +34,9 @@ pub fn stop(chat: &str) {
/// Runs the helper `command` (`{prompt}` is passed as `$PAPERCRAFT_PROMPT`) for one task.
/// `chat`: the chat that asked, so its Stop button ends the helper too.
pub fn run(command: &str, chat: Option<&str>, args: &serde_json::Value) -> ToolResult {
/// `agent`: the helper's name and the session of the agent that delegated it, passed to its
/// `papercraft-cli` calls so they are attributed to it and its claims.
pub fn run(command: &str, chat: Option<&str>, agent: (&str, &str), args: &serde_json::Value) -> ToolResult {
if command.trim().is_empty() {
return Err("No helper agent is set up. The user can choose one in Settings, Chat, Helper; do the work yourself.".into());
}
@@ -56,6 +58,8 @@ pub fn run(command: &str, chat: Option<&str>, args: &serde_json::Value) -> ToolR
std::os::unix::process::CommandExt::process_group(&mut cmd, 0);
let mut child = cmd
.env("PAPERCRAFT_PROMPT", &prompt)
.env("PAPERCRAFT_AGENT", agent.0)
.env("PAPERCRAFT_PARENT", agent.1)
// An app opened from Finder has a bare PATH: give the helper the user's shell PATH.
.env("PATH", crate::chat::path::resolved_path())
.current_dir(std::env::temp_dir())
@@ -147,16 +151,19 @@ mod tests {
}
// The task tries to run a command; it must arrive as plain text.
let task = "check Home\"; touch pwned; echo \"";
let out = run("printf '%s' {prompt} | tail -c 40", None, &json!({"role": "qa", "task": task})).unwrap();
let out = run("printf '%s' {prompt} | tail -c 40", None, ("Helper", ""), &json!({"role": "qa", "task": task})).unwrap();
let Out::Text(t) = &out[0] else { panic!() };
assert!(t.ends_with(task), "{t}");
assert!(!std::env::temp_dir().join("pwned").exists());
assert!(run("", None, &json!({"role": "qa", "task": "x"})).unwrap_err().contains("Settings"));
assert!(run("echo", None, &json!({"role": "design", "task": "x"})).is_err());
assert!(run("exit 3", None, &json!({"role": "read", "task": "x"})).is_err());
assert!(run("", None, ("Helper", ""), &json!({"role": "qa", "task": "x"})).unwrap_err().contains("Settings"));
assert!(run("echo", None, ("Helper", ""), &json!({"role": "design", "task": "x"})).is_err());
assert!(run("exit 3", None, ("Helper", ""), &json!({"role": "read", "task": "x"})).is_err());
let who = run("printf '%s|%s' \"$PAPERCRAFT_AGENT\" \"$PAPERCRAFT_PARENT\"", None, ("Chat / build 1", "s1"), &json!({"role": "qa", "task": "x"})).unwrap();
let Out::Text(t) = &who[0] else { panic!() };
assert_eq!(t, "Chat / build 1|s1", "the helper's CLI calls carry its identity");
assert_eq!(tail("héllo", 4), "llo");
// Stop ends a running helper, and the process it started, at once.
let t = std::thread::spawn(|| run("sleep 30", Some("chat-stop-test"), &json!({"role": "qa", "task": "x"})));
let t = std::thread::spawn(|| run("sleep 30", Some("chat-stop-test"), ("Helper", ""), &json!({"role": "qa", "task": "x"})));
std::thread::sleep(Duration::from_millis(500));
let begun = Instant::now();
stop("chat-stop-test");
+372 -29
View File
@@ -238,6 +238,27 @@ fn resolve(ed: &Editor, key: &str, default_page: usize) -> Result<(usize, NodeId
Ok((page, id))
}
/// The existing layers a call names (`nodeId`, `nodeIds`, `targetNodeId`, `parentId`, and the
/// same keys inside `updates`, `moves` and `nodes`): what the swarm checks claims and conflicts on.
pub(crate) fn named_layers(ed: &Editor, args: &Value) -> Vec<(usize, NodeId)> {
let Some(m) = args.as_object() else { return Vec::new() };
let def = page_arg(ed, &Args(m)).unwrap_or(ed.active_page);
let nested = ["updates", "moves", "nodes"].into_iter().flat_map(|l| m.get(l).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_object));
let mut keys: Vec<&str> = Vec::new();
for o in std::iter::once(m).chain(nested) {
for k in ["nodeId", "targetNodeId", "parentId"] {
keys.extend(o.get(k).and_then(Value::as_str));
}
if let Some(a) = o.get("nodeIds").and_then(Value::as_array) {
keys.extend(a.iter().filter_map(Value::as_str));
}
}
let mut out: Vec<(usize, NodeId)> = keys.into_iter().filter(|k| *k != ROOT).filter_map(|k| resolve(ed, k, def).ok()).collect();
out.sort();
out.dedup();
out
}
/// Resolve several ids that must all live on one page.
fn resolve_all(
ed: &Editor,
@@ -261,6 +282,11 @@ fn resolve_all(
/// Run one tool. Errors are returned as messages for the agent.
pub fn call(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult {
let r = dispatch(host, name, args);
super::incremental::after_call(host.editor(), name, args, r)
}
fn dispatch(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult {
let empty = Map::new();
let a = Args(args.as_object().unwrap_or(&empty));
match name {
@@ -288,6 +314,7 @@ pub fn call(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult {
// The first content on the page the user is looking at is brought into view.
let was_empty = ed.doc.root.is_empty();
let covered = if GUARDED.contains(&name) { screen_guard(ed, &a)? } else { None };
let boards = super::room::snapshot(ed.page_doc(scope));
let r = match name {
"create_page" => create_page(ed, &a),
"rename_pages" => rename_pages(ed, &a),
@@ -297,6 +324,7 @@ pub fn call(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult {
"get_selection" => selection(ed),
"get_node_info" => node_info(ed, &a),
"get_children" => children(ed, &a),
"get_bounds" => get_bounds(ed, &a),
"get_tree_summary" => tree_summary(ed, &a),
"get_screenshot" => screenshot(host, &a),
"get_jsx" => jsx(host, &a),
@@ -325,7 +353,7 @@ pub fn call(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult {
super::imagegen::generate(host.project().map(|p| p.dir.clone()).as_deref(), None, &provider, &Value::Object(a.0.clone()))
}
#[cfg(not(target_arch = "wasm32"))]
"delegate" => super::delegate::run(&crate::ui::settings::ChatPrefs::load().helper_command, None, &Value::Object(a.0.clone())),
"delegate" => super::delegate::run(&crate::ui::settings::ChatPrefs::load().helper_command, None, ("Helper", ""), &Value::Object(a.0.clone())),
"find_nodes" => find_nodes(ed, &a),
"set_component_props" => set_component_props(ed, &a),
"set_instance_props" => set_instance_props(ed, &a),
@@ -334,6 +362,7 @@ pub fn call(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult {
"set_screens" => set_screens(ed, &a),
"export" => export(host, &a),
"export_combined_pdf" => export_pdf(host, &a),
"export_presentation" => export_presentation(host, &a),
"create_artboard" => create_artboard(ed, &a),
"write_html" => write_html(ed, &a),
"create_shape" => create_shape(ed, &a),
@@ -341,6 +370,7 @@ pub fn call(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult {
"set_text_content" => set_text_content(ed, &a),
"get_variables" => get_variables(ed, &a),
"lint_design" => lint_design(ed, &a),
"get_changes" => super::incremental::get_changes(ed, a.0),
"get_feedback" => get_feedback(ed, &a),
"add_feedback" => add_feedback(ed, &a),
"resolve_feedback" => resolve_feedback(ed, &a),
@@ -363,6 +393,11 @@ pub fn call(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult {
"rename_nodes" => rename_nodes(ed, &a),
"set_code_link" => set_code_link(ed, &a),
"apply_ui_kit" => apply_ui_kit(host, &a),
"critique_design" => critique_design(host, &a),
"claim_nodes" | "release_claims" | "list_claims" => {
Err("claims are shared through the running PaperCraft app; offline there is one writer at a time".into())
}
"design_lessons" => design_lessons(host, &a),
"delete_nodes" => delete_nodes(ed, &a),
"duplicate_nodes" => duplicate_nodes(ed, &a),
"move_nodes" => move_nodes(ed, &a),
@@ -371,7 +406,11 @@ pub fn call(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult {
"undo" | "redo" => undo_redo(ed, &a, name == "undo"),
"finish_working_on_nodes" => {
host.save()?;
text(json!({"ok": true, "saved": host.project().is_some()}))
let mut v = json!({"ok": true, "saved": host.project().is_some()});
if mark_critiqued(host) {
v["next"] = json!("The design changed since its last critique: ask the person (ask_user) whether to critique it now; on yes call critique_design.");
}
text(v)
}
_ => Err(format!("unknown tool `{name}`")),
};
@@ -379,16 +418,36 @@ pub fn call(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult {
if r.is_ok() && was_empty && !ed.doc.root.is_empty() && name != "set_viewport" {
ed.zoom_to_fit();
}
let pushed = if r.is_ok() { make_room(ed, scope, &boards) } else { None };
r.map(|mut out| {
if let Some(v) = covered {
out.push(Out::Text(serde_json::to_string_pretty(&v).unwrap_or_default()));
}
out.extend(pushed.map(Out::Text));
out
})
}
}
}
/// Frames that grew into the ones under them push those down (`room`), as one more undo step;
/// a note for the tool's output names them.
fn make_room(ed: &mut Editor, page: usize, before: &std::collections::HashMap<NodeId, Rect>) -> Option<String> {
let moves = super::room::moves(ed.page_doc(page), before);
if moves.is_empty() {
return None;
}
let names: Vec<String> = moves.iter().filter_map(|(id, _)| ed.page_doc(page).get(*id).map(|n| n.name.clone())).collect();
ed.with_page(page, |ed| {
ed.edit("Make room", |tx| {
for (id, dy) in &moves {
tx.node_mut(*id).unwrap().rect.y += dy;
}
})
});
Some(format!("moved down to clear a frame that grew: {}", names.join(", ")))
}
/// Projects match by manifest id, name or folder path.
fn find_project(store: &Store, key: &str) -> Result<PathBuf, String> {
let key = key.trim().trim_start_matches("/file/");
@@ -515,8 +574,9 @@ fn set_screens(ed: &mut Editor, a: &Args) -> ToolResult {
let role = match a.str("role").unwrap_or("screen") {
"screen" => Role::Screen,
"overlay" => Role::Overlay,
"section" => Role::Section,
"none" => Role::None,
r => return Err(format!("role `{r}` is not screen, overlay or none")),
r => return Err(format!("role `{r}` is not screen, overlay, section or none")),
};
let (page, ids) = resolve_all(ed, &a.strs("nodeIds")?, page_arg(ed, a)?)?;
ed.with_page(page, |ed| ed.set_role(&ids, role))?;
@@ -528,6 +588,9 @@ fn set_screens(ed: &mut Editor, a: &Args) -> ToolResult {
fn screen_json(ed: &Editor, page: usize, id: NodeId) -> Option<Value> {
let doc = ed.page_doc(page);
let n = doc.get(id)?;
if n.section {
return Some(json!({"isSection": true}));
}
if n.screen {
return Some(json!({"isScreen": true, "device": crate::editor::screens::device_of(n.rect.w).unwrap_or("Custom")}));
}
@@ -703,6 +766,10 @@ fn basic_info(host: &mut dyn Host, a: &Args) -> ToolResult {
p.dir.clone(),
)
});
let lessons: Vec<Value> = super::critique::applying(host.store(), project.as_ref().map(|p| p.0.as_str()))
.into_iter()
.map(|l| json!({"id": l.id, "rule": l.rule}))
.collect();
let ed = host.editor();
// Reports every page's node count (also reached right after `open_file`).
ed.finish_loading();
@@ -745,6 +812,8 @@ fn basic_info(host: &mut dyn Host, a: &Args) -> ToolResult {
"page": {"id": ed.pages[page].id, "name": ed.pages[page].name, "isActive": page == ed.active_page},
"pages": pages,
"nodeCount": doc.nodes.len(),
"rev": ed.changes.rev(),
"lessons": lessons,
"artboards": artboards,
"screens": crate::editor::screens::declared(doc).iter().map(|id| {
let n = doc.get(*id).unwrap();
@@ -1086,6 +1155,38 @@ fn children(ed: &mut Editor, a: &Args) -> ToolResult {
text(json!({"children": kids}))
}
fn get_bounds(ed: &mut Editor, a: &Args) -> ToolResult {
let def = page_arg(ed, a)?;
let targets: Vec<(usize, NodeId)> = match a.get("nodeIds") {
Some(_) => a.strs("nodeIds")?.iter().map(|k| resolve(ed, k, def)).collect::<Result<_, _>>()?,
None => ed.page_doc(def).root.iter().map(|id| (def, *id)).collect(),
};
let depth = a.num("depth").filter(|d| *d >= 1.0).map(|d| d as usize);
let mut rows: Vec<Value> = Vec::new();
let mut pending: Vec<String> = Vec::new();
for (page, id) in targets {
let room = super::bounds::MAX_ROWS - rows.len();
if room == 0 {
pending.push(node_key(ed, page, id));
continue;
}
let key = |n: NodeId| node_key(ed, page, n);
let got = super::bounds::collect(ed.page_doc(page), &[id], depth, room, &key);
rows.extend(got.rows);
pending.extend(got.pending.into_iter().map(key));
}
let mut out = json!({"columns": super::bounds::COLUMNS, "rows": rows});
if !pending.is_empty() {
out["truncated"] = json!(true);
out["continueWith"] = json!(pending);
out["note"] = json!(format!(
"Stopped at {} rows. Call get_bounds again with nodeIds = continueWith (same depth) for the rest.",
super::bounds::MAX_ROWS
));
}
text(out)
}
/// Text shown per text layer in `get_tree_summary` unless `fullText` is set.
const TREE_TEXT_CHARS: usize = 40;
@@ -1220,7 +1321,7 @@ fn screenshot(host: &mut dyn Host, a: &Args) -> ToolResult {
let (page, roots) = match a.str("nodeId").filter(|k| !k.is_empty() && *k != ROOT) {
Some(k) => {
let (p, id) = resolve(ed, k, def)?;
(p, vec![id])
(p, crate::io::export::with_layers_on_top(ed.page_doc(p), &[id]))
}
None => (def, ed.page_doc(def).root.clone()),
};
@@ -2419,6 +2520,49 @@ fn export_pdf(_: &mut dyn Host, _: &Args) -> ToolResult {
Err(NO_DISK.into())
}
#[cfg(target_arch = "wasm32")]
fn export_presentation(_: &mut dyn Host, _: &Args) -> ToolResult {
Err(NO_DISK.into())
}
/// A presentation deck of pages (default all): cover, contents, a divider per page, a slide
/// per screen, closing slide (`io::deck`).
#[cfg(not(target_arch = "wasm32"))]
fn export_presentation(host: &mut dyn Host, a: &Args) -> ToolResult {
let project_name = host.project().map(|p| p.manifest.name.clone());
let ed = host.editor();
ed.finish_loading();
let pages: Vec<usize> = match a.strs("pageIds") {
Ok(keys) if !keys.is_empty() => keys.iter().map(|k| find_page(ed, k)).collect::<Result<_, _>>()?,
_ => (0..ed.pages.len()).collect(),
};
let sections: Vec<crate::io::deck::Section> = pages
.iter()
.map(|&p| crate::io::deck::Section { name: ed.pages[p].name.clone(), doc: ed.page_doc(p), screens: crate::io::deck::screens_of(ed.page_doc(p)) })
.collect();
let screens: usize = sections.iter().map(|s| s.screens.len()).sum();
if screens == 0 {
return Err("no screens to present: these pages have no declared screens and no top-level frames of 300 × 300 or more".into());
}
let title = a.str("title").map(str::to_owned).or(project_name).unwrap_or_else(|| "Presentation".into());
let meta = crate::io::deck::Meta {
title: title.clone(),
subtitle: a.str("subtitle").map(str::to_owned),
author: a.str("author").map(str::to_owned),
now_ms: crate::project::format::now_ms(),
};
let path = match a.str("path") {
Some(p) => PathBuf::from(p),
None => {
let dir = out_dir(a);
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
unique_path(&dir, &title, "pdf")
}
};
crate::io::atomic::write(&path, &crate::io::deck::build(&meta, &sections)).map_err(|e| e.to_string())?;
text(json!({"file": path, "screens": screens, "pages": sections.iter().filter(|s| !s.screens.is_empty()).count()}))
}
#[cfg(not(target_arch = "wasm32"))]
fn export(host: &mut dyn Host, a: &Args) -> ToolResult {
use crate::io::export::{Format, Item, Scope, Spec};
@@ -2935,6 +3079,10 @@ impl Builder<'_, '_> {
/// Positioning that needs the final size (right/bottom).
fn place(&mut self, id: NodeId, a: &css::Applied, parent: &Parent) {
if let (Some(ps), true) = (parent.size, a.right.is_some() || a.bottom.is_some()) {
// An absolute box is not laid out with its parent: hug its content before measuring.
if self.tx.doc().get(id).is_some_and(|n| n.layout.is_some()) {
crate::layout::layout_frame(self.tx, id);
}
let n = self.tx.node_mut(id).unwrap();
if let Some(r) = a.right {
n.rect.x = ps.0 - n.rect.w - r;
@@ -2952,7 +3100,23 @@ impl Builder<'_, '_> {
let explicit = get(styles, if ca == 0 { "width" } else { "height" }).is_some()
|| get(styles, "alignSelf").is_some_and(|v| v != "stretch")
|| matches!(get(styles, "position"), Some("absolute" | "fixed"));
if parent.stretch && parent.definite[ca] && !explicit {
// A text in a column that centres or aligns its children still wraps at the column's
// width, as a browser's block does: fill it and align its lines instead.
let text_in_column = ca == 0 && matches!(n.kind, NodeKind::Text { .. });
if text_in_column
&& !parent.stretch
&& parent.definite[0]
&& !explicit
&& let (Some(l), NodeKind::Text { align, .. }) = (parent.layout, &mut n.kind)
&& get(styles, "textAlign").is_none()
{
*align = match l.align_cross {
crate::model::Align::Center => TextAlign::Center,
crate::model::Align::End => TextAlign::Right,
_ => TextAlign::Left,
};
}
if (parent.stretch || text_in_column) && parent.definite[ca] && !explicit {
n.layout_child.sizing[ca] = Sizing::Fill;
if ca == 0
&& let NodeKind::Text { auto_width, .. } = &mut n.kind
@@ -3303,9 +3467,14 @@ impl Builder<'_, '_> {
index: &mut Option<usize>,
) -> Option<NodeId> {
let raw = el.raw.clone().unwrap_or_default();
// `color: var(--on-accent)` on the <svg> is what its currentColor strokes take, bound to
// that variable so a theme change reaches them.
let color_var = get(own, "color")
.or_else(|| get(inherited, "color"))
.and_then(|c| c.trim().strip_prefix("var(--")?.split([')', ',']).next().map(|v| v.trim().to_owned()));
let color = get(own, "color")
.or_else(|| get(inherited, "color"))
.and_then(css::color)
.and_then(|c| css::color(&css::substitute_vars(c).0))
.unwrap_or(Color::BLACK);
let fs = 16.0;
let size_of = |k: &str| {
@@ -3390,6 +3559,14 @@ impl Builder<'_, '_> {
for mut s in shapes {
s.id = self.tx.alloc_id();
s.children.clear();
if let Some(var) = &color_var {
if s.style.stroke == Some(color) {
s.bindings.insert("stroke".into(), var.clone());
}
for p in s.style.fills.iter_mut().filter(|p| matches!(p, Paint::Solid { color: c, .. } if *c == color)) {
*p = Paint::solid_var(color, var);
}
}
self.tx.insert(s, Some(fid), None);
}
self.place(fid, &a, parent);
@@ -4066,7 +4243,7 @@ fn create_artboard(ed: &mut Editor, a: &Args) -> ToolResult {
return Err("styles.width and styles.height are required (e.g. \"1440px\")".into());
}
let page_id = ed.pages[page].id.clone();
let (id, ignored) = ed.with_page(page, |ed| {
let (id, ignored, device) = ed.with_page(page, |ed| {
ed.edit("Create artboard", |tx| {
let mut n = Node::new(
NodeId(0),
@@ -4084,6 +4261,14 @@ fn create_artboard(ed: &mut Editor, a: &Args) -> ToolResult {
});
let a = css::apply(&mut n, &styles, &Context::default());
let mut ignored = a.ignored.clone();
// A screen keeps its device's height and what does not fit scrolls (`screen_fit`);
// `overflow: visible` keeps a hugging board (a long landing page drawn in full).
let device = super::screen_fit::device_height(n.rect.w)
.filter(|_| n.layout_child.sizing[1] == Sizing::Hug && get(&styles, "overflow").is_none_or(|o| o != "visible"));
if let Some(h) = device {
n.layout_child.sizing[1] = Sizing::Fixed;
n.rect.h = h;
}
if let Some((src, mode)) = &a.image {
match image_asset(src) {
Ok(asset) => n.style.fills.push(Paint::Image {
@@ -4102,10 +4287,15 @@ fn create_artboard(ed: &mut Editor, a: &Args) -> ToolResult {
let id = tx.insert(n, None, None);
remember_stretch(&page_id, id, &styles);
relayout(tx, Some(id));
(id, ignored)
(id, ignored, device)
})
});
let mut v = json!({"id": node_key(ed, page, id), "name": name, "pageId": ed.pages[page].id});
if let Some(h) = device {
v["note"] = json!(format!(
"height set to the device's {h}px; content taller than the screen scrolls inside it (the main column, else the screen). Pass overflow: \"visible\" to let the board grow instead."
));
}
if !ignored.is_empty() {
v["ignoredStyles"] = json!(ignored);
}
@@ -4141,7 +4331,8 @@ fn write_html(ed: &mut Editor, a: &Args) -> ToolResult {
{
return Err("insert-children needs a frame or group target; use mode \"replace\" or target its parent".into());
}
let tree = html::parse(src);
let mut tree = html::parse(src);
let rewritten = super::rewrite::rewrite(&mut tree);
#[cfg(not(target_arch = "wasm32"))]
crate::plugins::prefetch(&tree, &crate::ui::settings::ChatPrefs::load().plugins);
let page_id = ed.pages[page].id.clone();
@@ -4216,10 +4407,17 @@ fn write_html(ed: &mut Editor, a: &Args) -> ToolResult {
}
}
relayout(b.tx, parent_id.or(made.first().copied()));
let (ig, w) = (b.ignored, b.warnings);
let (ig, mut w) = (b.ignored, b.warnings);
if let Some(screen) = parent_id.or(made.first().copied()).and_then(|id| artboard_of(b.tx.doc(), id)) {
let scrolled = super::screen_fit::fit(b.tx, screen, relayout, &|n| stretches(&page_id, n));
if !scrolled.is_empty() {
w.push(format!("content is taller than the screen, so it scrolls in: {} (the rest stays fixed)", scrolled.join(", ")));
}
}
(made, ig, w)
})
});
let warnings: Vec<String> = rewritten.into_iter().chain(warnings).collect();
if created.is_empty() && !warnings.is_empty() {
return Err(warnings.join("; "));
}
@@ -5012,18 +5210,35 @@ fn lint_design(ed: &mut Editor, a: &Args) -> ToolResult {
Some(v) => v.iter().filter_map(Value::as_f64).collect(),
None => super::lint::DEFAULT_SCALE.to_vec(),
};
let whole_page = a.str("nodeId").is_none_or(|n| n.is_empty() || n == "root");
let since = a.num("since").filter(|s| *s >= 0.0).map(|s| s as u64);
let scope = super::incremental::lint_scope(ed, page, &roots, since, a.bool("changedOnly").unwrap_or(false));
let total: usize = roots.iter().map(|r| ed.page_doc(page).subtree(*r).len()).sum();
let fixed = if a.bool("fix").unwrap_or(false) {
ed.with_page(page, |ed| {
ed.edit("Auto-fix design lint", |tx| {
super::lint::autofix(tx, &roots, &scale)
ed.edit("Auto-fix design lint", |tx| match &scope.ids {
None => super::lint::autofix(tx, &roots, &scale),
Some(ids) => {
let mut ids: Vec<_> = ids.iter().copied().collect();
ids.sort();
ids.into_iter().filter(|id| super::lint::autofix_node(tx, *id, &scale)).count()
}
})
})
} else {
0
};
// The next `changedOnly` lint starts from here. A subtree lint leaves the rest unchecked.
if whole_page {
let page_id = ed.pages[page].id.clone();
ed.changes.note_lint(&page_id);
}
let doc = ed.page_doc(page);
let mut issues = super::lint::lint(doc, &roots, &scale);
issues.extend(super::lint::not_an_instance(doc, &roots, &super::lint::component_mains(ed)));
if let Some(ids) = &scope.ids {
issues.retain(|i| ids.contains(&i.node));
}
// Layers hidden behind a drawer or modal in their screen are not fix-me findings: nobody sees them.
let before = issues.len();
issues.retain(|i| !crate::editor::screens::coverage(doc, i.node).is_some_and(|c| c.is_hidden()));
@@ -5042,9 +5257,13 @@ fn lint_design(ed: &mut Editor, a: &Args) -> ToolResult {
"name": doc.get(i.node).map(|n| n.name.clone()), "message": i.message, "fix": i.fix})
})
.collect();
let checked = scope.ids.as_ref().map_or(total, |ids| ids.len());
text(json!({
"errors": count("error"), "warnings": count("warning"), "info": count("info"),
"fixedNodes": fixed,
"rev": ed.changes.rev(),
"covered": {"checked": checked, "total": total, "since": scope.since,
"partial": scope.ids.is_some(), "fullPassBecause": scope.fallback},
"byRule": by_rule, "issues": list,
"truncated": issues.len() > 200,
"skippedHidden": skipped_hidden,
@@ -6445,6 +6664,90 @@ fn call_json(host: &mut dyn Host, name: &str, args: Value) -> Result<Value, Stri
serde_json::from_str(t).map_err(|e| format!("{name}: {e}"))
}
/// The revision each project was last critiqued or offered a critique at, so a finish with no
/// edits since does not offer it again.
fn critiqued() -> &'static Mutex<HashMap<String, u64>> {
static S: OnceLock<Mutex<HashMap<String, u64>>> = OnceLock::new();
S.get_or_init(Default::default)
}
/// Records the current revision; true when it differs from the last critiqued or offered one.
fn mark_critiqued(host: &mut dyn Host) -> bool {
let key = host.project().map(|p| p.manifest.id.clone()).unwrap_or_default();
let rev = host.editor().changes.rev();
critiqued().lock().unwrap().insert(key, rev) != Some(rev)
}
/// What a critique judges by (`critique`): the objective, the reviewers, the lint, the flow
/// between the page's screens, the person's lessons that apply here and the steps, ending with
/// the offer to keep findings as lessons.
fn critique_design(host: &mut dyn Host, a: &Args) -> ToolResult {
use super::critique::{FINDING, MAX_ISSUES, PERSONAS, SEVERITY, STEPS};
let wanted = match a.get("personas") {
Some(_) => a.strs("personas")?,
None => PERSONAS.iter().map(|p| p.id.to_string()).collect(),
};
if let Some(bad) = wanted.iter().find(|w| !PERSONAS.iter().any(|p| p.id == w.as_str())) {
let all: Vec<&str> = PERSONAS.iter().map(|p| p.id).collect();
return Err(format!("no persona `{bad}` (personas: {})", all.join(", ")));
}
let mut args = json!({});
for k in ["pageId", "nodeId"] {
if let Some(v) = a.str(k) {
args[k] = json!(v);
}
}
let lint = call_json(host, "lint_design", args)?;
let project = host.project().map(|p| p.manifest.id.clone());
let lessons = super::critique::applying(host.store(), project.as_deref());
mark_critiqued(host);
let ed = host.editor();
let page = page_arg(ed, a)?;
let flow = super::critique::flow(ed.page_doc(page));
let issues: Vec<Value> = lint["issues"].as_array().map(|i| i.iter().take(MAX_ISSUES).cloned().collect()).unwrap_or_default();
let personas: Vec<Value> = PERSONAS
.iter()
.filter(|p| wanted.iter().any(|w| w == p.id))
.map(|p| json!({"id": p.id, "role": p.role, "asks": p.asks, "method": p.method}))
.collect();
text(json!({
"objective": a.str("objective").unwrap_or("missing: ask the person what this must achieve, for whom, and the top tasks"),
"audience": a.str("audience").unwrap_or("missing: take it from the objective or ask"),
"personas": personas,
"findingFormat": FINDING,
"severity": SEVERITY,
"lint": {"errors": lint["errors"], "warnings": lint["warnings"], "issues": issues},
"flow": flow,
"lessons": lessons,
"steps": STEPS,
}))
}
/// List, add or remove the person's design lessons.
fn design_lessons(host: &mut dyn Host, a: &Args) -> ToolResult {
let project = host.project().map(|p| p.manifest.id.clone());
let store = host.store();
let mut v = json!({});
if let Some(add) = a.get("add") {
let add = add.as_object().ok_or("`add` is {rule, why, scope}")?;
let s = |k: &str| add.get(k).and_then(Value::as_str).unwrap_or("");
let scope = match s("scope") {
"" | "all" => None,
"project" => Some(project.clone().ok_or("no project is open to keep a project lesson in")?),
o => return Err(format!("scope is \"all\" or \"project\", not `{o}`")),
};
v["added"] = json!(super::critique::add(store, s("rule"), s("why"), scope)?);
}
if let Some(id) = a.str("remove") {
if !super::critique::remove(store, id)? {
return Err(format!("no lesson `{id}`"));
}
v["removed"] = json!(id);
}
v["lessons"] = json!(super::critique::applying(store, project.as_deref()));
text(v)
}
/// Build a "UI Kit / <name>" page from a kit (`crate::ui_kits`): its variables (Light and Dark),
/// tokens, and its components, each a main component linked to the library's code.
fn apply_ui_kit(host: &mut dyn Host, a: &Args) -> ToolResult {
@@ -6459,24 +6762,42 @@ fn apply_ui_kit(host: &mut dyn Host, a: &Args) -> ToolResult {
call_json(host, "set_variables", json!({"pageId": pid, "variables": map(kit.light), "mode": "Light"}))?;
call_json(host, "set_variables", json!({"pageId": pid, "variables": map(kit.dark), "mode": "Dark"}))?;
call_json(host, "set_tokens", json!({"pageId": pid, "tokens": map(kit.tokens)}))?;
let body: String = kit.components.iter().map(|c| c.html).collect();
let html = format!(
r#"<div layer-name="UI Kit / {}" style="display:flex;flex-wrap:wrap;gap:32px;align-items:flex-start;padding:48px;width:1200px;background:var(--{})">{body}</div>"#,
r#"<div layer-name="UI Kit / {}" style="display:flex;flex-wrap:wrap;gap:32px;align-items:flex-start;padding:48px;width:1200px;background:var(--{})"></div>"#,
kit.name, kit.surface
);
let written = call_json(host, "write_html", json!({"pageId": pid, "targetNodeId": "root", "html": html}))?;
let board = written["createdNodeIds"][0].as_str().ok_or("write_html made no frame")?.to_owned();
let kids = call_json(host, "get_children", json!({"pageId": pid, "nodeId": board}))?;
let mut made = Vec::new();
for c in kit.components {
let Some(node) = kids["children"].as_array().into_iter().flatten().find(|k| k["name"] == c.name).and_then(|k| k["id"].as_str()) else { continue };
let comp = call_json(host, "create_component", json!({"pageId": pid, "nodeId": node}))?;
let cid = comp["id"].as_str().unwrap_or(node).to_owned();
call_json(host, "set_code_link", json!({"pageId": pid, "nodeId": cid, "kit": kit.id, "import": c.import, "react": c.react, "egui": c.egui}))?;
made.push(json!({"name": c.name, "id": cid}));
// Components built from other components (a dialog's buttons) come once those are mains.
let (plain, composed): (Vec<_>, Vec<_>) = kit.components.iter().partition(|c| !c.html.contains("<x-paper-instance"));
for pass in [plain, composed] {
let body: String = pass.iter().map(|c| c.html).collect();
let w = call_json(host, "write_html", json!({"pageId": pid, "targetNodeId": board, "html": body}))?;
let ids = w["createdNodeIds"].as_array().cloned().unwrap_or_default();
for (c, node) in pass.iter().zip(ids.iter().filter_map(|i| i.as_str())) {
let comp = call_json(host, "create_component", json!({"pageId": pid, "nodeId": node}))?;
let cid = comp["id"].as_str().unwrap_or(node).to_owned();
let (import, react) = match kit.code {
[] => (c.import, c.react),
code => code.iter().find(|(n, ..)| *n == c.name).map_or(("", ""), |(_, i, r)| (*i, *r)),
};
call_json(host, "set_code_link", json!({"pageId": pid, "nodeId": cid, "kit": kit.id, "import": import, "react": react, "egui": c.egui}))?;
made.push(json!({"name": c.name, "id": cid}));
}
}
// Screen layouts from the components, in a row under the kit's board.
let below = call_json(host, "get_node_info", json!({"pageId": pid, "nodeId": board}))?;
let top = below["y"].as_f64().unwrap_or(0.0) + below["height"].as_f64().unwrap_or(0.0) + 160.0;
let mut layouts = Vec::new();
for (i, (name, html)) in kit.layouts.iter().enumerate() {
let w = call_json(host, "write_html", json!({"pageId": pid, "targetNodeId": "root", "html": html}))?;
let Some(id) = w["createdNodeIds"][0].as_str() else { continue };
call_json(host, "update_styles", json!({"pageId": pid, "updates": [{"nodeIds": [id], "styles": {"left": i as f64 * 1600.0, "top": top}}]}))?;
layouts.push(json!({"name": name, "id": id}));
}
text(json!({
"pageId": pid, "frame": board, "kit": kit.id, "components": made,
"pageId": pid, "frame": board, "kit": kit.id, "components": made, "layouts": layouts,
"next": "Place these with <x-paper-instance component=\"Name\" overrides='{\"Label\":\"Text\"}'/>; the code export and the AI spec use the library components for them. Use the kit's variables (var(--primary) and so on) for everything else."
}))
}
@@ -6894,16 +7215,38 @@ fn set_viewport(ed: &mut Editor, a: &Args) -> ToolResult {
fn undo_redo(ed: &mut Editor, a: &Args, undo: bool) -> ToolResult {
let page = page_arg(ed, a)?;
let label = ed.with_page(page, |ed| {
let me = ed.changes.client.as_ref().map(|w| w.key.clone());
// An agent takes back its own steps unless it asks for the page's; the person's
// undo is always the page's.
let mine = match (a.get("scope").and_then(Value::as_str), &me) {
(Some("page"), _) | (_, None) => None,
(Some("mine") | None, Some(me)) => Some(me.clone()),
(Some(other), _) => return Err(format!("scope must be \"mine\" or \"page\", not \"{other}\"")),
};
let label: Result<Option<String>, Vec<String>> = ed.with_page(page, |ed| {
let before = ed.history.generation();
if undo {
ed.undo();
} else {
ed.redo();
match (&mine, undo) {
(None, true) => ed.undo(),
(None, false) => ed.redo(),
(Some(me), true) => ed.undo_by(me)?,
// Only the caller's own undone step comes back; someone else's stays undone.
(Some(me), false) => {
if ed.history.redo_author() == Some(me.as_str()) {
ed.redo();
}
}
}
(ed.history.generation() != before).then(|| ed.status.clone())
Ok((ed.history.generation() != before).then(|| ed.status.clone()))
});
text(json!({"done": label.is_some(), "what": label}))
match label {
Ok(label) => text(json!({"done": label.is_some(), "what": label})),
Err(blocked) => text(json!({
"done": false,
"reason": "undo_blocked",
"blockedBy": blocked,
"hint": "later steps by others touched the same layers; fix it forward, or pass scope \"page\" to undo the page's last step",
})),
}
}
#[cfg(test)]
+231
View File
@@ -0,0 +1,231 @@
//! Incremental lint: `get_changes`, the `rev` every write tool reports, and the scope
//! `lint_design {since, changedOnly}` checks. The log itself is `editor::changes`.
use super::exec::{find_page, node_key};
use super::{Out, ToolResult, tools};
use crate::design_system::sanitize_name;
use crate::editor::Editor;
use crate::editor::changes::Filter;
use crate::model::{Document, NodeId};
use serde_json::{Map, Value, json};
use std::collections::{BTreeSet, HashSet};
/// What a lint pass covers.
#[derive(Debug, Clone, PartialEq)]
pub struct Scope {
/// The layers to check, or None for all of them.
pub ids: Option<HashSet<NodeId>>,
/// The revision the scope was computed from.
pub since: Option<u64>,
/// Why a `since` or `changedOnly` request was answered with a full pass.
pub fallback: Option<&'static str>,
}
impl Scope {
fn full(fallback: Option<&'static str>) -> Scope {
Scope { ids: None, since: None, fallback }
}
}
/// The layers a change to `changed` can affect: themselves, their ancestors (overflow, layout),
/// descendants, and siblings (spacing, reading order). Layers that no longer exist are skipped;
/// their parent is in the log too, because deleting a child edits its parent's list.
pub fn expand(doc: &Document, changed: &[NodeId]) -> HashSet<NodeId> {
let mut out = HashSet::new();
for id in changed.iter().filter(|id| doc.get(**id).is_some()) {
out.insert(*id);
out.extend(doc.ancestors(*id));
out.extend(doc.subtree(*id));
let parent = doc.get(*id).and_then(|n| n.parent);
out.extend(doc.children(parent).iter().copied());
}
out
}
/// The scope for linting `roots` of page `page`: everything, or what changed since `since`
/// (or since the running client's last whole-page lint when `changed_only`).
pub fn lint_scope(ed: &Editor, page: usize, roots: &[NodeId], since: Option<u64>, changed_only: bool) -> Scope {
let page_id = &ed.pages[page].id;
let since = match (since, changed_only) {
(Some(s), _) => s,
(None, true) => match ed.changes.last_lint(page_id) {
Some(s) => s,
None => return Scope::full(Some("no earlier lint by this client")),
},
(None, false) => return Scope::full(None),
};
let log = ed.changes.since(since, Some(page_id), Filter::All);
if log.truncated {
return Scope::full(Some("change log no longer reaches that revision"));
}
let doc = ed.page_doc(page);
let changed: Vec<NodeId> = log.changes.iter().flat_map(|g| g.ids.iter().copied()).collect();
let inside: HashSet<NodeId> = roots.iter().flat_map(|r| doc.subtree(*r)).collect();
let ids = expand(doc, &changed).into_iter().filter(|id| inside.contains(id)).collect();
Scope { ids: Some(ids), since: Some(since), fallback: None }
}
/// Which variables a variable tool edits: Some(names) in CSS form, None for "any".
fn variable_names(name: &str, args: &Value) -> Option<Option<BTreeSet<String>>> {
let keys = |field: &str| -> Option<BTreeSet<String>> {
let o = args.get(field)?;
let names: BTreeSet<String> = match o {
Value::Object(m) => m.keys().map(|k| sanitize_name(k)).collect(),
Value::Array(a) => a.iter().filter_map(Value::as_str).map(sanitize_name).collect(),
_ => return None,
};
(!names.is_empty()).then_some(names)
};
match name {
"set_variables" => Some(keys("variables")),
"set_tokens" => Some(keys("tokens")),
"delete_variables" => Some(keys("names")),
"set_variable_mode" | "import_design_system" => Some(None),
_ => None,
}
}
/// Post-processing of every tool call: variable edits count as touching the layers bound to
/// them, and write tools report the revision they left.
pub fn after_call(ed: &mut Editor, name: &str, args: &Value, r: ToolResult) -> ToolResult {
let mut out = r?;
if let Some(names) = variable_names(name, args) {
ed.log_variable_users(names.as_ref());
}
if tools::edits_doc(name, args) {
add_rev(&mut out, ed.changes.rev());
}
Ok(out)
}
/// Puts `rev` into the result's JSON object, or appends a `{"rev"}` result when the tool
/// answered with something else.
fn add_rev(out: &mut Vec<Out>, rev: u64) {
if let Some(Out::Text(t)) = out.first_mut()
&& let Ok(Value::Object(mut m)) = serde_json::from_str::<Value>(t)
{
m.insert("rev".into(), json!(rev));
*t = serde_json::to_string_pretty(&Value::Object(m)).unwrap_or_default();
return;
}
out.push(Out::Text(json!({"rev": rev}).to_string()));
}
/// `get_changes`.
pub fn get_changes(ed: &mut Editor, args: &Map<String, Value>) -> ToolResult {
let since = args
.get("since")
.and_then(Value::as_f64)
.filter(|s| *s >= 0.0)
.ok_or("`since` must be a revision number (the `rev` of an earlier result)")? as u64;
let page = match args.get("page").and_then(Value::as_str).filter(|p| !p.is_empty()) {
Some(p) => Some(find_page(ed, p)?),
None => None,
};
let me = ed.changes.client.clone();
let by = args.get("by").and_then(Value::as_str).filter(|b| !b.is_empty());
let filter = match (args.get("exclude").and_then(Value::as_str), by) {
(Some("self"), _) => match &me {
Some(w) => Filter::Exclude(&w.key),
None => return Err("`exclude: \"self\"` needs to know which client is asking; this connection is anonymous".into()),
},
(Some(other), _) => return Err(format!("`exclude` must be \"self\", not `{other}`")),
(None, Some(b)) => Filter::Only(b),
(None, None) => Filter::All,
};
let page_id = page.map(|p| ed.pages[p].id.clone());
let log = ed.changes.since(since, page_id.as_deref(), filter);
let changes: Vec<Value> = log
.changes
.iter()
.filter_map(|g| {
let p = ed.pages.iter().position(|p| p.id == g.page)?;
let ids: Vec<String> = g.ids.iter().map(|id| node_key(ed, p, *id)).collect();
Some(json!({"page": g.page, "ids": ids, "by": g.by, "rev": g.rev}))
})
.collect();
Ok(vec![Out::Text(
serde_json::to_string_pretty(&json!({
"rev": ed.changes.rev(), "since": since, "truncated": log.truncated, "changes": changes,
}))
.unwrap_or_default(),
)])
}
#[cfg(test)]
mod tests {
use super::*;
use crate::geom::Rect;
use crate::model::{Node, NodeKind};
/// root frame 1 > [frame 2 > [3, 4], frame 5 > [6]]
fn tree() -> Document {
let mut doc = Document::default();
let mut add = |parent: Option<NodeId>| {
let id = doc.alloc_id();
let mut n = Node::new(id, NodeKind::Frame { clip: false }, Rect::new(0.0, 0.0, 10.0, 10.0));
n.parent = parent;
doc.nodes.insert(id, n);
match parent {
Some(p) => doc.nodes.get_mut(&p).unwrap().children.push(id),
None => doc.root.push(id),
}
id
};
let r = add(None);
let a = add(Some(r));
add(Some(a));
add(Some(a));
let b = add(Some(r));
add(Some(b));
doc
}
fn set(v: &[u64]) -> HashSet<NodeId> {
v.iter().map(|n| NodeId(*n)).collect()
}
#[test]
fn a_change_reaches_ancestors_descendants_and_siblings() {
let doc = tree();
// Layer 2: itself, root 1, children 3 and 4, sibling 5. Not 5's child 6.
assert_eq!(expand(&doc, &[NodeId(2)]), set(&[1, 2, 3, 4, 5]));
// Layer 3: its parent and root, its sibling 4; not the uncle's subtree.
assert_eq!(expand(&doc, &[NodeId(3)]), set(&[1, 2, 3, 4]));
}
#[test]
fn a_top_level_layer_reaches_the_other_top_level_layers() {
let mut doc = tree();
let id = doc.alloc_id();
doc.nodes.insert(id, Node::new(id, NodeKind::Frame { clip: false }, Rect::new(0.0, 0.0, 1.0, 1.0)));
doc.root.push(id);
assert_eq!(expand(&doc, &[id]), set(&[1, 7]));
}
#[test]
fn deleted_layers_are_skipped() {
assert!(expand(&tree(), &[NodeId(99)]).is_empty());
}
#[test]
fn variable_tools_name_the_variables_they_edit() {
let some = |v: &[&str]| Some(Some(v.iter().map(|s| s.to_string()).collect::<BTreeSet<_>>()));
assert_eq!(variable_names("set_variables", &json!({"variables": {"accent": "#fff", "--bg": "#000"}})), some(&["accent", "bg"]));
assert_eq!(variable_names("delete_variables", &json!({"names": ["accent"]})), some(&["accent"]));
assert_eq!(variable_names("set_variable_mode", &json!({"mode": "Dark"})), Some(None));
assert_eq!(variable_names("set_variables", &json!({})), Some(None), "unreadable arguments mean any variable");
assert_eq!(variable_names("update_styles", &json!({})), None);
}
#[test]
fn rev_goes_into_a_json_object_or_a_result_of_its_own() {
let mut out = vec![Out::Text("{\"ok\": true}".into())];
add_rev(&mut out, 7);
let Out::Text(t) = &out[0] else { panic!() };
assert_eq!(serde_json::from_str::<Value>(t).unwrap(), json!({"ok": true, "rev": 7}));
let mut out = vec![Out::Text("done".into())];
add_rev(&mut out, 8);
assert_eq!(out.len(), 2);
}
}
+27
View File
@@ -189,6 +189,18 @@ pub fn lint(doc: &Document, roots: &[NodeId], scale: &[f64]) -> Vec<Issue> {
);
}
}
if let Some(l) = crate::text::layout_node(n)
&& let Some(t) = n.kind.text()
// More lines than words and line breaks: some word is split over lines.
&& l.lines.len() > t.split_whitespace().count().max(1) + t.matches('\n').count()
{
add(
"text-collapsed",
"error",
format!("`{}` is {:.0}px wide, so it breaks inside words ({} lines)", n.name, n.rect.w, l.lines.len()),
"give it room: width fit-content, or flex-grow in a parent with a set width".into(),
);
}
if size < 12.0 {
add(
"text-size",
@@ -298,6 +310,8 @@ pub fn lint(doc: &Document, roots: &[NodeId], scale: &[f64]) -> Vec<Issue> {
});
}
out.extend(theme_copies(doc, roots));
let all: Vec<NodeId> = roots.iter().flat_map(|r| doc.subtree(*r)).collect();
out.extend(super::lint_overflow::overflow(doc, &all));
out
}
@@ -592,6 +606,19 @@ mod tests {
assert!(is_default_name("Frame 12") && !is_default_name("Task Row"));
}
#[test]
fn a_text_too_narrow_for_its_words_is_an_error() {
let mut doc = Document::default();
let id = doc.alloc_id();
let kind = crate::model::NodeKind::Text { text: "Two Sum".into(), font_size: 14.0, auto_width: false, align: Default::default() };
doc.nodes.insert(id, Node::new(id, kind, crate::geom::Rect::new(0.0, 0.0, 0.0, 116.0)));
doc.root.push(id);
let collapsed = |d: &Document| lint(d, &[id], &[]).iter().any(|i| i.rule == "text-collapsed");
assert!(collapsed(&doc));
doc.nodes.get_mut(&id).unwrap().rect.w = 32.0; // one word a line is wrapping, not collapsing
assert!(!collapsed(&doc));
}
#[test]
fn text_colour_binding_is_not_hard_coded() {
let mut doc = Document::default();
+261
View File
@@ -0,0 +1,261 @@
//! The `overflow` lint rule: layers that stick out of the artboard or clipping frame they sit in,
//! and text taller than its box.
//!
//! A pure function over a document and a set of node ids, so a caller can scope it to any subset
//! (a subtree, the layers changed since a revision). It reads each layer's stored rect (auto
//! layout has already placed it) and the text height from the same layout the renderer uses.
//!
//! Not flagged: content of a `scroll` frame (it clips on purpose and the overflow is what
//! scrolls), and layers pinned inside one. Layers hidden themselves or by an ancestor are skipped.
//! `lint_design {fix}` does not repair overflow: whether to shrink, wrap, move or enlarge the
//! parent is a design call.
use super::lint::Issue;
use crate::geom::Rect;
use crate::model::{Document, Node, NodeId, NodeKind};
/// Overflow up to this many pixels is rounding noise from fractional layout.
const TOLERANCE: f64 = 0.5;
/// Overflow issues for the layers in `ids` (ids missing from `doc` are ignored).
pub fn overflow(doc: &Document, ids: &[NodeId]) -> Vec<Issue> {
ids.iter().flat_map(|id| check(doc, *id)).collect()
}
/// A frame the renderer clips to, or the artboard a layer must stay inside.
fn bounds_layer(n: &Node) -> bool {
n.screen || (n.parent.is_none() && n.is_frame()) || matches!(n.kind, NodeKind::Frame { clip: true })
}
/// The nearest ancestor `id` must stay inside; `None` for a top-level layer or one in scrolled content.
fn container_of(doc: &Document, id: NodeId) -> Option<NodeId> {
for a in doc.ancestors(id) {
let n = doc.get(a)?;
if n.scroll {
return None;
}
if bounds_layer(n) {
return Some(a);
}
}
None
}
/// How far `r` extends past `bounds` on each side, as `(side, px)` for the sides beyond tolerance.
fn past(r: Rect, bounds: Rect) -> Vec<(&'static str, f64)> {
let sides = [
("left", bounds.x - r.x),
("top", bounds.y - r.y),
("right", (r.x + r.w) - (bounds.x + bounds.w)),
("bottom", (r.y + r.h) - (bounds.y + bounds.h)),
];
sides.into_iter().filter(|(_, px)| *px > TOLERANCE).collect()
}
fn describe(sides: &[(&'static str, f64)]) -> String {
let parts: Vec<String> = sides.iter().map(|(s, px)| format!("{}px past the {s} edge", px.round())).collect();
parts.join(", ")
}
fn check(doc: &Document, id: NodeId) -> Vec<Issue> {
let Some(n) = doc.get(id) else { return Vec::new() };
if !doc.is_effectively_visible(id) {
return Vec::new();
}
let Some(container) = container_of(doc, id) else { return Vec::new() };
let mut out = Vec::new();
out.extend(outside_container(doc, n, container));
out.extend(text_taller_than_box(n));
out
}
fn outside_container(doc: &Document, n: &Node, container: NodeId) -> Option<Issue> {
let (rect, bounds) = (doc.world_rect(n.id)?, doc.world_rect(container)?);
let sides = past(rect, bounds);
if sides.is_empty() {
return None;
}
// A layer inside something that already overflows would repeat that finding.
if let Some(p) = n.parent.filter(|p| *p != container)
&& doc.world_rect(p).is_some_and(|r| !past(r, bounds).is_empty())
{
return None;
}
let cn = doc.get(container)?;
Some(Issue {
rule: "overflow",
severity: "error",
node: n.id,
message: format!("`{}` is {} of `{}`", n.name, describe(&sides), cn.name),
fix: "move or resize it inside its parent, or let the parent grow (hug sizing); do not rely on clipping".into(),
})
}
fn text_taller_than_box(n: &Node) -> Option<Issue> {
if !matches!(n.kind, NodeKind::Text { .. }) {
return None;
}
let extra = crate::text::layout_node(n)?.height - n.rect.h;
if extra <= TOLERANCE {
return None;
}
Some(Issue {
rule: "overflow",
severity: "error",
node: n.id,
message: format!("text `{}` is {}px taller than its {}px box and is clipped", n.name, extra.round(), n.rect.h.round()),
fix: "widen the box, shorten the text, lower the font size, or give it hug height".into(),
})
}
#[cfg(test)]
mod tests {
use super::*;
type R = (f64, f64, f64, f64);
fn add(doc: &mut Document, parent: Option<NodeId>, kind: NodeKind, r: R, name: &str) -> NodeId {
let id = doc.alloc_id();
let mut n = Node::new(id, kind, Rect::new(r.0, r.1, r.2, r.3));
n.name = name.into();
n.parent = parent;
doc.nodes.insert(id, n);
match parent {
Some(p) => doc.nodes.get_mut(&p).unwrap().children.push(id),
None => doc.root.push(id),
}
id
}
fn frame(doc: &mut Document, parent: Option<NodeId>, r: R, name: &str) -> NodeId {
add(doc, parent, NodeKind::Frame { clip: true }, r, name)
}
fn run(doc: &Document) -> Vec<Issue> {
overflow(doc, &doc.paint_order())
}
#[test]
fn child_past_the_right_edge_is_flagged_with_amount_and_side() {
let mut d = Document::default();
let board = frame(&mut d, None, (0.0, 0.0, 200.0, 200.0), "Board");
let kid = add(&mut d, Some(board), NodeKind::Rect, (150.0, 10.0, 80.0, 20.0), "Chip");
let issues = run(&d);
assert_eq!(issues.len(), 1);
assert_eq!((issues[0].rule, issues[0].node), ("overflow", kid));
assert!(issues[0].message.contains("30px past the right edge"), "{}", issues[0].message);
}
#[test]
fn a_child_inside_a_clipping_frame_is_flagged_on_every_side() {
let mut d = Document::default();
let board = frame(&mut d, None, (0.0, 0.0, 400.0, 400.0), "Board");
let card = frame(&mut d, Some(board), (50.0, 50.0, 100.0, 100.0), "Card");
add(&mut d, Some(card), NodeKind::Rect, (-10.0, 90.0, 50.0, 30.0), "Badge");
let issues = run(&d);
assert_eq!(issues.len(), 1);
let m = &issues[0].message;
assert!(m.contains("10px past the left edge") && m.contains("20px past the bottom edge") && m.contains("`Card`"), "{m}");
}
#[test]
fn a_non_clipping_frame_only_matters_against_the_artboard() {
let mut d = Document::default();
let board = frame(&mut d, None, (0.0, 0.0, 300.0, 300.0), "Board");
let loose = add(&mut d, Some(board), NodeKind::Frame { clip: false }, (0.0, 0.0, 100.0, 100.0), "Loose");
add(&mut d, Some(loose), NodeKind::Rect, (80.0, 0.0, 60.0, 20.0), "Inside board");
assert!(run(&d).is_empty(), "sticks out of Loose but stays on the artboard");
add(&mut d, Some(loose), NodeKind::Rect, (250.0, 0.0, 100.0, 20.0), "Off board");
let issues = run(&d);
assert_eq!(issues.len(), 1);
assert!(issues[0].message.contains("`Board`"));
}
#[test]
fn scrolling_content_and_pinned_layers_are_not_flagged() {
let mut d = Document::default();
let screen = frame(&mut d, None, (0.0, 0.0, 390.0, 800.0), "Home");
{
let s = d.nodes.get_mut(&screen).unwrap();
s.scroll = true;
s.screen = true;
}
add(&mut d, Some(screen), NodeKind::Rect, (0.0, 0.0, 390.0, 3000.0), "Long feed");
let bar = add(&mut d, Some(screen), NodeKind::Rect, (0.0, 780.0, 390.0, 60.0), "Tab bar");
d.nodes.get_mut(&bar).unwrap().layout_child.absolute = true;
assert!(run(&d).is_empty());
}
#[test]
fn overflow_inside_scrolled_content_is_still_checked_against_its_own_parent() {
let mut d = Document::default();
let screen = frame(&mut d, None, (0.0, 0.0, 390.0, 800.0), "Home");
d.nodes.get_mut(&screen).unwrap().scroll = true;
let card = frame(&mut d, Some(screen), (0.0, 1000.0, 200.0, 100.0), "Card");
add(&mut d, Some(card), NodeKind::Rect, (150.0, 0.0, 100.0, 20.0), "Pill");
let issues = run(&d);
assert_eq!(issues.len(), 1);
assert!(issues[0].message.contains("`Card`"));
}
#[test]
fn long_text_in_a_fixed_box_is_flagged_when_the_parent_clips() {
let mut d = Document::default();
let board = frame(&mut d, None, (0.0, 0.0, 300.0, 300.0), "Board");
let kind = NodeKind::Text {
text: "A very long title that has to wrap onto several lines in a narrow box".into(),
font_size: 16.0,
auto_width: false,
align: Default::default(),
};
let t = add(&mut d, Some(board), kind, (10.0, 10.0, 80.0, 20.0), "Title");
let issues = run(&d);
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].node, t);
assert!(issues[0].message.contains("taller than its 20px box"), "{}", issues[0].message);
}
#[test]
fn text_that_fits_its_box_is_clean() {
let mut d = Document::default();
let board = frame(&mut d, None, (0.0, 0.0, 300.0, 300.0), "Board");
let kind = NodeKind::Text { text: "Hi".into(), font_size: 16.0, auto_width: false, align: Default::default() };
let t = add(&mut d, Some(board), kind, (10.0, 10.0, 80.0, 20.0), "Title");
let h = crate::text::layout_node(d.get(t).unwrap()).unwrap().height;
d.nodes.get_mut(&t).unwrap().rect.h = h;
assert!(run(&d).is_empty());
}
#[test]
fn half_a_pixel_is_tolerated_and_a_bit_more_is_not() {
let mut d = Document::default();
let board = frame(&mut d, None, (0.0, 0.0, 100.0, 100.0), "Board");
let kid = add(&mut d, Some(board), NodeKind::Rect, (0.0, 0.0, 100.5, 50.0), "Edge");
assert!(run(&d).is_empty());
d.nodes.get_mut(&kid).unwrap().rect.w = 100.6;
assert_eq!(run(&d).len(), 1);
}
#[test]
fn hidden_layers_and_children_of_an_overflowing_parent_are_not_repeated() {
let mut d = Document::default();
let board = frame(&mut d, None, (0.0, 0.0, 200.0, 200.0), "Board");
let wide = frame(&mut d, Some(board), (100.0, 0.0, 200.0, 50.0), "Wide");
add(&mut d, Some(wide), NodeKind::Rect, (120.0, 0.0, 40.0, 20.0), "Inner");
let ghost = add(&mut d, Some(board), NodeKind::Rect, (500.0, 0.0, 10.0, 10.0), "Ghost");
d.nodes.get_mut(&ghost).unwrap().visible = false;
let issues = run(&d);
assert_eq!(issues.len(), 1, "{issues:?}");
assert_eq!(issues[0].node, wide);
}
#[test]
fn only_the_given_nodes_are_checked() {
let mut d = Document::default();
let board = frame(&mut d, None, (0.0, 0.0, 100.0, 100.0), "Board");
let bad = add(&mut d, Some(board), NodeKind::Rect, (90.0, 0.0, 50.0, 10.0), "Bad");
let good = add(&mut d, Some(board), NodeKind::Rect, (0.0, 0.0, 50.0, 10.0), "Good");
assert!(overflow(&d, &[good]).is_empty());
assert_eq!(overflow(&d, &[bad]).len(), 1);
}
}
+22 -2
View File
@@ -12,19 +12,27 @@
//! * [`relay_proto`]: JSON frames between the relay and a browser tab; [`relay`]: the
//! relay server itself (native only).
pub mod bounds;
pub mod codegen;
pub mod critique;
pub mod css;
pub mod diff;
pub mod enforcer;
pub mod exec;
pub mod html;
pub mod incremental;
pub mod lint;
pub mod lint_overflow;
#[cfg(not(target_arch = "wasm32"))]
pub mod imagegen;
#[cfg(not(target_arch = "wasm32"))]
pub mod delegate;
#[cfg(not(target_arch = "wasm32"))]
pub mod refs;
pub mod rewrite;
pub mod room;
pub mod screen_fit;
pub mod swarm;
#[cfg(not(target_arch = "wasm32"))]
pub mod relay;
pub mod relay_proto;
@@ -70,13 +78,25 @@ pub const ACTIONS_CLIENT: &str = "Actions";
pub struct Client {
/// The MCP `clientInfo` title or name ("Claude Code"), or [`CHAT_CLIENT`].
pub name: String,
/// The MCP session id, or the chat id.
/// The MCP session id, the chat id, or `agent:<name>` for a named CLI agent.
pub session: String,
/// The session of the agent that delegated this one.
pub parent: Option<String>,
}
impl Client {
pub fn chat(id: &str) -> Client {
Client { name: CHAT_CLIENT.into(), session: id.into() }
Client { name: CHAT_CLIENT.into(), session: id.into(), parent: None }
}
/// A CLI agent named with `--as` (or `$PAPERCRAFT_AGENT`): one session across its calls.
pub fn agent(name: &str, parent: Option<String>) -> Client {
Client { name: name.into(), session: format!("agent:{name}"), parent }
}
/// Name and session: what tells two agents apart (the change log's client key).
pub fn key(&self) -> String {
format!("{}#{}", self.name, self.session)
}
}
+418
View File
@@ -0,0 +1,418 @@
//! Rewrites the CSS that agents write by habit but layers cannot hold into markup that builds the
//! same layout, before `write_html` makes layers: grids become rows of equal cells, margins
//! become gaps or spacers, inline boxes (chips, badges) become boxes, and text inputs show their
//! placeholder. Each rewrite matches what a browser shows; what cannot be matched is reported.
use super::html::{Element, Html};
/// Rewrite `tree` in place; returns notes on what was only approximated.
pub fn rewrite(tree: &mut [Html]) -> Vec<String> {
let mut notes = Vec::new();
for h in tree.iter_mut() {
if let Html::Element(e) = h {
element(e, &mut notes);
}
}
notes
}
fn element(e: &mut Element, notes: &mut Vec<String>) {
if e.raw.is_some() {
return; // <svg>
}
input(e);
for c in e.children.iter_mut() {
if let Html::Element(k) = c {
element(k, notes);
}
}
inline_boxes(e);
grid(e, notes);
margins(e, notes);
}
/// `display: x` and the other declarations of an element's style, in order.
fn decls(e: &Element) -> Vec<(String, String)> {
let mut out = Vec::new();
let src = e.attr("style").unwrap_or("");
let (mut depth, mut cur) = (0i32, String::new());
for c in src.chars().chain(std::iter::once(';')) {
match c {
'(' => depth += 1,
')' => depth -= 1,
';' if depth == 0 => {
if let Some((k, v)) = cur.split_once(':') {
out.push((k.trim().to_ascii_lowercase(), v.trim().to_owned()));
}
cur.clear();
continue;
}
_ => {}
}
cur.push(c);
}
out
}
fn set_decls(e: &mut Element, d: &[(String, String)]) {
let style = d.iter().map(|(k, v)| format!("{k}:{v}")).collect::<Vec<_>>().join(";");
match e.attrs.iter_mut().find(|(k, _)| k.eq_ignore_ascii_case("style")) {
Some(s) => s.1 = style,
None => e.attrs.push(("style".into(), style)),
}
}
fn get<'a>(d: &'a [(String, String)], k: &str) -> Option<&'a str> {
d.iter().rev().find(|(n, _)| n == k).map(|(_, v)| v.as_str())
}
fn div(style: &str, name: &str, children: Vec<Html>) -> Html {
Html::Element(Element {
tag: "div".into(),
attrs: vec![("layer-name".into(), name.into()), ("style".into(), style.into())],
children,
raw: None,
})
}
/// A length in px (`16px`, `1rem`, `0`); None for `auto` and anything else.
fn px(v: &str) -> Option<f64> {
let v = v.trim();
if v == "0" {
return Some(0.0);
}
if let Some(n) = v.strip_suffix("px") {
return n.trim().parse().ok();
}
v.strip_suffix("rem").or_else(|| v.strip_suffix("em")).and_then(|n| n.trim().parse::<f64>().ok()).map(|n| n * 16.0)
}
fn is_row(d: &[(String, String)]) -> bool {
matches!(get(d, "display"), Some("flex" | "inline-flex")) && !get(d, "flex-direction").is_some_and(|v| v.starts_with("column"))
}
/// A text input shows its value, else its placeholder in the browser's placeholder grey.
fn input(e: &mut Element) {
let texty = e.tag == "textarea" || (e.tag == "input" && matches!(e.attr("type").unwrap_or("text"), "text" | "email" | "password" | "search" | "tel" | "url" | "number"));
if !texty {
return;
}
let (text, color) = match (e.attr("value").filter(|v| !v.is_empty()), e.attr("placeholder")) {
(Some(v), _) => (v.to_owned(), None),
(None, Some(p)) => (p.to_owned(), Some("#757575")),
(None, None) => return,
};
let text = if e.attr("type") == Some("password") && color.is_none() { "\u{2022}".repeat(text.chars().count()) } else { text };
if e.attr("layer-name").is_none() {
e.attrs.push(("layer-name".into(), "Input".into()));
}
let mut d = decls(e);
if get(&d, "display").is_none() {
d.push(("display".into(), "flex".into()));
d.push(("align-items".into(), "center".into()));
}
set_decls(e, &d);
let style = color.map_or(String::new(), |c| format!("color:{c}"));
e.tag = "div".into();
e.children = vec![Html::Element(Element {
tag: "span".into(),
attrs: vec![("layer-name".into(), if color.is_some() { "Placeholder" } else { "Value" }.into()), ("style".into(), style)],
children: vec![Html::Text(text)],
raw: None,
})];
}
/// Inline elements with a box (padding, background, border, inline-block) are boxes: chips,
/// badges, pills. A parent holding only such boxes lays them out in a wrapping row, as inline
/// flow does.
fn inline_boxes(e: &mut Element) {
let mut boxes = 0;
let mut other = false;
for c in e.children.iter_mut() {
match c {
Html::Text(t) if t.trim().is_empty() => {}
Html::Element(k) if super::html::is_inline(&k.tag) || k.tag == "button" => {
let d = decls(k);
let boxed = matches!(get(&d, "display"), Some("inline-block" | "inline-flex"))
|| d.iter().any(|(n, _)| n.starts_with("padding") || n.starts_with("background") || n.starts_with("border"));
if boxed {
if k.tag != "button" {
k.tag = "div".into();
}
boxes += 1;
} else {
other = true;
}
}
_ => other = true,
}
}
let mut d = decls(e);
if boxes > 1 && !other && get(&d, "display").is_none_or(|v| v == "block") {
d.retain(|(k, _)| k != "display");
d.extend([("display".into(), "flex".into()), ("flex-wrap".into(), "wrap".into()), ("align-items".into(), "center".into())]);
set_decls(e, &d);
}
}
/// A grid is laid out as rows of its columns: `fr` columns grow by their share, px columns are
/// fixed, `auto` columns hug. The last row is padded with empty cells so columns line up.
fn grid(e: &mut Element, notes: &mut Vec<String>) {
let mut d = decls(e);
if !matches!(get(&d, "display"), Some("grid" | "inline-grid")) {
return;
}
let gaps: Vec<f64> = get(&d, "gap").map_or(vec![], |g| g.split_whitespace().filter_map(px).collect());
let row_gap = get(&d, "row-gap").and_then(px).or(gaps.first().copied()).unwrap_or(0.0);
let col_gap = get(&d, "column-gap").and_then(px).or(gaps.get(1).or(gaps.first()).copied()).unwrap_or(0.0);
let width = get(&d, "width").and_then(px);
let tracks = get(&d, "grid-template-columns").map_or_else(|| vec!["1fr".to_owned()], |t| tracks(t, width, col_gap, notes));
d.retain(|(k, _)| !(k.starts_with("grid") || ["display", "gap", "row-gap", "column-gap", "justify-items"].contains(&k.as_str())));
d.extend([("display".into(), "flex".into()), ("flex-direction".into(), "column".into()), ("gap".into(), format!("{row_gap}px"))]);
set_decls(e, &d);
let cell_style = |t: &str| -> String {
if let Some(fr) = t.strip_suffix("fr").and_then(|f| f.trim().parse::<f64>().ok()) {
format!("flex-grow:{fr}")
} else if let Some(w) = px(t) {
format!("width:{w}px")
} else {
String::new()
}
};
if e.children.iter().any(|c| matches!(c, Html::Element(k) if k.attr("style").is_some_and(|s| s.contains("grid-column")))) {
notes.push("grid-column spans are laid out as one cell each".into());
}
let cells: Vec<Html> = std::mem::take(&mut e.children).into_iter().filter(|c| !matches!(c, Html::Text(t) if t.trim().is_empty())).collect();
let n = tracks.len().max(1);
let mut rows = Vec::new();
for (r, chunk) in cells.chunks(n).enumerate() {
let mut kids = Vec::new();
for (i, t) in tracks.iter().enumerate() {
let style = cell_style(t);
match chunk.get(i) {
Some(Html::Element(k)) => {
let mut k = k.clone();
let mut kd = decls(&k);
kd.retain(|(n, _)| !n.starts_with("grid"));
if get(&kd, "width").is_none() {
kd.extend(style.split_once(':').map(|(a, b)| (a.to_owned(), b.to_owned())));
}
set_decls(&mut k, &kd);
kids.push(Html::Element(k));
}
Some(t @ Html::Text(_)) => kids.push(div(&style, "Cell", vec![t.clone()])),
None if style.is_empty() => {}
None => kids.push(div(&style, "Empty", vec![])),
}
}
rows.push(div(&format!("display:flex;gap:{col_gap}px"), &format!("Row {}", r + 1), kids));
}
e.children = rows;
}
/// The columns of `grid-template-columns`, with `repeat(n, x)` expanded. `auto-fill` and
/// `auto-fit` take as many columns as fit `width`.
fn tracks(t: &str, width: Option<f64>, gap: f64, notes: &mut Vec<String>) -> Vec<String> {
let mut out = Vec::new();
let mut rest = t.trim();
while !rest.is_empty() {
if let Some(r) = rest.strip_prefix("repeat(") {
let Some(end) = matching_paren(r) else { break };
let (count, track) = r[..end].split_once(',').unwrap_or(("1", "1fr"));
let track = track.trim();
let one = track.strip_prefix("minmax(").and_then(|m| m.strip_suffix(')')).and_then(|m| m.split_once(',')).map_or(track, |(_, max)| max.trim());
let n = match count.trim() {
"auto-fill" | "auto-fit" => {
let min = track.strip_prefix("minmax(").and_then(|m| m.split_once(',')).and_then(|(min, _)| px(min)).or(px(track));
match (width, min) {
(Some(w), Some(m)) if m > 0.0 => ((w + gap) / (m + gap)).floor().max(1.0) as usize,
_ => {
notes.push(format!("grid `{}`: give the grid a px width to know its columns; laid out as 3", t.trim()));
3
}
}
}
c => c.parse().unwrap_or(1),
};
out.extend(std::iter::repeat_n(one.to_owned(), n));
rest = r[end + 1..].trim_start();
} else if let Some(m) = rest.strip_prefix("minmax(") {
let Some(end) = matching_paren(m) else { break };
out.push(m[..end].split_once(',').map_or("1fr", |(_, max)| max.trim()).to_owned());
rest = m[end + 1..].trim_start();
} else {
let (tok, r) = rest.split_once(char::is_whitespace).unwrap_or((rest, ""));
out.push(tok.to_owned());
rest = r.trim_start();
}
}
out
}
fn matching_paren(s: &str) -> Option<usize> {
let mut depth = 0;
for (i, c) in s.char_indices() {
match c {
'(' => depth += 1,
')' if depth == 0 => return Some(i),
')' => depth -= 1,
_ => {}
}
}
None
}
/// Margins of an element along an axis: (start, end), each a px value or `auto` (None).
fn margin(d: &[(String, String)], row: bool) -> (Option<Option<f64>>, Option<Option<f64>>) {
let val = |v: &str| if v.trim() == "auto" { Some(None) } else { px(v).map(Some) };
let mut m: [Option<Option<f64>>; 4] = [None; 4]; // top, right, bottom, left
for (k, v) in d {
match k.as_str() {
"margin" => {
let p: Vec<&str> = v.split_whitespace().collect();
let at = |i: usize| p.get(i).copied();
let sides = match p.len() {
1 => [at(0), at(0), at(0), at(0)],
2 => [at(0), at(1), at(0), at(1)],
3 => [at(0), at(1), at(2), at(1)],
_ => [at(0), at(1), at(2), at(3)],
};
for (s, v) in m.iter_mut().zip(sides) {
*s = v.and_then(val);
}
}
"margin-top" => m[0] = val(v),
"margin-right" => m[1] = val(v),
"margin-bottom" => m[2] = val(v),
"margin-left" => m[3] = val(v),
_ => {}
}
}
if row { (m[3], m[1]) } else { (m[0], m[2]) }
}
/// Children's margins along the parent's flow become its gap when they are all one size, else
/// spacers of their size; `auto` margins become growing spacers (they take the free space). A
/// block's `margin: 0 auto` centres it.
fn margins(e: &mut Element, notes: &mut Vec<String>) {
let d = decls(e);
let row = is_row(&d);
let flex = matches!(get(&d, "display"), Some("flex" | "inline-flex"));
let gap = get(&d, "gap").and_then(|g| g.split_whitespace().next().and_then(px)).unwrap_or(0.0);
let kids: Vec<usize> = e.children.iter().enumerate().filter(|(_, c)| matches!(c, Html::Element(_))).map(|(i, _)| i).collect();
let m: Vec<_> = kids
.iter()
.map(|i| match &e.children[*i] {
Html::Element(k) => margin(&decls(k), row),
Html::Text(_) => (None, None),
})
.collect();
if m.iter().all(|(a, b)| a.is_none() && b.is_none()) {
return;
}
let len = |x: Option<Option<f64>>| x.flatten().unwrap_or(0.0);
let auto = |x: Option<Option<f64>>| x == Some(None);
// Space before each child (and after the last): block margins collapse, flex margins add.
let mut space: Vec<f64> = Vec::new();
for j in 0..=m.len() {
let before = if j > 0 { len(m[j - 1].1) } else { 0.0 };
let after = m.get(j).map_or(0.0, |x| len(x.0));
space.push(if flex { before + after } else { before.max(after) });
}
let between = &space[1..m.len()];
let uniform = !between.is_empty() && between.iter().all(|s| (s - between[0]).abs() < 1e-9) && space[0] == 0.0 && space[m.len()] == 0.0;
let mut pd = d.clone();
let mut out: Vec<Html> = Vec::new();
let size = if row { "width" } else { "height" };
let spacer = |px: f64| div(&format!("{size}:{px}px;flex-shrink:0"), "Spacer", vec![]);
let grow = || div("flex-grow:1", "Spacer", vec![]);
for (i, c) in std::mem::take(&mut e.children).into_iter().enumerate() {
let Some(j) = kids.iter().position(|k| *k == i) else {
out.push(c);
continue;
};
let Html::Element(mut k) = c else { unreachable!() };
let (start, end) = m[j];
let extra = if j > 0 { space[j] - gap } else { space[j] };
if !uniform && extra > 0.0 {
out.push(spacer(extra));
}
if auto(start) {
out.push(grow());
}
// Margins along the flow are now spacers; across it, `auto` on both sides centres.
let mut kd = decls(&k);
let (cs, ce) = margin(&kd, !row);
kd.retain(|(n, _)| !n.starts_with("margin"));
if auto(cs) && auto(ce) {
kd.push(("align-self".into(), "center".into()));
} else if len(cs) > 0.0 || len(ce) > 0.0 {
notes.push(format!("margins across the flow are dropped (`{}`): pad the parent instead", k.attr("layer-name").unwrap_or(&k.tag)));
}
set_decls(&mut k, &kd);
out.push(Html::Element(k));
if auto(end) {
out.push(grow());
}
}
if !uniform && space[m.len()] > 0.0 {
out.push(spacer(space[m.len()]));
}
if uniform && between[0] > 0.0 {
pd.retain(|(k, _)| k != "gap");
pd.push(("gap".into(), format!("{}px", gap + between[0])));
set_decls(e, &pd);
}
e.children = out;
}
#[cfg(test)]
mod tests {
use super::*;
fn run(src: &str) -> String {
let mut t = super::super::html::parse(src);
rewrite(&mut t);
fn show(h: &Html) -> String {
match h {
Html::Text(t) => t.clone(),
Html::Element(e) => format!("<{} {}>{}</>", e.tag, e.attr("style").unwrap_or(""), e.children.iter().map(show).collect::<String>()),
}
}
t.iter().map(show).collect()
}
#[test]
fn a_grid_becomes_rows_of_equal_cells() {
let out = run("<div style=\"display:grid;grid-template-columns:repeat(3,1fr);gap:16px\"><p>a</p><p>b</p><p>c</p><p>d</p></div>");
assert_eq!(out.matches("<div display:flex;gap:16px>").count(), 2, "{out}");
assert_eq!(out.matches("flex-grow:1").count(), 6, "4 cells and 2 empty ones keep the columns: {out}");
assert!(out.starts_with("<div display:flex;flex-direction:column;gap:16px>"), "{out}");
}
#[test]
fn auto_fill_takes_the_columns_that_fit() {
let mut notes = Vec::new();
assert_eq!(tracks("repeat(auto-fill, minmax(200px, 1fr))", Some(640.0), 16.0, &mut notes).len(), 3);
assert_eq!(tracks("240px 1fr", None, 0.0, &mut notes), ["240px", "1fr"]);
assert!(notes.is_empty());
}
#[test]
fn margins_become_a_gap_or_spacers() {
let out = run("<div><p style=\"margin-bottom:16px\">a</p><p style=\"margin-bottom:16px\">b</p><p>c</p></div>");
assert!(out.starts_with("<div gap:16px>") && !out.contains("margin"), "{out}");
let out = run("<div style=\"display:flex\"><span>Title</span><button style=\"margin-left:auto\">Save</button></div>");
assert!(out.contains("<div flex-grow:1></><button >Save"), "auto margin takes the free space: {out}");
let out = run("<div><p style=\"margin-bottom:8px\">a</p><p style=\"margin-top:24px\">b</p><p>c</p></div>");
assert!(out.contains("height:24px") && !out.contains("height:8px"), "block margins collapse: {out}");
}
#[test]
fn inline_boxes_and_inputs_become_boxes() {
let out = run("<div><span style=\"padding:4px\">A</span><span style=\"padding:4px\">B</span></div>");
assert!(out.starts_with("<div display:flex;flex-wrap:wrap") && out.contains("<div padding:4px>A"), "{out}");
let out = run("<input placeholder=\"you@example.com\" style=\"padding:8px\"/>");
assert!(out.contains("<span color:#757575>you@example.com"), "{out}");
}
}
+91
View File
@@ -0,0 +1,91 @@
//! Keeps top-level frames clear of each other after an edit: a board that grew (a hugging section
//! that gained rows, an artboard made taller) pushes the frames it now runs into down, keeping
//! the gap they had, and those push the ones under them. Frames that already overlapped before
//! the edit are left alone: that overlap was there on purpose or is not this edit's doing.
use crate::geom::Rect;
use crate::model::{Document, NodeId};
use std::collections::HashMap;
/// Where each visible top-level frame is.
pub fn snapshot(doc: &Document) -> HashMap<NodeId, Rect> {
doc.root.iter().filter_map(|id| doc.get(*id).filter(|n| n.visible && n.is_frame()).map(|n| (*id, n.rect))).collect()
}
/// The moves (frame, dy) that clear the overlaps this edit made, given the frames before it.
pub fn moves(doc: &Document, before: &HashMap<NodeId, Rect>) -> Vec<(NodeId, f64)> {
let mut now = snapshot(doc);
let mut moved: HashMap<NodeId, f64> = HashMap::new();
// A cascade moves each frame at most once per frame above it; this bounds a bad loop.
for _ in 0..now.len() * 4 + 4 {
let mut ids: Vec<NodeId> = now.keys().copied().collect();
ids.sort_by(|a, b| now[a].y.total_cmp(&now[b].y).then(a.0.cmp(&b.0)));
// Only frames that grew (or were pushed) push: one placed on purpose moves nothing.
let grew = |id: &NodeId| moved.contains_key(id) || before.get(id).is_some_and(|p| now[id].h > p.h || now[id].w > p.w);
let hit = ids.iter().filter(|a| grew(a)).find_map(|a| {
let ra = now[a];
ids.iter().filter(|b| *b != a && now[*b].y >= ra.y).find_map(|b| {
let rb = now[b];
let was = match (before.get(a), before.get(b)) {
(Some(pa), Some(pb)) => pa.intersects(pb),
_ => false,
};
if !ra.intersects(&rb) || was {
return None;
}
// The gap they had, else the usual distance between boards.
let gap = match (before.get(a), before.get(b)) {
(Some(pa), Some(pb)) if pb.y >= pa.bottom() => pb.y - pa.bottom(),
_ => BOARD_GAP,
};
Some((*b, ra.bottom() + gap - rb.y))
})
});
let Some((id, dy)) = hit else { break };
now.get_mut(&id).unwrap().y += dy;
*moved.entry(id).or_default() += dy;
}
let mut out: Vec<(NodeId, f64)> = moved.into_iter().filter(|(_, dy)| *dy > 0.0).collect();
out.sort_by_key(|(id, _)| id.0);
out
}
/// Room left between boards pushed apart when they had no gap to keep.
const BOARD_GAP: f64 = 80.0;
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{Node, NodeKind};
fn board(doc: &mut Document, r: Rect) -> NodeId {
let id = doc.alloc_id();
doc.nodes.insert(id, Node::new(id, NodeKind::Frame { clip: true }, r));
doc.root.push(id);
id
}
#[test]
fn a_board_that_grew_pushes_the_stack_under_it_keeping_the_gaps() {
let mut doc = Document::default();
let a = board(&mut doc, Rect::new(0.0, 0.0, 400.0, 300.0));
let b = board(&mut doc, Rect::new(0.0, 400.0, 400.0, 300.0));
let c = board(&mut doc, Rect::new(0.0, 760.0, 400.0, 100.0));
let side = board(&mut doc, Rect::new(600.0, 350.0, 200.0, 100.0));
let before = snapshot(&doc);
doc.nodes.get_mut(&a).unwrap().rect.h = 500.0; // now runs 100 into b
let m = moves(&doc, &before);
assert_eq!(m, vec![(b, 200.0), (c, 200.0)], "b keeps its 100 gap, c its 60: {m:?}");
assert!(!m.iter().any(|(id, _)| *id == side), "a frame beside it stays");
}
#[test]
fn frames_that_already_overlapped_stay() {
let mut doc = Document::default();
let a = board(&mut doc, Rect::new(0.0, 0.0, 400.0, 300.0));
board(&mut doc, Rect::new(100.0, 100.0, 100.0, 100.0));
let before = snapshot(&doc);
doc.nodes.get_mut(&a).unwrap().rect.h = 320.0;
assert!(moves(&doc, &before).is_empty());
}
}
+108
View File
@@ -0,0 +1,108 @@
//! Screens keep their device height. When what `write_html` put in a fixed-height screen is
//! taller than the screen, the part that overflows scrolls instead, as an app does: the main
//! column under a fixed sidebar and header, the content between a header and a tab bar. The
//! screen itself scrolls only when nothing inside can.
use crate::history::Tx;
use crate::layout::{flow_children, hug_size};
use crate::model::{Direction, Document, Node, NodeId, NodeKind, Sizing};
/// Content past the bottom by up to this much is rounding noise.
const TOLERANCE: f64 = 0.5;
/// The device height of a screen this wide, if it is a device size (`screens::DEVICES`).
pub fn device_height(width: f64) -> Option<f64> {
crate::editor::screens::DEVICES.iter().find(|d| (d.1 - width).abs() < TOLERANCE).map(|d| d.2)
}
/// Make an overflowing fixed-height screen scroll where an app would and return the names of
/// the layers that now scroll. `relayout` lays the screen out again after a size change;
/// `stretches` tells whether a row stretches its children (CSS `align-items` left alone).
pub fn fit(tx: &mut Tx, screen: NodeId, relayout: fn(&mut Tx, Option<NodeId>), stretches: &dyn Fn(&Node) -> bool) -> Vec<String> {
let doc = tx.doc();
let Some(n) = doc.get(screen) else { return Vec::new() };
if n.parent.is_some() || !n.is_frame() || n.layout.is_none() || n.layout_child.sizing[1] == Sizing::Hug {
return Vec::new();
}
if !overflows(doc, screen) {
return Vec::new();
}
// An app shell (a sidebar beside the main column) that hugs its content takes the screen's
// height, so the sidebar stays and the column overflows inside it.
let column = n.layout.is_some_and(|l| l.direction == Direction::Vertical);
let shells: Vec<NodeId> = flow_children(doc, screen)
.into_iter()
.filter(|k| {
doc.get(*k).is_some_and(|c| {
c.instance.is_none()
&& c.layout_child.sizing[1] == Sizing::Hug
&& c.layout.is_some_and(|l| l.direction == Direction::Horizontal && !l.wrap)
&& flow_children(doc, *k).len() >= 2
}) && overflows_by_content(doc, *k, doc.get(screen).map_or(0.0, |s| s.rect.h))
})
.collect();
if column && let [shell] = shells.as_slice() {
let kids: Vec<NodeId> = if tx.doc().get(*shell).is_some_and(stretches) {
flow_children(tx.doc(), *shell)
.into_iter()
.filter(|k| tx.doc().get(*k).is_some_and(|n| n.layout.is_some() && n.layout_child.sizing[1] == Sizing::Hug))
.collect()
} else {
Vec::new()
};
tx.node_mut(*shell).unwrap().layout_child.sizing[1] = Sizing::Fill;
for k in kids {
tx.node_mut(k).unwrap().layout_child.sizing[1] = Sizing::Fill;
}
relayout(tx, Some(screen));
}
let mut scrolled = Vec::new();
for k in flow_children(tx.doc(), screen) {
deepest_overflow(tx.doc(), k, &mut scrolled);
}
if scrolled.is_empty() && overflows(tx.doc(), screen) {
scrolled.push(screen);
}
scrolled
.into_iter()
.filter_map(|id| {
let n = tx.node_mut(id)?;
if let NodeKind::Frame { clip } = &mut n.kind {
*clip = true;
}
n.scroll = true;
Some(n.name.clone())
})
.collect()
}
/// Its content is taller than it is.
fn overflows(doc: &Document, id: NodeId) -> bool {
doc.get(id).is_some_and(|n| overflows_by_content(doc, id, n.rect.h))
}
fn overflows_by_content(doc: &Document, id: NodeId, h: f64) -> bool {
hug_size(doc, id, 1) > h + TOLERANCE
}
/// The innermost frames under `id` whose height is set from outside (fixed, or filling their
/// parent) and whose content is taller: those are what scroll. Frames that already scroll or
/// clip on purpose, and instances, are left alone with what is inside them.
fn deepest_overflow(doc: &Document, id: NodeId, out: &mut Vec<NodeId>) -> bool {
let Some(n) = doc.get(id) else { return false };
if n.layout.is_none() || n.instance.is_some() || n.scroll || matches!(n.kind, NodeKind::Frame { clip: true }) {
return n.scroll;
}
let mut inner = false;
for k in flow_children(doc, id) {
inner |= deepest_overflow(doc, k, out);
}
if inner {
return true;
}
if n.layout_child.sizing[1] != Sizing::Hug && overflows(doc, id) {
out.push(id);
return true;
}
false
}
+56 -2
View File
@@ -111,9 +111,13 @@ impl ServerHandler for PaperServer {
/// backends connect that way), else the MCP client's title or name and its session id.
fn client_of(context: &RequestContext<RoleServer>) -> Client {
let parts = context.extensions.get::<axum::http::request::Parts>();
if let Some(id) = parts.and_then(|p| p.uri.query()).and_then(chat_of) {
let query = parts.and_then(|p| p.uri.query());
if let Some(id) = query.and_then(chat_of) {
return Client::chat(id);
}
if let Some((name, parent)) = query.and_then(agent_of) {
return Client::agent(&name, parent);
}
let name = context
.client_info()
.map(|i| i.title.filter(|t| !t.is_empty()).unwrap_or(i.name))
@@ -122,7 +126,7 @@ fn client_of(context: &RequestContext<RoleServer>) -> Client {
.and_then(|p| p.headers.get("mcp-session-id"))
.and_then(|v| v.to_str().ok())
.unwrap_or_default();
Client { name, session: session.into() }
Client { name, session: session.into(), parent: None }
}
/// The chat id in an MCP URL query (`chat=<id>`).
@@ -130,6 +134,47 @@ pub fn chat_of(query: &str) -> Option<&str> {
query.split('&').find_map(|kv| kv.strip_prefix("chat="))
}
/// Names a person or the app itself uses, which an agent cannot take.
const RESERVED: [&str; 3] = ["You", super::CHAT_CLIENT, super::ACTIONS_CLIENT];
/// Longest agent name (`--as`).
const MAX_AGENT_NAME: usize = 40;
/// The agent name and parent session in an MCP URL query (`agent=<name>&parent=<session>`, from
/// `papercraft-cli --as`). A name outside `[A-Za-z0-9 ._/-]{1,40}` or a reserved one is ignored.
pub fn agent_of(query: &str) -> Option<(String, Option<String>)> {
let get = |k: &str| query.split('&').find_map(|kv| kv.strip_prefix(k)).map(decode);
let name = get("agent=")?;
let ok = (1..=MAX_AGENT_NAME).contains(&name.chars().count())
&& name.chars().all(|c| c.is_ascii_alphanumeric() || " ._/-".contains(c))
&& !RESERVED.iter().any(|r| r.eq_ignore_ascii_case(name.trim()));
ok.then(|| (name, get("parent=").filter(|p| !p.is_empty())))
}
/// Percent-decoding of a query value (`+` is a space).
fn decode(s: &str) -> String {
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while i < b.len() {
let hex = b.get(i + 1..i + 3).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok());
match (b[i], hex) {
(b'%', Some(v)) => {
out.push(v);
i += 3;
}
(b'+', _) => {
out.push(b' ');
i += 1;
}
(c, _) => {
out.push(c);
i += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
/// Add the `Accept` header the streamable HTTP transport requires when a client omits it.
/// Shared with the relay, which serves the same transport to remote agents.
pub(crate) async fn fix_accept(
@@ -260,6 +305,15 @@ pub fn spawn(port: u16, dispatch: Dispatch) -> Result<Running, String> {
mod tests {
use super::*;
#[test]
fn a_named_cli_agent_is_read_from_the_query() {
assert_eq!(agent_of("agent=Codex%20B&parent=s1"), Some(("Codex B".into(), Some("s1".into()))));
assert_eq!(agent_of("agent=Chat+%2F+build+1"), Some(("Chat / build 1".into(), None)));
assert_eq!(agent_of("agent=You"), None, "a reserved name");
assert_eq!(agent_of("agent=a%3Bb"), None, "a character outside the set");
assert_eq!(agent_of("chat=7"), None);
}
#[test]
fn tools_deserialize_and_names_are_unique() {
let tools = tool_list();
+1 -1
View File
@@ -46,7 +46,7 @@ mod tests {
add("steer-a", "make it blue");
let other = attach(&Client::chat("steer-b"), Ok(vec![])).unwrap();
assert!(other.is_empty());
let mcp = Client { name: "Claude Code".into(), session: "steer-a".into() };
let mcp = Client { name: "Claude Code".into(), session: "steer-a".into(), parent: None };
assert!(attach(&mcp, Ok(vec![])).unwrap().is_empty(), "only chat turns");
let outs = attach(&Client::chat("steer-a"), Ok(vec![])).unwrap();
assert!(matches!(&outs[..], [Out::Text(t)] if t.ends_with("make it blue")));
+275
View File
@@ -0,0 +1,275 @@
//! Claims: an agent's short lease on layers (each with everything under it) or on a shared
//! resource, so other agents work elsewhere (`specs/swarm-safety/spec.md`, section 3).
//!
//! Pure logic over ids and a clock. A claim is all-or-nothing, never waited on, and expires
//! `ttl` after its holder's last call, so no agent can hold the canvas for good and no two can
//! deadlock. The person at the canvas is never checked.
use crate::model::NodeId;
/// Live claims one agent may hold, and all agents together: a runaway agent cannot fence off
/// the canvas.
pub const MAX_PER_CLIENT: usize = 16;
pub const MAX_CLAIMS: usize = 64;
pub const DEFAULT_TTL_SECS: u64 = 300;
pub const MIN_TTL_SECS: u64 = 10;
pub const MAX_TTL_SECS: u64 = 1800;
/// Shared things that cannot be split by layer.
pub const RESOURCES: [&str; 3] = ["variables", "components", "pages"];
/// Who holds a claim.
#[derive(Debug, Clone, PartialEq)]
pub struct Holder {
/// `Client::key`: name and session.
pub key: String,
/// What the person and other agents see ("Codex", "Chat / build 1").
pub label: String,
pub session: String,
}
/// One layer of one page.
pub type Layer = (String, NodeId);
#[derive(Debug, Clone)]
pub struct Claim {
pub id: String,
pub holder: Holder,
pub layers: Vec<Layer>,
pub resources: Vec<String>,
pub note: String,
pub ttl_ms: u64,
pub expires: u64,
}
impl Claim {
pub fn expires_in_secs(&self, now: u64) -> u64 {
self.expires.saturating_sub(now).div_ceil(1000)
}
}
/// What an agent asks to claim.
pub struct Request {
pub holder: Holder,
pub layers: Vec<Layer>,
pub resources: Vec<String>,
pub note: String,
pub ttl_secs: u64,
}
/// A request that met someone else's claim.
#[derive(Debug, Clone, PartialEq)]
pub struct Denied {
/// The requested layer (or resource) that overlaps.
pub what: String,
/// `self`, `ancestor` (the claim covers a parent of it), `descendant` or `resource`.
pub relation: &'static str,
pub claim: String,
}
/// How a requested layer relates to a claimed one: the same, inside it, or around it.
fn relation(want: &Layer, held: &Layer, ancestors: &dyn Fn(&Layer) -> Vec<NodeId>) -> Option<&'static str> {
if want.0 != held.0 {
return None;
}
if want.1 == held.1 {
Some("self")
} else if ancestors(want).contains(&held.1) {
Some("ancestor")
} else if ancestors(held).contains(&want.1) {
Some("descendant")
} else {
None
}
}
#[derive(Debug, Default)]
pub struct Claims {
pub live: Vec<Claim>,
next: u32,
}
impl Claims {
/// Drops the claims whose time is up.
pub fn expire(&mut self, now: u64) {
self.live.retain(|c| c.expires > now);
}
/// Any call by the holder keeps its claims alive.
pub fn renew(&mut self, key: &str, now: u64) {
for c in self.live.iter_mut().filter(|c| c.holder.key == key) {
c.expires = now + c.ttl_ms;
}
}
/// Other agents' claims that `layers` or `resources` overlap; `exempt` holders (an agent's
/// own, or one it delegated to) are skipped.
pub fn overlaps(
&self,
layers: &[Layer],
resources: &[String],
exempt: &dyn Fn(&Holder) -> bool,
ancestors: &dyn Fn(&Layer) -> Vec<NodeId>,
) -> Vec<Denied> {
let mut out = Vec::new();
for c in self.live.iter().filter(|c| !exempt(&c.holder)) {
for want in layers {
if let Some(rel) = c.layers.iter().find_map(|held| relation(want, held, ancestors)) {
out.push(Denied { what: format!("{}:{}", want.0, want.1.0), relation: rel, claim: c.id.clone() });
}
}
for r in resources.iter().filter(|r| c.resources.contains(r)) {
out.push(Denied { what: r.clone(), relation: "resource", claim: c.id.clone() });
}
}
out
}
/// Claims the request's layers and resources for its holder, all or nothing. Overlap with
/// the holder's own claim merges into it; overlap with anyone else's (but `exempt`
/// holders') denies the whole request.
pub fn claim(&mut self, req: Request, now: u64, exempt: &dyn Fn(&Holder) -> bool, ancestors: &dyn Fn(&Layer) -> Vec<NodeId>) -> Result<String, Vec<Denied>> {
let Request { holder, layers, resources, note, ttl_secs } = req;
self.expire(now);
let mine = |h: &Holder| h.key == holder.key || exempt(h);
let denied = self.overlaps(&layers, &resources, &mine, ancestors);
if !denied.is_empty() {
return Err(denied);
}
let ttl_ms = ttl_secs.clamp(MIN_TTL_SECS, MAX_TTL_SECS) * 1000;
// Merge into the holder's claim that overlaps (or its only one), so a lease grows
// instead of piling up.
let own = self.live.iter().position(|c| {
c.holder.key == holder.key
&& (layers.iter().any(|w| c.layers.iter().any(|h| relation(w, h, ancestors).is_some()))
|| resources.iter().any(|r| c.resources.contains(r)))
});
if let Some(i) = own {
let c = &mut self.live[i];
for l in layers {
if !c.layers.contains(&l) {
c.layers.push(l);
}
}
for r in resources {
if !c.resources.contains(&r) {
c.resources.push(r);
}
}
if !note.is_empty() {
c.note = note;
}
c.ttl_ms = c.ttl_ms.max(ttl_ms);
c.expires = now + c.ttl_ms;
return Ok(c.id.clone());
}
if self.live.iter().filter(|c| c.holder.key == holder.key).count() >= MAX_PER_CLIENT {
return Err(vec![Denied { what: format!("{MAX_PER_CLIENT} claims"), relation: "limit", claim: String::new() }]);
}
if self.live.len() >= MAX_CLAIMS {
return Err(vec![Denied { what: format!("{MAX_CLAIMS} claims on the project"), relation: "limit", claim: String::new() }]);
}
self.next += 1;
let id = format!("c{}", self.next);
self.live.push(Claim { id: id.clone(), holder, layers, resources, note, ttl_ms, expires: now + ttl_ms });
Ok(id)
}
/// Releases the holder's claims: the one with `id`, those covering `layers`, or all of them.
/// Returns the released ids.
pub fn release(&mut self, key: &str, id: Option<&str>, layers: &[Layer]) -> Vec<String> {
let hit = |c: &Claim| {
c.holder.key == key && id.is_none_or(|i| c.id == i) && (layers.is_empty() || c.layers.iter().any(|l| layers.contains(l)))
};
let gone: Vec<String> = self.live.iter().filter(|c| hit(c)).map(|c| c.id.clone()).collect();
self.live.retain(|c| !hit(c));
gone
}
/// Releases every claim of the holders `drop` picks (a finished chat turn, a project switch).
pub fn release_where(&mut self, drop: impl Fn(&Holder) -> bool) {
self.live.retain(|c| !drop(&c.holder));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn who(k: &str) -> Holder {
Holder { key: k.into(), label: k.into(), session: k.into() }
}
/// A page "p" where 2 sits in 1 and 3 in 2.
fn ancestors(l: &Layer) -> Vec<NodeId> {
match l.1.0 {
3 => vec![NodeId(2), NodeId(1)],
2 => vec![NodeId(1)],
_ => vec![],
}
}
fn at(n: u64) -> Layer {
("p".into(), NodeId(n))
}
fn req(k: &str, layers: Vec<Layer>, resources: Vec<String>) -> Request {
Request { holder: who(k), layers, resources, note: String::new(), ttl_secs: 60 }
}
fn none(_: &Holder) -> bool {
false
}
#[test]
fn a_claim_covers_its_subtree_and_denies_around_and_inside_it() {
let mut c = Claims::default();
c.claim(req("a", vec![at(2)], vec![]), 0, &none, &ancestors).unwrap();
for (n, rel) in [(2, "self"), (3, "ancestor"), (1, "descendant")] {
let d = c.claim(req("b", vec![at(n)], vec![]), 0, &none, &ancestors).unwrap_err();
assert_eq!(d[0].relation, rel, "layer {n}");
}
assert!(c.claim(req("b", vec![at(9)], vec![]), 0, &none, &ancestors).is_ok());
}
#[test]
fn a_denied_request_claims_nothing() {
let mut c = Claims::default();
c.claim(req("a", vec![at(2)], vec![]), 0, &none, &ancestors).unwrap();
assert!(c.claim(req("b", vec![at(9), at(3)], vec![]), 0, &none, &ancestors).is_err());
assert!(c.live.iter().all(|x| x.holder.key == "a"), "9 was not claimed either");
}
#[test]
fn own_claims_merge_and_leases_slide_until_the_holder_goes_quiet() {
let mut c = Claims::default();
let first = c.claim(req("a", vec![at(1)], vec![]), 0, &none, &ancestors).unwrap();
let again = c.claim(req("a", vec![at(3)], vec![]), 1_000, &none, &ancestors).unwrap();
assert_eq!((first.clone(), c.live.len()), (again, 1));
c.renew("a", 50_000);
c.expire(100_000);
assert_eq!(c.live.len(), 1, "renewed at 50 s, lives to 110 s");
c.expire(110_001);
assert!(c.live.is_empty());
}
#[test]
fn resources_and_release() {
let mut c = Claims::default();
c.claim(req("a", vec![], vec!["variables".into()]), 0, &none, &ancestors).unwrap();
let d = c.overlaps(&[], &["variables".into()], &none, &ancestors);
assert_eq!(d[0].relation, "resource");
assert!(c.overlaps(&[], &["variables".into()], &|h| h.key == "a", &ancestors).is_empty(), "exempt holder");
assert_eq!(c.release("b", None, &[]), Vec::<String>::new(), "only the holder releases");
assert_eq!(c.release("a", None, &[]).len(), 1);
}
#[test]
fn limits_stop_a_runaway_agent() {
let mut c = Claims::default();
for n in 0..MAX_PER_CLIENT as u64 {
c.claim(req("a", vec![at(100 + n)], vec![]), 0, &none, &ancestors).unwrap();
}
let d = c.claim(req("a", vec![at(500)], vec![]), 0, &none, &ancestors).unwrap_err();
assert_eq!(d[0].relation, "limit");
}
}
+362
View File
@@ -0,0 +1,362 @@
//! Several agents on one project (`specs/swarm-safety/spec.md`). Every call from the app's MCP
//! server and chat goes through [`Swarm::call`]: a write is checked against other agents'
//! claims (blocked) and against the layers others changed since this agent last looked at that
//! artboard (reported, or refused when the caller passed `ifRev`), then runs through the run
//! tracker, and what the check found rides back in a trailing `{"swarm": ...}` block.
//!
//! Nothing here waits: a check answers at once, so agents cannot deadlock. The person at the
//! canvas is never checked; their edits reach agents as conflicts by `You`.
pub mod claims;
use super::exec::{self, Host};
use super::{Client, Out, ToolResult};
use crate::editor::Editor;
use crate::editor::changes::Filter;
use crate::model::NodeId;
use crate::runs::Tracker;
use claims::{Claims, Denied, Holder, Layer, Request};
use serde_json::{Value, json};
use std::collections::HashMap;
use std::path::PathBuf;
/// Conflicts listed per call; the rest are counted.
const MAX_CONFLICTS: usize = 20;
/// Parent links followed when deciding whether a claim belongs to an agent's own helper.
const MAX_DELEGATION_DEPTH: usize = 8;
/// A delegated helper's claim lasts as long as the helper may run.
pub const HELPER_TTL_SECS: u64 = 900;
/// How far a write reaches below the layers it names.
#[derive(Debug, Clone, Copy, PartialEq)]
enum Reach {
/// The layer's own fields.
Node,
/// Adds layers under it: two agents appending to one frame do not conflict.
Children,
/// Rebuilds, moves or deletes it with everything under it.
Subtree,
}
fn reach(name: &str, args: &Value) -> Reach {
match name {
"write_html" if args["mode"] == "replace" => Reach::Subtree,
"write_html" | "create_shape" | "duplicate_nodes" | "create_instance" | "create_artboard" | "annotate" => Reach::Children,
"delete_nodes" | "move_nodes" | "detach_instance" | "swap_instance" => Reach::Subtree,
_ => Reach::Node,
}
}
/// The shared resource a tool changes, which a claim can hold for one agent.
fn resource_of(name: &str) -> Option<&'static str> {
match name {
"set_variables" | "delete_variables" | "create_collection" | "set_variable_mode" | "set_tokens" | "import_design_system"
| "enable_library" | "update_library" | "disable_library" => Some("variables"),
"create_component" | "import_components" | "merge_variants" | "set_component_props" | "apply_ui_kit" => Some("components"),
"create_page" | "rename_pages" | "delete_pages" => Some("pages"),
_ => None,
}
}
#[derive(Debug, Default)]
pub struct Swarm {
pub claims: Claims,
/// Revision each client last read or wrote each artboard at: what its picture of it is from.
seen: HashMap<(String, String, NodeId), u64>,
/// Revision of each client's first call, for artboards it never looked at.
first: HashMap<String, u64>,
/// Session of the agent that delegated each session.
parents: HashMap<String, String>,
project: Option<PathBuf>,
}
/// The page id and the layer of each (page index, node) pair.
fn layers(ed: &Editor, named: &[(usize, NodeId)]) -> Vec<Layer> {
named.iter().map(|(p, id)| (ed.pages[*p].id.clone(), *id)).collect()
}
fn ancestors_in(ed: &Editor) -> impl Fn(&Layer) -> Vec<NodeId> + '_ {
|l: &Layer| ed.pages.iter().position(|p| p.id == l.0).map_or_else(Vec::new, |i| ed.page_doc(i).ancestors(l.1))
}
fn holder(tracker: &mut Tracker, client: &Client) -> Holder {
Holder { key: client.key(), label: tracker.label(client), session: client.session.clone() }
}
fn denied_json(claims: &Claims, denied: &[Denied], now: u64) -> Vec<Value> {
denied
.iter()
.map(|d| {
let c = claims.live.iter().find(|c| c.id == d.claim);
json!({
"id": d.what, "relation": d.relation, "claimId": d.claim,
"heldBy": c.map(|c| c.holder.label.clone()),
"note": c.map(|c| c.note.clone()),
"expiresInSeconds": c.map(|c| c.expires_in_secs(now)),
})
})
.collect()
}
impl Swarm {
/// Another project means other layers: claims and what agents saw no longer apply.
fn sync_project(&mut self, dir: Option<PathBuf>) {
if dir != self.project {
*self = Swarm { project: dir, parents: std::mem::take(&mut self.parents), ..Swarm::default() };
}
}
/// `session` was delegated (directly or through helpers) by `ancestor`.
fn delegated_by(&self, session: &str, ancestor: &str) -> bool {
let mut cur = session;
for _ in 0..MAX_DELEGATION_DEPTH {
match self.parents.get(cur) {
Some(p) if p == ancestor => return true,
Some(p) => cur = p,
None => return false,
}
}
false
}
/// Run one call for `client`: the claim tools here, everything else checked, then through
/// the run tracker.
pub fn call(&mut self, tracker: &mut Tracker, host: &mut dyn Host, client: &Client, name: &str, args: &Value, now: u64) -> ToolResult {
self.sync_project(host.project().map(|p| p.dir.clone()));
if let Some(p) = &client.parent {
self.parents.insert(client.session.clone(), p.clone());
}
self.claims.expire(now);
self.claims.renew(&client.key(), now);
let key = client.key();
let start = host.editor().changes.rev();
self.first.entry(key.clone()).or_insert(start);
match name {
"claim_nodes" => return self.claim_tool(tracker, host, client, args, now),
"release_claims" => return self.release_tool(host, client, args),
"list_claims" => return text(self.list(host.editor(), now)),
_ => {}
}
let ed = host.editor();
ed.finish_loading();
let named = exec::named_layers(ed, args);
let mut notes = serde_json::Map::new();
if crate::mcp::tools::edits_doc(name, args) {
let wanted = layers(ed, &named);
let resources: Vec<String> = resource_of(name).map(String::from).into_iter().collect();
let found = self.claims.overlaps(&wanted, &resources, &|h| h.key == key, &ancestors_in(ed));
// An agent may work inside its own helpers' claims; it is told, not stopped.
let (warn, block): (Vec<Denied>, Vec<Denied>) = found.into_iter().partition(|d| {
self.claims.live.iter().find(|c| c.id == d.claim).is_some_and(|c| self.delegated_by(&c.holder.session, &client.session))
});
if !block.is_empty() {
return Err(json!({
"error": "claimed", "applied": false,
"claims": denied_json(&self.claims, &block, now),
"hint": "Another agent is working there. Work elsewhere, or retry after expiresInSeconds; never wait in a loop.",
})
.to_string());
}
if !warn.is_empty() {
notes.insert("claimWarnings".into(), json!(denied_json(&self.claims, &warn, now)));
}
let if_rev = args["ifRev"].as_u64();
let reference = if_rev.unwrap_or_else(|| self.reference(&key, ed, &named));
let strict = if_rev.is_some() && args["onConflict"] != "warn"
|| std::env::var("PAPERCRAFT_CONFLICTS").is_ok_and(|v| v == "strict") && args["onConflict"] != "warn";
let conflicts = conflicts(ed, &key, reference, &named, reach(name, args));
if strict && !conflicts.is_empty() {
return Err(json!({
"error": "conflict", "applied": false, "rev": ed.changes.rev(), "refRev": reference,
"conflicts": conflicts,
"hint": format!("Re-read the layers above (get_changes {{since: {reference}, exclude: \"self\"}}), then retry with ifRev {}.", ed.changes.rev()),
})
.to_string());
}
if !conflicts.is_empty() {
notes.insert("refRev".into(), json!(reference));
notes.insert(
"hint".into(),
json!(format!("Others changed layers you just wrote. get_changes {{since: {reference}, exclude: \"self\"}}, re-read those layers, adjust; do not retry blindly.")),
);
notes.insert("conflicts".into(), json!(conflicts));
}
}
let artboards: Vec<(String, NodeId)> = named.iter().map(|(p, id)| (ed.pages[*p].id.clone(), artboard(ed, *p, *id))).collect();
let r = tracker.call(host, client, name, args, now);
if r.is_ok() && name == "finish_working_on_nodes" {
let mine = layers(host.editor(), &named);
self.claims.release(&key, None, &mine);
}
let rev = host.editor().changes.rev();
for (page, a) in artboards {
self.seen.insert((key.clone(), page, a), rev);
}
if notes.is_empty() {
return r;
}
notes.insert("rev".into(), json!(rev));
notes.insert("applied".into(), json!(r.is_ok()));
r.map(|mut out| {
out.push(Out::Text(json!({"swarm": notes}).to_string()));
out
})
}
/// The oldest revision the client last saw any of these layers' artboards at (its first
/// call's for an artboard it never looked at): coarse, so it over-reports rather than misses.
fn reference(&self, key: &str, ed: &Editor, named: &[(usize, NodeId)]) -> u64 {
let first = self.first.get(key).copied().unwrap_or(0);
named
.iter()
.map(|(p, id)| self.seen.get(&(key.to_string(), ed.pages[*p].id.clone(), artboard(ed, *p, *id))).copied().unwrap_or(first))
.min()
.unwrap_or(first)
}
fn claim_tool(&mut self, tracker: &mut Tracker, host: &mut dyn Host, client: &Client, args: &Value, now: u64) -> ToolResult {
let ed = host.editor();
ed.finish_loading();
let keys: Vec<String> = args["ids"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()).unwrap_or_default();
let named = exec::named_layers(ed, &json!({"nodeIds": keys, "pageId": args["pageId"]}));
if named.len() < keys.len() {
return Err("some ids are not layers of this project (ids look like \"3fa9c1:14\")".into());
}
let resources: Vec<String> = args["resources"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()).unwrap_or_default();
if let Some(bad) = resources.iter().find(|r| !claims::RESOURCES.contains(&r.as_str())) {
return Err(format!("no resource `{bad}` (resources: {})", claims::RESOURCES.join(", ")));
}
if named.is_empty() && resources.is_empty() {
return Err("claim ids, resources or both".into());
}
let ttl = args["ttlSeconds"].as_u64().unwrap_or(claims::DEFAULT_TTL_SECS);
let note: String = args["note"].as_str().unwrap_or("").chars().take(120).collect();
let req = Request { holder: holder(tracker, client), layers: layers(ed, &named), resources: resources.clone(), note, ttl_secs: ttl };
let me = client.session.clone();
let mine = |h: &Holder| self.delegated_by(&h.session, &me);
let exempt: Vec<String> = self.claims.live.iter().filter(|c| mine(&c.holder)).map(|c| c.holder.key.clone()).collect();
match self.claims.claim(req, now, &|h| exempt.contains(&h.key), &ancestors_in(ed)) {
Ok(id) => {
let c = self.claims.live.iter().find(|c| c.id == id).ok_or("claim vanished")?;
text(json!({
"claimId": id,
"claimed": named.iter().map(|(p, n)| exec::node_key(ed, *p, *n)).collect::<Vec<_>>(),
"resources": resources,
"ttlSeconds": c.ttl_ms / 1000,
"rev": ed.changes.rev(),
}))
}
Err(denied) => text(json!({"claimed": [], "denied": denied_json(&self.claims, &denied, now),
"hint": "Nothing was claimed. Do other work first or report back; never wait in a loop."})),
}
}
fn release_tool(&mut self, host: &mut dyn Host, client: &Client, args: &Value) -> ToolResult {
let ed = host.editor();
let named = exec::named_layers(ed, &json!({"nodeIds": args["ids"], "pageId": args["pageId"]}));
let mut released = self.claims.release(&client.key(), args["claimId"].as_str(), &layers(ed, &named));
if let Some(rs) = args["resources"].as_array() {
for c in self.claims.live.iter_mut().filter(|c| c.holder.key == client.key()) {
c.resources.retain(|r| !rs.iter().any(|x| x == r.as_str()));
}
let empty: Vec<String> = self.claims.live.iter().filter(|c| c.layers.is_empty() && c.resources.is_empty()).map(|c| c.id.clone()).collect();
self.claims.live.retain(|c| !empty.contains(&c.id));
released.extend(empty);
}
text(json!({"released": released}))
}
fn list(&self, ed: &Editor, now: u64) -> Value {
let key = |l: &Layer| ed.pages.iter().position(|p| p.id == l.0).map(|p| exec::node_key(ed, p, l.1));
json!({"claims": self.claims.live.iter().map(|c| json!({
"claimId": c.id, "holder": c.holder.label, "ids": c.layers.iter().filter_map(key).collect::<Vec<_>>(),
"resources": c.resources, "note": c.note, "expiresInSeconds": c.expires_in_secs(now),
})).collect::<Vec<_>>()})
}
/// Claims a delegated helper's `scope` for it before it starts, so its work is fenced off
/// from the start. Returns the helper's identity, or the denial to give the caller.
pub fn claim_for_helper(&mut self, tracker: &mut Tracker, ed: &mut Editor, caller: &Client, role: &str, scope: &Value, now: u64) -> Result<Client, String> {
ed.finish_loading();
let n = self.parents.values().filter(|p| **p == caller.session).count() + 1;
let helper = Client::agent(&format!("{} / {role} {n}", tracker.label(caller)), Some(caller.session.clone()));
self.parents.insert(helper.session.clone(), caller.session.clone());
if role != "build" {
return Ok(helper);
}
let keys = scope["ids"].as_array().filter(|a| !a.is_empty());
let resources: Vec<String> = scope["resources"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()).unwrap_or_default();
if keys.is_none() && resources.is_empty() {
return Err("a build helper needs `scope` {ids, resources}: the layers it may change, so other agents keep out".into());
}
let named = exec::named_layers(ed, &json!({"nodeIds": keys}));
let req = Request { holder: holder(tracker, &helper), layers: layers(ed, &named), resources, note: "delegated build".into(), ttl_secs: HELPER_TTL_SECS };
let me = caller.key();
self.claims
.claim(req, now, &|h| h.key == me, &ancestors_in(ed))
.map(|_| helper)
.map_err(|d| json!({"error": "claimed", "denied": denied_json(&self.claims, &d, now), "hint": "Give the helper layers no one else holds."}).to_string())
}
/// A helper ended, a chat turn finished: its claims go.
pub fn release_client(&mut self, client: &Client) {
let key = client.key();
self.claims.release_where(|h| h.key == key);
}
/// Every chat's claims go (the chat turn is over).
pub fn release_chats(&mut self) {
self.claims.release_where(|h| h.key.starts_with(&format!("{}#", super::CHAT_CLIENT)));
}
}
/// The top-level layer `id` sits in (itself when top-level).
fn artboard(ed: &Editor, page: usize, id: NodeId) -> NodeId {
ed.page_doc(page).ancestors(id).last().copied().unwrap_or(id)
}
/// Layers named by a write that others (anyone but `me`, the person included) changed after
/// `since`: the layer itself, or for a subtree write anything under it.
fn conflicts(ed: &Editor, me: &str, since: u64, named: &[(usize, NodeId)], reach: Reach) -> Vec<Value> {
if reach == Reach::Children {
return Vec::new();
}
let mut out: Vec<Value> = Vec::new();
let mut more = 0;
for (p, id) in named {
let page = &ed.pages[*p].id;
let doc = ed.page_doc(*p);
let changed = ed.changes.since(since, Some(page), Filter::Exclude(me));
if changed.truncated {
return vec![json!({"relation": "history_reset", "hint": "the change log does not reach back that far; re-read what you are about to change"})];
}
for g in changed.changes {
let hit = g.ids.iter().find_map(|c| {
if c == id {
Some("self")
} else if reach == Reach::Subtree && doc.ancestors(*c).contains(id) {
Some("descendant")
} else {
None
}
});
let Some(relation) = hit else { continue };
if out.len() >= MAX_CONFLICTS {
more += 1;
continue;
}
out.push(json!({
"node": exec::node_key(ed, *p, *id), "name": doc.get(*id).map(|n| n.name.clone()),
"relation": relation, "by": g.by.unwrap_or_else(|| "You".into()), "rev": g.rev,
}));
}
}
if more > 0 {
out.push(json!({"more": more}));
}
out
}
fn text(v: Value) -> ToolResult {
Ok(vec![Out::Text(v.to_string())])
}
+188 -20
View File
@@ -18,7 +18,11 @@ fix each note, then resolve_feedback with a reply. Use list_resources / open_fil
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, \
a row, a card) so the user sees progress. Inline styles only; flexbox (display:flex, gap, \
padding, align-items, justify-content) is the layout model; no margin, grid or tables.
padding, align-items, justify-content) is the layout model; margins become gaps or spacers \
and grids become rows of cells (reported in warnings); no tables.
- get_basic_info lists the person's design lessons (rules kept from past critiques): follow \
every one. When finish_working_on_nodes returns `next`, offer a critique (ask_user, then \
critique_design); keep a finding as a lesson (design_lessons) only when the person approves.
- Text uses Inter (regular, bold, italic). Use px for font sizes.
- 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: \
@@ -33,6 +37,8 @@ write_html. Name every layer (layer-name) like a real file.\n\
export_design_system writes one back.\n\
- Check your work with get_screenshot. Artboard height is a starting point: when content \
clips set height: \"fit-content\" with update_styles.
- Other agents may share the project: claim_nodes what you will rewrite, read the conflicts a \
write reports (`swarm`) with get_changes, and undo takes back only your own steps.
- Call finish_working_on_nodes when done.";
fn file_id() -> Value {
@@ -63,6 +69,11 @@ fn tool(name: &str, desc: &str, props: Value, required: &[&str], read_only: bool
{
m.insert("fileId".into(), file_id());
}
// Every write can carry the revision the caller last synced to (`swarm`).
if !read_only && let Some(m) = props.as_object_mut() {
m.entry("ifRev").or_insert(json!({"type": "integer", "description": "The `rev` your last result returned: if another agent or the person changed these layers since, nothing is applied and the conflicts are listed."}));
m.entry("onConflict").or_insert(json!({"type": "string", "enum": ["warn"], "description": "Apply even with conflicts (they are listed)."}));
}
let ann = if read_only {
json!({"readOnlyHint": true})
} else if name == "delete_nodes" {
@@ -227,6 +238,25 @@ pub fn definitions() -> Vec<Value> {
&["nodeId"],
true,
),
tool(
"get_bounds",
"Rects of a whole subtree in one read, instead of one get_node_info per layer. Returns \
`columns` [id, parent, x, y, w, h, absX, absY, clip] and `rows` (one array per layer, \
parents before children): x/y/w/h are relative to the parent, absX/absY are page \
coordinates, clip is true when the layer clips its content (clipping or scrolling \
frame), parent is null for a top-level layer; values are rounded to 0.1. Without \
nodeIds it covers every artboard on the page (or pageId); `depth` limits levels, \
counting each given node as 1. At most 2000 rows per call: then `truncated` is true \
and `continueWith` lists the node ids to pass as nodeIds to get the rest. Read-only; \
use it to verify nothing leaves its artboard or clipping parent, or to align and \
space layers, and lint_design's overflow rule to list the offenders.",
json!({"nodeIds": {"type": "array", "items": {"type": "string"},
"description": "Subtrees to measure; omit for all artboards on the page."},
"depth": {"type": "number", "description": "Levels returned, counting each given node as 1 (default: all)."},
"pageId": page_id()}),
&[],
true,
),
tool(
"get_tree_summary",
"Compact indented text tree of a subtree: type, name, id and size per node. Much \
@@ -370,10 +400,15 @@ pub fn definitions() -> Vec<Value> {
frames, specs or URLs and returns key points, `build` makes edits you fully specify. \
It does not see this chat: the task carries everything (frame names or ids, the brief, \
the variables and components to use, exact markup for a build). Only its short answer \
comes back.",
comes back. A build needs `scope`: those layers are claimed for the helper before it \
starts (other agents keep out; you may still edit there) and released when it ends.",
json!({
"role": {"type": "string", "enum": ["qa", "read", "build"]},
"task": {"type": "string", "description": "The complete, self-contained instructions."},
"scope": {"type": "object", "description": "For build: what the helper may change.", "properties": {
"ids": {"type": "array", "items": {"type": "string"}, "description": "Layers (each with everything under it)."},
"resources": {"type": "array", "items": {"type": "string", "enum": ["variables", "components", "pages"]}},
}},
}),
&["role", "task"],
true,
@@ -398,11 +433,87 @@ pub fn definitions() -> Vec<Value> {
with the kit's variables (Light and Dark modes), tokens and components, each a main \
component linked to the library (so the code export and the AI spec write \
<Button variant=\"outline\"> instead of rebuilding divs). Kits: shadcn (shadcn/ui), \
antd (Ant Design), reactbits (React Bits animated pieces), egui (native Rust widgets).",
json!({"kit": {"type": "string", "enum": ["shadcn", "antd", "reactbits", "egui"]}}),
mui (Material UI), mantine, bootstrap (react-bootstrap), chakra (Chakra UI): about 48 \
components and 5 screen layouts each; antd (Ant Design), reactbits (React Bits \
animated pieces), egui (native Rust widgets).",
json!({"kit": {"type": "string", "enum": ["shadcn", "mui", "mantine", "bootstrap", "chakra", "antd", "reactbits", "egui"]}}),
&["kit"],
false,
),
tool(
"claim_nodes",
"Several agents on one project: claim the layers you will rewrite (each with everything \
under it) or a shared resource, so other agents' writes there are refused while you \
work. All or nothing; a denial names the holder and when it expires (never wait in a \
loop: do other work). The claim lasts ttlSeconds after your last call and goes with \
finish_working_on_nodes or release_claims. The person is never blocked.",
json!({
"ids": {"type": "array", "items": {"type": "string"}, "description": "Layer ids (\"3fa9c1:14\")."},
"resources": {"type": "array", "items": {"type": "string", "enum": ["variables", "components", "pages"]}, "description": "Shared things that cannot be split by layer."},
"ttlSeconds": {"type": "integer", "minimum": 10, "maximum": 1800, "description": "Default 300; renewed by each of your calls."},
"note": {"type": "string", "description": "What you are doing, shown to others (120 characters)."},
}),
&[],
false,
),
tool(
"release_claims",
"Release your claims: one by claimId, those on ids or resources, or all of them when \
nothing is given.",
json!({
"claimId": {"type": "string"},
"ids": {"type": "array", "items": {"type": "string"}},
"resources": {"type": "array", "items": {"type": "string", "enum": ["variables", "components", "pages"]}},
}),
&[],
false,
),
tool(
"list_claims",
"Who is working where: every live claim with its holder, layers, resources, note and \
seconds left.",
json!({}),
&[],
true,
),
tool(
"critique_design",
"Critique finished work the way a design team does (offer it with ask_user when \
finish_working_on_nodes says the design changed). Judges against the person's \
objective, through independent reviewers: vision (the person's brief and notes), ux \
(Nielsen's heuristics), flow (coherent journeys across screens), qa (states and edge \
cases), visual, accessibility (W3C POUR), copy and end-user (a walkthrough as \
the audience). Returns the reviewers' briefs, the finding format and 0-4 severity \
scale, lint, the arrows between screens (dead ends, unreached screens), the \
person's lessons and the steps: review each persona independently, merge, fix \
severity 3-4, report, then ask which findings to keep as lessons.",
json!({
"nodeId": {"type": "string", "description": "Artboard or subtree to critique; omit for the whole page."},
"objective": {"type": "string", "description": "What the design must achieve, for whom, and its top tasks (from the brief or the person)."},
"audience": {"type": "string", "description": "Who the end-user reviewer plays (\"first-time shopper on a phone\")."},
"personas": {"type": "array", "items": {"type": "string", "enum": ["vision", "ux", "flow", "qa", "visual", "accessibility", "copy", "end-user"]}, "description": "Reviewers to run (default: all)."},
}),
&[],
true,
),
tool(
"design_lessons",
"The person's design lessons: rules from past critiques that every design must \
follow (get_basic_info lists them too; read them before designing). Without \
arguments, lists those that apply in the open project. `add` stores one ONLY after \
the person approved it in this conversation (ask_user); never on your own. \
`remove` deletes one by id.",
json!({
"add": {"type": "object", "properties": {
"rule": {"type": "string", "description": "One line, an instruction (\"Screens keep their device height; the main column scrolls\")."},
"why": {"type": "string", "description": "What went wrong that taught it."},
"scope": {"type": "string", "enum": ["all", "project"], "description": "Every project (default) or only the open one."},
}, "required": ["rule"]},
"remove": {"type": "string", "description": "Id of a lesson to delete (\"L3\")."},
}),
&[],
false,
),
tool(
"set_code_link",
"Link a main component (or an instance's main) to its code in a library, used by the \
@@ -520,6 +631,23 @@ pub fn definitions() -> Vec<Value> {
&["nodeIds"],
false,
),
tool(
"export_presentation",
"Export pages as a presentation PDF, the way a designer sends work to a client: a \
cover, contents, a divider per page, one 1920×1080 slide per screen (its name and \
size; narrow side panels share a slide) and a closing slide, numbered. Screens are \
the page's declared screens, else its top-level frames of 300×300 or more; they stay \
vector. Returns the file path.",
json!({
"pageIds": {"type": "array", "items": {"type": "string"}, "description": "Pages to include, in this order (default: every page)."},
"path": {"type": "string", "description": "Output file (default: Downloads/<title>.pdf)."},
"title": {"type": "string", "description": "Deck title (default: the project name)."},
"subtitle": {"type": "string", "description": "Cover subtitle (default: screen and page counts)."},
"author": {"type": "string", "description": "Shown on the cover after the date."},
}),
&[],
false,
),
// ------------------------------------------------------------ writing
tool(
"create_artboard",
@@ -527,7 +655,10 @@ pub fn definitions() -> Vec<Value> {
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.",
left/top are given. Default sizes: desktop 1440×900, tablet 768×1024, mobile 390×844. \
At a device width, height \"fit-content\" becomes the device height; content \
taller than a screen scrolls in its main column (else the screen). overflow \
\"visible\" lets the board grow instead.",
json!({
"name": {"type": "string", "description": "Layer name."},
"styles": {
@@ -552,8 +683,9 @@ pub fn definitions() -> Vec<Value> {
\"replace\" replaces targetNodeId with it.\n\
- Inline styles only. Flexbox (display:flex, flex-direction, gap, padding, \
align-items, justify-content, align-self, flex-grow, flex-wrap) is the layout model; block \
elements stack vertically. position:absolute with left/top is supported. No margin, \
grid or tables.\n\
elements stack vertically. position:absolute with left/top is supported. Margins become \
gaps or spacers, display:grid becomes rows of cells, text inputs show their \
placeholder (each reported in warnings). No tables.\n\
- Text: font-size, font-weight (≥600 = bold), font-style, color, line-height, \
letter-spacing, text-align, text-decoration (including wavy and text-decoration-color); \
<b>/<i>/<u>/<s>/<span style> make rich text. Font family is always Inter.\n\
@@ -682,11 +814,21 @@ pub fn definitions() -> Vec<Value> {
"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 \
names (Frame, Rectangle), layers or text overflowing their artboard or clipping \
parent (rule `overflow`, never auto-fixed; scrolling frames are exempt), text too \
narrow for its words (text-collapsed), and hard-coded colors that equal a variable. Run it before \
finish_working_on_nodes and fix errors and warnings; `info` items are suggestions. \
Pass `fix: true` to automatically snap spacing/padding to 4px, relink matching color \
variables, snap text sizes, and enlarge small touch targets.",
variables, snap text sizes, and enlarge small touch targets. After the first full pass, \
lint only what you changed: `changedOnly: true` checks the layers changed since this \
client's last whole-page lint, `since: <rev>` since that revision (every write tool \
returns `rev`); both add what a change can affect (ancestors, descendants, siblings, \
layers bound to an edited variable), work with `fix`, and fall back to a full pass \
when the log does not reach back or this client has not linted yet. The result's \
`covered` says what was checked: {checked, total, since, partial, fullPassBecause}.",
json!({"nodeId": {"type": "string", "description": "Subtree to check; omit for the whole page."},
"since": {"type": "number", "description": "Check only layers changed after this revision (`rev` from a write tool, get_basic_info or get_changes), plus the layers a change affects."},
"changedOnly": {"type": "boolean", "description": "Same as `since`, with the revision of this client's last whole-page lint of the page."},
"scale": {"type": "array", "items": {"type": "number"},
"description": "Allowed font sizes. Default 12, 14, 16, 20, 24, 32, 40, 48, 64."},
"fix": {"type": "boolean", "description": "Apply safe automatic fixes before reporting remaining issues."},
@@ -694,6 +836,25 @@ pub fn definitions() -> Vec<Value> {
&[],
false,
),
tool(
"get_changes",
"Which layers changed after revision `since`. Every write tool returns `rev`, the \
revision after the call (get_basic_info reports the current one); each edit, undo and \
redo adds layers to the log, and editing a variable counts as changing every layer \
bound to it. Returns {rev, since, truncated, changes: [{page, ids, by, rev}]}: ids use \
the same \"<page>:<n>\" form as the other tools, `by` is the client that made them \
(null for the person using the app), `rev` the latest revision in the group. Reads \
change nothing. `truncated: true` means the log (the last 10000 layer changes) no \
longer reaches back to `since`, or `since` is newer than `rev`: do a full pass instead. \
`exclude: \"self\"` leaves out your own changes, to see what others changed; `by` keeps \
one client's. Layers deleted since are listed too. Use it with lint_design `since`.",
json!({"since": {"type": "number", "description": "A revision you have seen; 0 for everything on record."},
"page": {"type": "string", "description": "Only changes on this page (id)."},
"exclude": {"type": "string", "enum": ["self"], "description": "\"self\": leave out changes made by this client."},
"by": {"type": "string", "description": "Only changes made by this client (its `by` label)."}}),
&["since"],
true,
),
tool(
"get_variables",
"Variables of the page's project. Default JSON: `variables` (name -> hex), `tokens`, and \
@@ -932,14 +1093,15 @@ pub fn definitions() -> Vec<Value> {
tool(
"set_screens",
"Declare frames as screens (one device viewport of the product, not a section, slot or \
component) or as overlays (a drawer, modal or sheet inside a screen), or clear the role \
with `none`. One undo step. Inside a screen, edits to layers fully hidden behind an \
component), as overlays (a drawer, modal or sheet inside a screen) or as sections (a \
frame grouping screens on the canvas; its fill is removed, the canvas draws its outline \
and name), or clear the role with `none`. One undo step. Inside a screen, edits to layers fully hidden behind an \
overlay or opaque layer are refused (pass allowHidden:true to edit them on purpose), \
and partly covered ones are reported. get_basic_info lists screens; get_node_info tells \
a layer's visibility in its screen.",
json!({
"nodeIds": ids("Frames to declare."),
"role": {"type": "string", "enum": ["screen", "overlay", "none"], "description": "Default screen."},
"role": {"type": "string", "enum": ["screen", "overlay", "section", "none"], "description": "Default screen."},
"pageId": page_id(),
}),
&["nodeIds"],
@@ -1103,15 +1265,19 @@ pub fn definitions() -> Vec<Value> {
),
tool(
"undo",
"Undo the last change on a page (the user's or an agent's).",
json!({"pageId": page_id()}),
"Undo a change. An agent's undo (scope `mine`, its default) takes back its own newest \
step and leaves later steps by others in place; it is refused (`undo_blocked`, with \
the steps in the way) when one of them touched the same layers. Scope `page` undoes \
the page's last step, whoever made it.",
json!({"pageId": page_id(), "scope": {"type": "string", "enum": ["mine", "page"]}}),
&[],
false,
),
tool(
"redo",
"Redo the last undone change on a page.",
json!({"pageId": page_id()}),
"Redo the last undone change on a page. With scope `mine` (an agent's default) only \
when that step was the caller's own.",
json!({"pageId": page_id(), "scope": {"type": "string", "enum": ["mine", "page"]}}),
&[],
false,
),
@@ -1186,7 +1352,9 @@ border, border-top/border-right/border-bottom/border-left, border-color/width/st
border-radius (1–4 values), opacity, mix-blend-mode, color, font-size, font-weight, font-style,
line-height, letter-spacing, text-align, text-decoration (including `text-decoration-color` and
`text-decoration-style: wavy`, e.g. `text-decoration: underline wavy var(--warning)`).
Ignored (reported back): margin, grid, box-shadow, transform, filters, transitions.";
Rewritten (reported in warnings): margin (gaps or spacers; `auto` pushes), display:grid (rows
of cells; fr tracks grow), inline boxes (chips), <input>/<textarea> (a box with its placeholder).
Ignored (reported back): box-shadow, transform, filters, transitions.";
pub const GUIDE_WHITEBOARD: &str = "\
# Whiteboard elements
@@ -1210,11 +1378,11 @@ pub fn edits_doc(name: &str, args: &Value) -> bool {
fn classify(name: &str, args: &Value) -> Option<bool> {
Some(match name {
"list_resources" | "get_basic_info" | "get_inventory" | "get_selection" | "get_node_info" | "get_children"
| "get_tree_summary" | "get_screenshot" | "get_jsx" | "get_design_spec" | "verify_code"
| "get_bounds" | "get_tree_summary" | "get_screenshot" | "get_jsx" | "get_design_spec" | "verify_code"
| "get_computed_styles" | "get_fill_image" | "zoom_image" | "ask_user" | "fetch_reference_images" | "generate_image" | "report_issue" | "design_rules" | "delegate" | "find_nodes" | "get_font_family_info"
| "get_annotations" | "get_annotation_template" | "get_guide" | "get_feedback" | "get_variables" | "get_components" | "search_icons"
| "diff_frames" | "publish_library" | "list_libraries" => false,
"select_nodes" | "set_viewport" | "export" | "export_combined_pdf" | "export_design_system" | "open_file"
| "diff_frames" | "publish_library" | "list_libraries" | "get_changes" | "critique_design" | "design_lessons" | "claim_nodes" | "release_claims" | "list_claims" => false,
"select_nodes" | "set_viewport" | "export" | "export_combined_pdf" | "export_presentation" | "export_design_system" | "open_file"
| "create_file" | "rename_file" | "finish_working_on_nodes" => false,
"lint_design" => args["fix"] == true,
"create_page" | "rename_pages" | "delete_pages" | "create_artboard" | "write_html"
+5
View File
@@ -444,6 +444,10 @@ pub struct Node {
/// it is translucent (a scrim).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub overlay: bool,
/// A section: a frame that groups screens on the canvas. Drawn without a fill, with a faint
/// outline and its name (`editor::screens`).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub section: bool,
/// How far a [`Node::scroll`] frame is scrolled, in world units. A view state: not saved.
#[serde(skip)]
pub scroll_y: f64,
@@ -601,6 +605,7 @@ impl Node {
scroll: false,
screen: false,
overlay: false,
section: false,
scroll_y: 0.0,
props: Vec::new(),
prop_values: BTreeMap::new(),
+4
View File
@@ -87,6 +87,9 @@ pub struct Manifest {
/// Old manifests have no groups and load with none.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub page_groups: BTreeMap<String, Vec<String>>,
/// Page id -> icon (`set:name` from the installed icon sets) shown in the pages list.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub page_icons: BTreeMap<String, String>,
}
impl Manifest {
@@ -106,6 +109,7 @@ impl Manifest {
active_page: Some(page.id.clone()),
pages: vec![page],
page_groups: BTreeMap::new(),
page_icons: BTreeMap::new(),
}
}
+6
View File
@@ -124,6 +124,12 @@ pub fn config_dir() -> Option<PathBuf> {
}
}
/// The web build has no config folder: settings keep their defaults.
#[cfg(target_arch = "wasm32")]
pub fn config_dir() -> Option<PathBuf> {
None
}
impl Store {
/// `$PAPERCRAFT_HOME`, else `~/Documents/papercraft` (`~/Documents/paper-rs` when only that exists).
pub fn default_root() -> PathBuf {
+16 -7
View File
@@ -7,6 +7,11 @@ use crate::assets;
use crate::geom::Rect;
use crate::model::BlendMode;
use std::collections::HashMap;
use std::time::Duration;
use web_time::Instant;
/// An image not drawn for this long leaves the GPU; drawing it again uploads it again.
const IMAGE_IDLE: Duration = Duration::from_secs(60);
use std::sync::Arc;
use wgpu::util::DeviceExt;
@@ -167,7 +172,8 @@ pub struct CanvasRenderer {
img_layout: wgpu::BindGroupLayout,
img_sampler: wgpu::Sampler,
blank_img: wgpu::BindGroup,
images: HashMap<String, wgpu::BindGroup>,
/// Uploaded images and when each was last drawn.
images: HashMap<String, (wgpu::BindGroup, Instant)>,
world: Option<WorldBuffers>,
msaa: Option<(wgpu::TextureView, [u32; 2])>,
format: wgpu::TextureFormat,
@@ -426,12 +432,14 @@ impl CanvasRenderer {
}
fn image_bg(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, id: &str) {
if self.images.contains_key(id) {
if let Some((_, used)) = self.images.get_mut(id) {
*used = Instant::now();
return;
}
let Some(a) = assets::get(id) else { return };
let Some(rgba) = assets::get(id).and_then(|a| a.rgba()) else { return };
let (width, height) = rgba.dimensions();
// Mip chain so minified images stay smooth.
let mut levels = vec![a.rgba.clone()];
let mut levels = vec![rgba];
while levels.last().unwrap().width() > 1 || levels.last().unwrap().height() > 1 {
let l = levels.last().unwrap();
let (w, h) = ((l.width() / 2).max(1), (l.height() / 2).max(1));
@@ -445,7 +453,7 @@ impl CanvasRenderer {
break;
}
}
let tex = texture_2d(device, "image", a.width, a.height, levels.len() as u32);
let tex = texture_2d(device, "image", width, height, levels.len() as u32);
for (i, l) in levels.iter().enumerate() {
write_texture(queue, &tex, i as u32, l);
}
@@ -465,7 +473,7 @@ impl CanvasRenderer {
},
],
});
self.images.insert(id.to_string(), bg);
self.images.insert(id.to_string(), (bg, Instant::now()));
}
/// Rebuild the world vertex/index buffers when the item list changed.
@@ -575,6 +583,7 @@ impl CanvasRenderer {
}
}
}
self.images.retain(|_, (_, used)| used.elapsed() < IMAGE_IDLE);
self.sync_world(device, &frame.items);
// Screen-space batches (underlay + overlay) are small: rebuilt every frame.
@@ -715,7 +724,7 @@ impl CanvasRenderer {
.image
.as_ref()
.and_then(|i| self.images.get(i))
.unwrap_or(&self.blank_img);
.map_or(&self.blank_img, |(bg, _)| bg);
pass.set_bind_group(2, img, &[]);
pass.set_vertex_buffer(0, vb.slice(..));
pass.set_index_buffer(ib.slice(..), wgpu::IndexFormat::Uint32);
+14
View File
@@ -369,6 +369,9 @@ pub fn draw_text(
}
}
/// A section's outline: neutral grey, readable on light and dark canvases.
const SECTION_OUTLINE: [f32; 4] = [0.5, 0.5, 0.55, 0.35];
/// Interactive overlay: hover, selection, handles, marquee, guides, labels, caret.
pub fn overlay(mb: &mut MeshBuilder, ed: &Editor, offset: V2) {
let v = View {
@@ -381,6 +384,17 @@ pub fn overlay(mb: &mut MeshBuilder, ed: &Editor, offset: V2) {
let z = ed.camera.zoom;
mb.set_clip(Some(v.canvas_rect()));
// Sections have no fill: a faint outline shows where each one is.
let canvas = v.canvas_rect();
for n in ed.doc.nodes.values().filter(|n| n.section && n.visible) {
if let Some(wr) = ed.doc.world_rect(n.id) {
let sr = v.rect_to_screen(&wr);
if sr.intersects(&canvas) {
mb.rect_outline(sr, 1.0, SECTION_OUTLINE);
}
}
}
// Frame name labels.
for (id, r) in ed.frame_labels() {
let n = ed.doc.get(id).unwrap();
+21 -2
View File
@@ -518,7 +518,8 @@ impl Tracker {
}
}
/// Runs `exec::call`, recording it in `client`'s open run when it edits the document.
/// Runs `exec::call`, recording it in `client`'s open run when it edits the document. The
/// change log attributes what the call changes to `client`.
pub fn call(
&mut self,
host: &mut dyn Host,
@@ -526,6 +527,24 @@ impl Tracker {
name: &str,
args: &Value,
now: u64,
) -> ToolResult {
let who = crate::editor::changes::Who {
key: client.key(),
label: self.label(client),
};
host.editor().changes.client = Some(std::sync::Arc::new(who));
let r = self.call_attributed(host, client, name, args, now);
host.editor().changes.client = None;
r
}
fn call_attributed(
&mut self,
host: &mut dyn Host,
client: &Client,
name: &str,
args: &Value,
now: u64,
) -> ToolResult {
let dir = host.project().map(|p| p.dir.clone());
self.sync_project(now, dir.as_deref());
@@ -1630,7 +1649,7 @@ mod tests {
}
fn who(name: &str, session: &str) -> Client {
Client { name: name.into(), session: session.into() }
Client { name: name.into(), session: session.into(), parent: None }
}
fn frame_as(t: &mut Tracker, h: &mut SessionHost, c: &Client, now: u64) -> NodeId {
+2
View File
@@ -68,6 +68,7 @@ impl Session {
}
// The groups of the opened project (an old project has none).
ed.page_groups = p.manifest.page_groups.clone();
ed.page_icons = p.manifest.page_icons.clone();
ed.sync_page_group_order();
// Freshly loaded pages are saved by definition.
for i in 0..ed.pages.len() {
@@ -264,6 +265,7 @@ impl Session {
let wrote = !dirty.is_empty() || ed.meta_dirty;
p.manifest.pages = pages;
p.manifest.page_groups = ed.page_groups.clone();
p.manifest.page_icons = ed.page_icons.clone();
p.manifest.active_page = Some(ed.active_page_id().to_string());
if wrote {
p.manifest.modified = now_ms();
+7 -1
View File
@@ -948,9 +948,15 @@ pub fn modal_backing(ui: &egui::Ui, fill: Color32, radius: u8) {
craft_ui::material::paint_backing(ui, shared(), fill, radius);
}
/// Narrowest a popup or menu gets, so short lists don't read as a sliver.
pub const POPUP_MIN_W: f32 = 180.0;
/// Space between a button and the menu it opens.
pub const POPUP_GAP: f32 = 6.0;
/// [`modal_backing`] for a popup, menu or context menu: called first in its closure, it lights
/// the popup's frame (its rect from the last frame) under acrylic.
pub fn popup_backing(ui: &egui::Ui) {
pub fn popup_backing(ui: &mut egui::Ui) {
ui.set_min_width(POPUP_MIN_W);
let v = ui.visuals();
modal_backing(ui, v.window_fill, v.menu_corner_radius.nw);
}
+2 -2
View File
@@ -80,7 +80,7 @@ pub fn menu(ui: &mut Ui, ed: &Editor) -> Option<Act> {
pub fn chip(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
let r = super::kit::chip(ui, "Actions", false).on_hover_text("Actions on the selection");
let mut act = None;
egui::Popup::menu(&r).width(200.0).show(|ui| {
egui::Popup::menu(&r).gap(crate::theme::POPUP_GAP).width(200.0).show(|ui| {
crate::theme::popup_backing(ui);
act = menu(ui, ed);
if act.is_some() {
@@ -123,7 +123,7 @@ pub fn dispatch(ed: &mut Editor, st: &mut UiState, act: Act) {
/// The run's title is the summary the toast shows; nothing changed (or an error) goes to the
/// status line.
pub fn run(host: &mut dyn Host, runs: &mut Tracker, act: Act, keys: &[String], now: u64) {
let client = Client { name: ACTIONS_CLIENT.into(), session: String::new() };
let client = Client { name: ACTIONS_CLIENT.into(), session: String::new(), parent: None };
let undo_from = host.editor().history.undo_len();
let mut tool = |host: &mut dyn Host, name: &str, args: Value| -> Result<Value, String> {
let out = runs.call(host, &client, name, &args, now)?;
+1 -1
View File
@@ -58,7 +58,7 @@ pub fn json_config(url: &str) -> String {
impl AgentUi {
/// The top-level "Agent" menu.
pub fn menu(&mut self, ui: &mut egui::Ui) {
ui.menu_button("Agent", |ui| {
super::menus::menu_button(ui, "Agent", |ui| {
crate::theme::popup_backing(ui);
if super::icon_menu_item(ui, crate::theme::Icon::Export, "Connect…").clicked() {
self.open = true;
+1 -1
View File
@@ -279,7 +279,7 @@ fn category_select(ui: &mut Ui, current: &str, cats: &[AnnotationCategory]) -> O
p.secondary,
);
let mut chosen = None;
egui::Popup::menu(&resp).width(rect.width().max(190.0)).show(|ui| {
egui::Popup::menu(&resp).gap(crate::theme::POPUP_GAP).width(rect.width().max(190.0)).show(|ui| {
crate::theme::popup_backing(ui);
ui.spacing_mut().item_spacing.y = 2.0;
for c in cats {
+1 -1
View File
@@ -162,7 +162,7 @@ fn picker(ui: &mut Ui, ed: &Editor, kind: Kind, current: Option<&str>) -> Option
}
fn open_picker(resp: &egui::Response, ed: &Editor, kind: Kind, current: Option<&str>) -> Option<String> {
egui::Popup::menu(resp)
egui::Popup::menu(resp).gap(crate::theme::POPUP_GAP)
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
.width(POPUP_W)
.show(|ui| {
+48 -14
View File
@@ -15,7 +15,7 @@ pub use chat_pick::{layer_chip, open_ask};
use chat_agents::{Act, PanelIn};
use crate::chat::events::{Event, ImageAttachment};
use crate::chat::agents::{self, AgentKind, AgentState, AgentStatus};
use crate::chat::{Backend, BackendKind, Chat, IssueOutcome, Message, Role, ToolCall, store};
use crate::chat::{Backend, BackendKind, Chat, ChatSummary, IssueOutcome, Message, Role, ToolCall, store};
use crate::project::format::{new_id, now_ms};
use crate::theme::{self, Icon};
use crate::ui::{UiState, kit};
@@ -360,7 +360,7 @@ pub struct ChatState {
/// The open chat.
pub chat: Option<Chat>,
/// Chats of the open project, newest first (refreshed on save).
pub chats: Vec<Chat>,
pub chats: Vec<ChatSummary>,
/// Filter typed in the chat history popup.
pub history_query: String,
pub turn: Option<TurnHandle>,
@@ -549,6 +549,9 @@ impl ChatState {
/// project was last left in Chat mode.
pub fn switch_project(&mut self, dir: Option<PathBuf>) -> bool {
self.stop();
if let Some(d) = &dir {
store::prune_result_images(d, store::RESULT_IMAGES_KEEP);
}
let prefs = Prefs::load();
#[cfg(not(target_arch = "wasm32"))]
let tools = self.tools.take();
@@ -632,7 +635,7 @@ impl ChatState {
}
let Some(dir) = &self.project else { return };
self.chats = store::list(dir);
self.chat = Some(self.chats.first().cloned().unwrap_or_else(new_chat));
self.chat = Some(self.chats.first().and_then(|c| store::load(dir, &c.id)).unwrap_or_else(new_chat));
}
fn save(&mut self) {
@@ -641,7 +644,9 @@ impl ChatState {
if let Err(e) = store::save(dir, chat) {
log::warn!("could not save chat: {e}");
}
self.chats = store::list(dir);
// Just updated, so it is the newest; the other files are not read again.
self.chats.retain(|c| c.id != chat.id);
self.chats.insert(0, ChatSummary::of(chat));
}
/// Fold one backend event into the open chat. Returns true when the turn is over.
@@ -696,6 +701,9 @@ impl ChatState {
let call = chat.messages.iter_mut().rev().flat_map(|m| m.tools.iter_mut()).find(|t| t.id == id);
if let Some(t) = call {
(t.result, t.error, t.ms) = (result, error, ms);
if let (Some(dir), Some(r)) = (&self.project, &mut t.result) {
store::offload_images(dir, &chat.id, &t.id, r);
}
}
self.approvals.retain(|a| a.id != id);
}
@@ -758,7 +766,8 @@ impl ChatState {
let alive = self.turn.as_ref().is_some_and(TurnHandle::is_running);
let events: Vec<Event> = rx.try_iter().collect();
// The events belong to the turn's chat, not to whichever chat is open now.
let parked = self.chat.as_ref().filter(|_| self.turn_chat.is_some()).is_some_and(|c| Some(&c.id) != self.turn_chat.as_ref()).then(|| {
// Read from disk only when there is something to fold in, not every frame.
let parked = self.chat.as_ref().filter(|_| self.turn_chat.is_some() && (!events.is_empty() || !alive)).is_some_and(|c| Some(&c.id) != self.turn_chat.as_ref()).then(|| {
let turn = self.turn_chat.as_deref().zip(self.project.as_deref()).and_then(|(id, dir)| store::load(dir, id));
std::mem::replace(&mut self.chat, turn)
});
@@ -1032,9 +1041,13 @@ impl ChatState {
pub fn record_issue(&mut self, key: &str, o: IssueOutcome) {
let holds = |c: &Chat| c.messages.iter().any(|m| m.tools.iter().any(|t| t.id == key));
let Some(dir) = self.project.clone() else { return };
let mut other;
let target = match self.chat.as_mut().filter(|c| holds(c)) {
Some(c) => Some(c),
None => self.chats.iter_mut().find(|c| holds(c)),
None => {
other = self.chats.iter().filter_map(|c| store::load(&dir, &c.id)).find(|c| holds(c));
other.as_mut()
}
};
let Some(c) = target else { return };
c.issues.insert(key.to_owned(), o);
@@ -1187,8 +1200,8 @@ impl ChatState {
return;
}
self.fresh_next = false;
if let Some(c) = self.chats.iter().find(|c| c.id == id) {
self.chat = Some(c.clone());
if let Some(c) = self.project.as_deref().and_then(|dir| store::load(dir, id)) {
self.chat = Some(c);
self.reset_view();
}
}
@@ -1612,9 +1625,8 @@ fn pan_to(ed: &mut Editor, key: &str) {
/// The left column: replaces the layers panel in Chat mode.
pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, frame: egui::Frame, floating: bool, fill: egui::Color32) {
let r = egui::Panel::left("chat")
.resizable(true)
.default_size(st.chat.column_width())
.size_range(COLUMN_MIN..=COLUMN_MAX)
.resizable(false)
.exact_size(st.chat.column_width().clamp(COLUMN_MIN, COLUMN_MAX))
.frame(frame)
.show(ui, |ui| {
if floating {
@@ -1623,7 +1635,13 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, frame: egui::Frame,
column(ui, ed, st);
}
});
let w = r.response.rect.width();
let gap = if floating { super::PANEL_GAP } else { 0.0 };
let grip = super::panel_handle::show(ui.ctx(), "chat-grip".into(), r.response.rect, super::panel_handle::Side::Left, gap);
let w = if grip.reset {
COLUMN_DEFAULT
} else {
super::panel_handle::resized(st.chat.column_width(), grip.delta, COLUMN_MIN..=COLUMN_MAX)
};
if (w - st.chat.column_width()).abs() > 0.5 {
st.chat.width = w;
st.chat.width_dirty = true;
@@ -1924,7 +1942,7 @@ fn chat_menu(anchor: &egui::Response, st: &mut UiState) {
const LOGO: f32 = 14.0;
const LIST_MAX_H: f32 = 360.0;
let running = st.chat.is_running();
egui::Popup::menu(anchor).width(300.0).show(|ui| {
egui::Popup::menu(anchor).gap(crate::theme::POPUP_GAP).width(300.0).show(|ui| {
crate::theme::popup_backing(ui);
let p = theme::pal(ui);
ui.spacing_mut().item_spacing.y = 2.0;
@@ -1933,7 +1951,7 @@ fn chat_menu(anchor: &egui::Response, st: &mut UiState) {
let current = st.chat.chat.as_ref().map(|c| c.id.clone());
let now = now_ms();
let needle = st.chat.history_query.trim().to_lowercase();
let chats: Vec<Chat> = st.chat.chats.iter().filter(|c| needle.is_empty() || c.title.to_lowercase().contains(&needle)).cloned().collect();
let chats: Vec<ChatSummary> = st.chat.chats.iter().filter(|c| needle.is_empty() || c.title.to_lowercase().contains(&needle)).cloned().collect();
let group = |updated: u64| match now.saturating_sub(updated) / DAY_MS {
0 => "Today",
1 => "Yesterday",
@@ -2696,4 +2714,20 @@ mod tests {
let jpeg = ImageAttachment { mime: "image/jpeg".into(), data: vec![0xff, 0xd8] };
assert_eq!(crate::chat::shrink_image("p.jpg".into(), jpeg.clone()).1, jpeg, "unreadable or small JPEGs pass through");
}
#[test]
fn opaque_pasted_images_go_lossy_and_transparent_ones_stay_png() {
// Noise compresses badly as PNG, as a photo or a busy screenshot does.
let noisy = |alpha: u8| {
let mut rng = fastrand::Rng::with_seed(7);
image::RgbaImage::from_fn(600, 400, |x, y| image::Rgba([(x / 3) as u8 ^ rng.u8(..32), (y / 2) as u8, rng.u8(..), alpha]))
};
let opaque = crate::io::export::encode_png(&noisy(255)).unwrap();
let (name, img, _) = crate::chat::shrink_image("pasted.png".into(), ImageAttachment { mime: "image/png".into(), data: opaque.clone() });
assert_eq!((name.as_str(), img.mime.as_str()), ("pasted.jpg", "image/jpeg"));
assert!(img.data.len() * 2 < opaque.len(), "{} vs {}", img.data.len(), opaque.len());
let clear = crate::io::export::encode_png(&noisy(120)).unwrap();
let (_, img, _) = crate::chat::shrink_image("cut.png".into(), ImageAttachment { mime: "image/png".into(), data: clear });
assert_eq!(img.mime, "image/png", "transparency survives");
}
}
+11 -5
View File
@@ -334,17 +334,23 @@ pub fn paint_agent_mark(ui: &Ui, rect: Rect, id: &str, name: &str, ink: Color32)
const LOGO_PX: u32 = 64;
fn logo_texture(ctx: &egui::Context, name: &str, svg: &[u8]) -> Option<egui::TextureHandle> {
let id = Id::new(("agent-logo", name));
svg_texture(ctx, &format!("agent-logo-{name}"), svg)
}
/// A monochrome SVG as a white texture (its shape in alpha), so a tint gives its colour.
/// Cached per `key` for the context's life.
pub(crate) fn svg_texture(ctx: &egui::Context, key: &str, svg: &[u8]) -> Option<egui::TextureHandle> {
let id = Id::new(("svg-texture", key));
if let Some(t) = ctx.data(|d| d.get_temp::<egui::TextureHandle>(id)) {
return Some(t);
}
let svg = std::str::from_utf8(svg).ok()?.replace("currentColor", "#fff");
let tree = usvg::Tree::from_str(&svg, &usvg::Options::default()).ok()?;
let tree = usvg::Tree::from_data(svg, &usvg::Options::default()).ok()?;
let scale = LOGO_PX as f32 / tree.size().width().max(tree.size().height());
let mut pixmap = resvg::tiny_skia::Pixmap::new(LOGO_PX, LOGO_PX)?;
resvg::render(&tree, resvg::tiny_skia::Transform::from_scale(scale, scale), &mut pixmap.as_mut());
let img = egui::ColorImage::from_rgba_premultiplied([LOGO_PX as usize; 2], pixmap.data());
let t = ctx.load_texture(format!("agent-logo-{name}"), img, egui::TextureOptions::LINEAR);
let pixels: Vec<egui::Color32> = pixmap.pixels().iter().map(|p| egui::Color32::from_white_alpha(p.alpha())).collect();
let img = egui::ColorImage { size: [LOGO_PX as usize; 2], source_size: egui::vec2(LOGO_PX as f32, LOGO_PX as f32), pixels };
let t = ctx.load_texture(key, img, egui::TextureOptions::LINEAR);
ctx.data_mut(|d| d.insert_temp(id, t.clone()));
Some(t)
}
+108 -40
View File
@@ -6,7 +6,7 @@ use crate::theme::Surface as _;
use super::chat::Approval;
use super::chat_md;
use super::kit;
use crate::chat::{Chat, Message, Role, ToolCall, Usage};
use crate::chat::{Chat, Message, Role, ToolCall, Usage, store};
use crate::runs::{Run, RunState};
use crate::theme::{self, Icon};
use egui::{Align, Color32, Id, Layout, RichText, Sense, Ui, vec2};
@@ -16,6 +16,8 @@ const ARGS_LIMIT: usize = 600;
const TARGET_CHARS: usize = 40;
const THUMB_MAX: u32 = 320;
const THUMB_H: f32 = 52.0;
/// Widest a result screenshot's thumbnail is shown.
const SHOT_THUMB_W_MAX: u32 = 120;
const LIGHTBOX_MAX: u32 = 2048;
/// The tool whose calls are drawn as a question card instead of a tool card.
const ASK_TOOL: &str = "ask_user";
@@ -332,13 +334,14 @@ fn result_text(v: &Value) -> Value {
serde_json::from_str(&joined).unwrap_or(Value::String(joined))
}
/// The first image block of a result: (mime, base64 data).
pub fn image_of(result: &Value) -> Option<(String, String)> {
result.as_array()?.iter().find(|b| b["type"] == "image").and_then(|b| {
let data = b["source"]["data"].as_str().or_else(|| b["data"].as_str())?;
let mime = b["source"]["media_type"].as_str().or_else(|| b["mimeType"].as_str()).unwrap_or("image/png");
Some((mime.to_owned(), data.to_owned()))
})
/// The first image block of a result.
fn image_block(result: &Value) -> Option<&Value> {
result.as_array()?.iter().find(|b| store::image_data(b).is_some())
}
/// The bytes of a result's first image (inline, or the file it was moved to).
fn image_bytes(result: &Value) -> Option<Vec<u8>> {
store::image_data(image_block(result)?)?.1.bytes()
}
/// Title and detail line of an approval card.
@@ -494,12 +497,12 @@ fn thumb(ui: &Ui, tool_id: &str, result: &Value) -> Option<Thumb> {
return t;
}
let made = (|| {
use base64::{Engine, engine::general_purpose::STANDARD};
let (_, data) = image_of(result)?;
let bytes = STANDARD.decode(data.as_bytes()).ok()?;
let bytes = image_bytes(result)?;
let img = image::load_from_memory(&bytes).ok()?;
let size = [img.width(), img.height()];
let small = img.thumbnail(THUMB_MAX, THUMB_MAX).to_rgba8();
// Twice the size it is shown at (Retina); the lightbox decodes the full image.
let px = (2.0 * THUMB_H) as u32;
let small = img.thumbnail(SHOT_THUMB_W_MAX * 2, px).to_rgba8();
let ci = egui::ColorImage::from_rgba_unmultiplied([small.width() as usize, small.height() as usize], small.as_raw());
let tex = ui.ctx().load_texture(format!("chat-shot-{tool_id}"), ci, egui::TextureOptions::LINEAR);
Some(Thumb { tex, size, bytes: bytes.len() })
@@ -510,7 +513,8 @@ fn thumb(ui: &Ui, tool_id: &str, result: &Value) -> Option<Thumb> {
// ---------------------------------------------------------------------------------- attached images
/// An attached image as a texture (at most `LIGHTBOX_MAX` px a side), decoded once per `key`.
/// An attached image as a thumbnail texture (fits 3 x `THUMB_MAX` by `THUMB_MAX`), decoded once
/// per `key`; the full image is decoded only for the lightbox.
fn image_tex(ui: &Ui, key: &str, bytes: impl FnOnce() -> Option<Vec<u8>>) -> Option<egui::TextureHandle> {
let id = Id::new(("chat-image", key));
if let Some(t) = ui.data(|d| d.get_temp::<Option<egui::TextureHandle>>(id)) {
@@ -518,10 +522,7 @@ fn image_tex(ui: &Ui, key: &str, bytes: impl FnOnce() -> Option<Vec<u8>>) -> Opt
}
let made = (|| {
let img = image::load_from_memory(&bytes()?).ok()?;
let img = if img.width().max(img.height()) > LIGHTBOX_MAX { img.thumbnail(LIGHTBOX_MAX, LIGHTBOX_MAX) } else { img };
let rgba = img.to_rgba8();
let ci = egui::ColorImage::from_rgba_unmultiplied([rgba.width() as usize, rgba.height() as usize], rgba.as_raw());
Some(ui.ctx().load_texture(format!("chat-image-{key}"), ci, egui::TextureOptions::LINEAR))
Some(texture(ui.ctx(), &format!("chat-image-{key}"), img, 3 * THUMB_MAX, THUMB_MAX))
})();
ui.data_mut(|d| d.insert_temp(id, made.clone()));
made
@@ -529,19 +530,34 @@ fn image_tex(ui: &Ui, key: &str, bytes: impl FnOnce() -> Option<Vec<u8>>) -> Opt
/// A thumbnail of an attached image, `h` tall; a click opens it in the lightbox. None when the
/// image cannot be read.
pub fn image_thumb(ui: &mut Ui, key: &str, h: f32, bytes: impl FnOnce() -> Option<Vec<u8>>) -> Option<egui::Response> {
let tex = image_tex(ui, key, bytes)?;
pub fn image_thumb(ui: &mut Ui, key: &str, h: f32, bytes: impl Fn() -> Option<Vec<u8>>) -> Option<egui::Response> {
let tex = image_tex(ui, key, &bytes)?;
let [w, th] = tex.size();
let w = (h * w as f32 / th.max(1) as f32).clamp(h * 0.5, h * 3.0);
let resp = ui
.add(egui::Image::new(&tex).fit_to_exact_size(vec2(w, h)).corner_radius(theme::radius_md()).sense(Sense::click()))
.on_hover_cursor(egui::CursorIcon::ZoomIn);
if resp.clicked() {
ui.data_mut(|d| d.insert_temp(Id::new("chat-lightbox"), Some(tex)));
open_lightbox(ui.ctx(), key, bytes());
}
Some(resp)
}
/// `img` as a texture that fits `w` by `h` px.
fn texture(ctx: &egui::Context, name: &str, img: image::DynamicImage, w: u32, h: u32) -> egui::TextureHandle {
let img = if img.width() > w || img.height() > h { img.thumbnail(w, h) } else { img };
let rgba = img.to_rgba8();
let ci = egui::ColorImage::from_rgba_unmultiplied([rgba.width() as usize, rgba.height() as usize], rgba.as_raw());
ctx.load_texture(name, ci, egui::TextureOptions::LINEAR)
}
/// Shows the full image in the lightbox; its texture goes when the lightbox closes.
fn open_lightbox(ctx: &egui::Context, key: &str, bytes: Option<Vec<u8>>) {
let Some(img) = bytes.and_then(|b| image::load_from_memory(&b).ok()) else { return };
let tex = texture(ctx, &format!("chat-full-{key}"), img, LIGHTBOX_MAX, LIGHTBOX_MAX);
ctx.data_mut(|d| d.insert_temp(Id::new("chat-lightbox"), Some(tex)));
}
/// The image the user clicked, full size over the window. Esc or a click closes it.
pub fn lightbox(ctx: &egui::Context) {
let id = Id::new("chat-lightbox");
@@ -596,7 +612,10 @@ fn tool_card(ui: &mut Ui, t: &ToolCall, state: ToolState, acts: &mut Acts) {
ui.horizontal(|ui| {
let h = THUMB_H;
let w = h * s.size[0] as f32 / s.size[1].max(1) as f32;
ui.add(egui::Image::new(&s.tex).fit_to_exact_size(vec2(w.clamp(24.0, 120.0), h)).corner_radius(theme::radius_md()));
let img = ui.add(egui::Image::new(&s.tex).fit_to_exact_size(vec2(w.clamp(24.0, SHOT_THUMB_W_MAX as f32), h)).corner_radius(theme::radius_md()).sense(Sense::click()));
if img.on_hover_cursor(egui::CursorIcon::ZoomIn).clicked() {
open_lightbox(ui.ctx(), &t.id, t.result.as_ref().and_then(image_bytes));
}
ui.vertical(|ui| {
muted(ui, format!("{} x {}", s.size[0], s.size[1]));
muted(ui, format!("Result image, {} KB", (s.bytes / KB).max(1)));
@@ -670,7 +689,7 @@ fn tool_group(ui: &mut Ui, calls: &[&ToolCall], v: &View, acts: &mut Acts) {
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 4.0;
for (t, s) in calls.iter().zip(&states) {
if *s == ToolState::Error || t.result.as_ref().and_then(image_of).is_some() {
if *s == ToolState::Error || t.result.as_ref().and_then(image_block).is_some() {
tool_card(ui, t, *s, acts);
} else {
step_row(ui, t, *s);
@@ -1125,19 +1144,19 @@ fn question_card(ui: &mut Ui, t: &ToolCall, open: bool, answer: Option<&str>, ac
let hint = (open && qs.len() == 1 && oi < 9).then_some(oi + 1);
// A reference option shows its image; a double click opens it in the lightbox.
let tex = o["image"].as_str().and_then(|path| image_tex(ui, path, || crate::vfs::read(path).ok()));
let mut r = ui
.scope(|ui| {
if dim {
ui.multiply_opacity(ANSWER_DIM);
}
match &tex {
Some(tex) => option_tile(ui, label, tex, on, oi == 0),
None => option_chip(ui, label, &colors, on, oi == 0, hint),
}
})
.inner;
if let Some(tex) = tex.filter(|_| r.double_clicked()) {
ui.data_mut(|d| d.insert_temp(Id::new("chat-lightbox"), Some(tex)));
// Dimmed in this row, not in a scope: a child ui in a wrapping row starts its
// own line, so the options would never wrap and would widen the whole list.
let opacity = ui.opacity();
if dim {
ui.multiply_opacity(ANSWER_DIM);
}
let mut r = match &tex {
Some(tex) => option_tile(ui, label, tex, on, oi == 0),
None => option_chip(ui, label, &colors, on, oi == 0, hint),
};
ui.set_opacity(opacity);
if let Some(path) = o["image"].as_str().filter(|_| tex.is_some() && r.double_clicked()) {
open_lightbox(ui.ctx(), path, crate::vfs::read(path).ok());
}
if let Some(d) = o["detail"].as_str() {
r = r.on_hover_text(d);
@@ -1283,12 +1302,17 @@ fn option_chip(ui: &mut Ui, label: &str, colors: &[Color32], on: bool, recommend
const KEY_W: f32 = 18.0;
let p = theme::pal(ui);
let ink = if on { theme::accent() } else { p.text };
let galley = ui.painter().layout_no_wrap(label.to_owned(), theme::semibold(theme::SECONDARY + 1.0), ink);
let keyw = if key.is_some() { KEY_W + 6.0 } else { 0.0 };
let dot = if recommended { RECOMMENDED_DOT + 6.0 } else { 0.0 };
let sw = if colors.is_empty() { 0.0 } else { colors.len() as f32 * (SWATCH - 3.0) + 3.0 + 6.0 };
let (rect, resp) = ui.allocate_exact_size(vec2(2.0 * PAD + keyw + dot + sw + galley.size().x, CHIP_TALL), Sense::click());
option_frame(ui, rect, 255, on, resp.hovered());
let chrome = 2.0 * PAD + keyw + dot + sw;
// A label longer than the row wraps (the chip grows taller): one chip wider than the column
// would widen the whole message list.
let wrap = (ui.max_rect().width() - chrome).max(PAD);
let galley = ui.painter().layout(label.to_owned(), theme::semibold(theme::SECONDARY + 1.0), ink, wrap);
let tall = (galley.size().y + PAD).max(CHIP_TALL);
let (rect, resp) = ui.allocate_exact_size(vec2(chrome + galley.size().x, tall), Sense::click());
option_frame(ui, rect, if tall > CHIP_TALL { theme::radius_lg() } else { 255 }, on, resp.hovered());
// The number key that picks it, in a small cap.
if let Some(k) = key {
let cap = egui::Rect::from_center_size(egui::pos2(rect.left() + PAD - 2.0 + KEY_W / 2.0, rect.center().y), vec2(KEY_W, KEY_W));
@@ -1334,6 +1358,15 @@ fn option_tile(ui: &mut Ui, label: &str, tex: &egui::TextureHandle, on: bool, re
/// The scrolling message list of `chat`.
pub fn list(ui: &mut Ui, chat: &Chat, v: &View, acts: &mut Acts) {
// Thumbnails are cached per image while a chat is open; another chat frees them all.
let shown = Id::new("chat-thumbs-of");
if ui.data(|d| d.get_temp::<String>(shown)).as_deref() != Some(chat.id.as_str()) {
ui.data_mut(|d| {
d.remove_by_type::<Option<Thumb>>();
d.remove_by_type::<Option<egui::TextureHandle>>();
d.insert_temp(shown, chat.id.clone());
});
}
let jump_id = Id::new("chat-jump");
let jump = ui.data_mut(|d| d.remove_temp::<bool>(jump_id)).unwrap_or(false);
let out = egui::ScrollArea::vertical().id_salt(("chat-list", &chat.id)).auto_shrink(false).stick_to_bottom(true).show(ui, |ui| {
@@ -1497,6 +1530,38 @@ mod tests {
ToolCall { id: id.into(), name: name.into(), args, ..Default::default() }
}
/// How wide `f` draws in a 400 px column.
fn drawn_width(f: impl Fn(&mut Ui)) -> f32 {
let ctx = egui::Context::default();
super::super::setup_style(&ctx);
let mut w = 0.0;
ctx.run_ui(egui::RawInput::default(), |ui| {
w = ui.allocate_ui(vec2(400.0, 2000.0), |ui| f(ui)).response.rect.width();
})
.textures_delta
.clear();
w
}
#[test]
fn question_options_wrap_inside_the_column() {
let options: Vec<Value> = ["Sending in progress state", "Make the queue row a component", "Make the sheet a component", "Darken the overlay"]
.iter()
.map(|l| json!({"label": l}))
.collect();
let q = call("q", "AskUserQuestion", json!({"questions": [{"question": "Next step?", "options": options}]}));
assert!(drawn_width(|ui| question_card(ui, &q, true, None, &mut Acts::default())) <= 400.0, "open");
let answer = "Answers:\n- Next step? Make the sheet a component";
assert!(drawn_width(|ui| question_card(ui, &q, false, Some(answer), &mut Acts::default())) <= 400.0, "answered, the rest dimmed");
}
#[test]
fn an_option_longer_than_the_column_wraps() {
let long = "Motorista: permissions (location, camera, notifications), vehicle, 3-card tour, and a long tail";
let q = call("q", "ask_user", json!({"questions": [{"question": "What goes into each first-access wizard?", "options": [{"label": long}, {"label": "No"}]}]}));
assert!(drawn_width(|ui| question_card(ui, &q, true, None, &mut Acts::default())) <= 400.0);
}
#[test]
fn usage_line_matches_the_mockup() {
let u = Usage { input: 12_400, output: 1_100, cache_read: 9_800, cache_write: 0, cost_usd: Some(0.06) };
@@ -1619,8 +1684,8 @@ mod tests {
assert!(!pretty(&json!({"a": 1}), ARGS_LIMIT).1);
let img = json!([{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "A".repeat(4000)}}]);
assert!(elide(&img).to_string().contains("<base64, 2 KB>"));
assert_eq!(image_of(&img).map(|i| i.0).as_deref(), Some("image/png"));
assert_eq!(image_of(&json!([{"type": "text", "text": "x"}])), None);
assert_eq!(image_block(&img).and_then(store::image_data).map(|i| i.0), Some("image/png"));
assert_eq!(image_block(&json!([{"type": "text", "text": "x"}])), None);
}
#[test]
@@ -1698,3 +1763,6 @@ mod tests {
assert_eq!(chat_cost(&Chat::default()), None);
}
}

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