8 Commits

Author SHA1 Message Date
Marcos Paulo 613fa88239 chore(release): 0.10.0
release / test (push) Waiting to run
release / windows (push) Waiting to run
release / macos (push) Waiting to run
release / linux (push) Waiting to run
release / publish (push) Blocked by required conditions
release / site (push) Blocked by required conditions
site / publish (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J9PiVCQxc2QYmArFq9VBUX
2026-11-06 10:29:43 -03:00
Marcos Paulo 567ee63c7a feat: export sites, translate pages, and harden agent tools and sync
New agent tools (98 to 104):
- export_site writes a website from frames in one call: plain HTML,
  Astro, Vue (Nuxt or Vite), React (Next or Vite) or Angular; css, scss
  or tailwind, and for React vanilla-extract, styled-components or
  emotion; static pages or a single-page app; a folder per language.
  Fonts are subset to WOFF2 with metric-matched fallbacks, images are
  resized, headings follow the outline, pages reflow below the designed
  width, and _headers, vercel.json, _redirects and .nojekyll cache
  hashed assets for a year.
- get_strings and set_strings treat languages as variable modes: read a
  page's text for translation, fill a language or a pseudo-locale.
- get_context says where the agent is, what is cheap to read next and
  what lint found, in one call; big reads take maxTokens and report
  estimatedTokens.
- import_html opens a page from a prompt-to-design tool (.html or .zip)
  as layers.
- install_font adds fonts on top of the bundled Inter; text can name one.

App:
- Present mode plays a page's frames as a prototype: links jump to
  frames, open overlays, go back or open an address.
- Panics are kept in crash.log; release builds keep line tables so its
  traces name files and lines.
- Settings > Canvas is kept between sessions; a keyboard shortcuts
  sheet; icons in the menus.
- Chat: an opencode turn waiting out a rate limit moves on to the
  fallback model.
- The updater checks each download against the SHA-256 GitHub publishes.

Fixes:
- A NaN or infinite tool argument no longer makes a project unopenable.
- Agent paths cannot reach other machines, devices or NTFS streams; a
  tool writes only its own file type, and fills only a new, empty or
  earlier export folder.
- delegate and imagegen pass each value in its own environment
  variable, never as shell syntax.
- An instance placed inside its own component is refused.
- A panic in a tool fails that call instead of closing the app.
- An agent call during a drag waits for it, so the undo step stays whole.
- Sync holds downloads while the open project has unsaved edits, writes
  pages atomically, and leaves a page saved while its download was in
  flight for the next round to merge. Merges repair the tree instead
  of giving a node two parents or orphaning it.
- Share tokens come from the OS random generator.
- Imports have a total memory cap; Excalidraw import is linear.
- Unused images are freed when a project opens.
- Pages that use no added font are still written as format 3, so 0.9.1
  can open them.
- Code export escapes text, attributes and CSS in one place.

CI: a clippy job (warnings do not fail it yet).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J9PiVCQxc2QYmArFq9VBUX
2026-11-06 10:29:43 -03:00
Marcos Paulo 15cba71bf0 ci: gate pull requests and reuse release caches (#2)
* ci: gate pull requests and share release caches

* fix(ci): keep release caches restore only
2026-10-29 20:13:28 -03:00
Marcos Paulo 87c9c3a151 fix: repair release test failures
site / publish (push) Has been cancelled
release / site (push) Has been cancelled
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
2026-10-29 15:55:21 -03:00
Marcos Paulo ddd6fd96b8 Merge pull request #1 from nxtime/claude/affectionate-euler-blm40d
feat(site): add a landing page published to the pages branch
2026-10-29 15:40:10 -03:00
Claude 944311cba1 feat(site): add a landing page published to the pages branch
The page in site/ reads the latest GitHub release from release.json, offers
the build for the visitor's system, and shows the demo tour, a live agent
run and the feature set on the app's acrylic look. site.yml builds it with
the newest release and commits the result to the pages branch; release.yml
calls it after publishing, and a daily run catches releases made by hand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J9PiV8m3jnQjxY1skZUJfF
2026-10-29 18:23:31 +00:00
Marcos Paulo bc0c3c1a1b chore(release): 0.9.1
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
2026-10-29 14:37:34 -03:00
Marcos Paulo 23e0571b2c feat: expand agent chat and design handoff 2026-10-29 10:28:15 -03:00
177 changed files with 24157 additions and 1107 deletions
+42 -17
View File
@@ -1,5 +1,6 @@
# 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
# GitHub release with the files once the full test suite (test.yml) passes, then refreshes the
# landing page (site.yml). Gitea reads .github/workflows too, so every job is gated on the
# server URL: this pipeline only ever runs on github.com.
name: release
@@ -13,7 +14,7 @@ on:
required: true
permissions:
contents: write
contents: read
env:
CARGO_TERM_COLOR: always
@@ -28,10 +29,14 @@ jobs:
if: github.server_url == 'https://github.com'
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with: { ref: "${{ inputs.tag || github.ref }}" }
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master; toolchain stable
with: { toolchain: stable }
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2
with:
shared-key: papercraft-release
save-if: false
- name: Build and zip
shell: pwsh
run: |
@@ -40,31 +45,38 @@ jobs:
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
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with: { name: windows, path: dist/* }
macos:
if: github.server_url == 'https://github.com'
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with: { ref: "${{ inputs.tag || github.ref }}" }
- uses: dtolnay/rust-toolchain@stable
with: { targets: aarch64-apple-darwin }
- uses: Swatinem/rust-cache@v2
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master; toolchain stable
with: { toolchain: stable, targets: aarch64-apple-darwin }
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2
with:
shared-key: papercraft-release
save-if: false
- name: Package (Apple silicon, ad-hoc signed, not notarized)
run: packaging/macos/package.sh --arch aarch64
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with: { name: macos, path: dist/release/* }
linux:
if: github.server_url == 'https://github.com'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with: { ref: "${{ inputs.tag || github.ref }}" }
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master; toolchain stable
with: { toolchain: stable }
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2
with:
shared-key: papercraft-release
save-if: false
- name: Build dependencies
run: |
sudo apt-get update
@@ -77,19 +89,32 @@ jobs:
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
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with: { name: linux, path: dist/* }
publish:
if: github.server_url == 'https://github.com'
needs: [test, windows, macos, linux]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with: { path: files, merge-multiple: true }
- name: Create the release
env:
GH_TOKEN: ${{ github.token }}
run: |
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "Release $TAG already exists; refusing to overwrite its assets." >&2
exit 1
fi
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
--generate-notes --verify-tag
site:
if: github.server_url == 'https://github.com'
needs: publish
permissions:
contents: write
uses: ./.github/workflows/site.yml
+69
View File
@@ -0,0 +1,69 @@
# Builds the landing page (site/) with the latest GitHub release and commits it to the `pages`
# branch, which GitHub Pages serves (Settings → Pages → Deploy from a branch → pages, / (root)).
# release.yml calls it after publishing, a daily run catches releases made by hand, and pushes to
# the page's sources rebuild it. Nothing is committed when the result is unchanged. Gitea reads
# .github/workflows too, so the job is gated on the server URL.
name: site
on:
workflow_call:
workflow_dispatch:
schedule:
- cron: "23 5 * * *"
push:
branches: [main]
paths:
- site/**
- docs/demo.mp4
- docs/screenshots/readme/**
- assets/app-icon/papercraft.svg
- assets/agents/**
- .github/workflows/site.yml
permissions:
contents: write
concurrency:
group: site
cancel-in-progress: false
jobs:
publish:
if: github.server_url == 'https://github.com'
runs-on: ubuntu-latest
steps:
# The page comes from the default branch even when a release tag calls this workflow.
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with: { ref: "${{ github.event.repository.default_branch }}" }
- name: Install ffmpeg
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg
- name: Check out the pages branch
run: |
echo "PAGES=$RUNNER_TEMP/pages" >> "$GITHUB_ENV"
PAGES="$RUNNER_TEMP/pages"
if git ls-remote --exit-code --heads origin pages > /dev/null; then
git fetch --depth 1 origin pages:refs/remotes/origin/pages
git worktree add --detach "$PAGES" origin/pages
else
git worktree add --orphan -b pages "$PAGES"
fi
- name: Build
env:
GH_TOKEN: ${{ github.token }}
run: sh site/build.sh dist/site "$PAGES"
- name: Commit and push
run: |
cd "${PAGES:?}"
# Clearing through git drops files the new build no longer has, and only tracked ones.
git rm -rqf --ignore-unmatch .
cp -a "$GITHUB_WORKSPACE/dist/site/." .
git add -A
if git diff --cached --quiet; then
echo "The page is already up to date."
exit 0
fi
tag="$(jq -r .tag release.json)"
git -c user.name="github-actions[bot]" \
-c user.email="41898282+github-actions[bot]@users.noreply.github.com" \
commit -q -m "chore(site): publish the page for $tag"
git push origin HEAD:refs/heads/pages
+82 -8
View File
@@ -1,10 +1,13 @@
# 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.
# Pull requests and main run the full test suite and release-profile builds. The release workflow
# calls the same test job; its own platform jobs build the release artifacts. Gitea reads
# .github/workflows too, so jobs are gated on the server URL.
name: test
on:
pull_request:
branches: [main]
push:
branches: [main]
workflow_call:
inputs:
ref:
@@ -16,19 +19,90 @@ on:
description: Branch or tag to test (default the one you run from)
required: false
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
jobs:
test:
name: CI Windows tests
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
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with: { ref: "${{ inputs.ref || github.ref }}" }
- uses: dtolnay/rust-toolchain@stable
with: { targets: wasm32-unknown-unknown }
- uses: Swatinem/rust-cache@v2
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master; toolchain stable
with: { toolchain: stable, targets: wasm32-unknown-unknown }
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2
with:
shared-key: papercraft-test
save-if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
- run: cargo test --locked --no-fail-fast
- run: cargo check --locked --lib --target wasm32-unknown-unknown
clippy:
name: CI Clippy
if: github.server_url == 'https://github.com'
runs-on: windows-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with: { ref: "${{ inputs.ref || github.ref }}" }
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master; toolchain stable
with: { toolchain: stable, components: clippy }
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2
with:
shared-key: papercraft-clippy
save-if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
# Warnings are not fatal yet; errors (the lottie module's deny list) fail the job.
- run: cargo clippy --all-targets --locked
windows:
name: CI Windows release build
if: github.server_url == 'https://github.com' && (github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main'))
runs-on: windows-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master; toolchain stable
with: { toolchain: stable }
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2
with:
shared-key: papercraft-release
save-if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
- run: cargo build --release --locked --bins
macos:
name: CI macOS release build
if: github.server_url == 'https://github.com' && (github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main'))
runs-on: macos-latest
env:
MACOSX_DEPLOYMENT_TARGET: '11.0'
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master; toolchain stable
with: { toolchain: stable, targets: aarch64-apple-darwin }
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2
with:
shared-key: papercraft-release
save-if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
- run: cargo build --release --locked --bins --target aarch64-apple-darwin
linux:
name: CI Linux release build
if: github.server_url == 'https://github.com' && (github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main'))
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master; toolchain stable
with: { toolchain: stable }
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2
with:
shared-key: papercraft-release
save-if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
- 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
- run: cargo build --release --locked --bins
+4
View File
@@ -6,3 +6,7 @@ app.asar.unpacked/
web/pkg/
.worktrees/
.oc-agents/
.export-panels.tmp.pc
.design-work/
tools/showcase/*.log
+12 -6
View File
@@ -55,7 +55,9 @@ 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`).
1. Fork the repository if needed and make a short-lived branch from current `main`
(`fix/short-name`, `feat/short-name`). Open a pull request into `main` when ready, or a draft
while work is in progress.
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.
@@ -74,8 +76,9 @@ 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>`.
on a desktop. GitHub runs the full Windows suite, the web check and Windows, macOS and Linux
release builds on pull requests and `main`, and again before each release. The Windows suite can
also be run on demand with `gh workflow run test.yml -f ref=<branch>`.
Keep the tree free of the third-party branding patterns listed in `.debrand`.
@@ -111,14 +114,17 @@ Reference the issue (`Fixes #123`).
* 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.
* All required checks must pass before merging. Request review when another maintainer is
available; a solo maintainer may review and merge their own change after checks pass.
* 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.
Maintainers cut releases from green `main`. Merge a version-bump pull request first, then tag its
commit. A `v*` tag pushed to GitHub builds Windows, macOS and Linux and publishes them after the
tests pass (`.github/workflows/release.yml`). Do not move a published tag; release a new patch
version for a fix. Contributors do not need to bump versions for ordinary changes.
## Licensing
Generated
+204 -7
View File
@@ -66,6 +66,21 @@ dependencies = [
"memchr",
]
[[package]]
name = "alloc-no-stdlib"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a"
[[package]]
name = "alloc-stdlib"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1"
dependencies = [
"alloc-no-stdlib",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
@@ -473,6 +488,27 @@ dependencies = [
"piper",
]
[[package]]
name = "brotli"
version = "9.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "627b7cfb0578f0eafce087a90fcd40add1231900b69e4d4de2d456260a4f5d81"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
"brotli-decompressor",
]
[[package]]
name = "brotli-decompressor"
version = "6.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34c6783f42c21c952ad7112b354f0ba2f10760e9decea5b1b60523d6e270faaf"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
@@ -633,6 +669,46 @@ dependencies = [
"inout",
]
[[package]]
name = "clap"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 3.0.7",
]
[[package]]
name = "clap_lex"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
[[package]]
name = "clipboard-win"
version = "5.4.1"
@@ -1276,6 +1352,18 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8"
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foldhash"
version = "0.2.0"
@@ -1300,6 +1388,63 @@ dependencies = [
"roxmltree 0.20.0",
]
[[package]]
name = "fontcull-font-types"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7187211f698d3144c4278d99cede5f6d67814cd676bbf1d2b8607049b3d76b2"
dependencies = [
"bytemuck",
]
[[package]]
name = "fontcull-klippa"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceb68d1b34e3c675e78f1cabbc49e19c5962d6c6173b7c77d77392431b99840"
dependencies = [
"clap",
"fnv",
"fontcull-skrifa",
"fontcull-write-fonts",
"hashbrown 0.15.5",
"regex",
"thiserror 1.0.69",
]
[[package]]
name = "fontcull-read-fonts"
version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "170928e2d36f9605167b8480acd6e9fc5ef5daaf64fac43d178af9c563adba29"
dependencies = [
"bytemuck",
"fontcull-font-types",
]
[[package]]
name = "fontcull-skrifa"
version = "0.39.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c39120d1806ed11baf0559537bd601c6c60d7bf84ddcc385adea8fb8ff62d35"
dependencies = [
"bytemuck",
"fontcull-read-fonts",
]
[[package]]
name = "fontcull-write-fonts"
version = "0.44.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b506b0883cf5f8998391cbffa92024143cedcbd0f344fc3f1507dec12956cf"
dependencies = [
"fontcull-font-types",
"fontcull-read-fonts",
"indexmap",
"kurbo 0.12.0",
"log",
]
[[package]]
name = "fontdb"
version = "0.24.0"
@@ -1548,7 +1693,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a"
dependencies = [
"bytemuck",
"foldhash",
"foldhash 0.2.0",
"hashbrown 0.17.1",
"log",
"peniko",
@@ -1625,6 +1770,17 @@ dependencies = [
"smallvec",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash 0.1.5",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
@@ -1633,7 +1789,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
"foldhash 0.2.0",
]
[[package]]
@@ -1642,9 +1798,15 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
"foldhash 0.2.0",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.5.3"
@@ -2193,6 +2355,17 @@ version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "kurbo"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce9729cc38c18d86123ab736fd2e7151763ba226ac2490ec092d1dd148825e32"
dependencies = [
"arrayvec",
"euclid",
"smallvec",
]
[[package]]
name = "kurbo"
version = "0.13.1"
@@ -3128,7 +3301,7 @@ dependencies = [
[[package]]
name = "papercraft"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"arboard",
"axum",
@@ -3143,6 +3316,11 @@ dependencies = [
"egui-winit",
"env_logger",
"fastrand",
"fontcull-klippa",
"fontcull-read-fonts",
"fontcull-skrifa",
"fontcull-write-fonts",
"getrandom 0.4.3",
"i_overlay",
"image",
"js-sys",
@@ -3165,10 +3343,12 @@ dependencies = [
"semver",
"serde",
"serde_json",
"sha2",
"tokio",
"tokio-tungstenite",
"tokio-util",
"ttf-parser",
"ttf2woff2",
"usvg",
"wasm-bindgen",
"wasm-bindgen-futures",
@@ -3224,7 +3404,7 @@ checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6"
dependencies = [
"bytemuck",
"color",
"kurbo",
"kurbo 0.13.1",
"linebender_resource_handle",
"smallvec",
]
@@ -4335,6 +4515,12 @@ dependencies = [
"float-cmp",
]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
@@ -4347,7 +4533,7 @@ version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d"
dependencies = [
"kurbo",
"kurbo 0.13.1",
"siphasher",
]
@@ -4750,6 +4936,17 @@ version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
[[package]]
name = "ttf2woff2"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e13bfd9cb6a94529c1d33b0f15b69746fe8583a52b9509f59ace80340f4dcf3"
dependencies = [
"brotli",
"byteorder",
"thiserror 2.0.21",
]
[[package]]
name = "tungstenite"
version = "0.29.0"
@@ -4864,7 +5061,7 @@ dependencies = [
"fontdb",
"harfrust",
"imagesize",
"kurbo",
"kurbo 0.13.1",
"log",
"pico-args",
"roxmltree 0.21.1",
+13 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "papercraft"
version = "0.9.0"
version = "0.10.0"
edition = "2024"
rust-version = "1.95"
description = "A native infinite-canvas app for planning, working and sketching"
@@ -51,12 +51,18 @@ usvg = "0.48"
resvg = "0.48"
tokio = { version = "1", features = ["sync"] }
base64 = "0.23"
ttf2woff2 = { version = "0.14.1", default-features = false }
fontcull-klippa = "0.1.2"
fontcull-skrifa = "0.39.2"
fontcull-read-fonts = "0.38.0"
fontcull-write-fonts = "0.44.3"
[build-dependencies]
winresource = "0.1"
[profile.release]
debug = false
# Line tables only: crash.log stack traces name files and lines, at no runtime cost.
debug = "line-tables-only"
# Browser bundle only (`web/build.sh`): size over compile time. Native release is untouched.
[profile.web]
@@ -91,6 +97,8 @@ rmcp = { version = "3.5.1", default-features = false, features = ["server", "bas
tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "macros", "time", "io-std", "fs"] }
axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "ws", "json", "query"] }
tokio-util = "0.7"
# Share tokens come from the OS generator (the version rand and uuid already compile).
getrandom = "0.4"
# Shared-project sync; the system TLS stack so https servers work without bundling one.
reqwest = { version = "0.13", default-features = false, features = ["native-tls"] }
# Anthropic API key in the OS keychain (chat API backend). Linux uses the Secret Service (persistent; keyutils is not).
@@ -99,11 +107,13 @@ keyring = { version = "3", default-features = false, features = ["apple-native",
libc = "0.2"
# Pages and versions on disk are zstd-compressed JSON (`project::packed`).
zstd = "0.13"
# Checks downloaded updates against the checksum GitHub publishes.
sha2 = "0.10"
# Browser build (`src/web.rs`): canvas + WebGPU/WebGL surface, DOM events instead of winit.
# Process RAM and CPU readings for the performance overlay (`ui::perf`).
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_ProcessStatus", "Win32_System_Threading"] }
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_Console", "Win32_System_ProcessStatus", "Win32_System_Threading"] }
[target.'cfg(target_arch = "wasm32")'.dependencies]
wgpu = { version = "30", features = ["webgl"] }
+9
View File
@@ -76,6 +76,15 @@ 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-11-06: 0.10.0 (websites, languages, Present mode, safety)
* `export_site` turns frames into a website in one call: plain HTML, Astro, Vue (Nuxt or Vite), React (Next or Vite) or Angular; css, scss or tailwind, and vanilla-extract, styled-components or emotion for React; static pages or a single-page app; a folder per language. Fonts are subset to WOFF2 with metric-matched fallbacks, pages reflow below the designed width, and the host files cache hashed assets for a year.
* Languages as variable modes: `get_strings` reads a page's text for translation, `set_strings` fills a language or a pseudo-locale.
* Present mode plays a page's frames as a clickable prototype: links jump to frames, open overlays, go back or open an address.
* `get_context` orients an agent in one call and big reads take `maxTokens`; `import_html` opens pages from prompt-to-design tools; `install_font` adds fonts on top of Inter. 104 tools.
* Safety: agent paths are checked, `delegate` and `imagegen` never pass values as shell syntax, a panic in a tool fails only that call, sync no longer overwrites a page saved mid-download and repairs merged trees, share tokens come from the OS generator, imports have a memory cap, and the updater checks the published SHA-256.
* Panics are kept in `crash.log`; Settings > Canvas persists; a keyboard shortcuts sheet.
### 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.
+6 -6
View File
@@ -119,11 +119,11 @@ Projects live in `~/Documents/papercraft/<name>/` (`project.json`, one JSON file
## Agents
PaperCraft exposes **94 tools** (`src/mcp/tools.rs`) three ways. They run the same code inside the app.
PaperCraft exposes **104 tools** (`src/mcp/tools.rs`) three ways. They run the same code inside the app.
| Way | Notes |
|---|---|
| MCP over HTTP | Built into the app: `http://127.0.0.1:29980/mcp`. Loopback only; browser requests (with an `Origin` header) and foreign `Host` headers are rejected. `PAPERCRAFT_MCP_PORT` changes the port, `PAPERCRAFT_MCP=0` turns it off. |
| MCP over HTTP | Built into the app: `http://127.0.0.1:29980/mcp`. Loopback only; browser requests (with an `Origin` header) and foreign `Host` headers are rejected. `?tools=core` lists 33 common tools in full plus `search_tools` and `call_tool`, which reach the rest (about a third of the schema size); lean chat agents use it. `PAPERCRAFT_MCP_PORT` changes the port, `PAPERCRAFT_MCP=0` turns it off. |
| MCP over stdio | `papercraft-mcp` relays stdio to the running app. It lists tools even when the app is closed. |
| Shell | `papercraft-cli` calls the same tools, so an agent with a terminal needs no schemas in its context. |
@@ -167,7 +167,7 @@ EOF
### Chat
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).
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, Cursor or Goose) 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 with its starter prompts and input, and the annotated habit-tracker screens on the canvas" width="100%">
@@ -187,7 +187,7 @@ Annotations are notes that explain a design in place: business rules, behaviour,
### Download
Releases are at [git.marcospaulo.dev.br/masi/papercraft/releases](https://git.marcospaulo.dev.br/masi/papercraft/releases).
Releases are at [github.com/nxtime/papercraft/releases](https://github.com/nxtime/papercraft/releases), the same place the in-app updater checks.
| Build | File |
|---|---|
@@ -266,7 +266,7 @@ Draw a frame around existing shapes and it adopts them; dragging a layer into or
## Status and known limits
> [!IMPORTANT]
> 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.
> PaperCraft is an alpha (0.10.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.
@@ -284,7 +284,7 @@ Draw a frame around existing shapes and it adopts them; dragging a layer into or
* **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:** 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.
* **Releases:** the GitHub 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
+4 -3
View File
@@ -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** | 94 tools, one undo step per call, `papercraft-cli list / schema / call`. |
| 15 | MCP and CLI tool surface | **done** | 104 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). |
@@ -82,7 +82,7 @@ Spec: [specs/agent-runs/spec.md](specs/agent-runs/spec.md), tasks: [specs/agent-
| 39 | Agent runs and auto-versions | **done** | Editing calls group into runs; edited pages get auto-versions; runs persist, revert per page, and prune to 50. |
| 40 | Review agent changes | **done** | Id-paired diff, grouped review, canvas overlay, per-change revert and accept all; version compare UI remains todo. |
| 41 | Agent presence on the canvas | todo | Outline the node a tool call touches, label it, pause or stop the run. |
| 42 | `get_context` tool | todo | Selection, variables, nearby components and lint summary in one call, so an agent orients in one round trip. |
| 42 | `get_context` tool | **done** | Selection, outline with token estimates, variables, components and lint summary in one call, so an agent orients in one round trip. Big reads take `maxTokens` and report `estimatedTokens` and `truncated`. |
| 43 | Comments assigned to agents | todo | `@agent` in a thread triggers an edit and a reply. Needs threaded comments. |
| 44 | In-app prompt bar | **done** | Chat mode: Claude Code, Codex or your own Anthropic key; see `docs/chat.md`. |
| 45 | Correct tool annotations | **done** | `lint_design` no longer sends `readOnlyHint`; `fix: true` edits are classified. |
@@ -92,4 +92,5 @@ Spec: [specs/agent-runs/spec.md](specs/agent-runs/spec.md), tasks: [specs/agent-
| # | Item | Status | Notes |
|---|---|---|---|
| 47 | Design annotations | **done** | Notes with leader lines and categories, templates per project and page, view modes (⌘⇧A), Annotate tool (N), side view, inspector section, six MCP tools; see `docs/annotations.md`. |
| 48 | ACP agents in Chat | **done** | Any ACP agent on the PATH (OpenCode works; Gemini CLI refuses ACP clients for now; Cursor needs `cursor-agent login`), npx agents after enabling, custom agents; see `docs/chat.md`. |
| 48 | ACP agents in Chat | **done** | Any ACP agent on the PATH (OpenCode works; Cursor needs `cursor-agent login`), npx agents after enabling, custom agents; see `docs/chat.md`. |
| 48b | Free agents | **done** | OpenCode and Pi on free models with `Free (auto)` rotation, Pi provider keys, an install dialog per OS; see `specs/free-agents`. |
-1
View File
@@ -1 +0,0 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z"></path></svg>

Before

Width:  |  Height:  |  Size: 651 B

+5 -4
View File
@@ -18,15 +18,16 @@ Chat drives the same tools as an agent connected over MCP, so the edits, runs, u
The ACP agents PaperCraft knows about, and what was checked on the development Mac:
* **OpenCode** (`opencode acp`): works.
* **Gemini CLI** (`gemini --acp`): found and started, but its server currently refuses ACP clients, so turns fail with the agent's error until Google changes that.
* **OpenCode** (`opencode acp`): works. Its free models (`opencode/*-free`, Big Pickle) need no key.
* **Cursor** (`cursor-agent acp`): sign in first with `cursor-agent login`.
* **Pi** (`pi-acp`, an adapter run through `npx`): enable it once in the agent panel or Settings, then it works like the others.
* **Pi** (`pi-acp`, an adapter run through `npx`): enable it once in the agent panel or Settings. `pi-acp` does not pass MCP servers on to Pi, so a lean Pi gets its own folder (`PI_CODING_AGENT_DIR`) whose only package is `pi-mcp-adapter`, connected to PaperCraft. Pi runs on the providers you add a key for in Settings → Chat → Pi: OpenRouter (`:free` models, 50 requests a day without credits), Groq, Gemini, NVIDIA NIM and Cerebras. Keys live in the OS secret store and reach Pi only as environment variables.
* **Goose, Kimi, Devin, Junie, Mistral Vibe, Hermes, OpenHands, Stakpak, VT Code, Poolside, Droid**: started with the command their own docs give. They are not all verified here.
* **Claude Code and Codex ACP adapters** (`npx`): listed under Enable via npx. Claude Code and Codex themselves use the direct backends above.
Agents that run through `npx` are off until you enable them, because enabling runs code that npx fetches from npm.
**Free models.** For OpenCode and Pi the model list starts on free models (`Free only`) and offers `Free (auto)`, the default for new chats on them. It starts on the free model with the largest context. When a model fails with a rate limit, quota, overload or "gone" error before any tool ran, the turn moves to the next free one (at most two more) and the thread says so. A model that failed is skipped for 15 minutes. A failure after a tool ran is not retried, because the design may already have changed.
ACP permissions: PaperCraft's own tools run without asking, except the destructive ones listed under Approval below. Any other tool the agent asks to run (its own shell or file tools, for example) shows an approval card with Approve or Deny in the thread, and Deny is sent back to the agent.
**Custom agents** are your own ACP agents: a name, a command (a program name on the resolved `PATH`, or a full path) and arguments. The arguments are split on spaces, with no quoting. Custom agents appear under Custom in the agent panel and in Settings → Chat.
@@ -68,7 +69,7 @@ Settings → Chat has the same list, so an agent can also be hidden from the pan
### Setup card
A chat with no backend chosen shows one card, "Connect an agent". It lists the agents detected on this computer, up to four, with `Show all agents` for the rest. Below them are `Add an API endpoint` and `Add custom agent`. The card never lists install steps.
A chat with no backend chosen shows one card, "Connect an agent". It lists the agents detected on this computer, up to four, with `Show all agents` for the rest. Below them are `Add an API endpoint` and `Add custom agent`. When OpenCode or Pi is not found, its row has `Install…`. The dialog offers the install methods that work on this OS (Homebrew, Scoop, npm, Nix or the agent's install script; on Linux also the distribution's own). It shows the exact command and runs it only after you click Install, streaming the output. Methods that need `sudo` or an admin shell, and npm when Node is missing or too old, are shown with the reason and a command to copy instead.
### Sign-in
+62
View File
@@ -0,0 +1,62 @@
#!/bin/sh
# Build the landing page into a folder ready for GitHub Pages:
# sh site/build.sh <out-dir> [<previous-build-dir>]
# Needs curl, jq and ffmpeg. GH_TOKEN, when set, authenticates the GitHub API call.
# RELEASE_JSON=<file> uses that file instead of asking the API. The demo video is re-encoded only
# when docs/demo.mp4 changed since <previous-build-dir>, so a release-only update stays cheap.
# <out-dir> must be new or empty.
set -eu
cd "$(dirname "$0")/.."
OUT="${1:?usage: sh site/build.sh <out-dir> [<previous-build-dir>]}"
PREV="${2:-}"
REPO="${GITHUB_REPOSITORY:-nxtime/papercraft}"
if [ -n "$(ls -A "$OUT" 2>/dev/null)" ]; then
echo "$OUT is not empty; pass a new folder." >&2
exit 1
fi
mkdir -p "$OUT/media/agents"
cp site/index.html site/site.css site/site.js "$OUT/"
cp assets/app-icon/papercraft.svg "$OUT/media/icon.svg"
for agent in claude openai cursor goose kimi mistral qwen cline githubcopilot nousresearch; do
cp "assets/agents/$agent.svg" "$OUT/media/agents/"
done
for shot in hero chat annotations variables; do
cp "docs/screenshots/readme/$shot.png" "$OUT/media/"
done
# GitHub Pages runs Jekyll otherwise, which is slower and has nothing to do here.
touch "$OUT/.nojekyll"
# The page reads this shape; site.js maps the API's own shape onto it the same way.
if [ -n "${RELEASE_JSON:-}" ]; then
cp "$RELEASE_JSON" "$OUT/release.json"
else
set -- -fsSL -H "Accept: application/vnd.github+json"
if [ -n "${GH_TOKEN:-}" ]; then
set -- "$@" -H "Authorization: Bearer $GH_TOKEN"
fi
curl "$@" "https://api.github.com/repos/$REPO/releases/latest" | jq '{
tag: .tag_name,
name: .name,
publishedAt: .published_at,
url: .html_url,
assets: [.assets[] | {name, size, url: .browser_download_url, digest}]
}' > "$OUT/release.json"
fi
jq -e '.tag and (.assets | length > 0)' "$OUT/release.json" > /dev/null
SRC_SUM="$(sha256sum docs/demo.mp4 | cut -d' ' -f1)"
if [ -n "$PREV" ] && [ -f "$PREV/media/demo.mp4" ] && [ "$(cat "$PREV/media/demo.source" 2>/dev/null)" = "$SRC_SUM" ]; then
cp "$PREV/media/demo.mp4" "$PREV/media/poster.jpg" "$OUT/media/"
else
# 720p30 without audio keeps the tour near 2 MB, against 26 MB for the 1080p60 source.
ffmpeg -loglevel error -y -i docs/demo.mp4 -vf "scale=1280:-2,fps=30" -c:v libx264 -crf 28 \
-preset slow -pix_fmt yuv420p -an -movflags +faststart "$OUT/media/demo.mp4"
ffmpeg -loglevel error -y -ss 1.5 -i docs/demo.mp4 -frames:v 1 -vf scale=1280:-2 -q:v 4 \
"$OUT/media/poster.jpg"
fi
echo "$SRC_SUM" > "$OUT/media/demo.source"
echo "Built $(jq -r .tag "$OUT/release.json") into $OUT"
+375
View File
@@ -0,0 +1,375 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>PaperCraft</title>
<meta name="claude:viewport-fit-sides" content="cover">
<meta name="description" content="A native, local-first design and whiteboard canvas that AI agents can drive over MCP and a CLI.">
<meta name="theme-color" content="#11111b">
<meta property="og:title" content="PaperCraft">
<meta property="og:description" content="A native canvas that AI agents can drive. Local-first, no account, open source.">
<meta property="og:image" content="media/poster.jpg">
<link rel="icon" href="media/icon.svg" type="image/svg+xml">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500&display=swap">
<link rel="stylesheet" href="site.css">
</head>
<body>
<div class="stage" aria-hidden="true"><div class="glow a"></div><div class="glow b"></div><div class="glow c"></div><div class="spot"></div></div>
<header class="nav" id="nav">
<div class="wrap">
<div class="nav-bar acrylic">
<a class="brand" href="#top"><img src="media/icon.svg" alt="">PaperCraft</a>
<nav class="nav-links" aria-label="Sections">
<a href="#live">Agents</a>
<a href="#features">Features</a>
<a href="#local">Local-first</a>
<a href="#download">Download</a>
<a href="https://github.com/nxtime/papercraft">GitHub</a>
</nav>
<a class="btn btn-primary btn-sm" href="#download">Download <span class="ver" data-version>latest</span></a>
<span class="progress" aria-hidden="true"></span>
</div>
</div>
</header>
<main id="top">
<section class="hero wrap">
<img class="hero-mark rise" src="media/icon.svg" alt="PaperCraft app icon" width="76" height="76">
<div class="eyebrow rise d1">Rust · egui · wgpu · MCP</div>
<h1 class="serif" id="headline">A native canvas that <em>AI agents</em> can drive.</h1>
<p class="lede rise d2">PaperCraft is an infinite-canvas design tool and whiteboard. It runs on your desktop with no account and no cloud, and it serves the Model Context Protocol so Claude Code, Codex and other agents can edit the open canvas, screenshot it and lint it, one undo step per call.</p>
<div class="cta rise d3">
<a class="btn btn-primary" id="hero-dl" href="#download">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12m0 0-5-5m5 5 5-5M4 21h16"/></svg>
<span id="hero-dl-label">Download</span> <span class="ver" data-version>latest</span>
</a>
<a class="btn btn-ghost" href="https://github.com/nxtime/papercraft">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 .5a11.5 11.5 0 0 0-3.64 22.41c.58.1.79-.25.79-.56v-2c-3.2.7-3.88-1.37-3.88-1.37-.52-1.33-1.28-1.69-1.28-1.69-1.04-.71.08-.7.08-.7 1.15.08 1.76 1.19 1.76 1.19 1.03 1.76 2.69 1.25 3.35.96.1-.75.4-1.25.73-1.54-2.55-.29-5.24-1.28-5.24-5.69 0-1.26.45-2.29 1.19-3.09-.12-.29-.52-1.46.11-3.05 0 0 .97-.31 3.17 1.18a11 11 0 0 1 5.77 0c2.2-1.49 3.17-1.18 3.17-1.18.63 1.59.23 2.76.11 3.05.74.8 1.19 1.83 1.19 3.09 0 4.42-2.7 5.39-5.26 5.68.41.36.78 1.06.78 2.14v3.17c0 .31.21.67.8.56A11.5 11.5 0 0 0 12 .5Z"/></svg>
Source on GitHub
</a>
</div>
<div class="facts rise d3"><span>Local-first, no account</span><span>98 MCP tools</span><span>Windows · macOS · Linux</span><span>MIT or Apache-2.0</span></div>
</section>
<section class="showcase wrap" aria-label="Product tour">
<div class="window acrylic" id="window">
<div class="chrome" aria-hidden="true"><i></i><i></i><i></i><span>PaperCraft · Landing page</span></div>
<video src="media/demo.mp4" poster="media/poster.jpg" autoplay muted loop playsinline preload="metadata" aria-label="A 40-second tour of the editor, chat mode, annotations and design systems"></video>
</div>
<p class="caption">A 40-second tour, rendered from the app's own design rather than screen-recorded.</p>
</section>
<section class="marquee" aria-label="Agents that work with PaperCraft">
<p class="eyebrow wrap">Chat mode runs the agents you already use</p>
<div class="track">
<div class="belt">
<span class="chip acrylic"><i style="--logo:url(media/agents/claude.svg)"></i>Claude Code</span>
<span class="chip acrylic"><i style="--logo:url(media/agents/openai.svg)"></i>Codex</span>
<span class="chip acrylic"><i style="--logo:url(media/agents/cursor.svg)"></i>Cursor</span>
<span class="chip acrylic"><i class="none"></i>OpenCode</span>
<span class="chip acrylic"><i style="--logo:url(media/agents/goose.svg)"></i>Goose</span>
<span class="chip acrylic"><i style="--logo:url(media/agents/kimi.svg)"></i>Kimi</span>
<span class="chip acrylic"><i style="--logo:url(media/agents/mistral.svg)"></i>Mistral Vibe</span>
<span class="chip acrylic"><i style="--logo:url(media/agents/qwen.svg)"></i>Qwen Code</span>
<span class="chip acrylic"><i style="--logo:url(media/agents/cline.svg)"></i>Cline</span>
<span class="chip acrylic"><i style="--logo:url(media/agents/githubcopilot.svg)"></i>Copilot CLI</span>
<span class="chip acrylic"><i style="--logo:url(media/agents/nousresearch.svg)"></i>Hermes</span>
<span class="chip acrylic"><i class="none"></i>Any ACP agent</span>
</div>
</div>
</section>
<section class="block wrap" id="live">
<div class="section-head reveal">
<div class="eyebrow">For agents</div>
<h2 class="serif">Watch an agent <em>build a screen.</em></h2>
<p>Each tool call is one undo step. The agent writes HTML that becomes real layers, switches the theme by changing a variable mode, takes a screenshot and lints the result.</p>
</div>
<div class="live acrylic reveal" id="live-demo">
<div class="log">
<div class="log-head"><span class="dot" aria-hidden="true"></span>Agent run · Sign in<span class="undo" id="undo-count">0 undo steps</span></div>
<ol class="calls" id="calls">
<li class="call"><span class="st"></span><b>create_artboard</b><span class="ms">9 ms</span><span class="arg">{"name": "Sign in", "width": 360, "height": 480}</span></li>
<li class="call"><span class="st"></span><b>set_variables</b><span class="ms">4 ms</span><span class="arg">{"bg", "surface", "text", "accent"} with Dark and Light modes</span></li>
<li class="call"><span class="st"></span><b>write_html</b><span class="ms">18 ms</span><span class="arg">&lt;div style="display:flex; flex-direction:column; gap:12px"&gt; → 6 layers</span></li>
<li class="call"><span class="st"></span><b>update_styles</b><span class="ms">3 ms</span><span class="arg">{"#submit": {"background": "var(--accent)"}}</span></li>
<li class="call"><span class="st"></span><b>set_variable_mode</b><span class="ms">2 ms</span><span class="arg">{"collection": "Colors", "mode": "Light"}</span></li>
<li class="call"><span class="st"></span><b>get_screenshot</b><span class="ms">41 ms</span><span class="arg">sign-in@2x.png</span></li>
<li class="call"><span class="st"></span><b>lint_design</b><span class="ms">7 ms</span><span class="arg">contrast, 4px grid, type scale, touch targets</span></li>
</ol>
</div>
<div class="board-area" aria-hidden="true">
<span class="board-label">Sign in</span>
<span class="mode-pill" id="mode-pill">Mode: Dark</span>
<div class="board" id="board">
<div class="a-logo"></div>
<div class="a-title">Welcome back</div>
<div class="a-sub">Sign in to continue to your workspace.</div>
<div class="a-field"></div>
<div class="a-field"></div>
<div class="a-btn">Sign in</div>
</div>
<span class="lint" id="lint">✓ lint_design · 0 issues</span>
<span class="flash" id="flash"></span>
</div>
</div>
</section>
<section class="block wrap" id="features">
<div class="section-head reveal">
<div class="eyebrow">What you get</div>
<h2 class="serif">A real design tool, <em>not a chat window</em> with a canvas bolted on.</h2>
<p>Frames, auto layout, components, variables and a hand-drawn whiteboard on one GPU-rendered canvas.</p>
</div>
<div class="features">
<article class="feature reveal">
<div class="copy">
<div class="eyebrow">The editor</div>
<h3 class="serif">Layers, canvas, inspector. <em>One native window.</em></h3>
<p>Built in Rust on egui and wgpu. Cached frames take about 3 ms with ten thousand visible layers.</p>
<ul class="ticks">
<li>Auto layout with gap, padding, hug, fill, wrap and constraints</li>
<li>Pen and Bézier editing, boolean operations, gradients and blend modes</li>
<li>A command palette over every action, page and layer</li>
</ul>
</div>
<div class="tilt"><div class="shot acrylic"><img src="media/hero.png" alt="PaperCraft with a habit-tracker design open: layers on the left, three annotated screens on the canvas, the inspector on the right" loading="lazy"></div></div>
</article>
<article class="feature reveal">
<div class="copy">
<div class="eyebrow">Chat mode</div>
<h3 class="serif">Pick an agent. <em>Describe the screen.</em></h3>
<p>A conversation sits next to the canvas. Point at layers with @ mentions or a picked area, and the agent gets a crop with your notes drawn on it.</p>
<ul class="ticks">
<li>Claude Code and Codex through their CLIs, with your own logins</li>
<li>Any ACP agent on your PATH, or the Anthropic API with your key</li>
<li>Every run can be reviewed, reverted one change at a time, or accepted</li>
</ul>
</div>
<div class="tilt"><div class="shot acrylic"><img src="media/chat.png" alt="Chat mode: a conversation with an agent beside the canvas" loading="lazy"></div></div>
</article>
<article class="feature reveal">
<div class="copy">
<div class="eyebrow">Annotations</div>
<h3 class="serif">Annotate <em>in place.</em> Agents read the notes.</h3>
<p>Pin typed notes for rules, behaviour, content and data to any layer, with a template per project. They travel with the design into the handoff spec.</p>
</div>
<div class="tilt"><div class="shot acrylic"><img src="media/annotations.png" alt="The Annotations view listing notes grouped by screen, with their category chips" loading="lazy"></div></div>
</article>
<article class="feature reveal">
<div class="copy">
<div class="eyebrow">Design systems</div>
<h3 class="serif">Tokens out as CSS, DTCG or Tailwind. <em>Code out as JSX.</em></h3>
<p>Variables with modes and aliases, components that work across projects, and shared libraries you publish, enable and update.</p>
<ul class="ticks">
<li>Light and dark are modes on one artboard, never copies</li>
<li>Lint for contrast, the 4px grid, type scale and touch targets, with auto-fix</li>
</ul>
</div>
<div class="tilt"><div class="shot acrylic"><img src="media/variables.png" alt="The Variables modal with Light and Dark values for brand, foreground and background" loading="lazy"></div></div>
</article>
</div>
</section>
<section class="block wrap" aria-labelledby="more-title">
<div class="section-head reveal">
<div class="eyebrow">And the rest</div>
<h2 class="serif" id="more-title">Sketch it, <em>present it,</em> ship it.</h2>
</div>
<div class="bento stagger">
<article class="tile wide acrylic lit reveal">
<div class="art">
<svg class="sketch" viewBox="0 0 360 92" width="100%" height="92" preserveAspectRatio="xMinYMid meet" aria-hidden="true">
<path d="M12 18 C60 12 110 16 140 19 C143 40 141 60 138 76 C96 79 50 78 14 74 C11 55 13 36 12 18Z"/>
<path d="M146 46 C180 44 210 46 236 47 M226 38 L238 47 L226 56"/>
<rect class="note" x="252" y="14" width="78" height="64" rx="4" transform="rotate(3 291 46)"/>
</svg>
</div>
<h3>Whiteboard mode</h3>
<p>Hand-drawn shapes, arrows bound to shapes, elbow connectors, sticky notes, freehand ink and a laser pointer, on the same canvas as your screens.</p>
</article>
<article class="tile acrylic lit reveal">
<div class="art"><span class="fmt in">.fig</span><span class="fmt in">.excalidraw</span><span class="arrow-dash"></span><span class="fmt out">PDF</span></div>
<h3>Import and export</h3>
<p>Figma, Excalidraw and SVG in. PNG at 1 to 4x, SVG and vector PDF out, for a selection, a frame or a page.</p>
</article>
<article class="tile acrylic lit reveal">
<div class="art"><div class="versions"><div>Agent run · Sign in</div><div>Auto-version · 14:02</div><div>Before handoff</div></div></div>
<h3>Versions and runs</h3>
<p>Named and automatic versions per page. An agent run saves one before its first edit, so you can always go back.</p>
</article>
<article class="tile acrylic lit reveal">
<div class="art"><span class="fmt">Welcome</span><span class="arrow-dash"></span><span class="fmt">Today</span></div>
<h3>Present mode</h3>
<p>Prototype links between frames, followed with a camera glide.</p>
</article>
<article class="tile acrylic lit reveal">
<div class="art"><div class="peers"><span style="background:var(--mauve)">M</span><span style="background:var(--blue)">A</span><span style="background:var(--green)">J</span></div></div>
<h3>Shared projects</h3>
<p>An optional relay merges concurrent edits to a page. Without one, nothing leaves your machine.</p>
</article>
<article class="tile wide acrylic lit reveal">
<div class="art">
<div class="swatches" aria-hidden="true">
<span style="background:#eff1f5"></span><span style="background:#303446"></span><span style="background:#24273a"></span><span style="background:#1e1e2e"></span><span style="background:#11111b"></span>
</div>
</div>
<h3>Themes</h3>
<p>System, Light and Dark, plus the four Catppuccin flavours: Latte, Frappé, Macchiato and Mocha.</p>
</article>
<article class="tile acrylic lit reveal">
<div class="art"><span class="fmt">wasm32</span><span class="arrow-dash"></span><span class="fmt out">browser</span></div>
<h3>Web build</h3>
<p>The same editor compiled to WebAssembly, served by <code>papercraft-relay</code>.</p>
</article>
</div>
</section>
<section class="block wrap" id="local">
<div class="section-head reveal">
<div class="eyebrow">Local-first</div>
<h2 class="serif">Your project is <em>a folder on your disk.</em></h2>
<p>Plain JSON you can back up, diff and commit. No account to make and no server to trust.</p>
</div>
<div class="local">
<div class="tree acrylic reveal">
<pre><span class="path">~/Documents/papercraft/</span><span class="d">Pulse/</span>
├── project.json <span class="c"># manifest, variables, components</span>
├── <span class="d">pages/</span> <span class="c"># one JSON file per page</span>
├── <span class="d">versions/</span> <span class="c"># named and automatic versions</span>
├── <span class="d">runs/</span> <span class="c"># agent runs, for review and revert</span>
└── <span class="d">chats/</span> <span class="c"># chat threads with their tool calls</span></pre>
</div>
<div class="points stagger">
<div class="point acrylic lit reveal"><h3>Atomic autosave</h3><p>Every write is atomic, and older files are migrated forward when they load.</p></div>
<div class="point acrylic lit reveal"><h3>Works with the app closed</h3><p><code>papercraft-cli --offline</code> runs the same tools on the project store, screenshots included.</p></div>
<div class="point acrylic lit reveal"><h3>Keys stay in your OS</h3><p>API keys live in the system credential store and never in a project file, chat or log.</p></div>
</div>
</div>
</section>
<section class="block wrap" id="agents">
<div class="section-head reveal">
<div class="eyebrow">Connect</div>
<h2 class="serif">Same tools, <em>three ways in.</em></h2>
<p>The MCP server is built into the app and listens on loopback only. Browser requests and foreign hosts are rejected.</p>
</div>
<div class="agents stagger">
<div class="card acrylic lit reveal">
<div class="stat" id="tool-count" data-to="98">98</div>
<h3>MCP tools, one undo step each</h3>
<div class="ways">
<div class="way"><b>MCP over HTTP</b><span>Built into the app at <code>127.0.0.1:29980/mcp</code>.</span></div>
<div class="way"><b>MCP over stdio</b><span><code>papercraft-mcp</code> relays to the running app.</span></div>
<div class="way"><b>Shell</b><span><code>papercraft-cli</code> calls the same tools, so the agent needs no schemas in context.</span></div>
</div>
</div>
<div class="card acrylic lit reveal">
<h3>Register it once</h3>
<p>Start PaperCraft, then add it to your agent.</p>
<div class="code">
<button class="copy-btn" type="button" data-copy="claude mcp add --transport http --scope user papercraft http://127.0.0.1:29980/mcp">Copy</button>
<pre><span class="c"># Claude Code</span>
<span class="k">claude</span> mcp add --transport http --scope user \
papercraft http://127.0.0.1:29980/mcp</pre>
</div>
<div class="code">
<button class="copy-btn" type="button" data-copy="codex mcp add papercraft --url http://127.0.0.1:29980/mcp">Copy</button>
<pre><span class="c"># Codex</span>
<span class="k">codex</span> mcp add papercraft --url http://127.0.0.1:29980/mcp</pre>
</div>
<div class="code">
<button class="copy-btn" type="button" data-copy="papercraft-cli call get_screenshot '{&quot;nodeId&quot;:&quot;abc123:1&quot;}' --project Demo -o card.png">Copy</button>
<pre><span class="c"># No app running</span>
<span class="k">papercraft-cli</span> call get_screenshot '{"nodeId":"abc123:1"}' \
--project Demo -o card.png</pre>
</div>
</div>
</div>
</section>
<section class="block wrap" aria-labelledby="keys-title">
<div class="section-head reveal">
<div class="eyebrow">Keyboard first</div>
<h2 class="serif" id="keys-title">The shortcuts <em>you'll use daily.</em></h2>
</div>
<div class="keys stagger">
<div class="key acrylic lit reveal"><kbd data-keys="mod K"></kbd><span>Command palette</span></div>
<div class="key acrylic lit reveal"><kbd data-keys="mod J"></kbd><span>Design or Chat mode</span></div>
<div class="key acrylic lit reveal"><kbd data-keys="mod alt shift V"></kbd><span>Variables</span></div>
<div class="key acrylic lit reveal"><kbd data-keys="N"></kbd><span>Annotate</span></div>
<div class="key acrylic lit reveal"><kbd data-keys="/"></kbd><span>Hide the side panels</span></div>
</div>
</section>
<section class="block wrap" id="download">
<div class="dl-head reveal">
<div class="section-head">
<div class="eyebrow">Download</div>
<h2 class="serif">Get the <em>latest build.</em></h2>
</div>
<a class="release-pill acrylic" id="release-link" href="https://github.com/nxtime/papercraft/releases/latest">
<span class="pulse" aria-hidden="true"></span>
<span><b data-version>latest</b> · <span id="release-date">GitHub releases</span></span>
</a>
</div>
<div class="platforms stagger" id="platforms">
<article class="platform acrylic">
<h3>Loading the latest release</h3>
<p class="note">All builds are also on the <a href="https://github.com/nxtime/papercraft/releases/latest">GitHub releases page</a>.</p>
</article>
</div>
<div class="dl-foot">
<div class="card acrylic reveal">
<h3>First run</h3>
<p>The Windows zip is unsigned, so SmartScreen may ask you to confirm. macOS builds are ad-hoc signed and not notarized: open the app with right-click, Open the first time.</p>
</div>
<div class="card acrylic reveal">
<h3>Build from source</h3>
<p>Rust 1.95 or newer, then <code>cargo build --release</code>. Linux needs Vulkan or GL and the usual X11 or Wayland libraries.</p>
</div>
</div>
</section>
<section class="wrap">
<div class="status acrylic reveal">
<div>
<div class="eyebrow">Status</div>
<h2 class="serif">Alpha, <em>and used daily.</em></h2>
<p>PaperCraft is good for screens, design systems and sketches, and it is not yet a replacement for a mature design tool. The roadmap lists what is missing, in priority order.</p>
</div>
<div class="actions">
<a class="btn btn-ghost" href="https://github.com/nxtime/papercraft/blob/main/ROADMAP.md">Roadmap</a>
<a class="btn btn-ghost" href="https://github.com/nxtime/papercraft#status-and-known-limits">Known limits</a>
</div>
</div>
</section>
</main>
<footer>
<div class="wrap foot">
<span>PaperCraft is open source, licensed MIT or Apache-2.0.</span>
<nav aria-label="Links">
<a href="https://github.com/nxtime/papercraft">GitHub</a>
<a href="https://github.com/nxtime/papercraft/releases">Releases</a>
<a href="https://github.com/nxtime/papercraft/blob/main/docs/agents.md">Agent guide</a>
<a href="https://github.com/nxtime/papercraft/blob/main/CONTRIBUTING.md">Contributing</a>
</nav>
</div>
</footer>
<div class="toast acrylic" id="toast" role="status" aria-live="polite"></div>
<script src="site.js"></script>
</body>
</html>
+484
View File
@@ -0,0 +1,484 @@
/* The landing page sits on the app's own dark canvas: acrylic panels over a dot grid lit by
lavender glows, with the palette of the app icon (Catppuccin Mocha). */
:root {
color-scheme: dark;
--crust: #11111b;
--base: #181825;
--mantle: #1e1e2e;
--surface: #313244;
--overlay: #45475a;
--text: #cdd6f4;
--subtext: #a6adc8;
--muted: #7f849c;
--mauve: #cba6f7;
--mauve-hi: #d9bcfa;
--mauve-deep: #b48ef0;
--on-mauve: #1e1e2e;
--green: #a6e3a1;
--blue: #89b4fa;
--peach: #fab387;
--glass: rgb(30 30 46 / 0.55);
--glass-strong: rgb(30 30 46 / 0.8);
--glass-edge: rgb(205 214 244 / 0.09);
--glass-hi: rgb(255 255 255 / 0.06);
--glow: rgb(203 166 247 / 0.32);
--dot: rgb(205 214 244 / 0.07);
--well: rgb(17 17 27 / 0.6);
--display: "Instrument Serif", "Iowan Old Style", Georgia, serif;
--sans: "Geist", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--mono: "Geist Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
--r-panel: 22px;
--r-control: 12px;
--wrap: 1180px;
--ease: cubic-bezier(.2, .7, .2, 1);
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
background: var(--crust);
color: var(--text);
font-family: var(--sans);
font-size: 16px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
overflow-x: hidden;
}
body::before {
content: ""; position: fixed; inset: 0 0 auto; height: env(safe-area-inset-top, 0px);
z-index: 30; background: var(--crust);
}
img { max-width: 100%; }
code { font-family: var(--mono); font-size: .92em; }
a { color: inherit; }
:focus-visible { outline: 2px solid var(--mauve); outline-offset: 3px; border-radius: 6px; }
/* The canvas behind everything: dot grid, drifting glows and a spotlight that follows the pointer */
.stage { position: fixed; inset: 0; z-index: -1; pointer-events: none; background: var(--crust); overflow: hidden; }
.stage::before {
content: ""; position: absolute; inset: 0;
background: radial-gradient(circle at 1px 1px, var(--dot) 1px, transparent 0) 0 0 / 22px 22px;
mask-image: radial-gradient(70rem 50rem at 50% 0%, #000 30%, transparent 85%);
}
.glow { position: absolute; border-radius: 50%; filter: blur(80px); opacity: .55; }
.glow.a { width: 52rem; height: 34rem; left: 50%; top: -18rem; margin-left: -26rem; background: rgb(203 166 247 / .32); }
.glow.b { width: 34rem; height: 26rem; right: -10rem; top: 45%; background: rgb(137 180 250 / .12); }
.glow.c { width: 30rem; height: 24rem; left: -10rem; bottom: -6rem; background: rgb(203 166 247 / .14); }
.spot {
position: absolute; inset: 0;
background: radial-gradient(28rem 28rem at var(--mx, 50%) var(--my, -20%), rgb(203 166 247 / .07), transparent 70%);
}
.wrap {
width: 100%; max-width: var(--wrap); margin-inline: auto;
padding-left: max(20px, env(safe-area-inset-left, 0px));
padding-right: max(20px, env(safe-area-inset-right, 0px));
}
/* Acrylic: translucent fill, backdrop blur, a hairline edge and a top highlight */
.acrylic {
position: relative;
background: linear-gradient(180deg, var(--glass-hi), transparent 40%), var(--glass);
backdrop-filter: blur(22px) saturate(160%);
-webkit-backdrop-filter: blur(22px) saturate(160%);
border: 1px solid var(--glass-edge);
box-shadow: 0 1px 0 var(--glass-hi) inset, 0 24px 60px -24px rgb(0 0 0 / .6);
}
/* Cards light up under the pointer, like a hovered panel in the app */
.lit::after {
content: ""; position: absolute; inset: -1px; border-radius: inherit; pointer-events: none;
background: radial-gradient(22rem 22rem at var(--lx, 50%) var(--ly, 50%), rgb(203 166 247 / .14), transparent 60%);
opacity: 0; transition: opacity .4s;
}
.lit:hover::after { opacity: 1; }
.eyebrow {
font-family: var(--mono); font-size: 12px; letter-spacing: .18em;
text-transform: uppercase; color: var(--muted);
}
.serif { font-family: var(--display); font-weight: 400; letter-spacing: -.01em; }
em { font-style: italic; color: var(--mauve); }
/* Nav */
header.nav { position: sticky; top: env(safe-area-inset-top, 0px); z-index: 20; padding-block: 14px; }
.nav-bar {
display: flex; align-items: center; gap: 16px; padding: 8px 8px 8px 14px; border-radius: 999px;
transition: background .3s, box-shadow .3s;
}
.scrolled .nav-bar { background: var(--glass-strong); }
.brand { display: flex; align-items: center; gap: 10px; text-decoration: none; font-weight: 600; }
.brand img { width: 28px; height: 28px; }
.nav-links { display: flex; gap: 4px; margin-left: auto; }
.nav-links a {
text-decoration: none; color: var(--subtext); font-size: 14px;
padding: 6px 12px; border-radius: 999px; transition: color .2s, background .2s;
}
.nav-links a:hover, .nav-links a.active { color: var(--text); background: var(--glass-hi); }
.progress {
position: absolute; left: 24px; right: 24px; bottom: -1px; height: 1px; border-radius: 1px;
background: linear-gradient(90deg, transparent, var(--mauve), transparent);
transform-origin: 0 50%; transform: scaleX(var(--p, 0)); opacity: .7;
}
/* Buttons */
.btn {
position: relative; overflow: hidden;
display: inline-flex; align-items: center; gap: 10px; text-decoration: none;
font: 500 15px/1 var(--sans); padding: 13px 20px; border-radius: 999px;
border: 1px solid transparent; cursor: pointer; white-space: nowrap;
transition: transform .25s var(--ease), box-shadow .25s, background .25s;
}
.btn-primary {
background: linear-gradient(180deg, var(--mauve-hi), var(--mauve-deep)); color: var(--on-mauve);
box-shadow: 0 0 0 1px rgb(255 255 255 / .25) inset, 0 10px 30px -8px var(--glow);
}
.btn-primary::before {
content: ""; position: absolute; inset: 0; transform: translateX(-120%);
background: linear-gradient(100deg, transparent 30%, rgb(255 255 255 / .45) 50%, transparent 70%);
}
.btn-primary:hover { transform: translateY(-2px); box-shadow: 0 0 0 1px rgb(255 255 255 / .3) inset, 0 16px 40px -8px var(--glow); }
.btn-primary:hover::before { transition: transform .8s var(--ease); transform: translateX(120%); }
.btn-ghost { background: var(--glass); border-color: var(--glass-edge); color: var(--text); backdrop-filter: blur(12px); }
.btn-ghost:hover { background: var(--glass-strong); transform: translateY(-2px); }
.btn-sm { padding: 9px 14px; font-size: 14px; }
.btn svg { width: 16px; height: 16px; flex: none; }
.ver { font-family: var(--mono); font-size: 12px; opacity: .7; }
/* Hero */
.hero { text-align: center; padding-block: clamp(48px, 9vw, 104px) 36px; }
.hero-mark { width: 76px; height: 76px; margin: 0 auto 22px; display: block; filter: drop-shadow(0 12px 30px var(--glow)); }
.hero h1 { font-size: clamp(44px, 7.4vw, 92px); line-height: 1; margin: 18px auto 0; max-width: 14ch; text-wrap: balance; }
.hero h1 .w { display: inline-block; }
.lede { max-width: 58ch; margin: 22px auto 0; color: var(--subtext); font-size: clamp(16px, 1.6vw, 18px); text-wrap: pretty; }
.cta { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; margin-top: 32px; }
.facts {
display: flex; flex-wrap: wrap; justify-content: center; gap: 8px 22px; margin-top: 26px;
font-family: var(--mono); font-size: 12px; color: var(--muted); letter-spacing: .04em;
}
.facts span::before { content: ""; display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--green); margin-right: 8px; vertical-align: 2px; }
/* Video window: tilted back until it scrolls into view */
.showcase { padding-block: 24px 24px; perspective: 1600px; }
.window {
border-radius: var(--r-panel); padding: 10px;
transform: rotateX(var(--tilt, 10deg)) scale(var(--zoom, .96)); transform-origin: 50% 0;
box-shadow: 0 0 0 1px var(--glass-edge), 0 40px 120px -30px var(--glow), 0 30px 80px -30px rgb(0 0 0 / .8);
}
.chrome { display: flex; align-items: center; gap: 8px; padding: 4px 6px 10px; }
.chrome i { width: 11px; height: 11px; border-radius: 50%; background: var(--surface); }
.chrome span { margin-inline: auto; font-family: var(--mono); font-size: 12px; color: var(--muted); }
.window video { display: block; width: 100%; aspect-ratio: 16 / 9; border-radius: 14px; background: var(--base); }
.caption { text-align: center; color: var(--muted); font-size: 14px; margin: 18px 0 0; }
/* Agents marquee */
.marquee { padding-block: 40px 8px; }
.marquee p { text-align: center; margin: 0 0 18px; }
.track {
display: flex; overflow: hidden;
mask-image: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent);
}
.belt { display: flex; gap: 12px; padding-right: 12px; flex: none; animation: belt 48s linear infinite; }
.track:hover .belt { animation-play-state: paused; }
.chip {
display: inline-flex; align-items: center; gap: 9px; padding: 9px 14px; border-radius: 999px;
font-size: 14px; color: var(--subtext); white-space: nowrap;
}
.chip i {
width: 16px; height: 16px; flex: none; background: currentColor;
-webkit-mask: var(--logo) center / contain no-repeat; mask: var(--logo) center / contain no-repeat;
}
.chip i.none { -webkit-mask: none; mask: none; border-radius: 4px; background: var(--overlay); }
@keyframes belt { to { transform: translateX(-100%); } }
/* Sections */
section.block { padding-block: clamp(56px, 8vw, 96px); }
.section-head { max-width: 720px; margin-bottom: 44px; }
.section-head h2 { font-size: clamp(36px, 5vw, 60px); line-height: 1.04; margin: 14px 0 0; text-wrap: balance; }
.section-head p { color: var(--subtext); margin: 16px 0 0; max-width: 60ch; }
/* Live agent demo */
.live { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 6fr); gap: 0; border-radius: var(--r-panel); overflow: hidden; }
.log { padding: 22px; border-right: 1px solid var(--glass-edge); min-width: 0; display: flex; flex-direction: column; }
.log-head { display: flex; align-items: center; gap: 10px; font-size: 13px; color: var(--subtext); margin-bottom: 14px; }
.log-head .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); animation: pulse 2.4s infinite; }
.log-head .undo { margin-left: auto; font-family: var(--mono); font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; }
.calls { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; font: 13px/1.5 var(--mono); }
.call {
display: grid; grid-template-columns: 14px minmax(0, 1fr) auto; gap: 4px 10px; align-items: start;
padding: 9px 11px; border-radius: 10px; background: var(--well); border: 1px solid var(--glass-edge);
opacity: .28; transition: opacity .4s, border-color .4s, transform .4s var(--ease);
}
.call .st { width: 14px; height: 14px; margin-top: 3px; border-radius: 50%; border: 1.5px solid var(--overlay); }
.call b { font-weight: 500; color: var(--text); }
.call .arg { grid-column: 2 / span 2; color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }
.call .ms { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
.call.run { opacity: 1; border-color: rgb(203 166 247 / .5); transform: translateX(4px); }
.call.run .st { border-color: var(--mauve); border-top-color: transparent; animation: spin .7s linear infinite; }
.call.done { opacity: 1; }
.call.done .st { border: 0; background: var(--green) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4 8.5l2.5 2.5L12 5.5' fill='none' stroke='%231e1e2e' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / 12px no-repeat; }
.call:not(.done) .ms { visibility: hidden; }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes pulse { 0% { box-shadow: 0 0 0 0 rgb(166 227 161 / .6); } 70%, 100% { box-shadow: 0 0 0 8px rgb(166 227 161 / 0); } }
.board-area {
position: relative; min-width: 0; display: grid; place-items: center; padding: 40px 24px;
background: radial-gradient(circle at 1px 1px, var(--dot) 1px, transparent 0) 0 0 / 18px 18px, rgb(17 17 27 / .35);
}
.board-label { position: absolute; top: 16px; left: 20px; font: 12px var(--mono); color: var(--muted); }
.mode-pill {
position: absolute; top: 12px; right: 16px; font: 500 12px var(--mono); padding: 4px 10px; border-radius: 999px;
background: var(--well); border: 1px solid var(--glass-edge); color: var(--subtext); transition: color .4s;
}
.board {
--a-bg: #1e1e2e; --a-surface: #313244; --a-text: #cdd6f4; --a-sub: #a6adc8; --a-accent: #cba6f7; --a-on: #1e1e2e;
width: min(100%, 300px); aspect-ratio: 3 / 4; border-radius: 18px; padding: 26px 22px;
display: flex; flex-direction: column; gap: 12px;
outline: 1.5px dashed transparent; outline-offset: 6px;
background: transparent; transition: background .6s, outline-color .4s, box-shadow .6s;
}
.board.light { --a-bg: #eff1f5; --a-surface: #dce0e8; --a-text: #4c4f69; --a-sub: #6c6f85; --a-accent: #8839ef; --a-on: #eff1f5; }
.board.s1 { outline-color: rgb(203 166 247 / .6); }
.board.s2 { background: var(--a-bg); box-shadow: 0 30px 60px -30px rgb(0 0 0 / .8); }
.board > * { opacity: 0; transform: translateY(8px); transition: opacity .45s var(--ease), transform .45s var(--ease), background .6s, color .6s; }
.board.s3 > * { opacity: 1; transform: none; }
.board.s3 > *:nth-child(2) { transition-delay: .06s; } .board.s3 > *:nth-child(3) { transition-delay: .12s; }
.board.s3 > *:nth-child(4) { transition-delay: .18s; } .board.s3 > *:nth-child(5) { transition-delay: .24s; }
.board.s3 > *:nth-child(6) { transition-delay: .3s; }
.a-logo { width: 34px; height: 34px; border-radius: 10px; background: var(--a-surface); }
.a-title { font: 600 22px/1.2 var(--sans); color: var(--a-text); }
.a-sub { font-size: 12px; color: var(--a-sub); margin-top: -6px; }
.a-field { height: 36px; border-radius: 9px; background: var(--a-surface); }
.a-btn {
height: 38px; border-radius: 10px; margin-top: auto; display: grid; place-items: center;
font: 600 13px var(--sans); background: var(--a-surface); color: var(--a-sub);
}
.board.s4 .a-btn { background: var(--a-accent); color: var(--a-on); }
.board.s4 .a-logo { background: var(--a-accent); }
.flash { position: absolute; inset: 0; background: #fff; opacity: 0; pointer-events: none; }
.flash.on { animation: flash .5s ease-out; }
@keyframes flash { 0% { opacity: .35; } 100% { opacity: 0; } }
.lint {
position: absolute; bottom: 16px; left: 50%; display: inline-flex; align-items: center; gap: 8px;
padding: 7px 12px; border-radius: 999px; font: 500 12px var(--mono); color: var(--green);
background: var(--glass-strong); border: 1px solid rgb(166 227 161 / .35);
opacity: 0; transform: translate(-50%, 10px); transition: opacity .4s, transform .4s var(--ease);
}
.lint.on { opacity: 1; transform: translate(-50%, 0); }
/* Feature rows */
.features { display: grid; gap: 24px; }
.feature {
display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); gap: clamp(24px, 4vw, 56px);
align-items: center; padding-block: 28px;
}
.feature:nth-child(even) { grid-template-columns: minmax(0, 7fr) minmax(0, 5fr); }
.feature:nth-child(even) .copy { order: 2; }
.feature h3 { font-size: clamp(30px, 3.6vw, 44px); line-height: 1.06; margin: 12px 0 0; text-wrap: balance; }
.feature p { color: var(--subtext); margin: 14px 0 0; }
.ticks { margin: 16px 0 0; padding: 0; list-style: none; display: grid; gap: 8px; color: var(--subtext); font-size: 15px; }
.ticks li { display: flex; gap: 10px; }
.ticks li::before { content: ""; flex: none; width: 6px; height: 6px; margin-top: 10px; border-radius: 2px; background: var(--mauve); }
.tilt { perspective: 1200px; }
.shot {
border-radius: var(--r-panel); padding: 8px;
transform: rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)); transition: transform .5s var(--ease);
}
.shot img { display: block; width: 100%; height: auto; border-radius: 15px; }
/* Bento grid */
.bento { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
.tile { border-radius: var(--r-panel); padding: 24px; min-width: 0; display: flex; flex-direction: column; gap: 10px; }
.tile.wide { grid-column: span 2; }
.tile h3 { margin: 0; font-size: 17px; font-weight: 600; }
.tile p { margin: 0; color: var(--subtext); font-size: 15px; }
.tile .art { height: 92px; margin-bottom: 8px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.fmt {
font: 500 12px var(--mono); padding: 6px 10px; border-radius: 8px; background: var(--well);
border: 1px solid var(--glass-edge); color: var(--subtext);
}
.fmt.in { color: var(--blue); } .fmt.out { color: var(--mauve); }
.arrow-dash { flex: 1; min-width: 30px; height: 1px; background: repeating-linear-gradient(90deg, var(--overlay) 0 6px, transparent 6px 12px); background-size: 24px 1px; animation: dash 1.2s linear infinite; }
@keyframes dash { to { background-position: 24px 0; } }
.swatches { display: flex; gap: 8px; }
.swatches span {
width: 40px; height: 56px; border-radius: 10px; border: 1px solid var(--glass-edge);
transition: transform .4s var(--ease);
}
.tile:hover .swatches span { transform: translateY(-6px); }
.tile:hover .swatches span:nth-child(2) { transition-delay: .04s; } .tile:hover .swatches span:nth-child(3) { transition-delay: .08s; }
.tile:hover .swatches span:nth-child(4) { transition-delay: .12s; } .tile:hover .swatches span:nth-child(5) { transition-delay: .16s; }
.sketch path { fill: none; stroke: var(--mauve); stroke-width: 2; stroke-linecap: round; stroke-dasharray: 340; stroke-dashoffset: 340; }
.sketch .note { fill: var(--peach); opacity: .85; }
.in-view .sketch path { animation: draw 1.6s var(--ease) forwards; }
.in-view .sketch path:nth-of-type(2) { animation-delay: .4s; }
@keyframes draw { to { stroke-dashoffset: 0; } }
.versions { display: grid; gap: 6px; width: 100%; font: 12px var(--mono); color: var(--muted); }
.versions div { display: flex; gap: 10px; align-items: center; }
.versions div::before { content: ""; width: 8px; height: 8px; border-radius: 50%; border: 1.5px solid var(--overlay); flex: none; }
.versions div:first-child { color: var(--text); }
.versions div:first-child::before { border-color: var(--mauve); background: var(--mauve); }
.peers { display: flex; align-items: center; }
.peers span {
width: 38px; height: 38px; border-radius: 50%; display: grid; place-items: center; font: 600 13px var(--sans);
color: var(--on-mauve); margin-left: -8px; border: 2px solid var(--mantle);
}
.peers span:first-child { margin-left: 0; }
/* Local-first */
.local { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 20px; align-items: stretch; }
.tree { border-radius: var(--r-panel); padding: 22px 24px; font: 13px/2 var(--mono); color: var(--subtext); overflow-x: auto; min-width: 0; }
.tree .path { color: var(--muted); }
.tree .d { color: var(--mauve); }
.tree .c { color: var(--muted); }
.tree pre { margin: 0; }
.points { display: grid; gap: 14px; align-content: start; }
.point { border-radius: 16px; padding: 18px 20px; }
.point h3 { margin: 0; font-size: 16px; font-weight: 600; }
.point p { margin: 4px 0 0; color: var(--subtext); font-size: 15px; }
/* Agents */
.agents { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 20px; }
.card { border-radius: var(--r-panel); padding: 24px; min-width: 0; }
.card h3 { margin: 0; font-size: 17px; font-weight: 600; }
.card > p { color: var(--subtext); margin: 8px 0 0; font-size: 15px; }
.stat { font-family: var(--display); font-size: 72px; line-height: 1; color: var(--mauve); font-variant-numeric: tabular-nums; }
.ways { display: grid; gap: 14px; margin-top: 18px; }
.way { display: grid; grid-template-columns: 120px minmax(0, 1fr); gap: 12px; font-size: 14px; }
.way b { font-weight: 500; }
.way span { color: var(--subtext); }
.code {
position: relative; margin-top: 14px; border-radius: var(--r-control); background: var(--well);
border: 1px solid var(--glass-edge); font: 13px/1.7 var(--mono); color: var(--subtext);
}
.code pre { margin: 0; padding: 14px 16px; padding-right: 70px; overflow-x: auto; }
.code .c { color: var(--muted); }
.code .k { color: var(--mauve); }
.copy-btn {
position: absolute; top: 8px; right: 8px; font: 500 12px var(--sans); color: var(--subtext);
background: var(--glass-strong); border: 1px solid var(--glass-edge); border-radius: 8px;
padding: 5px 9px; cursor: pointer; transition: color .2s, border-color .2s;
}
.copy-btn:hover { color: var(--text); border-color: rgb(203 166 247 / .4); }
/* Shortcuts */
.keys { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; }
.key { border-radius: 16px; padding: 18px; display: grid; gap: 12px; justify-items: start; }
.key span { font-size: 14px; color: var(--subtext); }
kbd {
display: inline-flex; gap: 4px; font: 500 14px var(--mono); color: var(--text);
}
kbd b {
min-width: 30px; height: 30px; padding: 0 8px; display: grid; place-items: center; font-weight: 500;
border-radius: 8px; background: var(--well); border: 1px solid var(--glass-edge);
box-shadow: 0 2px 0 rgb(0 0 0 / .4); transition: transform .15s, box-shadow .15s;
}
.key:hover kbd b { transform: translateY(2px); box-shadow: 0 0 0 rgb(0 0 0 / .4); }
/* Download */
.dl-head { display: flex; flex-wrap: wrap; align-items: end; justify-content: space-between; gap: 16px; margin-bottom: 28px; }
.dl-head .section-head { margin: 0; }
.release-pill {
display: inline-flex; align-items: center; gap: 10px; padding: 8px 14px; border-radius: 999px;
font-family: var(--mono); font-size: 13px; color: var(--subtext); text-decoration: none;
}
.release-pill b { color: var(--text); font-weight: 500; }
.pulse { width: 8px; height: 8px; border-radius: 50%; background: var(--green); animation: pulse 2.4s infinite; }
.platforms { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
.platform { display: flex; flex-direction: column; gap: 12px; border-radius: var(--r-panel); padding: 22px; min-width: 0; }
.platform.mine { box-shadow: 0 0 0 1px rgb(203 166 247 / .55), 0 0 50px -10px var(--glow); }
.platform .tag {
position: absolute; top: 18px; right: 18px; font: 500 11px var(--mono); letter-spacing: .08em; text-transform: uppercase;
color: var(--on-mauve); background: var(--mauve); padding: 3px 8px; border-radius: 999px;
}
.platform .os { display: flex; align-items: center; gap: 12px; margin-bottom: 4px; }
.platform .os svg { width: 28px; height: 28px; color: var(--text); flex: none; }
.platform h3 { margin: 0; font-size: 18px; font-weight: 600; }
.platform .arch { font-family: var(--mono); font-size: 12px; color: var(--muted); }
.file { display: grid; grid-template-columns: minmax(0, 1fr) auto; border-radius: var(--r-control); background: var(--well); border: 1px solid var(--glass-edge); transition: border-color .2s; }
.file:hover { border-color: rgb(203 166 247 / .5); }
.file a { display: grid; gap: 2px; padding: 12px 14px; text-decoration: none; min-width: 0; }
.file .fname { font: 500 14px var(--sans); }
.file .fmeta { font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; font-family: var(--mono); }
.file .sha {
align-self: center; margin-right: 8px; font: 500 11px var(--mono); color: var(--muted); cursor: pointer;
background: transparent; border: 1px solid var(--glass-edge); border-radius: 7px; padding: 5px 8px;
}
.file .sha:hover { color: var(--text); }
.platform .note { font-size: 13px; color: var(--muted); margin: auto 0 0; }
.dl-foot { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; margin-top: 16px; }
.dl-foot .card p { margin: 8px 0 0; color: var(--subtext); font-size: 14px; }
/* Status */
.status { border-radius: var(--r-panel); padding: clamp(24px, 4vw, 40px); display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 24px; align-items: center; }
.status h2 { font-size: clamp(30px, 4vw, 44px); line-height: 1.06; margin: 10px 0 0; }
.status p { color: var(--subtext); margin: 12px 0 0; max-width: 62ch; }
.status .actions { display: flex; gap: 12px; flex-wrap: wrap; }
footer { padding-block: 40px; border-top: 1px solid var(--glass-edge); margin-top: 56px; }
.foot { display: flex; flex-wrap: wrap; gap: 16px 28px; align-items: center; justify-content: space-between; color: var(--muted); font-size: 14px; }
.foot nav { display: flex; gap: 20px; flex-wrap: wrap; }
.foot a { text-decoration: none; }
.foot a:hover { color: var(--text); }
.toast {
position: fixed; left: 50%; bottom: calc(24px + env(safe-area-inset-bottom, 0px)); z-index: 40;
padding: 10px 16px; border-radius: 999px; font-size: 14px; transform: translate(-50%, 20px); opacity: 0;
transition: opacity .3s, transform .3s var(--ease); pointer-events: none;
}
.toast.on { opacity: 1; transform: translate(-50%, 0); }
/* Motion. Everything is readable at rest; motion only moves it into place. */
@media (prefers-reduced-motion: no-preference) {
.glow.a { animation: drift-a 22s ease-in-out infinite alternate; }
.glow.b { animation: drift-b 28s ease-in-out infinite alternate; }
.glow.c { animation: drift-c 26s ease-in-out infinite alternate; }
.hero-mark { animation: float 6s ease-in-out infinite; }
.rise { animation: rise .9s var(--ease) both; }
.hero h1 .w { animation: word .9s var(--ease) both; animation-delay: calc(var(--i) * 60ms + 120ms); }
.d1 { animation-delay: .1s; } .d2 { animation-delay: .45s; } .d3 { animation-delay: .6s; }
.js .reveal { opacity: .001; transform: translateY(24px); transition: opacity .8s var(--ease), transform .8s var(--ease); }
.js .reveal.in-view { opacity: 1; transform: none; }
.js .stagger > .reveal:nth-child(2) { transition-delay: .08s; }
.js .stagger > .reveal:nth-child(3) { transition-delay: .16s; }
.js .stagger > .reveal:nth-child(4) { transition-delay: .24s; }
.js .stagger > .reveal:nth-child(5) { transition-delay: .32s; }
.js .stagger > .reveal:nth-child(6) { transition-delay: .4s; }
@keyframes rise { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } }
@keyframes word { from { opacity: 0; transform: translateY(.4em); filter: blur(6px); } to { opacity: 1; transform: none; filter: none; } }
@keyframes float { 50% { transform: translateY(-6px); } }
@keyframes drift-a { to { transform: translate(6rem, 4rem) scale(1.1); } }
@keyframes drift-b { to { transform: translate(-8rem, -6rem) scale(1.15); } }
@keyframes drift-c { to { transform: translate(10rem, -4rem); } }
}
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
.window { --tilt: 0deg; --zoom: 1; }
.belt { animation: none; }
.track { overflow-x: auto; }
.pulse, .log-head .dot, .arrow-dash { animation: none; }
.sketch path { stroke-dashoffset: 0; }
}
@media (max-width: 960px) {
.feature, .feature:nth-child(even) { grid-template-columns: minmax(0, 1fr); }
.feature:nth-child(even) .copy { order: 0; }
.agents, .platforms, .local, .dl-foot, .live { grid-template-columns: minmax(0, 1fr); }
.log { border-right: 0; border-bottom: 1px solid var(--glass-edge); }
.bento { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.keys { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.status { grid-template-columns: minmax(0, 1fr); }
}
@media (max-width: 640px) {
.nav-links { display: none; }
.nav-bar .btn { margin-left: auto; }
.way { grid-template-columns: minmax(0, 1fr); gap: 2px; }
.window { --tilt: 0deg; --zoom: 1; padding: 6px; }
.chrome { display: none; }
.bento { grid-template-columns: minmax(0, 1fr); }
.tile.wide { grid-column: auto; }
.keys { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
+347
View File
@@ -0,0 +1,347 @@
const REPO = 'nxtime/papercraft';
const REDUCED = matchMedia('(prefers-reduced-motion: reduce)').matches;
const ICONS = {
windows: '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M3 5.5 10.5 4.4v7.1H3V5.5Zm0 13 7.5 1.1v-7H3v5.9Zm8.4 1.2L21 21V12.6h-9.6v7.1Zm0-15.4v7.2H21V3l-9.6 1.3Z"/></svg>',
macos: '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M16.4 12.6c0-2.4 2-3.6 2.1-3.7-1.1-1.7-2.9-1.9-3.5-1.9-1.5-.2-2.9.9-3.7.9-.8 0-1.9-.9-3.2-.8-1.6 0-3.1 1-4 2.4-1.7 3-.4 7.3 1.2 9.7.8 1.2 1.8 2.5 3 2.4 1.2 0 1.7-.8 3.1-.8 1.5 0 1.9.8 3.2.8 1.3 0 2.2-1.2 3-2.4.9-1.4 1.3-2.7 1.3-2.8 0 0-2.5-1-2.5-3.8ZM14 5.4c.7-.8 1.1-1.9 1-3-.9 0-2.1.6-2.8 1.4-.6.7-1.2 1.9-1 2.9 1.1.1 2.1-.5 2.8-1.3Z"/></svg>',
linux: '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2c-2 0-3.3 1.7-3.3 4.2 0 1.4.4 2.4-.4 3.7C7 12 5.5 14 5.5 16.3c0 .6.1 1.1.4 1.6-.9.4-2 .9-2 1.8 0 1.2 1.9 1.3 3.5 1.8 1.3.4 2 .5 2.6-.3.4.1 1.2.2 2 .2s1.6-.1 2-.2c.6.8 1.3.7 2.6.3 1.6-.5 3.5-.6 3.5-1.8 0-.9-1.1-1.4-2-1.8.3-.5.4-1 .4-1.6 0-2.3-1.5-4.3-2.8-6.4-.8-1.3-.4-2.3-.4-3.7C15.3 3.7 14 2 12 2Zm-1.4 4c.4 0 .7.5.7 1.1s-.3 1-.7 1-.7-.4-.7-1 .3-1.1.7-1.1Zm2.8 0c.4 0 .7.5.7 1.1s-.3 1-.7 1-.7-.4-.7-1 .3-1.1.7-1.1ZM12 9c.9 0 2 .6 2 1s-1.1 1-2 1-2-.6-2-1 1.1-1 2-1Z"/></svg>'
};
const PLATFORMS = [
{ key: 'windows', name: 'Windows', arch: 'x86_64', note: 'App, CLI, MCP bridge and relay in one zip.' },
{ key: 'macos', name: 'macOS', arch: 'Apple silicon', note: 'PaperCraft.app in a disk image, plus the CLI tools.' },
{ key: 'linux', name: 'Linux', arch: 'x86_64 · X11 and Wayland', note: 'Needs Vulkan or GL and the usual X11 libraries.' }
];
const OS = (() => {
const ua = navigator.userAgent;
if (/Windows/i.test(ua)) return 'windows';
if (/Macintosh|Mac OS X/i.test(ua) && !/iPhone|iPad/i.test(ua)) return 'macos';
if (/Linux/i.test(ua) && !/Android/i.test(ua)) return 'linux';
return null;
})();
const toast = message => {
const el = document.getElementById('toast');
el.textContent = message;
el.classList.add('on');
clearTimeout(toast.timer);
toast.timer = setTimeout(() => el.classList.remove('on'), 1800);
};
const copy = (text, label) => navigator.clipboard.writeText(text)
.then(() => toast(`${label} copied`))
.catch(() => toast('Copy was blocked. Select the text instead.'));
const escapeHtml = s => s.replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
/* Release: release.json is written by the site workflow; the GitHub API is the fallback. */
const formatSize = bytes => `${(bytes / 1048576).toFixed(1)} MB`;
const formatDate = iso => new Date(iso).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
const fileType = name => (name.endsWith('.tar.gz') ? 'tar.gz' : name.split('.').pop());
const fileLabel = name => (name.includes('-cli-') ? 'CLI tools' : 'Desktop app');
const loadRelease = async () => {
try {
const res = await fetch('release.json', { cache: 'no-cache' });
if (res.ok) return await res.json();
} catch (err) {
console.warn('release.json is unavailable, asking the GitHub API', err);
}
const res = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`);
if (!res.ok) throw new Error(`The GitHub API answered ${res.status}`);
const r = await res.json();
return {
tag: r.tag_name,
name: r.name,
publishedAt: r.published_at,
url: r.html_url,
assets: r.assets.map(a => ({ name: a.name, size: a.size, url: a.browser_download_url, digest: a.digest || null }))
};
};
const renderRelease = release => {
const grid = document.getElementById('platforms');
document.querySelectorAll('[data-version]').forEach(el => { el.textContent = release.tag; });
document.getElementById('release-date').textContent = formatDate(release.publishedAt);
document.getElementById('release-link').href = release.url;
grid.innerHTML = PLATFORMS.map(p => {
const files = release.assets.filter(a => a.name.includes(`-${p.key}-`));
const mine = p.key === OS;
const rows = files.map(a => `
<div class="file">
<a href="${escapeHtml(a.url)}">
<span class="fname">${fileLabel(a.name)}</span>
<span class="fmeta">${fileType(a.name)} · ${formatSize(a.size)}</span>
</a>
${a.digest ? `<button class="sha" type="button" data-digest="${escapeHtml(a.digest)}" title="Copy the SHA-256 checksum">SHA-256</button>` : ''}
</div>`).join('');
return `
<article class="platform acrylic lit reveal${mine ? ' mine' : ''}">
${mine ? '<span class="tag">Your system</span>' : ''}
<div class="os">${ICONS[p.key]}<div><h3>${p.name}</h3><div class="arch">${p.arch}</div></div></div>
${rows || '<p class="note">No build in this release. Build from source.</p>'}
<p class="note">${p.note}</p>
</article>`;
}).join('');
grid.querySelectorAll('.sha').forEach(btn => {
btn.addEventListener('click', () => copy(btn.dataset.digest.replace(/^sha256:/, ''), 'Checksum'));
});
grid.querySelectorAll('.reveal').forEach(el => revealer.observe(el));
grid.querySelectorAll('.lit').forEach(light);
const primary = OS && release.assets.find(a => a.name.includes(`-${OS}-`) && !a.name.includes('-cli-'));
if (primary) {
document.getElementById('hero-dl').href = primary.url;
document.getElementById('hero-dl-label').textContent = `Download for ${PLATFORMS.find(p => p.key === OS).name}`;
}
};
const showReleaseError = err => {
console.error(err);
document.getElementById('platforms').innerHTML = `
<article class="platform acrylic">
<h3>The release list did not load</h3>
<p class="note">Every build is on the GitHub releases page.</p>
<a class="btn btn-ghost" href="https://github.com/${REPO}/releases/latest">Open releases</a>
</article>`;
};
/* Motion */
const revealer = new IntersectionObserver(entries => {
entries.forEach(e => {
if (!e.isIntersecting) return;
e.target.classList.add('in-view');
revealer.unobserve(e.target);
});
}, { rootMargin: '0px 0px -8% 0px' });
const light = el => {
el.addEventListener('pointermove', e => {
const r = el.getBoundingClientRect();
el.style.setProperty('--lx', `${e.clientX - r.left}px`);
el.style.setProperty('--ly', `${e.clientY - r.top}px`);
});
};
const splitHeadline = () => {
const h1 = document.getElementById('headline');
let i = 0;
const wrap = node => {
if (node.nodeType === Node.TEXT_NODE) {
const frag = document.createDocumentFragment();
node.textContent.split(/(\s+)/).forEach(part => {
if (!part) return;
if (/^\s+$/.test(part)) { frag.append(part); return; }
const span = document.createElement('span');
span.className = 'w';
span.style.setProperty('--i', i++);
span.textContent = part;
frag.append(span);
});
node.replaceWith(frag);
return;
}
[...node.childNodes].forEach(wrap);
};
[...h1.childNodes].forEach(wrap);
};
const onScroll = () => {
const y = scrollY;
const max = document.documentElement.scrollHeight - innerHeight;
const win = document.getElementById('window');
const rect = win.getBoundingClientRect();
const t = Math.min(1, Math.max(0, 1 - rect.top / (innerHeight * 0.8)));
document.getElementById('nav').classList.toggle('scrolled', y > 24);
document.querySelector('.progress').style.setProperty('--p', max > 0 ? (y / max).toFixed(4) : 0);
if (!REDUCED && innerWidth > 640) {
win.style.setProperty('--tilt', `${(10 * (1 - t)).toFixed(2)}deg`);
win.style.setProperty('--zoom', (0.96 + 0.04 * t).toFixed(4));
}
};
const tiltShots = () => {
document.querySelectorAll('.tilt').forEach(box => {
const shot = box.querySelector('.shot');
box.addEventListener('pointermove', e => {
const r = box.getBoundingClientRect();
const x = (e.clientX - r.left) / r.width - 0.5;
const y = (e.clientY - r.top) / r.height - 0.5;
shot.style.setProperty('--ry', `${(x * 6).toFixed(2)}deg`);
shot.style.setProperty('--rx', `${(-y * 6).toFixed(2)}deg`);
});
box.addEventListener('pointerleave', () => {
shot.style.setProperty('--ry', '0deg');
shot.style.setProperty('--rx', '0deg');
});
});
};
const countUp = el => {
const to = Number(el.dataset.to);
const start = performance.now();
const step = now => {
const k = Math.min(1, (now - start) / 1200);
el.textContent = Math.round(to * (1 - Math.pow(1 - k, 3)));
if (k < 1) requestAnimationFrame(step);
};
requestAnimationFrame(step);
};
/* The live agent demo: each tool call lights up, then the artboard shows its effect. */
const runDemo = () => {
const calls = [...document.querySelectorAll('#calls .call')];
const board = document.getElementById('board');
const pill = document.getElementById('mode-pill');
const undo = document.getElementById('undo-count');
const flash = document.getElementById('flash');
const lint = document.getElementById('lint');
const effects = [
() => board.classList.add('s1'),
() => board.classList.add('s2'),
() => board.classList.add('s3'),
() => board.classList.add('s4'),
() => { board.classList.add('light'); pill.textContent = 'Mode: Light'; },
() => { flash.classList.remove('on'); void flash.offsetWidth; flash.classList.add('on'); },
() => lint.classList.add('on')
];
let step = 0;
let timer = null;
const reset = () => {
step = 0;
calls.forEach(c => c.classList.remove('run', 'done'));
board.className = 'board';
pill.textContent = 'Mode: Dark';
lint.classList.remove('on');
undo.textContent = '0 undo steps';
};
const tick = () => {
if (step === calls.length) {
timer = setTimeout(() => { reset(); tick(); }, 3200);
return;
}
const call = calls[step];
call.classList.add('run');
timer = setTimeout(() => {
call.classList.replace('run', 'done');
effects[step]();
step++;
undo.textContent = `${step} undo step${step === 1 ? '' : 's'}`;
timer = setTimeout(tick, 380);
}, 620);
};
if (REDUCED) {
calls.forEach((c, i) => { c.classList.add('done'); effects[i](); });
undo.textContent = `${calls.length} undo steps`;
return;
}
new IntersectionObserver(([e]) => {
if (e.isIntersecting && !timer) tick();
if (!e.isIntersecting && timer) { clearTimeout(timer); timer = null; reset(); }
}, { threshold: 0.35 }).observe(document.getElementById('live-demo'));
};
const keyLabels = () => {
const mac = OS === 'macos';
const names = mac ? { mod: '⌘', alt: '⌥', shift: '⇧' } : { mod: 'Ctrl', alt: 'Alt', shift: 'Shift' };
document.querySelectorAll('kbd[data-keys]').forEach(kbd => {
kbd.innerHTML = kbd.dataset.keys.split(' ').map(k => `<b>${names[k] || k}</b>`).join('');
});
};
const activeNav = () => {
const links = [...document.querySelectorAll('.nav-links a[href^="#"]')];
const spy = new IntersectionObserver(entries => {
entries.forEach(e => {
if (!e.isIntersecting) return;
links.forEach(a => a.classList.toggle('active', a.getAttribute('href') === `#${e.target.id}`));
});
}, { rootMargin: '-45% 0px -50% 0px' });
links.forEach(a => {
const target = document.querySelector(a.getAttribute('href'));
if (target) spy.observe(target);
});
};
/* Boot */
document.documentElement.classList.add('js');
splitHeadline();
keyLabels();
activeNav();
tiltShots();
runDemo();
document.querySelectorAll('.reveal').forEach(el => revealer.observe(el));
document.querySelectorAll('.lit').forEach(light);
document.querySelectorAll('.copy-btn').forEach(btn => {
btn.addEventListener('click', () => copy(btn.dataset.copy, 'Command'));
});
const belt = document.querySelector('.belt');
const twin = belt.cloneNode(true);
// A second copy makes the loop seamless; screen readers only need the first.
twin.setAttribute('aria-hidden', 'true');
belt.after(twin);
const counter = document.getElementById('tool-count');
new IntersectionObserver(([e], obs) => {
if (!e.isIntersecting) return;
if (!REDUCED) countUp(counter);
obs.disconnect();
}, { threshold: 0.6 }).observe(counter);
addEventListener('scroll', onScroll, { passive: true });
addEventListener('resize', onScroll);
onScroll();
addEventListener('pointermove', e => {
const spot = document.querySelector('.spot');
spot.style.setProperty('--mx', `${e.clientX}px`);
spot.style.setProperty('--my', `${e.clientY}px`);
}, { passive: true });
loadRelease().then(renderRelease).catch(showReleaseError);
+2 -2
View File
@@ -5,7 +5,7 @@ description: Design-quality rules for work made in PaperCraft (or any flexbox-ba
# papercraft-design
Rules for making designs that look intentional, adapted to what PaperCraft can do: flexbox layout, Inter as the only font, color variables, components, `lint_design`. Mechanics (tools, loop, limits) are in the `papercraft` skill. Sources: Figma's agent skills (variables, auto layout, components, design-system audits, spacing snapped to a scale), Anthropic's frontend-design guidance (commit to a direction, avoid generic AI aesthetics) and common UI practice (4/8pt grid, WCAG contrast, 44px targets).
Rules for making designs that look intentional, adapted to what PaperCraft can do: flexbox layout, Inter as the default font, color variables, components, `lint_design`. Mechanics (tools, loop, limits) are in the `papercraft` skill. Sources: Figma's agent skills (variables, auto layout, components, design-system audits, spacing snapped to a scale), Anthropic's frontend-design guidance (commit to a direction, avoid generic AI aesthetics) and common UI practice (4/8pt grid, WCAG contrast, 44px targets).
## 0. Before you draw anything: commit to a direction
@@ -18,7 +18,7 @@ Write down, in a few lines, before the first tool call:
Avoid the default AI look: purple-to-blue gradient on white, a row of three identical rounded cards with an icon and two lines, centered everything, generic stock phrases ("Seamless. Powerful. Effortless."), emoji as icons, drop shadows everywhere. If your draft would pass for any SaaS template, change one structural thing (layout, scale contrast, color block) until it would not.
**Inter is the only font.** You cannot pair typefaces, so hierarchy has to come from size, weight, color and space. Use real scale contrast (a 40 or 64px headline against 16px body), bold only for what matters, and `text-muted` for secondary text. Tight negative letter-spacing on large headlines and uppercase 12px labels with positive letter-spacing give Inter more character. Do not fake a second font.
**Inter is the default font.** Add another with `install_font` only when the brief calls for it. Hierarchy has to come from size, weight, color and space. Use real scale contrast (a 40 or 64px headline against 16px body), bold only for what matters, and `text-muted` for secondary text. Tight negative letter-spacing on large headlines and uppercase 12px labels with positive letter-spacing give Inter more character. Do not fake a second font.
## 1. Spacing: a 4/8pt scale
+7 -3
View File
@@ -7,7 +7,7 @@ description: Drive the PaperCraft design app (native infinite-canvas design tool
PaperCraft is a native Rust design app. You edit the project that is open in the running app: every tool call is one undo step and the window repaints live. Same tools over MCP (`http://127.0.0.1:29980/mcp` or the `papercraft-mcp` stdio bridge) and over the shell (`papercraft-cli`). Look and taste live in the `papercraft-design` skill; this one is the mechanics.
References: [compact-workflow.md](references/compact-workflow.md) (pages and groups, compressing, design-code sync, few tokens, shared work), [tools.md](references/tools.md) (every tool and argument), [html-subset.md](references/html-subset.md) (what `write_html` accepts), [recipes.md](references/recipes.md) (mobile flow, landing page, dashboard, design-system page).
References: [compact-workflow.md](references/compact-workflow.md) (pages and groups, compressing, design-code sync, few tokens, shared work), [tools.md](references/tools.md) (every tool and argument), [html-subset.md](references/html-subset.md) (what `write_html` accepts), [recipes.md](references/recipes.md) (mobile flow, landing page, dashboard, design-system page), [export-site.md](references/export-site.md) (designs as a website or a React, Vue, Angular or Astro project).
## 1. Connect and orient
@@ -17,6 +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.
* Call `get_context` first (page, target, outline with token estimates, variables, components, lint); read deeper nodes only as needed, one at a time. Every big read takes `maxTokens` and says when it was `truncated`.
* 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`.
@@ -29,6 +30,7 @@ papercraft-cli call get_basic_info # fails with exit 1 if the app is not ru
## 2. The loop: build, look, lint, fix
1. **Tokens first.** `set_variables` once (roles: `bg`, `surface`, `border`, `text`, `text-muted`, `accent`, `on-accent`, `danger`). Add a second mode for dark or light. No hex in the design afterwards.
**Themes are modes, never copies.** One artboard per screen. Show a dark or light (or any other) theme by giving each variable a value per mode and switching with `set_variable_mode`; screenshot each mode in turn. Do not duplicate artboards side by side as "Screen / Light" and "Screen / Dark", and do not rebuild a screen with different hex values for another theme: the copies drift apart and the mode switch stops meaning anything. If a theme needs a different shape (an ornament only one theme shows), make it a variable too (a colour that is `transparent` in the other mode, or `visible` bound to a boolean variable).
2. **Components first.** Anything that appears twice (button, field, chip, row, tab bar) is built once as an artboard, `create_component`, then placed with `create_instance`.
3. **Build in small steps.** `create_artboard`, then one `write_html` per visual group (header, card, row) so the user sees progress. Name every layer with `layer-name`.
4. **Look.** `get_screenshot` of the artboard after each group or two. Scale 2 to read small text. Read it as a designer: hierarchy, alignment, spacing rhythm, clipping, wrapping.
@@ -122,7 +124,7 @@ Full flows: [recipes.md](references/recipes.md).
| Reuse | `create_component`, `create_instance`, `swap_instance` (also a nested badge inside a row instance: a swap that survives main edits), `get_components`, `<x-paper-instance>` |
| 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 |
| Prototype | `set_prototype_links {links:[{nodeId, to}]}`: back arrows, rows and tabs go to their screens in Present mode; or one of `overlay` (frame opened as a dimmed, scrollable overlay), `url` (https, http, mailto, tel), `action` `back` / `close` |
| 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` |
@@ -136,13 +138,14 @@ Full flows: [recipes.md](references/recipes.md).
| Report a PaperCraft problem | `report_issue` (a draft the user sends; synthetic repro only) |
| 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) |
| Publish as a website | `export_site`: ask the person for framework, render and styling first ([export-site.md](references/export-site.md)), then follow its `next_steps` |
| Point at things for the user | `select_nodes` (`zoom: true`) |
| Take back | `undo`, `redo` |
| Save | `finish_working_on_nodes` |
## 6. Known limits (design around them)
* **Font is Inter only**, regular and bold plus italic. No other families, no weights between. Build hierarchy from size, weight, color and space.
* **Font is Inter** unless the brief needs another typeface: `install_font` adds a .ttf or .otf to the project and `font-family` applies it to a whole text layer. Each family has regular and bold plus italic at most, no weights between. Build hierarchy from size, weight, color and space.
* **Variables** are typed (`color`, `number`, `string`, `boolean`), live in collections with modes, group with `/` (`space/md`, `color/brand/primary`) and can alias each other. Colors work as `var(--name)` on fills and text; every other field binds with `bind_variable` (stroke, text-color, width, height, gap, padding, radius, font-size, line-height, opacity, visible). Numbers bind as px; line-height below 4 is a multiplier. Gradients keep fixed colors.
* **No margin, grid or tables; no shadows, filters or transforms** (reported under `ignoredStyles`). Fake elevation with a border or a lighter surface.
* **No rotation.** Nodes are axis-aligned.
@@ -169,6 +172,7 @@ Full flows: [recipes.md](references/recipes.md).
| 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 | 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"`. |
| Tempted to show a second theme next to the first | Don't copy the artboards. Give the variables a value in each mode and switch with `set_variable_mode`; screenshot one mode, switch, screenshot again. |
| 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. |
@@ -21,7 +21,7 @@ How to keep designs small, keep design and code in step, and spend few tokens do
1. Design is the source of truth for structure, tokens and copy. Code is generated or verified from it, never the other way round without updating the design.
2. `get_design_spec {nodeId}` for each artboard: one deterministic JSON (layout, sizing, tokens, icons, `instanceOf`, `componentDefinitions`). Hand that to the implementer, not screenshots.
3. Implement from the spec. `verify_code {code, nodeId, language}` after each file; fix what it reports. Re-run `get_design_spec` only for artboards that changed (`diff_frames` between the old and new version of an artboard names the changed properties).
3. Implement from the spec. `verify_code {code, nodeId, language}` after each file; fix what it reports. Re-run `get_design_spec` only for what changed: `mark_handoff {name}` when screens go to development, then `get_handoff_changes {since: name}` lists each changed screen, component and token with its kind and action, and `export_handoff {dir, since}` writes the bundle (specs, reference images, tokens, changes.md) rewriting only changed files.
4. Name code pieces after components: `Button / Primary` becomes `button::primary` or `<ButtonPrimary>`. A 1:1 name map is the cheapest way to stay synced; keep it in a table in the feature spec (`specs/<feature>/design-map.md`).
5. Screenshots only for the final visual check (one per artboard, scale 1), and for diffing code output against the design (`get_screenshot` of both, compare).
@@ -0,0 +1,77 @@
# Website export (`export_site`)
`export_site` writes frames as a website: plain HTML, or a project for React, Vue, Angular or Astro. Ask the person which framework, render and styling they want before the first export. Recommend from what they already have.
## Recommend
1. **Framework:** the one their repo or team uses (read `package.json`). If none:
- `html` for a finished site nobody will develop further;
- `astro` for a content or marketing site they will grow.
2. **Render:**
- `static` (prerendered pages) for anything public: search engines and link previews read the prerendered head;
- `spa` only for an app behind a login, because it sets the head in the browser.
3. **Styling:**
- what their project already uses, else `css`;
- `scss` and `tailwind` (v4) work with every framework;
- CSS-in-JS works with React only: `vanilla-extract` (zero runtime, recommended), `styled-components` or `emotion` (each adds about 12 KB of runtime).
Client JavaScript per page (gzip, measured on the sample site in November 2026):
| Framework | static | spa |
|---|---|---|
| html, astro | 0 | n/a |
| vue | 0 (Nuxt scripts off) | 40 KB |
| angular | 77 KB | 63 KB |
| react | 136 KB (148 KB with styled-components or emotion) | 97 KB (108 KB with styled-components or emotion) |
## What each framework writes
| Framework, render | Project | Build | Serve |
|---|---|---|---|
| html | pages, one shared stylesheet, `assets/` | none (`scss`/`tailwind`: `npm install && npm run build`) | the folder |
| astro | Astro 7: `src/layouts/Layout.astro` owns the head, one component per frame in `src/components` | `npm install && npm run build` | `dist/` |
| vue, static | Nuxt 4, one SFC per frame, every route prerendered | `npm install && npm run generate` | `.output/public/` |
| vue, spa | Vite 8, vue-router 5, @unhead/vue | `npm install && npm run build` | `dist/` with a fallback to `index.html` |
| angular, static | Angular 22 standalone components, every route prerendered | `npm install && npm run build` | `dist/site/browser/` |
| angular, spa | the same, rendered in the browser | `npm install && npm run build` | `dist/site/browser/` with a fallback to `index.html` |
| react, static | Next.js 16 static export | `npm install && npm run build` | `out/` |
| react, spa | Vite 8, React 19, React Router 8 | `npm install && npm run build` | `dist/` with a fallback to `index.html` |
The reply's `next_steps` repeats the commands for the chosen variant.
The writers share these behaviours:
- Every framework gets the same pages: semantic tags from layer names, one h1, `<img>` with alt text from the layer name, self-hosted fonts, Open Graph tags and `robots.txt`.
- Headings follow the outline: text in a header or nav (the logo) is not a heading, a short line right under a larger heading in the same group is a paragraph, and no level is skipped.
- Name a layer `Favicon` (any case; a picture or a vector) and it becomes the site icon, as SVG or the picture's own file. Without one the page links `data:,`, so the browser asks for no `/favicon.ico`.
- The page's h1 face and the face most body text uses are preloaded (two fonts at most). Custom properties are written only where a declaration reads them, and `warnings` lists every text layer below WCAG AA contrast (4.5:1, 3:1 for large text).
- A link or button with no text gets the layer name as its `aria-label`.
- A frame at least 1024 px wide takes the window's full width. A narrower frame (a phone or tablet screen) stays a centred column of its designed width. Every page is at least as tall as the window, so its background reaches the edges.
- Below the designed width, pages reflow instead of clipping: fixed widths give way, rows of cards wrap, and type of 40 px or more shrinks with the window. Rows wider than their frame (carousels) and layers that overflow on purpose keep their designed size.
- Fonts are cut down to the characters the site uses in every language and written as WOFF2, each with a fallback face of matching metrics so the swap does not move the layout. Kerning is left out when the subsetter cannot keep it; the canvas does not kern either.
- `base_url` makes canonical and hreflang URLs absolute and adds `sitemap.xml`.
## Hosting
Every export also writes the files static hosts read. `vercel.json` sits at the project root; the others go in `public/` for the npm frameworks, so the build copies them into the folder you deploy (for html, the root is that folder):
- `_headers` (Netlify, Cloudflare Pages) and `vercel.json`: `Cache-Control: public, max-age=31536000, immutable` on the content-hashed paths only (`/assets/*`, plus `/_astro/*`, `/_nuxt/*`, `/_next/static/*`, or Angular's root `/*.js` and `/*.css`). HTML gets no rule on purpose: those hosts already revalidate it, and Netlify and Cloudflare would merge a catch-all with the asset rule.
- SPA renders only: `_redirects` (`/* /index.html 200`) and a Vercel rewrite to `/index.html`. Real files win over the fallback.
- `.nojekyll` for astro, static vue and static react, whose output folders start with `_`.
- GitHub Pages ignores `_headers` and `vercel.json` (it sets `max-age=600`); deploy there with `.nojekyll`.
## Languages
- **Input:** pass `modes` with a variable collection whose modes are languages (`en`, `pt-BR`), and bind the copy to string variables.
- **Routes:** each mode becomes `/<lang>/` routes with one JSON catalog per language, and `/` lists the languages.
- **What changes per language:** text that differs becomes a message, and styles that differ become `:lang()` rules.
- **Title and description:** `title` and `description` may be objects keyed by mode name (`{"en": "...", "pt-BR": "..."}`); each language's pages carry their own `<title>`, meta description and og/twitter tags. A language without an entry takes the base language's.
- **Language picker:** `/` carries the first language's `lang`.
- **Single language:** routes sit at the root with literal text, and `lang` sets the language tag.
## Limits
- One layout per frame. Auto-layout frames shrink to the screen; frames without auto layout stay absolutely positioned, and the export warns about them.
- Pictures keep their format (PNG, JPEG, GIF, lossless WebP), downscaled to twice their displayed size. There is no AVIF.
- Vectors and icons are SVG files shown with `<img>`.
- Without `base_url`, canonical and hreflang URLs are relative (React leaves them out and warns), and no sitemap is written. Pass `base_url` for anything public.
- After design changes, export again to the same folder: a re-export replaces only the files the previous export wrote.
+11 -3
View File
@@ -28,9 +28,17 @@
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`.
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.
Rewritten before layers are made, each reported in `warnings`:
- `margin` between siblings becomes the parent's `gap` when every gap is the same, else spacer layers; the margin before the first and after the last child becomes the parent's `padding` (spacers when its padding is not plain px). `margin-*:auto` is a growing spacer and `margin:0 auto` centres the item across the flow. Negative margins are dropped with a warning.
- `display:grid` becomes rows of cells: `repeat(N, 1fr)`, px, `fr`, `minmax(px, 1fr)` and `repeat(auto-fill, minmax())` (columns counted from the width) tracks; `gap`, `row-gap`, `column-gap`; `grid-column: span N` (also `1 / 3`) makes a cell cover N columns. `grid-template-rows`, areas, `grid-row` and explicit placement are not supported: cells flow row by row, with a warning.
- `transform: translate()`, `translateX()` and `translateY()` move a free-positioned layer by that offset (px). Inside auto layout the offset cannot stay, so use margin or gap. `rotate`, `scale`, `skew` and `matrix` are reported in `ignoredStyles` (layers have no rotation).
- Inline boxes (chips, badges) become boxes in a wrapping row; `<input>` and `<textarea>` become a box showing their placeholder or value.
Not supported (check `ignoredStyles` to be sure): tables, `float`, `calc()`, viewport units, `transform` and rotation.
`ignoredStyles` lists what was dropped, one entry per property (`transform: rotate, scale`).
Prefer `gap` and `padding` anyway: the result is the same and shorter. To open a whole exported page (a `.html` or a `.zip` with its stylesheets and images), use `import_html`: it inlines `<style>` and linked CSS (tag, `.class` and `#id` selectors, `:root` custom properties; media queries and combinators are reported), registers the zip's images, and writes each HTML file into its own new frame on the page.
Not supported (check `ignoredStyles` to be sure): tables, `float`, `calc()`, viewport units, rotation.
Tips:
@@ -41,7 +49,7 @@ Tips:
## Text
Supported: `font-size` (px), `font-weight` (600 and above renders bold; otherwise regular), `font-style:italic`, `color`, `line-height` (px or unitless), `letter-spacing`, `text-align`, `text-decoration` (including `text-decoration-color` and `text-decoration-style: wavy`, e.g. `text-decoration: underline wavy var(--warning)`). The font is always **Inter**; `font-family` is ignored, and there are only regular and bold weights (plus italic), so hierarchy comes from size, weight, color and space.
Supported: `font-size` (px), `font-weight` (600 and above renders bold; otherwise regular), `font-style:italic`, `color`, `line-height` (px or unitless), `letter-spacing`, `text-align`, `text-decoration` (including `text-decoration-color` and `text-decoration-style: wavy`, e.g. `text-decoration: underline wavy var(--warning)`). The font is **Inter** unless `font-family` names a family added with `install_font` (it applies to the whole text layer, not a span). There are only regular and bold weights (plus italic), so hierarchy comes from size, weight, color and space.
## Boxes
+14 -2
View File
@@ -93,7 +93,19 @@ jq -Rs --arg t "$AB" '{targetNodeId:$t,mode:"insert-children",html:.}' landing.h
Check the headline in the screenshot: text boxes with a fixed `width` can wrap earlier than in a browser, so give long headlines generous widths (960px above) and verify.
## 3. Dashboard (sidebar, stat cards, chart)
## 3. Translate a page
A language is a mode of a string collection, never a copy of the page. Text layers bind to string variables, so switching the mode changes the text and re-fits auto-size text and auto layout.
1. `set_strings {"extract": true, "frame": "<frame id>", "mode": "en"}`: every text layer gets a variable named from its layer path (`hero/title`) and the keys come back with their texts.
2. Optional, before any real translation: `set_strings {"collection": "Strings", "mode": "xx", "pseudo": true}`, then `set_variable_mode {"mode": "xx"}`. The text is accented, about 35% longer and in brackets, so overflow shows up now.
3. `set_strings {"collection": "Strings", "mode": "pt", "strings": {"hero/title": "..."}}` per language. Translate in steps with `get_strings {"missing": "pt"}`, which lists only the keys still without a value, with their base text. An unknown key is an error that applies nothing.
4. `set_variable_mode {"mode": "pt"}`, then `lint_design` and `get_screenshot` in each mode to check overflow: fix a layer that outgrows its box by letting it wrap or hug, never by shrinking the text only in one language.
5. `set_variable_mode` back to the base mode.
Typing into a bound text detaches it from its variable (as a new colour does for a `text-color` binding); edit the text with `set_strings` instead.
## 4. Dashboard (sidebar, stat cards, chart)
A row artboard (`flexDirection:"row"`) with a fixed 240px sidebar and a `flex-grow:1` main area.
@@ -132,7 +144,7 @@ jq -Rs --arg t "$AB" '{targetNodeId:$t,mode:"insert-children",html:.}' dashboard
Charts are just boxes: size the bars from the data (`height = value / max * plot height`). Add axis labels at 12px. Always label what the bars mean; color alone is not information.
## 4. Design-system page
## 5. Design-system page
Generate swatches from the project's own variables and a type-scale specimen, so the page cannot drift from the tokens:
+137 -10
View File
@@ -70,12 +70,22 @@ Essential context about the open project: project name, the page, node count, ar
The selected layers on the page the user is viewing: ids, names, types, sizes, positions and the artboard each belongs to, plus `viewport` ({pageId, rect: [x, y, w, h] world, zoom}).
### `get_node_info` (read-only)
### `get_context` (read-only)
Details of one node: type, name, size, visibility, lock state, parent, children ids, text content, x/y (relative to parent) and worldX/worldY. Errors if it doesn't exist.
The one compact read to make before editing (about 2k tokens): the `page`, the `target` (`id`, else the first selected layer, else the whole page) and its `screen`, the `selection`, an `outline` of the target two levels deep with child counts and an `estimatedTokens` per layer (what a `get_tree_summary` of it costs, so you know what is cheap to read next), the `variables` used inside it (value in the active mode, collection), every collection with its modes and the active one, the `components` used with instance counts, and a `lint` summary of the target only (counts per rule and the first 5 findings). Read deeper nodes afterwards, one at a time.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `id` | string | | A layer or screen to describe; omit for the selection, else the page. |
### `get_node_info` (read-only)
Details of one node: type, name, size, visibility, lock state, parent, children ids, text content, x/y (relative to parent) and worldX/worldY. Errors if it doesn't exist. Reports `estimatedTokens`; over `maxTokens` the child ids are cut (`truncated`).
| Argument | Type | Required | Notes |
|---|---|---|---|
| `maxTokens` | number | | Token budget of the result (default 20000). Over it the result is cut at a whole node or line, `truncated` is true and the result says how to read the rest. |
| `nodeId` | string | yes | The node to inspect. |
@@ -92,22 +102,25 @@ Rects of a whole subtree in one read, instead of one get_node_info per layer. Re
### `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.
Direct children of a node (or of the page root when nodeId is "root"), with ids, names, types, child counts and positions. Reports `estimatedTokens`; over `maxTokens` the list is cut (`truncated`).
| Argument | Type | Required | Notes |
|---|---|---|---|
| `maxTokens` | number | | Token budget of the result (default 20000). Over it the result is cut at a whole node or line, `truncated` is true and the result says how to read the rest. |
| `nodeId` | string | yes | The parent node, or "root". |
### `get_tree_summary` (read-only)
Compact indented text tree of a subtree: type, name, id and size per node. Much cheaper than get_jsx for orientation. nodeId "root" summarizes the whole page.
Compact indented text tree of a subtree: type, name, id and size per node. Much cheaper than get_jsx for orientation. nodeId "root" summarizes the whole page. A second result block reports `estimatedTokens`; over `maxTokens` the tree is cut at a whole line and the block carries `truncated`, `nextOffset` (pass it as `offset`) and a hint.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `maxTokens` | number | | Token budget of the result (default 20000). Over it the result is cut at a whole node or line, `truncated` is true and the result says how to read the rest. |
| `depth` | number | | Levels shown, counting nodeId (or each top-level layer for "root") as level 1 (default 3, max 10). Deeper levels collapse to "… N children". |
| `fullText` | boolean | | Show each text layer's complete text instead of its first 40 characters (for copy review). |
| `nodeId` | string | yes | Root of the summary, or "root". |
| `offset` | number | | Line to start at: the `nextOffset` of a truncated result. |
Depth counts from the node you pass, not from the page. For an artboard `Home` with a `Screens` frame holding rows, `{"nodeId": "<Home id>", "depth": 2}` stops at `Screens`:
@@ -122,22 +135,26 @@ The rows inside `Screens` are level 3, so they need `depth: 3` from `Home` (the
### `get_jsx` (read-only)
Export a node and its descendants to code: "inline" (default, React JSX with inline styles), "tailwind" (React JSX with Tailwind CSS utility classes), "react" (React TSX component), "angular" (Angular v17+ standalone component), "html" (self-contained <style> + HTML markup), or "egui" (Rust egui 0.36 code with tokens).
Export a node and its descendants to code: "inline" (default, React JSX with inline styles), "tailwind" (React JSX with Tailwind CSS utility classes), "react" (React TSX component), "angular" (Angular v17+ standalone component), "html" (self-contained <style> + HTML markup), or "egui" (Rust egui 0.36 code with tokens). A second result block reports `estimatedTokens`; over `maxTokens` the code is cut at a whole line and the block carries `truncated`, `nextOffset` (pass it as `offset`) and a hint to export a narrower node.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `maxTokens` | number | | Token budget of the result (default 20000). Over it the result is cut at a whole node or line, `truncated` is true and the result says how to read the rest. |
| `format` | `inline` \| `inline-styles` \| `tailwind` \| `react` \| `angular` \| `html` \| `egui` | | Output format: "inline" (default), "tailwind", "react", "angular", "html", or "egui". |
| `nodeId` | string | yes | The node to convert. |
| `offset` | number | | Line to start at: the `nextOffset` of a truncated result. |
### `get_design_spec` (read-only)
Export a deterministic AI Design-to-Code specification for a frame or subtree. Includes all design tokens/variables, component instances, exact auto-layout tree (sizing, flex/grid/egui flow, gaps, paddings, bound `--var` colors), mandatory enforcement rules, and reference target code. Always call this before implementing a design in React, Tailwind, Angular, HTML/CSS, or Rust egui instead of guessing from a screenshot.
Export a deterministic AI Design-to-Code specification for a frame or subtree. Includes all design tokens/variables, component instances, exact auto-layout tree (sizing, flex/grid/egui flow, gaps, paddings, bound `--var` colors), mandatory enforcement rules, and reference target code. Always call this before implementing a design in React, Tailwind, Angular, HTML/CSS, or Rust egui instead of guessing from a screenshot. Reports `estimatedTokens` (json format: a field; other formats: a second result block); over `maxTokens` the output is cut at a whole line (json: whole array elements) and flagged `truncated` with how to read the rest.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `maxTokens` | number | | Token budget of the result (default 20000). Over it the result is cut at a whole node or line, `truncated` is true and the result says how to read the rest. |
| `format` | `markdown` \| `json` | | Output format: "markdown" (default) or "json". |
| `nodeId` | string | yes | Root frame or node id to export. |
| `offset` | number | | Line to start at: the `nextOffset` of a truncated result. |
| `target` | `react` \| `tailwind` \| `angular` \| `html` \| `egui` | | Target UI framework (default "react"). |
@@ -191,9 +208,17 @@ The image used by a node's image fill, as image content.
| `nodeId` | string | yes | A node with an image fill. |
### `install_font`
Add a font and keep it in the open project, so it travels with the project. Takes one .ttf or .otf per call (one style of a family; a variable font gives its default style); call again for its bold and italic files. Returns the family; use it as font-family in write_html and update_styles. Check the font's licence allows the use.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `source` | string | yes | A local file path, or an http(s) URL of the font file itself (not a web page, zip or WOFF). |
### `get_font_family_info` (read-only)
Whether font families are available and their weights/styles. papercraft renders text with Inter (400, 700, normal and italic); other families fall back to Inter.
Whether font families are available and their weights/styles. Inter (400, 700, normal and italic) is built in and families added with install_font follow; others fall back to Inter.
| Argument | Type | Required | Notes |
|---|---|---|---|
@@ -255,6 +280,62 @@ Export pages as a presentation PDF, the way a designer sends work to a client: a
| `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. |
| `language` | string | | Language of the deck's own words and date: en, pt, es, fr, de, it (default: the one the screens are written in). |
### `mark_handoff`
Mark a handoff point: a named snapshot of every page, taken when screens go to development. get_handoff_changes and export_handoff `since` then report what changed against what the developers received. Marking a name again moves it to now. Saved in the project (handoffs/). Returns {name, pages, screens}.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `name` | string | yes | Name of the point, e.g. "design v1" or a release tag. |
### `list_handoffs` (read-only)
The project's handoff points (mark_handoff), newest first: [{name, saved}].
### `get_handoff_changes` (read-only)
What changed since a handoff point, sorted the way code acts on it, in few tokens: token values once (regenerate the theme), main components once with the screens using them (instances follow), then screens: added, removed, renamed, and per changed layer its kind (copy, binding, style, layout, structure, instance, note) and `field: old → new` with token names. Sizes that follow content, positions set by auto layout and instance fields that follow their main are left out. Markdown shows 8 changes per screen; pass `nodeId` (a screen or component key from it) for all of one. Layer keys work with get_design_spec {nodeId, format: compact} to fetch just that subtree.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `format` | `markdown` \| `json` | | markdown (default, an index) or json (everything). |
| `nodeId` | string | | Only this screen or main component, every change. |
| `since` | string | yes | Handoff point name (list_handoffs). |
### `export_handoff`
Write the handoff bundle a code base builds from into a folder: index.md (one line per screen with its spec file and size in tokens), inventory.json, tokens.json (DTCG with modes), tokens.css, tokens.tailwind.css, components.json, open-questions.md (`? (team)` notes and the table on a page named Handoff), and per screen a compact spec (screens/<page>/<screen>.txt) and a reference PNG at scale 1 (a scrolling screen shown whole). With `since`, also changes.md and changes.json (get_handoff_changes). manifest.json lists each file's hash and tokens. Only files whose bytes changed are written, and files a previous export wrote that no longer exist are removed; nothing else in the folder is touched. Returns {dir, files, written, removed, screens, images}.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `dir` | string | yes | Folder to write into (created if missing), e.g. docs/design. |
| `images` | boolean | | Write reference PNGs (default true). |
| `since` | string | | Handoff point to report changes against. |
### `export_site`
Write frames of a page as a website in one call; returns a short summary, never the markup. Before the first export ASK THE PERSON which `framework`, `render` and `styling` they want, and recommend from what they have: the framework their repo or team uses (read package.json), else html for a finished site nobody will develop or astro for a content site they will grow. Client JavaScript per page (gzip): html and astro 0; static vue ~71 KB, angular ~77 KB, react ~135 KB; spa vue ~40 KB, angular ~63 KB, react ~97 KB. render static (prerendered) for anything public that needs search or link previews, spa only for an app behind a login. styling: what their project uses, else css; scss and tailwind work everywhere; CSS-in-JS is react only: vanilla-extract (recommended, zero runtime), styled-components or emotion. Defaults html, static, css. One page per frame (the first is the home page) with semantic tags and one h1, <img> alt and link labels from layer names, self-hosted fonts, Open Graph and robots.txt; `base_url` makes canonical and hreflang absolute and adds sitemap.xml. Prototype links to exported frames and Url links become links. With `modes` (a collection whose modes are languages such as en and pt-BR) each mode is a locale: text that differs becomes a message per locale, styles that differ become :lang() rules. A project (any framework, scss, tailwind) needs a build: follow `next_steps`. The folder must be new, empty or an earlier export_site folder; a re-export removes only what the previous one wrote. Per framework: [export-site.md](export-site.md). Returns {dir, framework, render, styling, pages, files, bytes, written, removed, links: {resolved, unresolved}, urls, next_steps, warnings}.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `path` | string | yes | Folder to write into (created if missing). |
| `frames` | array | | Frame ids, in page order. Default: the page's screens (set_screens), else its top-level frames. |
| `modes` | object | | `{collection, modes: [..]}`: collection name or id and the mode names to export, e.g. ["en", "pt-BR"]. |
| `base_url` | string | | Where the site will be served (https://example.com): absolute hreflang and canonical URLs, and sitemap.xml. |
| `framework` | string | | Output: html (static pages, no build step), or a project for react, vue, angular or astro. Ask the person; default html. |
| `render` | string | | static: pages pre-rendered for search (landing, marketing, docs). spa: client-rendered app (behind a login). Only for react, vue and angular. Ask the person; default static. |
| `styling` | string | | css (default, no build step), scss, tailwind, or CSS-in-JS for react only: vanilla-extract (recommended, zero runtime), styled-components, emotion. Ask the person; use what their project uses. |
| `lang` | string | | Language tag of a single-language site (en, pt-BR): `<html lang>` and og:locale. |
| `title` | string or object | | Site title: the home page title and the suffix of the others. Default: the frame names. With `modes`, an object keyed by mode name (`{"en": "...", "pt-BR": "..."}`) gives each language its own; a language without an entry takes the base language's. |
| `description` | string or object | | Meta description for every page. Default: each page's first line of copy. With `modes`, an object keyed by mode name gives each language its own, as for `title`. |
| `pageId` | string | | Page to export from (default the active page). |
## Write
@@ -274,7 +355,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. 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.
- 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 applies to a whole text layer: the first installed family in the list, Inter by default.
- Boxes: background(-color/-image: colors, linear/radial gradients, url()), border (and border-top/right/bottom/left), border-radius, opacity, overflow:hidden, mix-blend-mode, width/height (px, %, fit-content), min-width/max-width/min-height/max-height.
- <img src> takes paper-asset:///abs/path, file:// URLs, absolute paths or data: URIs. <svg> markup is imported as vector layers.
- <x-paper-icon name="set:name" size="20" color="var(--accent)"/> places an installed icon (search_icons); <x-paper-instance component="Name" component-id="ID" layer-name="..."/> places a component instance; colors may be var(--name) of set_variables.
@@ -287,6 +368,15 @@ Returns the ids of the created top-level layers and the styles that were ignored
| `mode` | `insert-children` \| `replace` | yes | |
| `targetNodeId` | string | yes | Target node id, or "root" for the page. |
### `import_html`
Open an HTML page exported by a prompt-to-design tool as layers: a .html file or a .zip with its stylesheets and images. `<style>` blocks and linked stylesheets are inlined (tag, .class and #id selectors; media queries and combinators are reported), images in the zip become assets, and each HTML file is written with write_html into its own new top-level frame on the page (side by side, named after the file). Returns the frames per file, `ignoredStyles` grouped by property and `warnings`.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `path` | string | yes | A local .html or .zip file. |
| `pageId` | string | no | The page for the frames. Default: the active page. |
### `update_styles`
@@ -377,6 +467,17 @@ Variables of the page's project. Default JSON: `variables` (name -> hex), `token
| `format` | `css` \| `json` \| `tailwind` | | |
### `get_strings` (read-only)
The page text for translation, with no styles or geometry. Returns `strings` (text layers bound to a string variable, by variable name, with the value per mode name), `unbound` (layer id -> text of text layers bound to nothing; set_strings {extract} binds them), `missing` (per mode name, the keys with no value of their own, which show the base text) and `collections`. `missing` set to a mode name returns only {mode, base, missing: {key: base text}}, so a translation is done in steps. Scope: `frame` (a layer id; default every layer of the page), `pageId`; `collection` keeps one collection.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `collection` | string | | Collection name or id to keep. |
| `frame` | string | | Layer id to read inside. Omitted = the whole page. |
| `missing` | string | | A mode name: only the keys that mode lacks, with their base text. |
### `set_variables`
Define or change variables (names like `surface`, `accent-text`, or grouped `color/brand/primary`), on every page. Plain {"name": "#hex"} defines a color. An entry may be an object {value, type, collection, mode, alias, description, hidden}: `type` color|number|string|boolean (numbers bind as px), `collection` a name or id (created if missing), `alias` the name of another variable of the same type. Use them as `var(--name)` in write_html / update_styles / create_artboard colors (backgroundColor, background, color) instead of hex literals, so a theme change is one call. Fills already following a changed variable are updated. Variables apply to fills and text color; strokes and gradients keep fixed colors.
@@ -420,7 +521,7 @@ Define non-color design tokens (spacing, radius, font size, shadows, widths), on
### `bind_variable`
Bind a field of layers to a variable (or detach it). `field`: stroke, text-color, width, height, gap, padding, padding-top/right/bottom/left, radius, font-size, line-height, opacity, visible. `variable` is a name like color/brand/primary or its css name; null or omitted detaches (the current value stays). The value applies at once; on an instance the field becomes an override.
Bind a field of layers to a variable (or detach it). `field`: stroke, text-color, width, height, gap, padding, padding-top/right/bottom/left, radius, font-size, line-height, opacity, visible, text (a text layer's content, from a string variable; typing into the layer detaches it). `variable` is a name like color/brand/primary or its css name; null or omitted detaches (the current value stays). The value applies at once; on an instance the field becomes an override.
| Argument | Type | Required | Notes |
|---|---|---|---|
@@ -429,6 +530,20 @@ Bind a field of layers to a variable (or detach it). `field`: stroke, text-color
| `variable` | string \| null | | |
### `set_strings`
Translate a page in one undoable call; languages are modes of a string collection, never copies. Does not switch the active mode (set_variable_mode does). One of: (a) `extract` true with `frame` (default the page) and `collection` (default `Strings`, created with a base mode named `mode`, default `Default`): every unbound text layer gets a string variable named from its layer path (`hero/title`, repeats numbered), holding its text in the base mode, and is bound to it; returns the keys with their texts. (b) `collection`, `mode` and `strings` {key: text}: sets those values, creating the mode if missing; an unknown key, a non-string variable or an unknown collection is an error that lists the offenders and changes nothing. (c) `collection`, `mode` and `pseudo` true: fills the mode with the base text accented, about 35% longer and in brackets, to show overflow before a real translation. Bound text re-fits when the mode is switched, so run lint_design and get_screenshot per mode.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `collection` | string | | Collection name or id. |
| `extract` | boolean | | |
| `frame` | string | | extract: layer id to read inside. Omitted = the whole page. |
| `mode` | string | | Mode name (the language); extract: the base mode's name when the collection is created. |
| `pseudo` | boolean | | |
| `strings` | object | | {key: text}, keys as returned by get_strings. |
### `set_variable_mode`
Switch the active variable mode (defined with set_variables {mode}): every variable takes that mode's value and all fills following them change, on every page. One call turns a whole design from light to dark.
@@ -458,7 +573,11 @@ Write the page's variables, modes and tokens as an OpenDesign design-system pack
### `get_components` (read-only)
Main components of every page (or one with `pageId`): id, name, `componentKey` (stable), `pageId`, `pageName`, size, and `uses` (instances across all pages).
Main components of every page (or one with `pageId`): id, name, `componentKey` (stable), `pageId`, `pageName`, size, and `uses` (instances across all pages). Reports `estimatedTokens`; over `maxTokens` the list is cut (`truncated`).
| Argument | Type | Required | Notes |
|---|---|---|---|
| `maxTokens` | number | | Token budget of the result (default 20000). Over it the result is cut at a whole node or line, `truncated` is true and the result says how to read the rest. |
### `import_components`
@@ -516,6 +635,14 @@ Detach one or more component instances into plain frames, breaking their link to
| `nodeIds` | array of string | yes | Instance root nodes to detach. |
### `set_prototype_links`
Sets what clicking a layer does in Present mode. Per link give at most one of `to`, `overlay`, `url` or `action`; none clears the layer's link. Every link is checked before any is applied. Links never cross pages.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `links` | array | yes | Objects `{nodeId, to?, overlay?, url?, action?}`. `to`: a frame on the same page to go to (null removes). `overlay`: a frame opened centred over the current one with a dimmed backdrop (taller than the viewport scrolls; backdrop click or Escape closes). `url`: https, http, mailto or tel only. `action`: `back` (previous frame) or `close` (close the overlay). |
### `search_icons` (read-only)
Search installed open icon sets (Lucide, Tabler, Phosphor, Heroicons, Material). Returns `set:name` references for <x-paper-icon name="lucide:check" size="20" color="var(--text)"/> in write_html. Without a query it lists installed and installable sets; install with `papercraft-cli icons add lucide`.
+76
View File
@@ -0,0 +1,76 @@
# Design handoff and design updates
## Problem
Handing a PaperCraft design to code is a wrapper script today (`shipcraft/bin/pc-freeze.py`): one CLI call per screen (186 for Rota Fresca), the Handoff table parsed out of tree text, every file rewritten on each run, no picture of the design, and `verify_code` only against the running app. Worse, once code exists, a design edit has no handoff at all: nobody can tell which screens changed since the code was built, what kind of change it was (copy, a token, a component, the layout, a business rule), or what the code side has to do about it. Rota Fresca's `design-changes.md` shows the cost: nine design changes tracked by hand, each followed by "re-exported" of whole pages, and a stroke-colour question that took a manual check to settle. The same gap applies to any team that is not using shipcraft: there is no "what changed since you last built this" in PaperCraft.
## Goal
From any point the code was built from, PaperCraft says which screens and components changed, how, and what to do about each, in files a person or an agent can act on without opening the app.
## Non-Goals
- Generating or editing application code; PaperCraft describes, the code side implements.
- Git, issue-tracker or chat integrations; outputs are files and JSON others can feed into them.
- Merging two design branches; shared-project merge already exists.
- Replacing the presentation deck; it stays the owner-facing view.
- Pixel-diffing the running app against the design (shipcraft's `measure:route` keeps that job).
## User Stories
- As a developer, I want one command that writes the whole handoff bundle for a project (inventory, tokens, components, open questions, specs, reference images), so that I build from frozen files and never from the live app.
- As a designer, I want to mark a set of screens as handed off ("Ready for dev") at a named point, so that later edits are measured against what developers actually received.
- As a developer, I want a list of what changed since a handoff point, sorted by screen and by kind of change, each with the action it calls for, so that I update only what moved.
- As an agent building one screen, I want only the changed part of a screen's spec (the subtree and its path) plus before/after images, so that an update costs a few thousand tokens and not a whole-page rebuild.
- As a developer, I want to record which screens are implemented and from which handoff point, so that each screen's next diff starts from what was built for it, not from one global date.
- As a project owner, I want a "what changed" deck (before and after per changed screen, with the reason), so that I can approve an update without reading specs.
- As a team not using shipcraft, I want the bundle to be framework-neutral (DTCG tokens, CSS variables, Tailwind theme, SVG icons, named image assets, per-screen HTML reference), so that any stack can consume it.
## Acceptance Criteria
### Bundle
- [ ] Given a project, when the handoff export runs into a folder, then the folder holds the inventory, tokens (DTCG, CSS, Tailwind), components, open questions (including the Handoff table as structured rows), one compact spec per screen, one reference image per screen per mode, icons as SVG and images as named files, and a manifest listing every file with a content hash.
- [ ] Given a screen whose content scrolls, when it is exported, then its spec marks the scrolling frame and its full content height, and its reference image shows the whole content (as the deck does).
- [ ] Given an unchanged project, when the export runs twice, then the second run changes no file (byte-identical, same manifest).
- [ ] Given a renamed or deleted screen, when the export runs again, then the old files are removed and no stale file remains.
- [ ] Given the app is closed, when the export runs through the offline CLI on the same project, then the output is the same as with the app open.
### Handoff points
- [ ] Given selected screens or pages, when the designer marks them handed off under a name, then PaperCraft stores a baseline of those screens and the tokens and components they use, and the canvas shows each screen's status (draft, ready, changed since handoff, implemented).
- [ ] Given a screen marked ready, when anyone edits it, then its status becomes "changed since handoff" without any extra step.
- [ ] Given the code side reports a screen implemented from a named handoff point (and optionally a commit), when the next change report runs, then that screen is compared against that point.
### Change report
- [ ] Given a handoff point and later edits, when the change report runs, then it lists added, removed and renamed screens, and for each changed screen the changed nodes matched by id (not by position), each with the old and new value.
- [ ] Given a change, then it is classified as one of: copy, token value, token binding, style, layout, structure (nodes added, removed or moved), component main, instance swap, asset, annotation (behaviour or business rule), or screen added, removed or renamed.
- [ ] Given each class, then the report states the action it calls for. For example: copy → update the string key; token value → regenerate the theme, no screen work; component main → rebuild that component, and list the screens that use it with no screen work; layout or structure → rebuild that subtree; annotation → review the rule in the domain model; screen removed → remove the route.
- [ ] Given a token value or a component main changed, then the report lists every affected screen once, under that token or component, and does not repeat the change on each screen.
- [ ] Given a changed subtree, then the report includes that subtree's compact spec and its path from the screen root, and before and after images of the screen with the changed area outlined.
- [ ] Given a change report, then it is written as Markdown for people and JSON for scripts and agents, from the same data.
- [ ] Given a move or resize under 1 px, or a re-save with no content change, then the report lists nothing.
### Token cost
- [ ] Given a change report, then its first section is an index of one line per affected screen or component (name, change classes, action, file to read), and a reader reads detail files only for the items it acts on.
- [ ] Given a token or component change, then it appears once, as a single line naming the token or component, and is not repeated on each screen that uses it.
- [ ] Given a changed subtree, then its spec fragment holds only the changed nodes and the path from the screen root (names only, no styles). Unchanged siblings appear as one line each, or are left out.
- [ ] Given a change, then each value is written as `field: old → new`, using token names rather than resolved values. Values that did not change are not printed.
- [ ] Given a change that needs no screen work (token value, asset swap, annotation), then the report says so and points at no screen spec.
- [ ] Given the bundle, then every compact spec states its size in tokens in the manifest, so a brief can budget before it reads.
- [ ] Given before and after images, then they are cropped to the changed area plus a margin and kept at scale 1, so they are only fetched when needed.
- [ ] Given a one-node copy change on one screen, then the whole report for it (index line plus detail) is under 300 tokens.
### Verification and review
- [ ] Given a bundle on disk and source files, when verification runs against the bundle, then it gives the same verdict as `verify_code` against the live app, without the app running.
- [ ] Given a change report, when the "what changed" deck is exported, then it holds one slide per changed screen with before and after, the change classes, and the designer's note for the change, in the project's language.
## Constraints
- Stack: Rust (PaperCraft core, MCP tools, CLI); outputs are plain files (JSON, Markdown, PNG, SVG, CSS).
- Performance: a full export of 200 screens finishes in under a minute on the user's machine. A change report for one edited screen costs the reader under 2k tokens; the index for a 50-change update is under 3k.
- Token economy is a requirement, not polish: every output is read by agents billed per token. Fewer, smaller files read on demand beat one complete file.
- Data safety: baselines are stored in the project and migrate with it; exports write atomically; nothing is written outside the chosen folder.
- Compat: works offline (CLI) and online (app); the bundle format is versioned so a reader can refuse an unknown version.
- Tool count: each new MCP tool needs a test in `tests/mcp.rs`, and the README and ROADMAP counts updated.
## Open Questions
- Should a handoff point cover the whole project or only the screens selected? Per-screen baselines are more precise but heavier to store.
- How should "implemented" be reported back: an MCP tool the code side calls, a file in the bundle the export reads (for example `implemented.json` committed in the app repo), or both?
- Which counts as the action for a copy change in a team without a string table: edit the text in place, or always the string key?
- Should the canvas status badges ("changed since handoff") show to every viewer, or only in a "dev" view?
- Is a static handoff viewer (one HTML file in the bundle that browses screens, specs, values and changes) wanted, or are the Markdown and images enough?
- What retention applies to old baselines (keep all, keep the last N, keep the named ones)?
+17
View File
@@ -0,0 +1,17 @@
# Design handoff: tasks
Defaults taken for the spec's open questions: a handoff point covers the whole project; `since` is passed per call; old points are kept until marked again under the same name.
## Done
- [x] Compact spec marks scrolling frames: `scroll+N` (N px past the bottom).
- [x] Handoff points: `mark_handoff`, `list_handoffs` (`src/handoffs.rs`, project folder `handoffs/`).
- [x] Change report: `get_handoff_changes` (`src/mcp/handoff/changes.rs`): tokens once, mains once with the screens using them, screens by kind with the action; derived sizes, auto-layout positions and instance fields that follow their main left out; 8 lines per screen, `nodeId` for the rest.
- [x] Bundle: `export_handoff` (`src/mcp/handoff/bundle.rs`): index, inventory, DTCG/CSS/Tailwind tokens, components, open questions (with the Handoff page table), compact spec and unrolled reference PNG per screen, changes; manifest with hash and tokens; unchanged files not rewritten, stale ones removed.
## Next
- [ ] Per-screen "implemented from" record, so each screen diffs from what was built for it.
- [ ] Canvas status badges: draft, ready, changed since handoff, implemented.
- [ ] Before/after images of each changed screen, cropped to the change.
- [ ] "What changed" deck from a change report.
- [ ] `verify_code` against a bundle on disk (no app).
- [ ] shipcraft `bin/pc-freeze.py` → one `export_handoff` call.
+62
View File
@@ -0,0 +1,62 @@
# Plan: free agents
## Facts checked (2026-10-29)
* `pi-acp` 0.0.34 runs `pi --mode rpc` (or `PI_ACP_PI_COMMAND`). It honours `PI_CODING_AGENT_DIR`
and offers models through ACP `configOptions` / `availableModels` with `setSessionConfigOption`
and `setSessionModel`, so the existing ACP model code works for Pi unchanged.
* `pi-mcp-adapter` 2.11 reads `$PI_CODING_AGENT_DIR/mcp.json` (`mcpServers`, `settings`).
`settings.requestTimeoutMs` sets the call timeout. A server with `"url"` uses streamable HTTP.
`"directTools": true` registers its tools one by one instead of behind the `mcp` proxy tool.
It also reads `~/.config/mcp/mcp.json` and the project's `.mcp.json` / `.pi/mcp.json`.
* `pi --list-models` prints `provider model context max-out thinking images` for providers that
have a key. It has no price column.
* OpenCode's free models are `opencode/*-free` and `opencode/big-pickle`, and cost 0 in
`opencode models --verbose`.
## Design
* **Free models** (`src/chat/models.rs`):
* `AgentModel::is_free()`: cost 0, or an id ending in `:free` (OpenRouter).
* Pi's free-tier providers (Groq, Cerebras, NVIDIA NIM, Gemini) count as free when their key is
set. `ponytail:` a provider-level guess, because Pi lists no prices.
* `parse_pi` / `fetch_pi` read `pi --list-models`.
* `free_order(catalog, skip)` ranks by context size, then image input.
* **Rotation** (`src/chat/acp.rs`):
* `TurnConfig.fallbacks: Vec<String>` holds the next models to try, filled by the UI when the
chat's model is `FREE_AUTO`.
* A `session/prompt` error that `is_retryable` before any tool call switches the model
(`pick_model`) and resends, at most `MAX_SWITCHES`.
* `Event::ModelSwitch { from, to, reason }` becomes one line in the thread, and the UI marks
`from` as failed for 15 min.
* **Lean Pi** (`src/chat/agents.rs`):
* `pure_env("pi", home, ..)` sets `PI_CODING_AGENT_DIR=<home>/agent`.
* `write_pi_home(home, mcp_url, model)` writes `settings.json` (`packages:
["npm:pi-mcp-adapter"]`, default provider/model) and `mcp.json` atomically.
* Keys are added to the env from the keyring.
* The UI calls it right before starting the turn, because the URL carries the chat id.
* **Keys** (`src/chat/keys.rs`):
* `PROVIDERS` lists, per provider: id, name, env var and key page URL.
* `get` / `set` / `forget` go through `keyring` (service "PaperCraft", user `pi:<id>`).
* `env()` returns the env pairs for the providers that have a key.
* **Install** (`src/chat/install.rs`):
* `Method { os, label, program, args, needs, kind: Package | Script, elevated }` holds the
methods per agent, as data.
* `Host { os, distro, has(program), node_major }` is detected once.
* `methods(agent, host)` returns the methods that apply, in preference order.
* `run(method) -> Receiver<Line>` streams output and runs nothing elevated.
* **UI**:
* `chat_models.rs`: a "Free (auto)" row and a "Free only" toggle, on for opencode and pi.
* `chat_agents.rs`: Install on missing OpenCode/Pi rows. The card holds the method menu, the
command preview, Confirm, streamed output, the copy command and the docs link.
* `settings.rs`: a Pi provider key list.
* `chat.rs`: filling in the fallbacks, tracking failed models, and the switch line.
## Risks
* Retryable error text is a guess. `is_retryable` matches `429`, `rate limit`, `quota`,
`overloaded`, `unavailable`, `not found`, `insufficient`, and is tested on fixtures. An error
it doesn't recognise ends the turn, as today.
* `pi --list-models` output format may change. The parser skips lines it can't read.
* `chat.rs` and `chat_agents.rs` are already over 800 lines. New UI goes in new files
(`chat_install.rs`, `chat_keys.rs`).
+112
View File
@@ -0,0 +1,112 @@
# Free agents: OpenCode and Pi set up for free models, with an install wizard
## Why (user request, 2026-10-29)
* The user wants to run PaperCraft chats on alternative agents (OpenCode, Pi; later Codex and
Antigravity) without paying for a model.
* OpenCode ships free models (`opencode/*-free`, `opencode/big-pickle`) that need no key, but they
are "free for a limited time" and change; a chat pinned to one that disappears or is rate
limited fails.
* Pi is detected but useless in a chat: `pi-acp` accepts the MCP servers PaperCraft sends and
never passes them to Pi, so Pi has no PaperCraft tools. Pi also loads the user's whole setup
(14 packages, ~190 skills), like OpenCode did before lean mode.
* Pi supports OpenRouter (`:free` models), Groq, Gemini, NVIDIA NIM and Cerebras through
`*_API_KEY` env vars; the user has no key flow for them.
* A missing agent shows "Not found" and a docs link only. The user now wants an install wizard.
This reverses "No install instructions" in `specs/chat-agents/spec.md` for OpenCode and Pi.
## Decisions (asked 2026-10-29)
* Pi reaches PaperCraft through `pi-mcp-adapter` installed in an isolated Pi dir.
* Provider keys live in the OS secret store through the `keyring` crate, as `src/chat/endpoint.rs`
does: Keychain on macOS, Credential Manager on Windows, Secret Service on Linux. They reach Pi
only as env vars of its process.
* Pi providers: OpenRouter plus Groq, Gemini, NVIDIA NIM and Cerebras, each used when its key is
set.
* The wizard installs on a click after a confirmation, and falls back to a copyable command and
the docs link.
## Scope
1. **Free model set.** A model is free when the catalog gives it input and output cost 0
(`opencode models --verbose`; Pi: `pi --list-models` plus OpenRouter's `:free` suffix). The
model picker gets a "Free only" filter, on by default for OpenCode and Pi, and a "Free (auto)"
row that means "rotate through free models".
2. **Rotation.** With "Free (auto)", a turn starts on the first free model in a ranked order
(context size, then image input, then the last one that worked). When the turn fails with a
rate-limit, quota, overload or unknown-model error before any tool ran, PaperCraft switches the
session to the next free model (`session/set_config_option`, else `session/set_model`) and
resends the prompt once per model, at most 3 models. A model that failed is skipped for 15 min.
The thread shows one line per switch ("Switched to GLM-5.3 Flash Free: Big Pickle is rate
limited"). Errors after a tool ran are not retried (the design may have changed).
3. **Lean Pi.** `PI_CODING_AGENT_DIR=<config_dir>/agent-home/pi/agent` with a generated
`settings.json` (packages: `npm:pi-mcp-adapter` only, the chosen default provider and model)
and the adapter's MCP config pointing at PaperCraft's URL with `tools=core`, MCP timeout above
`CALL_TIMEOUT` like OpenCode's. `pi-acp` is launched with that env. Non-lean keeps the user's
own `~/.pi`.
4. **Provider keys.** Settings › Agents › Pi lists OpenRouter, Groq, Gemini, NVIDIA NIM and
Cerebras: a key field (masked, "Get a free key" link to the provider's key page), saved to the
OS secret store, removed with "Forget". Keys are passed as `OPENROUTER_API_KEY` etc. and never
written to disk or logs. The free model set for Pi is the union over providers with a key.
5. **Install wizard, per OS.** When OpenCode or Pi is not found, its row and the setup card show
"Install". The wizard detects the OS (and on Linux the distribution, from `/etc/os-release`)
and which package managers are on the resolved PATH, then offers the methods that apply, the
first one preselected and the others in a menu. The methods come from each agent's install docs
(checked 2026-10-29), as data in the agent registry:
| Agent | macOS | Linux | Windows |
|---|---|---|---|
| OpenCode | `brew install anomalyco/tap/opencode`, `npm install -g opencode-ai`, `curl -fsSL https://opencode.ai/install \| bash` | Homebrew tap, `npm install -g opencode-ai`, Arch `paru -S opencode-bin`, install script | `scoop install opencode`, `choco install opencode`, `npm install -g opencode-ai` |
| Pi | `npm install -g --ignore-scripts @earendil-works/pi-coding-agent`, `brew install pi-coding-agent`, `curl -fsSL https://pi.dev/install.sh \| sh` | npm, install script, `nix profile add github:earendil-works/pi/stable` | npm, `powershell -c "irm https://pi.dev/install.ps1 \| iex"` |
* OpenCode's docs say plain `brew install opencode` and Arch's `pacman -S opencode` install
OpenCode 2. PaperCraft's ACP and model code is tested on 1.x, so the wizard offers the 1.x
packages until 2 is tested. A detected 2.x is still used, with a warning.
* The npm methods need Node (Pi: 22.19 or newer). When `node` is missing or too old, npm
methods are hidden and the card says so, with a link to nodejs.org. Pi's install scripts
install Node themselves.
* Script methods (`curl … | sh`, `irm … | iex`) run remote code. They are listed last and the
confirmation shows the full command and URL.
* On Windows, the commands run through PowerShell. `npx` resolves to `npx.cmd`. The agent home
goes under `%APPDATA%` through `config_dir()`.
* The click shows exactly what will run, and runs it only after "Install" is confirmed. Output
streams into the card, then the agent is detected again.
* Nothing runs elevated. When a method needs `sudo` or an admin shell, the card shows the
command to copy instead of running it.
* On failure, the card keeps the output, the copyable command and the docs link.
* After install, Pi asks for at least one provider key. OpenCode needs nothing.
* `pi-acp` and `pi-mcp-adapter` are fetched on first use.
6. **Usage and price.** Free models show "Free" in the chip; context use comes from
`usage_update` (Pi sends it each turn) or the catalog's context size.
## Not in scope
* Codex and Antigravity (separate spec; Codex lean mode, Antigravity after checking its CLI).
* Paid-model rotation, provider sign-up flows, `pi-freerouter`.
* Installing Node or Homebrew themselves (the wizard says what is missing and links to them).
## Risks
* The free lists and their limits change without notice; rotation reads the live catalog at the
start of each chat, never a hard-coded list.
* OpenRouter `:free` allows 50 requests a day without credits, and one design turn makes many.
The picker says so next to OpenRouter.
* `pi-mcp-adapter` and `pi-acp` are third-party npm code fetched on first use; enabling Pi
already requires a confirmation, and the wizard repeats it.
* The error text OpenCode and Pi send for rate limits is not documented; classification is
tested against recorded fixtures and falls back to "not retried".
## Acceptance
* A new OpenCode chat with "Free (auto)" builds an artboard on a free model; with the first model
forced to fail (fixture), the turn switches once and completes, and the thread shows the switch.
* A lean Pi chat with an OpenRouter key lists only free models, calls PaperCraft tools through
`pi-mcp-adapter`, and loads none of the user's `~/.pi` packages or skills.
* No key appears in any file under the config dir, the agent home or the logs.
* With OpenCode missing from PATH (test PATH), the card offers Install. The install command is
shown before it runs. A failure keeps the command and docs link.
* The offered methods match a fake OS, distribution and PATH: macOS with brew, macOS without
Node, Ubuntu, Arch, and Windows with scoop.
* Keys round-trip through `keyring` on macOS here. Windows is covered by the CI run.
* Tests: free filter and ranking, error classification (recorded fixtures), rotation with a fake
ACP agent, Pi lean env and generated settings, key env passing (fake keyring), wizard state, method selection per OS.
+15
View File
@@ -0,0 +1,15 @@
# Tasks: free agents
1. [x] Free models: `is_free`, `parse_pi`/`fetch_pi`, `free_order`. Unit tests in `models.rs`.
2. [x] Rotation: `fallbacks`, `is_retryable`, `Event::ModelSwitch`, retry loop. Fake-agent fixture
test (first model rate limited, second completes; error after a tool is not retried).
3. [x] Provider keys: `chat/keys.rs` (keyring, env). Test env building with an injected getter.
4. [x] Lean Pi: `pure_env("pi")`, `write_pi_home`, keys in env, Pi catalog fetch. Tests for env and
generated files.
5. [x] UI models: "Free (auto)" row, "Free only" toggle, fallbacks filled, failed cache, switch line.
UI test: clicking Free (auto) sets `FREE_AUTO`.
6. [x] Install data and runner: `chat/install.rs` methods per OS, `Host` detection. Tests per fake
host (macOS brew, macOS no Node, Ubuntu, Arch, Windows scoop).
7. [x] Install UI card + Pi key settings UI. UI tests: Install shows command before running; failure
keeps command and docs link.
8. [x] README/ROADMAP notes, clippy, targeted tests.
+124 -11
View File
@@ -57,6 +57,24 @@ fn release_freed_memory() {
/// Shortest gap between frames drawn for an agent's edits alone (20 FPS).
const AGENT_FRAME: Duration = Duration::from_millis(50);
/// How long agent calls wait for a drag to end before running anyway, so a stuck gesture
/// cannot block them for good.
const GESTURE_HOLD: Duration = Duration::from_secs(10);
/// Whether agent calls should keep waiting: a gesture is in progress and has not held them
/// longer than `GESTURE_HOLD`. `since` is when the wait began; it is cleared once they may run.
fn hold_for_gesture(busy: bool, since: &mut Option<Instant>, now: Instant) -> bool {
if !busy {
*since = None;
return false;
}
let start = *since.get_or_insert(now);
if now.duration_since(start) >= GESTURE_HOLD {
*since = None;
return false;
}
true
}
pub struct App {
gfx: Option<Gfx>,
@@ -89,6 +107,9 @@ pub struct App {
)>,
/// Groups the agent's editing calls into reviewable runs.
runs: crate::runs::Tracker,
/// Tool calls waiting for the person's drag to end, and since when.
held: Vec<crate::mcp::Call>,
held_since: Option<Instant>,
/// 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>>,
@@ -143,10 +164,13 @@ impl App {
menu: None,
mcp: None,
runs: Default::default(),
held: Vec::new(),
held_since: None,
swarm: Default::default(),
};
app.st.library.library = library;
app.st.perf.prefs = crate::ui::perf::PerfPrefs::load();
crate::ui::canvas_prefs::CanvasPrefs::load().apply(&mut app.ed, &mut app.st);
app.st.menu_bar_hidden = crate::ui::menu_toggle::load_hidden();
app.ed.clipboard_io = crate::io::clipboard::System::new()
.map(|c| Box::new(c) as Box<dyn crate::io::clipboard::Provider>);
@@ -300,6 +324,10 @@ impl App {
self.st.save_state.clear();
}
fn release_unused_images(&self, dir: &std::path::Path) {
crate::assets::release_unused(&dir.join(crate::project::ASSETS_DIR), self.ed.images_held_outside_pages());
}
fn open_project(&mut self, dir: &std::path::Path) {
if self.session.project.is_some() {
self.save_project(true);
@@ -309,6 +337,7 @@ impl App {
match self.session.open(&mut self.ed, dir) {
Ok(()) => {
self.runs.end_counted(crate::project::format::now_ms(), counts, None);
self.release_unused_images(dir);
self.st.home = None;
self.st.project_name = self.session.project_name();
self.st.project_dir = self.session.project.as_ref().map(|p| p.dir.clone());
@@ -745,6 +774,8 @@ impl App {
}
if self.confirm_discard() {
event_loop.exit();
} else {
self.st.updater.restart = None;
}
}
@@ -1123,6 +1154,7 @@ impl ApplicationHandler<UserEvent> for App {
);
window.request_redraw();
self.st.perf.gpu = format!("{} ({:?})", adapter_info.name, adapter_info.backend);
self.st.perf.display_hz = display_hz(&window);
self.gfx = Some(Gfx {
window,
surface,
@@ -1161,6 +1193,11 @@ impl ApplicationHandler<UserEvent> for App {
}
redraw = true;
}
WindowEvent::Moved(_) => {
if let Some(g) = &self.gfx {
self.st.perf.display_hz = display_hz(&g.window);
}
}
WindowEvent::ScaleFactorChanged { .. } => {
// Moved between Retina and non-Retina displays. egui-winit has already picked
// up the new pixels-per-point; resize the surface to the new physical size
@@ -1277,10 +1314,15 @@ impl ApplicationHandler<UserEvent> for App {
} else {
d
};
self.ed.handle(InputEvent::Scroll {
delta: -d,
pos: self.to_canvas(p),
});
let pos = self.to_canvas(p);
// Only a notched wheel: a trackpad's pixel scrolling stays a pan.
let notched = matches!(delta, MouseScrollDelta::LineDelta(..));
if self.st.wheel_zooms && notched && !self.ed.mods.shift {
let factor = (d.y * crate::editor::ZOOM_PER_SCROLL_PX).exp();
self.ed.handle(InputEvent::Zoom { factor, pos });
} else {
self.ed.handle(InputEvent::Scroll { delta: -d, pos });
}
redraw = true;
}
}
@@ -1447,7 +1489,7 @@ impl ApplicationHandler<UserEvent> for App {
}
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
if std::mem::take(&mut self.close_requested) {
if std::mem::take(&mut self.close_requested) || self.st.updater.restart.is_some() {
self.try_exit(event_loop);
}
// Autosave runs off a timer so it also fires while the window sits idle.
@@ -1462,6 +1504,9 @@ impl ApplicationHandler<UserEvent> for App {
}
}
self.tick_sync();
if !self.held.is_empty() {
self.run_mcp_calls();
}
let wake = [
self.next_repaint,
self.session.next_deadline(),
@@ -1573,13 +1618,25 @@ impl App {
/// Run the MCP tool calls that arrived since the last wake-up, then repaint.
fn run_mcp_calls(&mut self) {
let calls: Vec<crate::mcp::Call> = match &self.mcp {
Some((rx, _, _)) => rx.try_iter().collect(),
None => return,
let Some((rx, _, _)) = &self.mcp else {
// No server to answer for: fail held calls rather than leave their callers waiting.
for c in std::mem::take(&mut self.held) {
let _ = c.reply.send(Err("PaperCraft's MCP server is not running".into()));
}
return;
};
let mut calls = std::mem::take(&mut self.held);
calls.extend(rx.try_iter());
if calls.is_empty() {
return;
}
// A call landing mid-drag would commit the half-made drag as its own undo step and
// the rest of the drag would overwrite it; wait for the gesture to end instead.
if hold_for_gesture(self.ed.is_busy(), &mut self.held_since, Instant::now()) {
self.held = calls;
self.next_repaint = Some(Instant::now() + AGENT_FRAME);
return;
}
for c in calls {
// Network and Codex work runs off the editor thread; it only reads the project folder.
// The helper calls back into this app through papercraft-cli, so it must not hold the UI thread.
@@ -1620,7 +1677,13 @@ impl App {
}
let mut runs = std::mem::take(&mut self.runs);
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 called = catch_call(&c.name, || swarm.lock().unwrap_or_else(|p| p.into_inner()).call(&mut runs, self, &c.client, &c.name, &c.args, crate::project::format::now_ms()));
let r = called.unwrap_or_else(|e| {
// The panic may have left an edit half done; drop it before the next call commits it.
self.ed.abort();
swarm.clear_poison();
Err(e)
});
let touched = runs.take_touched();
if r.is_ok() {
let who = runs.label(&c.client);
@@ -1676,6 +1739,7 @@ impl crate::mcp::exec::Host for App {
self.session
.open(&mut self.ed, dir)
.map_err(|e| format!("could not open {}: {e}", dir.display()))?;
self.release_unused_images(dir);
self.st.home = None;
self.st.project_name = self.session.project_name();
self.st.project_dir = self.session.project.as_ref().map(|p| p.dir.clone());
@@ -1768,14 +1832,63 @@ pub fn run(start: Start) -> Result<(), String> {
let mut app = App::new(start, store);
app.mcp = start_mcp(event_loop.create_proxy());
app.st.agent_endpoint = app.mcp.as_ref().map(|(_, r, _)| r.url()).unwrap_or_default();
event_loop.run_app(&mut app).map_err(|e| e.to_string())
if let Some(dir) = std::env::current_exe().ok().as_deref().and_then(std::path::Path::parent) {
crate::update::remove_replaced(dir);
}
if !cfg!(debug_assertions) && ui::update::auto_enabled() {
app.st.updater.check_quietly();
}
event_loop.run_app(&mut app).map_err(|e| e.to_string())?;
match app.st.updater.restart.take() {
Some(ui::update::Restart::Relaunch) => crate::update::relaunch(None),
Some(ui::update::Restart::Install(msi)) => crate::update::relaunch(Some(&msi)),
None => Ok(()),
}
}
/// The window's monitor's refresh rate, rounded to whole Hz.
fn display_hz(window: &Window) -> Option<u32> {
window.current_monitor()?.refresh_rate_millihertz().map(|mhz| (mhz + 500) / 1000)
}
/// Runs one tool call so that a panic in it becomes an error for that call instead of ending
/// the app (calls run on the UI thread). Release builds unwind; the web build aborts.
/// Err: the panic, described.
fn catch_call(tool: &str, call: impl FnOnce() -> crate::mcp::ToolResult) -> Result<crate::mcp::ToolResult, String> {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(call)).map_err(|p| {
let why = p.downcast_ref::<&str>().copied().or_else(|| p.downcast_ref::<String>().map(String::as_str)).unwrap_or("unknown cause");
format!("{tool} crashed ({why}); PaperCraft kept the document consistent, but part of the call may have been applied: check the state before retrying. This is a PaperCraft bug.")
})
}
#[cfg(test)]
mod tests {
use super::prefer_x11;
use super::{catch_call, hold_for_gesture, prefer_x11, GESTURE_HOLD};
use std::time::Instant;
use std::ffi::OsStr;
#[test]
fn a_panicking_tool_call_becomes_an_error() {
let err = catch_call("write_html", || panic!("boom")).unwrap_err();
assert!(err.contains("write_html") && err.contains("boom") && err.contains("check the state"), "{err}");
assert!(catch_call("x", || Ok(Vec::new())).unwrap().is_ok());
}
#[test]
fn agent_calls_wait_for_a_drag_but_not_forever() {
let t0 = Instant::now();
let mut since = None;
assert!(!hold_for_gesture(false, &mut since, t0));
assert!(hold_for_gesture(true, &mut since, t0));
assert!(hold_for_gesture(true, &mut since, t0 + GESTURE_HOLD / 2));
// A stuck gesture releases them.
assert!(!hold_for_gesture(true, &mut since, t0 + GESTURE_HOLD));
// The gesture ending clears the wait.
assert!(hold_for_gesture(true, &mut since, t0 + GESTURE_HOLD));
assert!(!hold_for_gesture(false, &mut since, t0 + GESTURE_HOLD));
assert!(since.is_none());
}
#[test]
fn wsl_prefers_x11_when_a_display_is_available() {
for kernel in ["6.6.87.2-microsoft-standard-WSL2", "4.4.0-19041-Microsoft"] {
+25 -1
View File
@@ -6,7 +6,7 @@
//! 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.
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::sync::{Arc, Mutex, OnceLock};
@@ -108,6 +108,22 @@ pub fn load_dir(dir: &Path) {
}
}
/// After a project opens, forget the images of the one that was open before: the registry is
/// process-wide and would otherwise only grow. Kept are the images in the new project's `dir`
/// (including pages still loading), and `extra` (the clipboard and library documents, which can
/// hold ids that are not in the folder).
pub fn release_unused(dir: &Path, extra: impl IntoIterator<Item = String>) {
let mut keep: HashSet<String> = extra.into_iter().collect();
if let Ok(rd) = crate::vfs::read_dir(dir) {
keep.extend(rd.flatten().filter_map(|e| Some(e.path().file_stem()?.to_string_lossy().into_owned())));
}
prune(&mut registry().lock().unwrap(), &keep);
}
fn prune(map: &mut HashMap<String, Arc<ImageAsset>>, keep: &HashSet<String>) {
map.retain(|id, _| keep.contains(id));
}
/// Write the given assets into `dir` (skipping ones already there).
pub fn save_to_dir(dir: &Path, ids: impl IntoIterator<Item = String>) -> std::io::Result<()> {
for id in ids {
@@ -205,6 +221,14 @@ mod tests {
assert_eq!(base64(b"foobar"), "Zm9vYmFy");
}
#[test]
fn prune_drops_images_nobody_keeps() {
let asset = |id: &str| Arc::new(ImageAsset { id: id.into(), width: 1, height: 1, bytes: Vec::new(), ext: "png" });
let mut map: HashMap<String, Arc<ImageAsset>> = ["a", "b"].iter().map(|id| (id.to_string(), asset(id))).collect();
prune(&mut map, &HashSet::from(["a".to_string()]));
assert_eq!(map.keys().collect::<Vec<_>>(), ["a"]);
}
#[test]
fn register_dedups_by_content() {
let img = image::RgbaImage::from_pixel(3, 2, image::Rgba([255, 0, 0, 255]));
+359 -31
View File
@@ -3,7 +3,7 @@
//! (or `session/load`) with PaperCraft's MCP server, `session/prompt`, then the agent's
//! `session/update` notifications are mapped to [`Event`]s until the prompt response arrives.
use super::events::{Event, Turn, TurnConfig};
use super::events::{Event, Retry, Turn, TurnConfig};
use super::{tools, Usage};
use base64::{engine::general_purpose::STANDARD, Engine};
use serde_json::{json, Value};
@@ -33,6 +33,8 @@ const SERVER_NAME: &str = "papercraft";
const BRIDGE: &str = "papercraft-mcp";
/// How agents prefix MCP tool names; longest first.
const NAME_PREFIXES: [&str; 4] = ["mcp__papercraft__", "mcp_papercraft_", "papercraft__", "papercraft_"];
/// Where opencode puts a pending retry in a `session_info_update` (null once a call goes through).
const RETRY_META: &str = "opencode/retry";
/// Same keys the other backends drop: the agent must run on its own login.
const CREDENTIAL_ENV: [&str; 4] = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "OPENAI_API_KEY", "CODEX_API_KEY"];
@@ -106,13 +108,14 @@ impl Turn for AcpTurn {
/// Starts `program args…` and runs one prompt. Events arrive on `tx`; the last is `TurnEnd` or
/// `Error`.
pub fn start(cfg: &TurnConfig, program: &Path, args: &[String], prompt: &str, tx: Sender<Event>) -> Result<AcpTurn, String> {
spawn(cfg, program, args, prompt, tx, None)
/// `env` is added to the agent's environment (a lean turn's `agents::pure_env`).
pub fn start(cfg: &TurnConfig, program: &Path, args: &[String], env: &[(String, String)], prompt: &str, tx: Sender<Event>) -> Result<AcpTurn, String> {
spawn(cfg, program, args, env, prompt, tx, None)
}
fn spawn(cfg: &TurnConfig, program: &Path, args: &[String], prompt: &str, tx: Sender<Event>, bridge: Option<PathBuf>) -> Result<AcpTurn, String> {
fn spawn(cfg: &TurnConfig, program: &Path, args: &[String], env: &[(String, String)], prompt: &str, tx: Sender<Event>, bridge: Option<PathBuf>) -> Result<AcpTurn, String> {
let mut cmd = Command::new(program);
cmd.args(args).current_dir(&cfg.project_dir).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
crate::chat::path::no_window(&mut cmd).args(args).envs(env.iter().cloned()).current_dir(&cfg.project_dir).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
for k in CREDENTIAL_ENV {
cmd.env_remove(k);
}
@@ -175,6 +178,9 @@ fn spawn(cfg: &TurnConfig, program: &Path, args: &[String], prompt: &str, tx: Se
cost: None,
plans: 0,
sid: None,
retrying: None,
switch_on_retry: false,
cancelled_for: None,
};
let result = c.run(&cfg, &prompt, bridge, &session);
c.close_open_tools();
@@ -213,6 +219,14 @@ fn spawn(cfg: &TurnConfig, program: &Path, args: &[String], prompt: &str, tx: Se
Ok(turn)
}
/// An error that says the model, not the request, is the problem: rate limited, out of quota,
/// overloaded or gone. ponytail: agents do not document these, so it matches their wording.
fn is_retryable(e: &str) -> bool {
const SIGNS: [&str; 10] = ["429", "rate limit", "rate_limit", "too many requests", "quota", "overloaded", "unavailable", "model not found", "no longer available", "insufficient"];
let e = e.to_lowercase();
SIGNS.iter().any(|s| e.contains(s))
}
fn tail(s: &str) -> &str {
const KEEP: usize = 600;
let mut from = s.len().saturating_sub(KEEP);
@@ -246,6 +260,13 @@ struct Client {
cost: Option<f64>,
plans: u32,
sid: Option<String>,
/// The provider error the agent is waiting out, while it retries.
retrying: Option<String>,
/// A fallback model is left ("Free (auto)"): a retry cancels the prompt to move on to it
/// instead of waiting for the same provider.
switch_on_retry: bool,
/// The prompt was cancelled for this retry; its answer becomes that error.
cancelled_for: Option<String>,
}
impl Client {
@@ -253,6 +274,20 @@ impl Client {
let _ = self.tx.send(e);
}
/// Switches the session to the chat's model when the agent offers it. A refusal keeps the
/// agent's model, which is what the chat then shows.
fn pick_model(&mut self, sid: &str, m: &mut Models, want: Option<&str>) -> Option<Value> {
let want = want.filter(|w| *w != m.current && m.options.iter().any(|(id, _)| id == w))?;
let r = match &m.config_id {
Some(config) => self.call("session/set_config_option", json!({"sessionId": sid, "configId": config, "value": want}), Some(HANDSHAKE_TIMEOUT)),
None => self.call("session/set_model", json!({"sessionId": sid, "modelId": want}), Some(HANDSHAKE_TIMEOUT)),
};
match r {
Ok(r) => { want.clone_into(&mut m.current); Some(r) },
Err(e) => { log::warn!("acp: could not switch the model to {want}: {e}"); None },
}
}
fn run(&mut self, cfg: &TurnConfig, prompt: &str, bridge: Option<PathBuf>, slot: &Mutex<Option<String>>) -> Result<String, String> {
let init = self.call(
"initialize",
@@ -268,7 +303,7 @@ impl Client {
let image = caps["promptCapabilities"]["image"] == true;
let bridge = if http { None } else { bridge.or_else(find_bridge) };
let session_params = |extra: Value| -> Result<Value, String> {
let mut p = json!({"cwd": cfg.project_dir, "mcpServers": mcp_servers(&cfg.mcp_url, http, bridge.as_deref())?});
let mut p = json!({"cwd": cfg.project_dir, "mcpServers": mcp_servers(&session_url(cfg), http, bridge.as_deref())?});
if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) {
p.extend(extra.clone());
}
@@ -281,21 +316,46 @@ impl Client {
let r = self.call("session/load", session_params(json!({"sessionId": id}))?, Some(HANDSHAKE_TIMEOUT));
self.replay = false;
match r {
Ok(_) => loaded = Some(id.clone()),
Ok(r) => loaded = Some((id.clone(), r)),
Err(e) => log::warn!("acp: session/load failed ({e}); starting a new session"),
}
}
let first = loaded.is_none();
let sid = match loaded {
Some(id) => id,
let (sid, initial) = match loaded {
Some((id, r)) => (id, r),
None => {
let r = self.call("session/new", session_params(json!({}))?, Some(HANDSHAKE_TIMEOUT))?;
r["sessionId"].as_str().ok_or("session/new returned no sessionId")?.to_string()
(r["sessionId"].as_str().ok_or("session/new returned no sessionId")?.to_string(), r)
}
};
let mut models = session_models(&initial);
self.sid = Some(sid.clone());
*slot.lock().unwrap() = Some(sid.clone());
self.emit(Event::Session(sid.clone()));
if let Some(m) = models.as_mut() {
let updated = self.pick_model(&sid, m, cfg.model.as_deref());
self.emit(Event::Models { current: m.current.clone(), options: m.options.clone() });
if cfg.effort.is_some() && cfg.model.as_deref().is_some_and(|want| want != m.current) {
return Err("Could not select the model for the requested effort".into());
}
let configs = updated.as_ref().unwrap_or(&initial);
if let Some(option) = effort_option(configs) {
self.emit(Event::ModelEfforts { model: m.current.clone(), options: option.options.clone() });
if let Some(want) = cfg.effort.as_deref() {
if !option.options.iter().any(|(id, _)| id == want) {
return Err(format!("The selected model does not offer effort {want}"));
}
if want != option.current {
self.call("session/set_config_option", json!({"sessionId": sid, "configId": option.id, "value": want}), Some(HANDSHAKE_TIMEOUT))
.map_err(|e| format!("Could not set effort to {want}: {e}"))?;
}
}
} else if cfg.effort.is_some() {
return Err("This agent did not offer an effort setting for the selected model".into());
}
} else if cfg.effort.is_some() {
return Err("This agent did not offer an effort setting for the selected model".into());
}
// ACP has no system prompt: the PaperCraft text leads the first prompt of a session.
let mut blocks = vec![];
@@ -306,7 +366,26 @@ impl Client {
if image {
blocks.extend(cfg.images.iter().map(|i| json!({"type": "image", "mimeType": i.mime, "data": STANDARD.encode(&i.data)})));
}
let r = self.call("session/prompt", json!({"sessionId": sid, "prompt": blocks}), None)?;
let mut fallbacks = cfg.fallbacks.iter();
let r = loop {
self.switch_on_retry = models.as_ref().is_some_and(|m| fallbacks.clone().any(|f| m.options.iter().any(|(id, _)| id == f)));
(self.retrying, self.cancelled_for) = (None, None);
let e = match self.call("session/prompt", json!({"sessionId": sid, "prompt": blocks}), None) {
Ok(r) => break r,
Err(e) => e,
};
// A failure after a tool ran is not retried: the design may already have changed.
let retry = is_retryable(&e) && self.tools.is_empty() && !self.stopped.load(Ordering::SeqCst);
let Some(m) = models.as_mut().filter(|_| retry) else { return Err(e) };
let Some(next) = fallbacks.find(|f| m.options.iter().any(|(id, _)| id == *f)) else { return Err(e) };
let from = m.current.clone();
self.pick_model(&sid, m, Some(next));
if m.current != *next {
return Err(e);
}
self.emit(Event::ModelSwitch { from, to: next.clone(), reason: e });
self.emit(Event::Models { current: m.current.clone(), options: m.options.clone() });
};
self.usage = usage_of(&r["usage"]).map(|mut u| {
u.cost_usd = self.cost;
u
@@ -338,8 +417,17 @@ impl Client {
}
}
match self.rx.recv_timeout(TICK) {
Ok(msg) if msg.get("method").is_some() => self.incoming(&msg),
Ok(msg) if msg.get("method").is_some() => {
self.incoming(&msg);
if method == "session/prompt" {
self.cancel_for_retry();
}
}
Ok(msg) if msg["id"] == json!(id) => {
// Cancelled to move off a rate-limited model: the error is the provider's.
if let Some(why) = self.cancelled_for.take() {
return Err(format!("Rate limited: {why}"));
}
return match msg.get("error") {
Some(e) => Err(rpc_error(e)),
None => Ok(msg["result"].clone()),
@@ -356,6 +444,17 @@ impl Client {
}
}
/// Under "Free (auto)", a retry before any tool ran cancels the prompt: the next free model
/// answers now instead of the agent waiting out this one's rate limit.
fn cancel_for_retry(&mut self) {
if !self.switch_on_retry || self.cancelled_for.is_some() || !self.tools.is_empty() || self.stopped.load(Ordering::SeqCst) {
return;
}
let (Some(why), Some(sid)) = (self.retrying.clone(), self.sid.clone()) else { return };
send(&self.stdin, &json!({"jsonrpc": "2.0", "method": "session/cancel", "params": {"sessionId": sid}}));
self.cancelled_for = Some(why);
}
fn incoming(&mut self, msg: &Value) {
let method = msg["method"].as_str().unwrap_or_default();
match (method, msg.get("id")) {
@@ -386,6 +485,13 @@ impl Client {
}
}
"plan" => self.plan(u),
"session_info_update" => {
if let Some(r) = u["_meta"].get(RETRY_META) {
let retry = retry_of(r);
self.retrying = retry.as_ref().map(|r| r.message.clone());
self.emit(Event::Retrying(retry));
}
}
"usage_update" => {
if let Some(used) = u["used"].as_u64() {
self.emit(Event::Context(used));
@@ -486,6 +592,45 @@ impl Client {
}
}
/// opencode's retry status: `{attempt, nextRetryAt, error: {type, message, status?}}`; None for
/// null (the retry is over).
fn retry_of(r: &Value) -> Option<Retry> {
if !r.is_object() {
return None;
}
let e = &r["error"];
let text = e["message"].as_str().filter(|m| !m.trim().is_empty()).or_else(|| e["type"].as_str()).unwrap_or("The provider failed the request");
let message = match e["status"].as_u64() {
Some(code) if !text.contains(&code.to_string()) => format!("{} (HTTP {code})", text.trim()),
_ => text.trim().to_owned(),
};
Some(Retry {
attempt: r["attempt"].as_u64().map_or(1, |a| a as u32),
message,
next_at_ms: r["nextRetryAt"].as_str().and_then(iso_ms),
})
}
/// Unix ms of an RFC 3339 UTC time (`2026-11-02T17:14:41.528Z`, as JavaScript's `toISOString`).
fn iso_ms(s: &str) -> Option<u64> {
let s = s.strip_suffix('Z')?;
let (date, time) = s.split_once('T')?;
let mut d = date.splitn(3, '-').map(|p| p.parse::<i64>().ok());
let (y, m, day) = (d.next()??, d.next()??, d.next()??);
let (hms, frac) = time.split_once('.').unwrap_or((time, "0"));
let mut t = hms.splitn(3, ':').map(|p| p.parse::<i64>().ok());
let (h, min, sec) = (t.next()??, t.next()??, t.next()??);
let ms: i64 = format!("{frac:0<3}").get(..3)?.parse().ok()?;
// Days from civil (Howard Hinnant).
let y = if m <= 2 { y - 1 } else { y };
let era = y.div_euclid(400);
let yoe = y - era * 400;
let doy = (153 * (m + if m > 2 { -3 } else { 9 }) + 2) / 5 + day - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let days = era * 146_097 + doe - 719_468;
u64::try_from(((days * 24 + h) * 60 + min) * 60 + sec).ok().map(|secs| secs * 1000 + ms as u64)
}
fn reply(id: &Value, result: Value) -> Value {
json!({"jsonrpc": "2.0", "id": id, "result": result})
}
@@ -530,6 +675,45 @@ fn content_text(v: &Value) -> String {
.unwrap_or_default()
}
/// The models of a session: its current one and the ones it offers.
struct Models {
current: String,
options: Vec<(String, String)>,
/// The `model` config option's id; None when the agent only has the older `models` field.
config_id: Option<String>,
}
struct EffortOption {
id: String,
current: String,
options: Vec<(String, String)>,
}
fn effort_option(r: &Value) -> Option<EffortOption> {
let o = r["configOptions"].as_array()?.iter().find(|o| o["category"] == "thought_level" || ["effort", "reasoning_effort"].contains(&o["id"].as_str().unwrap_or_default()))?;
let options = o["options"].as_array()?.iter().filter_map(|v| {
let id = v["value"].as_str()?;
Some((id.to_owned(), v["name"].as_str().unwrap_or(id).to_owned()))
}).collect();
Some(EffortOption { id: o["id"].as_str()?.to_owned(), current: o["currentValue"].as_str()?.to_owned(), options })
}
/// The models in a `session/new` or `session/load` result: the `model` config option, else the
/// older (unstable) `models` field.
fn session_models(r: &Value) -> Option<Models> {
let pairs = |list: &Value, id: &str| -> Vec<(String, String)> {
let list = list.as_array().into_iter().flatten();
list.filter_map(|o| Some((o[id].as_str()?.to_owned(), o["name"].as_str().or(o[id].as_str())?.to_owned()))).collect()
};
let opts = r["configOptions"].as_array().into_iter().flatten();
if let Some(o) = opts.into_iter().find(|o| o["category"] == "model" || o["id"] == "model") {
let current = o["currentValue"].as_str()?.to_owned();
return Some(Models { current, options: pairs(&o["options"], "value"), config_id: o["id"].as_str().map(String::from) });
}
let m = &r["models"];
Some(Models { current: m["currentModelId"].as_str()?.to_owned(), options: pairs(&m["availableModels"], "modelId"), config_id: None })
}
/// `{inputTokens, outputTokens, cachedReadTokens, cachedWriteTokens}` of a prompt response.
fn usage_of(u: &Value) -> Option<Usage> {
if !u.is_object() {
@@ -539,6 +723,16 @@ fn usage_of(u: &Value) -> Option<Usage> {
Some(Usage { input: get("inputTokens"), output: get("outputTokens"), cache_read: get("cachedReadTokens"), cache_write: get("cachedWriteTokens"), cost_usd: None })
}
/// The MCP URL for the session. ACP agents load every tool schema up front, so a lean turn asks
/// for the core list (`mcp::core_tools`); the rest stay reachable through `call_tool`.
pub fn session_url(cfg: &TurnConfig) -> String {
if !cfg.lean {
return cfg.mcp_url.clone();
}
let join = if cfg.mcp_url.contains('?') { '&' } else { '?' };
format!("{}{join}tools=core", cfg.mcp_url)
}
/// PaperCraft's MCP server as an ACP `mcpServers` list: HTTP when the agent supports it, else
/// the stdio `papercraft-mcp` bridge aimed at the same port.
fn mcp_servers(url: &str, http: bool, bridge: Option<&Path>) -> Result<Value, String> {
@@ -563,12 +757,18 @@ mod tests {
const AGENT: &str = include_str!("../../tests/fixtures/chat/acp_fake_agent.sh");
const HTTP: &str = include_str!("../../tests/fixtures/chat/acp_http.jsonl");
const MODEL: &str = include_str!("../../tests/fixtures/chat/acp_model.jsonl");
const EFFORT: &str = include_str!("../../tests/fixtures/chat/acp_effort.jsonl");
const ROTATE: &str = include_str!("../../tests/fixtures/chat/acp_rotate.jsonl");
const ROTATE_LATE: &str = include_str!("../../tests/fixtures/chat/acp_rotate_late.jsonl");
const STDIO: &str = include_str!("../../tests/fixtures/chat/acp_stdio.jsonl");
const PERMISSION: &str = include_str!("../../tests/fixtures/chat/acp_permission.jsonl");
const CANCEL: &str = include_str!("../../tests/fixtures/chat/acp_cancel.jsonl");
const LOAD: &str = include_str!("../../tests/fixtures/chat/acp_load.jsonl");
const LOAD_FAILS: &str = include_str!("../../tests/fixtures/chat/acp_load_fails.jsonl");
const ERROR: &str = include_str!("../../tests/fixtures/chat/acp_error.jsonl");
const RETRY: &str = include_str!("../../tests/fixtures/chat/acp_retry.jsonl");
const RETRY_WAIT: &str = include_str!("../../tests/fixtures/chat/acp_retry_wait.jsonl");
fn cfg() -> TurnConfig {
TurnConfig { project_dir: std::env::temp_dir(), mcp_url: "http://127.0.0.1:29991/mcp".into(), system: "SYS".into(), ..Default::default() }
@@ -584,7 +784,7 @@ mod tests {
std::fs::write(&fx, fixture).unwrap();
let args: Vec<String> = [&script, &fx, &log].iter().map(|p| p.display().to_string()).collect();
let (tx, rx) = channel();
let mut turn = spawn(&cfg, Path::new("/bin/sh"), &args, "hello", tx, bridge).unwrap();
let mut turn = spawn(&cfg, Path::new("/bin/sh"), &args, &[], "hello", tx, bridge).unwrap();
let mut events = vec![];
loop {
let e = rx.recv_timeout(Duration::from_secs(15)).expect("turn timed out");
@@ -630,19 +830,146 @@ mod tests {
assert_eq!(sent.iter().find(|v| v["id"] == 50).unwrap()["error"]["code"], METHOD_NOT_FOUND);
assert_eq!(ev[0], Event::Session("sess-1".into()));
assert_eq!(ev[1], Event::Thinking("hmm".into()));
assert_eq!(ev[2], Event::Text("Hel".into()));
assert_eq!(ev[3], Event::Text("lo".into()));
assert_eq!(ev[4], Event::ToolStart { id: "t1".into(), name: "get_basic_info".into(), args: json!({}) });
assert_eq!(ev[5], Event::ToolEnd { id: "t1".into(), result: Some(json!("{\"ok\":true}")), error: None });
assert_eq!(ev[6], Event::ToolStart { id: "t2".into(), name: "ls".into(), args: json!({"path": "."}) });
assert_eq!(ev[7], Event::ToolEnd { id: "t2".into(), result: None, error: Some("denied".into()) });
assert_eq!(ev[8], Event::ToolStart { id: "acp-plan-1".into(), name: "TodoWrite".into(), args: json!({"todos": [{"content": "A", "status": "completed"}, {"content": "B", "status": "pending"}]}) });
assert_eq!(ev[9], Event::ToolEnd { id: "acp-plan-1".into(), result: Some(json!("ok")), error: None });
assert_eq!(ev[10], Event::Context(5000));
let Event::TurnEnd { usage: Some(u), session: Some(s), stop_reason: Some(r) } = &ev[11] else { panic!("{:?}", ev[11]) };
assert_eq!(ev[1], Event::Models { current: "opencode/big-pickle".into(), options: vec![] }, "the model option, not the mode");
assert_eq!(ev[2], Event::Thinking("hmm".into()));
assert_eq!(ev[3], Event::Text("Hel".into()));
assert_eq!(ev[4], Event::Text("lo".into()));
assert_eq!(ev[5], Event::ToolStart { id: "t1".into(), name: "get_basic_info".into(), args: json!({}) });
assert_eq!(ev[6], Event::ToolEnd { id: "t1".into(), result: Some(json!("{\"ok\":true}")), error: None });
assert_eq!(ev[7], Event::ToolStart { id: "t2".into(), name: "ls".into(), args: json!({"path": "."}) });
assert_eq!(ev[8], Event::ToolEnd { id: "t2".into(), result: None, error: Some("denied".into()) });
assert_eq!(ev[9], Event::ToolStart { id: "acp-plan-1".into(), name: "TodoWrite".into(), args: json!({"todos": [{"content": "A", "status": "completed"}, {"content": "B", "status": "pending"}]}) });
assert_eq!(ev[10], Event::ToolEnd { id: "acp-plan-1".into(), result: Some(json!("ok")), error: None });
assert_eq!(ev[11], Event::Context(5000));
let Event::TurnEnd { usage: Some(u), session: Some(s), stop_reason: Some(r) } = &ev[12] else { panic!("{:?}", ev[12]) };
assert_eq!((u.input, u.output, u.cost_usd, s.as_str(), r.as_str()), (7, 3, Some(0.5), "sess-1", "end_turn"));
assert_eq!(ev.len(), 12);
assert_eq!(ev.len(), 13);
}
#[test]
fn switches_to_the_chats_model_when_offered() {
let mut c = cfg();
c.model = Some("paid/b".into());
let (ev, sent) = run(MODEL, c, None, |_, _| {});
assert_eq!(method(&sent, "session/set_config_option")["params"], json!({"sessionId": "sess-1", "configId": "model", "value": "paid/b"}));
let options = vec![("free/a".into(), "A".into()), ("paid/b".into(), "B".into())];
assert_eq!(ev[1], Event::Models { current: "paid/b".into(), options });
}
#[test]
fn selects_effort_after_switching_model() {
let mut c = cfg();
c.model = Some("paid/b".into());
c.effort = Some("high".into());
let (ev, sent) = run(EFFORT, c, None, |_, _| {});
let settings: Vec<_> = sent.iter().filter(|v| v["method"] == "session/set_config_option").collect();
assert_eq!(settings[0]["params"]["value"], "paid/b");
assert_eq!(settings[1]["params"], json!({"sessionId": "sess-1", "configId": "effort", "value": "high"}));
assert!(ev.iter().any(|e| matches!(e, Event::ModelEfforts { model, options } if model == "paid/b" && options.iter().any(|(id, _)| id == "high"))));
assert!(matches!(ev.last(), Some(Event::TurnEnd { .. })));
}
#[test]
fn model_switch_without_refreshed_options_never_uses_old_efforts() {
let fixture = r#"> "method":"initialize"
< {"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{}}}
> "method":"session/new"
< {"jsonrpc":"2.0","id":1,"result":{"sessionId":"sess-1","configOptions":[{"id":"model","category":"model","currentValue":"free/a","options":[{"value":"free/a"},{"value":"paid/b"}]},{"id":"effort","category":"thought_level","currentValue":"low","options":[{"value":"low"},{"value":"high"}]}]}}
> "method":"session/set_config_option"
< {"jsonrpc":"2.0","id":2,"result":{}}
"#;
let mut c = cfg();
c.model = Some("paid/b".into());
c.effort = Some("high".into());
let (ev, sent) = run(fixture, c, None, |_, _| {});
assert!(matches!(ev.last(), Some(Event::Error(e)) if e.contains("effort setting")), "{ev:?}");
assert_eq!(sent.iter().filter(|v| v["method"] == "session/set_config_option").count(), 1);
assert!(sent.iter().all(|v| v["method"] != "session/prompt"));
let rejected = fixture.replace(r#"{"jsonrpc":"2.0","id":2,"result":{}}"#, r#"{"jsonrpc":"2.0","id":2,"error":{"code":-32602,"message":"invalid model"}}"#);
let mut c = cfg();
c.model = Some("paid/b".into());
c.effort = Some("high".into());
let (ev, sent) = run(&rejected, c, None, |_, _| {});
assert!(matches!(ev.last(), Some(Event::Error(e)) if e.contains("select the model")), "{ev:?}");
assert_eq!(sent.iter().filter(|v| v["method"] == "session/set_config_option").count(), 1);
}
#[test]
fn effort_without_session_model_is_not_silently_ignored() {
let fixture = r#"> "method":"initialize"
< {"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{}}}
> "method":"session/new"
< {"jsonrpc":"2.0","id":1,"result":{"sessionId":"sess-1"}}
"#;
let mut c = cfg();
c.effort = Some("high".into());
let (ev, sent) = run(fixture, c, None, |_, _| {});
assert!(matches!(ev.last(), Some(Event::Error(e)) if e.contains("effort setting")), "{ev:?}");
assert!(sent.iter().all(|v| v["method"] != "session/prompt"));
}
#[test]
fn a_rate_limited_model_hands_the_turn_to_the_next_free_one() {
let mut c = cfg();
c.fallbacks = vec!["gone/x".into(), "paid/b".into()];
let (ev, sent) = run(ROTATE, c, None, |_, _| {});
assert_eq!(sent.iter().filter(|v| v["method"] == "session/prompt").count(), 2);
assert_eq!(method(&sent, "session/set_config_option")["params"]["value"], "paid/b");
assert!(ev.iter().any(|e| matches!(e, Event::ModelSwitch { from, to, reason } if from == "free/a" && to == "paid/b" && reason.contains("Rate limit"))), "{ev:?}");
assert!(matches!(ev.last(), Some(Event::TurnEnd { .. })), "{ev:?}");
}
#[test]
fn a_retrying_agent_is_shown_and_free_auto_moves_on_at_once() {
let mut c = cfg();
c.fallbacks = vec!["free/b".into()];
let (ev, sent) = run(RETRY, c, None, |_, _| {});
let retry = Retry { attempt: 1, message: "Rate limit exceeded (HTTP 429)".into(), next_at_ms: Some(1_793_639_681_528) };
assert!(ev.contains(&Event::Retrying(Some(retry))), "{ev:?}");
assert_eq!(method(&sent, "session/cancel")["params"]["sessionId"], "sess-1");
assert!(ev.iter().any(|e| matches!(e, Event::ModelSwitch { from, to, reason } if from == "free/a" && to == "free/b" && reason.contains("Rate limit exceeded"))), "{ev:?}");
assert!(matches!(ev.last(), Some(Event::TurnEnd { stop_reason: Some(r), .. }) if r == "end_turn"), "{ev:?}");
}
#[test]
fn a_chosen_model_waits_out_its_retry_and_the_notice_clears() {
let (ev, sent) = run(RETRY_WAIT, cfg(), None, |_, _| {});
assert!(ev.iter().any(|e| matches!(e, Event::Retrying(Some(r)) if r.attempt == 2 && r.message == "Overloaded")), "{ev:?}");
assert!(ev.contains(&Event::Retrying(None)), "{ev:?}");
assert!(!sent.iter().any(|v| v["method"] == "session/cancel"), "no fallback: the agent keeps trying");
assert!(matches!(ev.last(), Some(Event::TurnEnd { stop_reason: Some(r), .. }) if r == "end_turn"), "{ev:?}");
assert_eq!(iso_ms("1970-01-01T00:00:01.5Z"), Some(1500));
assert_eq!(iso_ms("2000-03-01T00:00:00Z"), Some(951_868_800_000));
assert_eq!(iso_ms("not a date"), None);
}
#[test]
fn a_failure_after_a_tool_ran_is_not_retried() {
let mut c = cfg();
c.fallbacks = vec!["paid/b".into()];
let (ev, sent) = run(ROTATE_LATE, c, None, |_, _| {});
assert_eq!(sent.iter().filter(|v| v["method"] == "session/prompt").count(), 1);
assert!(matches!(ev.last(), Some(Event::Error(e)) if e.contains("Rate limit")), "{ev:?}");
assert!(!is_retryable("invalid params") && is_retryable("HTTP 429 Too Many Requests"));
}
#[test]
fn a_model_the_agent_does_not_offer_is_not_sent() {
let mut c = cfg();
c.model = Some("gone/x".into());
let (ev, sent) = run(HTTP, c, None, |_, _| {});
assert!(sent.iter().all(|v| v["method"] != "session/set_config_option" && v["method"] != "session/set_model"));
assert!(matches!(ev.last(), Some(Event::TurnEnd { .. })), "{ev:?}");
}
#[test]
fn a_lean_turn_asks_for_the_core_tools() {
let mut c = cfg();
assert_eq!(session_url(&c), c.mcp_url);
c.lean = true;
c.mcp_url = "http://127.0.0.1:1/mcp?chat=c1".into();
assert_eq!(session_url(&c), "http://127.0.0.1:1/mcp?chat=c1&tools=core");
}
#[test]
@@ -693,7 +1020,7 @@ mod tests {
fn stop_kills_an_agent_that_ignores_cancel() {
let (tx, rx) = channel();
let args = ["-c".to_string(), "trap '' INT TERM; while :; do sleep 1; done".to_string()];
let mut turn = spawn(&cfg(), Path::new("/bin/sh"), &args, "x", tx, None).unwrap();
let mut turn = spawn(&cfg(), Path::new("/bin/sh"), &args, &[], "x", tx, None).unwrap();
thread::sleep(Duration::from_millis(100));
let t = Instant::now();
turn.stop();
@@ -717,8 +1044,9 @@ mod tests {
assert!(sent.iter().all(|v| v["method"] != "session/new"));
assert_eq!(method(&sent, "session/prompt")["params"]["prompt"], json!([{"type": "text", "text": "hello"}]));
assert_eq!(ev[0], Event::Session("old".into()));
assert_eq!(ev[1], Event::Text("fresh".into()), "replayed history must not surface: {ev:?}");
assert!(matches!(&ev[2], Event::TurnEnd { session: Some(s), .. } if s == "old"));
assert_eq!(ev[1], Event::Models { current: "gemini-3-pro".into(), options: vec![] });
assert_eq!(ev[2], Event::Text("fresh".into()), "replayed history must not surface: {ev:?}");
assert!(matches!(&ev[3], Event::TurnEnd { session: Some(s), .. } if s == "old"));
}
#[test]
@@ -739,7 +1067,7 @@ mod tests {
#[test]
fn missing_program_is_an_error() {
let (tx, _rx) = channel();
assert!(spawn(&cfg(), Path::new("/nonexistent/agent"), &[], "x", tx, None).is_err());
assert!(spawn(&cfg(), Path::new("/nonexistent/agent"), &[], &[], "x", tx, None).is_err());
}
#[test]
@@ -765,7 +1093,7 @@ mod tests {
];
let (tx, rx) = channel();
let (stdin, _) = (Arc::new(Mutex::new(None)), ());
let mut c = Client { rx: channel().1, stdin, approvals: channel().1, stopped: Arc::new(AtomicBool::new(false)), tx, next_id: 0, replay: false, pending: HashMap::new(), tools: HashMap::new(), usage: None, cost: None, plans: 0, sid: None };
let mut c = Client { rx: channel().1, stdin, approvals: channel().1, stopped: Arc::new(AtomicBool::new(false)), tx, next_id: 0, replay: false, pending: HashMap::new(), tools: HashMap::new(), usage: None, cost: None, plans: 0, sid: None, retrying: None, switch_on_retry: false, cancelled_for: None };
for u in fx {
c.update(&serde_json::from_str(u).unwrap());
}
@@ -782,7 +1110,7 @@ mod tests {
let (tx, rx) = channel();
// never reads stdin; a big write fills the pipe and blocks
let args = ["-c".to_string(), "trap '' INT TERM; while :; do sleep 1; done".to_string()];
let mut turn = spawn(&cfg(), Path::new("/bin/sh"), &args, "x", tx, None).unwrap();
let mut turn = spawn(&cfg(), Path::new("/bin/sh"), &args, &[], "x", tx, None).unwrap();
*turn.session.lock().unwrap() = Some("s".repeat(1 << 20));
thread::sleep(Duration::from_millis(100));
turn.stop();
+133 -13
View File
@@ -2,13 +2,14 @@
//! on the resolved PATH and the command that launches one over ACP.
//!
//! Launch args were checked against the agents installed on the dev machine (2026-10-21):
//! * Gemini CLI 0.44.1: `gemini --acp` (`--help` lists `--acp`; `--experimental-acp` is deprecated).
//! * OpenCode 1.18.30: `opencode acp` ("start ACP (Agent Client Protocol) server").
//! * Cursor Agent 2026.03.20: the binary is `cursor-agent` (its help says `agent`); `cursor-agent acp`
//! ("Start the Cursor Agent as an ACP server").
//! * Pi 0.80.3 has no ACP mode of its own; the `pi-acp` adapter is run with `npx`.
//! * Goose: `/opt/homebrew/bin/goose` here is pressly/goose, the SQL migration tool. Block's agent
//! has an `acp` subcommand; the migration tool's `--help` never says `acp`, so that is the identity.
//!
//! Gemini CLI is left out: Google retired it for the Antigravity CLI (June 2026).
//! The rest keep their documented command and fail with the agent's own error.
#[cfg(not(target_arch = "wasm32"))]
@@ -52,7 +53,6 @@ const fn npx(id: &'static str, name: &'static str, package: &'static str, launch
pub const AGENTS: &[AgentDef] = &[
AgentDef { id: "claude-code", name: "Claude Code", binary: "claude", launch: &[], kind: AgentKind::Direct, docs_url: super::CLAUDE_DOCS, identity: Some("claude code") },
AgentDef { id: "codex", name: "Codex", binary: "codex", launch: &[], kind: AgentKind::Direct, docs_url: super::CODEX_DOCS, identity: Some("codex") },
native("gemini", "Gemini CLI", "gemini", &["--acp"], "https://github.com/google-gemini/gemini-cli", Some("gemini cli")),
native("cursor", "Cursor", "cursor-agent", &["acp"], "https://cursor.com/cli", Some("cursor agent")),
native("opencode", "OpenCode", "opencode", &["acp"], "https://opencode.ai/docs", Some("opencode")),
native("goose", "Goose", "goose", &["acp"], "https://block.github.io/goose", Some("acp")),
@@ -175,8 +175,15 @@ fn detect_def(def: &AgentDef, prefs: &ChatPrefs, path: &std::ffi::OsStr) -> Agen
let version = probe(&bin, "--version", path, PROBE_TIMEOUT);
if let Some(want) = def.identity {
let hit = |o: &Option<String>| o.as_ref().is_some_and(|o| o.to_lowercase().contains(want));
if !hit(&version) && !hit(&probe(&bin, "--help", path, HELP_TIMEOUT)) {
return AgentState::WrongProgram { path: shown };
if !hit(&version) {
// Only a `--help` that answered without the name proves a different program. One that
// did not answer in time (a cold start or an antivirus scan while the app launches,
// usual on Windows where node CLIs start through a `.cmd` shim) keeps the agent: its
// turn fails with its own error if it is not the agent after all.
let help = probe(&bin, "--help", path, HELP_TIMEOUT);
if help.is_some() && !hit(&help) {
return AgentState::WrongProgram { path: shown };
}
}
}
AgentState::Ready { path: shown, version: version.as_deref().and_then(|v| v.lines().find(|l| !l.trim().is_empty())).unwrap_or_default().trim().to_owned() }
@@ -209,7 +216,7 @@ fn probe(bin: &std::path::Path, arg: &str, path: &std::ffi::OsStr, timeout: std:
use std::io::Read;
use std::process::{Command, Stdio};
let mut cmd = Command::new(bin);
cmd.arg(arg).env("PATH", path).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
crate::chat::path::no_window(&mut cmd).arg(arg).env("PATH", path).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
for k in super::path::CREDENTIAL_ENV {
cmd.env_remove(k);
}
@@ -264,6 +271,75 @@ fn probe(bin: &std::path::Path, arg: &str, path: &std::ffi::OsStr, timeout: std:
done.then_some(out)
}
/// Environment agent `id` always runs with. opencode waits 60 s for an MCP reply (the SDK's
/// default), less than PaperCraft lets a call run (`CALL_TIMEOUT`): a slow `generate_image` would
/// finish with nobody left to read it.
#[cfg(not(target_arch = "wasm32"))]
pub fn base_env(id: &str) -> Vec<(String, String)> {
const MARGIN_MS: u128 = 30_000;
match id {
"opencode" => {
let ms = crate::mcp::server::CALL_TIMEOUT.as_millis() + MARGIN_MS;
vec![("OPENCODE_CONFIG_CONTENT".into(), serde_json::json!({"experimental": {"mcp_timeout": ms}}).to_string())]
}
_ => vec![],
}
}
/// Environment that starts agent `id` without the user's own setup (config, plugins, rules,
/// skills), so a lean turn carries only PaperCraft's instructions. `home` is an empty folder
/// PaperCraft owns. Empty for agents whose sign-in would not survive it, and where `real_home` is
/// unknown (Windows).
pub fn pure_env(id: &str, home: &std::path::Path, real_home: Option<&std::path::Path>) -> Vec<(String, String)> {
let Some(real) = real_home else { return vec![] };
let xdg = |k: &str, under: &str| (k.to_owned(), std::env::var(k).unwrap_or_else(|_| real.join(under).display().to_string()));
match id {
// Skills are found under HOME (~/.agents, ~/.claude) and config under XDG_CONFIG_HOME;
// sign-ins and sessions (so `session/load` still works) stay in the real data folder.
"opencode" => vec![
("HOME".into(), home.display().to_string()),
("XDG_CONFIG_HOME".into(), home.join("config").display().to_string()),
xdg("XDG_DATA_HOME", ".local/share"),
xdg("XDG_CACHE_HOME", ".cache"),
xdg("XDG_STATE_HOME", ".local/state"),
("OPENCODE_PURE".into(), "1".into()),
("OPENCODE_DISABLE_PROJECT_CONFIG".into(), "1".into()),
],
// Pi finds skills under HOME (~/.agents/skills) and its config, packages and sessions in
// PI_CODING_AGENT_DIR; npm (npx pi-acp, Pi's package installs) keeps the user's cache.
"pi" => vec![
("HOME".into(), home.display().to_string()),
("PI_CODING_AGENT_DIR".into(), home.join(PI_AGENT_DIR).display().to_string()),
("npm_config_cache".into(), std::env::var("npm_config_cache").unwrap_or_else(|_| real.join(".npm").display().to_string())),
],
_ => vec![],
}
}
/// Pi's folder inside its lean home (`PI_CODING_AGENT_DIR`).
pub const PI_AGENT_DIR: &str = "agent";
/// Writes the lean Pi folder under `home`: only `pi-mcp-adapter` as a package, connected to
/// PaperCraft at `mcp_url` with its tools listed one by one (not behind the adapter's `mcp`
/// proxy) and a call timeout above PaperCraft's own.
#[cfg(not(target_arch = "wasm32"))]
pub fn write_pi_home(home: &std::path::Path, mcp_url: &str) -> Result<(), String> {
const MARGIN_MS: u128 = 30_000;
let dir = home.join(PI_AGENT_DIR);
crate::vfs::create_dir_all(&dir).map_err(|e| format!("Could not make {}: {e}", dir.display()))?;
let settings = serde_json::json!({"packages": ["npm:pi-mcp-adapter"], "quietStartup": true});
let mcp = serde_json::json!({
"mcpServers": {"papercraft": {"url": mcp_url, "directTools": true}},
"settings": {"requestTimeoutMs": crate::mcp::server::CALL_TIMEOUT.as_millis() + MARGIN_MS},
});
for (name, v) in [("settings.json", settings), ("mcp.json", mcp)] {
let path = dir.join(name);
let text = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
crate::io::atomic::write(&path, text.as_bytes()).map_err(|e| format!("Could not write {}: {e}", path.display()))?;
}
Ok(())
}
/// What the ACP backend runs for this agent: program and args. None for an unknown id, a
/// `Direct` agent, an `npx` agent that is not enabled, or a program that is not on PATH.
#[cfg(not(target_arch = "wasm32"))]
@@ -331,7 +407,7 @@ mod tests {
#[test]
fn detects_ready_wrong_missing_and_npx_states() {
let (dir, path) = fake_dir(&[
("gemini", r#"if [ "$1" = --version ]; then echo 0.1.0; else echo "Gemini CLI help"; fi"#),
("cursor-agent", r#"if [ "$1" = --version ]; then echo 0.1.0; else echo "Cursor Agent help"; fi"#),
// The migration tool: version string and help with no "acp".
("goose", r#"if [ "$1" = --version ]; then echo "goose version: v3.27.1"; else echo "Usage: goose DRIVER DBSTRING"; fi"#),
("opencode", r#"echo "opencode 1.0""#),
@@ -341,10 +417,10 @@ mod tests {
let all = detect_all_in(&prefs, &path);
assert_eq!(all.len(), AGENTS.len());
let shown = |n: &str| dir.join(n).display().to_string();
assert_eq!(state(&all, "gemini"), AgentState::Ready { path: shown("gemini"), version: "0.1.0".into() });
assert_eq!(state(&all, "cursor"), AgentState::Ready { path: shown("cursor-agent"), version: "0.1.0".into() });
assert_eq!(state(&all, "opencode"), AgentState::Ready { path: shown("opencode"), version: "opencode 1.0".into() });
assert_eq!(state(&all, "goose"), AgentState::WrongProgram { path: shown("goose") });
assert_eq!(state(&all, "cursor"), AgentState::NotFound);
assert_eq!(state(&all, "kimi"), AgentState::NotFound);
assert_eq!(state(&all, "pi"), AgentState::NeedsEnable);
prefs.agents.insert("pi".into(), AgentPrefs { enabled: true, ..Default::default() });
assert_eq!(state(&detect_all_in(&prefs, &path), "pi"), AgentState::Ready { path: shown("npx"), version: String::new() });
@@ -355,6 +431,16 @@ mod tests {
use std::ffi::OsStr;
#[test]
fn a_program_too_slow_to_answer_is_still_detected() {
// `opencode --version` prints only the number, so `--help` decides; at app launch it can
// miss the timeout, which used to file the agent under "Different program".
let (dir, path) = fake_dir(&[("opencode", r#"if [ "$1" = --version ]; then echo 1.18.30; else /bin/sleep 30; fi"#)]);
let def = find("opencode").unwrap();
assert_eq!(detect_def(def, &ChatPrefs::default(), &path), AgentState::Ready { path: dir.join("opencode").display().to_string(), version: "1.18.30".into() });
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn path_override_wins_and_hung_program_times_out() {
let (dir, path) = fake_dir(&[("opencode", "echo opencode"), ("hang", "/bin/sleep 30")]);
@@ -367,22 +453,56 @@ mod tests {
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn opencode_waits_longer_for_a_tool_than_papercraft_lets_it_run() {
let env = base_env("opencode");
let config: serde_json::Value = serde_json::from_str(&env[0].1).unwrap();
let ms = config["experimental"]["mcp_timeout"].as_u64().unwrap();
assert!(u128::from(ms) > crate::mcp::server::CALL_TIMEOUT.as_millis());
assert!(base_env("cursor").is_empty());
}
#[test]
fn pure_opencode_hides_the_users_home_but_keeps_its_sign_ins() {
let env = pure_env("opencode", std::path::Path::new("/pc/home"), Some(std::path::Path::new("/Users/me")));
let get = |k: &str| env.iter().find(|(n, _)| n == k).map(|(_, v)| v.as_str());
assert_eq!((get("HOME"), get("XDG_CONFIG_HOME"), get("OPENCODE_PURE")), (Some("/pc/home"), Some("/pc/home/config"), Some("1")));
let data = std::env::var("XDG_DATA_HOME").unwrap_or_else(|_| "/Users/me/.local/share".into());
assert_eq!(get("XDG_DATA_HOME"), Some(data.as_str()), "auth.json and sessions live there");
assert!(pure_env("cursor", std::path::Path::new("/pc/home"), Some(std::path::Path::new("/Users/me"))).is_empty(), "its sign-in is under HOME");
assert!(pure_env("opencode", std::path::Path::new("/pc/home"), None).is_empty());
let pi = pure_env("pi", std::path::Path::new("/pc/home"), Some(std::path::Path::new("/Users/me")));
assert!(pi.contains(&("HOME".into(), "/pc/home".into())) && pi.contains(&("PI_CODING_AGENT_DIR".into(), "/pc/home/agent".into())), "{pi:?}");
}
#[test]
fn the_lean_pi_folder_has_only_the_mcp_adapter_pointed_at_papercraft() {
let home = std::env::temp_dir().join(format!("pc-pi-home-{}", std::process::id()));
write_pi_home(&home, "http://127.0.0.1:1/mcp?chat=c1&tools=core").unwrap();
let read = |f: &str| serde_json::from_str::<serde_json::Value>(&std::fs::read_to_string(home.join("agent").join(f)).unwrap()).unwrap();
assert_eq!(read("settings.json")["packages"], serde_json::json!(["npm:pi-mcp-adapter"]));
let mcp = read("mcp.json");
assert_eq!(mcp["mcpServers"]["papercraft"]["url"], "http://127.0.0.1:1/mcp?chat=c1&tools=core");
assert!(mcp["settings"]["requestTimeoutMs"].as_u64().unwrap() > crate::mcp::server::CALL_TIMEOUT.as_millis() as u64);
std::fs::remove_dir_all(&home).unwrap();
}
#[test]
fn launch_commands_for_native_npx_custom_and_direct() {
let (dir, path) = fake_dir(&[("gemini", ""), ("npx", ""), ("my-agent", "")]);
let (dir, path) = fake_dir(&[("cursor-agent", ""), ("npx", ""), ("my-agent", "")]);
let mut prefs = ChatPrefs::default();
prefs.custom_agents.push(CustomAgent { id: "mine".into(), name: "Mine".into(), command: "my-agent".into(), args: "serve --stdio".into() });
let at = |n: &str| dir.join(n);
assert_eq!(launch_command_in("gemini", &prefs, &path), Some((at("gemini"), vec!["--acp".into()])));
assert_eq!(launch_command_in("cursor", &prefs, &path), Some((at("cursor-agent"), vec!["acp".into()])));
assert_eq!(launch_command_in("mine", &prefs, &path), Some((at("my-agent"), vec!["serve".into(), "--stdio".into()])));
assert_eq!(launch_command_in("pi", &prefs, &path), None);
prefs.agents.insert("pi".into(), AgentPrefs { enabled: true, ..Default::default() });
assert_eq!(launch_command_in("pi", &prefs, &path), Some((at("npx"), vec!["-y".into(), "pi-acp".into()])));
prefs.agents.insert("gemini".into(), AgentPrefs { args: Some("--acp --debug".into()), ..Default::default() });
assert_eq!(launch_command_in("gemini", &prefs, &path).unwrap().1, ["--acp", "--debug"]);
prefs.agents.insert("cursor".into(), AgentPrefs { args: Some("acp --debug".into()), ..Default::default() });
assert_eq!(launch_command_in("cursor", &prefs, &path).unwrap().1, ["acp", "--debug"]);
assert_eq!(launch_command_in("claude-code", &prefs, &path), None);
assert_eq!(launch_command_in("nope", &prefs, &path), None);
assert_eq!(launch_command_in("cursor", &prefs, &path), None);
assert_eq!(launch_command_in("kimi", &prefs, &path), None);
std::fs::remove_dir_all(&dir).unwrap();
}
}
+27 -1
View File
@@ -24,6 +24,29 @@ pub enum Event {
/// Tokens in the context at the backend's latest model call (input and cache), not summed
/// over the turn: the header meter.
Context(u64),
/// The model the agent's session runs on and the ones it offers, as `(id, name)`.
Models { current: String, options: Vec<(String, String)> },
/// The selected ACP model's available reasoning levels.
ModelEfforts { model: String, options: Vec<(String, String)> },
/// The turn moved from model `from` to `to` because `from` failed with `reason` before doing
/// anything ("Free (auto)").
ModelSwitch { from: String, to: String, reason: String },
/// The agent's provider failed the model call (rate limit, overload) and the agent waits to
/// try again on its own; None once a call goes through. Without this a rate-limited turn just
/// sat on "Working" with no message.
Retrying(Option<Retry>),
}
/// An agent waiting out a failed model call (opencode's `session_info_update` with
/// `_meta["opencode/retry"]`).
#[derive(Debug, Clone, PartialEq)]
pub struct Retry {
/// 1 for the first retry.
pub attempt: u32,
/// The provider's error, e.g. "Rate limit exceeded (HTTP 429)".
pub message: String,
/// Unix ms of the next attempt, when the agent says.
pub next_at_ms: Option<u64>,
}
/// Sends a tool call to the UI thread (the app's MCP dispatch), so runs, undo and the agent log
@@ -73,7 +96,10 @@ pub struct TurnConfig {
pub ds_digest: String,
/// Model id; None = the backend's default (API: the endpoint's model).
pub model: Option<String>,
/// `low` | `medium` | `high`; None = the backend's default.
/// ACP: models to move to, in order, when `model` fails before any tool ran (rate limit,
/// quota, gone). Filled for "Free (auto)".
pub fallbacks: Vec<String>,
/// Reasoning level; None = the backend's default.
pub effort: Option<String>,
/// Resume this backend session.
pub session: Option<String>,
+278
View File
@@ -0,0 +1,278 @@
//! Installing an agent PaperCraft can run on free models (OpenCode, Pi): the methods each agent's
//! install docs give per OS (checked 2026-10-29), the ones that apply to this machine, and a
//! runner that streams their output. Nothing runs elevated; methods that need `sudo` or an admin
//! shell are offered as a command to copy.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Os {
Mac,
Linux,
Windows,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
/// A package manager installs it.
Package,
/// A script downloaded from the agent's site runs: listed last, its URL shown in full.
Script,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Method {
pub label: &'static str,
pub os: &'static [Os],
/// Linux only: the distributions (os-release `ID` or `ID_LIKE`) it is for; empty = any.
pub distros: &'static [&'static str],
/// The program that must be on PATH (`brew`, `npm`, `curl`, ...).
pub needs: &'static str,
/// Lowest Node version (major, minor) an npm method needs.
pub node: Option<(u32, u32)>,
pub command: &'static str,
pub kind: Kind,
/// Asks for a password or an admin shell: shown to copy, never run.
pub elevated: bool,
}
const UNIX: &[Os] = &[Os::Mac, Os::Linux];
const ALL: &[Os] = &[Os::Mac, Os::Linux, Os::Windows];
const WINDOWS: &[Os] = &[Os::Windows];
const fn method(label: &'static str, os: &'static [Os], needs: &'static str, command: &'static str, kind: Kind) -> Method {
Method { label, os, distros: &[], needs, node: None, command, kind, elevated: false }
}
/// OpenCode 1.x: plain `brew install opencode` and Arch's `pacman -S opencode` install OpenCode 2,
/// which PaperCraft's ACP and model code has not been tested on.
const OPENCODE: &[Method] = &[
method("Homebrew", UNIX, "brew", "brew install anomalyco/tap/opencode", Kind::Package),
method("Scoop", WINDOWS, "scoop", "scoop install opencode", Kind::Package),
method("npm", ALL, "npm", "npm install -g opencode-ai", Kind::Package),
Method { distros: &["arch"], elevated: true, ..method("AUR (paru)", &[Os::Linux], "paru", "paru -S opencode-bin", Kind::Package) },
Method { elevated: true, ..method("Chocolatey", WINDOWS, "choco", "choco install opencode", Kind::Package) },
method("Install script", UNIX, "curl", "curl -fsSL https://opencode.ai/install | bash", Kind::Script),
];
const PI: &[Method] = &[
Method { node: Some((22, 19)), ..method("npm", ALL, "npm", "npm install -g --ignore-scripts @earendil-works/pi-coding-agent", Kind::Package) },
method("Homebrew", UNIX, "brew", "brew install pi-coding-agent", Kind::Package),
method("Nix", UNIX, "nix", "nix profile add github:earendil-works/pi/stable", Kind::Package),
method("Install script", UNIX, "curl", "curl -fsSL https://pi.dev/install.sh | sh", Kind::Script),
method("Install script", WINDOWS, "powershell", "powershell -c \"irm https://pi.dev/install.ps1 | iex\"", Kind::Script),
];
/// The agents the wizard installs.
pub const AGENTS: [&str; 2] = ["opencode", "pi"];
/// What the wizard knows about this machine.
pub struct Host {
pub os: Os,
/// os-release `ID` and `ID_LIKE` words (Linux).
pub distro: Vec<String>,
/// Programs found on PATH, from those methods need.
pub has: Vec<&'static str>,
pub node: Option<(u32, u32)>,
}
/// Why a method this OS has is not offered: shown so the user knows what to add.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Missing {
pub label: &'static str,
pub reason: String,
}
/// The methods for `agent` that can run here, best first, and the ones this OS has that cannot.
pub fn methods(agent: &str, host: &Host) -> (Vec<Method>, Vec<Missing>) {
let all = match agent {
"opencode" => OPENCODE,
"pi" => PI,
_ => &[],
};
let mut ok = vec![];
let mut missing = vec![];
for m in all.iter().filter(|m| m.os.contains(&host.os) && (m.distros.is_empty() || m.distros.iter().any(|d| host.distro.iter().any(|h| h == d)))) {
let reason = if !host.has.contains(&m.needs) {
Some(format!("needs {}", m.needs))
} else {
m.node.filter(|min| host.node.is_none_or(|v| v < *min)).map(|(a, b)| format!("needs Node {a}.{b} or newer"))
};
match reason {
Some(reason) => missing.push(Missing { label: m.label, reason }),
None => ok.push(*m),
}
}
ok.sort_by_key(|m| m.kind == Kind::Script);
(ok, missing)
}
/// One line of a running install, or its end.
#[derive(Debug, Clone, PartialEq)]
pub enum Line {
Out(String),
Done(Result<(), String>),
}
#[cfg(not(target_arch = "wasm32"))]
pub use native::{detect, run};
#[cfg(not(target_arch = "wasm32"))]
mod native {
use super::*;
use std::io::{BufRead, BufReader};
use std::process::{Command, Stdio};
use std::sync::mpsc::{Receiver, Sender, channel};
/// `v22.19.0` as (22, 19).
pub(super) fn parse_node(v: &str) -> Option<(u32, u32)> {
let mut parts = v.trim().trim_start_matches('v').split('.');
Some((parts.next()?.parse().ok()?, parts.next()?.parse().ok()?))
}
/// os-release `ID` and `ID_LIKE` words.
pub(super) fn parse_os_release(text: &str) -> Vec<String> {
text.lines()
.filter_map(|l| l.strip_prefix("ID=").or_else(|| l.strip_prefix("ID_LIKE=")))
.flat_map(|v| v.trim_matches('"').split_whitespace().map(str::to_owned).collect::<Vec<_>>())
.collect()
}
/// The OS, distribution, the programs methods need and the Node version. Runs `node
/// --version`, so call it off the UI thread.
pub fn detect() -> Host {
let os = if cfg!(windows) {
Os::Windows
} else if cfg!(target_os = "macos") {
Os::Mac
} else {
Os::Linux
};
let distro = std::fs::read_to_string("/etc/os-release").map(|t| parse_os_release(&t)).unwrap_or_default();
let mut needs: Vec<&'static str> = OPENCODE.iter().chain(PI).map(|m| m.needs).collect();
needs.dedup();
let has = needs.into_iter().filter(|n| crate::chat::path::which(n).is_some()).collect();
let node = crate::chat::path::which("node").and_then(|n| {
let mut cmd = Command::new(n);
crate::chat::path::apply(cmd.arg("--version").stdin(Stdio::null()));
cmd.output().ok().and_then(|o| parse_node(&String::from_utf8_lossy(&o.stdout)))
});
Host { os, distro, has, node }
}
/// Runs `m` through the shell (pipes and quoting as the docs write them) with the merged
/// PATH, streaming stdout and stderr lines. An elevated method is refused.
pub fn run(m: &Method) -> Receiver<Line> {
let (tx, rx) = channel();
if m.elevated {
let _ = tx.send(Line::Done(Err(format!("{} needs an admin shell or a password: run it yourself", m.label))));
return rx;
}
let mut cmd = if cfg!(windows) {
let mut c = Command::new("powershell");
c.args(["-NoProfile", "-Command", m.command]);
c
} else {
let mut c = Command::new("sh");
c.args(["-c", m.command]);
c
};
crate::chat::path::apply(&mut cmd).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
let command = m.command;
std::thread::spawn(move || {
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => {
let _ = tx.send(Line::Done(Err(format!("Could not start `{command}`: {e}"))));
return;
}
};
let pipe = |r: Box<dyn std::io::Read + Send>, tx: Sender<Line>| {
std::thread::spawn(move || {
for l in BufReader::new(r).lines().map_while(Result::ok) {
if tx.send(Line::Out(l)).is_err() {
break;
}
}
})
};
let readers = [child.stdout.take().map(|o| pipe(Box::new(o), tx.clone())), child.stderr.take().map(|e| pipe(Box::new(e), tx.clone()))];
let status = child.wait();
for r in readers.into_iter().flatten() {
let _ = r.join();
}
let end = match status {
Ok(s) if s.success() => Ok(()),
Ok(s) => Err(format!("`{command}` failed ({s})")),
Err(e) => Err(format!("`{command}`: {e}")),
};
let _ = tx.send(Line::Done(end));
});
rx
}
}
#[cfg(test)]
mod tests {
use super::*;
use super::native::{parse_node, parse_os_release};
fn host(os: Os, distro: &[&str], has: &[&'static str], node: Option<(u32, u32)>) -> Host {
Host { os, distro: distro.iter().map(|d| (*d).to_owned()).collect(), has: has.to_vec(), node }
}
fn labels(agent: &str, h: &Host) -> Vec<&'static str> {
methods(agent, h).0.iter().map(|m| m.label).collect()
}
#[test]
fn mac_with_homebrew_and_node() {
let h = host(Os::Mac, &[], &["brew", "npm", "curl"], Some((24, 13)));
assert_eq!(labels("opencode", &h), ["Homebrew", "npm", "Install script"]);
assert_eq!(labels("pi", &h), ["npm", "Homebrew", "Install script"]);
}
#[test]
fn mac_without_node_hides_npm_and_says_why() {
let h = host(Os::Mac, &[], &["curl"], None);
let (ok, missing) = methods("pi", &h);
assert_eq!(ok.iter().map(|m| m.label).collect::<Vec<_>>(), ["Install script"]);
assert!(missing.contains(&Missing { label: "npm", reason: "needs npm".into() }));
let old = host(Os::Mac, &[], &["npm"], Some((22, 3)));
assert!(methods("pi", &old).1.iter().any(|m| m.reason == "needs Node 22.19 or newer"));
}
#[test]
fn ubuntu_and_arch() {
let ubuntu = host(Os::Linux, &parse_os_release("ID=ubuntu\nID_LIKE=debian\n").iter().map(String::as_str).collect::<Vec<_>>(), &["curl", "npm", "paru"], Some((22, 20)));
assert_eq!(labels("opencode", &ubuntu), ["npm", "Install script"], "paru is for Arch only");
let arch = host(Os::Linux, &["arch"], &["curl", "paru"], None);
let (ok, _) = methods("opencode", &arch);
assert!(ok.iter().any(|m| m.label == "AUR (paru)" && m.elevated), "offered to copy, never run");
}
#[test]
fn windows_with_scoop() {
let h = host(Os::Windows, &[], &["scoop", "powershell"], None);
assert_eq!(labels("opencode", &h), ["Scoop"]);
assert_eq!(labels("pi", &h), ["Install script"]);
assert!(methods("pi", &h).0[0].command.contains("install.ps1"));
}
#[test]
fn versions_and_distros_parse() {
assert_eq!(parse_node("v22.19.0\n"), Some((22, 19)));
assert_eq!(parse_os_release("NAME=\"Arch Linux\"\nID=arch\n"), ["arch"]);
}
#[cfg(unix)]
#[test]
fn a_run_streams_lines_and_reports_failure() {
let ok = run(&method("echo", UNIX, "sh", "echo one; echo two >&2", Kind::Package));
let lines: Vec<Line> = ok.iter().collect();
assert!(lines.contains(&Line::Out("one".into())) && lines.contains(&Line::Out("two".into())));
assert_eq!(lines.last(), Some(&Line::Done(Ok(()))));
let bad: Vec<Line> = run(&method("false", UNIX, "sh", "exit 3", Kind::Package)).iter().collect();
assert!(matches!(bad.last(), Some(Line::Done(Err(_)))));
let sudo: Vec<Line> = run(&Method { elevated: true, ..method("x", UNIX, "sh", "echo never", Kind::Package) }).iter().collect();
assert_eq!(sudo.len(), 1, "an elevated method never runs");
}
}
+67
View File
@@ -0,0 +1,67 @@
//! API keys for the model providers Pi runs on, kept in the OS secret store (Keychain, Credential
//! Manager, Secret Service) and handed to Pi only as environment variables of its process.
/// A provider Pi reads a key for from the environment.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Provider {
pub id: &'static str,
pub name: &'static str,
pub env: &'static str,
/// Where the user gets a free key.
pub key_url: &'static str,
/// Shown next to the key: what the free tier allows.
pub note: &'static str,
}
pub const PROVIDERS: [Provider; 5] = [
Provider { id: "openrouter", name: "OpenRouter", env: "OPENROUTER_API_KEY", key_url: "https://openrouter.ai/keys", note: ":free models, 50 requests a day (1000 after buying 10 credits)" },
Provider { id: "groq", name: "Groq", env: "GROQ_API_KEY", key_url: "https://console.groq.com/keys", note: "Free tier with per-minute limits" },
Provider { id: "gemini", name: "Gemini", env: "GEMINI_API_KEY", key_url: "https://aistudio.google.com/apikey", note: "Free tier on Flash models" },
Provider { id: "nvidia", name: "NVIDIA NIM", env: "NVIDIA_API_KEY", key_url: "https://build.nvidia.com/settings/api-keys", note: "Free tier with per-minute limits" },
Provider { id: "cerebras", name: "Cerebras", env: "CEREBRAS_API_KEY", key_url: "https://cloud.cerebras.ai", note: "Free tier with daily limits" },
];
/// `(env var, key)` for each provider `get` has a key for. Never log the result.
pub fn env(get: impl Fn(&Provider) -> Option<String>) -> Vec<(String, String)> {
PROVIDERS.iter().filter_map(|p| get(p).map(|k| (p.env.to_owned(), k))).collect()
}
#[cfg(not(target_arch = "wasm32"))]
pub use store::{forget, get, set};
#[cfg(not(target_arch = "wasm32"))]
mod store {
use super::Provider;
const SERVICE: &str = "PaperCraft";
fn entry(p: &Provider) -> Result<keyring::Entry, String> {
keyring::Entry::new(SERVICE, &format!("pi:{}", p.id)).map_err(|e| e.to_string())
}
pub fn get(p: &Provider) -> Option<String> {
entry(p).ok()?.get_password().ok().map(|k| k.trim().to_owned()).filter(|k| !k.is_empty())
}
pub fn set(p: &Provider, key: &str) -> Result<(), String> {
entry(p)?.set_password(key.trim()).map_err(|e| e.to_string())
}
pub fn forget(p: &Provider) -> Result<(), String> {
match entry(p)?.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => Err(e.to_string()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_providers_with_a_key_reach_the_environment() {
let env = env(|p| (p.id == "openrouter").then(|| "sk-or-test".to_owned()));
assert_eq!(env, [("OPENROUTER_API_KEY".to_owned(), "sk-or-test".to_owned())]);
}
}
+3
View File
@@ -10,6 +10,9 @@ pub mod codex;
pub mod ds_digest;
pub mod endpoint;
pub mod events;
pub mod install;
pub mod keys;
pub mod models;
#[cfg(not(target_arch = "wasm32"))]
pub mod openai;
#[cfg(not(target_arch = "wasm32"))]
+222
View File
@@ -0,0 +1,222 @@
//! The models an agent can run: read from the agent's own CLI where it has one (`opencode models
//! --verbose` gives names, prices, context sizes and image input without starting a session), else
//! from the model option of its ACP sessions.
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// One model an agent offers. Prices are US dollars per million tokens.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct AgentModel {
/// What the agent takes to select it, e.g. `opencode/big-pickle`.
pub id: String,
pub name: String,
pub provider: String,
pub cost_in: Option<f64>,
pub cost_out: Option<f64>,
pub context: Option<u64>,
pub image: Option<bool>,
/// Model-specific reasoning variants offered by the agent.
pub variants: Vec<String>,
}
impl AgentModel {
/// "Free", "$0.30 / $1.20", or "" when the price is unknown.
pub fn price(&self) -> String {
match (self.cost_in, self.cost_out) {
(Some(i), Some(o)) if i == 0.0 && o == 0.0 => "Free".into(),
(Some(i), Some(o)) => format!("${i:.2} / ${o:.2}"),
_ => String::new(),
}
}
/// Costs nothing to run: priced at 0, an OpenRouter `:free` variant, or served by a provider
/// whose API keys come with a free tier.
pub fn is_free(&self) -> bool {
matches!((self.cost_in, self.cost_out), (Some(i), Some(o)) if i == 0.0 && o == 0.0)
|| self.id.ends_with(":free")
|| FREE_TIER_PROVIDERS.contains(&self.provider.as_str())
}
}
/// The chat's model when it runs on whichever free model works ("Free (auto)").
pub const FREE_AUTO: &str = "free-auto";
/// Models a "Free (auto)" turn may move to after its first one fails.
pub const MAX_FALLBACKS: usize = 2;
/// Pi providers whose keys have a free tier. ponytail: per provider, because `pi --list-models`
/// gives no prices; a paid model there runs on the free tier's limits.
const FREE_TIER_PROVIDERS: [&str; 4] = ["groq", "cerebras", "nvidia", "google"];
/// The free models of `catalog` minus `skip`, the most capable first: larger context, then image
/// input. The order "Free (auto)" tries them in.
pub fn free_order(catalog: &[AgentModel], skip: &[String]) -> Vec<String> {
let mut free: Vec<&AgentModel> = catalog.iter().filter(|m| m.is_free() && !skip.contains(&m.id)).collect();
free.sort_by_key(|m| (std::cmp::Reverse(m.context.unwrap_or(0)), m.image != Some(true)));
free.into_iter().map(|m| m.id.clone()).collect()
}
/// ACP config option values `(id, name)`; a name like "Ollama Cloud/GLM-5.3" carries its provider.
pub fn from_options(options: &[(String, String)]) -> Vec<AgentModel> {
options
.iter()
.map(|(id, name)| {
let (provider, name) = name.split_once('/').unwrap_or_else(|| (id.split_once('/').map_or("", |(p, _)| p), name));
AgentModel { id: id.clone(), name: name.to_owned(), provider: provider.to_owned(), ..Default::default() }
})
.collect()
}
/// `fresh` becomes the list; what it does not say (price, context, a provider name) is kept from
/// the old entry with the same id. True when anything changed.
pub fn merge(list: &mut Vec<AgentModel>, fresh: Vec<AgentModel>) -> bool {
let merged: Vec<AgentModel> = fresh
.into_iter()
.map(|mut m| {
if let Some(old) = list.iter().find(|o| o.id == m.id) {
if m.provider.is_empty() {
m.provider.clone_from(&old.provider);
}
m.cost_in = m.cost_in.or(old.cost_in);
m.cost_out = m.cost_out.or(old.cost_out);
m.context = m.context.or(old.context);
m.image = m.image.or(old.image);
if m.variants.is_empty() {
m.variants.clone_from(&old.variants);
}
}
m
})
.collect();
let changed = *list != merged;
*list = merged;
changed
}
/// `opencode models --verbose`: a `provider/model` line, then the model as pretty JSON, per model.
pub fn parse_opencode(out: &str) -> Vec<AgentModel> {
let mut models = vec![];
let mut lines = out.lines();
while let Some(head) = lines.next() {
let Some((provider, _)) = head.trim().split_once('/').filter(|_| !head.starts_with([' ', '{', '}'])) else { continue };
let mut body = String::new();
for l in lines.by_ref() {
body.push_str(l);
if l == "}" {
break;
}
}
let Ok(v) = serde_json::from_str::<Value>(&body) else { continue };
let num = |p: &str| v.pointer(p).and_then(Value::as_f64);
models.push(AgentModel {
id: head.trim().to_owned(),
name: v["name"].as_str().unwrap_or(head.trim()).to_owned(),
provider: provider.to_owned(),
cost_in: num("/cost/input"),
cost_out: num("/cost/output"),
context: v.pointer("/limit/context").and_then(Value::as_u64),
image: v.pointer("/capabilities/input/image").and_then(Value::as_bool),
variants: v["variants"].as_object().map(|v| v.iter().filter(|(_, value)| value["disabled"] != true).map(|(name, _)| name.clone()).collect()).unwrap_or_default(),
});
}
models
}
/// `pi --list-models`: a header, then `provider model context max-out thinking images` columns,
/// sizes written like `131.1K` or `1M`.
pub fn parse_pi(out: &str) -> Vec<AgentModel> {
let size = |s: &str| -> Option<u64> {
let (n, mul) = match s.as_bytes().last()? {
b'K' => (&s[..s.len() - 1], 1e3),
b'M' => (&s[..s.len() - 1], 1e6),
_ => (s, 1.0),
};
n.parse::<f64>().ok().map(|v| (v * mul).round() as u64)
};
out.lines()
.filter_map(|l| {
let c: Vec<&str> = l.split_whitespace().collect();
let [provider, model, context, _, _, images] = c[..] else { return None };
if provider == "provider" {
return None;
}
Some(AgentModel {
id: format!("{provider}/{model}"),
name: model.to_owned(),
provider: provider.to_owned(),
context: size(context),
image: Some(images == "yes"),
..Default::default()
})
})
.collect()
}
/// Runs `pi --list-models --offline` with `env` (the lean Pi folder and the provider keys).
#[cfg(not(target_arch = "wasm32"))]
pub fn fetch_pi(program: &std::path::Path, env: &[(String, String)]) -> Option<Vec<AgentModel>> {
let mut cmd = std::process::Command::new(program);
crate::chat::path::no_window(&mut cmd).args(["--list-models", "--offline"]).envs(env.iter().cloned()).stdin(std::process::Stdio::null());
super::path::apply(&mut cmd);
let out = cmd.output().map_err(|e| log::warn!("models: could not run {}: {e}", program.display())).ok()?;
let models = parse_pi(&String::from_utf8_lossy(&out.stdout));
(!models.is_empty()).then_some(models)
}
/// Runs `program models --verbose` with `env` (the lean environment, so the list matches what a
/// lean session offers). None when it fails or lists nothing.
#[cfg(not(target_arch = "wasm32"))]
pub fn fetch_opencode(program: &std::path::Path, env: &[(String, String)]) -> Option<Vec<AgentModel>> {
let mut cmd = std::process::Command::new(program);
crate::chat::path::no_window(&mut cmd).args(["models", "--verbose"]).envs(env.iter().cloned()).stdin(std::process::Stdio::null());
super::path::apply(&mut cmd);
let out = cmd.output().map_err(|e| log::warn!("models: could not run {}: {e}", program.display())).ok()?;
let models = parse_opencode(&String::from_utf8_lossy(&out.stdout));
(!models.is_empty()).then_some(models)
}
#[cfg(test)]
mod tests {
use super::*;
const OUT: &str = "opencode/big-pickle\n{\n \"id\": \"big-pickle\",\n \"name\": \"Big Pickle\",\n \"cost\": {\n \"input\": 0,\n \"output\": 0\n },\n \"limit\": {\n \"context\": 200000\n },\n \"capabilities\": {\n \"input\": {\n \"image\": false\n }\n }\n}\nminimax/MiniMax-M3\n{\n \"name\": \"MiniMax-M3\",\n \"cost\": {\n \"input\": 0.3,\n \"output\": 1.2\n }\n}\n";
#[test]
fn opencode_listing_gives_names_prices_and_limits() {
let m = parse_opencode(OUT);
assert_eq!(m.iter().map(|m| (m.id.as_str(), m.provider.as_str(), m.price())).collect::<Vec<_>>(), [("opencode/big-pickle", "opencode", "Free".to_owned()), ("minimax/MiniMax-M3", "minimax", "$0.30 / $1.20".to_owned())]);
assert_eq!((m[0].context, m[0].image, m[1].context), (Some(200_000), Some(false), None));
}
#[test]
fn opencode_listing_keeps_model_variants() {
let models = parse_opencode("provider/model\n{\n\"name\":\"Model\",\n\"variants\":{\"high\":{},\"low\":{}}\n}\n");
assert_eq!(models[0].variants, ["high", "low"]);
}
#[test]
fn pi_listing_gives_ids_context_and_images() {
let out = "provider model context max-out thinking images\nopenrouter qwen/qwen3-coder:free 262.1K 32.8K no no\ngroq llama-4-scout 1M 8.2K yes yes\nbroken line\n";
let m = parse_pi(out);
assert_eq!(m.iter().map(|m| (m.id.as_str(), m.context, m.image)).collect::<Vec<_>>(), [("openrouter/qwen/qwen3-coder:free", Some(262_100), Some(false)), ("groq/llama-4-scout", Some(1_000_000), Some(true))]);
}
#[test]
fn free_models_go_first_by_context_and_skip_the_failed() {
let mut list = parse_opencode(OUT);
list.extend(parse_pi("openrouter a:free 1M 1K no no\nopenrouter b 1M 1K no no\ngroq c 100K 1K no yes\n"));
assert_eq!(free_order(&list, &[]), ["openrouter/a:free", "opencode/big-pickle", "groq/c"]);
assert_eq!(free_order(&list, &["openrouter/a:free".into()]), ["opencode/big-pickle", "groq/c"]);
}
#[test]
fn session_options_keep_what_the_listing_knew() {
let mut list = parse_opencode(OUT);
let fresh = from_options(&[("opencode/big-pickle".into(), "OpenCode Zen/Big Pickle".into()), ("x/y".into(), "Y".into())]);
assert!(merge(&mut list, fresh));
assert_eq!((list[0].provider.as_str(), list[0].name.as_str(), list[0].price(), list[0].context), ("OpenCode Zen", "Big Pickle", "Free".to_owned(), Some(200_000)));
assert_eq!((list[1].provider.as_str(), list.len()), ("x", 2), "a model the session no longer offers is dropped");
assert!(!merge(&mut list.clone(), list.clone()));
}
}
+9 -2
View File
@@ -56,7 +56,7 @@ fn merge(shell: &str, process: &OsStr, extras: Vec<PathBuf>) -> String {
/// Runs `shell -l -c script` and returns the text between the PATH markers; None on failure or timeout.
fn shell_path(shell: &str, script: &str, timeout: Duration) -> Option<String> {
let mut cmd = Command::new(shell);
cmd.args(["-l", "-c", script]).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null());
no_window(&mut cmd).args(["-l", "-c", script]).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null());
for k in CREDENTIAL_ENV {
cmd.env_remove(k);
}
@@ -107,7 +107,14 @@ fn is_executable(p: &Path) -> bool {
/// Sets the merged PATH on a child, so node-based CLIs find `node`.
pub fn apply(cmd: &mut Command) -> &mut Command {
cmd.env("PATH", OsString::from(resolved_path()))
no_window(cmd).env("PATH", OsString::from(resolved_path()))
}
/// The app has no console on Windows, so every console tool it starts would open its own.
pub fn no_window(cmd: &mut Command) -> &mut Command {
#[cfg(windows)]
std::os::windows::process::CommandExt::creation_flags(cmd, windows_sys::Win32::System::Threading::CREATE_NO_WINDOW);
cmd
}
#[cfg(all(test, unix))]
+3 -1
View File
@@ -228,7 +228,9 @@ mod tests {
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");
let saved: Value = serde_json::from_str(&on_disk).unwrap();
assert_eq!(saved["messages"][0]["tools"][0]["result"][0]["path"], path, "the slimmer chat was written back");
assert!(!on_disk.contains(&STANDARD.encode(&png)));
assert_eq!((mime, ImageSrc::File(path).bytes()), ("image/png", Some(png)));
assert_eq!(r[1]["text"], "ok");
remove_tmp(&dir, "shots");
+61
View File
@@ -0,0 +1,61 @@
//! Keeps panics on disk: a release build on Windows has no console, so stderr goes nowhere and
//! the app would simply vanish.
use std::backtrace::Backtrace;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
const FILE_NAME: &str = "crash.log";
/// Where crash reports go.
pub fn path() -> Option<PathBuf> {
Some(crate::project::config_dir()?.join(FILE_NAME))
}
/// Appends each panic, on any thread, to `path`; the default report still prints afterwards.
pub fn install(path: PathBuf) {
let default_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let secs = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs());
let thread = std::thread::current();
let report = format!(
"--- unix time {secs}, version {}, thread {}\n{info}\n{}\n",
env!("CARGO_PKG_VERSION"),
thread.name().unwrap_or("unnamed"),
Backtrace::force_capture(),
);
if let Err(error) = append(&path, &report) {
eprintln!("could not write {}: {error}", path.display());
}
default_hook(info);
}));
}
fn append(path: &Path, report: &str) -> std::io::Result<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::OpenOptions::new().create(true).append(true).open(path)?.write_all(report.as_bytes())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_panic_is_appended_to_the_log_with_where_it_happened() {
let dir = std::env::temp_dir().join(format!("papercraft-crash-{}", std::process::id()));
let log = dir.join(FILE_NAME);
install(log.clone());
for _ in 0..2 {
assert!(std::panic::catch_unwind(|| panic!("boom for the log")).is_err());
}
drop(std::panic::take_hook());
let text = std::fs::read_to_string(&log).unwrap();
assert_eq!(text.matches("boom for the log").count(), 2);
assert!(text.contains("crash_log"));
std::fs::remove_dir_all(&dir).unwrap();
}
}
+100 -31
View File
@@ -90,35 +90,48 @@ impl Editor {
}
// Variables whose CSS name changed (rename, group rename): bound fields follow.
let renames = css_renames(&before, &next);
let mut fills = Fills::new();
for i in 0..self.pages.len() {
let old = self.pages[i].modes.clone();
let modes = match (&active_modes, i == active) {
(Some(m), true) => m.clone(),
_ => old.clone(),
};
let cache = next.cache_for(&modes);
let carry = Some((before.clone(), next.clone()));
let page_modes = (modes != old).then(|| (old, modes.clone()));
let extra = if i == active { on_active.take() } else { None };
let n = self.with_page(i, |ed| {
ed.rebuild_cache(
label,
&cache,
&renames,
pre,
extra.map(|o| o.apply),
|rec| {
rec.design = carry;
rec.page_modes = page_modes;
},
true,
)
});
self.pages[i].modes = modes;
add_fills(&mut fills, n);
}
// A panic part way (a tool call the app survives) would leave the pages already rebuilt
// on `next` and `self.design` on `before`. Roll forward to `next` so they agree again.
let rebuilt = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let mut fills = Fills::new();
for i in 0..self.pages.len() {
let old = self.pages[i].modes.clone();
let modes = match (&active_modes, i == active) {
(Some(m), true) => m.clone(),
_ => old.clone(),
};
let cache = next.cache_for(&modes);
let carry = Some((before.clone(), next.clone()));
let page_modes = (modes != old).then(|| (old, modes.clone()));
let extra = if i == active { on_active.take() } else { None };
let n = self.with_page(i, |ed| {
ed.rebuild_cache(
label,
&cache,
&renames,
pre,
extra.map(|o| o.apply),
|rec| {
rec.design = carry;
rec.page_modes = page_modes;
},
true,
)
});
self.pages[i].modes = modes;
add_fills(&mut fills, n);
}
fills
}));
self.design = next;
let fills = match rebuilt {
Ok(f) => f,
Err(panic) => {
self.abort();
self.rebuild_all_silently(&renames);
std::panic::resume_unwind(panic)
}
};
Ok((r, fills))
}
@@ -128,7 +141,11 @@ impl Editor {
self.finish_loading();
for i in 0..self.pages.len() {
let cache = self.design.cache_for(&self.pages[i].modes);
self.with_page(i, |ed| ed.rebuild_cache("", &cache, renames, None, None, |_| {}, false));
// Recovery from a panic runs through here: a page that panics again is left as it is
// so the pages after it still get rebuilt (with_page restores its own state).
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
self.with_page(i, |ed| ed.rebuild_cache("", &cache, renames, None, None, |_| {}, false))
}));
self.pages[i].saved_gen = u64::MAX;
}
}
@@ -184,16 +201,21 @@ impl Editor {
// re-running layout (fit_text/fixup need the editor).
pub fn page_doc_in_mode(&mut self, page: usize, mode: &str) -> Result<crate::model::Document, String> {
let (cid, mid) = find_mode(&self.design, mode).ok_or(format!("no mode `{mode}`"))?;
Ok(self.page_doc_in(page, &cid, &mid))
}
/// [`Self::page_doc_in_mode`] for a mode picked by collection and mode id.
pub fn page_doc_in(&mut self, page: usize, cid: &str, mid: &str) -> crate::model::Document {
self.ensure_loaded(page);
let mut modes = self.pages[page].modes.clone();
modes.insert(cid, mid);
modes.insert(cid.into(), mid.into());
let cache = self.design.cache_for(&modes);
let mut doc = self.page_doc(page).clone();
let mut rec = crate::history::Recording::new("Preview mode", &doc);
let mut tx = crate::history::Tx::new(&mut doc, &mut rec);
tx.set_cache(&cache, &BTreeMap::new());
apply_bindings(&mut tx, &cache, &BTreeMap::new());
Ok(doc)
doc
}
/// Switch the active page to `mode_id` of `collection_id`: that page's modes, cache and bound
@@ -281,6 +303,7 @@ impl Editor {
"gap" | "padding" | "padding-top" | "padding-right" | "padding-bottom"
| "padding-left" => n.layout.is_some(),
"font-size" | "line-height" | "text-color" => n.kind.font_size().is_some(),
"text" => n.kind.text().is_some(),
_ => true,
};
if want.is_some() && !ok {
@@ -364,6 +387,7 @@ fn field_kind(field: &str) -> Option<Kind> {
| "padding-bottom" | "padding-left" | "radius" | "font-size" | "line-height"
| "opacity" => Kind::Number,
"visible" => Kind::Boolean,
"text" => Kind::String,
_ => return None,
})
}
@@ -377,6 +401,25 @@ fn token_number(cache: &variables::Cache, name: &str) -> Option<f64> {
t.strip_suffix("px").unwrap_or(t).trim().parse().ok()
}
/// Replace a text layer's content with a bound string, keeping whole-text spans whole and
/// clamping the others, as `set_text_content` does.
fn set_bound_text(n: &mut Node, new: &str) {
let Some(old) = n.kind.text().filter(|t| *t != new) else { return };
let old_len = old.chars().count();
let len = new.chars().count();
if let Some(slot) = n.kind.text_mut() {
*slot = new.into();
}
for s in &mut n.text_style.spans {
if s.start == 0 && s.end >= old_len {
s.end = len;
}
s.start = s.start.min(len);
s.end = s.end.min(len);
}
n.text_style.spans.retain(|s| s.end > s.start);
}
/// Set `field` of `n` from variable `name` in `cache`. An unresolved variable leaves the value.
fn apply_field(n: &mut Node, field: &str, name: &str, cache: &variables::Cache) {
match field {
@@ -398,6 +441,11 @@ fn apply_field(n: &mut Node, field: &str, name: &str, cache: &variables::Cache)
n.visible = *b;
}
}
"text" => {
if let Some(t) = cache.tokens.get(name) {
set_bound_text(n, t);
}
}
_ => {
let Some(v) = token_number(cache, name) else {
return;
@@ -920,6 +968,27 @@ mod tests {
e.page_doc(page).variables["accent"]
}
#[test]
fn a_panic_part_way_through_a_design_edit_leaves_every_page_on_the_new_design() {
let (mut e, ids) = two_pages();
let green = Color::rgb(0, 255, 0);
let calls = std::cell::Cell::new(0);
let boom = |_: &mut crate::history::Tx| {
calls.set(calls.get() + 1);
if calls.get() == 2 {
panic!("second page");
}
};
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
e.edit_design_each("Change", |ds| set_color(ds, &PageModes::new(), "accent", None, green), &boom)
}));
assert!(r.is_err());
for (page, &id) in ids.iter().enumerate() {
assert_eq!(accent(&e, page), green);
assert_eq!(fill(&e, page, id).0, green);
}
}
#[test]
fn undo_on_another_page_restores_design_and_every_cache() {
let (mut e, ids) = two_pages();
+57 -13
View File
@@ -115,7 +115,7 @@ impl Tool {
}
/// Ctrl+wheel zoom rate: one 40px wheel notch is about 13%.
const ZOOM_PER_SCROLL_PX: f64 = 0.003;
pub const ZOOM_PER_SCROLL_PX: f64 = 0.003;
/// Requests the editor can't fulfil itself (dialogs, file system, clipboard).
#[derive(Debug, Clone, PartialEq)]
@@ -217,6 +217,15 @@ enum Gesture {
const DRAG_THRESHOLD: f64 = 3.0;
const PASTE_OFFSET: f64 = 16.0;
/// A frame Present mode shows as an overlay: drawn `shift` away from where it sits on the canvas,
/// clipped to `window`, over a dimmed backdrop. A view state; the document is not touched.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PresentOverlay {
pub id: NodeId,
pub shift: V2,
pub window: Rect,
}
pub struct Editor {
pub doc: Document,
pub history: History,
@@ -231,6 +240,8 @@ pub struct Editor {
pub guides: Vec<Guide>,
/// World-space marquee rectangle while dragging.
pub marquee: Option<Rect>,
/// The overlay Present mode has open, if any.
pub present_overlay: Option<PresentOverlay>,
pub hover: Option<NodeId>,
pub pointer: V2,
pub ink_width: f64,
@@ -348,6 +359,7 @@ impl Editor {
text_edit: None,
guides: Vec::new(),
marquee: None,
present_overlay: None,
hover: None,
pointer: V2::ZERO,
ink_width: 3.0,
@@ -477,6 +489,22 @@ impl Editor {
}
}
/// Rolls back the open transaction, if any, without touching the undo history. For work that
/// stopped half way (a gesture cancelled, a tool call that panicked).
pub fn abort(&mut self) {
self.touched_mains.clear();
if let Some((rec, key)) = self.take_rec() {
let mut h = History::default();
let mut changed = Changed::Nodes(Arc::new([]));
if h.commit(&self.doc, rec, key)
&& let Some(u) = h.undo(&mut self.doc)
{
changed = Changed::Nodes(u.nodes.into());
}
self.after_history(changed);
}
}
/// 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);
@@ -2259,17 +2287,7 @@ impl Editor {
| Gesture::ConnectorEnd { .. }
| Gesture::ConnectorMid { .. }
| Gesture::Erase => {
// Roll back the open transaction.
if let Some((rec, key)) = self.take_rec() {
let mut h = History::default();
let mut changed = Changed::Nodes(Arc::new([]));
if h.commit(&self.doc, rec, key)
&& let Some(u) = h.undo(&mut self.doc)
{
changed = Changed::Nodes(u.nodes.into());
}
self.after_history(changed);
}
self.abort();
}
_ => {}
}
@@ -2334,6 +2352,17 @@ impl Editor {
}
}
/// Image ids held by the clipboard and the library documents: they can outlive the project
/// that registered them, so [`crate::assets::release_unused`] must keep them.
pub fn images_held_outside_pages(&self) -> Vec<String> {
let mut ids: Vec<String> = self.library_docs.values().flat_map(crate::assets::used_by).collect();
ids.extend(self.clipboard.iter().flat_map(|n| n.style.fills.iter()).filter_map(|p| match p {
crate::model::Paint::Image { asset, .. } => Some(asset.clone()),
_ => None,
}));
ids
}
/// Paste from the system clipboard when it holds something newer than our own copy
/// (layers from another window, Excalidraw JSON, SVG, an image or text); otherwise from
/// the internal clipboard.
@@ -2448,8 +2477,13 @@ impl Editor {
let same_page = self.clipboard_page.as_deref() == Some(self.active_page_id());
let world = self.clipboard_world.clone();
let new_roots = self.insert_subtrees("Paste", &nodes, &roots, &world, offset, same_page);
let skipped = roots.len() - new_roots.len();
self.selection = new_roots;
self.status = format!("Pasted {} layer(s)", self.selection.len());
self.status = if skipped > 0 {
format!("Pasted {} layer(s); {skipped} skipped: a component can't contain an instance of itself", self.selection.len())
} else {
format!("Pasted {} layer(s)", self.selection.len())
};
}
/// Insert copies of subtrees. `world[i]` is root `i`'s world rect; `same_page` keeps the
@@ -2633,6 +2667,16 @@ pub fn insert_subtrees_tx(
let parent = n
.parent
.filter(|p| same_page && tx.doc().nodes.contains_key(p));
// An instance of X pasted inside X's main would expand into itself and lose its layers.
let nests = parent.is_some()
&& nodes
.iter()
.filter(|d| d.id == n.id || is_desc(nodes, d.id, n.id))
.filter_map(|d| d.instance.as_ref())
.any(|l| l.source.is_none() && !map.contains_key(&l.main) && structure::would_nest(tx.doc(), l.main, parent));
if nests {
continue;
}
let w = world.get(i).copied().unwrap_or(n.rect);
let mut copy = n.clone();
copy.id = map[&n.id];
+26 -7
View File
@@ -472,8 +472,14 @@ impl Editor {
let saved_marker = self.saved_marker;
let outer = self.edit_page.replace(i);
swap(self, &mut s);
let r = f(self);
self.commit();
// A panic in `f` (a tool call the app survives) must not leave page `i`'s document in the
// active slot: the next save would write it under the wrong page.
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(self)));
if r.is_ok() {
self.commit();
} else {
self.abort();
}
swap(self, &mut s);
self.edit_page = outer;
self.saved_marker = saved_marker;
@@ -481,8 +487,13 @@ impl Editor {
self.revision += 1;
// The active page's document is the one it was before `f`.
self.mark(super::Changed::Nodes(std::sync::Arc::new([])));
self.flush_cross_pages();
r
match r {
Ok(r) => {
self.flush_cross_pages();
r
}
Err(panic) => std::panic::resume_unwind(panic),
}
}
/// Re-sync instances on other pages after a main component changed: each touched page
@@ -495,7 +506,17 @@ impl Editor {
// Instances on a page that is still being read must follow the change too.
self.finish_loading();
self.cross_busy = true;
for _ in 0..MAX_ROUNDS {
// `cross_busy` stays set for good if a sync panics, which would stop all later syncs.
let done = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.flush_rounds(MAX_ROUNDS)));
self.cross_pending.clear();
self.cross_busy = false;
if let Err(panic) = done {
std::panic::resume_unwind(panic);
}
}
fn flush_rounds(&mut self, max_rounds: usize) {
for _ in 0..max_rounds {
let Some((src, mains)) = (!self.cross_pending.is_empty())
.then(|| self.cross_pending.remove(0))
else {
@@ -529,8 +550,6 @@ impl Editor {
});
}
}
self.cross_pending.clear();
self.cross_busy = false;
}
/// Hand over the pages `open_pages` got as stand-ins (`pending`: indexes into the page list)
+3
View File
@@ -407,12 +407,15 @@ pub fn category(a: Action) -> &'static str {
| A::SaveAs
| A::ExportPng
| A::ExportSvg
| A::ExportPdf
| A::ExportPresentation
| A::PlaceImage
| A::Home => "File",
A::Undo
| A::Redo
| A::Copy
| A::CopyAsPng
| A::CopyAsSvg
| A::Cut
| A::Paste
| A::Duplicate
+164 -6
View File
@@ -132,6 +132,117 @@ fn copy_props(main: &Node, inst: &mut Node, overrides: &[String], is_root: bool)
}
}
/// How many instance links `origin` follows: a file can hold a loop of them.
const MAX_LINK_HOPS: usize = 64;
/// The layer an instance finally derives from, following links within this document (a link
/// to another document, or a missing main, ends the chain).
fn origin(doc: &Document, mut id: NodeId) -> NodeId {
for _ in 0..MAX_LINK_HOPS {
let Some(link) = doc.get(id).and_then(|n| n.instance.as_ref()) else { break };
let (main, source) = link.swap.as_ref().map_or((link.main, &link.source), |s| (s.main, &s.source));
if source.is_some() || doc.get(main).is_none() {
break;
}
id = main;
}
id
}
/// True when an instance of `main` placed under `under` (`None` = top level) would sit inside
/// the layers it is made from, directly or through the instances `main` contains. Such an
/// instance would expand into itself for ever.
pub fn would_nest(doc: &Document, main: NodeId, under: Option<NodeId>) -> bool {
// The layers themselves, not their mains: a layer added inside an instance stays in that
// instance, so an instance of X inside another instance of X is fine.
let hosts: HashSet<NodeId> = under.into_iter().flat_map(|u| std::iter::once(u).chain(doc.ancestors(u))).collect();
if hosts.is_empty() {
return false;
}
let mut seen = HashSet::new();
let mut todo = vec![main];
while let Some(root) = todo.pop() {
for n in doc.subtree(root) {
let o = origin(doc, n);
if hosts.contains(&n) || hosts.contains(&o) {
return true;
}
if o != n && seen.insert(o) {
todo.push(o);
}
}
}
false
}
/// Like [`would_nest`] for a main on page `main_page` placed under `under` on page `page`,
/// following instance links across pages. A loop through two pages is not infinite (each sync
/// copies from a snapshot) but nests the copies one level deeper on every edit.
pub fn would_nest_across(ed: &Editor, main_page: usize, main: NodeId, page: usize, under: Option<NodeId>) -> bool {
// A page still being read has no document yet; its links cannot be followed.
let doc_of = |p: usize| (p == ed.active_page).then_some(&ed.doc).or_else(|| ed.pages.get(p)?.stash.as_ref().map(|s| &s.doc));
let Some(host_doc) = doc_of(page) else { return false };
let hosts: HashSet<(usize, NodeId)> = under
.into_iter()
.flat_map(|u| std::iter::once(u).chain(host_doc.ancestors(u)))
.map(|n| (page, n))
.collect();
if hosts.is_empty() {
return false;
}
let origin = |mut at: (usize, NodeId)| {
for _ in 0..MAX_LINK_HOPS {
let Some(link) = doc_of(at.0).and_then(|d| d.get(at.1)).and_then(|n| n.instance.as_ref()) else { break };
let (m, source) = link.target();
let next = match source {
None => at.0,
Some(Source::Page(id)) => match ed.pages.iter().position(|p| p.id == *id) {
Some(p) => p,
None => break,
},
Some(_) => break,
};
if doc_of(next).and_then(|d| d.get(m)).is_none() {
break;
}
at = (next, m);
}
at
};
let mut seen = HashSet::new();
let mut todo = vec![(main_page, main)];
while let Some((p, root)) = todo.pop() {
let Some(d) = doc_of(p) else { continue };
for id in d.subtree(root) {
let o = origin((p, id));
if hosts.contains(&(p, id)) || hosts.contains(&o) {
return true;
}
if o != (p, id) && seen.insert(o) {
todo.push(o);
}
}
}
false
}
/// Whether moving or pasting `id` under `under` would put an instance inside its own main.
pub fn moves_into_cycle(doc: &Document, id: NodeId, under: Option<NodeId>) -> bool {
doc.subtree(id).into_iter().any(|n| doc.get(n).is_some_and(|x| x.instance.is_some()) && would_nest(doc, n, under))
}
/// [`moves_into_cycle`] for a subtree copied or moved from `src_page` under `under` on `page`.
pub fn copy_nests_across(ed: &Editor, src_page: usize, id: NodeId, page: usize, under: Option<NodeId>) -> bool {
let doc = ed.page_doc(src_page);
doc.subtree(id).into_iter().any(|n| doc.get(n).is_some_and(|x| x.instance.is_some()) && would_nest_across(ed, src_page, n, page, under))
}
/// Whether swapping instance `inst` to `new_main` is impossible: the new main is the instance
/// or inside it (the swap would delete it), or would nest the instance in itself.
pub fn swap_nests(doc: &Document, inst: NodeId, new_main: NodeId) -> bool {
inst == new_main || doc.is_ancestor(inst, new_main) || would_nest(doc, new_main, doc.get(inst).and_then(|n| n.parent))
}
/// Copy a subtree as a new instance subtree under `parent` (ids are fresh). The main is on
/// the same page.
pub fn instantiate(
@@ -179,6 +290,10 @@ pub fn instantiate_from(
}
tx.insert(n, parent, index);
for c in src_node.children {
// A child that holds this very instance (or one of its mains) would copy itself for ever.
if src.is_none() && would_nest(tx.doc(), c, Some(id)) {
continue;
}
instantiate_from(tx, src, source.clone(), c, Some(id), None, None);
}
id
@@ -293,6 +408,9 @@ pub(super) fn sync_node(tx: &mut Tx, inst: NodeId, is_root: bool, ext: Option<&D
sync_node(tx, k, false, kid_ext);
}
None => {
if ext.is_none() && would_nest(tx.doc(), *mc, Some(inst)) {
continue;
}
instantiate_from(tx, ext, source.clone(), *mc, Some(inst), Some(i), None);
}
}
@@ -763,11 +881,7 @@ impl Editor {
/// Swap an instance root to point at `new_main` component, preserving overrides on children
/// matched by name and kind.
pub fn swap_instance(&mut self, inst: NodeId, new_main: NodeId) -> bool {
if !self.is_instance_root(inst)
|| !self.doc.get(new_main).is_some_and(|m| m.component)
|| inst == new_main
|| self.doc.is_ancestor(inst, new_main)
{
if !self.is_instance_root(inst) || !self.doc.get(new_main).is_some_and(|m| m.component) || swap_nests(&self.doc, inst, new_main) {
return false;
}
self.edit("Swap instance", |tx| swap_instance_tx(tx, None, None, inst, new_main));
@@ -1003,6 +1117,9 @@ pub fn swap_instance_tx(
new_main: NodeId,
) -> bool {
let src = ext.unwrap_or(tx.doc());
if ext.is_none() && swap_nests(tx.doc(), inst, new_main) {
return false;
}
let Some(new_comp) = src.get(new_main).cloned() else {
return false;
};
@@ -1059,7 +1176,7 @@ pub fn swap_instance_tx(
/// the outer instance, so later syncs of the outer main keep the swap.
pub fn swap_nested_tx(tx: &mut Tx, ext: Option<&Document>, source: Option<Source>, inst: NodeId, new_main: NodeId) -> bool {
let src = ext.unwrap_or(tx.doc());
if !src.get(new_main).is_some_and(|c| c.component) {
if !src.get(new_main).is_some_and(|c| c.component) || (ext.is_none() && swap_nests(tx.doc(), inst, new_main)) {
return false;
}
let name = src.get(new_main).unwrap().name.clone();
@@ -1182,4 +1299,45 @@ mod tests {
let l = e.doc.get(kids[0]).unwrap().instance.clone().unwrap();
assert_eq!((l.main, l.source), (kid, None));
}
#[test]
fn an_instance_inside_its_own_component_syncs_without_recursing() {
let mut e = Editor::default();
let comp = e.add_node(Node::new(NodeId(0), NodeKind::Frame { clip: false }, Rect::new(0., 0., 50., 50.)), None);
let kid = e.add_node(Node::new(NodeId(0), NodeKind::Frame { clip: false }, Rect::new(0., 0., 5., 5.)), Some(comp));
e.selection = vec![comp];
e.create_component();
assert!(would_nest(&e.doc, comp, Some(kid)));
let inst = e.edit("i", |tx| instantiate(tx, comp, Some(kid), None, None));
e.edit("sync", |tx| sync_node(tx, inst, true, None));
e.edit("sync main", |tx| sync_node(tx, inst, true, None));
assert!(e.doc.get(inst).unwrap().children.is_empty(), "the copy of the kid would hold the instance again");
}
#[test]
fn a_loop_of_two_components_is_a_nest() {
let mut e = Editor::default();
let a = e.add_node(Node::new(NodeId(0), NodeKind::Frame { clip: false }, Rect::new(0., 0., 50., 50.)), None);
let b = e.add_node(Node::new(NodeId(0), NodeKind::Frame { clip: false }, Rect::new(100., 0., 50., 50.)), None);
e.selection = vec![a, b];
e.create_component();
let in_b = e.edit("i", |tx| instantiate(tx, a, Some(b), None, None));
assert!(e.doc.get(in_b).is_some_and(|n| n.instance.is_some()));
assert!(would_nest(&e.doc, b, Some(a)), "b holds an instance of a: a cannot hold b");
assert!(!would_nest(&e.doc, a, None));
}
#[test]
fn an_instance_can_sit_inside_another_instance_of_the_same_component() {
let mut e = Editor::default();
let comp = e.add_node(Node::new(NodeId(0), NodeKind::Frame { clip: false }, Rect::new(0., 0., 50., 50.)), None);
e.add_node(Node::new(NodeId(0), NodeKind::Frame { clip: false }, Rect::new(0., 0., 5., 5.)), Some(comp));
e.selection = vec![comp];
e.create_component();
let outer = e.edit("i", |tx| instantiate(tx, comp, None, None, None));
let slot = e.doc.get(outer).unwrap().children[0];
assert!(!would_nest(&e.doc, comp, Some(slot)), "a layer of an instance is not a layer of its main");
let inner = e.edit("i", |tx| instantiate(tx, comp, Some(slot), None, None));
assert_eq!(e.doc.get(inner).unwrap().children.len(), 1, "the nested copy keeps its layers");
}
}
+66
View File
@@ -492,6 +492,24 @@ fn copy_paste_frame_with_children_and_undo() {
e.doc.validate().unwrap();
}
#[test]
fn pasting_an_instance_into_its_own_component_is_skipped() {
let mut e = ed();
let main = e.add_node(Node::new(NodeId(0), NodeKind::Frame { clip: false }, Rect::new(0.0, 0.0, 100.0, 100.0)), None);
let kid = e.add_node(Node::new(NodeId(0), NodeKind::Frame { clip: false }, Rect::new(0.0, 0.0, 10.0, 10.0)), Some(main));
e.select(vec![main]);
e.create_component();
let inst = e.edit("i", |tx| crate::editor::structure::instantiate(tx, main, None, None, None));
// A stale clipboard: the copy's recorded parent is now a layer of the component.
let mut node = e.doc.get(inst).unwrap().clone();
node.parent = Some(kid);
let before = e.doc.nodes.len();
let made = e.insert_subtrees("Paste", &[node], &[inst], &[Rect::new(0.0, 0.0, 100.0, 100.0)], V2::ZERO, true);
assert!(made.is_empty());
assert_eq!(e.doc.nodes.len(), before);
e.doc.validate().unwrap();
}
#[test]
fn dragging_into_and_out_of_frames_reparents() {
let mut e = ed();
@@ -654,3 +672,51 @@ fn restore_snapshot_restores_design_system_in_one_undo() {
assert_eq!(e.doc.modes["Light"]["accent"], Color::rgb(0, 200, 0));
assert_eq!(rect_of(&e, a).min(), v2(300.0, 100.0));
}
#[test]
fn abort_rolls_back_a_half_finished_edit_and_keeps_the_history() {
let mut e = ed();
let (a, _) = two_rects(&mut e);
e.undo();
e.redo();
let (undo, redo, doc) = (e.history.undo_len(), e.history.redo_len(), e.doc.nodes.clone());
e.begin("Move");
e.tx().node_mut(a).unwrap().rect = Rect::new(0.0, 0.0, 9.0, 9.0);
e.abort();
assert_eq!(e.doc.nodes, doc, "the partial change is gone");
assert_eq!((e.history.undo_len(), e.history.redo_len()), (undo, redo));
assert!(e.rec.is_none(), "no transaction stays open");
e.undo();
assert_eq!(e.history.undo_len(), undo - 1, "the next undo steps back the last real edit");
}
#[test]
fn a_panic_inside_with_page_leaves_the_active_page_intact() {
let mut e = ed();
let (a, _) = two_rects(&mut e);
e.select(vec![a]);
e.add_page_in_background(None);
let (doc, sel, undo) = (e.doc.nodes.clone(), e.selection.clone(), e.history.undo_len());
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
e.with_page(1, |e| {
e.begin("half");
panic!("tool bug");
})
}));
assert!(r.is_err(), "the panic still propagates");
assert_eq!(e.doc.nodes, doc, "the active page keeps its own document");
assert_eq!((e.selection.clone(), e.history.undo_len()), (sel, undo));
assert!(e.edit_page.is_none() && e.rec.is_none() && !e.cross_busy);
assert!(e.pages[1].stash.is_some(), "the other page is parked again");
}
#[test]
fn ancestors_and_validate_stop_on_a_parent_loop() {
let mut e = ed();
let (a, b) = two_rects(&mut e);
e.doc.nodes.get_mut(&a).unwrap().parent = Some(b);
e.doc.nodes.get_mut(&b).unwrap().parent = Some(a);
assert_eq!(e.doc.ancestors(a), vec![b]);
assert!(e.doc.is_ancestor(b, a));
assert!(e.doc.validate().is_err(), "a loop is reported, not followed");
}
+3
View File
@@ -48,6 +48,8 @@ impl Editor {
let byte = byte_at(text, caret);
text.insert_str(byte, s);
n.text_style.on_insert(caret, n_new);
// Typed text replaces a bound string, as a new colour replaces a `text-color` binding.
n.bindings.remove("text");
if let Some(sp) = inherit {
n.text_style.apply_range(caret, caret + n_new, |t| {
*t = TextSpan {
@@ -81,6 +83,7 @@ impl Editor {
let (ba, bb) = (byte_at(text, a), byte_at(text, b));
text.replace_range(ba..bb, "");
n.text_style.on_delete(a, b);
n.bindings.remove("text");
}
self.text_edit = Some(TextEdit {
id: te.id,
+268
View File
@@ -0,0 +1,268 @@
//! Fonts people add on top of the bundled Inter.
//!
//! Faces are registered process-wide and never dropped, like image assets, because glyph caches
//! and queued draws keep their ids. A project stores its faces as `assets/fonts/<hash>.<ext>`, so
//! it opens the same anywhere; text naming a family that is not installed renders in Inter.
use crate::text::{BUILT_IN_FACES, FaceId, face_id};
use std::path::Path;
use std::sync::{OnceLock, RwLock};
const FONTS_DIR: &str = "fonts";
/// Larger than any Latin family with hinting, and small enough to refuse a wrong download.
pub const MAX_FONT_BYTES: usize = 32 << 20;
const REGULAR_WEIGHT: i32 = 400;
const BOLD_WEIGHT: i32 = 700;
/// Chars cached per face, see `text::CACHED_CHARS`.
pub type AdvanceTable = Vec<(FaceId, u16, f64)>;
pub struct LoadedFace {
hash: String,
pub bytes: &'static [u8],
pub face: ttf_parser::Face<'static>,
weight: i32,
/// The Inter face of the same style, for characters this one lacks.
pub fallback: FaceId,
pub advances: OnceLock<AdvanceTable>,
}
impl LoadedFace {
fn ext(&self) -> &'static str {
if self.face.tables().cff.is_some() { "otf" } else { "ttf" }
}
}
struct Family {
name: String,
/// Indexed like a built-in `FaceId`: bit 0 = bold, bit 1 = italic.
slots: [Option<FaceId>; 4],
}
#[derive(Default)]
struct Registry {
faces: Vec<&'static LoadedFace>,
families: Vec<Family>,
}
impl Registry {
fn family(&self, name: &str) -> Option<&Family> {
self.families.iter().find(|f| f.name.eq_ignore_ascii_case(name))
}
fn loaded(&self, id: FaceId) -> &'static LoadedFace {
self.faces[usize::from(id - BUILT_IN_FACES)]
}
}
fn registry() -> &'static RwLock<Registry> {
static R: OnceLock<RwLock<Registry>> = OnceLock::new();
R.get_or_init(Default::default)
}
/// A face registered by [`register`]; ids below [`BUILT_IN_FACES`] are Inter's.
pub fn loaded(id: FaceId) -> &'static LoadedFace {
registry().read().unwrap().loaded(id)
}
fn family_name(face: &ttf_parser::Face) -> Option<String> {
use ttf_parser::name_id::{FAMILY, TYPOGRAPHIC_FAMILY};
let named = |id| face.names().into_iter().find(|n| n.name_id == id && n.is_unicode()).and_then(|n| n.to_string());
named(TYPOGRAPHIC_FAMILY).or_else(|| named(FAMILY)).filter(|n| !n.trim().is_empty())
}
/// Registers a TrueType or OpenType file and returns its family's name.
pub fn register(bytes: &[u8]) -> Result<String, String> {
let hash = crate::assets::hash(bytes);
let mut reg = registry().write().unwrap();
let known = (0..reg.faces.len()).find(|&i| reg.faces[i].hash == hash).map(|i| i as FaceId + BUILT_IN_FACES);
if let Some(id) = known {
let name = reg.families.iter().find(|f| f.slots.contains(&Some(id))).map(|f| f.name.clone());
return name.ok_or_else(|| "the font was replaced by another of its style".to_owned());
}
if bytes.len() > MAX_FONT_BYTES {
return Err(format!("the font is over {} MB", MAX_FONT_BYTES >> 20));
}
let probe = ttf_parser::Face::parse(bytes, 0).map_err(|_| "not a TrueType or OpenType font (.ttf or .otf); WOFF and archives must be unpacked first".to_owned())?;
let tables = probe.tables();
if tables.cmap.is_none() || (tables.glyf.is_none() && tables.cff.is_none()) {
return Err("the font has no outlines or character map".into());
}
let family = family_name(&probe).ok_or("the font does not name its family")?;
if family.eq_ignore_ascii_case("inter") {
return Err("Inter is built in".into());
}
let (bold, italic) = (probe.is_bold() || probe.weight().to_number() >= 600, probe.is_italic() || probe.is_oblique());
let weight = i32::from(probe.weight().to_number());
// Leaked on purpose: a face lives as long as the process, see the module comment.
let bytes: &'static [u8] = Box::leak(bytes.to_vec().into_boxed_slice());
let face = ttf_parser::Face::parse(bytes, 0).map_err(|e| e.to_string())?;
let fallback = face_id(bold, italic);
let id = reg.faces.len() as FaceId + BUILT_IN_FACES;
reg.faces.push(Box::leak(Box::new(LoadedFace { hash, bytes, face, weight, fallback, advances: OnceLock::new() })));
let index = match reg.families.iter().position(|f| f.name.eq_ignore_ascii_case(&family)) {
Some(i) => i,
None => {
reg.families.push(Family { name: family.clone(), slots: [None; 4] });
reg.families.len() - 1
}
};
let slot = usize::from(fallback);
let target = if bold { BOLD_WEIGHT } else { REGULAR_WEIGHT };
let held = reg.families[index].slots[slot].map(|old| (reg.loaded(old).weight - target).abs());
// Of a family's many weights, the slot keeps the one nearest what it stands for.
if held.is_none_or(|held| (weight - target).abs() < held) {
reg.families[index].slots[slot] = Some(id);
}
Ok(reg.families[index].name.clone())
}
/// The face to draw with: the family's nearest style, or Inter's when it is not installed.
pub fn style_face(family: Option<&str>, bold: bool, italic: bool) -> FaceId {
let inter = face_id(bold, italic);
let Some(name) = family else { return inter };
let reg = registry().read().unwrap();
let Some(f) = reg.family(name) else { return inter };
let slot = usize::from(inter);
[slot, slot & 2, slot & 1, 0].iter().find_map(|&s| f.slots[s]).or_else(|| f.slots.iter().flatten().next().copied()).unwrap_or(inter)
}
/// Installed family names, sorted.
pub fn families() -> Vec<String> {
let mut names: Vec<String> = registry().read().unwrap().families.iter().map(|f| f.name.clone()).collect();
names.sort_by_key(|n| n.to_lowercase());
names
}
/// A family's installed name, however `name` is spelled, with `(bold, italic)` for each style.
pub fn styles(name: &str) -> Option<(String, Vec<(bool, bool)>)> {
let reg = registry().read().unwrap();
let f = reg.family(name)?;
let styles = (0..4).filter(|&s| f.slots[s].is_some()).map(|s| (s & 1 != 0, s & 2 != 0)).collect();
Some((f.name.clone(), styles))
}
/// What a CSS `font-family` list asks for, its first family that can be drawn: `Some(None)` is
/// Inter, and `None` means none can, so the layer keeps its font.
pub fn pick(css: &str) -> Option<Option<String>> {
css.split(',').map(|f| f.trim().trim_matches(['"', '\''])).find_map(|f| {
if f.eq_ignore_ascii_case("inter") {
return Some(None);
}
styles(f).map(|(name, _)| Some(name))
})
}
/// Registers a font and keeps it in the project, when there is one.
pub fn install(bytes: &[u8], assets_dir: Option<&Path>) -> Result<String, String> {
let family = register(bytes)?;
if let Some(dir) = assets_dir {
save_to_dir(dir, std::slice::from_ref(&family)).map_err(|e| format!("could not save the font: {e}"))?;
}
Ok(family)
}
/// Registers the fonts in a project's `assets/` folder.
pub fn load_dir(assets_dir: &Path) {
let Ok(rd) = crate::vfs::read_dir(assets_dir.join(FONTS_DIR)) else {
return;
};
for e in rd.flatten() {
let loaded = crate::vfs::read(e.path()).map_err(|e| e.to_string()).and_then(|b| register(&b));
if let Err(err) = loaded {
log::warn!("{}: {err}", e.path().display());
}
}
}
/// Writes these families into a project's `assets/` folder (skipping files already there).
pub fn save_to_dir(assets_dir: &Path, families: &[String]) -> std::io::Result<()> {
let dir = assets_dir.join(FONTS_DIR);
for name in families {
let faces: Vec<&'static LoadedFace> = {
let reg = registry().read().unwrap();
let Some(f) = reg.family(name) else { continue };
f.slots.iter().flatten().map(|id| reg.loaded(*id)).collect()
};
for face in faces {
let path = dir.join(format!("{}.{}", face.hash, face.ext()));
if !crate::vfs::exists(&path) {
crate::vfs::create_dir_all(&dir)?;
crate::io::atomic::write(&path, face.bytes)?;
}
}
}
Ok(())
}
/// Font families a document's text names.
pub fn used_by(doc: &crate::model::Document) -> Vec<String> {
let mut out: Vec<String> = doc.nodes.values().filter_map(|n| n.text_style.font.clone()).collect();
out.sort();
out.dedup();
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{Color, TextAlign, TextStyle};
static VARELA: &[u8] = include_bytes!("../assets/fonts/VarelaRound-Regular.ttf");
fn styled(font: &str) -> TextStyle {
TextStyle { font: Some(font.into()), ..TextStyle::default() }
}
#[test]
fn registering_rejects_what_is_not_a_font_and_keeps_one_copy() {
assert!(register(b"<html>not a font</html>").is_err());
assert_eq!(register(crate::theme::INTER_REGULAR), Err("Inter is built in".into()));
let family = register(VARELA).unwrap();
assert_eq!(family, "Varela Round");
assert_eq!(register(VARELA).unwrap(), family);
assert_eq!(families().iter().filter(|f| **f == family).count(), 1);
}
#[test]
fn text_lays_out_in_its_family_and_falls_back_per_character() {
let family = register(VARELA).unwrap();
let wide = |font: &TextStyle| crate::text::layout_rich("Hamburg", 20.0, Color::INK, font, None, TextAlign::Left);
let custom = wide(&styled(&family));
assert!(custom.glyphs.iter().all(|g| g.face >= BUILT_IN_FACES));
assert_ne!(custom.width, wide(&TextStyle::default()).width);
let missing = crate::text::layout_rich("Ω", 20.0, Color::INK, &styled(&family), None, TextAlign::Left);
let glyph_lacking = crate::text::face_of(custom.glyphs[0].face).glyph_index('Ω').is_none();
assert_eq!(missing.glyphs[0].face < BUILT_IN_FACES, glyph_lacking);
}
#[test]
fn a_family_without_a_style_uses_its_nearest_and_an_unknown_one_inter() {
let family = register(VARELA).unwrap();
assert_eq!(style_face(Some(&family), true, true), style_face(Some(&family), false, false));
assert_eq!(style_face(Some("No Such Font"), true, false), face_id(true, false));
assert!(style_face(Some("varela round"), false, false) >= BUILT_IN_FACES);
}
#[test]
fn css_lists_take_the_first_family_that_can_be_drawn() {
let family = register(VARELA).unwrap();
assert_eq!(pick("'Nope', \"varela round\", serif"), Some(Some(family)));
assert_eq!(pick("Nope, Inter"), Some(None));
assert_eq!(pick("system-ui, sans-serif"), None);
}
#[test]
fn a_project_keeps_its_fonts_in_its_assets_folder() {
let family = register(VARELA).unwrap();
let dir = std::env::temp_dir().join(format!("papercraft-fonts-{}", std::process::id()));
install(VARELA, Some(&dir)).unwrap();
save_to_dir(&dir, std::slice::from_ref(&family)).unwrap();
let files: Vec<_> = std::fs::read_dir(dir.join(FONTS_DIR)).unwrap().flatten().collect();
assert_eq!(files.len(), 1);
assert_eq!(std::fs::read(files[0].path()).unwrap(), VARELA);
std::fs::remove_dir_all(&dir).unwrap();
}
}
+89
View File
@@ -0,0 +1,89 @@
//! Handoff points: a named snapshot of every page, taken when screens go to development, so a
//! later design edit can be reported against what the developers received. Kept in the project
//! folder under `handoffs/`, one file per name (marking a name again moves it).
use crate::model::Document;
use crate::project::packed;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
const DIR: &str = "handoffs";
#[derive(Serialize, Deserialize)]
pub struct Page {
pub id: String,
pub name: String,
pub doc: Document,
}
#[derive(Serialize, Deserialize)]
pub struct Handoff {
pub name: String,
/// Unix milliseconds.
pub saved: u64,
pub pages: Vec<Page>,
}
/// A handoff file without its pages: listing must not build every document.
#[derive(Deserialize)]
pub struct Header {
pub name: String,
pub saved: u64,
}
fn dir(project: &Path) -> PathBuf {
project.join(DIR)
}
/// The file stem of a name: letters and digits, the rest as `-`.
fn stem(name: &str) -> String {
let s: String = name.trim().chars().map(|c| if c.is_alphanumeric() { c.to_ascii_lowercase() } else { '-' }).collect();
let s = s.split('-').filter(|p| !p.is_empty()).collect::<Vec<_>>().join("-");
if s.is_empty() { "handoff".into() } else { s }
}
pub fn save(project: &Path, h: &Handoff) -> std::io::Result<()> {
crate::vfs::create_dir_all(dir(project))?;
let json = serde_json::to_string(h).map_err(std::io::Error::other)?;
packed::write(&packed::path(&dir(project), &stem(&h.name)), &json)
}
/// Every handoff point, newest first.
pub fn list(project: &Path) -> Vec<Header> {
let Ok(entries) = crate::vfs::read_dir(dir(project)) else { return Vec::new() };
let mut out: Vec<Header> = entries
.flatten()
.filter_map(|e| {
let id = packed::stem(e.file_name().to_str()?)?.to_owned();
serde_json::from_str(&packed::read(&packed::path(&dir(project), &id)).ok()?).ok()
})
.collect();
out.sort_by_key(|h| std::cmp::Reverse(h.saved));
out
}
pub fn load(project: &Path, name: &str) -> Result<Handoff, String> {
let json = packed::read(&packed::path(&dir(project), &stem(name))).map_err(|_| {
let known: Vec<String> = list(project).into_iter().map(|h| h.name).collect();
format!("no handoff point named `{name}` (known: {})", if known.is_empty() { "none, mark one with mark_handoff".into() } else { known.join(", ") })
})?;
serde_json::from_str(&json).map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_name_marked_again_moves_and_lists_once() {
let dir = std::env::temp_dir().join(format!("papercraft-handoffs-{:016x}", fastrand::u64(..)));
let h = |saved| Handoff { name: "Design v1".into(), saved, pages: vec![Page { id: "p".into(), name: "P".into(), doc: Document::default() }] };
save(&dir, &h(1)).unwrap();
save(&dir, &h(2)).unwrap();
let all = list(&dir);
assert_eq!((all.len(), all[0].saved), (1, 2));
assert_eq!(load(&dir, "design v1").unwrap().pages.len(), 1, "names match by their stem");
assert!(load(&dir, "v2").err().is_some_and(|e| e.contains("Design v1")));
std::fs::remove_dir_all(&dir).unwrap();
}
}
+1 -1
View File
@@ -37,7 +37,7 @@ pub fn is_zip(bytes: &[u8]) -> bool {
}
/// A relative path made only of normal components, so an archive cannot write outside `root`.
fn safe_relative(name: &str) -> Option<PathBuf> {
pub(crate) fn safe_relative(name: &str) -> Option<PathBuf> {
let p = Path::new(name);
p.components()
.all(|c| matches!(c, Component::Normal(_)))
+45 -46
View File
@@ -8,6 +8,11 @@ use super::pdf::{Insert, PdfPage};
use crate::editor::Editor;
use crate::geom::{Rect, V2, v2};
use crate::model::{Color, Document, Node, NodeId, NodeKind, TextAlign};
pub use words::Lang;
use words::{Noun, Words, fill};
pub(crate) mod unroll;
mod words;
/// A page of the project in the deck.
pub struct Section<'a> {
@@ -23,6 +28,8 @@ pub struct Meta {
pub author: Option<String>,
/// Unix milliseconds, for the date on the cover.
pub now_ms: u64,
/// The deck's own words; None: the language the screens are written in.
pub language: Option<Lang>,
}
const W: f64 = 1920.0;
@@ -52,8 +59,6 @@ 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`]
@@ -133,10 +138,10 @@ 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 {
fn tally(w: &Words, 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")) }
let screens = w.count(s.screens.len() - boards, Noun::Screen);
if boards == 0 { screens } else { format!("{screens} \u{b7} {}", w.count(boards, Noun::Board)) }
}
/// A design note shown on a slide.
@@ -373,7 +378,14 @@ fn cards_of(doc: &Document, board: NodeId) -> Option<Vec<NodeId>> {
/// 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 unrolled: Vec<Option<Document>> = sections.iter().map(|s| unroll::unrolled(s.doc, &s.screens)).collect();
let sections: Vec<Section> = sections
.iter()
.zip(&unrolled)
.filter(|(s, _)| !s.screens.is_empty())
.map(|(s, u)| Section { name: s.name.clone(), doc: u.as_ref().unwrap_or(s.doc), screens: s.screens.clone() })
.collect();
let sections: Vec<&Section> = sections.iter().collect();
let mut slides = vec![Slide::Cover, Slide::Contents];
let mut starts = Vec::new();
let mut appendix = Vec::new();
@@ -423,6 +435,7 @@ pub fn build(meta: &Meta, sections: &[Section]) -> Vec<u8> {
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 w = meta.language.unwrap_or_else(|| Lang::detect(sections.iter().map(|s| s.doc))).words();
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();
@@ -435,13 +448,13 @@ pub fn build(meta: &Meta, sections: &[Section]) -> Vec<u8> {
let mut overlay = None;
match slide {
Slide::Cover => {
t(&mut ed, f, "DESIGN PRESENTATION", 22.0, true, ACCENT, v2(MARGIN, 330.0));
t(&mut ed, f, w.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")));
let sub = meta.subtitle.clone().unwrap_or_else(|| format!("{} \u{b7} {}", w.count(screens, Noun::Screen), w.count(sections.len(), Noun::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),
Some(a) => format!("{} \u{b7} {a}", w.date(meta.now_ms)),
None => w.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).
@@ -459,15 +472,15 @@ pub fn build(meta: &Meta, sections: &[Section]) -> Vec<u8> {
}
}
Slide::Contents => {
t(&mut ed, f, "Contents", 56.0, true, INK, v2(MARGIN, MARGIN));
t(&mut ed, f, w.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));
t(&mut ed, f, &tally(w, 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)];
let extras = [("?", w.open_questions, questions_start), ("A", w.appendix_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));
@@ -478,7 +491,7 @@ pub fn build(meta: &Meta, sections: &[Section]) -> Vec<u8> {
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));
t(&mut ed, f, w.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;
@@ -493,7 +506,7 @@ pub fn build(meta: &Meta, sections: &[Section]) -> Vec<u8> {
}
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));
t(&mut ed, f, w.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;
@@ -503,19 +516,23 @@ pub fn build(meta: &Meta, sections: &[Section]) -> Vec<u8> {
}
}
Slide::NextSteps => {
t(&mut ed, f, "Next steps", 56.0, true, INK, v2(MARGIN, MARGIN));
t(&mut ed, f, w.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")));
steps.push((fill(w.answer, &[&w.count(asked, Noun::OpenQuestion)]), fill(w.answer_detail, &[&q.to_string()])));
}
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()));
let reviewed = if boards == 0 {
w.count(screens, Noun::Screen)
} else {
fill(w.and, &[&w.count(screens, Noun::Screen), &w.count(boards, Noun::Board)])
};
steps.push((fill(w.review, &[&reviewed]), w.review_detail.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")));
steps.push((w.check_states.to_owned(), fill(w.check_states_detail, &[&a.to_string()])));
}
if let Some(a) = &meta.author {
steps.push(("Send feedback".to_owned(), format!("To {a}")));
steps.push((w.send_feedback.to_owned(), fill(w.to, &[a])));
}
for (j, (step, detail)) in steps.iter().enumerate() {
let y = 260.0 + j as f64 * 130.0;
@@ -526,26 +543,26 @@ pub fn build(meta: &Meta, sections: &[Section]) -> Vec<u8> {
}
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));
t(&mut ed, f, w.appendix, 80.0, true, INK, v2(MARGIN, 500.0));
t(&mut ed, f, w.other_states, 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));
t(&mut ed, f, &tally(w, 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})");
title = format!("{title} ({})", fill(w.notes, &[&k.to_string(), &n.to_string()]));
}
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")));
aside.push(fill(w.in_appendix, &[&w.count(*states, Noun::State)]));
}
if let Some(b) = s.doc.world_rect(*id) {
aside.push(format!("{} \u{d7} {}", b.w.round(), b.h.round()));
@@ -624,9 +641,9 @@ pub fn build(meta: &Meta, sections: &[Section]) -> Vec<u8> {
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, w.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));
t(&mut ed, f, &format!("{} \u{b7} {}", w.count(screens, Noun::Screen), w.date(meta.now_ms)), 22.0, false, MUTED, v2(MARGIN, 570.0));
}
}
if k > 0 {
@@ -763,23 +780,5 @@ fn ask(line: &str) -> String {
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;
+30 -5
View File
@@ -56,7 +56,7 @@ fn notes_on_a_screen_number_its_layers_and_continue_on_a_second_slide() {
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] }]);
let pdf = build(&Meta { title: "T".into(), subtitle: None, author: None, now_ms: 0, language: None }, &[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");
}
@@ -67,7 +67,7 @@ fn a_page_opens_with_a_map_and_the_deck_closes_on_its_questions() {
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] }]);
let pdf = build(&Meta { title: "T".into(), subtitle: None, author: None, now_ms: 0, language: None }, &[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"));
}
@@ -118,8 +118,33 @@ fn questions_drop_the_team_markers() {
#[test]
fn dates_read_as_days() {
assert_eq!(date(0), "1 January 1970");
assert_eq!(date(1_793_145_600_000), "28 October 2026");
assert_eq!(Lang::En.words().date(0), "1 January 1970");
assert_eq!(Lang::En.words().date(1_793_145_600_000), "28 October 2026");
assert_eq!(Lang::Pt.words().date(1_793_145_600_000), "28 de outubro de 2026");
}
#[test]
fn the_deck_speaks_the_language_its_screens_are_written_in() {
let mut ed = Editor::default();
let screen = ed.add_node(Node::new(NodeId(0), NodeKind::Frame { clip: true }, Rect::new(0.0, 0.0, 390.0, 844.0)), None);
let text = |t: &str| Node::new(NodeId(0), NodeKind::Text { text: t.into(), font_size: 16.0, auto_width: true, align: Default::default() }, Rect::new(0.0, 0.0, 200.0, 20.0));
ed.add_node(text("Distribuição de alimentos para a sua comunidade"), Some(screen));
assert_eq!(Lang::detect([&ed.doc]), Lang::Pt);
assert_eq!(Lang::detect([&Editor::default().doc]), Lang::En, "nothing to go by");
// The deck's words are real text: the font's ToUnicode map lists what it drew.
let drawn = |language| {
let pdf = build(&Meta { title: "T".into(), subtitle: None, author: None, now_ms: 0, language }, &[Section { name: "P".into(), doc: &ed.doc, screens: vec![screen] }]);
pdf.windows(7)
.enumerate()
.filter(|(_, w)| *w == b"stream\n")
.filter_map(|(at, _)| miniz_oxide::inflate::decompress_to_vec_zlib(&pdf[at + 7..]).ok())
.map(|s| String::from_utf8_lossy(&s).into_owned())
.collect::<String>()
};
// Capitals the screen does not use: the cover's "APRESENTAÇÃO" and "PRÄSENTATION".
assert!(drawn(None).contains("<00C7>"), "the detected Portuguese");
assert!(!drawn(Some(Lang::En)).contains("<00C7>"), "English when asked for");
assert!(drawn(Some(Lang::De)).contains("<00C4>"), "German when asked for");
}
#[test]
@@ -149,7 +174,7 @@ fn deck_has_cover_contents_divider_screens_panels_and_end() {
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 meta = Meta { title: "Deck".into(), subtitle: None, author: None, now_ms: 0, language: None };
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.
+109
View File
@@ -0,0 +1,109 @@
//! Scrolling screens unrolled for print: paper cannot scroll, so each frame whose content
//! scrolls grows to show all of it and its screen grows with it, like a full-page capture.
//! Parts pinned to the bottom (a tab bar) follow the new bottom; what sits below a grown
//! screen on the canvas moves down so it stays clear of it.
use crate::editor::{Editor, scroll_range};
use crate::layout::resize_children;
use crate::model::{Constraint, Document, NodeId};
/// How close to the bottom edge a pinned bar sits.
const FLUSH: f64 = 1.0;
/// `doc` with the `screens` unrolled; None when none of them scrolls.
pub(crate) fn unrolled(doc: &Document, screens: &[NodeId]) -> Option<Document> {
let scrolls = |doc: &Document, s: NodeId| -> Vec<NodeId> {
let mut ids = doc.subtree(s);
// Innermost first: unrolling one grows its ancestors, not what is inside it.
ids.reverse();
ids.retain(|id| doc.get(*id).is_some_and(|n| n.scroll && n.visible) && scroll_range(doc, *id) > 0.0);
ids
};
if screens.iter().all(|s| scrolls(doc, *s).is_empty()) {
return None;
}
let mut ed = Editor::default();
ed.doc = doc.clone();
let mut order = screens.to_vec();
order.sort_by(|a, b| doc.get(*a).map(|n| n.rect.y).partial_cmp(&doc.get(*b).map(|n| n.rect.y)).unwrap_or(std::cmp::Ordering::Equal));
ed.edit("Unroll scrolling screens", |tx| {
for s in order {
let Some(before) = tx.doc().world_rect(s) else { continue };
for f in scrolls(tx.doc(), s) {
let grow = scroll_range(tx.doc(), f);
let Some(n) = tx.node_mut(f) else { continue };
n.scroll = false;
n.scroll_y = 0.0;
let mut cur = Some(f);
while let Some(id) = cur {
// A bar flush with the bottom edge is meant to stay there, however it is
// positioned (`top: 756px` on an 844 px screen).
let h = tx.doc().get(id).map_or(0.0, |n| n.rect.h);
let pinned: Vec<NodeId> = tx
.doc()
.get(id)
.map(|n| n.children.clone())
.unwrap_or_default()
.into_iter()
.filter(|k| tx.doc().get(*k).is_some_and(|c| c.layout_child.absolute && (c.rect.bottom() - h).abs() < FLUSH))
.collect();
for k in pinned {
if let Some(c) = tx.node_mut(k) {
c.layout_child.constraints.v = Constraint::Max;
}
}
let Some(n) = tx.node_mut(id) else { break };
let old = n.rect;
n.rect.h += grow;
let (new, parent) = (n.rect, n.parent);
resize_children(tx, id, old, new);
cur = parent.filter(|_| id != s);
}
}
let Some(after) = tx.doc().world_rect(s) else { continue };
let grew = after.h - before.h;
let below: Vec<NodeId> = tx
.doc()
.root
.iter()
.copied()
.filter(|id| *id != s && tx.doc().get(*id).is_some_and(|n| n.rect.y >= before.bottom() && n.rect.x < before.right() && n.rect.right() > before.x))
.collect();
for id in below {
if let Some(n) = tx.node_mut(id) {
n.rect.y += grew;
}
}
}
});
Some(ed.doc)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::geom::Rect;
use crate::model::{Node, NodeKind};
#[test]
fn a_scrolling_screen_shows_all_its_content_with_its_tab_bar_at_the_bottom() {
let mut ed = Editor::default();
let frame = |r: Rect| Node::new(NodeId(0), NodeKind::Frame { clip: true }, r);
let screen = ed.add_node(frame(Rect::new(0.0, 0.0, 390.0, 844.0)), None);
ed.doc.nodes.get_mut(&screen).unwrap().scroll = true;
ed.add_node(frame(Rect::new(0.0, 0.0, 390.0, 1500.0)), Some(screen));
let mut bar = frame(Rect::new(0.0, 764.0, 390.0, 80.0));
bar.layout_child.absolute = true;
let bar = ed.add_node(bar, Some(screen));
let below = ed.add_node(frame(Rect::new(0.0, 1484.0, 390.0, 844.0)), None);
let beside = ed.add_node(frame(Rect::new(1000.0, 1484.0, 390.0, 844.0)), None);
assert!(unrolled(&ed.doc, &[below]).is_none(), "nothing scrolls");
let doc = unrolled(&ed.doc, &[screen, below]).unwrap();
let rect = |id| doc.get(id).unwrap().rect;
assert_eq!(rect(screen).h, 1500.0);
assert!(!doc.get(screen).unwrap().scroll);
assert_eq!(rect(bar).bottom(), 1500.0, "pinned to the new bottom");
assert_eq!(rect(below).y, 1484.0 + 656.0, "moved clear of the grown screen");
assert_eq!(rect(beside).y, 1484.0, "not under it");
}
}
+304
View File
@@ -0,0 +1,304 @@
//! The deck's own words in the language the project is written in: detected from its text, or
//! named by the caller. Templates take their values for `{}` in order.
use crate::model::{Document, NodeKind};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Lang {
#[default]
En,
Pt,
Es,
Fr,
De,
It,
}
/// Things the deck counts.
#[derive(Clone, Copy)]
pub enum Noun {
Screen,
Board,
Page,
State,
OpenQuestion,
}
pub struct Words {
pub presentation: &'static str,
pub contents: &'static str,
pub at_a_glance: &'static str,
pub open_questions: &'static str,
pub next_steps: &'static str,
pub appendix: &'static str,
pub appendix_states: &'static str,
pub other_states: &'static str,
pub thank_you: &'static str,
pub answer: &'static str,
pub answer_detail: &'static str,
pub review: &'static str,
pub and: &'static str,
pub review_detail: &'static str,
pub check_states: &'static str,
pub check_states_detail: &'static str,
pub send_feedback: &'static str,
pub to: &'static str,
pub notes: &'static str,
pub in_appendix: &'static str,
/// Singular and plural of each [`Noun`], in its order.
nouns: [(&'static str, &'static str); 5],
months: [&'static str; 12],
/// Day, month name, year.
date: &'static str,
}
/// `template` with each `{}` replaced by the next of `values`.
pub fn fill(template: &str, values: &[&str]) -> String {
let mut out = String::new();
let mut rest = template;
for v in values {
let Some((head, tail)) = rest.split_once("{}") else { break };
out.push_str(head);
out.push_str(v);
rest = tail;
}
out.push_str(rest);
out
}
impl Words {
pub fn count(&self, n: usize, what: Noun) -> String {
let (one, many) = self.nouns[what as usize];
format!("{n} {}", if n == 1 { one } else { many })
}
/// The date of Unix milliseconds (UTC; civil-from-days), as the language writes it.
pub fn date(&self, 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);
fill(self.date, &[&d.to_string(), self.months[(m - 1) as usize], &y.to_string()])
}
}
impl Lang {
/// `en`, `pt`, `pt-BR`, `es`, `fr`, `de` or `it`.
pub fn parse(code: &str) -> Option<Lang> {
let base = code.split(['-', '_']).next()?.to_ascii_lowercase();
Some(match base.as_str() {
"en" => Lang::En,
"pt" => Lang::Pt,
"es" => Lang::Es,
"fr" => Lang::Fr,
"de" => Lang::De,
"it" => Lang::It,
_ => return None,
})
}
/// The language most of the documents' text is written in; English when nothing stands out.
pub fn detect<'a>(docs: impl IntoIterator<Item = &'a Document>) -> Lang {
// Common words, and letters only some of the languages use.
const CUES: [(Lang, &[&str], &str); 6] = [
(Lang::En, &["the", "and", "of", "to", "is", "with", "for", "your", "you", "this", "on"], ""),
(Lang::Pt, &["de", "da", "do", "que", "para", "com", "não", "uma", "os", "das", "dos", "em", "você", "seu", "sua"], "ãõç"),
(Lang::Es, &["de", "la", "el", "que", "en", "los", "para", "con", "una", "del", "las", "tu", "su"], "ñ¿¡"),
(Lang::Fr, &["le", "la", "les", "de", "des", "et", "pour", "avec", "une", "du", "vous", "est", "votre"], "èêëœ"),
(Lang::De, &["der", "die", "und", "das", "mit", "für", "ist", "ein", "eine", "nicht", "zu", "den", "ihre"], "äöüß"),
(Lang::It, &["il", "di", "che", "per", "con", "una", "gli", "della", "del", "non", "le", "sono"], "ìòù"),
];
const LETTER_WEIGHT: usize = 3;
let mut score = [0usize; 6];
for doc in docs {
for n in doc.nodes.values() {
let (NodeKind::Text { text, .. } | NodeKind::Sticky { text, .. }) = &n.kind else { continue };
let lower = text.to_lowercase();
for word in lower.split(|c: char| !c.is_alphabetic()).filter(|w| !w.is_empty()) {
for (i, (_, words, _)) in CUES.iter().enumerate() {
score[i] += usize::from(words.contains(&word));
}
}
for c in lower.chars() {
for (i, (_, _, letters)) in CUES.iter().enumerate() {
score[i] += LETTER_WEIGHT * usize::from(letters.contains(c));
}
}
}
}
let best = (0..CUES.len()).max_by_key(|i| (score[*i], std::cmp::Reverse(*i))).unwrap_or(0);
if score[best] == 0 { Lang::En } else { CUES[best].0 }
}
pub fn words(self) -> &'static Words {
match self {
Lang::En => &EN,
Lang::Pt => &PT,
Lang::Es => &ES,
Lang::Fr => &FR,
Lang::De => &DE,
Lang::It => &IT,
}
}
}
static EN: Words = Words {
presentation: "DESIGN PRESENTATION",
contents: "Contents",
at_a_glance: "At a glance",
open_questions: "Open questions",
next_steps: "Next steps",
appendix: "Appendix",
appendix_states: "Appendix \u{b7} states",
other_states: "Other states of the screens",
thank_you: "Thank you",
answer: "Answer {}",
answer_detail: "From the notes on the screens; slide {} on",
review: "Review the {}",
and: "{} and {}",
review_detail: "Feedback on a screen by its name, as titled on its slide",
check_states: "Check the other states",
check_states_detail: "The states kept out of the main flow; appendix, slide {} on",
send_feedback: "Send feedback",
to: "To {}",
notes: "notes {}/{}",
in_appendix: "+ {} in the appendix",
nouns: [("screen", "screens"), ("board", "boards"), ("page", "pages"), ("state", "states"), ("open question", "open questions")],
months: ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"],
date: "{} {} {}",
};
static PT: Words = Words {
presentation: "APRESENTA\u{c7}\u{c3}O DE DESIGN",
contents: "Sum\u{e1}rio",
at_a_glance: "Vis\u{e3}o geral",
open_questions: "Perguntas em aberto",
next_steps: "Pr\u{f3}ximos passos",
appendix: "Ap\u{ea}ndice",
appendix_states: "Ap\u{ea}ndice \u{b7} estados",
other_states: "Outros estados das telas",
thank_you: "Obrigado",
answer: "Responder {}",
answer_detail: "Das notas nas telas; a partir do slide {}",
review: "Revisar {}",
and: "{} e {}",
review_detail: "Coment\u{e1}rios sobre uma tela pelo nome, como no t\u{ed}tulo do slide",
check_states: "Ver os outros estados",
check_states_detail: "Os estados fora do fluxo principal; ap\u{ea}ndice, a partir do slide {}",
send_feedback: "Enviar coment\u{e1}rios",
to: "Para {}",
notes: "notas {}/{}",
in_appendix: "+ {} no ap\u{ea}ndice",
nouns: [("tela", "telas"), ("quadro", "quadros"), ("p\u{e1}gina", "p\u{e1}ginas"), ("estado", "estados"), ("pergunta em aberto", "perguntas em aberto")],
months: ["janeiro", "fevereiro", "mar\u{e7}o", "abril", "maio", "junho", "julho", "agosto", "setembro", "outubro", "novembro", "dezembro"],
date: "{} de {} de {}",
};
static ES: Words = Words {
presentation: "PRESENTACI\u{d3}N DE DISE\u{d1}O",
contents: "\u{cd}ndice",
at_a_glance: "De un vistazo",
open_questions: "Preguntas abiertas",
next_steps: "Pr\u{f3}ximos pasos",
appendix: "Ap\u{e9}ndice",
appendix_states: "Ap\u{e9}ndice \u{b7} estados",
other_states: "Otros estados de las pantallas",
thank_you: "Gracias",
answer: "Responder {}",
answer_detail: "De las notas en las pantallas; desde la diapositiva {}",
review: "Revisar {}",
and: "{} y {}",
review_detail: "Comentarios sobre una pantalla por su nombre, como se titula en su diapositiva",
check_states: "Revisar los otros estados",
check_states_detail: "Los estados fuera del flujo principal; ap\u{e9}ndice, desde la diapositiva {}",
send_feedback: "Enviar comentarios",
to: "Para {}",
notes: "notas {}/{}",
in_appendix: "+ {} en el ap\u{e9}ndice",
nouns: [("pantalla", "pantallas"), ("tablero", "tableros"), ("p\u{e1}gina", "p\u{e1}ginas"), ("estado", "estados"), ("pregunta abierta", "preguntas abiertas")],
months: ["enero", "febrero", "marzo", "abril", "mayo", "junio", "julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre"],
date: "{} de {} de {}",
};
static FR: Words = Words {
presentation: "PR\u{c9}SENTATION DESIGN",
contents: "Sommaire",
at_a_glance: "En bref",
open_questions: "Questions ouvertes",
next_steps: "Prochaines \u{e9}tapes",
appendix: "Annexe",
appendix_states: "Annexe \u{b7} \u{e9}tats",
other_states: "Autres \u{e9}tats des \u{e9}crans",
thank_you: "Merci",
answer: "R\u{e9}pondre \u{e0} {}",
answer_detail: "D\u{2019}apr\u{e8}s les notes des \u{e9}crans ; \u{e0} partir de la diapositive {}",
review: "Relire {}",
and: "{} et {}",
review_detail: "Vos retours sur un \u{e9}cran par son nom, tel qu\u{2019}il est titr\u{e9} sur sa diapositive",
check_states: "Voir les autres \u{e9}tats",
check_states_detail: "Les \u{e9}tats hors du parcours principal ; annexe, \u{e0} partir de la diapositive {}",
send_feedback: "Envoyer vos retours",
to: "\u{c0} {}",
notes: "notes {}/{}",
in_appendix: "+ {} en annexe",
nouns: [("\u{e9}cran", "\u{e9}crans"), ("tableau", "tableaux"), ("page", "pages"), ("\u{e9}tat", "\u{e9}tats"), ("question ouverte", "questions ouvertes")],
months: ["janvier", "f\u{e9}vrier", "mars", "avril", "mai", "juin", "juillet", "ao\u{fb}t", "septembre", "octobre", "novembre", "d\u{e9}cembre"],
date: "{} {} {}",
};
static DE: Words = Words {
presentation: "DESIGN-PR\u{c4}SENTATION",
contents: "Inhalt",
at_a_glance: "Auf einen Blick",
open_questions: "Offene Fragen",
next_steps: "N\u{e4}chste Schritte",
appendix: "Anhang",
appendix_states: "Anhang \u{b7} Zust\u{e4}nde",
other_states: "Weitere Zust\u{e4}nde der Screens",
thank_you: "Vielen Dank",
answer: "{} beantworten",
answer_detail: "Aus den Notizen auf den Screens; ab Folie {}",
review: "{} pr\u{fc}fen",
and: "{} und {}",
review_detail: "Feedback zu einem Screen mit seinem Namen, wie auf seiner Folie betitelt",
check_states: "Die weiteren Zust\u{e4}nde ansehen",
check_states_detail: "Die Zust\u{e4}nde au\u{df}erhalb des Hauptablaufs; Anhang, ab Folie {}",
send_feedback: "Feedback senden",
to: "An {}",
notes: "Notizen {}/{}",
in_appendix: "+ {} im Anhang",
nouns: [("Screen", "Screens"), ("Board", "Boards"), ("Seite", "Seiten"), ("Zustand", "Zust\u{e4}nde"), ("offene Frage", "offene Fragen")],
months: ["Januar", "Februar", "M\u{e4}rz", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"],
date: "{}. {} {}",
};
static IT: Words = Words {
presentation: "PRESENTAZIONE DI DESIGN",
contents: "Indice",
at_a_glance: "In sintesi",
open_questions: "Domande aperte",
next_steps: "Prossimi passi",
appendix: "Appendice",
appendix_states: "Appendice \u{b7} stati",
other_states: "Altri stati delle schermate",
thank_you: "Grazie",
answer: "Rispondere a {}",
answer_detail: "Dalle note sulle schermate; dalla slide {}",
review: "Rivedere {}",
and: "{} e {}",
review_detail: "Feedback su una schermata con il suo nome, come nel titolo della slide",
check_states: "Controllare gli altri stati",
check_states_detail: "Gli stati fuori dal flusso principale; appendice, dalla slide {}",
send_feedback: "Inviare feedback",
to: "A {}",
notes: "note {}/{}",
in_appendix: "+ {} in appendice",
nouns: [("schermata", "schermate"), ("board", "board"), ("pagina", "pagine"), ("stato", "stati"), ("domanda aperta", "domande aperte")],
months: ["gennaio", "febbraio", "marzo", "aprile", "maggio", "giugno", "luglio", "agosto", "settembre", "ottobre", "novembre", "dicembre"],
date: "{} {} {}",
};
+21 -5
View File
@@ -474,8 +474,8 @@ fn build(mut imp: Imported) -> (Document, Vec<NodeId>) {
let id = tx.alloc_id();
n.id = id;
tx.insert(n, None, None);
order.push((eid.clone(), id));
ids.insert(eid, id);
order.push(id);
}
// Frames: frame children (listed below the frame in Excalidraw) move inside it.
for (eid, (_, frame)) in &imp.nesting {
@@ -487,10 +487,7 @@ fn build(mut imp: Imported) -> (Document, Vec<NodeId>) {
}
// Groups, outermost first so inner groups nest inside outer ones.
let mut group_nodes: HashMap<String, NodeId> = HashMap::new();
for id in &order {
let Some((eid, _)) = ids.iter().find(|(_, v)| *v == id) else {
continue;
};
for (eid, id) in &order {
let (groups, _) = imp.nesting[eid].clone();
let mut parent = tx.doc().get(*id).and_then(|n| n.parent);
for g in groups.iter().rev() {
@@ -654,6 +651,25 @@ mod tests {
assert!(!frame.children.is_empty());
}
#[test]
fn imports_thousands_of_grouped_elements() {
let els: Vec<String> = (0..3000)
.map(|i| {
format!(
r#"{{"id":"r{i}","type":"rectangle","x":{x},"y":0,"width":10,"height":10,"groupIds":["g{g}"]}}"#,
x = i * 12,
g = i / 3
)
})
.collect();
let json = format!(r#"{{"type":"excalidraw","elements":[{}]}}"#, els.join(","));
let doc = import_scene(&json).unwrap();
let rects = doc.nodes.values().filter(|n| n.kind.type_name() == "Rectangle").count();
let groups = doc.nodes.values().filter(|n| n.kind.type_name() == "Group").count();
assert_eq!((rects, groups), (3000, 1000));
doc.validate().unwrap();
}
#[test]
fn imports_the_library_fixture() {
let json = include_str!("../../tests/fixtures/excalidraw/library.excalidrawlib");
+1
View File
@@ -184,6 +184,7 @@ pub fn presentation(items: &[Item], title: &str) -> Vec<u8> {
subtitle: None,
author: None,
now_ms: crate::project::format::now_ms(),
language: None,
};
crate::io::deck::build(&meta, &sections)
}
+39 -3
View File
@@ -20,6 +20,9 @@ const END_LEN: usize = 22;
const METHOD_STORED: u16 = 0;
const METHOD_DEFLATE: u16 = 8;
const MAX_ENTRY: usize = 256 << 20;
/// Largest total inflated across the wanted entries of one archive; entries may overlap in the
/// file, so the per-entry limit alone does not bound memory.
const MAX_ZIP_TOTAL: usize = 1 << 30;
const ZSTD_MAGIC: [u8; 4] = [0x28, 0xB5, 0x2F, 0xFD];
// ---------------------------------------------------------------------------------- detection
@@ -99,10 +102,21 @@ fn zip_has_entry(bytes: &[u8], target: &str) -> bool {
false
}
/// Canvas bytes, the file name from `meta.json`, and the `images/*` entries.
type FigZip = (Vec<u8>, Option<String>, HashMap<String, Vec<u8>>);
/// Extract `canvas.fig`, optional `meta.json`, and any `images/*` entries from a `.fig` ZIP.
fn read_fig_zip(
bytes: &[u8],
) -> Result<(Vec<u8>, Option<String>, HashMap<String, Vec<u8>>), String> {
) -> Result<FigZip, String> {
read_fig_zip_capped(bytes, MAX_ZIP_TOTAL)
}
fn read_fig_zip_capped(
bytes: &[u8],
max_total: usize,
) -> Result<FigZip, String> {
let mut total = 0usize;
let end = (0..=bytes.len().saturating_sub(END_LEN))
.rev()
.find(|&i| u32_at(bytes, i) == Some(END_SIG))
@@ -138,12 +152,23 @@ fn read_fig_zip(
let l_extra = u16_at(bytes, local + 28).ok_or("truncated zip")? as usize;
let start = local + LOCAL_LEN + l_name + l_extra;
let raw = bytes.get(start..start + csize).ok_or("truncated zip")?;
let budget = max_total - total;
let too_big = || format!("{name}: archive expands past {} MiB", max_total >> 20);
let data = match method {
METHOD_STORED if raw.len() > budget => return Err(too_big()),
METHOD_STORED => raw.to_vec(),
METHOD_DEFLATE => miniz_oxide::inflate::decompress_to_vec_with_limit(raw, MAX_ENTRY)
.map_err(|e| format!("cannot inflate {name}: {e:?}"))?,
METHOD_DEFLATE => {
miniz_oxide::inflate::decompress_to_vec_with_limit(raw, MAX_ENTRY.min(budget))
.map_err(|e| match e.status {
miniz_oxide::inflate::TINFLStatus::HasMoreOutput if budget < MAX_ENTRY => {
too_big()
}
_ => format!("cannot inflate {name}: {:?}", e.status),
})?
}
m => return Err(format!("{name}: unsupported zip compression method {m}")),
};
total += data.len();
if name == "canvas.fig" {
canvas = Some(data);
} else if name == "meta.json" {
@@ -2162,6 +2187,17 @@ fn parse_svg_path_data(d: &str) -> Option<VPath> {
mod tests {
use super::*;
#[test]
fn fig_zip_refuses_entries_past_the_total_cap() {
let z = crate::io::zip::write(&[
("canvas.fig".into(), vec![1; 600]),
("images/a".into(), vec![2; 600]),
]);
let err = read_fig_zip_capped(&z, 1000).unwrap_err();
assert!(err.contains("expands past"), "{err}");
assert_eq!(read_fig_zip_capped(&z, 1200).unwrap().2.len(), 1);
}
#[test]
fn rest_json_imports_frames_autolayout_shapes_and_text() {
let json = r#"{
+52 -10
View File
@@ -3,17 +3,19 @@
//! One PDF page per exported area (a selection, each frame, or a whole page). Shapes, strokes
//! and glyph outlines are vector paths; gradients are axial/radial shadings; images are
//! deflated RGB XObjects with an alpha soft mask; opacity and all 16 blend modes go through
//! ExtGState; clipping frames clip. Text is outlined (not selectable), as in the SVG export.
//! ExtGState; clipping frames clip. Text is real text in the embedded Inter faces (`font`), so
//! it can be selected, searched and edited.
use crate::assets;
use crate::geom::{Rect, V2};
use crate::model::{BlendMode, Cap, Color, Document, Join, NodeId, NodeKind, Stop};
use crate::render::draw::{self, DrawOp, PaintSpec};
use crate::text;
use crate::vector::{Seg, VPath};
use std::collections::HashMap;
use std::fmt::Write;
mod font;
/// One page of output: what to draw and the area it covers.
pub struct PdfPage<'a> {
pub doc: &'a Document,
@@ -101,10 +103,12 @@ struct Res {
gstate_objs: Vec<(String, usize)>,
shadings: Vec<(String, usize)>,
images: HashMap<String, (String, usize)>,
fonts: std::collections::BTreeMap<String, usize>,
}
struct PageWriter<'a> {
objs: &'a mut Objects,
fonts: &'a mut font::Fonts,
res: Res,
out: String,
/// While drawing an insert: its source area. Layers outside it are left out of the file.
@@ -420,13 +424,35 @@ impl PageWriter<'_> {
color,
glyphs,
} => {
let mut p = VPath::new();
for (gid, o) in glyphs {
p.append(&text::glyph_path(*face, *gid, *o, *size));
}
let path = self.emit_path(&p);
let (name, id) = self.fonts.name(self.objs, *face, glyphs.iter().map(|g| g.0));
self.res.fonts.insert(name.clone(), id);
let gs = self.gstate(color[3], 1.0, blend);
let _ = writeln!(self.out, "q /{gs} gs {} rg {path}f\nQ", Self::rgb(*color));
let _ = write!(self.out, "q /{gs} gs {} rg BT /{name} {} Tf", Self::rgb(*color), num(*size));
// One text matrix per baseline (it flips y back upright), then each glyph
// nudged from where the font's advance would put it to where layout did.
let mut pen: Option<V2> = None;
for (gid, o) in glyphs {
match pen {
Some(p) if (p.y - o.y).abs() < 0.01 => {
let adj = (p.x - o.x) * 1000.0 / size;
if adj.abs() > 0.05 {
let _ = write!(self.out, " {}", num(adj));
}
}
_ => {
if pen.is_some() {
self.out.push_str("] TJ");
}
let _ = write!(self.out, " 1 0 0 -1 {} {} Tm [", num(o.x), num(o.y));
}
}
let _ = write!(self.out, "<{gid:04X}>");
pen = Some(V2 { x: o.x + font::advance(*face, *gid, *size), y: o.y });
}
if pen.is_some() {
self.out.push_str("] TJ");
}
self.out.push_str(" ET Q\n");
}
}
}
@@ -471,10 +497,12 @@ pub fn export(pages: &[PdfPage]) -> Vec<u8> {
let catalog = objs.reserve();
let pages_id = objs.reserve();
let mut kids = Vec::new();
let mut fonts = font::Fonts::default();
for pg in pages {
let b = pg.bounds;
let mut w = PageWriter {
objs: &mut objs,
fonts: &mut fonts,
res: Res::default(),
out: String::new(),
cull: None,
@@ -533,19 +561,22 @@ pub fn export(pages: &[PdfPage]) -> Vec<u8> {
.join(" ")
};
let images: Vec<(String, usize)> = res.images.values().cloned().collect();
let fonts_of_page: Vec<(String, usize)> = res.fonts.into_iter().collect();
let page = objs.add(
format!(
"<< /Type /Page /Parent {pages_id} 0 R /MediaBox [0 0 {} {}] /Contents {content_id} 0 R /Resources << /ExtGState << {} >> /Shading << {} >> /XObject << {} >> >> >>",
"<< /Type /Page /Parent {pages_id} 0 R /MediaBox [0 0 {} {}] /Contents {content_id} 0 R /Resources << /ExtGState << {} >> /Shading << {} >> /XObject << {} >> /Font << {} >> >> >>",
num(b.w),
num(b.h),
dict(&res.gstate_objs),
dict(&res.shadings),
dict(&images)
dict(&images),
dict(&fonts_of_page)
)
.into_bytes(),
);
kids.push(page);
}
fonts.finish(&mut objs);
objs.set(
catalog,
format!("<< /Type /Catalog /Pages {pages_id} 0 R >>").into_bytes(),
@@ -660,6 +691,17 @@ mod tests {
assert!(pdf[first_off..].starts_with(b"1 0 obj"));
}
#[test]
fn text_is_written_as_text_in_an_embedded_font() {
use crate::model::{Node, NodeKind};
let mut ed = crate::editor::Editor::default();
let t = ed.add_node(Node::new(NodeId(0), NodeKind::Text { text: "Olá".into(), font_size: 16.0, auto_width: true, align: Default::default() }, Rect::new(0.0, 0.0, 200.0, 40.0)), None);
let pdf = export(&pages_for(&ed.doc, Some(&[t]), false, None));
let s = String::from_utf8_lossy(&pdf);
assert!(s.contains("/Subtype /Type0") && s.contains("/ToUnicode") && s.contains("/FontFile2"), "embedded font");
assert_eq!(s.matches("/Subtype /Type0").count(), 1, "one font object per face");
}
#[test]
fn an_insert_far_from_the_origin_lands_where_it_is_placed() {
use crate::geom::Rect;
+137
View File
@@ -0,0 +1,137 @@
//! Text as real PDF text: each face a text uses is embedded once per file (a Type0 font
//! over a CIDFontType2, glyph ids as character codes) with a ToUnicode map, so a reader can
//! select, search, copy and edit it in any language the font covers.
use super::{Objects, num};
use crate::text::{self, FaceId};
use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write;
/// The faces a file uses: the object id reserved for each and the glyphs drawn with it.
#[derive(Default)]
pub(super) struct Fonts {
used: BTreeMap<FaceId, (usize, BTreeSet<u16>)>,
}
/// PDF glyph space: 1000 units to the em.
const EM: f64 = 1000.0;
impl Fonts {
/// The resource name of `face` (`/F<face>`), its object reserved on first use.
pub(super) fn name(&mut self, objs: &mut Objects, face: FaceId, gids: impl IntoIterator<Item = u16>) -> (String, usize) {
let (id, set) = self.used.entry(face).or_insert_with(|| (objs.reserve(), BTreeSet::new()));
set.extend(gids);
(format!("F{face}"), *id)
}
/// Writes every font used, once all pages are drawn.
pub(super) fn finish(self, objs: &mut Objects) {
for (face, (id, gids)) in self.used {
let f = text::face_of(face);
let bytes = text::face_bytes(face);
let k = EM / f64::from(f.units_per_em());
let name = f
.names()
.into_iter()
.find(|n| n.name_id == ttf_parser::name_id::POST_SCRIPT_NAME)
.and_then(|n| n.to_string())
.filter(|n| n.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'))
.unwrap_or_else(|| format!("Font{face}"));
// OpenType outlines are embedded whole; their glyph ids are not TrueType's map.
let cff = f.tables().cff.is_some();
let (file, file_key, cid_font) = if cff {
(objs.stream("/Subtype /OpenType", bytes), "FontFile3", "/CIDFontType0")
} else {
(objs.stream(&format!("/Length1 {}", bytes.len()), bytes), "FontFile2", "/CIDFontType2 /CIDToGIDMap /Identity")
};
let b = f.global_bounding_box();
let descriptor = objs.add(
format!(
"<< /Type /FontDescriptor /FontName /{name} /Flags {} /FontBBox [{} {} {} {}] /ItalicAngle {} /Ascent {} /Descent {} /CapHeight {} /StemV 80 /{file_key} {file} 0 R >>",
if f.is_italic() { 32 | 64 } else { 32 },
num(f64::from(b.x_min) * k),
num(f64::from(b.y_min) * k),
num(f64::from(b.x_max) * k),
num(f64::from(b.y_max) * k),
num(f.italic_angle().into()),
num(f64::from(f.ascender()) * k),
num(f64::from(f.descender()) * k),
num(f64::from(f.capital_height().unwrap_or(f.ascender())) * k),
)
.into_bytes(),
);
let widths: String = gids
.iter()
.map(|g| format!("{g} [{}]", num(f64::from(f.glyph_hor_advance(ttf_parser::GlyphId(*g)).unwrap_or(0)) * k)))
.collect::<Vec<_>>()
.join(" ");
let cid = objs.add(
format!(
"<< /Type /Font /Subtype {cid_font} /BaseFont /{name} /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /FontDescriptor {descriptor} 0 R /W [{widths}] >>"
)
.into_bytes(),
);
let to_unicode = objs.stream("", to_unicode(f, &gids).as_bytes());
objs.set(
id,
format!("<< /Type /Font /Subtype /Type0 /BaseFont /{name} /Encoding /Identity-H /DescendantFonts [{cid} 0 R] /ToUnicode {to_unicode} 0 R >>")
.into_bytes(),
);
}
}
}
/// Glyph advance in points at `size`.
pub(super) fn advance(face: FaceId, gid: u16, size: f64) -> f64 {
let f = text::face_of(face);
f64::from(f.glyph_hor_advance(ttf_parser::GlyphId(gid)).unwrap_or(0)) * size / f64::from(f.units_per_em())
}
/// The character each glyph stands for, read back from the font's own character map (the
/// lowest code point when several share a glyph).
fn chars_of(f: &ttf_parser::Face, gids: &BTreeSet<u16>) -> BTreeMap<u16, char> {
let mut out = BTreeMap::new();
for t in f.tables().cmap.into_iter().flat_map(|c| c.subtables).filter(|t| t.is_unicode()) {
t.codepoints(|cp| {
let (Some(g), Some(c)) = (t.glyph_index(cp), char::from_u32(cp)) else { return };
if gids.contains(&g.0) && out.get(&g.0).is_none_or(|old: &char| c < *old) {
out.insert(g.0, c);
}
});
}
out
}
fn to_unicode(f: &ttf_parser::Face, gids: &BTreeSet<u16>) -> String {
let map = chars_of(f, gids);
let mut s = String::from(
"/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n/CMapName /Adobe-Identity-UCS def\n/CMapType 2 def\n1 begincodespacerange\n<0000> <FFFF>\nendcodespacerange\n",
);
// A bfchar block holds at most 100 entries.
let entries: Vec<(u16, char)> = map.into_iter().collect();
for chunk in entries.chunks(100) {
let _ = writeln!(s, "{} beginbfchar", chunk.len());
for (g, c) in chunk {
let utf16: String = c.encode_utf16(&mut [0; 2]).iter().map(|u| format!("{u:04X}")).collect();
let _ = writeln!(s, "<{g:04X}> <{utf16}>");
}
s.push_str("endbfchar\n");
}
s.push_str("endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n");
s
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn text_in_any_language_the_font_covers_maps_back_to_its_characters() {
let f = text::face_of(0);
let s = "Ação, saída! Привет Ωμέγα";
let gids: BTreeSet<u16> = s.chars().filter_map(|c| f.glyph_index(c)).map(|g| g.0).collect();
let back: BTreeSet<char> = chars_of(f, &gids).into_values().collect();
assert!(s.chars().all(|c| back.contains(&c)), "{back:?}");
assert!(to_unicode(f, &gids).contains(&format!("<{:04X}>", 'ç' as u32)));
}
}
+42 -4
View File
@@ -217,12 +217,13 @@ fn walk(g: &usvg::Group, opacity: f32, out: &mut Vec<Node>) {
fn raster(node: &usvg::Node, g: &usvg::Group, opacity: f32) -> Option<Node> {
use resvg::tiny_skia::{Pixmap, Transform};
let b = node.abs_layer_bounding_box()?;
let (w, h) = ((b.width() * RASTER_SCALE).ceil() as u32, (b.height() * RASTER_SCALE).ceil() as u32);
let mut pixmap = Pixmap::new(w.max(1), h.max(1))?;
// A huge group would ask for gigabytes of pixels: lower the scale until it fits the budget.
let (scale, w, h) = raster_size(b.width(), b.height());
let mut pixmap = Pixmap::new(w, h)?;
// render_node applies the group's own transform after the root one and shifts by the bounds,
// so the root carries the parent's absolute transform, between that shift and its undo.
let parent = g.abs_transform().pre_concat(g.transform().invert()?);
let root = Transform::from_scale(RASTER_SCALE, RASTER_SCALE).pre_translate(-b.x(), -b.y()).pre_concat(parent).pre_translate(b.x(), b.y());
let root = Transform::from_scale(scale, scale).pre_translate(-b.x(), -b.y()).pre_concat(parent).pre_translate(b.x(), b.y());
resvg::render_node(node, root, &mut pixmap.as_mut())?;
let asset = crate::assets::register(&pixmap.encode_png().ok()?).ok()?;
let mut n = Node::new(NodeId(0), NodeKind::Rect, Rect::new(b.x() as f64, b.y() as f64, b.width() as f64, b.height() as f64));
@@ -232,13 +233,28 @@ fn raster(node: &usvg::Node, g: &usvg::Group, opacity: f32) -> Option<Node> {
Some(n)
}
/// The scale and pixel size a group of this size is drawn at: capped by total pixels and by each
/// side, since a very long thin group still rounds every side up to a whole pixel.
fn raster_size(width: f32, height: f32) -> (f32, u32, u32) {
let scale = RASTER_SCALE
.min((MAX_RASTER_PIXELS / (width * height)).sqrt())
.min(MAX_RASTER_SIDE / width)
.min(MAX_RASTER_SIDE / height);
let side = |v: f32| ((v * scale).ceil().clamp(1.0, MAX_RASTER_SIDE)) as u32;
(scale, side(width), side(height))
}
/// Most pixels along one side of a masked group's image.
const MAX_RASTER_SIDE: f32 = 16_384.0;
/// Pixels per SVG unit for a masked group drawn as an image: sharp at 3x zoom.
const RASTER_SCALE: f32 = 3.0;
/// Most pixels a masked group's image may have (64 MB of RGBA).
const MAX_RASTER_PIXELS: f32 = 16_000_000.0;
fn options() -> usvg::Options<'static> {
let mut o = usvg::Options::default();
let db = o.fontdb_mut();
for i in 0..4u8 {
for i in 0..crate::text::BUILT_IN_FACES {
db.load_font_data(crate::text::face_bytes(i).to_vec());
}
db.load_font_data(crate::theme::INTER_MEDIUM.to_vec());
@@ -309,6 +325,14 @@ mod tests {
<text x="20" y="100" font-size="20" fill="#333">Hello</text>
</svg>"##;
#[test]
fn raster_size_caps_each_side_not_only_the_total() {
let (scale, w, h) = raster_size(4e8, 0.01);
assert!(w <= 16_384 && h >= 1, "{w}x{h} at {scale}");
assert!((w as u64) * (h as u64) < 20_000_000);
assert_eq!(raster_size(100.0, 50.0), (RASTER_SCALE, 300, 150));
}
#[test]
fn imports_shapes_gradients_strokes_and_text() {
let nodes = parse(SVG).unwrap();
@@ -333,6 +357,20 @@ mod tests {
assert_eq!(doc.root.len(), 1);
}
#[test]
fn a_huge_masked_group_is_drawn_at_a_capped_size() {
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<mask id="m"><rect width="60000" height="60000" fill="#fff"/></mask>
<g mask="url(#m)"><rect width="60000" height="60000" fill="#f00"/></g>
</svg>"##;
let nodes = parse(svg).unwrap();
let Some(Paint::Image { asset, .. }) = nodes.iter().find_map(|n| n.style.fills.first()) else {
panic!("masked group was not rastered");
};
let img = crate::assets::get(asset).unwrap();
assert!(u64::from(img.width) * u64::from(img.height) <= 16_100_000, "{}x{}", img.width, img.height);
}
#[test]
fn roundtrips_our_own_svg_export() {
let ed = crate::demo::showcase();
+79 -2
View File
@@ -20,6 +20,9 @@ const METHOD_DEFLATE: u16 = 8;
const DOS_DATE: u16 = 0x0021;
/// Largest entry the reader inflates, against zip bombs.
const MAX_ENTRY: usize = 512 << 20;
/// Largest total the reader inflates across all entries: entries may overlap in the file, so
/// per-entry limits alone let a small archive expand to many times its size.
const MAX_TOTAL: usize = 1 << 30;
pub type Entry = (String, Vec<u8>);
@@ -99,6 +102,12 @@ fn u32_at(b: &[u8], i: usize) -> Result<u32, String> {
/// Every file in the archive (folders are skipped), verifying CRCs.
pub fn read(bytes: &[u8]) -> Result<Vec<Entry>, String> {
read_capped(bytes, MAX_TOTAL)
}
/// [`read`] with a smaller cap on the total inflated size.
pub fn read_capped(bytes: &[u8], max_total: usize) -> Result<Vec<Entry>, String> {
let mut total = 0usize;
let end = (0..=bytes.len().saturating_sub(END_LEN))
.rev()
.find(|&i| u32_at(bytes, i).is_ok_and(|s| s == END_SIG))
@@ -134,12 +143,23 @@ pub fn read(bytes: &[u8]) -> Result<Vec<Entry>, String> {
+ u16_at(bytes, local + 26)? as usize
+ u16_at(bytes, local + 28)? as usize;
let raw = bytes.get(start..start + csize).ok_or("truncated zip")?;
let budget = max_total - total;
let too_big = || format!("{name}: archive expands past {} MiB", max_total >> 20);
let data = match method {
METHOD_STORED if raw.len() > budget => return Err(too_big()),
METHOD_STORED => raw.to_vec(),
METHOD_DEFLATE => miniz_oxide::inflate::decompress_to_vec_with_limit(raw, MAX_ENTRY)
.map_err(|e| format!("cannot inflate {name}: {e:?}"))?,
METHOD_DEFLATE => {
miniz_oxide::inflate::decompress_to_vec_with_limit(raw, MAX_ENTRY.min(budget))
.map_err(|e| match e.status {
miniz_oxide::inflate::TINFLStatus::HasMoreOutput if budget < MAX_ENTRY => {
too_big()
}
_ => format!("cannot inflate {name}: {:?}", e.status),
})?
}
m => return Err(format!("{name}: unsupported zip method {m}")),
};
total += data.len();
if data.len() != usize_ || crc32(&data) != crc {
return Err(format!("{name}: checksum mismatch"));
}
@@ -213,6 +233,63 @@ mod tests {
assert_eq!(read(&patched).unwrap(), vec![("x".to_string(), data)]);
}
/// A zip whose `count` central entries all point at one deflated local entry of `data`.
fn overlapping_zip(data: &[u8], count: u16) -> Vec<u8> {
let packed = miniz_oxide::deflate::compress_to_vec(data, 6);
let mut z = Vec::new();
z.extend(LOCAL_SIG.to_le_bytes());
z.extend([20, 0, 0, 0]);
z.extend(METHOD_DEFLATE.to_le_bytes());
z.extend([0; 4]);
z.extend(crc32(data).to_le_bytes());
z.extend((packed.len() as u32).to_le_bytes());
z.extend((data.len() as u32).to_le_bytes());
z.extend([1, 0, 0, 0]);
z.push(b'x');
z.extend(&packed);
let dir = z.len() as u32;
for i in 0..count {
let mut c = vec![0u8; CENTRAL_LEN];
c[..4].copy_from_slice(&CENTRAL_SIG.to_le_bytes());
c[10..12].copy_from_slice(&METHOD_DEFLATE.to_le_bytes());
c[16..20].copy_from_slice(&crc32(data).to_le_bytes());
c[20..24].copy_from_slice(&(packed.len() as u32).to_le_bytes());
c[24..28].copy_from_slice(&(data.len() as u32).to_le_bytes());
c[28] = 1;
z.extend(c);
z.push(b'a' + i as u8);
}
let dir_len = z.len() as u32 - dir;
z.extend(END_SIG.to_le_bytes());
z.extend([0; 4]);
z.extend(count.to_le_bytes());
z.extend(count.to_le_bytes());
z.extend(dir_len.to_le_bytes());
z.extend(dir.to_le_bytes());
z.extend([0; 2]);
z
}
#[test]
fn refuses_archives_that_inflate_past_the_total_cap() {
let data = vec![7u8; 600_000];
let one = overlapping_zip(&data, 1);
assert_eq!(read_capped(&one, 1 << 20).unwrap().len(), 1);
// Two entries over the same bytes: each is under the cap, together they are not.
let two = overlapping_zip(&data, 2);
let err = read_capped(&two, 1 << 20).unwrap_err();
assert!(err.contains("expands past 1 MiB"), "{err}");
// A single entry bigger than the whole cap is refused too.
assert!(read_capped(&one, 100_000).is_err());
}
#[test]
fn counts_stored_entries_against_the_total_cap() {
let z = write(&[("a".into(), vec![1; 600]), ("b".into(), vec![2; 600])]);
assert!(read_capped(&z, 1000).is_err());
assert_eq!(read_capped(&z, 1200).unwrap().len(), 2);
}
#[test]
fn rejects_garbage_and_corruption() {
assert!(read(b"nope").is_err());
+1 -1
View File
@@ -151,7 +151,7 @@ fn tea_login(host: &str) -> Option<String> {
fn tea(args: &[&str]) -> Result<String, String> {
let bin = crate::chat::path::which("tea").ok_or("no issue token in Settings and no `tea` CLI: add a token in Settings, Updates, Issues")?;
let out = Command::new(bin).args(args).env("PATH", crate::chat::path::resolved_path()).output().map_err(|e| e.to_string())?;
let out = crate::chat::path::apply(Command::new(bin).args(args)).output().map_err(|e| e.to_string())?;
if !out.status.success() {
let err = String::from_utf8_lossy(&out.stderr);
return Err(format!("tea: {}", err.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("failed")));
+3
View File
@@ -3,7 +3,9 @@
#[cfg(not(target_arch = "wasm32"))]
pub mod app;
pub mod assets;
pub mod fonts;
pub mod chat;
pub mod crash_log;
#[cfg(not(target_arch = "wasm32"))]
pub mod cli;
pub mod connector;
@@ -11,6 +13,7 @@ pub mod demo;
pub mod design_system;
pub mod editor;
pub mod geom;
pub mod handoffs;
pub mod history;
pub mod icons;
#[cfg(not(target_arch = "wasm32"))]
+23
View File
@@ -241,6 +241,7 @@ fn component_nodes(doc: &Document, root: NodeId, key: &str) -> Vec<Node> {
n.parent = n.parent.filter(|p| inside.contains(p)).map(|p| snapshot_id(key, p));
n.children = n.children.iter().map(|c| snapshot_id(key, *c)).collect();
n.link = None;
n.link_action = None;
n.comments.clear();
match &mut n.instance {
Some(l) if l.source.is_none() && inside.contains(&l.main) => l.main = snapshot_id(key, l.main),
@@ -746,4 +747,26 @@ mod tests {
assert_eq!(start.unwrap().node, snapshot_id("k", NodeId(2)));
assert!(end.is_none());
}
#[test]
fn a_published_component_never_holds_an_instance_of_another_component() {
// A's main holds an instance of B and B's holds one of A: both are published as plain
// copies, so a library can't form a loop however the projects are linked.
let frame = |id: u64| Node::new(NodeId(id), NodeKind::Frame { clip: false }, Rect::new(0., 0., 10., 10.));
let link = |main: u64, source: Option<Source>| Some(InstanceLink { main: NodeId(main), overrides: vec![], source, swap: None });
let mut doc = Document::default();
let (mut a, mut a_in, mut b, mut b_in) = (frame(1), frame(2), frame(3), frame(4));
a_in.instance = link(3, None);
b_in.instance = link(1, Some(Source::Library("other".into())));
a.children = vec![NodeId(2)];
a_in.parent = Some(NodeId(1));
b.children = vec![NodeId(4)];
b_in.parent = Some(NodeId(3));
for n in [a, a_in, b, b_in] {
doc.nodes.insert(n.id, n);
}
for (root, key) in [(1, "a"), (3, "b")] {
assert!(component_nodes(&doc, NodeId(root), key).iter().all(|n| n.instance.is_none()), "{key}");
}
}
}
+1
View File
@@ -168,6 +168,7 @@ pub(crate) fn ellipse([x, y]: [f32; 2], [width, height]: [f32; 2]) -> Bezier {
}
#[cfg(test)]
#[allow(clippy::panic)]
mod tests {
use super::{ellipse, estimate, path, rectangle};
use crate::lottie::model::{Bezier, Matrix};
+1
View File
@@ -157,6 +157,7 @@ fn ease(progress: f32, outgoing: [f32; 2], incoming: [f32; 2]) -> f32 {
}
#[cfg(test)]
#[allow(clippy::panic)]
mod tests {
use crate::lottie::model::{Key, Track};
+1
View File
@@ -244,4 +244,5 @@ fn reverse(path: &mut crate::lottie::model::Bezier) {
}
#[cfg(test)]
#[allow(clippy::panic)]
mod tests;
+1
View File
@@ -105,6 +105,7 @@ fn interpolate([ax, ay]: [f32; 2], [bx, by]: [f32; 2], t: f32) -> [f32; 2] {
}
#[cfg(test)]
#[allow(clippy::panic)]
mod tests {
use super::apply;
use crate::lottie::Polyline;
+11 -7
View File
@@ -12,13 +12,17 @@
//! over all keyframes and may reject animations whose sampled frames are smaller.
//! The 0.05 pixel cubic tessellation tolerance is subject to `f32` rounding.
#![deny(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unimplemented,
clippy::todo,
clippy::unreachable
// Tests assert with `panic!`/`unwrap`; only the shipped code must not.
#![cfg_attr(
not(test),
deny(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unimplemented,
clippy::todo,
clippy::unreachable
)
)]
mod decode;
+12
View File
@@ -1,3 +1,6 @@
// A launched app must not open a terminal window; `main` re-attaches to one it was run from.
#![cfg_attr(windows, windows_subsystem = "windows")]
use papercraft::app::Start;
use papercraft::editor::Editor;
use papercraft::io::{export, file, import, png, svg};
@@ -62,7 +65,16 @@ fn load_editor(path: Option<&str>, demo: bool) -> Result<Editor, String> {
}
fn main() -> ExitCode {
// Fails when no terminal started us, which is the normal launch and needs no handling.
#[cfg(windows)]
unsafe {
use windows_sys::Win32::System::Console::{ATTACH_PARENT_PROCESS, AttachConsole};
AttachConsole(ATTACH_PARENT_PROCESS);
}
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init();
if let Some(path) = papercraft::crash_log::path() {
papercraft::crash_log::install(path);
}
match run(std::env::args().skip(1).collect()) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
+92
View File
@@ -0,0 +1,92 @@
//! Token budget for read tools: every big read reports `estimatedTokens` and is cut at a clean
//! boundary (whole lines, or whole array elements of the JSON) when it passes `maxTokens`, so
//! one call cannot swamp the agent's context.
use super::Out;
use serde_json::{Value, json};
/// Budget when a call gives no `maxTokens`.
pub const DEFAULT_MAX_TOKENS: usize = 20_000;
const BYTES_PER_TOKEN: usize = 4;
pub fn tokens(bytes: usize) -> usize {
bytes.div_ceil(BYTES_PER_TOKEN)
}
fn budget_bytes(max_tokens: Option<f64>) -> usize {
max_tokens
.filter(|t| *t >= 1.0)
.map_or(DEFAULT_MAX_TOKENS, |t| t as usize)
.saturating_mul(BYTES_PER_TOKEN)
}
/// A text result (tree, code, markdown). The first block is the text itself, unchanged when it
/// fits; the second is a JSON block with `estimatedTokens` and, once cut, `truncated`,
/// `nextOffset` (pass it as `offset` to read on) and `hint`. Cuts fall on line ends and at least
/// one line is always returned.
pub fn text_out(body: String, max_tokens: Option<f64>, offset: Option<f64>, hint: &str) -> Vec<Out> {
let total = tokens(body.len());
let skip = offset.filter(|o| *o >= 1.0).map_or(0, |o| o as usize);
let budget = budget_bytes(max_tokens);
if skip == 0 && body.len() <= budget {
return vec![Out::Text(body), Out::Text(json!({"estimatedTokens": total}).to_string())];
}
let mut shown = String::new();
let mut next = skip;
let mut cut = false;
for line in body.split_inclusive('\n').skip(skip) {
if next > skip && shown.len() + line.len() > budget {
cut = true;
break;
}
shown.push_str(line);
next += 1;
}
let mut meta = json!({"estimatedTokens": total});
if cut {
meta["truncated"] = json!(true);
meta["nextOffset"] = json!(next);
meta["hint"] = json!(format!("Pass offset={next} to read on, or {hint}."));
}
vec![Out::Text(shown), Out::Text(meta.to_string())]
}
/// A JSON result (pretty-printed, as before). `estimatedTokens` is added to the object; over
/// budget, the longest array loses its tail (whole elements, so the JSON stays valid) until it
/// fits, and `truncated` and `hint` are added. Arrays are not paged: the hint names the
/// narrower read to use.
pub fn json_out(mut v: Value, max_tokens: Option<f64>, hint: &str) -> Vec<Out> {
let size = |v: &Value| serde_json::to_string_pretty(v).map_or(0, |s| s.len());
let budget = budget_bytes(max_tokens);
let total = size(&v);
v["estimatedTokens"] = json!(tokens(total));
if total > budget {
v["truncated"] = json!(true);
v["hint"] = json!(format!("Output was cut at {} tokens: {hint}.", budget / BYTES_PER_TOKEN));
while size(&v) > budget {
let Some(items) = longest_array(&mut v).filter(|a| !a.is_empty()) else { break };
items.truncate(items.len() / 2);
}
}
vec![Out::Text(serde_json::to_string_pretty(&v).unwrap_or_default())]
}
/// The first array on the heaviest path down the object, wherever it sits.
fn longest_array(v: &mut Value) -> Option<&mut Vec<Value>> {
fn weight(v: &Value) -> usize {
match v {
Value::Array(a) if !a.is_empty() => v.to_string().len(),
Value::Array(_) => 0,
Value::Object(m) => m.values().map(weight).max().unwrap_or(0),
_ => 0,
}
}
match v {
Value::Array(a) => Some(a),
Value::Object(m) => {
let key = m.iter().max_by_key(|(_, e)| weight(e)).filter(|(_, e)| weight(e) > 0)?.0.clone();
longest_array(m.get_mut(&key)?)
}
_ => None,
}
}
+240 -55
View File
@@ -6,14 +6,15 @@ use crate::model::{
};
use crate::variables::{DesignSystem, Kind, PageModes, Value as DsValue};
use serde_json::{Map, Value, json};
use std::collections::BTreeMap;
fn parent_layout(doc: &Document, n: &Node) -> Option<AutoLayout> {
pub(crate) fn parent_layout(doc: &Document, n: &Node) -> Option<AutoLayout> {
n.parent.and_then(|p| doc.get(p)).and_then(|p| p.layout)
}
/// `(set, name)` of an icon frame: a frame called `Icon / <set>:<name>` whose children are all vector
/// geometry (what `<x-paper-icon>` and the icon tools create).
fn icon_of(doc: &Document, n: &Node) -> Option<(String, String)> {
pub(crate) fn icon_of(doc: &Document, n: &Node) -> Option<(String, String)> {
if !matches!(n.kind, NodeKind::Frame { .. }) || n.children.is_empty() {
return None;
}
@@ -65,7 +66,7 @@ fn icon_tint(doc: &Document, n: &Node) -> (Option<String>, Option<String>) {
/// Inline SVG of exactly this node (and its descendants), without the XML prolog. The export is in
/// world coordinates; the result is shifted so its `viewBox` starts at 0 0 and can be placed anywhere.
fn inline_svg(doc: &Document, id: NodeId) -> String {
pub(crate) fn inline_svg(doc: &Document, id: NodeId) -> String {
let svg = crate::io::svg::export(doc, Some(&[id]), None)
.unwrap_or_default()
.lines()
@@ -227,7 +228,7 @@ fn tw_font_size(s: f64) -> String {
}
}
fn tw_classes(doc: &Document, n: &Node, is_root: bool) -> String {
pub(crate) fn tw_classes(doc: &Document, n: &Node, is_root: bool) -> String {
let mut c: Vec<String> = Vec::new();
let pl = parent_layout(doc, n);
let in_flow = pl.is_some() && !n.layout_child.absolute;
@@ -319,6 +320,9 @@ fn tw_classes(doc: &Document, n: &Node, is_root: bool) -> String {
font_size, align, ..
} => {
c.push(tw_font_size(*font_size));
if let Some(font) = &n.text_style.font {
c.push(format!("font-['{}']", font.replace(' ', "_")));
}
if n.text_style.bold {
c.push("font-bold".into());
}
@@ -378,22 +382,88 @@ fn tw_classes(doc: &Document, n: &Node, is_root: bool) -> String {
c.join(" ")
}
fn jsx_text(t: &str) -> String {
if t.chars()
.all(|c| !matches!(c, '{' | '}' | '<' | '>' | '\n' | '"' | '\\'))
{
t.to_string()
} else {
format!("{{{}}}", serde_json::to_string(t).unwrap_or_default())
/// Where escaped text is going; each target has its own characters that mean something.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Escape {
/// HTML text or a double-quoted attribute value.
Html,
/// JSX text: anything JSX would parse becomes a string expression.
Jsx,
/// Inside an Angular component's template literal, text or attribute value.
Angular,
}
/// The one escaper every exporter uses, so a layer's text or name reads back as written.
pub(crate) fn escape(mode: Escape, t: &str) -> String {
match mode {
Escape::Html => escape_with(t, false),
// `&` too: JSX decodes `&amp;` in text, so "Fish &amp; Chips" must not stay bare.
Escape::Jsx if !t.contains(['{', '}', '<', '>', '&', '\n', '"', '\\']) => t.to_string(),
Escape::Jsx => format!("{{{}}}", serde_json::to_string(t).unwrap_or_default()),
Escape::Angular => escape_with(t, true),
}
}
fn instance_comp_attr(doc: &Document, n: &Node) -> String {
/// HTML escaping; `template` also makes the result safe inside a TypeScript template literal
/// (`\`, backtick) and inert for Angular (`{{ x }}` interpolation and `{`/`}` blocks).
fn escape_with(t: &str, template: bool) -> String {
let mut out = String::with_capacity(t.len());
for c in t.chars() {
match c {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\\' if template => out.push_str(r"\\"),
'`' if template => out.push_str(r"\`"),
'{' if template => out.push_str("&#123;"),
'}' if template => out.push_str("&#125;"),
c => out.push(c),
}
}
out
}
/// A CSS identifier piece (variable key, mode name) with everything but letters, digits, `-` and
/// `_` written as a hex escape, so a name can never end the rule or the `<style>` block.
pub(crate) fn css_ident(s: &str) -> String {
s.chars()
.map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c.to_string() } else { format!("\\{:x} ", c as u32) })
.collect()
}
/// A quoted CSS string; `<` is escaped too so `</style>` cannot appear.
pub(crate) fn css_string(s: &str) -> String {
let body: String = s
.chars()
.map(|c| match c {
'"' | '\\' | '<' | '\n' | '\r' => format!("\\{:x} ", c as u32),
c => c.to_string(),
})
.collect();
format!("\"{body}\"")
}
/// The CSS text of a computed style value; font names are user text, so each family is quoted
/// (generic keywords stay bare).
pub(crate) fn css_value(prop: &str, val: &str) -> String {
if prop != "fontFamily" {
return val.to_string();
}
const GENERIC: [&str; 8] = ["serif", "sans-serif", "monospace", "cursive", "fantasy", "system-ui", "ui-monospace", "ui-sans-serif"];
let family = |f: &str| {
let f = f.trim().trim_matches(['"', '\'']);
if GENERIC.contains(&f) { f.to_string() } else { css_string(f) }
};
val.split(',').map(family).collect::<Vec<_>>().join(", ")
}
fn instance_comp_attr(doc: &Document, n: &Node, mode: Escape) -> String {
n.instance
.as_ref()
.and_then(|l| doc.get(l.main))
.filter(|m| m.component)
.map(|m| format!(" data-component=\"{}\"", m.name.replace('"', "'")))
.map(|m| format!(" data-component=\"{}\"", escape(mode, &m.name)))
.unwrap_or_default()
}
@@ -430,7 +500,7 @@ fn fill_template(doc: &Document, id: NodeId, template: &str, escape: fn(&str) ->
/// A linked component's JSX, filled in from the instance (None when it has no React link).
pub(crate) fn linked_react(doc: &Document, id: NodeId) -> Option<String> {
let n = doc.get(id)?;
code_link(doc, n).filter(|l| !l.react.is_empty()).map(|l| fill_template(doc, id, &l.react, jsx_text))
code_link(doc, n).filter(|l| !l.react.is_empty()).map(|l| fill_template(doc, id, &l.react, |t| escape(Escape::Jsx, t)))
}
/// The distinct import lines of the linked components under `root`, in order of first use.
@@ -459,13 +529,14 @@ fn tailwind_node(doc: &Document, id: NodeId, depth: usize, is_root: bool, out: &
let ind = " ".repeat(depth);
// A linked component is written as its library usage, not rebuilt from its layers.
if let Some(link) = code_link(doc, n).filter(|l| !l.react.is_empty()) {
push_indented(out, &ind, &fill_template(doc, id, &link.react, jsx_text));
push_indented(out, &ind, &fill_template(doc, id, &link.react, |t| escape(Escape::Jsx, t)));
return;
}
let cls = tw_classes(doc, n, is_root);
let name = n.name.replace('"', "'");
let comp_attr = instance_comp_attr(doc, n);
let name = escape(Escape::Html, &n.name);
let comp_attr = instance_comp_attr(doc, n, Escape::Html);
if let Some((set, icon)) = icon_of(doc, n) {
let (set, icon) = (escape(Escape::Html, &set), escape(Escape::Html, &icon));
out.push_str(&format!(
"{ind}<div data-name=\"{name}\" data-icon=\"{set}:{icon}\"{comp_attr} className=\"{cls}\">{}</div>\n",
inline_svg(doc, id)
@@ -491,7 +562,7 @@ fn tailwind_node(doc: &Document, id: NodeId, depth: usize, is_root: bool, out: &
NodeKind::Text { text, .. } => {
out.push_str(&format!(
"{ind}<span{comp_attr} className=\"{cls}\">{}</span>\n",
jsx_text(text)
escape(Escape::Jsx, text)
));
}
NodeKind::Rect | NodeKind::Ellipse => {
@@ -514,7 +585,7 @@ fn tailwind_node(doc: &Document, id: NodeId, depth: usize, is_root: bool, out: &
NodeKind::Sticky { text, .. } => {
out.push_str(&format!(
"{ind}<div data-name=\"{name}\"{comp_attr} className=\"{cls} p-4\">{}</div>\n",
jsx_text(text)
escape(Escape::Jsx, text)
));
}
_ => {
@@ -539,7 +610,7 @@ pub fn to_tailwind_jsx(doc: &Document, root: NodeId) -> String {
out
}
fn camel_to_kebab(s: &str) -> String {
pub(crate) fn camel_to_kebab(s: &str) -> String {
let mut out = String::new();
for c in s.chars() {
if c.is_ascii_uppercase() {
@@ -552,19 +623,41 @@ fn camel_to_kebab(s: &str) -> String {
out
}
fn escape_html(t: &str) -> String {
t.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
/// Prototype links the page can honour: layer link target -> the href to use.
pub(crate) type Hrefs = BTreeMap<NodeId, String>;
/// A layer with a prototype link whose target has a page becomes a real anchor around it;
/// `display: contents` keeps the anchor out of layout, so the design does not move.
#[allow(clippy::too_many_arguments)]
fn html_node(
doc: &Document,
id: NodeId,
depth: usize,
is_root: bool,
hrefs: &Hrefs,
styles: &mut String,
markup: &mut String,
in_link: bool,
) {
let href = doc.get(id).and_then(|n| n.link).and_then(|t| hrefs.get(&t));
// An anchor inside an anchor is invalid HTML and browsers close the outer one early.
let Some(href) = href.filter(|_| !in_link) else { return html_node_inner(doc, id, depth, is_root, hrefs, styles, markup, in_link) };
let ind = " ".repeat(depth);
markup.push_str(&format!("{ind}<a class=\"pc-link\" href=\"{}\">\n", escape(Escape::Html, href)));
html_node_inner(doc, id, depth + 1, is_root, hrefs, styles, markup, true);
markup.push_str(&format!("{ind}</a>\n"));
}
#[allow(clippy::too_many_arguments)]
fn html_node_inner(
doc: &Document,
id: NodeId,
depth: usize,
is_root: bool,
hrefs: &Hrefs,
styles: &mut String,
markup: &mut String,
in_link: bool,
) {
let Some(n) = doc.get(id) else { return };
let cls = format!("node-{}", id.0);
@@ -580,15 +673,16 @@ fn html_node(
styles.push_str(&format!(".{cls} {{\n"));
for (k, v) in &st {
if let Some(val) = v.as_str() {
styles.push_str(&format!(" {}: {val};\n", camel_to_kebab(k)));
styles.push_str(&format!(" {}: {};\n", camel_to_kebab(k), css_value(k, val)));
}
}
styles.push_str("}\n");
let ind = " ".repeat(depth);
let name = n.name.replace('"', "'");
let comp_attr = instance_comp_attr(doc, n);
let name = escape(Escape::Html, &n.name);
let comp_attr = instance_comp_attr(doc, n, Escape::Html);
if let Some((set, icon)) = icon_of(doc, n) {
let (set, icon) = (escape(Escape::Html, &set), escape(Escape::Html, &icon));
markup.push_str(&format!(
"{ind}<div class=\"{cls}\" data-name=\"{name}\" data-icon=\"{set}:{icon}\"{comp_attr}>{}</div>\n",
inline_svg(doc, id)
@@ -606,7 +700,7 @@ fn html_node(
"{ind}<div class=\"{cls}\" data-name=\"{name}\"{comp_attr}>\n"
));
for c in &n.children {
html_node(doc, *c, depth + 1, false, styles, markup);
html_node(doc, *c, depth + 1, false, hrefs, styles, markup, in_link);
}
markup.push_str(&format!("{ind}</div>\n"));
}
@@ -614,13 +708,13 @@ fn html_node(
NodeKind::Text { text, .. } => {
markup.push_str(&format!(
"{ind}<span class=\"{cls}\"{comp_attr}>{}</span>\n",
escape_html(text)
escape(Escape::Html, text)
));
}
NodeKind::Sticky { text, .. } => {
markup.push_str(&format!(
"{ind}<div class=\"{cls}\" data-name=\"{name}\"{comp_attr}>{}</div>\n",
escape_html(text)
escape(Escape::Html, text)
));
}
_ => {
@@ -634,25 +728,32 @@ fn html_node(
/// Generate a self-contained `<style>...</style>` + HTML block for `root`.
pub fn to_html_css(doc: &Document, root: NodeId) -> String {
let (css, markup) = html_css_parts(doc, root, &Hrefs::new());
format!("<style>\n{css}</style>\n{markup}")
}
/// The stylesheet and the markup of `root` apart, for a caller that builds the page around them
/// (`export_site`), with links to the frames in `hrefs` written as anchors.
pub(crate) fn html_css_parts(doc: &Document, root: NodeId, hrefs: &Hrefs) -> (String, String) {
let mut css_out = String::new();
css_out.push_str(":root {\n");
for (k, c) in &doc.variables {
css_out.push_str(&format!(" --{k}: {};\n", c.to_hex()));
css_out.push_str(&format!(" --{}: {};\n", css_ident(k), c.to_hex()));
}
for (k, v) in &doc.tokens {
css_out.push_str(&format!(" --{k}: {v};\n"));
css_out.push_str(&format!(" --{}: {};\n", css_ident(k), v.replace(['{', '}', ';', '<'], " ")));
}
css_out.push_str("}\n");
for (mode, vars) in &doc.modes {
css_out.push_str(&format!("[data-mode=\"{mode}\"] {{\n"));
css_out.push_str(&format!("[data-mode={}] {{\n", css_string(mode)));
for (k, c) in vars {
css_out.push_str(&format!(" --{k}: {};\n", c.to_hex()));
css_out.push_str(&format!(" --{}: {};\n", css_ident(k), c.to_hex()));
}
css_out.push_str("}\n");
}
let mut markup = String::new();
html_node(doc, root, 0, true, &mut css_out, &mut markup);
format!("<style>\n{css_out}</style>\n{markup}")
html_node(doc, root, 0, true, hrefs, &mut css_out, &mut markup, false);
(css_out, markup)
}
/// Variables and tokens as `:root` custom properties plus one `[data-theme]` block per mode.
@@ -915,14 +1016,6 @@ pub fn to_react_tsx(doc: &Document, root: NodeId) -> String {
out
}
fn escape_ng_template(t: &str) -> String {
escape_html(t)
.replace('`', "\\`")
.replace("${", "\\${")
.replace('{', "&#123;")
.replace('}', "&#125;")
}
fn angular_node(
doc: &Document,
id: NodeId,
@@ -945,22 +1038,23 @@ fn angular_node(
styles.push_str(&format!(" .{cls} {{\n"));
for (k, v) in &st {
if let Some(val) = v.as_str() {
styles.push_str(&format!(" {}: {val};\n", camel_to_kebab(k)));
styles.push_str(&format!(" {}: {};\n", camel_to_kebab(k), css_value(k, val)));
}
}
styles.push_str(" }\n");
let ind = " ".repeat(depth + 2);
let name = n.name.replace('"', "'");
let comp_attr = instance_comp_attr(doc, n);
let name = escape(Escape::Angular, &n.name);
let comp_attr = instance_comp_attr(doc, n, Escape::Angular);
let attrs = format!(
"class=\"{cls}\" data-node=\"{}\" data-name=\"{name}\"{comp_attr}",
id.0
);
if let Some((set, icon)) = icon_of(doc, n) {
let (set, icon) = (escape(Escape::Angular, &set), escape(Escape::Angular, &icon));
markup.push_str(&format!(
"{ind}<div {attrs} data-icon=\"{set}:{icon}\">{}</div>\n",
escape_ng_template(&inline_svg(doc, id))
escape(Escape::Angular, &inline_svg(doc, id))
));
return;
}
@@ -979,19 +1073,19 @@ fn angular_node(
NodeKind::Text { text, .. } => {
markup.push_str(&format!(
"{ind}<span {attrs}>{}</span>\n",
escape_ng_template(text)
escape(Escape::Angular, text)
));
}
NodeKind::Sticky { text, .. } => {
markup.push_str(&format!(
"{ind}<div {attrs}>{}</div>\n",
escape_ng_template(text)
escape(Escape::Angular, text)
));
}
_ => {
markup.push_str(&format!(
"{ind}<div {attrs}>{}</div>\n",
escape_ng_template(&inline_svg(doc, id))
escape(Escape::Angular, &inline_svg(doc, id))
));
}
}
@@ -1391,7 +1485,7 @@ fn build_spec_tree(
return Some(json!({
"id": n.id.0, "name": n.name, "kind": "library",
"library": {"kit": link.kit, "import": link.import, "react": link.react, "egui": link.egui,
"usage": fill_template(doc, id, &link.react, jsx_text), "eguiUsage": fill_template(doc, id, &link.egui, |t| format!("{t:?}"))},
"usage": fill_template(doc, id, &link.react, |t| escape(Escape::Jsx, t)), "eguiUsage": fill_template(doc, id, &link.egui, |t| format!("{t:?}"))},
"bounds": {"width": n.rect.w, "height": n.rect.h},
"sizing": {"horizontal": "fixed", "vertical": "fixed", "inFlow": in_flow},
}));
@@ -1495,7 +1589,7 @@ fn build_spec_tree(
Some(json!({
"content": text,
"fontSize": font_size,
"fontFamily": "Inter",
"fontFamily": n.text_style.font.as_deref().unwrap_or("Inter"),
"fontWeight": if n.text_style.bold { 700 } else { 400 },
"lineHeight": n.text_style.line_height.map(|m| m * font_size),
"letterSpacing": n.text_style.letter_spacing,
@@ -1676,6 +1770,10 @@ fn build_spec_tree(
if let NodeKind::Frame { clip } = n.kind {
m.insert("clip".into(), json!(clip));
}
if n.scroll {
// How far the content reaches past the frame: code needs it for `overflow: auto`.
m.insert("scroll".into(), json!(crate::editor::scroll_range(doc, n.id).round()));
}
if let Some(sh) = n.style.shadow {
m.insert(
"shadow".into(),
@@ -2036,7 +2134,7 @@ fn format_compact(spec: &Value) -> String {
out.push('\n');
}
}
out.push_str("## Tree\nFormat: Kind \"layer\" WxH layout style. hug/fill = sizing. Tokens as --name. Text: \"content\" size/lineHeight weight color.\n\n");
out.push_str("## Tree\nFormat: Kind \"layer\" WxH layout style. hug/fill = sizing. Tokens as --name. scroll+N = content scrolls, N px past the bottom. Text: \"content\" size/lineHeight weight color.\n\n");
compact_node(&spec["layoutTree"], 0, &mut out);
out
}
@@ -2137,6 +2235,9 @@ fn compact_style(st: &Value) -> Vec<String> {
if st["clip"] == true {
parts.push("clip".into());
}
if let Some(more) = st["scroll"].as_f64() {
parts.push(format!("scroll+{}", px_num(more)));
}
parts
}
@@ -2204,6 +2305,90 @@ mod tests {
use crate::geom::Rect;
use crate::model::{Color, InstanceLink};
fn hostile_doc() -> (Document, NodeId) {
let mut doc = Document::default();
let root = doc.alloc_id();
let mut frame = Node::new(root, NodeKind::Frame { clip: true }, Rect::new(0.0, 0.0, 200.0, 100.0));
frame.name = "Home".into();
let icon = doc.alloc_id();
let mut icon_node = Node::new(icon, NodeKind::Frame { clip: true }, Rect::new(0.0, 0.0, 24.0, 24.0));
icon_node.name = "Icon / a\" onmouseover=\"alert(1):b".into();
icon_node.parent = Some(root);
let shape = doc.alloc_id();
let mut shape_node = Node::new(shape, NodeKind::Rect, Rect::new(0.0, 0.0, 4.0, 4.0));
shape_node.parent = Some(icon);
icon_node.children.push(shape);
let txt = doc.alloc_id();
let mut txt_node = Node::new(
txt,
NodeKind::Text { text: "hi".into(), font_size: 12.0, auto_width: true, align: TextAlign::Left },
Rect::new(0.0, 30.0, 40.0, 20.0),
);
txt_node.text_style.font = Some("x}</style><script>alert(1)</script>".into());
txt_node.parent = Some(root);
frame.children = vec![icon, txt];
for (id, n) in [(root, frame), (icon, icon_node), (shape, shape_node), (txt, txt_node)] {
doc.nodes.insert(id, n);
}
doc.root.push(root);
doc.variables.insert("a}</style><script>1</script>".into(), Color::WHITE);
doc.modes.insert("m\"]{}</style><script>2</script>".into(), BTreeMap::from([("k;}".to_owned(), Color::WHITE)]));
(doc, root)
}
#[test]
fn html_export_keeps_hostile_names_inside_their_attributes_and_the_style_block() {
let (doc, root) = hostile_doc();
let (css, markup) = html_css_parts(&doc, root, &Hrefs::new());
let tree = crate::mcp::html::parse(&markup);
fn walk<'a>(nodes: &'a [crate::mcp::html::Html], out: &mut Vec<&'a crate::mcp::html::Element>) {
for n in nodes {
if let crate::mcp::html::Html::Element(e) = n {
out.push(e);
walk(&e.children, out);
}
}
}
let mut els = Vec::new();
walk(&tree, &mut els);
assert!(els.iter().all(|e| e.tag != "script"), "{markup}");
let icon = els.iter().find(|e| e.attr("data-icon").is_some()).expect("icon element");
assert_eq!(icon.attr("data-icon"), Some("a\" onmouseover=\"alert(1):b"), "{markup}");
assert!(icon.attrs.iter().all(|(k, _)| k == "class" || k == "data-name" || k == "data-icon"), "{markup}");
assert!(!css.contains("</style>") && !css.contains("<script"), "{css}");
assert!(css.contains("font-family: \"x}"), "{css}");
}
#[test]
fn html_export_does_not_nest_anchors() {
let (mut doc, root) = hostile_doc();
let inner = doc.get(root).unwrap().children[0];
let target = NodeId(999);
doc.nodes.get_mut(&root).unwrap().link = Some(target);
doc.nodes.get_mut(&inner).unwrap().link = Some(target);
let (_, markup) = html_css_parts(&doc, root, &Hrefs::from([(target, "t.html".to_owned())]));
assert_eq!(markup.matches("<a ").count(), 1, "{markup}");
}
#[test]
fn html_escape_covers_markup_and_quotes() {
assert_eq!(escape(Escape::Html, r#"a & <b> "c" {{x}}"#), "a &amp; &lt;b&gt; &quot;c&quot; {{x}}");
}
#[test]
fn jsx_escape_keeps_entities_literal() {
assert_eq!(escape(Escape::Jsx, "Plain text"), "Plain text");
assert_eq!(escape(Escape::Jsx, "Fish &amp; Chips"), r#"{"Fish &amp; Chips"}"#);
assert_eq!(escape(Escape::Jsx, "a {b}\n"), r#"{"a {b}\n"}"#);
}
#[test]
fn angular_escape_defuses_bindings_and_template_literals() {
assert_eq!(escape(Escape::Angular, "{{ total }}"), "&#123;&#123; total &#125;&#125;");
assert_eq!(escape(Escape::Angular, r"a\b`c"), r"a\\b\`c");
assert_eq!(escape(Escape::Angular, r#"say "hi" & <go>"#), "say &quot;hi&quot; &amp; &lt;go&gt;");
}
fn sample_card_doc() -> (Document, NodeId) {
let mut doc = Document::default();
doc.variables
+208
View File
@@ -0,0 +1,208 @@
//! `get_context`: the one compact read an agent makes before editing. It answers where the agent
//! is, what is cheap to read next, which tokens and components are in play and what is already
//! wrong, in about two thousand tokens, so the heavy reads (tree, code, spec) can be aimed at
//! one node.
use super::exec::{artboard_of, design_of, node_key, resolve};
use super::{Out, ToolResult};
use crate::editor::Editor;
use crate::model::{Document, NodeId, Source};
use crate::variables::{DesignSystem, Value as DsVal};
use serde_json::{Map, Value, json};
use std::collections::{BTreeMap, BTreeSet};
/// Rough cost of reading one layer through `get_tree_summary` (a line of about 64 bytes).
const TOKENS_PER_NODE: usize = 16;
/// Entries listed per outline level and in the selection; the rest is counted in `more`.
const MAX_LISTED: usize = 24;
const MAX_ISSUES: usize = 5;
pub fn get_context(ed: &mut Editor, id: Option<&str>) -> ToolResult {
let (page, target) = match id.filter(|s| !s.is_empty() && *s != "root") {
Some(key) => {
let (p, n) = resolve(ed, key, ed.active_page)?;
(p, Some(n))
}
None => (ed.active_page, ed.selection.iter().copied().find(|i| ed.doc.get(*i).is_some())),
};
let doc = ed.page_doc(page);
let roots = target.map_or_else(|| doc.root.clone(), |t| vec![t]);
let parents = target.map_or_else(|| roots.clone(), |t| doc.children(Some(t)).to_vec());
let scope: Vec<NodeId> = roots.iter().flat_map(|r| doc.subtree(*r)).collect();
let mut out = json!({"page": {"id": ed.pages[page].id, "name": ed.pages[page].name}});
if let Some(t) = target {
out["target"] = brief(ed, page, t);
if let Some(ab) = artboard_of(doc, t).filter(|ab| *ab != t) {
out["screen"] = brief(ed, page, ab);
}
}
let selected: Vec<NodeId> = ed.selection.iter().copied().filter(|i| ed.doc.get(*i).is_some()).collect();
if page == ed.active_page && !selected.is_empty() {
out["selection"] = json!(selected.iter().take(MAX_LISTED).map(|i| brief(ed, page, *i)).collect::<Vec<_>>());
}
out["outline"] = outline(ed, page, &parents, 2);
let ds = design_of(ed);
out["variables"] = used_variables(ed, page, &scope, &ds);
out["collections"] = collections(ed, page, &ds);
out["components"] = used_components(ed, page, &scope);
out["lint"] = lint_summary(ed, page, &roots);
Ok(vec![Out::Text(out.to_string())])
}
fn brief(ed: &Editor, page: usize, id: NodeId) -> Value {
let n = ed.page_doc(page).get(id);
json!({"id": node_key(ed, page, id), "name": n.map(|n| n.name.clone()),
"kind": n.map(|n| n.kind.type_name()),
"size": n.map(|n| format!("{}x{}", n.rect.w.round(), n.rect.h.round()))})
}
/// `parents` and, `levels` deep, their children, each with a child count and the tokens a full
/// `get_tree_summary` of that layer would cost.
fn outline(ed: &Editor, page: usize, parents: &[NodeId], levels: usize) -> Value {
let doc = ed.page_doc(page);
let mut items: Vec<Value> = parents
.iter()
.take(MAX_LISTED)
.filter_map(|id| {
let n = doc.get(*id)?;
let mut v = brief(ed, page, *id);
v["children"] = json!(n.children.len());
v["estimatedTokens"] = json!(doc.subtree(*id).len() * TOKENS_PER_NODE);
if levels > 1 && !n.children.is_empty() {
v["items"] = outline(ed, page, &n.children, levels - 1);
}
Some(v)
})
.collect();
if parents.len() > MAX_LISTED {
items.push(json!({"more": parents.len() - MAX_LISTED}));
}
json!(items)
}
fn used_variables(ed: &Editor, page: usize, scope: &[NodeId], ds: &DesignSystem) -> Value {
let doc = ed.page_doc(page);
let mut names: BTreeSet<String> = BTreeSet::new();
for n in scope.iter().filter_map(|i| doc.get(*i)) {
names.extend(n.style.fills.iter().filter_map(|p| p.var()).map(str::to_string));
names.extend(n.bindings.values().cloned());
}
let modes = &ed.pages[page].modes;
names
.into_iter()
.map(|name| {
let found = ds.find_by_css(&name);
let value = match (doc.variables.get(&name), found) {
(Some(c), _) => json!(c.to_hex()),
(None, Some((c, v))) => {
// Same fallbacks as `DesignSystem::resolve`, which the canvas uses.
let mode = modes.get(&c.id).filter(|m| c.modes.iter().any(|x| &x.id == *m)).unwrap_or(&c.default_mode);
v.values.get(mode).or_else(|| v.values.get(&c.default_mode)).map_or(Value::Null, |x| ds_value(ds, x))
}
(None, None) => Value::Null,
};
json!({"name": name, "value": value, "collection": found.map(|(c, _)| c.name.clone())})
})
.collect()
}
fn ds_value(ds: &DesignSystem, v: &DsVal) -> Value {
match v {
DsVal::Color(c) => json!(c.to_hex()),
DsVal::Number(n) => json!(n),
DsVal::String(s) => json!(s),
DsVal::Boolean(b) => json!(b),
DsVal::Alias(id) => json!(ds.find(id).map(|(_, t)| t.name.clone())),
}
}
fn collections(ed: &Editor, page: usize, ds: &DesignSystem) -> Value {
let modes = &ed.pages[page].modes;
ds.collections
.iter()
.map(|c| {
let active = modes.get(&c.id).unwrap_or(&c.default_mode);
let name = c.modes.iter().find(|m| &m.id == active).map(|m| m.name.clone());
json!({"name": c.name, "modes": c.modes.iter().map(|m| &m.name).collect::<Vec<_>>(), "active": name})
})
.collect()
}
/// Components with at least one instance inside `scope`, counting instance roots only.
fn used_components(ed: &Editor, page: usize, scope: &[NodeId]) -> Value {
let doc = ed.page_doc(page);
let mut counts: BTreeMap<(usize, NodeId), usize> = BTreeMap::new();
for n in scope.iter().filter_map(|i| doc.get(*i)) {
let Some(link) = &n.instance else { continue };
if n.parent.and_then(|p| doc.get(p)).is_some_and(|p| p.instance.is_some()) {
continue;
}
let (main, source) = link.target();
let main_page = match source {
None => page,
Some(Source::Page(p)) => match ed.pages.iter().position(|s| s.id == *p) {
Some(i) => i,
None => continue,
},
Some(Source::Library(_)) => continue,
};
if main != n.id || main_page != page {
*counts.entry((main_page, main)).or_default() += 1;
}
}
counts
.into_iter()
.map(|((p, main), instances)| {
json!({"id": node_key(ed, p, main), "name": ed.page_doc(p).get(main).map(|m| m.name.clone()),
"instances": instances})
})
.collect()
}
/// The lint rules on this scope only: counts per rule and the first few findings.
fn lint_summary(ed: &Editor, page: usize, roots: &[NodeId]) -> Value {
let doc: &Document = ed.page_doc(page);
let mut issues = super::lint::lint(doc, roots, &super::lint::DEFAULT_SCALE);
issues.extend(super::lint::not_an_instance(doc, roots, &super::lint::component_mains(ed)));
// Layers hidden behind a drawer or modal are not findings: nobody sees them.
issues.retain(|i| !crate::editor::screens::coverage(doc, i.node).is_some_and(|c| c.is_hidden()));
let mut by_rule: Map<String, Value> = Map::new();
for i in &issues {
let n = by_rule.get(i.rule).and_then(Value::as_u64).unwrap_or(0);
by_rule.insert(i.rule.to_string(), json!(n + 1));
}
let count = |sev: &str| issues.iter().filter(|i| i.severity == sev).count();
json!({"errors": count("error"), "warnings": count("warning"), "info": count("info"),
"byRule": by_rule,
"first": issues.iter().take(MAX_ISSUES).map(|i| json!({
"rule": i.rule, "severity": i.severity, "node": node_key(ed, page, i.node), "message": i.message,
})).collect::<Vec<_>>()})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::geom::Rect;
use crate::model::{Node, NodeKind};
use crate::variables::Kind;
#[test]
fn a_variable_missing_in_the_active_mode_shows_its_default_mode_value() {
let mut ed = Editor::default();
let mut n = Node::new(NodeId(0), NodeKind::Rect, Rect::new(0., 0., 10., 10.));
n.bindings.insert("gap".into(), "space".into());
let id = ed.add_node(n, None);
let mut ds = DesignSystem::default();
let c = ds.add_collection("Sizes");
let dark = ds.add_mode(&c, "Dark").unwrap();
let v = ds.add_variable(&c, "space", Kind::Number).unwrap();
let default_mode = ds.collections[0].default_mode.clone();
ds.set_value(&v, &default_mode, DsVal::Number(8.0)).unwrap();
ds.collections[0].variables[0].values.remove(&dark);
ed.pages[0].modes.insert(c, dark);
let vars = used_variables(&ed, 0, &[id], &ds);
assert_eq!(vars[0]["value"], json!(8.0));
}
}
+123
View File
@@ -0,0 +1,123 @@
//! The lean tool list (`?tools=core` on the MCP URL) for agents that load every tool schema up
//! front: the tools a design turn nearly always uses in full, every other tool by name only, and
//! two tools that reach the rest (`search_tools` to read one, `call_tool` to run it).
use serde_json::{Value, json};
/// Listed in full. Picked from the calls in saved chats: together they make nearly all of them.
pub const CORE: [&str; 33] = [
"get_basic_info", "get_selection", "get_tree_summary", "get_children", "find_nodes", "get_node_info",
"get_computed_styles", "get_screenshot", "create_page", "create_artboard", "write_html", "update_styles",
"set_text_content", "rename_nodes", "duplicate_nodes", "move_nodes", "delete_nodes", "create_component",
"create_instance", "swap_instance", "get_components", "get_variables", "set_variables", "bind_variable",
"set_variable_mode", "search_icons", "lint_design", "finish_working_on_nodes", "ask_user", "generate_image",
"fetch_reference_images", "get_guide", "report_issue",
];
pub const SEARCH: &str = "search_tools";
pub const CALL: &str = "call_tool";
/// Most tools `search_tools` returns at once.
const MAX_HITS: usize = 8;
/// `all` (the full catalogue) cut to the core tools, plus `search_tools` and `call_tool`, whose
/// description names every tool left out.
pub fn definitions(all: Vec<Value>) -> Vec<Value> {
let (mut core, rest): (Vec<Value>, Vec<Value>) = all.into_iter().partition(|t| CORE.contains(&name(t)));
let others: Vec<&str> = rest.iter().map(name).collect();
core.push(json!({
"name": SEARCH,
"description": "Find a PaperCraft tool this session does not list (call_tool's description names them all) and read how to use it. Then run it with call_tool.",
"inputSchema": {"type": "object", "properties": {
"query": {"type": "string", "description": "An exact tool name, or words its name or description contains."},
"detail": {"type": "string", "enum": ["name", "description", "schema"], "description": "How much to return per tool (default schema: description and input schema)."}},
"required": ["query"]},
"annotations": {"readOnlyHint": true},
}));
core.push(json!({
"name": CALL,
"description": format!("Run a PaperCraft tool that is not listed here, with the arguments its schema takes (read it with search_tools first). The other tools: {}.", others.join(", ")),
"inputSchema": {"type": "object", "properties": {
"name": {"type": "string"},
"arguments": {"type": "object"}},
"required": ["name"]},
"annotations": {"readOnlyHint": false},
}));
core
}
/// The tools in `all` that `query` names exactly, else those whose name or description holds
/// every word of it; at most `MAX_HITS`, cut to `detail`.
pub fn search(all: &[Value], query: &str, detail: &str) -> Value {
let q = query.trim().to_lowercase();
let exact: Vec<&Value> = all.iter().filter(|t| name(t) == q).collect();
let hits = if exact.is_empty() {
let words: Vec<&str> = q.split_whitespace().collect();
let text = |t: &Value| format!("{} {}", name(t), t["description"].as_str().unwrap_or_default()).to_lowercase();
all.iter().filter(|t| !words.is_empty() && words.iter().all(|w| text(t).contains(w))).collect()
} else {
exact
};
let more = hits.len().saturating_sub(MAX_HITS);
let tools: Vec<Value> = hits
.into_iter()
.take(MAX_HITS)
.map(|t| match detail {
"name" => json!(name(t)),
"description" => json!({"name": name(t), "description": t["description"]}),
_ => json!({"name": name(t), "description": t["description"], "inputSchema": t["inputSchema"]}),
})
.collect();
if tools.is_empty() {
return json!({"tools": [], "hint": "No tool matches; call_tool's description lists every name."});
}
json!({"tools": tools, "more": more})
}
/// `call_tool`'s target: the tool name and its arguments (an empty object when left out).
pub fn target(args: &Value) -> Result<(String, Value), String> {
let name = args["name"].as_str().filter(|n| !n.is_empty()).ok_or("call_tool needs the name of a tool")?;
if [SEARCH, CALL].contains(&name) {
return Err(format!("{name} cannot be run through call_tool"));
}
Ok((name.to_owned(), args.get("arguments").filter(|a| !a.is_null()).cloned().unwrap_or_else(|| json!({}))))
}
fn name(t: &Value) -> &str {
t["name"].as_str().unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_core_tool_exists_and_the_rest_are_named() {
let all = crate::mcp::tools::definitions();
let names: Vec<&str> = all.iter().map(name).collect();
assert!(CORE.iter().all(|c| names.contains(c)), "a core tool was renamed or removed");
let lean = definitions(all.clone());
assert_eq!(lean.len(), CORE.len() + 2);
let call = lean.iter().find(|t| name(t) == CALL).unwrap()["description"].as_str().unwrap().to_owned();
assert!(names.iter().filter(|n| !CORE.contains(n)).all(|n| call.contains(n)), "call_tool names every other tool");
let size = |v: &[Value]| serde_json::to_string(v).unwrap().len();
assert!(size(&lean) * 2 < size(&all), "the lean list is less than half the full one");
}
#[test]
fn search_finds_by_name_or_words_and_cuts_to_the_detail() {
let all = crate::mcp::tools::definitions();
let one = search(&all, "update_annotation", "schema");
assert_eq!(one["tools"].as_array().unwrap().len(), 1);
assert!(one["tools"][0]["inputSchema"]["properties"].is_object());
let words = search(&all, "Annotation TEMPLATE", "name");
assert!(words["tools"].as_array().unwrap().iter().all(|n| n.as_str().unwrap().contains("annotation_template")), "{words}");
assert!(search(&all, "zzzz", "name")["tools"].as_array().unwrap().is_empty());
}
#[test]
fn call_tool_unwraps_its_target_but_never_itself() {
assert_eq!(target(&json!({"name": "undo"})).unwrap(), ("undo".into(), json!({})));
assert_eq!(target(&json!({"name": "export", "arguments": {"format": "png"}})).unwrap().1, json!({"format": "png"}));
assert!(target(&json!({"name": "call_tool"})).is_err() && target(&json!({})).is_err());
}
}
+104 -23
View File
@@ -142,7 +142,7 @@ pub fn split_ws(s: &str) -> Vec<String> {
/// (use [`percent`]).
pub fn length(s: &str, font_size: f64) -> Option<f64> {
let s = s.trim();
let num = |t: &str| t.trim().parse::<f64>().ok().filter(|v| v.is_finite());
let num = |t: &str| finite(t);
if s == "0" {
return Some(0.0);
}
@@ -150,21 +150,26 @@ pub fn length(s: &str, font_size: f64) -> Option<f64> {
return num(t);
}
if let Some(t) = s.strip_suffix("rem") {
return num(t).map(|v| v * 16.0);
return num(t).map(|v| v * 16.0).filter(|v| v.is_finite());
}
if let Some(t) = s.strip_suffix("em") {
return num(t).map(|v| v * font_size);
return num(t).map(|v| v * font_size).filter(|v| v.is_finite());
}
if let Some(t) = s.strip_suffix("pt") {
return num(t).map(|v| v * 4.0 / 3.0);
return num(t).map(|v| v * 4.0 / 3.0).filter(|v| v.is_finite());
}
num(s)
}
/// A number that JSON can store: "NaN" and "1e999" parse as f64 but would make a saved page unopenable.
pub fn finite(s: &str) -> Option<f64> {
s.trim().parse::<f64>().ok().filter(|v| v.is_finite())
}
pub fn percent(s: &str) -> Option<f64> {
s.trim()
.strip_suffix('%')
.and_then(|t| t.trim().parse::<f64>().ok())
.and_then(finite)
.map(|v| v / 100.0)
}
@@ -239,7 +244,7 @@ fn channel(s: &str, scale: f64) -> Option<f64> {
let s = s.trim();
match percent(s) {
Some(p) => Some(p * scale),
None => s.parse::<f64>().ok(),
None => finite(s),
}
}
@@ -342,18 +347,15 @@ pub fn color(s: &str) -> Option<Color> {
channel(&a[2], 255.0)? / 255.0,
],
"hsl" | "hsla" if a.len() >= 3 => {
let h = a[0].trim_end_matches("deg").parse::<f64>().ok()?;
let sat = percent(&a[1]).or_else(|| a[1].parse::<f64>().ok().map(|v| v / 100.0))?;
let l = percent(&a[2]).or_else(|| a[2].parse::<f64>().ok().map(|v| v / 100.0))?;
let h = finite(a[0].trim_end_matches("deg"))?;
let sat = percent(&a[1]).or_else(|| finite(&a[1]).map(|v| v / 100.0))?;
let l = percent(&a[2]).or_else(|| finite(&a[2]).map(|v| v / 100.0))?;
hsl(h, sat, l)
}
"oklch" if a.len() >= 3 => {
let l = channel(&a[0], 1.0)?;
let c = channel(&a[1], 0.4)?;
let h = a[2]
.trim_end_matches("deg")
.parse::<f64>()
.ok()?
let h = finite(a[2].trim_end_matches("deg"))?
.to_radians();
oklab_to_srgb(l, c * h.cos(), c * h.sin())
}
@@ -399,7 +401,7 @@ pub fn gradient(s: &str) -> Option<Paint> {
if name == "linear-gradient" {
let first = args.first()?.to_ascii_lowercase();
if let Some(d) = first.strip_suffix("deg") {
angle = d.trim().parse().ok()?;
angle = finite(d)?;
stops_src = &args[1..];
} else if let Some(dir) = first.strip_prefix("to ") {
let (mut x, mut y) = (0.0f64, 0.0f64);
@@ -924,11 +926,18 @@ fn apply_resolved(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> A
}
let is_text = n.kind.text().is_some();
let fs = n.kind.font_size().unwrap_or(16.0);
let mut shift = (0.0, 0.0);
for (k, v) in styles {
let v = v.trim();
let lower = v.to_ascii_lowercase();
let ok = match k.as_str() {
"fontSize" => true, // applied above; inherited by text children
"transform" => {
let (by, lost) = transform(v, fs);
shift = (shift.0 + by.0, shift.1 + by.1);
a.ignored.extend(lost.into_iter().map(|f| format!("transform: {f}")));
true
}
"width" => {
set_size(n, 0, v, ctx, &mut a, k);
true
@@ -1098,7 +1107,7 @@ fn apply_resolved(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> A
let parts = split_ws(v);
let grow = parts
.first()
.and_then(|g| g.parse::<f64>().ok())
.and_then(|g| finite(g))
.unwrap_or(0.0);
match ctx.parent_layout {
Some(pl) => {
@@ -1170,6 +1179,10 @@ fn apply_resolved(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> A
}
}
"textTransform" if is_text => {
if matches!(lower.as_str(), "uppercase" | "lowercase" | "capitalize") {
// Changed text replaces a bound string, as in `set_text_content`.
n.bindings.remove("text");
}
if let Some(t) = n.kind.text_mut() {
match lower.as_str() {
"uppercase" => *t = t.to_uppercase(),
@@ -1243,8 +1256,14 @@ fn apply_resolved(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> A
true
}
"flexShrink" | "flexBasis" | "boxSizing" | "zIndex" | "cursor" | "whiteSpace"
| "fontFamily" | "wordBreak" | "overflowWrap" => {
// Accepted without an equivalent; harmless. fontFamily: Inter is the only face.
| "wordBreak" | "overflowWrap" => {
// Accepted without an equivalent; harmless.
true
}
"fontFamily" => {
if let Some(font) = crate::fonts::pick(v) {
n.text_style.font = font;
}
true
}
"overflow" | "overflowY" if n.is_frame() => {
@@ -1304,7 +1323,7 @@ fn apply_resolved(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> A
let w = match lower.as_str() {
"bold" | "bolder" => 700.0,
"normal" | "lighter" => 400.0,
_ => v.parse().unwrap_or(400.0),
_ => finite(v).unwrap_or(400.0),
};
n.text_style.bold = w >= 600.0;
true
@@ -1321,7 +1340,7 @@ fn apply_resolved(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> A
} else if v.ends_with("px") || v.ends_with("em") || v.ends_with("rem") {
length(v, fs).map(|px| px / fs)
} else {
v.parse::<f64>().ok()
finite(v)
};
n.text_style.line_height = lh.filter(|x| *x > 0.0);
lh.is_some() || lower == "normal"
@@ -1514,7 +1533,7 @@ fn apply_resolved(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> A
}
None => false,
},
"opacity" => match v.parse::<f64>().ok().or(percent(v)) {
"opacity" => match finite(v).or(percent(v)) {
Some(o) => {
n.style.opacity = o.clamp(0.0, 1.0);
true
@@ -1542,7 +1561,7 @@ fn apply_resolved(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> A
"architect" => Some(0.0),
"artist" => Some(1.0),
"cartoonist" => Some(2.0),
_ => v.parse::<f64>().ok(),
_ => finite(v),
};
match r {
Some(r) => {
@@ -1569,7 +1588,7 @@ fn apply_resolved(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> A
None => false,
}
}
"bowing" => match v.parse::<f64>() {
"bowing" => match finite(v).ok_or(()) {
Ok(b) => {
rough_mut(n).bowing = b;
true
@@ -1589,9 +1608,49 @@ fn apply_resolved(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> A
a.ignored.push(k.clone());
}
}
if shift != (0.0, 0.0) {
if ctx.parent_layout.is_some() && !n.layout_child.absolute {
// Auto layout places the box again on every pass, so an offset would not stay.
a.ignored.push("transform: translate (inside auto layout; use margin or gap)".into());
} else {
n.rect.x += shift.0;
n.rect.y += shift.1;
}
}
a
}
/// The offset of a `transform`'s translate functions, and the functions the model cannot hold
/// (there is no rotation, scale or skew on a layer).
fn transform(v: &str, font_size: f64) -> ((f64, f64), Vec<String>) {
let (mut by, mut lost) = ((0.0, 0.0), Vec::new());
let mut rest = v.trim();
while let Some(open) = rest.find('(') {
let name = rest[..open].trim().to_ascii_lowercase();
let Some(close) = rest[open..].find(')') else { break };
let args: Vec<&str> = rest[open + 1..open + close].split(',').map(str::trim).collect();
rest = &rest[open + close + 1..];
let len = |i: usize| args.get(i).map(|t| length(t, font_size));
let (x, y) = match name.as_str() {
"translate" | "translate3d" => (len(0), len(1).or(Some(Some(0.0)))),
"translatex" => (len(0), Some(Some(0.0))),
"translatey" => (Some(Some(0.0)), len(0)),
_ => {
lost.push(name);
continue;
}
};
match (x.flatten(), y.flatten()) {
(Some(x), Some(y)) => {
by.0 += x;
by.1 += y;
}
_ => lost.push(format!("{name} (needs px)")),
}
}
(by, lost)
}
fn set_dash(n: &mut Node, style: &str) {
let w = n.style.stroke_width.max(1.0);
n.style.dash = match style {
@@ -1837,7 +1896,7 @@ pub fn computed(n: &Node, parent_layout: Option<&AutoLayout>, is_root: bool) ->
NodeKind::Text {
font_size, align, ..
} => {
put("fontFamily", "Inter".into());
put("fontFamily", n.text_style.font.clone().unwrap_or_else(|| "Inter".into()));
put("fontSize", px(*font_size));
put(
"fontWeight",
@@ -2044,6 +2103,28 @@ mod tests {
assert_eq!(back["borderRadius"], "12px");
}
#[test]
fn text_transform_unbinds_the_text_it_changes() {
let kind = NodeKind::Text { text: "hi".into(), font_size: 14.0, auto_width: true, align: Default::default() };
let mut n = Node::new(NodeId(4), kind, Rect::new(0.0, 0.0, 10.0, 10.0));
n.bindings.insert("text".into(), "greeting".into());
apply(&mut n, &parse_inline("text-transform: uppercase"), &Context::default());
assert_eq!(n.kind.text().map(String::as_str), Some("HI"));
assert!(!n.bindings.contains_key("text"));
}
#[test]
fn translate_moves_a_free_layer_and_the_rest_of_transform_is_reported() {
let mut n = Node::new(NodeId(3), NodeKind::Rect, Rect::new(0.0, 0.0, 10.0, 10.0));
let a = apply(&mut n, &parse_inline("transform: translate(10px, -4px) rotate(5deg) scale(2); left: 20px"), &Context::default());
assert_eq!((n.rect.x, n.rect.y), (30.0, -4.0));
assert_eq!(a.ignored, ["transform: rotate", "transform: scale"]);
let ctx = Context { parent_layout: Some(AutoLayout::default()), parent_size: None };
let a = apply(&mut n, &parse_inline("transform: translateX(8px)"), &ctx);
assert_eq!(n.rect.x, 30.0);
assert_eq!(a.ignored.len(), 1, "{:?}", a.ignored);
}
#[test]
fn gradients_and_sketch() {
let Some(Paint::Linear { stops, from, to }) = gradient("linear-gradient(90deg, red, blue)")
+4 -12
View File
@@ -4,7 +4,7 @@
//! inherits `PAPERCRAFT_MCP_PORT`), and only its final answer comes back into the main context.
use super::{Out, ToolResult};
use std::process::{Command, Stdio};
use std::process::Stdio;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
@@ -44,20 +44,12 @@ pub fn run(command: &str, chat: Option<&str>, agent: (&str, &str), args: &serde_
let task = args["task"].as_str().map(str::trim).filter(|t| !t.is_empty()).ok_or("give the task with everything the helper needs")?;
let cli = crate::chat::path::which("papercraft-cli").ok_or("papercraft-cli is not installed, so a helper cannot reach PaperCraft")?;
let prompt = format!("{}\n\n{}\n\nTask:\n{task}", preamble(&cli.display().to_string()), role_rules(role));
let mut cmd = if cfg!(windows) {
let mut c = Command::new("cmd.exe");
c.args(["/C", &command.replace("{prompt}", "%PAPERCRAFT_PROMPT%")]);
c
} else {
let mut c = Command::new("/bin/sh");
c.args(["-c", &command.replace("{prompt}", "\"$PAPERCRAFT_PROMPT\"")]);
c
};
let mut cmd = super::shell::command(command, &[("{prompt}", "PAPERCRAFT_PROMPT", &prompt)]);
crate::chat::path::no_window(&mut cmd);
// Its own process group, so stopping it also ends the agent the shell started.
#[cfg(unix)]
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.
@@ -99,7 +91,7 @@ pub fn run(command: &str, chat: Option<&str>, agent: (&str, &str), args: &serde_
/// End the helper and everything it started.
fn kill_group(child: &mut std::process::Child) {
#[cfg(unix)]
let _ = Command::new("kill").args(["-TERM", &format!("-{}", child.id())]).status();
let _ = std::process::Command::new("kill").args(["-TERM", &format!("-{}", child.id())]).status();
let _ = child.kill();
let _ = child.wait();
}
+268 -103
View File
@@ -123,16 +123,16 @@ impl Host for SessionHost {
// ------------------------------------------------------------------------------ arguments
struct Args<'a>(&'a Map<String, Value>);
pub(super) struct Args<'a>(pub(super) &'a Map<String, Value>);
impl<'a> Args<'a> {
fn get(&self, k: &str) -> Option<&'a Value> {
pub(super) fn get(&self, k: &str) -> Option<&'a Value> {
self.0.get(k).filter(|v| !v.is_null())
}
fn str(&self, k: &str) -> Option<&'a str> {
pub(super) fn str(&self, k: &str) -> Option<&'a str> {
self.get(k).and_then(Value::as_str).map(str::trim)
}
fn req_str(&self, k: &str) -> Result<&'a str, String> {
pub(super) fn req_str(&self, k: &str) -> Result<&'a str, String> {
self.str(k)
.ok_or_else(|| format!("missing required string argument `{k}`"))
}
@@ -142,11 +142,13 @@ impl<'a> Args<'a> {
Value::String(s) => s.trim().trim_end_matches("px").parse().ok(),
_ => None,
})
// "NaN" and "1e999" parse, but JSON cannot store them: a saved page would not reopen.
.filter(|v| v.is_finite())
}
fn bool(&self, k: &str) -> Option<bool> {
pub(super) fn bool(&self, k: &str) -> Option<bool> {
self.get(k).and_then(Value::as_bool)
}
fn arr(&self, k: &str) -> Result<&'a Vec<Value>, String> {
pub(super) fn arr(&self, k: &str) -> Result<&'a Vec<Value>, String> {
self.get(k)
.and_then(Value::as_array)
.ok_or_else(|| format!("missing required array argument `{k}`"))
@@ -164,7 +166,7 @@ impl<'a> Args<'a> {
}
}
fn text(v: Value) -> ToolResult {
pub(super) fn text(v: Value) -> ToolResult {
Ok(vec![Out::Text(
serde_json::to_string_pretty(&v).unwrap_or_default(),
)])
@@ -214,7 +216,7 @@ pub(crate) fn find_page(ed: &Editor, key: &str) -> Result<usize, String> {
})
}
fn page_arg(ed: &Editor, a: &Args) -> Result<usize, String> {
pub(super) fn page_arg(ed: &Editor, a: &Args) -> Result<usize, String> {
match a.str("pageId") {
Some(p) if !p.is_empty() => find_page(ed, p),
_ => Ok(ed.active_page),
@@ -222,7 +224,7 @@ fn page_arg(ed: &Editor, a: &Args) -> Result<usize, String> {
}
/// Resolve a node id string to (page index, node id).
fn resolve(ed: &Editor, key: &str, default_page: usize) -> Result<(usize, NodeId), String> {
pub(crate) fn resolve(ed: &Editor, key: &str, default_page: usize) -> Result<(usize, NodeId), String> {
let key = key.trim().trim_start_matches('#');
let (page, num) = match key.split_once(':') {
Some((p, n)) => (find_page(ed, p)?, n),
@@ -296,6 +298,7 @@ fn dispatch(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult {
"rename_file" => rename_file(host, &a),
"get_font_family_info" => font_info(&a),
"get_guide" => guide(&a),
"import_html" => super::import_html::run(host, args),
"publish_library" | "list_libraries" | "enable_library" | "update_library"
| "disable_library" => {
check_file(host, &a)?;
@@ -322,6 +325,7 @@ fn dispatch(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult {
"get_basic_info" => basic_info(host, &a),
"get_inventory" => get_inventory(ed),
"get_selection" => selection(ed),
"get_context" => super::context::get_context(ed, a.str("id")),
"get_node_info" => node_info(ed, &a),
"get_children" => children(ed, &a),
"get_bounds" => get_bounds(ed, &a),
@@ -346,6 +350,8 @@ fn dispatch(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult {
#[cfg(not(target_arch = "wasm32"))]
"design_rules" => design_rules(&a),
#[cfg(not(target_arch = "wasm32"))]
"install_font" => install_font(host, &a),
#[cfg(not(target_arch = "wasm32"))]
"fetch_reference_images" => super::refs::fetch(host.project().map(|p| p.dir.clone()).as_deref(), None, &Value::Object(a.0.clone())),
#[cfg(not(target_arch = "wasm32"))]
"generate_image" => {
@@ -358,11 +364,16 @@ fn dispatch(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult {
"set_component_props" => set_component_props(ed, &a),
"set_instance_props" => set_instance_props(ed, &a),
"merge_variants" => merge_variants(ed, &a),
"set_prototype_links" => set_prototype_links(ed, &a),
"set_prototype_links" => super::prototype::set_prototype_links(ed, &a),
"set_screens" => set_screens(ed, &a),
"export" => export(host, &a),
"export_combined_pdf" => export_pdf(host, &a),
"export_presentation" => export_presentation(host, &a),
"mark_handoff" => super::handoff::mark(host, args),
"list_handoffs" => super::handoff::list(host),
"get_handoff_changes" => super::handoff::get_changes(host, args),
"export_handoff" => export_handoff(host, args),
"export_site" => export_site(host, args),
"create_artboard" => create_artboard(ed, &a),
"write_html" => write_html(ed, &a),
"create_shape" => create_shape(ed, &a),
@@ -387,6 +398,8 @@ fn dispatch(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult {
"create_instance" => create_instance(ed, &a),
"swap_instance" => swap_instance(ed, &a),
"bind_variable" => bind_variable(ed, &a),
"get_strings" => super::strings::get_strings(ed, &a),
"set_strings" => super::strings::set_strings(ed, &a),
"detach_instance" => detach_instance(ed, &a),
"diff_frames" => diff_frames(ed, &a),
"search_icons" => search_icons(&a),
@@ -452,6 +465,9 @@ fn make_room(ed: &mut Editor, page: usize, before: &std::collections::HashMap<No
fn find_project(store: &Store, key: &str) -> Result<PathBuf, String> {
let key = key.trim().trim_start_matches("/file/");
let p = Path::new(key);
if p.is_absolute() {
check_path(key)?;
}
if p.is_absolute() && crate::vfs::is_file(&p.join(crate::project::MANIFEST)) {
return Ok(p.to_path_buf());
}
@@ -753,7 +769,7 @@ fn node_brief(ed: &Editor, page: usize, id: NodeId) -> Value {
}
/// The top-level frame a node belongs to.
fn artboard_of(doc: &Document, id: NodeId) -> Option<NodeId> {
pub(super) fn artboard_of(doc: &Document, id: NodeId) -> Option<NodeId> {
let top = doc.ancestors(id).last().copied().unwrap_or(id);
doc.get(top).filter(|n| n.is_frame()).map(|n| n.id)
}
@@ -822,7 +838,7 @@ fn basic_info(host: &mut dyn Host, a: &Args) -> ToolResult {
"width": n.rect.w, "height": n.rect.h, "section": section})
}).collect::<Vec<_>>(),
"otherTopLevelLayers": others,
"fontFamilies": ["Inter"],
"fontFamilies": std::iter::once("Inter".to_owned()).chain(crate::fonts::families()).collect::<Vec<_>>(),
"selectionCount": if page == ed.active_page { ed.selection.len() } else { 0 },
"openFeedback": (0..ed.pages.len())
.map(|p| ed.page_doc(p).nodes.values().map(|n| n.open_comments()).sum::<usize>())
@@ -1134,7 +1150,7 @@ fn node_info(ed: &mut Editor, a: &Args) -> ToolResult {
if let Some(s) = screen_json(ed, page, id) {
v["screen"] = s;
}
text(v)
Ok(super::budget::json_out(v, a.num("maxTokens"), "use get_children for the child ids"))
}
fn children(ed: &mut Editor, a: &Args) -> ToolResult {
@@ -1152,7 +1168,7 @@ fn children(ed: &mut Editor, a: &Args) -> ToolResult {
.iter()
.map(|c| node_brief(ed, page, *c))
.collect();
text(json!({"children": kids}))
Ok(super::budget::json_out(json!({"children": kids}), a.num("maxTokens"), "ask for a deeper node or use find_nodes"))
}
fn get_bounds(ed: &mut Editor, a: &Args) -> ToolResult {
@@ -1250,7 +1266,7 @@ fn tree_summary(ed: &mut Editor, a: &Args) -> ToolResult {
if out.is_empty() {
out = "(empty page)".into();
}
Ok(vec![Out::Text(out)])
Ok(super::budget::text_out(out, a.num("maxTokens"), a.num("offset"), "pass a narrower nodeId or a smaller depth"))
}
/// Largest screenshot edge, in pixels.
@@ -1306,6 +1322,20 @@ fn shot_out(img: &image::RgbaImage, scale: &mut f64) -> Result<Option<Vec<Out>>,
}
fn screenshot(host: &mut dyn Host, a: &Args) -> ToolResult {
// GPU failures (device lost, out of memory) panic inside wgpu and the readback. In the app that
// is the UI thread, so unsaved work dies with it; a shot only reads, so nothing is left half done.
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| shoot(host, a))).unwrap_or_else(|p| {
let why = p
.downcast_ref::<&str>()
.copied()
.or_else(|| p.downcast_ref::<String>().map(String::as_str))
.unwrap_or("unknown panic");
log::error!("get_screenshot panicked: {why}");
Err(format!("the screenshot failed: {why}"))
})
}
fn shoot(host: &mut dyn Host, a: &Args) -> ToolResult {
let gpu = host.gpu();
let defer = host.defer_pixels();
if gpu.is_none() && !defer {
@@ -1315,7 +1345,8 @@ fn screenshot(host: &mut dyn Host, a: &Args) -> ToolResult {
.str("out")
.or_else(|| a.str("path"))
.filter(|s| !s.is_empty())
.map(PathBuf::from);
.map(|p| out_file(p, "png"))
.transpose()?;
let ed = host.editor();
let def = page_arg(ed, a)?;
let (page, roots) = match a.str("nodeId").filter(|k| !k.is_empty() && *k != ROOT) {
@@ -1580,7 +1611,7 @@ fn fill_image(ed: &mut Editor, a: &Args) -> ToolResult {
fn ask_user(a: &Args) -> ToolResult {
let qs = a.arr("questions")?;
if !(1..=ASK_MAX_QUESTIONS).contains(&qs.len()) {
return Err(format!("ask 1 to {ASK_MAX_QUESTIONS} questions"));
return Err(format!("questions has {} items; provide 1 to {ASK_MAX_QUESTIONS} questions and retry", qs.len()));
}
for (i, q) in qs.iter().enumerate() {
let n = q["options"].as_array().map_or(0, Vec::len);
@@ -1615,7 +1646,7 @@ fn design_rules(a: &Args) -> ToolResult {
(_, Some(q)) if !q.trim().is_empty() => vec!["search", "-n", "5", q.trim()],
_ => return Err("give query or rule".into()),
};
let out = std::process::Command::new(bin).args(&args).output().map_err(|e| e.to_string())?;
let out = crate::chat::path::no_window(std::process::Command::new(bin).args(&args)).output().map_err(|e| e.to_string())?;
let found = String::from_utf8_lossy(&out.stdout).trim().to_owned();
if found.is_empty() {
return Err(format!("no playbook match: {}", String::from_utf8_lossy(&out.stderr).trim()));
@@ -1627,7 +1658,9 @@ fn design_rules(a: &Args) -> ToolResult {
/// image when it was scaled down, else the image itself. Only files in the project's chat folder.
fn zoom_image(host: &mut dyn Host, a: &Args) -> ToolResult {
let dir = host.project().map(|p| p.dir.join(crate::chat::TMP_DIR)).ok_or("open a project first")?;
let sent = std::path::Path::new(a.req_str("path")?);
let sent = a.req_str("path")?;
check_path(sent)?;
let sent = std::path::Path::new(sent);
let (Ok(root), Ok(sent)) = (std::fs::canonicalize(&dir), std::fs::canonicalize(sent)) else {
return Err("no such attached image".into());
};
@@ -1792,13 +1825,16 @@ fn font_info(a: &Args) -> ToolResult {
.iter()
.map(|f| {
let inter = f.trim().eq_ignore_ascii_case("inter");
let styles = match inter {
true => Some(vec![(false, false), (true, false), (false, true), (true, true)]),
false => crate::fonts::styles(f.trim()).map(|(_, styles)| styles),
};
let note = if styles.is_some() { "" } else { "not installed; install_font adds it, until then text renders in Inter" };
json!({
"family": f,
"available": inter,
"styles": if inter { json!([
{"weight": 400, "style": "normal"}, {"weight": 700, "style": "normal"},
{"weight": 400, "style": "italic"}, {"weight": 700, "style": "italic"}]) } else { json!([]) },
"note": if inter { "" } else { "not available; text renders in Inter" },
"available": styles.is_some(),
"styles": font_styles(styles.as_deref().unwrap_or_default()),
"note": note,
})
})
.collect();
@@ -1807,6 +1843,32 @@ fn font_info(a: &Args) -> ToolResult {
)
}
fn font_styles(styles: &[(bool, bool)]) -> Vec<Value> {
styles
.iter()
.map(|(bold, italic)| json!({"weight": if *bold { 700 } else { 400 }, "style": if *italic { "italic" } else { "normal" }}))
.collect()
}
#[cfg(not(target_arch = "wasm32"))]
fn install_font(host: &dyn Host, a: &Args) -> ToolResult {
let source = a.req_str("source")?;
let bytes = if source.starts_with("https://") || source.starts_with("http://") {
super::refs::download(source, crate::fonts::MAX_FONT_BYTES)?.1
} else {
check_path(source)?;
let size = std::fs::metadata(source).map_err(|e| format!("{source}: {e}"))?.len();
if size > crate::fonts::MAX_FONT_BYTES as u64 {
return Err(format!("{source}: the font is over {} MB", crate::fonts::MAX_FONT_BYTES >> 20).into());
}
std::fs::read(source).map_err(|e| format!("{source}: {e}"))?
};
let assets = host.project().map(|p| p.dir.join(crate::project::ASSETS_DIR));
let family = crate::fonts::install(&bytes, assets.as_deref())?;
let styles = crate::fonts::styles(&family).map(|(_, s)| s).unwrap_or_default();
text(json!({"family": family, "styles": font_styles(&styles), "savedInProject": assets.is_some()}))
}
fn guide(a: &Args) -> ToolResult {
let t = a.req_str("topic")?;
let s = match t {
@@ -1815,9 +1877,10 @@ fn guide(a: &Args) -> ToolResult {
}
"html" | "write_html" => tools::GUIDE_HTML,
"whiteboard" | "excalidraw" => tools::GUIDE_WHITEBOARD,
"skill" | "papercraft" => include_str!("../../skills/papercraft/SKILL.md"),
_ => {
return Err(format!(
"unknown topic `{t}`; topics: papercraft-mcp-instructions, html, whiteboard"
"unknown topic `{t}`; topics: papercraft-mcp-instructions, html, whiteboard, skill"
));
}
};
@@ -2283,6 +2346,9 @@ fn delete_annotations(ed: &mut Editor, a: &Args) -> ToolResult {
// ------------------------------------------------------------------------------ jsx
/// How to read the rest of a cut `get_jsx` or `get_design_spec`.
const JSX_HINT: &str = "export a narrower nodeId (a child from get_children)";
fn jsx(host: &mut dyn Host, a: &Args) -> ToolResult {
let assets_dir = host
.project()
@@ -2297,14 +2363,15 @@ fn jsx(host: &mut dyn Host, a: &Args) -> ToolResult {
out.push_str(&format!("{import}\n"));
}
jsx_node(doc, id, 0, true, assets_dir.as_deref(), &mut out);
Ok(vec![Out::Text(out)])
Ok(super::budget::text_out(out, a.num("maxTokens"), a.num("offset"), JSX_HINT))
}
Some(
f @ ("tailwind" | "jsx" | "react" | "tsx" | "angular" | "ng" | "html" | "css" | "egui"
| "rust"),
) => Ok(vec![Out::Text(super::codegen::code_for_target_with_categories(
doc, id, f, &ed.design.categories(),
)?)]),
) => {
let code = super::codegen::code_for_target_with_categories(doc, id, f, &ed.design.categories())?;
Ok(super::budget::text_out(code, a.num("maxTokens"), a.num("offset"), JSX_HINT))
}
Some(f) => Err(format!(
"unknown format `{f}` (inline, tailwind, react, angular, html, or egui)"
)),
@@ -2318,15 +2385,15 @@ fn get_design_spec(ed: &mut Editor, a: &Args) -> ToolResult {
match a.str("format").unwrap_or("markdown") {
"" | "markdown" | "md" => {
let md = super::codegen::to_ai_spec_markdown_with_categories(doc, id, target, &ed.design.categories())?;
Ok(vec![Out::Text(md)])
Ok(super::budget::text_out(md, a.num("maxTokens"), a.num("offset"), JSX_HINT))
}
"json" => {
let spec = super::codegen::to_ai_spec_with_categories(doc, id, target, &ed.design.categories())?;
text(spec)
Ok(super::budget::json_out(spec, a.num("maxTokens"), JSX_HINT))
}
"compact" => {
let s = super::codegen::to_ai_spec_compact_with_categories(doc, id, target, &ed.design.categories())?;
Ok(vec![Out::Text(s)])
Ok(super::budget::text_out(s, a.num("maxTokens"), a.num("offset"), JSX_HINT))
}
other => Err(format!(
"unknown format `{other}` (expected markdown, json or compact)"
@@ -2351,16 +2418,6 @@ fn style_obj(m: &Map<String, Value>) -> String {
format!("{{{{ {} }}}}", parts.join(", "))
}
fn jsx_text(t: &str) -> String {
if t.chars()
.all(|c| !matches!(c, '{' | '}' | '<' | '>' | '\n' | '"' | '\\'))
{
t.to_string()
} else {
format!("{{{}}}", serde_json::to_string(t).unwrap_or_default())
}
}
fn jsx_node(
doc: &Document,
id: NodeId,
@@ -2405,7 +2462,7 @@ fn jsx_node(
out.push_str(&format!(
"{ind}<span style={}>{}</span>\n",
style_obj(&st),
jsx_text(text)
super::codegen::escape(super::codegen::Escape::Jsx, text)
));
}
NodeKind::Rect | NodeKind::Ellipse => {
@@ -2438,7 +2495,7 @@ fn jsx_node(
out.push_str(&format!(
"{ind}<div style={}>{}</div>\n",
style_obj(&st),
jsx_text(text)
super::codegen::escape(super::codegen::Escape::Jsx, text)
));
}
_ => {
@@ -2471,26 +2528,85 @@ fn parse_scale(s: Option<&str>, b: Rect) -> Result<f64, String> {
let Some(s) = s.map(str::trim).filter(|s| !s.is_empty()) else {
return Ok(2.0);
};
let (num, unit) = s.split_at(s.len() - 1);
let v: f64 = num
.parse()
.map_err(|_| format!("bad scale `{s}` (use e.g. 2x, 512w, 512h, 720p)"))?;
let unit_at = s.char_indices().next_back().map_or(0, |(i, _)| i);
let (num, unit) = s.split_at(unit_at);
let v = css::finite(num)
.ok_or_else(|| format!("bad scale `{s}` (use e.g. 2x, 512w, 512h, 720p)"))?;
Ok(match unit {
"x" => v,
"w" => v / b.w.max(1.0),
"h" => v / b.h.max(1.0),
"p" => v / b.w.min(b.h).max(1.0),
_ => s.parse().map_err(|_| format!("bad scale `{s}`"))?,
_ => css::finite(s).ok_or_else(|| format!("bad scale `{s}`"))?,
})
}
/// Refuses a path an agent could use to make Windows talk to another machine or hide data: a
/// network share (`\\host\share`, `//host`, `\\?\UNC\`) sends the user's credentials to the host,
/// a device path (`\\.\`) reaches hardware, and a `:` past the drive letter names an NTFS
/// alternate data stream. Every path an agent supplies, to read or to write, goes through here.
pub fn check_path(path: &str) -> Result<(), String> {
let s = path.trim();
let b = s.as_bytes();
let sep = |c: u8| c == b'\\' || c == b'/';
let mut rest = s;
if b.len() >= 2 && sep(b[0]) && sep(b[1]) {
// `\\?\C:\x` is the long form of a local path; any other double-separator start is a
// share or a device, or a way to one (`\\?\UNC\`, `\\?\GLOBALROOT\Device\Mup\`).
let t = &b[2..];
let local = t.len() >= 4 && t[0] == b'?' && sep(t[1]) && t[2].is_ascii_alphabetic() && t[3] == b':';
if !local {
return Err(format!("`{path}` is a network or device path; give a local path"));
}
rest = &s[4..];
}
if rest.starts_with(r"\??\") {
return Err(format!("`{path}` is a device path; give a local path"));
}
let drive = rest.as_bytes().first().is_some_and(u8::is_ascii_alphabetic) && rest.as_bytes().get(1) == Some(&b':');
if rest[if drive { 2 } else { 0 }..].contains(':') {
return Err(format!("`{path}`: a `:` is only allowed after the drive letter"));
}
Ok(())
}
/// The file a tool writes at the agent's `path`. Only the extension the tool produces is
/// allowed, so a path cannot name a document to overwrite or a script to drop in a startup folder.
fn out_file(path: &str, ext: &str) -> Result<PathBuf, String> {
check_path(path)?;
let p = PathBuf::from(path);
if p.extension().is_some_and(|e| e.eq_ignore_ascii_case(ext)) {
return Ok(p);
}
Err(format!("`{path}` must end in .{ext}: this tool only writes .{ext} files"))
}
/// Checks the folder a tool is about to fill: it is new, empty, or holds the tool's own
/// `marker` file from an earlier export, which `is_ours` must recognise by what the tool itself writes. Any other folder may be the
/// person's own files.
#[cfg(not(target_arch = "wasm32"))]
fn out_dir(a: &Args) -> PathBuf {
a.str("directory")
pub(crate) fn out_folder(dir: &Path, marker: &str, is_ours: impl Fn(&[u8]) -> bool) -> Result<(), String> {
check_path(&dir.to_string_lossy())?;
let Ok(mut entries) = std::fs::read_dir(dir) else {
return if dir.exists() { Err(format!("{}: not a folder", dir.display())) } else { Ok(()) };
};
let ours = || std::fs::read(dir.join(marker)).is_ok_and(|bytes| is_ours(&bytes));
if entries.next().is_none() || ours() {
return Ok(());
}
Err(format!("{}: the folder is not empty and holds no earlier export of this tool; give a new or empty folder", dir.display()))
}
#[cfg(not(target_arch = "wasm32"))]
fn out_dir(a: &Args) -> Result<PathBuf, String> {
if let Some(d) = a.str("directory") {
check_path(d)?;
}
Ok(a.str("directory")
.map(PathBuf::from)
.or_else(dirs::download_dir)
.or_else(dirs::home_dir)
.unwrap_or_else(|| PathBuf::from("."))
.unwrap_or_else(|| PathBuf::from(".")))
}
#[cfg(not(target_arch = "wasm32"))]
@@ -2545,16 +2661,21 @@ fn export_presentation(host: &mut dyn Host, a: &Args) -> ToolResult {
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 language = match a.str("language") {
Some(code) => Some(crate::io::deck::Lang::parse(code).ok_or_else(|| format!("language `{code}` is not one of en, pt, es, fr, de, it"))?),
None => None,
};
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(),
language,
};
let path = match a.str("path") {
Some(p) => PathBuf::from(p),
Some(p) => out_file(p, "pdf")?,
None => {
let dir = out_dir(a);
let dir = out_dir(a)?;
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
unique_path(&dir, &title, "pdf")
}
@@ -2563,6 +2684,26 @@ fn export_presentation(host: &mut dyn Host, a: &Args) -> ToolResult {
text(json!({"file": path, "screens": screens, "pages": sections.iter().filter(|s| !s.screens.is_empty()).count()}))
}
#[cfg(not(target_arch = "wasm32"))]
fn export_handoff(host: &mut dyn Host, a: &Value) -> ToolResult {
super::handoff::bundle::export(host, a)
}
#[cfg(target_arch = "wasm32")]
fn export_handoff(_: &mut dyn Host, _: &Value) -> ToolResult {
Err(NO_DISK.into())
}
#[cfg(not(target_arch = "wasm32"))]
fn export_site(host: &mut dyn Host, a: &Value) -> ToolResult {
super::site::export(host, a)
}
#[cfg(target_arch = "wasm32")]
fn export_site(_: &mut dyn Host, _: &Value) -> ToolResult {
Err(NO_DISK.into())
}
#[cfg(not(target_arch = "wasm32"))]
fn export(host: &mut dyn Host, a: &Args) -> ToolResult {
use crate::io::export::{Format, Item, Scope, Spec};
@@ -2573,7 +2714,7 @@ fn export(host: &mut dyn Host, a: &Args) -> ToolResult {
.get("nodes")
.and_then(Value::as_object)
.ok_or("`nodes` must be an object of nodeId → export settings")?;
let dir = out_dir(a);
let dir = out_dir(a)?;
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let mut written = Vec::new();
for (key, settings) in nodes {
@@ -2597,17 +2738,27 @@ fn export(host: &mut dyn Host, a: &Args) -> ToolResult {
f => return Err(format!("format `{f}` is not supported (png, svg, pdf)")),
};
let scale = parse_scale(s.str("scale"), b)?;
// `name` overrides the file stem (node names repeat across screens); `transparent`
// (per node, else top-level) drops the white background for PNG and SVG.
let stem_base = s
.str("name")
.filter(|n| !n.trim().is_empty())
.map_or_else(|| name.clone(), str::to_owned);
let transparent = s
.bool("transparent")
.or_else(|| a.bool("transparent"))
.unwrap_or(false);
let stem = if format == Format::Png && scale != 1.0 {
format!("{name}@{}x", (scale * 100.0).round() / 100.0)
format!("{stem_base}@{}x", (scale * 100.0).round() / 100.0)
} else {
name.clone()
stem_base
};
let path = unique_path(&dir, &stem, format.ext());
let spec = Spec {
format,
scope: Scope::Selection,
scale,
transparent: false,
transparent,
};
let item = Item {
name: name.clone(),
@@ -2646,9 +2797,9 @@ fn export_pdf(host: &mut dyn Host, a: &Args) -> ToolResult {
})
.collect();
let path = match a.str("path") {
Some(p) => PathBuf::from(p),
Some(p) => out_file(p, "pdf")?,
None => {
let dir = out_dir(a);
let dir = out_dir(a)?;
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
unique_path(&dir, project_name.as_deref().unwrap_or("papercraft"), "pdf")
}
@@ -2816,6 +2967,7 @@ fn image_asset(src: &str) -> Result<String, String> {
return Ok(path.to_string());
}
let path = percent_decode(path);
check_path(&path)?;
crate::vfs::read(&path).map_err(|e| format!("{path}: {e}"))?
};
if bytes.starts_with(b"<svg") || bytes.starts_with(b"<?xml") {
@@ -3487,7 +3639,7 @@ impl Builder<'_, '_> {
let self_closing = raw[..raw.find('>').unwrap_or(0)].ends_with('/');
// The importer keeps viewBox units: fit the viewBox into width × height ourselves, as a
// browser does (centred, aspect kept), and make the viewBox the final size.
let vb: Vec<f64> = el.attr("viewBox").map(|v| v.split([' ', ',']).filter_map(|n| n.trim().parse().ok()).collect()).unwrap_or_default();
let vb: Vec<f64> = el.attr("viewBox").map(|v| v.split([' ', ',']).filter_map(css::finite).collect()).unwrap_or_default();
let mut fitted = None;
if let [vx, vy, vw, vh] = vb[..]
&& vw > 0.0
@@ -3878,9 +4030,9 @@ impl Builder<'_, '_> {
return fail(format!("{} did not return an SVG", p.name));
};
// No viewBox (flagcdn's jp.svg has only width and height): the size is the box.
let px = |k: &str| svg_el.attr(k).and_then(|v| v.trim_end_matches("px").parse::<f64>().ok());
let px = |k: &str| svg_el.attr(k).and_then(|v| css::finite(v.trim_end_matches("px")));
let vb: Vec<f64> = match svg_el.attr("viewBox") {
Some(v) => v.split([' ', ',']).filter_map(|n| n.parse().ok()).collect(),
Some(v) => v.split([' ', ',']).filter_map(css::finite).collect(),
None => px("width").zip(px("height")).map(|(w, h)| vec![0.0, 0.0, w, h]).unwrap_or_default(),
};
// The importer keeps viewBox units: scale the artwork to the layer size.
@@ -3971,7 +4123,7 @@ impl Builder<'_, '_> {
// a scale group and make the viewBox match the final size.
let vb: Vec<f64> = svg_el
.attr("viewBox")
.map(|v| v.split([' ', ',']).filter_map(|n| n.parse().ok()).collect())
.map(|v| v.split([' ', ',']).filter_map(css::finite).collect())
.unwrap_or_default();
if let [vx, vy, vw, vh] = vb[..]
&& vw > 0.0
@@ -4045,6 +4197,10 @@ impl Builder<'_, '_> {
return None;
};
let same_page = page_part.is_none_or(|p| match_page_key(&self.page_id, "", p));
if same_page && crate::editor::structure::moves_into_cycle(self.tx.doc(), src, parent.id) {
self.warnings.push(format!("x-paper-clone: node `{key}` skipped, {NEST_ERR}"));
return None;
}
let id = if same_page && self.tx.doc().get(src).is_some() {
clone_subtree(self.tx, src, parent.id, *index, V2::ZERO).0
} else if let Some((_, _, src_doc)) =
@@ -4433,7 +4589,7 @@ fn write_html(ed: &mut Editor, a: &Args) -> ToolResult {
v["parentId"] = json!(parent.map_or(ROOT.into(), |p| node_key(ed, page, p)));
}
if !ignored.is_empty() {
v["ignoredStyles"] = json!(ignored);
v["ignoredStyles"] = json!(super::import_html::group_ignored(ignored.iter().map(String::as_str)));
}
if !warnings.is_empty() {
v["warnings"] = json!(warnings);
@@ -4760,6 +4916,8 @@ fn set_text_content(ed: &mut Editor, a: &Args) -> ToolResult {
)
})?;
*node.kind.text_mut().unwrap() = t.clone();
// Hand-set text replaces a bound string, as a new colour replaces a `text-color` binding.
node.bindings.remove("text");
crate::editor::structure::mark_overrides(tx, id, &["text"]);
let node = tx.node_mut(id).unwrap();
let len = t.chars().count();
@@ -4836,7 +4994,7 @@ fn get_variables(ed: &mut Editor, a: &Args) -> ToolResult {
/// The project design system; a project that never went through the session reads as its first
/// page does, like `edit_design`.
fn design_of(ed: &Editor) -> DesignSystem {
pub(super) fn design_of(ed: &Editor) -> DesignSystem {
if ed.design.collections.is_empty() {
return variables::migrate_from_document(ed.page_doc(0));
}
@@ -4909,9 +5067,11 @@ fn parse_value(name: &str, kind: Option<Kind>, v: &Value) -> Result<(Kind, DsVal
.trim()
.trim_end_matches("px")
.trim()
.parse()
.parse::<f64>()
.ok()
.filter(|n| n.is_finite())
.map(|n| (Kind::Number, DsVal::Number(n)))
.map_err(|_| bad("number")),
.ok_or_else(|| bad("number")),
(Some(Kind::Number), _) => Err(bad("number")),
(Some(Kind::String), Value::String(s)) => Ok((Kind::String, DsVal::String(s.clone()))),
(Some(Kind::String), _) => Err(bad("string")),
@@ -5313,15 +5473,19 @@ fn set_tokens(ed: &mut Editor, a: &Args) -> ToolResult {
fn set_variable_mode(ed: &mut Editor, a: &Args) -> ToolResult {
let mode = a.req_str("mode")?.to_string();
if !ed.page_doc(ed.active_page).modes.contains_key(&mode) {
let known: Vec<String> = ed.page_doc(ed.active_page).modes.keys().cloned().collect();
let Some((cid, mid)) = design::find_mode(&ed.design, &mode) else {
let known: Vec<&str> = ed
.design
.collections
.iter()
.flat_map(|c| c.modes.iter().map(|m| m.name.as_str()))
.collect();
return Err(format!(
"no mode `{mode}`; define it with set_variables {{mode}} first (modes: {})",
known.join(", ")
));
}
};
// Project-wide like before: every page switches (the Design tab switches one page).
let (cid, mid) = design::find_mode(&ed.design, &mode).ok_or(format!("no mode `{mode}`"))?;
let mut fills = 0;
for page in 0..ed.pages.len() {
fills += ed
@@ -5381,7 +5545,7 @@ fn get_components(ed: &mut Editor, a: &Args) -> ToolResult {
}
}
}
text(json!({"components": items}))
Ok(super::budget::json_out(json!({"components": items}), a.num("maxTokens"), "pass pageId to list one page"))
}
/// `color` on an icon (vector shapes with no text, e.g. an <x-paper-icon>) recolors the shapes'
@@ -5624,6 +5788,8 @@ fn find_component(ed: &Editor, a: &Args, default_page: usize) -> Result<(usize,
Ok((page, id))
}
const NEST_ERR: &str = "a component can't contain an instance of itself, directly or through other components";
fn create_instance(ed: &mut Editor, a: &Args) -> ToolResult {
let def = page_arg(ed, a)?;
// A componentKey that no page owns may name a component of an enabled library.
@@ -5645,6 +5811,12 @@ fn create_instance(ed: &mut Editor, a: &Args) -> ToolResult {
(pp, Some(id))
}
};
if lib.is_none() && cpage == page && crate::editor::structure::would_nest(ed.page_doc(page), comp, parent) {
return Err(NEST_ERR.into());
}
if lib.is_none() && cpage != page && crate::editor::structure::would_nest_across(ed, cpage, comp, page, parent) {
return Err(NEST_ERR.into());
}
let index = a.num("index").map(|i| i.max(0.0) as usize);
let at = a.num("x").zip(a.num("y"));
let name = a
@@ -5718,33 +5890,6 @@ fn create_instance(ed: &mut Editor, a: &Args) -> ToolResult {
text(json!({"id": node_key(ed, page, id), "textLayers": texts, "layers": layers}))
}
/// Prototype links: clicking `nodeId` in Present mode goes to frame `to` (same page); `to: null`
/// removes the link.
fn set_prototype_links(ed: &mut Editor, a: &Args) -> ToolResult {
let page = page_arg(ed, a)?;
let mut set = Vec::new();
for l in a.arr("links")? {
let l = Args(l.as_object().ok_or("each link must be an object")?);
let (p, from) = resolve(ed, l.req_str("nodeId")?, page)?;
let to = match l.str("to").filter(|t| !t.is_empty()) {
Some(t) => {
let (tp, to) = resolve(ed, t, p)?;
if tp != p || !ed.page_doc(p).get(to).is_some_and(|n| n.is_frame()) {
return Err(format!("`{t}` must be a frame on the same page"));
}
Some(to)
}
None => None,
};
set.push((p, from, to));
}
let n = set.len();
for (p, from, to) in set {
ed.with_page(p, |ed| ed.edit("Prototype link", |tx| tx.node_mut(from).unwrap().link = to));
}
text(json!({"linked": n}))
}
/// The inspector's way to set instance properties on the page being viewed.
pub fn set_props_ui(ed: &mut Editor, ids: &[NodeId], name: &str, value: &str) -> Result<(), String> {
let page = ed.active_page;
@@ -6378,6 +6523,11 @@ fn swap_instance(ed: &mut Editor, a: &Args) -> ToolResult {
(d, s)
}
};
// Checked before `with_page`, which parks the other pages' documents it needs to read.
let under = ed.page_doc(page).get(inst).and_then(|n| n.parent);
if lib.is_none() && cpage != page && crate::editor::structure::would_nest_across(ed, cpage, new_main, page, under) {
return Err(NEST_ERR.into());
}
ed.with_page(page, |ed| {
// An instance root: linked, and not itself inside an instance. A missing main is fine,
// so a swap restores such an instance.
@@ -6392,8 +6542,8 @@ fn swap_instance(ed: &mut Editor, a: &Args) -> ToolResult {
a.req_str("nodeId").unwrap_or_default()
));
}
if cpage == page && lib.is_none() && (inst == new_main || ed.doc.is_ancestor(inst, new_main)) {
return Err("could not swap instance to that component".into());
if cpage == page && lib.is_none() && crate::editor::structure::swap_nests(&ed.doc, inst, new_main) {
return Err(NEST_ERR.into());
}
// A nested instance (inside another instance) keeps its place in it: a swap override.
let ok = ed.edit("Swap instance", |tx| {
@@ -6480,6 +6630,7 @@ fn diff_frames(ed: &mut Editor, a: &Args) -> ToolResult {
/// design system is project-wide, so every page gets it in one undo step.
#[cfg(not(target_arch = "wasm32"))]
fn import_design_system(host: &mut dyn Host, a: &Args) -> ToolResult {
check_path(a.req_str("path")?)?;
let dir = PathBuf::from(a.req_str("path")?);
let pkg = crate::design_system::import_package(&dir)?;
let ed = host.editor();
@@ -6521,6 +6672,9 @@ fn import_design_system(_: &mut dyn Host, _: &Args) -> ToolResult {
#[cfg(not(target_arch = "wasm32"))]
fn export_design_system(host: &mut dyn Host, a: &Args) -> ToolResult {
let dir = PathBuf::from(a.req_str("path")?);
out_folder(&dir, "manifest.json", |b| {
std::str::from_utf8(b).is_ok_and(|raw| crate::design_system::parse_manifest(raw).is_ok())
})?;
let project = host.project().map(|p| p.manifest.name.clone());
let ed = host.editor();
let page = page_arg(ed, a)?;
@@ -6658,7 +6812,7 @@ fn set_code_link(ed: &mut Editor, a: &Args) -> ToolResult {
}
/// The JSON a tool call answers with (its first text block).
fn call_json(host: &mut dyn Host, name: &str, args: Value) -> Result<Value, String> {
pub(super) fn call_json(host: &mut dyn Host, name: &str, args: Value) -> Result<Value, String> {
let outs = call(host, name, &args)?;
let t = outs.iter().find_map(|o| if let Out::Text(t) = o { Some(t.as_str()) } else { None }).ok_or_else(|| format!("{name} answered no text"))?;
serde_json::from_str(t).map_err(|e| format!("{name}: {e}"))
@@ -6883,6 +7037,11 @@ fn duplicate_nodes(ed: &mut Editor, a: &Args) -> ToolResult {
(pp, Some(Some(pid)))
}
};
if let Some(under) = parent
&& crate::editor::structure::copy_nests_across(ed, src_page, id, dest_page, under)
{
return Err(NEST_ERR.into());
}
jobs.push((src_page, dest_page, id, parent));
}
let mut out = Vec::new();
@@ -7019,6 +7178,9 @@ fn move_nodes(ed: &mut Editor, a: &Args) -> ToolResult {
return Err("the destination can't have children".into());
}
}
if crate::editor::structure::moves_into_cycle(doc, id, target) {
return Err(NEST_ERR.into());
}
tx.reparent(id, target, None);
let list: Vec<NodeId> = tx
.doc()
@@ -7052,6 +7214,9 @@ fn move_nodes(ed: &mut Editor, a: &Args) -> ToolResult {
{
return Err("the destination can't have children".into());
}
if crate::editor::structure::copy_nests_across(ed, src_page, id, dest_page, target) {
return Err(NEST_ERR.into());
}
let src_doc = ed.page_doc(src_page).clone();
let ids = (ed.pages[src_page].id.clone(), ed.pages[dest_page].id.clone());
// Notes on the moved nodes go with them instead of staying behind detached.
+255
View File
@@ -0,0 +1,255 @@
//! `export_handoff`: the folder a code base builds from, written in one call. Files are read
//! on demand by agents billed per token, so the bundle is many small files behind a one-line-
//! per-screen index, every file's size in tokens is in the manifest, and a run that changes
//! nothing writes nothing (a diff of the folder is the change).
use super::super::exec::{self, Host};
use super::super::{Out, ToolResult};
use super::{markdown, report_since, str_arg, text};
use crate::model::{Color, Document, NodeId, NodeKind};
use serde_json::{Value, json};
use std::collections::BTreeMap;
use crate::io::backup::safe_relative;
use std::path::Path;
const MANIFEST: &str = "manifest.json";
/// Bumped when a file's layout changes, so a reader can refuse a bundle it does not know.
const FORMAT: u32 = 1;
/// Characters per token, near enough to budget a brief.
const CHARS_PER_TOKEN: usize = 4;
/// The page holding the team's question table, by name.
const HANDOFF_PAGE: &str = "handoff";
pub fn export(host: &mut dyn Host, a: &Value) -> ToolResult {
let dir = Path::new(str_arg(a, "dir").ok_or("`dir` is required: the folder to write the bundle into")?).to_path_buf();
exec::out_folder(&dir, MANIFEST, is_bundle_manifest)?;
let images = a.get("images").and_then(Value::as_bool).unwrap_or(true);
let mut files: BTreeMap<String, Vec<u8>> = BTreeMap::new();
let mut inv: Value = serde_json::from_str(&call(host, "get_inventory", json!({}))?).map_err(|e| e.to_string())?;
let project = host.project().map_or_else(|| "Untitled".into(), |p| p.manifest.name.clone());
let questions = handoff_rows(host);
inv["project"] = json!(project);
inv["handoffQuestions"] = json!(questions);
files.insert("inventory.json".into(), pretty(&inv)?);
files.insert("tokens.json".into(), call(host, "get_variables", json!({"format": "json"}))?.into_bytes());
files.insert("tokens.css".into(), call(host, "get_variables", json!({"format": "css"}))?.into_bytes());
files.insert("tokens.tailwind.css".into(), call(host, "get_variables", json!({"format": "tailwind"}))?.into_bytes());
files.insert("components.json".into(), call(host, "get_components", json!({}))?.into_bytes());
files.insert("open-questions.md".into(), open_questions(&inv, &questions).into_bytes());
let gpu = if images { host.gpu().map(Ok).unwrap_or_else(crate::render::gpu::Headless::new) } else { Err("images: false".into()) };
let mut index = Vec::new();
let mut shots = 0;
for page in inv["pages"].as_array().into_iter().flatten() {
let (page_id, page_name) = (page["id"].as_str().unwrap_or_default(), page["name"].as_str().unwrap_or_default());
let screens = page["screens"].as_array().into_iter().flatten().collect::<Vec<_>>();
if screens.is_empty() {
continue;
}
index.push(format!("\n## {page_name} (screens/{}/)", slug(page_name)));
let mut used = Vec::new();
for s in screens {
let (key, name) = (s["id"].as_str().unwrap_or_default(), s["name"].as_str().unwrap_or_default());
let mut stem = format!("screens/{}/{}", slug(page_name), slug(name));
if used.contains(&stem) {
stem = format!("{stem}-{}", key.rsplit(':').next().unwrap_or_default());
}
used.push(stem.clone());
let spec = call(host, "get_design_spec", json!({"pageId": page_id, "nodeId": key, "format": "compact"}))?;
let (w, h, class) = (s["width"].as_f64().unwrap_or(0.0), s["height"].as_f64().unwrap_or(0.0), s["class"].as_str().unwrap_or_default());
let spec = format!("<!-- {key} {w}x{h} {class} -->\n{spec}");
index.push(format!("- {name} · {class} {w}×{h} · `{key}` · {}.txt ~{}", stem.rsplit('/').next().unwrap_or_default(), tokens(spec.len())));
files.insert(format!("{stem}.txt"), spec.into_bytes());
if let Ok(g) = &gpu {
let ed = host.editor();
let Some(p) = ed.pages.iter().position(|p| p.id == page_id) else { continue };
let id = key.rsplit(':').next().and_then(|n| n.parse().ok()).map(NodeId).ok_or("bad screen key")?;
files.insert(format!("{stem}.png"), shot(g, ed.page_doc(p), id)?);
shots += 1;
}
}
}
let changes = match str_arg(a, "since") {
Some(since) => {
let r = report_since(host, since)?;
files.insert("changes.md".into(), markdown(&r, None).into_bytes());
files.insert("changes.json".into(), pretty(&serde_json::to_value(&r).map_err(|e| e.to_string())?)?);
Some(json!({"tokens": r.tokens.len(), "components": r.components.len(), "screens": r.screens.len()}))
}
None => None,
};
let screens = inv["screens"].as_u64().unwrap_or(0);
let head = format!(
"# {project}: design handoff\n{screens} screens · {} components. Build from these files, not the live app. Tokens: tokens.json (DTCG, modes), tokens.css, tokens.tailwind.css. Components: components.json. Questions: open-questions.md.{}\nEach screen: compact spec (.txt, ~tokens) and a reference image (.png) beside it; a scrolling screen's image shows its whole content.\n",
inv["components"].as_array().map_or(0, Vec::len),
if changes.is_some() { " Changes since the handoff point: changes.md (index), changes.json." } else { "" },
);
files.insert("index.md".into(), (head + &index.join("\n") + "\n").into_bytes());
let (written, removed) = write(&dir, files.clone())?;
let mut out = json!({"dir": dir, "files": files.len() + 1, "written": written, "removed": removed, "screens": screens, "images": shots});
if let Err(e) = gpu {
out["imagesSkipped"] = json!(e);
}
if let Some(c) = changes {
out["changes"] = c;
}
text(out)
}
/// Whether `bytes` is a manifest this tool wrote: its exact `format` and a `files` map of
/// entries that each carry a content `hash`. A foreign manifest.json with a `format` key is not.
fn is_bundle_manifest(bytes: &[u8]) -> bool {
let Ok(m) = serde_json::from_slice::<Value>(bytes) else { return false };
m["format"] == FORMAT && m["files"].as_object().is_some_and(|f| f.values().all(|e| e["hash"].is_string()))
}
fn call(host: &mut dyn Host, tool: &str, args: Value) -> Result<String, String> {
exec::call(host, tool, &args)?
.into_iter()
.find_map(|o| match o {
Out::Text(t) => Some(t),
Out::Image { .. } => None,
})
.ok_or_else(|| format!("{tool} returned no text"))
}
fn pretty(v: &Value) -> Result<Vec<u8>, String> {
serde_json::to_vec_pretty(v).map_err(|e| e.to_string())
}
fn tokens(bytes: usize) -> usize {
bytes.div_ceil(CHARS_PER_TOKEN)
}
/// FNV-1a: stable across builds, which `DefaultHasher` is not.
fn hash(bytes: &[u8]) -> String {
let h = bytes.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x0100_0000_01b3));
format!("{h:016x}")
}
/// The screen at scale 1 on white, unrolled when its content scrolls (as the deck shows it).
fn shot(g: &crate::render::gpu::Headless, doc: &Document, id: NodeId) -> Result<Vec<u8>, String> {
let unrolled = crate::io::deck::unroll::unrolled(doc, &[id]);
let doc = unrolled.as_ref().unwrap_or(doc);
let roots = crate::io::export::with_layers_on_top(doc, &[id]);
crate::io::export::encode_png(&crate::io::png::render(g, doc, Some(&roots), 1.0, Some(Color::WHITE))?)
}
/// Writes the files whose bytes differ and the manifest, and removes files the last manifest
/// listed that this one does not (nothing else in the folder is touched). Returns how many
/// files were written and removed.
fn write(dir: &Path, mut files: BTreeMap<String, Vec<u8>>) -> Result<(usize, usize), String> {
let old: Value = std::fs::read(dir.join(MANIFEST)).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or(Value::Null);
let entries: BTreeMap<&String, Value> = files
.iter()
.map(|(k, v)| {
let mut e = json!({"hash": hash(v)});
if !k.ends_with(".png") {
e["tokens"] = json!(tokens(v.len()));
}
(k, e)
})
.collect();
let manifest = pretty(&json!({"format": FORMAT, "files": entries}))?;
files.insert(MANIFEST.into(), manifest);
let mut written = 0;
for (rel, bytes) in &files {
let path = dir.join(rel);
if std::fs::read(&path).is_ok_and(|b| &b == bytes) {
continue;
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
}
crate::io::atomic::write(&path, bytes).map_err(|e| format!("{}: {e}", path.display()))?;
written += 1;
}
let mut removed = 0;
for rel in old["files"].as_object().into_iter().flat_map(|m| m.keys()).filter(|k| !files.contains_key(*k)) {
// The manifest is read back from disk: only plain relative paths inside the folder.
let Some(rel) = safe_relative(rel) else { continue };
if std::fs::remove_file(dir.join(rel)).is_ok() {
removed += 1;
}
}
Ok((written, removed))
}
/// A file name from a layer name, as `shipcraft/bin/pc-freeze.py` writes it: word characters
/// kept, runs of spaces, `/` and `·` as one `-`, lower case.
fn slug(s: &str) -> String {
let kept: String = s.chars().filter(|c| c.is_alphanumeric() || c.is_whitespace() || "_/·.-".contains(*c)).collect();
let mut out = String::new();
for part in kept.trim().split(|c: char| c.is_whitespace() || c == '/' || c == '·').filter(|p| !p.is_empty()) {
if !out.is_empty() {
out.push('-');
}
out.push_str(part);
}
let out = out.trim_matches('-').to_lowercase();
if out.is_empty() { "unnamed".into() } else { out }
}
/// The question table on a page named "Handoff": rows of (id like `C7`, question, where,
/// proposed default) text layers, in layer order.
fn handoff_rows(host: &mut dyn Host) -> Vec<Value> {
let ed = host.editor();
let Some(p) = ed.pages.iter().position(|p| p.name.trim().eq_ignore_ascii_case(HANDOFF_PAGE)) else { return Vec::new() };
let doc = ed.page_doc(p);
let mut texts = Vec::new();
let mut stack: Vec<NodeId> = doc.root.iter().rev().copied().collect();
while let Some(id) = stack.pop() {
let Some(n) = doc.get(id).filter(|n| n.visible) else { continue };
if let NodeKind::Text { text, .. } = &n.kind {
texts.push(text.trim().to_owned());
}
stack.extend(n.children.iter().rev());
}
let is_id = |s: &str| {
let letters = s.chars().take_while(char::is_ascii_uppercase).count();
let digits = s.len() - letters;
(1..=3).contains(&letters) && (1..=3).contains(&digits) && s[letters..].chars().all(|c| c.is_ascii_digit())
};
let mut rows = Vec::new();
let mut i = 0;
while i + 3 < texts.len() {
if is_id(&texts[i]) {
rows.push(json!({"id": texts[i], "question": texts[i + 1], "where": texts[i + 2], "default": texts[i + 3]}));
i += 4;
} else {
i += 1;
}
}
rows
}
fn open_questions(inv: &Value, table: &[Value]) -> String {
let mut q = vec!["# Open questions".to_owned(), "Build with the proposed default; change it only when the owner answers otherwise.".into()];
if !table.is_empty() {
q.push("\n## Handoff table\n| # | Question | Where | Proposed default |\n|---|---|---|---|".into());
q.extend(table.iter().map(|r| format!("| {} | {} | {} | {} |", r["id"].as_str().unwrap_or_default(), r["question"].as_str().unwrap_or_default(), r["where"].as_str().unwrap_or_default(), r["default"].as_str().unwrap_or_default())));
}
for p in inv["pages"].as_array().into_iter().flatten() {
let open = p["openQuestions"].as_array().into_iter().flatten().collect::<Vec<_>>();
if open.is_empty() {
continue;
}
q.push(format!("\n## {}: notes marked `? (team)`", p["name"].as_str().unwrap_or_default()));
q.extend(open.iter().map(|n| format!("- **{}**: {}", n["layer"].as_str().unwrap_or("detached"), n["text"].as_str().unwrap_or_default().replace('\n', " — "))));
}
q.join("\n") + "\n"
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slugs_match_the_freeze_script() {
assert_eq!(slug("Consultor / Primeiro acesso · Pronto"), "consultor-primeiro-acesso-pronto");
assert_eq!(slug("Pedidos (PDVs)!"), "pedidos-pdvs");
assert_eq!(slug(" "), "unnamed");
}
}
+391
View File
@@ -0,0 +1,391 @@
//! What changed since a handoff point, sorted the way the code side acts on it: token values
//! once (regenerate the theme), main components once (their instances follow), then screens.
//! Built on [`diff_pages`]; what this adds is dropping what code never sees (sizes that follow
//! content, positions set by auto layout, instance fields that follow their main, a token's
//! new value repeated on every node using it) and naming the action for each kind.
use crate::mcp::diff::{NodeDiff, PropChange, diff_pages};
use crate::model::{Document, Node, NodeId, NodeKind, Paint, Sizing, Source};
use serde::Serialize;
use std::collections::{BTreeMap, BTreeSet};
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Kind {
Copy,
Binding,
Style,
Layout,
Structure,
Instance,
Note,
Added,
Removed,
Renamed,
}
impl Kind {
pub fn name(self) -> &'static str {
match self {
Kind::Copy => "copy",
Kind::Binding => "binding",
Kind::Style => "style",
Kind::Layout => "layout",
Kind::Structure => "structure",
Kind::Instance => "instance",
Kind::Note => "note",
Kind::Added => "added",
Kind::Removed => "removed",
Kind::Renamed => "renamed",
}
}
}
/// What each kind asks of the code; printed once at the top of a report.
pub const ACTIONS: &str = "Actions: copy = update the string · binding/style = restyle that node · layout/structure = rebuild that subtree (get_design_spec {nodeId, format: compact}) · instance = swap the component or its props · note = review the rule · added = build from its spec · removed = delete it · renamed = rename the symbol or layer id.";
/// A move or resize smaller than this is noise.
const MIN_PX: f64 = 1.0;
#[derive(Debug, Serialize)]
pub struct Line {
pub kind: Kind,
pub key: String,
/// Layer names from the screen (or main) down, without it.
pub path: String,
pub field: String,
pub before: String,
pub after: String,
}
/// The changes of one screen or one main component.
#[derive(Debug, Serialize)]
pub struct Group {
pub page: String,
pub key: String,
pub name: String,
pub kinds: BTreeSet<Kind>,
pub lines: Vec<Line>,
/// Screens using a main component.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub used_by: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct Token {
pub name: String,
pub before: String,
pub after: String,
/// Screens drawing with it.
pub screens: Vec<String>,
}
#[derive(Debug, Default, Serialize)]
pub struct Report {
pub since: String,
pub tokens: Vec<Token>,
pub components: Vec<Group>,
pub screens: Vec<Group>,
}
impl Report {
pub fn is_empty(&self) -> bool {
self.tokens.is_empty() && self.components.is_empty() && self.screens.is_empty()
}
}
/// One page at the handoff point and now (None where it did not exist).
pub struct PagePair<'a> {
pub id: &'a str,
pub name: &'a str,
/// Prefix of its node keys (`<prefix>:<id>`).
pub prefix: String,
pub before: Option<&'a Document>,
pub after: Option<&'a Document>,
}
/// The pages of one side, by page id, to find main components across pages.
type Side<'a> = BTreeMap<&'a str, &'a Document>;
pub fn report(since: &str, pairs: &[PagePair]) -> Report {
let empty = Document::default();
let before: Side = pairs.iter().filter_map(|p| Some((p.id, p.before?))).collect();
let after: Side = pairs.iter().filter_map(|p| Some((p.id, p.after?))).collect();
let mut groups: BTreeMap<(bool, usize, u64), Group> = BTreeMap::new();
let mut tokens: BTreeMap<String, Token> = BTreeMap::new();
for (pi, p) in pairs.iter().enumerate() {
let (b, a) = (p.before.unwrap_or(&empty), p.after.unwrap_or(&empty));
let key = |id: NodeId| format!("{}:{}", p.prefix, id.0);
let mut push = |doc: &Document, id: NodeId, line: Option<Line>, kind: Kind| {
let Some((main, owner)) = owner_of(doc, id) else { return };
let g = groups.entry((main, pi, owner.0)).or_insert_with(|| Group {
page: p.name.to_owned(),
key: key(owner),
name: doc.get(owner).map(|n| n.name.clone()).unwrap_or_default(),
kinds: BTreeSet::new(),
lines: Vec::new(),
used_by: Vec::new(),
});
g.kinds.insert(kind);
g.lines.extend(line);
};
let d = diff_pages(b, a);
for nd in d.groups.iter().flat_map(|g| &g.nodes) {
let removed = nd.status == "removed";
let (doc, id) = if removed { (b, nd.id_a) } else { (a, nd.id_b) };
let Some(id) = id else { continue };
let owner = owner_of(doc, id).map(|(_, o)| o);
let path = path_of(doc, id, owner);
if owner == Some(id) && nd.status != "modified" {
push(doc, id, None, if removed { Kind::Removed } else { Kind::Added });
continue;
}
if nd.status != "modified" {
let what = format!("{} \"{}\"", nd.kind, nd.name);
let (before, after) = if removed { (what, String::new()) } else { (String::new(), what) };
let line = Line { kind: Kind::Structure, key: key(id), path, field: nd.status.into(), before, after };
push(doc, id, Some(line), Kind::Structure);
continue;
}
let (Some(na), Some(nb)) = (b.get(id), a.get(id)) else { continue };
for (kind, c) in classify(nd, na, nb, a, &after) {
let kind = if owner == Some(id) && c.field == "name" { Kind::Renamed } else { kind };
let line = Line { kind, key: key(id), path: path.clone(), field: c.field.into(), before: c.before, after: c.after };
push(a, id, Some(line), kind);
}
}
// Instance swaps and property values: not part of a node's own properties.
for (id, nb) in &a.nodes {
let Some(na) = b.get(*id) else { continue };
let (Some(la), Some(lb)) = (&na.instance, &nb.instance) else { continue };
if !is_instance_root(nb, a, &after) {
continue;
}
let path = path_of(a, *id, owner_of(a, *id).map(|(_, o)| o));
let line = |field: &str, before: String, after: String| Line { kind: Kind::Instance, key: key(*id), path: path.clone(), field: field.into(), before, after };
if la.target() != lb.target() {
let name = |side: &Side, l: &crate::model::InstanceLink, doc: &Document| main_of(side, l, doc).map_or("?".into(), |m| m.name.clone());
push(a, *id, Some(line("component", name(&before, la, b), name(&after, lb, a))), Kind::Instance);
}
if na.prop_values != nb.prop_values {
let show = |v: &BTreeMap<String, String>| v.iter().map(|(k, v)| format!("{k}={v}")).collect::<Vec<_>>().join(", ");
push(a, *id, Some(line("props", show(&na.prop_values), show(&nb.prop_values))), Kind::Instance);
}
}
for ad in &d.annotations {
let (old, new) = (b.annotations.iter().find(|n| n.id == ad.id), a.annotations.iter().find(|n| n.id == ad.id));
let Some((doc, target)) = new.and_then(|n| Some((a, n.target?))).or_else(|| old.and_then(|n| Some((b, n.target?)))) else { continue };
let show = |n: Option<&crate::model::annotation::Annotation>| n.map_or(String::new(), |n| format!("{} — {}", n.label, n.body.lines().next().unwrap_or("")).trim_end_matches(" — ").to_owned());
let line = Line { kind: Kind::Note, key: key(target), path: path_of(doc, target, owner_of(doc, target).map(|(_, o)| o)), field: "note".into(), before: show(old), after: show(new) };
push(doc, target, Some(line), Kind::Note);
}
token_changes(b, a, &mut tokens);
}
// Who uses each changed main, and each changed token.
let screens_using = |hit: &dyn Fn(&str, &Document, &Node) -> bool| -> Vec<String> {
let mut names = BTreeSet::new();
for (pid, doc) in &after {
for n in doc.nodes.values().filter(|n| hit(pid, doc, n)) {
if let Some((false, s)) = owner_of(doc, n.id) {
names.insert(doc.get(s).map(|s| s.name.clone()).unwrap_or_default());
}
}
}
names.into_iter().collect()
};
for ((main, pi, owner), g) in groups.iter_mut() {
if !*main {
continue;
}
let (page, owner) = (pairs[*pi].id, NodeId(*owner));
g.used_by = screens_using(&|pid, doc, n| {
n.instance.as_ref().is_some_and(|l| {
let (m, src) = l.target();
m == owner && match src {
None => pid == page,
Some(Source::Page(p)) => p == page,
Some(Source::Library(_)) => false,
}
}) && is_instance_root(n, doc, &after)
});
}
for t in tokens.values_mut() {
t.screens = screens_using(&|_, _, n| uses_var(n, &t.name));
}
let (components, screens): (Vec<_>, Vec<_>) = groups.into_iter().partition(|((main, ..), _)| *main);
Report {
since: since.into(),
tokens: tokens.into_values().collect(),
components: components.into_iter().map(|(_, g)| g).collect(),
screens: screens.into_iter().map(|(_, g)| g).collect(),
}
}
/// Who a node's change belongs to: `(true, main)` inside a main component, else `(false,
/// screen)`, the nearest declared screen above it or its top-level frame.
fn owner_of(doc: &Document, id: NodeId) -> Option<(bool, NodeId)> {
let chain: Vec<NodeId> = std::iter::once(id).chain(doc.ancestors(id)).collect();
if let Some(m) = chain.iter().find(|i| doc.get(**i).is_some_and(|n| n.component)) {
return Some((true, *m));
}
let top = *chain.last()?;
let screen = chain.iter().find(|i| doc.get(**i).is_some_and(|n| n.screen)).copied();
doc.get(top).filter(|n| n.is_frame() || screen.is_some()).map(|_| (false, screen.unwrap_or(top)))
}
fn path_of(doc: &Document, id: NodeId, owner: Option<NodeId>) -> String {
let mut names = Vec::new();
let mut cur = Some(id);
while let Some(n) = cur.filter(|c| Some(*c) != owner).and_then(|c| doc.get(c)) {
names.push(n.name.as_str());
cur = n.parent;
}
names.reverse();
names.join(" > ")
}
fn main_of<'a>(side: &Side<'a>, l: &crate::model::InstanceLink, own: &'a Document) -> Option<&'a Node> {
let (m, src) = l.target();
match src {
None => own.get(m),
Some(Source::Page(p)) => side.get(p.as_str()).and_then(|d| d.get(m)),
Some(Source::Library(_)) => None,
}
}
/// The top of an instance: its link points at a main component itself (a library main counts).
fn is_instance_root(n: &Node, doc: &Document, side: &Side) -> bool {
n.instance.as_ref().is_some_and(|l| matches!(l.target().1, Some(Source::Library(_))) || main_of(side, l, doc).is_some_and(|m| m.component))
}
/// The changes of a node present on both sides that code sees, by kind.
fn classify(nd: &NodeDiff, na: &Node, nb: &Node, doc: &Document, side: &Side) -> Vec<(Kind, PropChange)> {
// Inside an instance a field follows its main unless overridden; the main reports it.
let inner = nb.instance.is_some() && !is_instance_root(nb, doc, side);
let follows = |key: &str| inner && !nb.instance.as_ref().is_some_and(|l| l.overrides.iter().any(|o| o == key));
let mut out = Vec::new();
for c in &nd.changes {
let (kind, key) = match c.field {
"text" => (Kind::Copy, "text"),
"fills" => {
let (x, y) = (fills(na), fills(nb));
if x == y || follows("fills") {
continue;
}
let kind = if x.contains("--") || y.contains("--") { Kind::Binding } else { Kind::Style };
out.push((kind, PropChange { field: "fills", before: x, after: y }));
continue;
}
"stroke" => {
let (x, y) = (stroke(na), stroke(nb));
if x == y || follows("stroke") {
continue;
}
let kind = if x.contains("--") || y.contains("--") { Kind::Binding } else { Kind::Style };
out.push((kind, PropChange { field: "stroke", before: x, after: y }));
continue;
}
"corner_radius" => (Kind::Style, "radius"),
"opacity" => (Kind::Style, "opacity"),
"font_size" => (Kind::Style, "font"),
"size" if !sized_by_hand(na, nb) => continue,
"size" => (Kind::Layout, "size"),
"position" if !placed_by_hand(nb, doc) || moved_less(na, nb) => continue,
"position" => (Kind::Layout, "position"),
"layout" => (Kind::Layout, "layout"),
"visible" => (Kind::Structure, "visible"),
"name" => (Kind::Structure, "name"),
"parent" | "order" => (Kind::Structure, ""),
"comments" => continue,
_ => (Kind::Style, ""),
};
if !key.is_empty() && follows(key) {
continue;
}
out.push((kind, c.clone()));
}
out
}
/// A size code sets: an axis that is fixed and changed by a pixel or more (hug and fill sizes,
/// and auto-width text, follow their content or parent).
fn sized_by_hand(na: &Node, nb: &Node) -> bool {
let auto_text = matches!(nb.kind, NodeKind::Text { auto_width: true, .. });
let s = nb.layout_child.sizing;
let w = s[0] == Sizing::Fixed && !auto_text && (na.rect.w - nb.rect.w).abs() >= MIN_PX;
let h = s[1] == Sizing::Fixed && !matches!(nb.kind, NodeKind::Text { .. }) && (na.rect.h - nb.rect.h).abs() >= MIN_PX;
w || h
}
/// A position code sets: not a top-level frame on the canvas, not placed by auto layout.
fn placed_by_hand(n: &Node, doc: &Document) -> bool {
n.parent.and_then(|p| doc.get(p)).is_some_and(|p| p.layout.is_none() || n.layout_child.absolute)
}
fn moved_less(na: &Node, nb: &Node) -> bool {
(na.rect.x - nb.rect.x).abs() < MIN_PX && (na.rect.y - nb.rect.y).abs() < MIN_PX
}
/// Fills as code writes them: a token name, or a value when there is none.
fn fills(n: &Node) -> String {
let all: Vec<String> = n
.style
.fills
.iter()
.map(|p| match p {
Paint::Solid { var: Some(v), .. } => format!("--{v}"),
Paint::Solid { color, var: None } => color.to_hex(),
Paint::Image { .. } => "image".into(),
Paint::Linear { .. } => "linear-gradient".into(),
Paint::Radial { .. } => "radial-gradient".into(),
})
.collect();
if all.is_empty() { "none".into() } else { all.join(", ") }
}
fn stroke(n: &Node) -> String {
let Some(c) = n.style.stroke.filter(|_| n.style.stroke_width > 0.0) else { return "none".into() };
let color = n.bindings.get("stroke").map_or_else(|| c.to_hex(), |v| format!("--{}", v.trim_start_matches("--")));
format!("{color} {}px", n.style.stroke_width)
}
fn uses_var(n: &Node, name: &str) -> bool {
let bare = name.trim_start_matches("--");
n.style.fills.iter().any(|p| matches!(p, Paint::Solid { var: Some(v), .. } if v == bare)) || n.bindings.values().any(|v| v.trim_start_matches("--") == bare)
}
/// Variable values (each mode) and dimension tokens that changed, once per name across pages.
fn token_changes(b: &Document, a: &Document, out: &mut BTreeMap<String, Token>) {
let mut values = |name: &str, mode: Option<&str>, x: Option<String>, y: Option<String>| {
if x == y {
return;
}
let show = |v: Option<String>| v.unwrap_or_else(|| "none".into());
let label = |v: String| mode.map_or(v.clone(), |m| format!("{m} {v}"));
let t = out.entry(format!("--{name}")).or_insert_with(|| Token { name: format!("--{name}"), before: String::new(), after: String::new(), screens: Vec::new() });
let (bx, ay) = (label(show(x)), label(show(y)));
if !t.before.split("; ").any(|s| s == bx) {
t.before = [t.before.as_str(), bx.as_str()].into_iter().filter(|s| !s.is_empty()).collect::<Vec<_>>().join("; ");
t.after = [t.after.as_str(), ay.as_str()].into_iter().filter(|s| !s.is_empty()).collect::<Vec<_>>().join("; ");
}
};
// With modes, the base values are only the active mode's: switching modes is no change.
if b.modes.is_empty() && a.modes.is_empty() {
let names: BTreeSet<&String> = b.variables.keys().chain(a.variables.keys()).collect();
for n in names {
values(n, None, b.variables.get(n).map(|c| c.to_hex()), a.variables.get(n).map(|c| c.to_hex()));
}
}
let modes: BTreeSet<&String> = b.modes.keys().chain(a.modes.keys()).collect();
for m in modes {
let (x, y) = (b.modes.get(m), a.modes.get(m));
let names: BTreeSet<&String> = x.into_iter().chain(y).flat_map(|v| v.keys()).collect();
for n in names {
values(n, Some(m), x.and_then(|v| v.get(n)).map(|c| c.to_hex()), y.and_then(|v| v.get(n)).map(|c| c.to_hex()));
}
}
let names: BTreeSet<&String> = b.tokens.keys().chain(a.tokens.keys()).collect();
for n in names {
values(n, None, b.tokens.get(n).cloned(), a.tokens.get(n).cloned());
}
}
+155
View File
@@ -0,0 +1,155 @@
//! Design handoff (`specs/design-handoff/spec.md`): handoff points, the change report against
//! one, and the bundle a code base builds from.
#[cfg(not(target_arch = "wasm32"))]
pub mod bundle;
pub mod changes;
use super::exec::Host;
use super::{Out, ToolResult};
use crate::handoffs::{self, Handoff};
use changes::{Group, Kind, PagePair, Report};
use serde_json::{Value, json};
use std::path::PathBuf;
/// Change lines shown per screen in the index; the rest are one `get_changes {nodeId}` away.
const LINES_PER_GROUP: usize = 8;
/// Screen names listed under a changed token or component.
const NAMES_SHOWN: usize = 6;
/// Longest value printed, except copy, which code needs whole.
const VALUE_CHARS: usize = 80;
fn project_dir(host: &dyn Host) -> Result<PathBuf, String> {
host.project().map(|p| p.dir.clone()).ok_or_else(|| "handoff points live in a project: open one first".into())
}
fn str_arg<'a>(a: &'a Value, k: &str) -> Option<&'a str> {
a.get(k).and_then(Value::as_str).filter(|s| !s.trim().is_empty())
}
/// `mark_handoff {name}`: snapshot every page as handoff point `name`.
pub fn mark(host: &mut dyn Host, a: &Value) -> ToolResult {
let name = str_arg(a, "name").ok_or("`name` is required (e.g. \"design v1\")")?.trim().to_owned();
let dir = project_dir(host)?;
let ed = host.editor();
ed.finish_loading();
let pages: Vec<handoffs::Page> =
(0..ed.pages.len()).map(|i| handoffs::Page { id: ed.pages[i].id.clone(), name: ed.pages[i].name.clone(), doc: ed.page_doc(i).clone() }).collect();
let screens: usize = pages.iter().map(|p| p.doc.root.iter().filter(|id| p.doc.get(**id).is_some_and(|n| n.is_frame() && !n.component)).count()).sum();
let h = Handoff { name: name.clone(), saved: crate::project::format::now_ms(), pages };
handoffs::save(&dir, &h).map_err(|e| e.to_string())?;
text(json!({"name": name, "pages": h.pages.len(), "screens": screens}))
}
/// `list_handoffs`: the project's handoff points, newest first.
pub fn list(host: &mut dyn Host) -> ToolResult {
let dir = project_dir(host)?;
let all: Vec<Value> = handoffs::list(&dir).into_iter().map(|h| json!({"name": h.name, "saved": h.saved})).collect();
text(json!({"handoffs": all}))
}
/// The report of what changed since handoff point `since`.
pub fn report_since(host: &mut dyn Host, since: &str) -> Result<Report, String> {
let dir = project_dir(host)?;
let h = handoffs::load(&dir, since)?;
let ed = host.editor();
ed.finish_loading();
let ed: &crate::editor::Editor = ed;
let mut pairs: Vec<PagePair> = (0..ed.pages.len())
.map(|i| {
let p = &ed.pages[i];
PagePair { id: &p.id, name: &p.name, prefix: prefix(&p.id), before: h.pages.iter().find(|b| b.id == p.id).map(|b| &b.doc), after: Some(ed.page_doc(i)) }
})
.collect();
for b in h.pages.iter().filter(|b| !ed.pages.iter().any(|p| p.id == b.id)) {
pairs.push(PagePair { id: &b.id, name: &b.name, prefix: prefix(&b.id), before: Some(&b.doc), after: None });
}
Ok(changes::report(&h.name, &pairs))
}
/// Node keys use a page's first six characters, as everywhere else (`exec::node_key`).
fn prefix(page_id: &str) -> String {
page_id[..page_id.len().min(6)].to_owned()
}
/// `get_handoff_changes {since, nodeId?, format?}`.
pub fn get_changes(host: &mut dyn Host, a: &Value) -> ToolResult {
let since = str_arg(a, "since").ok_or("`since` is required: a handoff point name (list_handoffs)")?;
let r = report_since(host, since)?;
let only = str_arg(a, "nodeId");
match str_arg(a, "format").unwrap_or("markdown") {
"markdown" | "md" => Ok(vec![Out::Text(markdown(&r, only))]),
"json" => text(serde_json::to_value(&r).map_err(|e| e.to_string())?),
f => Err(format!("unknown format `{f}` (markdown or json)")),
}
}
fn text(v: Value) -> ToolResult {
Ok(vec![Out::Text(serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?)])
}
fn clip(s: &str) -> String {
if s.chars().count() <= VALUE_CHARS { s.to_owned() } else { format!("{}…", s.chars().take(VALUE_CHARS).collect::<String>()) }
}
fn names(all: &[String]) -> String {
let shown = all.iter().take(NAMES_SHOWN).map(String::as_str).collect::<Vec<_>>().join(", ");
match all.len().saturating_sub(NAMES_SHOWN) {
0 => shown,
more => format!("{shown} +{more}"),
}
}
/// The report as an index a reader acts from: one heading per screen or component and its
/// first few changes; `only` (a node key) prints that one group whole.
pub fn markdown(r: &Report, only: Option<&str>) -> String {
let mut out = format!("# Changes since `{}`\n", r.since);
if r.is_empty() {
out.push_str("No changes.\n");
return out;
}
let groups = |gs: &[Group]| gs.iter().filter(|g| only.is_none_or(|k| k == g.key)).count();
if only.is_none() {
let new = r.screens.iter().filter(|g| g.kinds.contains(&Kind::Added)).count();
let gone = r.screens.iter().filter(|g| g.kinds.contains(&Kind::Removed)).count();
let counts = [(r.tokens.len(), "tokens"), (r.components.len(), "components"), (r.screens.len(), "screens"), (new, "added"), (gone, "removed")];
let counts: Vec<String> = counts.iter().filter(|(n, _)| *n > 0).map(|(n, w)| if *n == 1 { format!("1 {}", w.trim_end_matches('s')) } else { format!("{n} {w}") }).collect();
out += &format!("{}\n{}\n", counts.join(" · "), changes::ACTIONS);
if !r.tokens.is_empty() {
out.push_str("\n## Tokens: regenerate the theme, no screen work\n");
for t in &r.tokens {
out += &format!("- {}: {} → {} · {} screens{}\n", t.name, t.before, t.after, t.screens.len(), if t.screens.is_empty() { String::new() } else { format!(": {}", names(&t.screens)) });
}
}
}
let cap = if only.is_some() { usize::MAX } else { LINES_PER_GROUP };
for (title, gs) in [("Components: rebuild the component, its instances follow", &r.components), ("Screens", &r.screens)] {
if groups(gs) == 0 {
continue;
}
out += &format!("\n## {title}\n");
for g in gs.iter().filter(|g| only.is_none_or(|k| k == g.key)) {
let kinds: Vec<&str> = g.kinds.iter().map(|k| k.name()).collect();
out += &format!("### {} / {} `{}` · {}", g.page, g.name, g.key, kinds.join(", "));
if !g.used_by.is_empty() {
out += &format!(" · used on {} screens: {}", g.used_by.len(), names(&g.used_by));
}
out.push('\n');
for l in g.lines.iter().take(cap) {
let at = if l.path.is_empty() { String::new() } else { format!("{} ", l.path) };
let key = if l.key == g.key { String::new() } else { format!("`{}` ", l.key) };
let (b, a) = if l.kind == Kind::Copy { (l.before.clone(), l.after.clone()) } else { (clip(&l.before), clip(&l.after)) };
let change = match (b.is_empty(), a.is_empty()) {
(true, false) => format!("{} {a}", l.field),
(false, true) => format!("{} {b}", l.field),
_ => format!("{}: {b} → {a}", l.field),
};
out += &format!("- {at}{key}{}: {change}\n", l.kind.name());
}
if g.lines.len() > cap {
out += &format!("- … {} more: get_handoff_changes {{since: \"{}\", nodeId: \"{}\"}}\n", g.lines.len() - cap, r.since, g.key);
}
}
}
out
}
+5 -25
View File
@@ -115,7 +115,7 @@ fn run_codex(bin: &Path, prompt: &str) -> Result<Vec<PathBuf>, String> {
let scratch = std::env::temp_dir().join(format!("papercraft-imagegen-{}", std::process::id()));
std::fs::create_dir_all(&scratch).map_err(|e| e.to_string())?;
let mut cmd = Command::new(bin);
cmd.env("PATH", crate::chat::path::resolved_path());
crate::chat::path::no_window(&mut cmd).env("PATH", crate::chat::path::resolved_path());
for k in CREDENTIAL_ENV {
cmd.env_remove(k);
}
@@ -154,21 +154,10 @@ fn run_codex(bin: &Path, prompt: &str) -> Result<Vec<PathBuf>, String> {
fn run_command(template: &str, prompt: &str, aspect: &str) -> Result<Vec<PathBuf>, String> {
let out = std::env::temp_dir().join(format!("papercraft-imagegen-{}-{}-{:08x}", std::process::id(), crate::project::format::now_ms(), fastrand::u32(..)));
std::fs::create_dir_all(&out).map_err(|e| e.to_string())?;
let script = shell_script(template);
let mut cmd = if cfg!(windows) {
let mut c = Command::new("cmd.exe");
c.args(["/C", &script]);
c
} else {
let mut c = Command::new("/bin/sh");
c.args(["-c", &script]);
c
};
let mut child = cmd
.env("PAPERCRAFT_PROMPT", prompt)
.env("PAPERCRAFT_ASPECT", aspect)
.env("PAPERCRAFT_RATIO", ratio(aspect))
.env("PAPERCRAFT_OUT", &out)
let out_dir = out.display().to_string();
let vars = [("{prompt}", "PAPERCRAFT_PROMPT", prompt), ("{aspect}", "PAPERCRAFT_ASPECT", aspect), ("{ratio}", "PAPERCRAFT_RATIO", ratio(aspect)), ("{out}", "PAPERCRAFT_OUT", out_dir.as_str())];
let mut cmd = super::shell::command(template, &vars);
let mut child = crate::chat::path::no_window(&mut cmd)
// An app opened from Finder has a bare PATH: give the command the user's shell PATH.
.env("PATH", crate::chat::path::resolved_path())
.current_dir(&out)
@@ -197,15 +186,6 @@ fn run_command(template: &str, prompt: &str, aspect: &str) -> Result<Vec<PathBuf
Ok(images)
}
/// The template with its placeholders turned into quoted variable references.
fn shell_script(template: &str) -> String {
template
.replace("{prompt}", "\"$PAPERCRAFT_PROMPT\"")
.replace("{aspect}", "\"$PAPERCRAFT_ASPECT\"")
.replace("{ratio}", "\"$PAPERCRAFT_RATIO\"")
.replace("{out}", "\"$PAPERCRAFT_OUT\"")
}
/// The thread id of a `codex exec --json` run (`thread.started`).
fn thread_id(events: &str) -> Option<String> {
events
+545
View File
@@ -0,0 +1,545 @@
//! `import_html`: opens a page exported by a prompt-to-design tool (one `.html`, or a `.zip` with
//! its stylesheets and images) as layers. The CSS is inlined into `style` attributes and the
//! images become project assets; `write_html` then maps the result like any other markup.
//!
//! Each HTML file becomes its own frame on the page, side by side, rather than its own page:
//! the files of one export are screens of one design, which are easier to compare and link when
//! they share a canvas, and the caller can still name a page to put them on.
use std::collections::{BTreeMap, BTreeSet, HashMap};
use serde_json::{Value, json};
use super::ToolResult;
use super::exec::{Host, call_json, check_path, text};
use super::html::{self, Element, Html};
use crate::io::{backup::safe_relative, zip};
/// A page without a width of its own gets a desktop viewport, as a browser window would.
const DEFAULT_WIDTH: &str = "1280px";
/// Elements of a document head that make no layers.
const HEAD_TAGS: [&str; 3] = ["link", "meta", "base"];
/// Largest HTML file read, and largest zip (read whole, then inflated up to `MAX_INFLATED`).
const MAX_HTML_BYTES: u64 = 64 << 20;
const MAX_ZIP_BYTES: u64 = 256 << 20;
const MAX_INFLATED: usize = 256 << 20;
const VOID: [&str; 5] = ["img", "input", "br", "hr", "wbr"];
pub fn run(host: &mut dyn Host, args: &Value) -> ToolResult {
let path = args["path"].as_str().ok_or("missing required string argument `path`")?.trim();
check_path(path)?;
let is_zip = check_file(path)?;
let bytes = crate::vfs::read(path).map_err(|e| format!("{path}: {e}"))?;
let mut warnings = Vec::new();
let (files, resources) = if is_zip {
archive(&bytes, &mut warnings)?
} else {
let src = String::from_utf8(bytes).map_err(|_| format!("{path}: not UTF-8 text"))?;
let name = path.rsplit(['/', '\\']).next().unwrap_or(path).to_owned();
(vec![(name, src)], HashMap::new())
};
if files.is_empty() {
return Err(format!("{path}: no .html file found"));
}
let (mut frames, mut ignored) = (Vec::new(), Vec::new());
for (name, src) in files {
let html = page(&name, &src, &resources, &mut warnings);
let mut w = json!({"targetNodeId": "root", "html": html});
if let Some(p) = args.get("pageId").filter(|p| !p.is_null()) {
w["pageId"] = p.clone();
}
if let Some(f) = args.get("fileId").filter(|f| !f.is_null()) {
w["fileId"] = f.clone();
}
let r = call_json(host, "write_html", w).map_err(|e| format!("{name}: {e}"))?;
let ids: Vec<Value> = r["createdNodeIds"].as_array().cloned().unwrap_or_default();
frames.push(json!({"file": name, "nodeIds": ids}));
ignored.extend(r["ignoredStyles"].as_array().into_iter().flatten().filter_map(|v| v.as_str().map(str::to_owned)));
warnings.extend(r["warnings"].as_array().into_iter().flatten().filter_map(|v| v.as_str().map(|s| format!("{name}: {s}"))));
}
let mut v = json!({"imported": frames});
if !ignored.is_empty() {
v["ignoredStyles"] = json!(group_ignored(ignored.iter().map(String::as_str)));
}
if !warnings.is_empty() {
v["warnings"] = json!(warnings);
}
text(v)
}
/// Whether `path` is a zip, after checking it is a page file of a sane size. The text of the
/// file comes back to the caller, so nothing else (`~/.ssh/config`) may be opened.
fn check_file(path: &str) -> Result<bool, String> {
let lower = path.to_ascii_lowercase();
let is_zip = lower.ends_with(".zip");
if !is_zip && !lower.ends_with(".html") && !lower.ends_with(".htm") {
return Err(format!("{path}: import_html opens only .html, .htm and .zip files"));
}
let cap = if is_zip { MAX_ZIP_BYTES } else { MAX_HTML_BYTES };
let size = crate::vfs::metadata(path).map_err(|e| format!("{path}: {e}"))?.len();
if size > cap {
return Err(format!("{path}: {} MiB is over the {} MiB limit", size >> 20, cap >> 20));
}
Ok(is_zip)
}
/// The ignored-style entries of a write, one per property: `transform: rotate, scale`.
pub fn group_ignored<'a>(items: impl IntoIterator<Item = &'a str>) -> Vec<String> {
let mut by: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
for item in items {
let (prop, detail) = item.split_once(": ").unwrap_or((item, ""));
let details = by.entry(prop).or_default();
details.extend(detail.split(", ").filter(|d| !d.is_empty()));
}
by.into_iter()
.map(|(p, d)| if d.is_empty() { p.to_owned() } else { format!("{p}: {}", d.into_iter().collect::<Vec<_>>().join(", ")) })
.collect()
}
/// The `.html` files of an archive in order, and every other safely named file by path.
/// Entries that would leave the archive's folder when extracted are skipped.
type Archive = (Vec<(String, String)>, HashMap<String, Vec<u8>>);
fn archive(bytes: &[u8], warnings: &mut Vec<String>) -> Result<Archive, String> {
let (mut pages, mut files) = (Vec::new(), HashMap::new());
for (name, data) in zip::read_capped(bytes, MAX_INFLATED)? {
if name.starts_with("__MACOSX/") {
continue;
}
if safe_relative(&name).is_none() {
warnings.push(format!("skipped `{name}`: its path leaves the archive"));
continue;
}
let lower = name.to_ascii_lowercase();
if lower.ends_with(".html") || lower.ends_with(".htm") {
match String::from_utf8(data) {
Ok(src) => pages.push((name, src)),
Err(_) => warnings.push(format!("skipped `{name}`: not UTF-8 text")),
}
} else {
files.insert(name, data);
}
}
Ok((pages, files))
}
/// The path `href` names, relative to the file in `dir`, if it stays inside the archive.
fn resolve(dir: &str, href: &str) -> Option<String> {
let href = href.split(['?', '#']).next().unwrap_or_default();
if href.is_empty() || href.contains("://") || href.starts_with("//") || href.starts_with("data:") {
return None;
}
let mut parts: Vec<&str> = if href.starts_with('/') { vec![] } else { dir.split('/').filter(|p| !p.is_empty()).collect() };
for p in href.split('/') {
match p {
"" | "." => {}
".." => {
parts.pop()?;
}
p => parts.push(p),
}
}
Some(parts.join("/"))
}
/// One file as markup `write_html` takes: its CSS inlined, its images registered, and its body
/// as the one top-level frame.
fn page(name: &str, src: &str, resources: &HashMap<String, Vec<u8>>, warnings: &mut Vec<String>) -> String {
let dir = name.rsplit_once('/').map_or("", |(d, _)| d);
let mut tree = html::parse(src);
let css = css_in_order(src, dir, resources, name, warnings);
let rules = Rules::parse(&css, warnings);
inline(&mut tree, &rules);
images(&mut tree, dir, resources, name, warnings);
let stem = name.rsplit('/').next().unwrap_or(name).rsplit_once('.').map_or(name, |(s, _)| s);
let (mut style, children) = match find(&tree, "body") {
Some(b) => (b.attr("style").unwrap_or("").to_owned(), b.children.clone()),
None => (String::new(), tree),
};
if !declarations(&style).any(|(k, _)| k == "width") {
style = format!("width:{DEFAULT_WIDTH};{style}");
}
let frame = Element {
tag: "div".into(),
attrs: vec![("layer-name".into(), stem.to_owned()), ("style".into(), style)],
children: children.into_iter().filter(|c| !matches!(c, Html::Element(e) if HEAD_TAGS.contains(&e.tag.as_str()))).collect(),
raw: None,
};
serialize(&Html::Element(frame))
}
fn find<'a>(tree: &'a [Html], tag: &str) -> Option<&'a Element> {
tree.iter().find_map(|h| match h {
Html::Element(e) if e.tag == tag => Some(e),
Html::Element(e) => find(&e.children, tag),
Html::Text(_) => None,
})
}
/// The `href` of every `<link rel="stylesheet">`.
fn links(tree: &[Html]) -> Vec<String> {
let mut out = Vec::new();
for h in tree {
if let Html::Element(e) = h {
if e.tag == "link" && e.attr("rel").is_some_and(|r| r.eq_ignore_ascii_case("stylesheet")) {
out.extend(e.attr("href").map(str::to_owned));
}
out.extend(links(&e.children));
}
}
out
}
/// The CSS of every `<style>` block (the parser drops them from the tree) and linked stylesheet,
/// in document order, so a later rule overrides an earlier one as in a browser.
fn css_in_order(src: &str, dir: &str, resources: &HashMap<String, Vec<u8>>, name: &str, warnings: &mut Vec<String>) -> String {
let lower = src.to_ascii_lowercase();
let (mut out, mut at) = (String::new(), 0);
while let Some(open) = [lower[at..].find("<style"), lower[at..].find("<link")].into_iter().flatten().min().map(|o| at + o) {
let Some(gt) = lower[open..].find('>').map(|g| open + g) else { break };
at = gt + 1;
if lower[open..].starts_with("<style") {
let end = lower[at..].find("</style").map_or(src.len(), |e| at + e);
out.push_str(&src[at..end]);
out.push('\n');
at = end;
continue;
}
for href in links(&html::parse(&src[open..at])) {
match resolve(dir, &href).and_then(|p| resources.get(&p)) {
Some(b) => out.push_str(&String::from_utf8_lossy(b)),
None => warnings.push(format!("{name}: stylesheet `{href}` not found in the archive")),
}
}
}
out
}
/// Images in the archive become project assets, named by the id `write_html` accepts as a src.
fn images(tree: &mut [Html], dir: &str, resources: &HashMap<String, Vec<u8>>, name: &str, warnings: &mut Vec<String>) {
for h in tree {
let Html::Element(e) = h else { continue };
if e.tag == "img"
&& let Some(src) = e.attr("src").map(str::to_owned)
&& let Some(path) = resolve(dir, &src)
{
let id = resources.get(&path).ok_or_else(|| "not in the archive".to_owned()).and_then(|b| crate::assets::register(b));
match id {
Ok(id) => {
if let Some(a) = e.attrs.iter_mut().find(|(k, _)| k == "src") {
a.1 = id;
}
}
Err(why) => warnings.push(format!("{name}: image `{src}`: {why}")),
}
}
images(&mut e.children, dir, resources, name, warnings);
}
}
// ---------------------------------------------------------------------------------- CSS
/// A selector of one element: `tag`, `.class`, `#id`, `*` and their combinations.
struct Simple {
tag: Option<String>,
id: Option<String>,
classes: Vec<String>,
}
impl Simple {
fn parse(sel: &str) -> Option<Simple> {
let mut s = Simple { tag: None, id: None, classes: vec![] };
let mut rest = sel.trim();
let name = |r: &str| r.find(|c: char| !(c.is_ascii_alphanumeric() || c == '-' || c == '_')).unwrap_or(r.len());
if let Some(r) = rest.strip_prefix('*') {
rest = r;
} else if !rest.starts_with(['.', '#']) {
let n = name(rest);
s.tag = Some(rest[..n].to_ascii_lowercase());
rest = &rest[n..];
}
while !rest.is_empty() {
let kind = rest.chars().next()?;
let k = kind.len_utf8();
let n = name(&rest[k..]);
let word = rest[k..k + n].to_owned();
match kind {
'.' if n > 0 => s.classes.push(word),
'#' if n > 0 => s.id = Some(word),
_ => return None,
}
rest = &rest[k + n..];
}
(s.tag.as_deref() != Some("")).then_some(s)
}
fn matches(&self, e: &Element) -> bool {
let class = e.attr("class").unwrap_or("");
self.tag.as_ref().is_none_or(|t| *t == e.tag)
&& self.id.as_deref().is_none_or(|i| e.attr("id") == Some(i))
&& self.classes.iter().all(|c| class.split_whitespace().any(|k| k == c))
}
/// Ids, then classes, then the tag decide which rule wins.
fn specificity(&self) -> (usize, usize, usize) {
(usize::from(self.id.is_some()), self.classes.len(), usize::from(self.tag.is_some()))
}
}
struct Rules {
/// In cascade order: later wins.
list: Vec<(Simple, String)>,
}
impl Rules {
fn parse(css: &str, warnings: &mut Vec<String>) -> Rules {
let (mut vars, mut raw, mut skipped) = (HashMap::new(), Vec::new(), BTreeSet::new());
for (sel, body) in blocks(&strip_comments(css)) {
if sel.starts_with('@') {
skipped.insert(sel.split_whitespace().next().unwrap_or("@").to_owned());
continue;
}
let is_root = matches!(sel.as_str(), ":root" | "html");
for part in sel.split(',') {
raw.push((part.trim().to_owned(), body.clone(), is_root));
}
if is_root {
for (k, v) in declarations(&body).filter(|(k, _)| k.starts_with("--")) {
vars.insert(k, v);
}
}
}
let mut list: Vec<(Simple, String)> = Vec::new();
for (sel, body, is_root) in raw {
match Simple::parse(&sel) {
Some(s) => {
let body = declarations(&body)
.filter(|(k, _)| !k.starts_with("--"))
.map(|(k, v)| format!("{k}:{}", substitute(&v, &vars)))
.collect::<Vec<_>>()
.join(";");
list.push((s, body));
}
None if is_root => {}
None => {
skipped.insert(sel);
}
}
}
// Stable: equal specificity keeps source order.
list.sort_by_key(|(s, _)| s.specificity());
if !skipped.is_empty() {
let shown: Vec<_> = skipped.iter().take(4).cloned().collect();
warnings.push(format!("{} CSS rule(s) not inlined (only tag, .class and #id selectors are): {}", skipped.len(), shown.join(" | ")));
}
Rules { list }
}
}
fn strip_comments(css: &str) -> String {
let mut out = String::with_capacity(css.len());
let mut rest = css;
while let Some(i) = rest.find("/*") {
out.push_str(&rest[..i]);
rest = rest[i + 2..].split_once("*/").map_or("", |(_, r)| r);
}
out + rest
}
/// `selector { body }` pairs at the top level; at-rules keep their `@name` as selector.
fn blocks(css: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
let mut rest = css;
while let Some(open) = rest.find('{') {
let sel = rest[..open].trim().to_owned();
let (mut depth, mut end) = (0usize, rest.len());
for (i, c) in rest[open..].char_indices() {
match c {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
end = open + i;
break;
}
}
_ => {}
}
}
out.push((sel, rest[open + 1..end].to_owned()));
rest = rest.get(end + 1..).unwrap_or("");
}
out
}
/// Splits on `;` outside parentheses and quotes, so `url(data:...;base64,...)` and `"a;b"` stay whole.
fn split_declarations(body: &str) -> Vec<&str> {
let (mut out, mut start, mut depth, mut quote) = (Vec::new(), 0, 0usize, None);
for (i, c) in body.char_indices() {
match (c, quote) {
(q @ ('"' | '\''), None) => quote = Some(q),
(q, Some(open)) if q == open => quote = None,
('(', None) => depth += 1,
(')', None) => depth = depth.saturating_sub(1),
(';', None) if depth == 0 => {
out.push(&body[start..i]);
start = i + 1;
}
_ => {}
}
}
out.push(&body[start..]);
out
}
fn declarations(body: &str) -> impl Iterator<Item = (String, String)> + '_ {
split_declarations(body).into_iter().filter_map(|d| {
let (k, v) = d.split_once(':')?;
let v = v.replace("!important", "");
Some((k.trim().to_ascii_lowercase(), v.trim().to_owned()))
})
}
/// `var(--x)` and `var(--x, fallback)` with the page's own custom properties.
fn substitute(value: &str, vars: &HashMap<String, String>) -> String {
let mut out = String::new();
let mut rest = value;
while let Some(i) = rest.find("var(--") {
out.push_str(&rest[..i]);
let inner = &rest[i + 4..];
let Some(end) = inner.find(')') else { break };
let name = inner[..end].split(',').next().unwrap_or("").trim();
match vars.get(name) {
Some(v) => out.push_str(v),
None => out.push_str(&rest[i..i + 4 + end + 1]),
}
rest = &inner[end + 1..];
}
out + rest
}
/// Puts the matching rules before each element's own `style`, so the element's own wins.
fn inline(tree: &mut [Html], rules: &Rules) {
for h in tree {
let Html::Element(e) = h else { continue };
let css: Vec<&str> = rules.list.iter().filter(|(s, _)| s.matches(e)).map(|(_, b)| b.as_str()).filter(|b| !b.is_empty()).collect();
if !css.is_empty() {
let style = format!("{};{}", css.join(";"), e.attr("style").unwrap_or(""));
match e.attrs.iter_mut().find(|(k, _)| k.eq_ignore_ascii_case("style")) {
Some(a) => a.1 = style,
None => e.attrs.push(("style".into(), style)),
}
}
inline(&mut e.children, rules);
}
}
// ---------------------------------------------------------------------------------- markup
fn escape(s: &str, quote: bool) -> String {
let s = s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;");
if quote { s.replace('"', "&quot;") } else { s }
}
fn serialize(h: &Html) -> String {
match h {
Html::Text(t) => escape(t, false),
Html::Element(e) => {
if let Some(raw) = &e.raw {
return raw.clone();
}
let attrs: String = e.attrs.iter().map(|(k, v)| format!(" {k}=\"{}\"", escape(v, true))).collect();
if VOID.contains(&e.tag.as_str()) {
return format!("<{}{attrs}/>", e.tag);
}
format!("<{0}{attrs}>{1}</{0}>", e.tag, e.children.iter().map(serialize).collect::<String>())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn css_is_inlined_by_specificity_and_the_elements_own_style_wins() {
let mut w = Vec::new();
let rules = Rules::parse(":root{--c:#123} p{color:red} .a{color:var(--c);margin:0} #x{color:blue} .a .b{color:green} @media (x){p{color:pink}}", &mut w);
let mut t = html::parse("<p id=x class=a style=\"margin:4px\">t</p>");
inline(&mut t, &rules);
let out = serialize(&t[0]);
assert!(out.contains("color:red;color:#123;margin:0;color:blue;margin:4px"), "{out}");
assert!(w[0].contains("2 CSS rule(s)"), "{w:?}");
}
#[test]
fn non_ascii_selectors_do_not_panic() {
let mut w = Vec::new();
let rules = Rules::parse(".título{color:red} p€{color:red} p{color:blue}", &mut w);
assert_eq!(rules.list.len(), 1, "only `p` is a supported selector");
assert!(w[0].contains("2 CSS rule(s)"), "{w:?}");
}
#[test]
fn declarations_split_outside_urls_and_strings() {
let d: Vec<_> = declarations("background:url(data:image/png;base64,AAA);content:\"a;b\";color:red").collect();
assert_eq!(d.len(), 3, "{d:?}");
assert_eq!(d[0].1, "url(data:image/png;base64,AAA)");
assert_eq!(d[1].1, "\"a;b\"");
}
#[test]
fn css_follows_document_order_and_width_means_width() {
let res = HashMap::from([("a.css".to_owned(), b"p{color:red}".to_vec())]);
let mut w = Vec::new();
let src = "<style>p{color:green}</style><link rel=stylesheet href=a.css><style>p{color:blue}</style><body><p>x</p></body>";
let css = css_in_order(src, "", &res, "x.html", &mut w);
assert!(css.find("green") < css.find("red") && css.find("red") < css.find("blue"), "{css}");
let out = page("x.html", "<body style=\"max-width:600px;border-width:1px\"><p>x</p></body>", &res, &mut w);
assert!(out.contains("width:1280px"), "{out}");
let out = page("x.html", "<body style=\"width:600px\"><p>x</p></body>", &res, &mut w);
assert!(!out.contains("1280px"), "{out}");
}
#[test]
fn archive_skips_macos_and_non_utf8_pages() {
let z = zip::write(&[
("__MACOSX/._a.html".into(), vec![0xff, 0xfe]),
("b.html".into(), vec![0xff, 0xfe]),
("a.html".into(), b"<p>x</p>".to_vec()),
]);
let mut w = Vec::new();
let (pages, _) = archive(&z, &mut w).unwrap();
assert_eq!(pages.len(), 1);
assert_eq!(w.len(), 1, "{w:?}");
assert!(w[0].contains("b.html"));
}
#[test]
fn only_page_files_of_a_sane_size_are_opened() {
let dir = std::env::temp_dir();
let txt = dir.join(format!("pc-imp-{:016x}.txt", fastrand::u64(..)));
std::fs::write(&txt, "secret").unwrap();
let err = check_file(&txt.to_string_lossy()).unwrap_err();
assert!(err.contains("only .html"), "{err}");
let big = dir.join(format!("pc-imp-{:016x}.HTML", fastrand::u64(..)));
std::fs::File::create(&big).unwrap().set_len(MAX_HTML_BYTES + 1).unwrap();
let err = check_file(&big.to_string_lossy()).unwrap_err();
assert!(err.contains("limit"), "{err}");
std::fs::remove_file(&txt).ok();
std::fs::remove_file(&big).ok();
}
#[test]
fn ignored_styles_group_by_property() {
let g = group_ignored(["transform: rotate", "transform: scale", "float", "transform: rotate"]);
assert_eq!(g, ["float", "transform: rotate, scale"]);
}
#[test]
fn paths_stay_inside_the_archive() {
assert_eq!(resolve("a/b", "../c.css").as_deref(), Some("a/c.css"));
assert_eq!(resolve("", "../c.css"), None);
assert_eq!(resolve("a", "https://x/y.css"), None);
}
}
+1 -1
View File
@@ -80,7 +80,7 @@ fn variable_names(name: &str, args: &Value) -> Option<Option<BTreeSet<String>>>
"set_variables" => Some(keys("variables")),
"set_tokens" => Some(keys("tokens")),
"delete_variables" => Some(keys("names")),
"set_variable_mode" | "import_design_system" => Some(None),
"set_variable_mode" | "set_strings" | "import_design_system" => Some(None),
_ => None,
}
}
+26 -19
View File
@@ -17,7 +17,7 @@ pub struct Issue {
}
/// Screens at least this wide are desktop (pointer) UIs.
const DESKTOP_MIN_W: f64 = 1024.0;
pub(crate) const DESKTOP_MIN_W: f64 = 1024.0;
/// Touch targets: Apple HIG 44pt, WCAG 2.5.5 (AAA).
const TOUCH_TARGET: f64 = 44.0;
/// Pointer targets: WCAG 2.2 2.5.8 (AA). Compact 32px desktop controls pass.
@@ -140,6 +140,19 @@ fn off_grid(v: f64) -> bool {
v > 0.0 && (v / 4.0 - (v / 4.0).round()).abs() > 1e-6
}
/// A text layer's contrast against what is behind it: `(text, backdrop, ratio, needed ratio)`.
/// WCAG AA needs 4.5:1, or 3:1 for large text (24px, or 18.66px bold).
pub fn text_contrast(doc: &Document, id: NodeId) -> Option<(Color, Color, f64, f64)> {
let n = doc.get(id)?;
let size = n.kind.font_size()?;
n.kind.text()?;
let (fg, _) = solid(n)?;
let bg = backdrop(doc, id)?;
let fg = blend(bg, fg);
let large = size >= 24.0 || (size >= 18.66 && n.text_style.bold);
Some((fg, bg, contrast(fg, bg), if large { 3.0 } else { 4.5 }))
}
/// Lint the subtrees under `roots` (instances are skipped: their component is linted instead).
pub fn lint(doc: &Document, roots: &[NodeId], scale: &[f64]) -> Vec<Issue> {
let mut out: Vec<Issue> = Vec::new();
@@ -169,25 +182,19 @@ pub fn lint(doc: &Document, roots: &[NodeId], scale: &[f64]) -> Vec<Issue> {
};
// ---- text
if let (Some(size), Some(_)) = (n.kind.font_size(), n.kind.text()) {
if let Some((fg, _)) = solid(n)
&& let Some(bg) = backdrop(doc, id)
if let Some((fg, bg, ratio, need)) = text_contrast(doc, id)
&& ratio < need
{
let fg = blend(bg, fg);
let ratio = contrast(fg, bg);
let large = size >= 24.0 || (size >= 18.66 && n.text_style.bold);
let need = if large { 3.0 } else { 4.5 };
if ratio < need {
add(
"contrast",
"error",
format!(
"text {} on {} is {ratio:.1}:1 (needs {need}:1)",
fg.to_hex(),
bg.to_hex()
),
"use a lighter or darker text color, or change the background".into(),
);
}
add(
"contrast",
"error",
format!(
"text {} on {} is {ratio:.1}:1 (needs {need}:1)",
fg.to_hex(),
bg.to_hex()
),
"use a lighter or darker text color, or change the background".into(),
);
}
if let Some(l) = crate::text::layout_node(n)
&& let Some(t) = n.kind.text()
+11
View File
@@ -13,29 +13,40 @@
//! relay server itself (native only).
pub mod bounds;
pub mod budget;
pub mod codegen;
pub mod context;
pub mod critique;
pub mod css;
pub mod diff;
pub mod enforcer;
pub mod handoff;
pub mod exec;
pub mod html;
pub mod import_html;
pub mod incremental;
pub mod lint;
pub mod lint_overflow;
mod prototype;
#[cfg(not(target_arch = "wasm32"))]
pub mod imagegen;
#[cfg(not(target_arch = "wasm32"))]
pub mod delegate;
#[cfg(not(target_arch = "wasm32"))]
pub mod shell;
#[cfg(not(target_arch = "wasm32"))]
pub mod refs;
pub mod rewrite;
pub mod strings;
pub mod room;
pub mod screen_fit;
#[cfg(not(target_arch = "wasm32"))]
pub mod site;
pub mod swarm;
#[cfg(not(target_arch = "wasm32"))]
pub mod relay;
pub mod relay_proto;
pub mod core_tools;
#[cfg(not(target_arch = "wasm32"))]
pub mod server;
pub mod steer;
+66
View File
@@ -0,0 +1,66 @@
//! `set_prototype_links`: what clicking a layer does in Present mode.
use super::exec::{page_arg, resolve, text, Args};
use super::ToolResult;
use crate::editor::Editor;
use crate::model::link::valid_url;
use crate::model::{LinkAction, NodeId};
use serde_json::json;
/// One layer's new click behaviour: a frame to go to, or another action, or neither (cleared).
type Change = (usize, NodeId, Option<NodeId>, Option<LinkAction>);
/// A frame on the page of the link's layer; links never cross pages.
fn target_frame(ed: &Editor, key: &str, page: usize) -> Result<NodeId, String> {
let (tp, id) = resolve(ed, key, page)?;
if tp != page || !ed.page_doc(page).get(id).is_some_and(|n| n.is_frame()) {
return Err(format!("`{key}` must be a frame on the same page"));
}
Ok(id)
}
fn parse_link(ed: &Editor, l: &Args, default_page: usize) -> Result<Change, String> {
let (p, from) = resolve(ed, l.req_str("nodeId")?, default_page)?;
let to = l.str("to").filter(|t| !t.is_empty());
let overlay = l.str("overlay").filter(|t| !t.is_empty());
let url = l.str("url");
let action = l.str("action");
let given = [to.is_some(), overlay.is_some(), url.is_some(), action.is_some()];
if given.iter().filter(|g| **g).count() > 1 {
return Err("give one of `to`, `overlay`, `url` or `action` per link".into());
}
let (link, act) = match (to, overlay, url, action) {
(Some(t), ..) => (Some(target_frame(ed, t, p)?), None),
(_, Some(t), ..) => (None, Some(LinkAction::Overlay { target: target_frame(ed, t, p)? })),
(.., Some(u), _) => (None, Some(LinkAction::Url { url: valid_url(u)? })),
(.., Some("back")) => (None, Some(LinkAction::Back)),
(.., Some("close")) => (None, Some(LinkAction::Close)),
(.., Some(other)) => return Err(format!("`action` must be \"back\" or \"close\", not `{other}`")),
_ => (None, None),
};
Ok((p, from, link, act))
}
/// Sets, per layer, one of: `to` (go to a frame), `overlay` (open a frame as an overlay), `url`
/// (https, http, mailto or tel), `action` ("back" or "close"). A layer with none of them loses
/// its link. Every link is checked before any is applied.
pub(super) fn set_prototype_links(ed: &mut Editor, a: &Args) -> ToolResult {
let page = page_arg(ed, a)?;
let mut set = Vec::new();
for l in a.arr("links")? {
let l = Args(l.as_object().ok_or("each link must be an object")?);
set.push(parse_link(ed, &l, page)?);
}
let n = set.len();
for (p, from, link, act) in set {
ed.with_page(p, |ed| {
ed.edit("Prototype link", |tx| {
if let Some(node) = tx.node_mut(from) {
node.link = link;
node.link_action = act;
}
})
});
}
text(json!({"linked": n}))
}
+190 -42
View File
@@ -3,6 +3,8 @@
//! 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 std::collections::BTreeSet;
use super::html::{Element, Html};
/// Rewrite `tree` in place; returns notes on what was only approximated.
@@ -167,47 +169,96 @@ fn grid(e: &mut Element, notes: &mut Vec<String>) {
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));
unmapped_container(&d, 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));
if cells.is_empty() {
return;
}
e.children = rows;
let n = tracks.len().max(1);
let mut unmapped = BTreeSet::new();
// An empty cell keeps the columns of a short row aligned.
let empty = |t: &String| {
let style = cell_style(std::slice::from_ref(t), col_gap);
(!style.is_empty()).then(|| div(&style, "Empty", vec![]))
};
let mut rows: Vec<Vec<Html>> = vec![vec![]];
let mut used = 0;
for cell in cells {
let span = match &cell {
Html::Element(k) => get(&decls(k), "grid-column").and_then(|v| column_span(v, n)).unwrap_or(1),
Html::Text(_) => 1,
};
if used + span > n {
if let Some(r) = rows.last_mut() {
r.extend(tracks[used..].iter().filter_map(empty));
}
rows.push(vec![]);
used = 0;
}
let style = cell_style(&tracks[used..used + span], col_gap);
let cell = match cell {
Html::Element(mut k) => {
let mut kd = decls(&k);
unmapped.extend(kd.iter().filter(|(p, _)| p.starts_with("grid") && p != "grid-column").map(|(p, _)| p.clone()));
kd.retain(|(p, _)| !p.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);
Html::Element(k)
}
t => div(&style, "Cell", vec![t]),
};
if let Some(r) = rows.last_mut() {
r.push(cell);
}
used += span;
}
if let Some(r) = rows.last_mut() {
r.extend(tracks[used.min(n)..].iter().filter_map(empty));
}
if !unmapped.is_empty() {
notes.push(format!("grid {} not supported: cells flow row by row", unmapped.into_iter().collect::<Vec<_>>().join(", ")));
}
e.children = rows
.into_iter()
.enumerate()
.map(|(r, kids)| div(&format!("display:flex;gap:{col_gap}px"), &format!("Row {}", r + 1), kids))
.collect();
}
/// `grid-template-rows`, areas and auto-flow have no layer equivalent.
fn unmapped_container(d: &[(String, String)], notes: &mut Vec<String>) {
let lost: Vec<&str> = d.iter().map(|(k, _)| k.as_str()).filter(|k| k.starts_with("grid") && !matches!(*k, "grid-template-columns" | "grid-column")).collect();
if !lost.is_empty() {
notes.push(format!("grid {} not supported: cells flow row by row", lost.join(", ")));
}
}
/// The style that gives a cell spanning `tracks` their width: `fr` tracks add their shares, px
/// tracks add their widths and the gaps between them.
fn cell_style(tracks: &[String], gap: f64) -> String {
let fr: Vec<f64> = tracks.iter().filter_map(|t| t.strip_suffix("fr").and_then(|f| f.trim().parse().ok())).collect();
if !fr.is_empty() {
return format!("flex-grow:{}", fr.iter().sum::<f64>());
}
let w: Vec<f64> = tracks.iter().filter_map(|t| px(t)).collect();
if w.is_empty() { String::new() } else { format!("width:{}px", w.iter().sum::<f64>() + gap * (w.len() - 1) as f64) }
}
/// Columns a `grid-column` value covers: `span 2`, `1 / 3` or `1 / -1`, at most `max`.
fn column_span(v: &str, max: usize) -> Option<usize> {
let span = if let Some((_, n)) = v.rsplit_once("span") {
n.trim().parse().ok()?
} else {
let (a, b) = v.split_once('/')?;
let (a, b): (i64, i64) = (a.trim().parse().ok()?, b.trim().parse().ok()?);
if b < 0 { max as i64 + b + 2 - a } else { b - a }
};
usize::try_from(span).ok().filter(|s| *s >= 1).map(|s| s.min(max))
}
/// The columns of `grid-template-columns`, with `repeat(n, x)` expanded. `auto-fill` and
@@ -291,10 +342,52 @@ fn margin(d: &[(String, String)], row: bool) -> (Option<Option<f64>>, Option<Opt
if row { (m[3], m[1]) } else { (m[0], m[2]) }
}
/// An element's padding as top, right, bottom, left when every part is a px length.
fn padding(d: &[(String, String)]) -> Option<[f64; 4]> {
let mut p = [0.0; 4];
for (k, v) in d.iter().filter(|(k, _)| k.starts_with("padding")) {
let parts: Vec<f64> = v.split_whitespace().map(px).collect::<Option<_>>()?;
match (k.as_str(), parts.as_slice()) {
("padding", [a]) => p = [*a; 4],
("padding", [a, b]) => p = [*a, *b, *a, *b],
("padding", [a, b, c]) => p = [*a, *b, *c, *b],
("padding", [a, b, c, l]) => p = [*a, *b, *c, *l],
("padding-top", [a]) => p[0] = *a,
("padding-right", [a]) => p[1] = *a,
("padding-bottom", [a]) => p[2] = *a,
("padding-left", [a]) => p[3] = *a,
_ => return None,
}
}
Some(p)
}
/// Layers cannot overlap by pulling a sibling in, so a negative margin is dropped, with a note.
fn drop_negative(k: &mut Element, notes: &mut Vec<String>) {
let mut d = decls(k);
let mut found = false;
for (_, v) in d.iter_mut().filter(|(n, _)| n.starts_with("margin")) {
let parts: Vec<&str> = v.split_whitespace().collect();
if parts.iter().any(|t| px(t).is_some_and(|x| x < 0.0)) {
found = true;
*v = parts.iter().map(|t| if px(t).is_some_and(|x| x < 0.0) { "0" } else { t }).collect::<Vec<_>>().join(" ");
}
}
if found {
notes.push(format!("negative margin dropped on `{}`", k.attr("layer-name").unwrap_or(&k.tag)));
set_decls(k, &d);
}
}
/// 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
/// spacers of their size; the margins at either end become the parent's padding; `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>) {
for c in e.children.iter_mut() {
if let Html::Element(k) = c {
drop_negative(k, notes);
}
}
let d = decls(e);
let row = is_row(&d);
let flex = matches!(get(&d, "display"), Some("flex" | "inline-flex"));
@@ -320,7 +413,11 @@ fn margins(e: &mut Element, notes: &mut Vec<String>) {
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;
// Margins at either end of the flow pad the parent, when its padding is plain px.
let (lead, trail) = (space[0], space[m.len()]);
let pad = padding(&d).filter(|_| lead > 0.0 || trail > 0.0);
// Without that padding the end margins need spacers, which the uniform gap does not make.
let uniform = !between.is_empty() && between.iter().all(|s| (s - between[0]).abs() < 1e-9) && (pad.is_some() || (lead == 0.0 && trail == 0.0));
let mut pd = d.clone();
let mut out: Vec<Html> = Vec::new();
let size = if row { "width" } else { "height" };
@@ -334,7 +431,7 @@ fn margins(e: &mut Element, notes: &mut Vec<String>) {
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 {
if !uniform && extra > 0.0 && !(j == 0 && pad.is_some()) {
out.push(spacer(extra));
}
if auto(start) {
@@ -355,12 +452,22 @@ fn margins(e: &mut Element, notes: &mut Vec<String>) {
out.push(grow());
}
}
if !uniform && space[m.len()] > 0.0 {
out.push(spacer(space[m.len()]));
if !uniform && trail > 0.0 && pad.is_none() {
out.push(spacer(trail));
}
if uniform && between[0] > 0.0 {
let gapped = uniform && between[0] > 0.0;
if gapped {
pd.retain(|(k, _)| k != "gap");
pd.push(("gap".into(), format!("{}px", gap + between[0])));
}
if let Some(mut p) = pad {
let (s, t) = if row { (3, 1) } else { (0, 2) };
p[s] += lead;
p[t] += trail;
pd.retain(|(k, _)| !k.starts_with("padding"));
pd.push(("padding".into(), format!("{}px {}px {}px {}px", p[0], p[1], p[2], p[3])));
}
if gapped || pad.is_some() {
set_decls(e, &pd);
}
e.children = out;
@@ -408,6 +515,47 @@ mod tests {
assert!(out.contains("height:24px") && !out.contains("height:8px"), "block margins collapse: {out}");
}
#[test]
fn outer_margins_pad_the_parent_and_auto_centres() {
let out = run("<div style=\"padding:8px\"><p style=\"margin:16px 0\">a</p><p style=\"margin:16px 0\">b</p></div>");
assert!(out.starts_with("<div gap:16px;padding:24px 8px 24px 8px>") && !out.contains("margin") && !out.contains("Spacer"), "{out}");
let out = run("<div style=\"display:flex\"><p style=\"margin-left:12px\">a</p></div>");
assert!(out.contains("padding:0px 0px 0px 12px"), "{out}");
let out = run("<div><div style=\"width:200px;margin:0 auto\">a</div></div>");
assert!(out.contains("align-self:center") && !out.contains("margin"), "{out}");
}
#[test]
fn end_margins_survive_a_parent_whose_padding_is_not_plain_px() {
for pad in ["5%", "0 auto"] {
let out = run(&format!("<div style=\"padding:{pad}\"><p style=\"margin:16px 0\">a</p><p style=\"margin:16px 0\">b</p></div>"));
assert!(out.matches("height:16px").count() >= 2 && !out.contains("margin"), "{pad}: {out}");
}
}
#[test]
fn an_empty_grid_makes_no_rows() {
let out = run("<div style=\"display:grid;grid-template-columns:1fr 1fr\"></div>");
assert!(!out.contains("Row 1"), "{out}");
}
#[test]
fn negative_margins_are_dropped_with_a_note() {
let mut t = super::super::html::parse("<div><p style=\"margin-top:-8px\">a</p></div>");
let notes = rewrite(&mut t);
assert_eq!(notes, ["negative margin dropped on `p`"]);
}
#[test]
fn grid_spans_cover_columns_and_the_rest_is_reported() {
let out = run("<div style=\"display:grid;grid-template-columns:1fr 1fr 2fr;gap:8px\"><p style=\"grid-column:span 2\">a</p><p>b</p><p style=\"grid-column:1 / -1\">c</p></div>");
assert!(out.contains("<p flex-grow:2>a") && out.contains("<p flex-grow:2>b") && out.contains("<p flex-grow:4>c"), "{out}");
assert_eq!(out.matches("display:flex;gap:8px").count(), 2, "{out}");
let mut t = super::super::html::parse("<div style=\"display:grid;grid-template-columns:100px 100px;grid-template-rows:auto\"><p style=\"grid-row:2\">a</p></div>");
let notes = rewrite(&mut t);
assert!(notes.iter().any(|n| n.contains("grid-template-rows")) && notes.iter().any(|n| n.contains("grid-row")), "{notes:?}");
}
#[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>");
+25 -9
View File
@@ -5,7 +5,7 @@
//! `Accept` header (some clients omit it and then misread the 406 as an auth failure) and
//! answers unknown routes with a plain JSON hint rather than an OAuth-looking error.
use super::{Call, Client, ENDPOINT, Out, ToolResult, tools};
use super::{Call, Client, ENDPOINT, Out, ToolResult, core_tools, tools};
use rmcp::model::{
CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, Implementation,
ListToolsResult, PaginatedRequestParams, ServerCapabilities, ServerConfig, Tool,
@@ -77,9 +77,13 @@ impl ServerHandler for PaperServer {
async fn list_tools(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
context: RequestContext<RoleServer>,
) -> Result<ListToolsResult, ErrorData> {
Ok(ListToolsResult::with_all_items(tool_list()))
if !wants_core(&context) {
return Ok(ListToolsResult::with_all_items(tool_list()));
}
let core = core_tools::definitions(tools::definitions());
Ok(ListToolsResult::with_all_items(core.into_iter().map(|t| serde_json::from_value(t).expect("core tool definitions are valid MCP tools")).collect()))
}
async fn call_tool(
@@ -87,13 +91,19 @@ impl ServerHandler for PaperServer {
request: CallToolRequestParams,
context: RequestContext<RoleServer>,
) -> Result<CallToolResponse, ErrorData> {
let (mut name, mut args) = (request.name.to_string(), serde_json::Value::Object(request.arguments.unwrap_or_default()));
if name == core_tools::SEARCH {
let found = core_tools::search(&tools::definitions(), args["query"].as_str().unwrap_or_default(), args["detail"].as_str().unwrap_or("schema"));
return Ok(to_result(Ok(vec![Out::Text(found.to_string())])).into());
}
if name == core_tools::CALL {
match core_tools::target(&args) {
Ok(t) => (name, args) = t,
Err(e) => return Ok(to_result(Err(e)).into()),
}
}
let (reply, rx) = tokio::sync::oneshot::channel();
let call = Call {
name: request.name.to_string(),
args: serde_json::Value::Object(request.arguments.unwrap_or_default()),
client: client_of(&context),
reply,
};
let call = Call { name, args, client: client_of(&context), reply };
let r = if !(self.dispatch)(call) {
Err("papercraft is shutting down".to_string())
} else {
@@ -129,6 +139,12 @@ fn client_of(context: &RequestContext<RoleServer>) -> Client {
Client { name, session: session.into(), parent: None }
}
/// The session asked for the lean tool list (`tools=core` in the URL query).
fn wants_core(context: &RequestContext<RoleServer>) -> bool {
let parts = context.extensions.get::<axum::http::request::Parts>();
parts.and_then(|p| p.uri.query()).is_some_and(|q| q.split('&').any(|kv| kv == "tools=core"))
}
/// The chat id in an MCP URL query (`chat=<id>`).
pub fn chat_of(query: &str) -> Option<&str> {
query.split('&').find_map(|kv| kv.strip_prefix("chat="))
+143
View File
@@ -0,0 +1,143 @@
//! Runs a user-configured command template (`{prompt}`, `{out}`, ...) through the platform shell
//! without letting any value act as shell syntax: each value travels in its own environment
//! variable and the shell only dereferences it after parsing.
use std::process::Command;
/// `template` with each `(placeholder, variable, value)` turned into a quoted reference to the
/// variable, as a shell command with the variables set.
pub fn command(template: &str, vars: &[(&str, &str, &str)]) -> Command {
let mut script = template.to_owned();
for (placeholder, name, _) in vars {
// The reference brings its own quotes: quotes the template already puts around the
// placeholder would pair with them and leave the value bare.
for q in ['"', '\''] {
script = script.replace(&format!("{q}{placeholder}{q}"), placeholder);
}
script = script.replace(placeholder, &reference(name));
}
let mut cmd = shell(&script);
for (_, name, value) in vars {
cmd.env(name, env_value(value));
}
cmd
}
#[cfg(windows)]
fn shell(script: &str) -> Command {
use std::os::windows::process::CommandExt;
let mut cmd = Command::new("cmd.exe");
// `/V:ON` expands `!VAR!` after cmd has parsed the line (`%VAR%` expands before, so `&` or `<`
// in a value would be syntax). `/S` plus raw quotes: Rust's own quoting is not cmd's.
cmd.args(["/V:ON", "/S", "/C"]).raw_arg(format!("\"{script}\""));
cmd
}
#[cfg(not(windows))]
fn shell(script: &str) -> Command {
let mut cmd = Command::new("/bin/sh");
cmd.args(["-c", script]);
cmd
}
fn reference(name: &str) -> String {
if cfg!(windows) { format!("\"!{name}!\"") } else { format!("\"${name}\"") }
}
/// The text a variable carries. A value starting with `-` gets a space in front so the program
/// cannot read it as an option. On Windows the expanded text is read again by batch-file shims
/// (npm's `pi.cmd`, `gf.cmd`, ...) that inherit `/V:ON`: a `"` would end the quoting and let `&`
/// run a command, `!` would expand variables and a line break would cut the line, so those become
/// `'`, `!` and a space. A shim that re-expands an argument unquoted (`%~1`) is still unsafe.
fn env_value(value: &str) -> String {
let value = if value.starts_with('-') { format!(" {value}") } else { value.to_owned() };
if !cfg!(windows) {
return value;
}
let mut out: String = value
.chars()
.map(|c| match c {
'"' => '\'',
'!' => '\u{FF01}',
'\r' | '\n' => ' ',
c => c,
})
.collect();
// The closing quote follows: trailing backslashes must not escape it.
let trailing = out.len() - out.trim_end_matches('\\').len();
out.push_str(&"\\".repeat(trailing));
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::{Path, PathBuf};
const NASTY: &str = "a & b | c < d > e ^ f %PATH% !PATH! \"q\" \\\" g\\ \n h";
fn scratch() -> PathBuf {
let dir = std::env::temp_dir().join(format!("papercraft-shell-{}-{:08x}", std::process::id(), fastrand::u32(..)));
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// Runs `template` with `{prompt}` set to `value`; what the program wrote to `{out}`.
fn received(dir: &Path, template: &str, value: &str) -> String {
let out = dir.join("arg.txt");
let mut cmd = command(template, &[("{prompt}", "PAPERCRAFT_PROMPT", value), ("{out}", "PAPERCRAFT_OUT", &out.display().to_string())]);
cmd.current_dir(dir);
assert!(cmd.output().unwrap().status.success());
std::fs::read_to_string(&out).unwrap()
}
#[test]
fn a_value_starting_with_a_dash_is_not_read_as_an_option() {
let cmd = command("prog {p}", &[("{p}", "PAPERCRAFT_P", "--help")]);
let value = cmd.get_envs().find(|(k, _)| *k == "PAPERCRAFT_P").and_then(|(_, v)| v);
assert_eq!(value, Some(std::ffi::OsStr::new(" --help")));
}
#[test]
#[cfg(unix)]
fn a_value_reaches_the_program_as_one_literal_argument() {
let dir = scratch();
let got = received(&dir, "printf '%s' {prompt} > {out}", NASTY);
std::fs::remove_dir_all(&dir).ok();
assert_eq!(got, NASTY);
}
/// A PowerShell script that writes its first argument to the file its second names.
#[cfg(windows)]
fn writer(dir: &Path) -> PathBuf {
let script = dir.join("write.ps1");
std::fs::write(&script, "[IO.File]::WriteAllText($args[1], $args[0])").unwrap();
script
}
#[test]
#[cfg(windows)]
fn a_value_reaches_the_program_as_one_literal_argument() {
let dir = scratch();
let template = format!("powershell -NoProfile -ExecutionPolicy Bypass -File \"{}\" {{prompt}} {{out}}", writer(&dir).display());
let got = received(&dir, &template, NASTY);
std::fs::remove_dir_all(&dir).ok();
assert_eq!(got, "a & b | c < d > e ^ f %PATH% \u{FF01}PATH\u{FF01} 'q' \\' g\\ h");
}
#[test]
#[cfg(windows)]
fn a_batch_file_shim_cannot_be_made_to_run_a_command() {
let dir = scratch();
// What npm's shims do: hand the arguments on with `%*`, which cmd parses again.
let shim = dir.join("tool.cmd");
std::fs::write(&shim, format!("@powershell -NoProfile -ExecutionPolicy Bypass -File \"{}\" %*\r\n", writer(&dir).display())).unwrap();
// Escaping the quote as `\"` (enough for a program's own argv parser) lets this one run.
let attack = "x\" & echo pwned> injected.txt & \"y !PATH!";
let got = received(&dir, &format!("\"{}\" \"{{prompt}}\" {{out}}", shim.display()), attack);
let injected = dir.join("injected.txt").exists();
std::fs::remove_dir_all(&dir).ok();
assert!(!injected, "the prompt ran a command");
assert_eq!(got, "x' & echo pwned> injected.txt & 'y \u{FF01}PATH\u{FF01}");
}
}
+84
View File
@@ -0,0 +1,84 @@
//! The Angular writer: a standalone-component project built with `ng build`. One component per
//! page frame; with several locales the routes are `:lang`, each locale's messages are a JSON
//! catalog and `/` lists the languages. Each page's head comes from `site.json` (computed here
//! by the core's `head_tags`) and is applied by the `Seo` service, so prerendering writes it into
//! the HTML. Every TypeScript string literal and the JSON files come from `serde_json`; template
//! text goes through the core's Angular escaping.
mod pages;
mod project;
mod runtime;
#[cfg(test)]
mod tests;
use super::model::{Output, Render, Site, Styling};
use crate::mcp::site::style::{class_slug, unique};
use std::collections::BTreeSet;
/// File names that are not pages, so a page cannot take them.
const RESERVED_FILES: [&str; 1] = ["lang-index"];
/// Where a page component lives under `src/app/pages/`.
pub(super) struct PageFile {
pub stem: String,
pub selector: String,
}
pub fn write(site: &Site) -> Result<Output, String> {
let multi = site.locales.iter().any(|l| l.folder.is_some());
let files = page_files(site);
let mut out = Output::default();
for (path, bytes) in &site.assets {
out.files.insert(format!("public/{path}"), bytes.clone());
}
project::write(site, &mut out)?;
runtime::write(site, multi, &mut out)?;
pages::write(site, multi, &files, &mut out)?;
if let Some(base) = &site.base_url {
let abs =
|loc: usize, page: usize| format!("{base}{}", runtime::route_url(site, page, loc));
out.files.insert(
"public/sitemap.xml".into(),
site.sitemap(&abs, multi.then(|| format!("{base}/")))
.into_bytes(),
);
}
out.files
.insert("public/robots.txt".into(), site.robots().into_bytes());
out.next_steps.push(next_step(site));
Ok(out)
}
fn next_step(site: &Site) -> String {
let deploy = match site.options.render {
Render::Static => {
"Every page is prerendered to <route>/index.html, so any static host serves it."
}
Render::Spa => {
"It is a single-page app: configure the host to answer unknown paths with index.html."
}
};
let styles = match site.options.styling {
Styling::Scss => " The styles are src/styles/*.scss.",
Styling::Tailwind => " Tailwind v4 reads the templates; the theme is in src/styles.css.",
_ => "",
};
format!(
"Run `npm install` in the output folder, then `npm start` to preview or `npm run build` to write dist/site/browser. {deploy}{styles}"
)
}
/// File stems and selectors for the pages: slugs of the stems, unique and never a reserved name.
fn page_files(site: &Site) -> Vec<PageFile> {
let mut taken: BTreeSet<String> = RESERVED_FILES.iter().map(|s| (*s).to_owned()).collect();
site.pages
.iter()
.map(|p| {
let stem = unique(&mut taken, class_slug(&p.stem));
PageFile {
selector: format!("app-{stem}"),
stem,
}
})
.collect()
}
+263
View File
@@ -0,0 +1,263 @@
//! The page components, the language index and the routes. A page is a standalone component
//! whose template is the core's element tree printed in the Angular dialect.
use super::PageFile;
use crate::mcp::site::markup::{
self, Dialect, Hooks, Link, TextOut, Val, escape_attr, escape_text,
};
use crate::mcp::site::model::{Output, Site, Styling};
use crate::mcp::site::tree::{Element, LinkTarget};
/// The language index is only a list of links; it styles itself.
const INDEX_STYLE: &str = "main { margin: 2rem; font-family: system-ui, sans-serif; }";
struct NgHooks<'a> {
site: &'a Site,
multi: bool,
}
/// A TypeScript or JSON string literal.
fn lit(s: &str) -> String {
serde_json::to_string(s).unwrap_or_else(|_| "\"\"".into())
}
impl Hooks for NgHooks<'_> {
fn class(&self, el: &Element) -> Option<Val> {
let class = if self.site.options.styling == Styling::Tailwind {
el.tailwind_classes()
} else {
el.class.clone()
};
(!class.is_empty()).then_some(Val::Lit(class))
}
fn link(&self, _: &Element, target: &LinkTarget) -> Link {
let attrs = match target {
LinkTarget::Page(i) => vec![(
"routerLink".to_owned(),
Val::Expr(route_expr(self.site, self.multi, *i)),
)],
LinkTarget::Url(u) => vec![("href".to_owned(), Val::Lit(u.clone()))],
};
Link {
tag: "a".into(),
attrs,
}
}
fn asset(&self, path: &str) -> Val {
Val::Lit(format!("/{path}"))
}
fn message(&self, key: &str) -> TextOut {
if self.multi {
// Bracket access: keys hold dots and may start with a digit.
TextOut::Raw(format!("{{{{ t()[{}] }}}}", lit(key)))
} else {
TextOut::Lit(
self.site
.messages
.first()
.and_then(|m| m.get(key))
.cloned()
.unwrap_or_default(),
)
}
}
}
/// The `routerLink` array for a page: `['/', lang(), "pricing"]`, or without the language.
fn route_expr(site: &Site, multi: bool, page: usize) -> String {
let mut parts = vec!["'/'".to_owned()];
if multi {
parts.push("lang()".into());
}
if page != 0 {
parts.push(lit(&site.pages[page].stem));
}
format!("[{}]", parts.join(", "))
}
pub fn write(site: &Site, multi: bool, files: &[PageFile], out: &mut Output) -> Result<(), String> {
let hooks = NgHooks { site, multi };
for (i, (page, file)) in site.pages.iter().zip(files).enumerate() {
let mut html = String::new();
markup::render(Dialect::Angular, &hooks, &page.root, 0, &mut html);
let css = if site.options.styling == Styling::Css {
site.style.page_css(i, "/")
} else {
String::new()
};
let has_css = !css.is_empty();
if has_css {
out.files
.insert(format!("src/app/pages/{}.css", file.stem), css.into_bytes());
}
let ts = component(
&page.component,
file,
i,
multi,
html.contains("[routerLink]"),
has_css,
);
out.files.insert(
format!("src/app/pages/{}.html", file.stem),
html.into_bytes(),
);
out.files
.insert(format!("src/app/pages/{}.ts", file.stem), ts.into_bytes());
}
routes(site, multi, files, out);
if multi {
language_index(site, out);
}
Ok(())
}
fn component(
name: &str,
file: &PageFile,
page: usize,
multi: bool,
router_link: bool,
has_css: bool,
) -> String {
let stem = &file.stem;
let style = if has_css {
format!(" styleUrl: {},\n", lit(&format!("./{stem}.css")))
} else {
String::new()
};
let (core, hook, body) = if multi {
(
"Component, OnChanges, ViewEncapsulation, computed, inject, input",
"OnChanges",
format!(
" /** Bound from the :lang route parameter. */\n readonly lang = input.required<string>();\n protected readonly t = computed(() => messages(this.lang()));\n private readonly seo = inject(Seo);\n\n ngOnChanges(): void {{\n this.seo.set({page}, this.lang());\n }}\n"
),
)
} else {
(
"Component, OnInit, ViewEncapsulation, inject",
"OnInit",
format!(
" private readonly seo = inject(Seo);\n\n ngOnInit(): void {{\n this.seo.set({page});\n }}\n"
),
)
};
let router = if router_link {
"import { RouterLink } from '@angular/router';\n"
} else {
""
};
let i18n = if multi {
"import { messages } from '../i18n';\n"
} else {
""
};
let imports = if router_link {
" imports: [RouterLink],\n"
} else {
""
};
format!(
"import {{ {core} }} from '@angular/core';\n{router}{i18n}import {{ Seo }} from '../seo';\n\n@Component({{\n selector: {},\n{imports} templateUrl: {},\n{style} encapsulation: ViewEncapsulation.None,\n}})\nexport class {name} implements {hook} {{\n{body}}}\n",
lit(&file.selector),
lit(&format!("./{stem}.html"))
)
}
fn routes(site: &Site, multi: bool, files: &[PageFile], out: &mut Output) {
let path = |i: usize| match (multi, i) {
(true, 0) => ":lang".to_owned(),
(true, _) => format!(":lang/{}", site.pages[i].stem),
(false, 0) => String::new(),
(false, _) => site.pages[i].stem.clone(),
};
let mut imports = String::new();
let mut list = String::new();
if multi {
imports.push_str("import { LanguageIndex } from './pages/lang-index';\n");
list.push_str(" { path: '', component: LanguageIndex },\n");
}
for (i, (page, file)) in site.pages.iter().zip(files).enumerate() {
imports.push_str(&format!(
"import {{ {} }} from {};\n",
page.component,
lit(&format!("./pages/{}", file.stem))
));
list.push_str(&format!(
" {{ path: {}, component: {} }},\n",
lit(&path(i)),
page.component
));
}
out.files.insert("src/app/app.routes.ts".into(), format!("import {{ Routes }} from '@angular/router';\n{imports}\nexport const routes: Routes = [\n{list}];\n").into_bytes());
if site.options.render != crate::mcp::site::model::Render::Static {
return;
}
let mut server = String::from("import { RenderMode, ServerRoute } from '@angular/ssr';\n");
let mut entries = String::new();
if multi {
server.push_str("import { LANGS } from './i18n';\n\nconst langParams = async () => LANGS.map((lang) => ({ lang }));\n");
entries.push_str(" { path: '', renderMode: RenderMode.Prerender },\n");
}
for i in 0..site.pages.len() {
let params = if multi {
", getPrerenderParams: langParams"
} else {
""
};
entries.push_str(&format!(
" {{ path: {}, renderMode: RenderMode.Prerender{params} }},\n",
lit(&path(i))
));
}
entries.push_str(" { path: '**', renderMode: RenderMode.Prerender },\n");
out.files.insert(
"src/app/app.routes.server.ts".into(),
format!("{server}\nexport const serverRoutes: ServerRoute[] = [\n{entries}];\n")
.into_bytes(),
);
}
fn language_index(site: &Site, out: &mut Output) {
let langs = site.language_locales();
let items: String = site
.locales
.iter()
.enumerate()
.map(|(i, l)| {
let expr = format!("['/', {}]", lit(l.folder.as_deref().unwrap_or_default()));
let lang = l
.tag
.as_ref()
.filter(|_| langs.contains(&i))
.map(|t| {
format!(
" lang=\"{0}\" hreflang=\"{0}\"",
escape_attr(Dialect::Angular, t)
)
})
.unwrap_or_default();
format!(
" <li><a [routerLink]=\"{}\"{lang}>{}</a></li>\n",
escape_attr(Dialect::Html, &expr),
escape_text(Dialect::Angular, &l.label)
)
})
.collect();
let html = format!(
"<main>\n <h1>{}</h1>\n <ul>\n{items} </ul>\n</main>\n",
escape_text(Dialect::Angular, &site.site_title)
);
let ts = format!(
"import {{ Component, OnInit, inject }} from '@angular/core';\nimport {{ RouterLink }} from '@angular/router';\nimport {{ Seo }} from '../seo';\n\n@Component({{\n selector: 'app-lang-index',\n imports: [RouterLink],\n templateUrl: './lang-index.html',\n styles: [{}],\n}})\nexport class LanguageIndex implements OnInit {{\n private readonly seo = inject(Seo);\n\n ngOnInit(): void {{\n this.seo.setIndex();\n }}\n}}\n",
lit(INDEX_STYLE)
);
out.files
.insert("src/app/pages/lang-index.html".into(), html.into_bytes());
out.files
.insert("src/app/pages/lang-index.ts".into(), ts.into_bytes());
}
+234
View File
@@ -0,0 +1,234 @@
//! The project around the pages: package.json, angular.json, tsconfig, the entry points, the
//! global stylesheet for the chosen styling and index.html. Everything here is fixed text or
//! built with `serde_json`; the only site data is the title, the first locale and the font.
use crate::mcp::site::markup::{Dialect, escape_attr, escape_text};
use crate::mcp::site::model::{Output, Render, Site, Styling};
use serde_json::{Value, json};
/// The name of the Angular project: fixed, so it is always a valid identifier and the build
/// folder is `dist/site/browser`.
const PROJECT: &str = "site";
const TSCONFIG: &str = r#"{
"compileOnSave": false,
"compilerOptions": {
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"experimentalDecorators": true,
"importHelpers": true,
"target": "ES2022",
"module": "preserve",
"resolveJsonModule": true
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"references": [{ "path": "./tsconfig.app.json" }]
}
"#;
const TSCONFIG_APP: &str =
"{\n \"extends\": \"./tsconfig.json\",\n \"include\": [\"src/**/*.ts\"]\n}\n";
const MAIN: &str = "import { bootstrapApplication } from '@angular/platform-browser';\nimport { appConfig } from './app/app.config';\nimport { App } from './app/app';\n\nbootstrapApplication(App, appConfig).catch((err) => console.error(err));\n";
const MAIN_SERVER: &str = "import { BootstrapContext, bootstrapApplication } from '@angular/platform-browser';\nimport { App } from './app/app';\nimport { config } from './app/app.config.server';\n\nconst bootstrap = (context: BootstrapContext) => bootstrapApplication(App, config, context);\n\nexport default bootstrap;\n";
const APP: &str = "import { Component } from '@angular/core';\nimport { RouterOutlet } from '@angular/router';\n\n@Component({\n selector: 'app-root',\n imports: [RouterOutlet],\n template: '<router-outlet />',\n})\nexport class App {}\n";
const CONFIG_SERVER: &str = "import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';\nimport { provideServerRendering, withRoutes } from '@angular/ssr';\nimport { appConfig } from './app.config';\nimport { serverRoutes } from './app.routes.server';\n\nconst serverConfig: ApplicationConfig = {\n providers: [provideServerRendering(withRoutes(serverRoutes))],\n};\n\nexport const config = mergeApplicationConfig(appConfig, serverConfig);\n";
const POSTCSS: &str = "{\n \"plugins\": {\n \"@tailwindcss/postcss\": {}\n }\n}\n";
pub fn write(site: &Site, out: &mut Output) -> Result<(), String> {
let ssr = site.options.render == Render::Static;
let json = |v: &Value| {
serde_json::to_vec_pretty(v)
.map(|mut b| {
b.push(b'\n');
b
})
.map_err(|e| e.to_string())
};
out.files
.insert("package.json".into(), json(&package_json(site))?);
out.files
.insert("angular.json".into(), json(&angular_json(site))?);
out.files.insert("tsconfig.json".into(), TSCONFIG.into());
out.files
.insert("tsconfig.app.json".into(), TSCONFIG_APP.into());
out.files.insert(
".gitignore".into(),
".angular/\ndist/\nnode_modules/\n".into(),
);
out.files.insert("src/main.ts".into(), MAIN.into());
out.files.insert("src/app/app.ts".into(), APP.into());
out.files
.insert("src/app/app.config.ts".into(), app_config(ssr).into());
out.files
.insert("src/index.html".into(), index_html(site).into());
if ssr {
out.files
.insert("src/main.server.ts".into(), MAIN_SERVER.into());
out.files
.insert("src/app/app.config.server.ts".into(), CONFIG_SERVER.into());
}
match site.options.styling {
Styling::Scss => {
let stems: Vec<(usize, String)> = site
.pages
.iter()
.enumerate()
.map(|(i, p)| (i, p.stem.clone()))
.collect();
for (path, content) in site.style.scss("/", &stems) {
out.files
.insert(format!("src/styles/{path}"), content.into());
}
}
Styling::Tailwind => {
let css = format!(
"@import \"tailwindcss\";\n{}{}{}{}",
site.style.tailwind_theme(),
site.style.tokens_css(),
site.style.fonts_css("/"),
site.style.overrides_css("/", |_| true)
);
out.files.insert("src/styles.css".into(), css.into());
out.files.insert(".postcssrc.json".into(), POSTCSS.into());
}
_ => {
out.files
.insert("src/styles.css".into(), site.style.global_css("/").into());
}
}
Ok(())
}
fn package_json(site: &Site) -> Value {
let mut deps = json!({
"@angular/common": "^22.2.0",
"@angular/compiler": "^22.2.0",
"@angular/core": "^22.2.0",
"@angular/platform-browser": "^22.2.0",
"@angular/router": "^22.2.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
});
if site.options.render == Render::Static {
deps["@angular/platform-server"] = json!("^22.2.0");
deps["@angular/ssr"] = json!("^22.2.6");
}
let mut dev = json!({
"@angular/build": "^22.2.6",
"@angular/cli": "^22.2.6",
"@angular/compiler-cli": "^22.2.0",
"typescript": "~6.0.2"
});
if site.options.styling == Styling::Tailwind {
dev["@tailwindcss/postcss"] = json!("^4.3.4");
dev["postcss"] = json!("^8.5.32");
dev["tailwindcss"] = json!("^4.3.4");
}
json!({
"name": PROJECT,
"version": "0.0.0",
"private": true,
"scripts": {"ng": "ng", "start": "ng serve", "build": "ng build", "watch": "ng build --watch --configuration development"},
"dependencies": deps,
"devDependencies": dev
})
}
fn angular_json(site: &Site) -> Value {
let style = if site.options.styling == Styling::Scss {
"src/styles/main.scss"
} else {
"src/styles.css"
};
let mut options = json!({
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"assets": [{"glob": "**/*", "input": "public"}],
"styles": [style]
});
if site.options.render == Render::Static {
options["server"] = json!("src/main.server.ts");
options["outputMode"] = json!("static");
options["security"] = json!({"allowedHosts": []});
}
json!({
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {"packageManager": "npm"},
"newProjectRoot": "projects",
"projects": {PROJECT: {
"projectType": "application",
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": options,
"configurations": {
"production": {"outputHashing": "all"},
"development": {"optimization": false, "extractLicenses": false, "sourceMap": true}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": {"buildTarget": format!("{PROJECT}:build:production")},
"development": {"buildTarget": format!("{PROJECT}:build:development")}
},
"defaultConfiguration": "development"
}
}
}}
})
}
fn app_config(ssr: bool) -> String {
let (hydration_import, hydration) = if ssr {
(
"import { provideClientHydration } from '@angular/platform-browser';\n",
" provideClientHydration(),\n",
)
} else {
("", "")
};
format!(
"import {{ ApplicationConfig, provideBrowserGlobalErrorListeners }} from '@angular/core';\nimport {{ provideRouter, withComponentInputBinding, UrlSerializer }} from '@angular/router';\n{hydration_import}import {{ routes }} from './app.routes';\nimport {{ TrailingSlashSerializer }} from './trailing-slash';\n\nexport const appConfig: ApplicationConfig = {{\n providers: [\n provideBrowserGlobalErrorListeners(),\n provideRouter(routes, withComponentInputBinding()),\n {{ provide: UrlSerializer, useClass: TrailingSlashSerializer }},\n{hydration} ],\n}};\n"
)
}
fn index_html(site: &Site) -> String {
let lang = site
.locales
.first()
.and_then(|l| l.tag.as_deref())
.map(|t| format!(" lang=\"{}\"", escape_attr(Dialect::Html, t)))
.unwrap_or_default();
let tags: String = site
.asset_tags(0, "/")
.iter()
.map(|t| format!(" {}\n", t.to_html()))
.collect();
format!(
"<!doctype html>\n<html{lang}>\n<head>\n <meta charset=\"utf-8\">\n <title>{}</title>\n <base href=\"/\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n{}</head>\n<body>\n <app-root></app-root>\n</body>\n</html>\n",
escape_text(Dialect::Html, &site.site_title),
tags
)
}
+194
View File
@@ -0,0 +1,194 @@
//! What the pages share at run time: the `Seo` service and its data (`site.json`, the head of
//! every page and locale as the core computed it), the message catalogs and the serializer that
//! keeps hrefs ending in `/`.
use crate::mcp::site::model::{HeadTag, Output, Site};
use crate::mcp::site::style::{class_slug, unique};
use serde_json::{Value, json};
use std::collections::BTreeSet;
const SEO_TS: &str = r#"import { DOCUMENT, Injectable, inject } from '@angular/core';
import { Title } from '@angular/platform-browser';
import data from './site.json';
interface Tag { tag: string; attrs: [string, string][] }
interface Head { title: string; lang?: string; tags: Tag[] }
interface SiteData {
locales: { folder: string | null; lang: string | null; class: string | null }[];
pages: Head[][];
index: Head | null;
}
const site = data as unknown as SiteData;
/** Writes the head of a page: title, description, canonical, hreflang, Open Graph and Twitter tags. */
@Injectable({ providedIn: 'root' })
export class Seo {
private readonly title = inject(Title);
private readonly doc = inject(DOCUMENT);
/** The head of one page in the locale with this route folder (the first locale when unknown). */
set(page: number, folder: string | null = null): void {
const i = Math.max(0, site.locales.findIndex((l) => l.folder === folder));
const locale = site.locales[i];
this.apply(site.pages[page][i], locale.lang, locale.class);
}
/** The language index at "/". */
setIndex(): void {
if (site.index) this.apply(site.index, site.index.lang ?? null, null);
}
private apply(head: Head, lang: string | null, cls: string | null): void {
const root = this.doc.documentElement;
if (lang) root.setAttribute('lang', lang); else root.removeAttribute('lang');
if (cls) root.setAttribute('class', cls); else root.removeAttribute('class');
this.title.setTitle(head.title);
// Tags of the previous page go first, so client navigation never duplicates them.
this.doc.head.querySelectorAll('[data-seo]').forEach((n) => n.remove());
for (const t of head.tags) {
const el = this.doc.createElement(t.tag);
for (const [k, v] of t.attrs) el.setAttribute(k, v);
el.setAttribute('data-seo', '');
this.doc.head.appendChild(el);
}
}
}
"#;
const TRAILING_SLASH_TS: &str = r#"import { DefaultUrlSerializer, UrlTree } from '@angular/router';
/** Makes routerLink hrefs and router URLs end with "/" (directory style). */
export class TrailingSlashSerializer extends DefaultUrlSerializer {
override serialize(tree: UrlTree): string {
const url = super.serialize(tree);
const i = url.search(/[?#]/);
const path = i < 0 ? url : url.slice(0, i);
return (path.endsWith('/') ? path : path + '/') + (i < 0 ? '' : url.slice(i));
}
}
"#;
pub fn write(site: &Site, multi: bool, out: &mut Output) -> Result<(), String> {
out.files.insert("src/app/seo.ts".into(), SEO_TS.into());
out.files
.insert("src/app/trailing-slash.ts".into(), TRAILING_SLASH_TS.into());
let data = site_json(site, multi);
out.files.insert("src/app/site.json".into(), pretty(&data)?);
if multi {
catalogs(site, out)?;
}
Ok(())
}
fn pretty(v: &Value) -> Result<Vec<u8>, String> {
serde_json::to_vec_pretty(v)
.map(|mut b| {
b.push(b'\n');
b
})
.map_err(|e| e.to_string())
}
/// The address of a page under the site: its route with a trailing slash.
pub fn route_url(site: &Site, page: usize, loc: usize) -> String {
let route = site.url_path(page, loc);
if route.ends_with('/') {
route
} else {
format!("{route}/")
}
}
fn absolute(site: &Site, route: &str) -> String {
format!("{}{route}", site.base_url.as_deref().unwrap_or_default())
}
fn tag_json(t: &HeadTag) -> Value {
json!({"tag": t.tag, "attrs": t.attrs.iter().map(|(k, v)| json!([k, v])).collect::<Vec<_>>()})
}
fn site_json(site: &Site, multi: bool) -> Value {
let url = |_from: usize, to: usize, page: usize| absolute(site, &route_url(site, page, to));
let locales: Vec<Value> = site
.locales
.iter()
.map(|l| json!({"folder": l.folder, "lang": l.tag, "class": l.class}))
.collect();
let pages: Vec<Value> = (0..site.pages.len())
.map(|p| {
let per_locale: Vec<Value> = (0..site.locales.len())
.map(|l| json!({"title": site.pages[p].title[l], "tags": site.head_tags(p, l, &url).iter().map(tag_json).collect::<Vec<_>>()}))
.collect();
Value::Array(per_locale)
})
.collect();
json!({"locales": locales, "pages": pages, "index": multi.then(|| index_head(site))})
}
/// The head of the language index: the alternates point at the first page of each language.
fn index_head(site: &Site) -> Value {
let langs = site.language_locales();
let at = |loc: usize| absolute(site, &route_url(site, 0, loc));
let mut tags = vec![
HeadTag {
tag: "meta",
attrs: vec![
("name", "description".into()),
("content", "Choose a version of this site.".into()),
],
},
HeadTag {
tag: "link",
attrs: vec![("rel", "canonical".into()), ("href", absolute(site, "/"))],
},
];
for &l in &langs {
tags.push(HeadTag {
tag: "link",
attrs: vec![
("rel", "alternate".into()),
("hreflang", site.locales[l].tag.clone().unwrap_or_default()),
("href", at(l)),
],
});
}
if let Some(&first) = langs.first() {
tags.push(HeadTag {
tag: "link",
attrs: vec![
("rel", "alternate".into()),
("hreflang", "x-default".into()),
("href", at(first)),
],
});
}
json!({"title": site.site_title, "lang": site.index_lang(), "tags": tags.iter().map(tag_json).collect::<Vec<_>>()})
}
/// `src/i18n/<locale>.json` per locale and `src/app/i18n.ts`, which maps route folders to them.
fn catalogs(site: &Site, out: &mut Output) -> Result<(), String> {
let mut taken = BTreeSet::new();
let mut imports = String::new();
let mut entries = Vec::new();
let mut folders = Vec::new();
for (i, l) in site.locales.iter().enumerate() {
let folder = l.folder.clone().unwrap_or_default();
let file = unique(&mut taken, class_slug(&folder));
out.files.insert(
format!("src/i18n/{file}.json"),
pretty(&json!(site.messages[i]))?,
);
imports.push_str(&format!("import c{i} from '../i18n/{file}.json';\n"));
let key = serde_json::to_string(&folder).map_err(|e| e.to_string())?;
entries.push(format!("[{key}, c{i}]"));
folders.push(key);
}
let ts = format!(
"{imports}\nexport type Messages = Record<string, string>;\n\n/** The route folder of every language and mode. */\nexport const LANGS: string[] = [{}];\n\nconst catalogs = new Map<string, Messages>([{}]);\n\n/** Unknown codes fall back to the first catalog. */\nexport const messages = (lang: string): Messages => catalogs.get(lang) ?? c0;\n",
folders.join(", "),
entries.join(", ")
);
out.files.insert("src/app/i18n.ts".into(), ts.into());
Ok(())
}
+489
View File
@@ -0,0 +1,489 @@
use super::write;
use crate::mcp::site::model::{Framework, Locale, Options, Output, Page, Render, Site, Styling};
use crate::mcp::site::style::{Class, FontFace, StyleModel, Token};
use crate::mcp::site::tree::{Child, Element, LinkTarget, Text};
use std::collections::{BTreeMap, BTreeSet};
const HOSTILE: &str = "</script>{{x}}@if (a) {b}${x}`<img onerror=alert(1)>";
fn el(tag: &str, class: &str, attrs: &[(&str, &str)], children: Vec<Child>) -> Element {
Element {
tag: tag.into(),
class: class.into(),
attrs: attrs
.iter()
.map(|(k, v)| ((*k).into(), (*v).into()))
.collect(),
link: None,
tw: if class.is_empty() {
String::new()
} else {
format!("tw-{class} flex")
},
has_overrides: false,
children,
}
}
fn text(s: &str) -> Child {
Child::Text(Text::Lit(s.into()))
}
fn msg(k: &str) -> Child {
Child::Text(Text::Msg(k.into()))
}
fn link(target: LinkTarget, children: Vec<Child>) -> Child {
let mut a = el("a", "button", &[], children);
a.link = Some(target);
Child::El(a)
}
fn locale(tag: &str, first: bool) -> Locale {
Locale {
tag: Some(tag.into()),
hreflang: true,
folder: Some(tag.into()),
label: format!("{tag} {HOSTILE}"),
scope: if first {
String::new()
} else {
format!(":root:lang({tag})")
},
class: None,
}
}
fn class(
name: &str,
pages: &[usize],
root_of: Option<usize>,
overrides: Vec<(usize, Vec<(String, String)>)>,
) -> Class {
Class {
name: name.into(),
decls: vec![
("display".into(), "flex".into()),
("background".into(), "var(--brand)".into()),
],
overrides,
pages: pages.iter().copied().collect::<BTreeSet<_>>(),
root_of,
}
}
/// Two pages (home, pricing); `multi` adds a second locale with its own messages.
fn sample(render: Render, styling: Styling, multi: bool) -> Site {
let locales = if multi {
vec![locale("en", true), locale("pt-BR", false)]
} else {
vec![Locale {
tag: Some("en".into()),
hreflang: false,
folder: None,
label: String::new(),
scope: String::new(),
class: None,
}]
};
let n = locales.len();
let title = |a: &str| (0..n).map(|i| format!("{a} {i}")).collect::<Vec<_>>();
let home = el(
"div",
"home",
&[],
vec![
Child::El(el(
"h1",
"",
&[],
vec![if multi {
msg("home.hero_title")
} else {
text("Hello")
}],
)),
Child::El(el("p", "", &[], vec![text(HOSTILE)])),
Child::El(el(
"img",
"",
&[("src", "assets/hero.png"), ("alt", HOSTILE)],
vec![],
)),
link(
LinkTarget::Page(1),
vec![if multi { msg("2cta") } else { text("Pricing") }],
),
link(
LinkTarget::Url("https://x.test/?a=\"><b>".into()),
vec![text("Out")],
),
],
);
let pricing = el(
"main",
"pricing",
&[],
vec![Child::El(el("h1", "", &[], vec![text("Pricing")]))],
);
let pages = vec![
Page {
name: "Home".into(),
stem: "index".into(),
component: "HomePage".into(),
root: home,
title: title("Home"),
description: title("Desc"),
},
Page {
name: "Pricing".into(),
stem: "pricing".into(),
component: "PricingPage".into(),
root: pricing,
title: title("Pricing"),
description: title("Desc"),
},
];
let messages = if multi {
vec![
BTreeMap::from([
("home.hero_title".to_owned(), "Hello".to_owned()),
("2cta".to_owned(), "Pricing".to_owned()),
]),
BTreeMap::from([
("home.hero_title".to_owned(), HOSTILE.to_owned()),
("2cta".to_owned(), "Precos".to_owned()),
]),
]
} else {
vec![BTreeMap::new()]
};
let style = StyleModel {
tokens: vec![Token {
name: "brand".into(),
value: "#4f46e5".into(),
color: true,
overrides: vec![],
}],
fallbacks: Vec::new(),
fonts: vec![FontFace {
family: "Varela Round".into(),
weight: 400,
italic: false,
path: "assets/fonts/f.ttf".into(),
format: "truetype",
}],
preload: Some(0),
favicon: None,
h1_faces: Vec::new(),
classes: vec![
class(
"home",
&[0],
Some(0),
if multi {
vec![(1, vec![("font-size".into(), "40px".into())])]
} else {
vec![]
},
),
class("pricing", &[1], Some(1), vec![]),
class("button", &[0, 1], None, vec![]),
],
scopes: locales.iter().map(|l| l.scope.clone()).collect(),
};
let assets = BTreeMap::from([
("assets/hero.png".to_owned(), vec![1, 2, 3]),
("assets/fonts/f.ttf".to_owned(), vec![4, 5]),
]);
Site {
options: Options {
framework: Framework::Angular,
render,
styling,
},
site_title: format!("Acme {HOSTILE}"),
base_url: Some("https://x.test".into()),
locales,
pages,
messages,
assets,
style,
warnings: vec![],
}
}
fn run(render: Render, styling: Styling, multi: bool) -> Output {
write(&sample(render, styling, multi)).unwrap()
}
fn get<'a>(o: &'a Output, path: &str) -> &'a str {
std::str::from_utf8(
o.files.get(path).unwrap_or_else(|| {
panic!("missing {path} in {:?}", o.files.keys().collect::<Vec<_>>())
}),
)
.unwrap()
}
const VARIANTS: [(Render, Styling); 6] = [
(Render::Static, Styling::Css),
(Render::Static, Styling::Scss),
(Render::Static, Styling::Tailwind),
(Render::Spa, Styling::Css),
(Render::Spa, Styling::Scss),
(Render::Spa, Styling::Tailwind),
];
#[test]
fn every_variant_writes_the_project_files() {
for (r, s) in VARIANTS {
let o = run(r, s, true);
for f in [
"package.json",
"angular.json",
"tsconfig.json",
"tsconfig.app.json",
"src/index.html",
"src/main.ts",
"src/app/app.ts",
"src/app/app.config.ts",
"src/app/app.routes.ts",
"src/app/seo.ts",
"src/app/site.json",
"src/app/i18n.ts",
"src/i18n/en.json",
"src/i18n/pt-br.json",
"src/app/pages/index.ts",
"src/app/pages/index.html",
"src/app/pages/pricing.ts",
"src/app/pages/lang-index.ts",
"public/robots.txt",
"public/sitemap.xml",
"public/assets/hero.png",
"public/assets/fonts/f.ttf",
] {
assert!(o.files.contains_key(f), "{r:?} {s:?}: {f}");
}
let ssr = r == Render::Static;
for f in [
"src/main.server.ts",
"src/app/app.config.server.ts",
"src/app/app.routes.server.ts",
] {
assert_eq!(o.files.contains_key(f), ssr, "{r:?} {f}");
}
assert_eq!(
o.files.contains_key(".postcssrc.json"),
s == Styling::Tailwind
);
assert_eq!(
o.files.contains_key("src/styles/main.scss"),
s == Styling::Scss
);
assert_eq!(o.files.contains_key("src/styles.css"), s != Styling::Scss);
assert_eq!(
o.files.contains_key("src/app/pages/index.css"),
s == Styling::Css,
"{r:?} {s:?}"
);
assert!(get(&o, "angular.json").contains(if s == Styling::Scss {
"src/styles/main.scss"
} else {
"src/styles.css"
}));
assert!(get(&o, "package.json").contains("tailwindcss") == (s == Styling::Tailwind));
assert_eq!(
get(&o, "angular.json").contains("\"outputMode\": \"static\""),
ssr
);
assert_eq!(
get(&o, "src/app/app.config.ts").contains("provideClientHydration()"),
ssr
);
assert!(
o.next_steps[0].contains("npm install")
&& o.next_steps[0].contains("dist/site/browser")
);
}
}
#[test]
fn several_locales_route_by_language_and_read_messages_with_brackets() {
let o = run(Render::Static, Styling::Css, true);
let routes = get(&o, "src/app/app.routes.ts");
assert!(
routes.contains("path: \"\", component: LanguageIndex")
|| routes.contains("path: '', component: LanguageIndex"),
"{routes}"
);
assert!(
routes.contains("path: \":lang\", component: HomePage")
&& routes.contains("path: \":lang/pricing\", component: PricingPage"),
"{routes}"
);
let server = get(&o, "src/app/app.routes.server.ts");
assert!(
server.contains("getPrerenderParams: langParams") && server.contains("path: '**'"),
"{server}"
);
let home = get(&o, "src/app/pages/index.html");
assert!(
home.contains("{{ t()[\"home.hero_title\"] }}") && home.contains("{{ t()[\"2cta\"] }}"),
"{home}"
);
assert!(
home.contains("[routerLink]=\"['/', lang(), &quot;pricing&quot;]\"")
&& home.contains("src=\"/assets/hero.png\""),
"{home}"
);
assert!(get(&o, "src/app/pages/index.ts").contains("this.seo.set(0, this.lang())"));
assert!(get(&o, "src/app/i18n.ts").contains("LANGS: string[] = [\"en\", \"pt-BR\"]"));
let site = get(&o, "src/app/site.json");
assert!(
site.contains("https://x.test/pt-BR/pricing/")
&& site.contains("x-default")
&& site.contains("og:locale"),
"{site}"
);
let sitemap = get(&o, "public/sitemap.xml");
assert!(
sitemap.contains("<loc>https://x.test/</loc>")
&& sitemap.contains("<loc>https://x.test/en/pricing/</loc>"),
"{sitemap}"
);
assert!(get(&o, "src/index.html").contains(
"<link rel=\"preload\" href=\"/assets/fonts/f.ttf\" as=\"font\" type=\"font/ttf\" crossorigin>"
));
}
#[test]
fn one_locale_has_root_routes_and_literal_text() {
let o = run(Render::Spa, Styling::Css, false);
assert!(
!o.files.contains_key("src/app/i18n.ts")
&& !o.files.contains_key("src/app/pages/lang-index.ts")
);
let routes = get(&o, "src/app/app.routes.ts");
assert!(
routes.contains("path: \"\", component: HomePage")
&& routes.contains("path: \"pricing\", component: PricingPage")
&& !routes.contains(":lang"),
"{routes}"
);
let home = get(&o, "src/app/pages/index.html");
assert!(
home.contains(">Hello</h1>")
&& home.contains("[routerLink]=\"['/', &quot;pricing&quot;]\""),
"{home}"
);
assert!(!get(&o, "src/app/pages/index.ts").contains("input."));
}
#[test]
fn styling_variants_carry_their_own_sources() {
let tw = run(Render::Static, Styling::Tailwind, true);
assert!(get(&tw, "src/styles.css").starts_with("@import \"tailwindcss\";\n@theme {"));
assert!(get(&tw, "src/app/pages/index.html").contains("class=\"tw-home flex\""));
assert_eq!(
get(&tw, ".postcssrc.json")
.matches("@tailwindcss/postcss")
.count(),
1
);
let scss = run(Render::Spa, Styling::Scss, true);
assert!(get(&scss, "src/styles/main.scss").contains("@use 'pages/index';"));
let css = run(Render::Static, Styling::Css, true);
assert!(
get(&css, "src/styles.css").contains("url(/assets/fonts/f.ttf)")
&& get(&css, "src/styles.css").contains(".button {")
);
assert!(
get(&css, "src/app/pages/index.css").contains(".home {")
&& get(&css, "src/app/pages/index.css").contains(":lang(pt-BR) .home")
);
assert!(get(&css, "src/app/pages/index.ts").contains("styleUrl: \"./index.css\""));
}
#[test]
fn hostile_text_stays_inert_in_templates_and_typescript() {
for (r, s) in VARIANTS {
for multi in [true, false] {
let o = run(r, s, multi);
for (path, bytes) in &o.files {
if !path.starts_with("src/app/")
|| !(path.ends_with(".html") || path.ends_with(".ts"))
{
continue;
}
let t = std::str::from_utf8(bytes).unwrap().replace("{{ '{' }}", "");
let bindings = t.matches("{{").count();
assert_eq!(
bindings,
t.matches("{{ t()[").count(),
"{path}: an interpolation that is not a message in {t}"
);
for bad in ["</script>", "@if", "${x}", "<img onerror", "alert(1)>"] {
assert!(!t.contains(bad), "{path}: `{bad}` in {t}");
}
if path.starts_with("src/app/") && path.ends_with(".html") {
assert!(!t.contains('`'), "{path}: backtick");
}
}
let index = get(&o, "src/app/pages/index.html");
assert!(
index.contains("&lt;/script&gt;{{ '{' }}{{ '{' }}x&#125;&#125;&#64;if"),
"{index}"
);
}
}
}
#[test]
fn hostile_texts_are_data_in_json() {
let o = run(Render::Static, Styling::Css, true);
let catalog: serde_json::Value = serde_json::from_str(get(&o, "src/i18n/pt-br.json")).unwrap();
assert_eq!(catalog["home.hero_title"], HOSTILE);
let data: serde_json::Value = serde_json::from_str(get(&o, "src/app/site.json")).unwrap();
assert!(data["index"]["title"].as_str().unwrap().contains(HOSTILE));
let lang = get(&o, "src/app/pages/lang-index.html");
assert!(
!lang.contains("{{x}}") && !lang.contains("</script>") && !lang.contains('`'),
"{lang}"
);
assert!(
lang.contains("[routerLink]=\"['/', &quot;pt-BR&quot;]\""),
"{lang}"
);
}
#[test]
fn output_is_deterministic() {
for (r, s) in VARIANTS {
let (a, b) = (run(r, s, true), run(r, s, true));
assert_eq!(a.files, b.files, "{r:?} {s:?}");
}
}
#[test]
fn font_and_token_text_only_reaches_style_files_never_a_typescript_literal() {
let evil = "a${x}`</style>";
for (r, s) in VARIANTS {
let mut site = sample(r, s, true);
site.style.fonts[0].family = evil.into();
site.style.tokens[0].name = "t".into();
site.style.tokens[0].value = format!("\"{evil}\"");
let o = write(&site).unwrap();
for (path, bytes) in &o.files {
let t = std::str::from_utf8(bytes).unwrap_or_default();
if path.starts_with("src/app/") && path.ends_with(".ts") {
for bad in ["`", "${", "</style>"] {
assert!(!t.contains(bad), "{path}: `{bad}` in a TypeScript file");
}
}
if path.starts_with("src/app/") && path.ends_with(".html") {
assert!(!t.contains("</style>") && !t.contains("${x}"), "{path}");
}
}
}
}
+538
View File
@@ -0,0 +1,538 @@
//! The astro writer: a static Astro 7 project (`npm run build` makes `dist/` with no client
//! JavaScript). One component per page frame, one Layout that owns the head, `public/` with the
//! assets, `sitemap.xml` and `robots.txt`.
//!
//! With several locales the pages are `src/pages/[lang]/*.astro` with `getStaticPaths`, the
//! messages are one JSON catalog per locale, and `/` lists the versions. A plain lookup is used
//! instead of Astro's i18n routing: the routes are fixed, so its middleware adds nothing, and its
//! helpers lower-case `pt-BR`. A single locale writes pages at the root with literal text.
//! All user text reaches the files through `markup`'s Astro escaping, or as JSON string literals
//! in `src/site.ts` (never inside the frontmatter of a page).
use super::markup::{self, Dialect, Hooks, Link, TextOut, Val, escape_attr, escape_text};
use super::model::{HeadTag, Locale, Output, Site, Styling};
use super::style::class_slug;
use super::tree::{Element, LinkTarget};
use serde_json::{Value, json};
use std::collections::BTreeSet;
const ASTRO_RANGE: &str = "^7.4.7";
const SASS_RANGE: &str = "^1.107.1";
const TAILWIND_RANGES: [(&str, &str); 2] =
[("@tailwindcss/vite", "^4.3.4"), ("tailwindcss", "^4.3.4")];
const NODE_RANGE: &str = ">=22.23.4";
/// Key of the only locale of a single-language site in `src/site.ts`.
const SINGLE_KEY: &str = "site";
const INDEX_CSS: &str = "body { font-family: system-ui, sans-serif; margin: 2rem; }";
const TSCONFIG: &str = "{\n \"extends\": \"astro/tsconfigs/strict\",\n \"include\": [\".astro/types.d.ts\", \"**/*\"],\n \"exclude\": [\"dist\"]\n}\n";
pub fn write(site: &Site) -> Result<Output, String> {
check_names(site)?;
let multi = site.locales.iter().any(|l| l.folder.is_some());
let mut out = Output::default();
for (path, bytes) in &site.assets {
out.files.insert(format!("public/{path}"), bytes.clone());
}
out.files
.insert("public/robots.txt".into(), site.robots().into_bytes());
if let Some(base) = &site.base_url {
let abs = |loc: usize, page: usize| format!("{base}{}", route(site, page, loc));
out.files.insert(
"public/sitemap.xml".into(),
site.sitemap(&abs, multi.then(|| format!("{base}/")))
.into_bytes(),
);
}
out.files.insert("package.json".into(), package_json(site)?);
out.files
.insert("astro.config.mjs".into(), config(site)?.into_bytes());
out.files.insert("tsconfig.json".into(), TSCONFIG.into());
out.files
.insert("src/site.ts".into(), site_ts(site)?.into_bytes());
out.files
.insert("src/layouts/Layout.astro".into(), layout(site).into_bytes());
styles(site, &mut out);
for (i, page) in site.pages.iter().enumerate() {
out.files.insert(
format!("src/components/{}.astro", page.component),
component(site, i, multi).into_bytes(),
);
let path = match (multi, i) {
(true, _) => format!("src/pages/[lang]/{}.astro", page.stem),
(false, _) => format!("src/pages/{}.astro", page.stem),
};
out.files
.insert(path, page_file(site, i, multi).into_bytes());
}
if multi {
write_catalogs(site, &mut out)?;
out.files.insert(
"src/pages/index.astro".into(),
language_index(site).into_bytes(),
);
}
out.next_steps.push("Run `npm install && npm run build` in the output folder: the static site is written to dist/ (`npm run dev` serves it while you edit). Run the export again after changing the design.".into());
Ok(out)
}
/// Names that become file names or identifiers must be plain, and unique on a case-insensitive disk.
fn check_names(site: &Site) -> Result<(), String> {
let plain = |s: &str| {
!s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
};
let mut taken = BTreeSet::from(["layout".to_owned()]);
for p in &site.pages {
let ident = p
.component
.chars()
.next()
.is_some_and(|c| c.is_ascii_uppercase())
&& p.component.chars().all(|c| c.is_ascii_alphanumeric());
if !ident || !plain(&p.stem) || !taken.insert(p.component.to_lowercase()) {
return Err(format!(
"page `{}` has no usable component name (`{}`) or file name (`{}`)",
p.name, p.component, p.stem
));
}
}
for l in &site.locales {
if l.folder.as_deref().is_some_and(|f| !plain(f)) {
return Err(format!(
"locale folder `{}` cannot be used as a route",
l.folder.as_deref().unwrap_or_default()
));
}
}
Ok(())
}
fn json_str(s: &str) -> String {
serde_json::to_string(s).unwrap_or_else(|_| "\"\"".into())
}
fn pretty(v: &Value) -> Result<Vec<u8>, String> {
let mut b = serde_json::to_vec_pretty(v).map_err(|e| e.to_string())?;
b.push(b'\n');
Ok(b)
}
fn package_json(site: &Site) -> Result<Vec<u8>, String> {
let mut dev = serde_json::Map::new();
match site.options.styling {
Styling::Scss => {
dev.insert("sass".into(), json!(SASS_RANGE));
}
Styling::Tailwind => {
for (name, range) in TAILWIND_RANGES {
dev.insert(name.into(), json!(range));
}
}
_ => {}
}
let mut pkg = json!({
"name": "site", "private": true, "type": "module", "version": "0.0.1",
"engines": {"node": NODE_RANGE},
"scripts": {"dev": "astro dev", "build": "astro build", "preview": "astro preview", "astro": "astro"},
"dependencies": {"astro": ASTRO_RANGE},
});
if !dev.is_empty() {
pkg["devDependencies"] = Value::Object(dev);
}
pretty(&pkg)
}
fn config(site: &Site) -> Result<String, String> {
let tailwind = site.options.styling == Styling::Tailwind;
let mut out = String::from("// @ts-check\nimport { defineConfig } from 'astro/config';\n");
if tailwind {
out.push_str("import tailwindcss from '@tailwindcss/vite';\n");
}
out.push_str("\nexport default defineConfig({\n");
if let Some(base) = &site.base_url {
out.push_str(&format!(" site: {},\n", json_str(base)));
}
out.push_str(
" output: 'static',\n trailingSlash: 'always',\n build: { format: 'directory' },\n",
);
if tailwind {
out.push_str(" vite: { plugins: [tailwindcss()] },\n");
}
out.push_str("});\n");
Ok(out)
}
/// The site-wide stylesheet, which the Layout imports, and for scss the page partials.
fn styles(site: &Site, out: &mut Output) {
let style = &site.style;
let (path, text) = match site.options.styling {
Styling::Scss => {
let stems: Vec<(usize, String)> = site
.pages
.iter()
.enumerate()
.map(|(i, p)| (i, p.stem.clone()))
.collect();
let parts = style.scss("/", &stems);
let shared = ["_tokens.scss", "_fonts.scss", "_base.scss", "_shared.scss"];
let text: String = parts
.iter()
.filter(|(p, _)| shared.contains(&p.as_str()))
.map(|(_, c)| c.as_str())
.collect();
("src/styles/global.scss", text)
}
Styling::Tailwind => (
"src/styles/global.css",
format!(
"@import \"tailwindcss\";\n{}{}{}{}",
style.tailwind_theme(),
style.tokens_css(),
style.fonts_css("/"),
style.overrides_css("/", |_| true)
),
),
_ => ("src/styles/global.css", style.global_css("/")),
};
out.files.insert(path.into(), text.into_bytes());
}
/// `locale`'s key in `src/site.ts`: its folder, or one fixed key for a single-language site.
fn key(l: &Locale) -> &str {
l.folder.as_deref().unwrap_or(SINGLE_KEY)
}
/// The route of a page, with the trailing slash `trailingSlash: 'always'` serves.
fn route(site: &Site, page: usize, loc: usize) -> String {
let r = site.url_path(page, loc);
if r.ends_with('/') { r } else { format!("{r}/") }
}
/// `{"a": "b", ...}` with the keys in the given order.
fn object(pairs: &[(&str, &str)]) -> String {
let items: Vec<String> = pairs
.iter()
.map(|(k, v)| format!("{}: {}", json_str(k), json_str(v)))
.collect();
format!("{{ {} }}", items.join(", "))
}
fn tag_object(t: &HeadTag) -> String {
let attrs: Vec<(&str, &str)> = t.attrs.iter().map(|(k, v)| (*k, v.as_str())).collect();
format!("{{ tag: {}, attrs: {} }}", json_str(t.tag), object(&attrs))
}
/// The head of one page in one locale, as the data `Layout.astro` prints.
fn meta(site: &Site, page: usize, loc: usize) -> String {
let base = site.base_url.as_deref().unwrap_or_default();
let url = |_from: usize, to: usize, p: usize| format!("{base}{}", route(site, p, to));
let mut tags = site.head_tags(page, loc, &url);
tags.extend(site.asset_tags(page, "/"));
let l = &site.locales[loc];
let mut fields = vec![format!("title: {}", json_str(&site.pages[page].title[loc]))];
fields.extend(l.tag.as_deref().map(|t| format!("lang: {}", json_str(t))));
fields.extend(l.class.as_deref().map(|c| format!("cls: {}", json_str(c))));
let tags: Vec<String> = tags
.iter()
.map(|t| format!(" {}", tag_object(t)))
.collect();
format!(
"{{ {}, tags: [\n{}\n ] }}",
fields.join(", "),
tags.join(",\n")
)
}
/// `src/site.ts`: the locales, the catalogs and every page's head. Strings are JSON literals.
fn site_ts(site: &Site) -> Result<String, String> {
let multi = site.locales.iter().any(|l| l.folder.is_some());
let mut out = String::from("// Generated by papercraft export_site.\n");
if multi {
for (i, l) in site.locales.iter().enumerate() {
out.push_str(&format!(
"import m{i} from {};\n",
json_str(&format!("./i18n/{}.json", key(l)))
));
}
}
out.push_str("\nexport interface Tag { tag: string; attrs: Record<string, string> }\n");
out.push_str(
"export interface Meta { title: string; lang?: string; cls?: string; tags: Tag[] }\n\n",
);
let locales: Vec<String> = site
.locales
.iter()
.map(|l| format!(" {}", object(&[("folder", key(l)), ("label", &l.label)])))
.collect();
out.push_str(&format!(
"export const locales = [\n{},\n];\n",
locales.join(",\n")
));
let catalogs: Vec<String> = site
.locales
.iter()
.enumerate()
.map(|(i, l)| format!(" {}: m{i}", json_str(key(l))))
.collect();
if multi {
out.push_str(&format!(
"export const messages: Record<string, Record<string, string>> = {{\n{},\n}};\n",
catalogs.join(",\n")
));
}
out.push_str("\nexport const pages: Record<string, Record<string, Meta>> = {\n");
for (p, page) in site.pages.iter().enumerate() {
out.push_str(&format!(" {}: {{\n", json_str(&page.stem)));
for (loc, l) in site.locales.iter().enumerate() {
out.push_str(&format!(
" {}: {},\n",
json_str(key(l)),
meta(site, p, loc)
));
}
out.push_str(" },\n");
}
out.push_str("};\n");
Ok(out)
}
fn write_catalogs(site: &Site, out: &mut Output) -> Result<(), String> {
for (l, messages) in site.locales.iter().zip(&site.messages) {
out.files.insert(
format!("src/i18n/{}.json", key(l)),
pretty(&json!(messages))?,
);
}
Ok(())
}
fn layout(site: &Site) -> String {
let css = if site.options.styling == Styling::Scss {
"scss"
} else {
"css"
};
let first = site.locales.first().map_or(SINGLE_KEY, key);
format!(
"---\nimport \"../styles/global.{css}\";\nimport {{ pages }} from \"../site\";\n\ninterface Props {{ page: string; lang?: string }}\nconst {{ page, lang = {} }} = Astro.props;\nconst meta = pages[page][lang];\n---\n\
<!doctype html>\n<html lang={{meta.lang}} class={{meta.cls}}>\n <head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <title>{{meta.title}}</title>\n {{meta.tags.map(({{ tag, attrs }}) => (tag === \"meta\" ? <meta {{...attrs}} /> : <link {{...attrs}} />))}}\n </head>\n <body>\n <slot />\n </body>\n</html>\n",
json_str(first)
)
}
fn page_file(site: &Site, page: usize, multi: bool) -> String {
let p = &site.pages[page];
let depth = if multi { "../../" } else { "../" };
let mut out = format!(
"---\nimport Layout from \"{depth}layouts/Layout.astro\";\nimport {} from \"{depth}components/{}.astro\";\n",
p.component, p.component
);
if multi {
out.push_str(&format!("import {{ locales }} from \"{depth}site\";\n\nexport function getStaticPaths() {{\n return locales.map((l) => ({{ params: {{ lang: l.folder }} }}));\n}}\n\nconst {{ lang }} = Astro.params;\n---\n"));
out.push_str(&format!(
"<Layout page={{{}}} lang={{lang}}>\n <{} lang={{lang}} />\n</Layout>\n",
json_str(&p.stem),
p.component
));
} else {
out.push_str(&format!(
"---\n<Layout page={{{}}}>\n <{} />\n</Layout>\n",
json_str(&p.stem),
p.component
));
}
out
}
/// A component per page: the markup, and the styles only this page uses.
fn component(site: &Site, page: usize, multi: bool) -> String {
let p = &site.pages[page];
let mut out = String::from("---\n");
if multi {
out.push_str("import { messages } from \"../site\";\n\ninterface Props { lang: string }\nconst { lang } = Astro.props;\nconst t = messages[lang];\n");
}
out.push_str("---\n");
markup::render(
Dialect::Astro,
&AstroHooks { site, multi },
&p.root,
0,
&mut out,
);
let style = &site.style;
let (attr, css) = match site.options.styling {
Styling::Css => ("", style.page_css(page, "/")),
Styling::Scss => (" lang=\"scss\"", scss_page(site, page)),
_ => ("", String::new()),
};
if !css.trim().is_empty() {
out.push_str(&format!(
"<style{attr}>\n{}</style>\n",
css.replace("</", r"<\/")
));
}
out
}
fn scss_page(site: &Site, page: usize) -> String {
let stems: Vec<(usize, String)> = site
.pages
.iter()
.enumerate()
.map(|(i, p)| (i, p.stem.clone()))
.collect();
let file = format!("pages/_{}.scss", class_slug(&site.pages[page].stem));
site.style
.scss("/", &stems)
.into_iter()
.find(|(p, _)| *p == file)
.map(|(_, c)| c)
.unwrap_or_default()
}
struct AstroHooks<'a> {
site: &'a Site,
multi: bool,
}
impl Hooks for AstroHooks<'_> {
fn class(&self, el: &Element) -> Option<Val> {
let class = if self.site.options.styling == Styling::Tailwind {
el.tailwind_classes()
} else {
el.class.clone()
};
(!class.is_empty()).then_some(Val::Lit(class))
}
fn link(&self, _: &Element, target: &LinkTarget) -> Link {
let href = match target {
LinkTarget::Url(u) => Val::Lit(u.clone()),
LinkTarget::Page(i) if self.multi => {
let tail = if *i == 0 {
"/".to_owned()
} else {
format!("/{}/", self.site.pages[*i].stem)
};
Val::Expr(format!("\"/\" + lang + {}", json_str(&tail)))
}
LinkTarget::Page(i) => Val::Lit(if *i == 0 {
"/".to_owned()
} else {
format!("/{}/", self.site.pages[*i].stem)
}),
};
Link {
tag: "a".into(),
attrs: vec![("href".into(), href)],
}
}
fn asset(&self, path: &str) -> Val {
Val::Lit(format!("/{path}"))
}
fn message(&self, key: &str) -> TextOut {
if self.multi {
return TextOut::Raw(format!("{{t[{}]}}", json_str(key)));
}
TextOut::Lit(
self.site
.messages
.first()
.and_then(|m| m.get(key))
.cloned()
.unwrap_or_default(),
)
}
}
fn head_line(t: &HeadTag) -> String {
let mut s = format!(" <{}", t.tag);
for (k, v) in &t.attrs {
if v.is_empty() {
s.push_str(&format!(" {k}"));
} else {
s.push_str(&format!(" {k}=\"{}\"", escape_attr(Dialect::Astro, v)));
}
}
s.push_str(" />\n");
s
}
/// `/`: one link per version, with its own head (it is not a locale page).
fn language_index(site: &Site) -> String {
let base = site.base_url.as_deref().unwrap_or_default();
let langs = site.language_locales();
let href = |to: usize| {
format!(
"{base}/{}/",
site.locales[to].folder.as_deref().unwrap_or_default()
)
};
let pair = |name: &'static str, v: String| HeadTag {
tag: "meta",
attrs: vec![("name", name.into()), ("content", v)],
};
let mut tags = vec![
pair("description", "Choose a version of this site.".into()),
HeadTag {
tag: "link",
attrs: vec![("rel", "canonical".into()), ("href", format!("{base}/"))],
},
];
for &l in &langs {
tags.push(HeadTag {
tag: "link",
attrs: vec![
("rel", "alternate".into()),
("hreflang", site.locales[l].tag.clone().unwrap_or_default()),
("href", href(l)),
],
});
}
if let Some(&first) = langs.first() {
tags.push(HeadTag {
tag: "link",
attrs: vec![
("rel", "alternate".into()),
("hreflang", "x-default".into()),
("href", href(first)),
],
});
}
let mut out = format!(
"<!doctype html>\n<html{}>\n <head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <title>{}</title>\n",
site.index_lang()
.map(|t| format!(" lang=\"{}\"", escape_attr(Dialect::Astro, t)))
.unwrap_or_default(),
escape_text(Dialect::Astro, &site.site_title)
);
tags.iter().for_each(|t| out.push_str(&head_line(t)));
out.push_str(&format!(" <style is:global>\n {INDEX_CSS}\n </style>\n </head>\n <body>\n <main>\n <h1>{}</h1>\n <ul>\n", escape_text(Dialect::Astro, &site.site_title)));
for (i, l) in site.locales.iter().enumerate() {
let lang = l
.tag
.as_ref()
.filter(|_| langs.contains(&i))
.map(|t| {
format!(
" lang=\"{0}\" hreflang=\"{0}\"",
escape_attr(Dialect::Astro, t)
)
})
.unwrap_or_default();
out.push_str(&format!(
" <li><a href=\"/{}/\"{lang}>{}</a></li>\n",
escape_attr(Dialect::Astro, l.folder.as_deref().unwrap_or_default()),
escape_text(Dialect::Astro, &l.label)
));
}
out.push_str(" </ul>\n </main>\n </body>\n</html>\n");
out
}
#[cfg(test)]
#[path = "astro_tests.rs"]
mod tests;
+265
View File
@@ -0,0 +1,265 @@
use super::*;
use crate::mcp::site::model::{Framework, Options, Page, Render};
use crate::mcp::site::style::{Class, FontFace, StyleModel};
use crate::mcp::site::tree::{Child, Text};
use std::collections::BTreeMap;
const HOSTILE: &str = "{x} </script> `b` ${x} ---";
fn el(tag: &str, class: &str, children: Vec<Child>) -> Element {
Element {
tag: tag.into(),
class: class.into(),
attrs: vec![],
link: None,
tw: String::new(),
has_overrides: false,
children,
}
}
fn locale(tag: &str, folder: Option<&str>) -> Locale {
Locale {
tag: Some(tag.into()),
hreflang: true,
folder: folder.map(Into::into),
label: HOSTILE.into(),
scope: String::new(),
class: None,
}
}
fn site(styling: Styling, multi: bool) -> Site {
let link = Element {
link: Some(LinkTarget::Page(1)),
..el("a", "", vec![Child::Text(Text::Lit(HOSTILE.into()))])
};
let ext = Element {
link: Some(LinkTarget::Url("https://example.com/?a=1&b=2".into())),
..el("a", "", vec![])
};
let root = el(
"main",
"home",
vec![
Child::El(el(
"h1",
"title",
vec![Child::Text(Text::Msg("home.title".into()))],
)),
Child::El(link),
Child::El(ext),
],
);
let n = if multi { 2 } else { 1 };
let page = |stem: &str, component: &str, root: Element| Page {
name: HOSTILE.into(),
stem: stem.into(),
component: component.into(),
root,
title: vec![HOSTILE.into(); n],
description: vec!["d".into(); n],
};
let locales = if multi {
vec![locale("en", Some("en")), locale("pt-BR", Some("pt-BR"))]
} else {
vec![locale("en", None)]
};
let msg = |v: &str| BTreeMap::from([("home.title".to_owned(), v.to_owned())]);
let style = StyleModel {
fallbacks: Vec::new(),
fonts: vec![FontFace {
family: "V".into(),
weight: 400,
italic: false,
path: "assets/fonts/ab.ttf".into(),
format: "truetype",
}],
preload: Some(0),
favicon: None,
h1_faces: Vec::new(),
classes: vec![Class {
name: "home".into(),
decls: vec![("color".into(), "red".into())],
overrides: vec![],
pages: [0].into(),
root_of: Some(0),
}],
scopes: vec![String::new(); n],
..Default::default()
};
Site {
options: Options {
framework: Framework::Astro,
render: Render::Static,
styling,
},
site_title: HOSTILE.into(),
base_url: Some("https://example.com".into()),
locales,
pages: vec![
page("index", "HomePage", root),
page("pricing", "PricingPage", el("main", "", vec![])),
],
messages: if multi {
vec![msg(HOSTILE), msg("Ola")]
} else {
vec![msg(HOSTILE)]
},
assets: BTreeMap::from([("assets/fonts/ab.ttf".to_owned(), vec![1, 2])]),
style,
warnings: vec![],
}
}
fn text(o: &Output, path: &str) -> String {
String::from_utf8(
o.files
.get(path)
.unwrap_or_else(|| panic!("missing {path}: {:?}", o.files.keys()))
.clone(),
)
.unwrap()
}
#[test]
fn multi_locale_css_files_and_snippets() {
let o = write(&site(Styling::Css, true)).unwrap();
for f in [
"package.json",
"astro.config.mjs",
"tsconfig.json",
"src/site.ts",
"src/layouts/Layout.astro",
"src/styles/global.css",
"src/components/HomePage.astro",
"src/components/PricingPage.astro",
"src/pages/index.astro",
"src/pages/[lang]/index.astro",
"src/pages/[lang]/pricing.astro",
"src/i18n/en.json",
"src/i18n/pt-BR.json",
"public/robots.txt",
"public/sitemap.xml",
"public/assets/fonts/ab.ttf",
] {
assert!(o.files.contains_key(f), "{f}");
}
let c = text(&o, "src/components/HomePage.astro");
assert!(
c.contains("{t[\"home.title\"]}") && c.contains("const t = messages[lang];"),
"{c}"
);
assert!(
c.contains("href={\"/\" + lang + \"/pricing/\"}") && c.contains("<style>"),
"{c}"
);
assert!(text(&o, "src/pages/[lang]/index.astro").contains("getStaticPaths"));
let ts = text(&o, "src/site.ts");
assert!(
ts.contains("\"hreflang\": \"x-default\"")
&& ts.contains("https://example.com/pt-BR/pricing/")
&& ts.contains("\"rel\": \"preload\""),
"{ts}"
);
assert!(text(&o, "astro.config.mjs").contains("site: \"https://example.com\""));
}
#[test]
fn single_locale_writes_literal_text_at_the_root() {
let o = write(&site(Styling::Css, false)).unwrap();
assert!(o.files.contains_key("src/pages/pricing.astro"));
assert!(
!o.files
.keys()
.any(|k| k.contains("i18n") || k.contains("[lang]"))
);
let c = text(&o, "src/components/HomePage.astro");
assert!(!c.contains("t["), "{c}");
assert!(c.contains("href=\"/pricing/\""), "{c}");
}
#[test]
fn styling_variants() {
let scss = write(&site(Styling::Scss, true)).unwrap();
assert!(
scss.files.contains_key("src/styles/global.scss")
&& text(&scss, "package.json").contains("\"sass\"")
);
assert!(text(&scss, "src/components/HomePage.astro").contains("<style lang=\"scss\">"));
assert!(text(&scss, "src/layouts/Layout.astro").contains("global.scss"));
let tw = write(&site(Styling::Tailwind, true)).unwrap();
assert!(text(&tw, "src/styles/global.css").starts_with("@import \"tailwindcss\";"));
assert!(
text(&tw, "astro.config.mjs").contains("tailwindcss()")
&& text(&tw, "package.json").contains("@tailwindcss/vite")
);
assert!(!text(&tw, "src/components/HomePage.astro").contains("<style"));
}
#[test]
fn hostile_text_stays_inert() {
let o = write(&site(Styling::Css, true)).unwrap();
let home = text(&o, "src/pages/index.astro");
assert!(
!home.contains("{x}") && !home.contains("</script>") && !home.contains("${x}"),
"{home}"
);
let comp = text(&o, "src/components/HomePage.astro");
assert!(
!comp.contains("{x}") && !comp.contains("${x}") && !comp.contains("</script>"),
"{comp}"
);
let ts = text(&o, "src/site.ts");
assert!(ts.contains("title: \"{x} </script> `b` ${x} ---\""), "{ts}");
}
#[test]
fn message_key_with_odd_characters_never_becomes_code() {
let mut s = site(Styling::Css, true);
s.pages[0].root = el(
"p",
"",
vec![Child::Text(Text::Msg("a\"]+alert(1)+t[\"".into()))],
);
s.messages[0].insert("a\"]+alert(1)+t[\"".into(), "x".into());
let c = text(&write(&s).unwrap(), "src/components/HomePage.astro");
assert!(
c.contains(r#"{t["a\"]+alert(1)+t[\""]}"#),
"the key stays one JSON string: {c}"
);
}
#[test]
fn output_is_deterministic() {
let a = write(&site(Styling::Scss, true)).unwrap();
let b = write(&site(Styling::Scss, true)).unwrap();
assert_eq!(a.files, b.files);
assert!(a.next_steps[0].contains("npm install && npm run build"));
}
#[test]
fn unusable_names_are_refused() {
let mut s = site(Styling::Css, true);
s.pages[1].component = "homepage".into();
assert!(write(&s).is_err());
let mut s = site(Styling::Css, true);
s.pages[1].component = "HOMEPAGE".into();
assert!(write(&s).is_err(), "case-insensitive clash");
let mut s = site(Styling::Css, true);
s.locales[1].folder = Some("../x".into());
assert!(write(&s).is_err());
}
#[test]
fn css_values_cannot_close_the_style_block() {
let mut s = site(Styling::Css, true);
let evil = "x</style><script>`${x}`</script>";
s.style.classes[0]
.decls
.push(("font-family".into(), evil.into()));
s.style.fonts[0].family = evil.into();
let o = write(&s).unwrap();
let c = text(&o, "src/components/HomePage.astro");
assert_eq!(c.matches("</style>").count(), 1, "{c}");
}
+41
View File
@@ -0,0 +1,41 @@
//! Text that misses WCAG AA contrast. The colours are the designer's, so the export only says so.
use super::model::Frame;
use crate::mcp::lint::text_contrast;
use crate::model::{Document, NodeKind};
/// One warning per visible text layer of the frames below AA, in every locale's document.
/// `labels` name the locales; they are left out of the message with a single locale.
pub fn warnings(docs: &[Document], frames: &[Frame], labels: &[String]) -> Vec<String> {
let mut out = Vec::new();
for (i, doc) in docs.iter().enumerate() {
for f in frames {
for id in doc.subtree(f.id) {
let Some(n) = doc.get(id).filter(|n| n.visible) else {
continue;
};
if !matches!(n.kind, NodeKind::Text { .. }) {
continue;
}
let Some((fg, bg, ratio, need)) = text_contrast(doc, id) else {
continue;
};
if ratio < need {
let mode = if docs.len() > 1 {
format!(" ({})", labels[i])
} else {
String::new()
};
out.push(format!(
"{} / {}{mode}: text {} on {} is {ratio:.2}:1, below WCAG AA ({need}:1); change a colour in the design",
f.name,
n.name,
fg.to_hex(),
bg.to_hex()
));
}
}
}
}
out
}
+175
View File
@@ -0,0 +1,175 @@
//! What lets a page narrower than its design reflow instead of clipping: fixed widths that give
//! way, rows that wrap, big type that scales, pictures that keep their proportions. Only layers
//! in an auto-layout flow qualify: absolutely positioned ones cannot reflow (the lint warns).
use super::style::Decls;
use crate::mcp::codegen;
use crate::mcp::lint::DESKTOP_MIN_W;
use crate::model::{Direction, Document, Node, NodeKind, Sizing};
/// Type from this size up shrinks with the window; body text keeps its size.
const FLUID_TEXT_PX: f64 = 40.0;
/// Slack for the rounding of rects.
const FIT_SLACK_PX: f64 = 0.5;
/// A fixed-width child this wide is a card or column, not an icon or a button.
const COLUMN_MIN_PX: f64 = 120.0;
fn r2(v: f64) -> f64 {
(v * 100.0).round() / 100.0
}
/// The extra declarations of `n`: at the designed width and wider each one is a no-op.
pub fn decls(doc: &Document, n: &Node, replaced: bool) -> Decls {
let mut out = Decls::new();
let Some(parent) = codegen::parent_layout(doc, n) else {
return out;
};
if n.layout_child.absolute {
return out;
}
let fixed_w = n.layout_child.sizing[0] == Sizing::Fixed
&& !matches!(
n.kind,
NodeKind::Text {
auto_width: true,
..
}
);
// A layer wider than its parent's content box overflows on purpose (a bleed, a strip in a
// clipped frame): shrinking it would change the design.
let room = n
.parent
.and_then(|p| doc.get(p))
.map_or(0.0, |p| p.rect.w - parent.padding[1] - parent.padding[3]);
if fixed_w && n.layout_child.max_size[0].is_none() && n.rect.w <= room + FIT_SLACK_PX {
out.push(("max-width".into(), "100%".into()));
}
if replaced
&& fixed_w
&& n.rect.w <= room + FIT_SLACK_PX
&& n.layout_child.sizing[1] == Sizing::Fixed
&& n.rect.h >= 1.0
{
out.push(("height".into(), "auto".into()));
out.push((
"aspect-ratio".into(),
format!("{} / {}", r2(n.rect.w), r2(n.rect.h)),
));
}
if wraps(doc, n) {
out.push(("flex-wrap".into(), "wrap".into()));
}
if let NodeKind::Text { font_size, .. } = &n.kind
&& *font_size >= FLUID_TEXT_PX
{
out.push((
"font-size".into(),
fluid_size(*font_size, full_size_width(doc, n)),
));
if let Some(lh) = n.text_style.line_height {
// A pixel line height would stay as tall as the big type it was drawn for.
out.push(("line-height".into(), format!("{}", r2(lh))));
}
}
out
}
/// The Tailwind classes of `decls`, for the properties `tw_classes` has no hook for.
pub fn tailwind(decls: &Decls) -> Vec<String> {
decls
.iter()
.filter_map(|(k, v)| match k.as_str() {
"max-width" => Some("max-w-full".into()),
"height" => Some("h-auto".into()),
"flex-wrap" => Some("flex-wrap".into()),
"aspect-ratio" => Some(format!("aspect-[{}]", v.replace(' ', ""))),
"font-size" => Some(format!("text-[length:{v}]").replace(' ', "")),
"line-height" => Some(format!("leading-[{v}]")),
_ => None,
})
.collect()
}
/// The window width from which type keeps its designed size: the page frame's own width, at most
/// a desktop's, so a phone design is not shrunk on the phone it was drawn for.
fn full_size_width(doc: &Document, n: &Node) -> f64 {
let page = doc
.ancestors(n.id)
.last()
.and_then(|p| doc.get(*p))
.unwrap_or(n);
page.rect.w.min(DESKTOP_MIN_W)
}
/// `clamp(half, designed size at `full` px, designed size)`.
pub fn fluid_size(px: f64, full: f64) -> String {
format!(
"clamp({}px, {}vw, {}px)",
r2(px / 2.0),
r2(px / full * 100.0),
r2(px)
)
}
/// A row of two or more fixed-width cards or columns may wrap. Rows with a child that fills the
/// rest, icon + label rows and rows that already wrap stay as designed: they fit at any width
/// the design does, and wrapping them would only risk a break from font differences.
fn wraps(doc: &Document, n: &Node) -> bool {
let Some(l) = n
.layout
.as_ref()
.filter(|l| !l.wrap && l.direction == Direction::Horizontal)
else {
return false;
};
let kids: Vec<&Node> = n
.children
.iter()
.filter_map(|c| doc.get(*c))
.filter(|c| !c.layout_child.absolute)
.collect();
let columns = kids
.iter()
.filter(|c| {
c.layout_child.sizing[0] == Sizing::Fixed
&& !matches!(c.kind, NodeKind::Text { .. })
&& c.rect.w >= COLUMN_MIN_PX
})
.count();
let gap = if l.gap_auto { 0.0 } else { l.gap };
let sum: f64 = kids.iter().map(|c| c.rect.w).sum::<f64>() + gap * (kids.len() as f64 - 1.0);
// A carousel that overflows its row by design must not stack.
let fits = sum <= n.rect.w - l.padding[1] - l.padding[3] + FIT_SLACK_PX;
fits && columns >= 2
&& kids
.iter()
.all(|c| c.layout_child.sizing[0] != Sizing::Fill)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_fluid_size_is_the_designed_one_from_a_desktop_up() {
assert_eq!(fluid_size(56.0, 1024.0), "clamp(28px, 5.47vw, 56px)");
assert_eq!(fluid_size(80.0, 1024.0), "clamp(40px, 7.81vw, 80px)");
}
#[test]
fn tailwind_names_the_fluid_declarations() {
let d: Decls = vec![
("max-width".into(), "100%".into()),
("aspect-ratio".into(), "300 / 200".into()),
("font-size".into(), fluid_size(56.0, 1024.0)),
];
assert_eq!(
tailwind(&d),
[
"max-w-full",
"aspect-[300/200]",
"text-[length:clamp(28px,5.47vw,56px)]"
]
);
}
}
+306
View File
@@ -0,0 +1,306 @@
//! Shrinks the font files a site ships: a subset with the characters the pages use, packed as
//! WOFF2, and the metrics of a matching system-font fallback so the swap does not shift layout.
use fontcull_klippa::{DEFAULT_LAYOUT_FEATURES, Plan, SubsetFlags, subset_font};
use fontcull_read_fonts::collections::IntSet;
use fontcull_skrifa::{FontRef, GlyphId, Tag};
use fontcull_write_fonts::types::NameId;
use std::collections::{BTreeSet, HashSet};
/// Always kept: the space, the no-break space, the ellipsis `text-overflow` draws with the
/// element's font and the hyphen browsers insert when they hyphenate.
const ALWAYS: [char; 4] = [' ', '\u{a0}', '\u{2026}', '-'];
/// Relative English letter frequencies (a-z) and the space, to weigh the average glyph width the
/// way Capsize does for `xWidthAvg`.
const WEIGHTS: [(char, f64); 27] = [
('a', 8.167),
('b', 1.492),
('c', 2.782),
('d', 4.253),
('e', 12.702),
('f', 2.228),
('g', 2.015),
('h', 6.094),
('i', 6.966),
('j', 0.153),
('k', 0.772),
('l', 4.025),
('m', 2.406),
('n', 6.749),
('o', 7.507),
('p', 1.929),
('q', 0.095),
('r', 5.987),
('s', 6.327),
('t', 9.056),
('u', 2.758),
('v', 0.978),
('w', 2.360),
('x', 0.150),
('y', 1.974),
('z', 0.074),
(' ', 18.0),
];
/// Average width (em) of [`WEIGHTS`] in Arial and Courier New, the faces `local()` finds on
/// Windows, macOS and most Linux setups, measured with the same weights on their own files.
const ARIAL: SystemFont = SystemFont {
name: "Arial",
width: 0.446_97,
};
const COURIER: SystemFont = SystemFont {
name: "Courier New",
width: 0.600_10,
};
struct SystemFont {
name: &'static str,
width: f64,
}
/// A `@font-face` named `<family> Fallback` that draws a system font at the web font's metrics.
#[derive(Debug, Clone, PartialEq)]
pub struct Fallback {
pub family: String,
pub local: &'static str,
/// Percentages, as CSS wants them.
pub size_adjust: f64,
pub ascent: f64,
pub descent: f64,
pub gap: f64,
}
pub fn fallback_name(family: &str) -> String {
format!("{family} Fallback")
}
/// The fallback face for `bytes`, from its hhea metrics and weighted average width. This is the
/// approach of Next.js `next/font`, Fontaine and Capsize: scale a system font to the web font's
/// average width (`size-adjust`), then divide its vertical metrics by that scale.
/// None when the font has no usable metrics.
pub fn fallback(family: &str, mono: bool, bytes: &[u8]) -> Option<Fallback> {
let face = ttf_parser::Face::parse(bytes, 0).ok()?;
let em = f64::from(face.units_per_em());
let (mut sum, mut weight) = (0.0, 0.0);
for (c, w) in WEIGHTS {
let advance = face
.glyph_index(c)
.and_then(|g| face.glyph_hor_advance(g))?;
sum += w * f64::from(advance) / em;
weight += w;
}
let width = sum / weight;
let sys = if mono { COURIER } else { ARIAL };
if width <= 0.0 || em <= 0.0 {
return None;
}
let scale = width / sys.width;
let pct = |v: f64| (v * 1000.0).round() / 10.0;
Some(Fallback {
family: fallback_name(family),
local: sys.name,
size_adjust: pct(scale),
ascent: pct(f64::from(face.ascender()) / em / scale),
descent: pct(f64::from(-face.descender()) / em / scale),
gap: pct(f64::from(face.line_gap()) / em / scale),
})
}
/// The characters to keep for text that uses `chars`: those, their other case (CSS
/// `text-transform`) and [`ALWAYS`].
pub fn keep(chars: &BTreeSet<char>) -> HashSet<char> {
let mut out: HashSet<char> = ALWAYS.into_iter().collect();
for c in chars {
out.insert(*c);
out.extend(c.to_uppercase());
out.extend(c.to_lowercase());
}
out
}
/// A font file ready to ship: `ext`/`format` describe `bytes`.
pub struct Packed {
pub bytes: Vec<u8>,
pub ext: &'static str,
pub format: &'static str,
/// What had to be given up to keep the file valid.
pub note: Option<&'static str>,
}
/// One klippa (the Rust port of HarfBuzz's subsetter, as vendored by `fontcull`) run, dropping
/// `drop` tables.
fn run_klippa(bytes: &[u8], chars: &HashSet<char>, drop: &[Tag]) -> Result<Vec<u8>, String> {
let font = FontRef::new(bytes).map_err(|e| format!("{e:?}"))?;
let unicodes: IntSet<u32> = chars.iter().map(|c| *c as u32).collect();
let features: IntSet<Tag> = DEFAULT_LAYOUT_FEATURES.iter().copied().collect();
let plan = Plan::new(
&IntSet::<GlyphId>::empty(),
&unicodes,
&font,
SubsetFlags::default(),
&drop.iter().copied().collect(),
&IntSet::<Tag>::all(),
&features,
&IntSet::<NameId>::empty(),
&IntSet::<u16>::empty(),
);
subset_font(&font, &plan).map_err(|e| format!("{e:?}"))
}
/// Whether every lookup of GPOS and GSUB parses to its last subtable. klippa 0.1.2 writes an
/// unreadable GPOS for fonts with many kerning pairs (Inter), which browsers' sanitizers then
/// discard, so the check must be ours.
fn layout_is_sound(font: &[u8]) -> bool {
let Ok(face) = ttf_parser::Face::parse(font, 0) else {
return false;
};
let gpos = face.tables().gpos.is_none_or(|t| {
(0..t.lookups.len()).all(|i| {
t.lookups.get(i).is_some_and(|l| {
(0..l.subtables.len()).all(|j| {
l.subtables
.get::<ttf_parser::gpos::PositioningSubtable>(j)
.is_some()
})
})
})
});
let gsub = face.tables().gsub.is_none_or(|t| {
(0..t.lookups.len()).all(|i| {
t.lookups.get(i).is_some_and(|l| {
(0..l.subtables.len()).all(|j| {
l.subtables
.get::<ttf_parser::gsub::SubstitutionSubtable>(j)
.is_some()
})
})
})
});
gpos && gsub
}
/// `bytes` cut down to `chars`. Layout tables the subsetter breaks are dropped (kerning is lost,
/// the text still draws), and the second value says so.
fn subset(bytes: &[u8], chars: &HashSet<char>) -> Result<(Vec<u8>, Option<&'static str>), String> {
let gpos = Tag::new(b"GPOS");
for drop in [&[][..], &[gpos][..], &[gpos, Tag::new(b"GSUB")][..]] {
let cut = run_klippa(bytes, chars, drop)?;
if layout_is_sound(&cut) {
return Ok((cut, note_static(drop)));
}
}
Err("the subset's layout tables do not parse".into())
}
fn note_static(drop: &[Tag]) -> Option<&'static str> {
match drop.len() {
// The canvas does not kern either, so without GPOS the text keeps the design's widths.
0 | 1 => None,
// GSUB also carries the shaping some scripts cannot draw without.
_ => Some("the font's kerning and ligatures could not be subset and were dropped"),
}
}
/// `bytes` cut down to `chars`, as WOFF2 when it encodes (TTF/OTF otherwise). Err names why the
/// font could not be subset, so the caller can ship the whole file instead.
pub fn pack(bytes: &[u8], chars: &HashSet<char>, cff: bool) -> Result<Packed, String> {
let (ttf, note) = subset(bytes, chars)?;
// A subset that lost a character the original had would draw it in the fallback font.
let (orig, cut) = (
ttf_parser::Face::parse(bytes, 0),
ttf_parser::Face::parse(&ttf, 0),
);
let (Ok(orig), Ok(cut)) = (orig, cut) else {
return Err("the subset does not parse".into());
};
if let Some(c) = chars
.iter()
.find(|c| orig.glyph_index(**c).is_some() && cut.glyph_index(**c).is_none())
{
return Err(format!("the subset lost U+{:04X}", *c as u32));
}
Ok(
match ttf2woff2::encode(&ttf, ttf2woff2::BrotliQuality::default()) {
Ok(woff) => Packed {
bytes: woff,
ext: "woff2",
format: "woff2",
note,
},
Err(_) if cff => Packed {
bytes: ttf,
ext: "otf",
format: "opentype",
note,
},
Err(_) => Packed {
bytes: ttf,
ext: "ttf",
format: "truetype",
note,
},
},
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::text::{face_bytes, face_id};
fn inter() -> &'static [u8] {
face_bytes(face_id(false, false))
}
#[test]
fn a_subset_is_far_smaller_keeps_every_used_character_and_other_cases() {
let chars = keep(&"Olá ção".chars().collect());
assert!(
chars.contains(&'o') && chars.contains(&'Ç'),
"other case is kept"
);
let (cut, _) = subset(inter(), &chars).unwrap();
assert!(
layout_is_sound(&cut),
"a broken GPOS must be dropped, not shipped"
);
let face = ttf_parser::Face::parse(&cut, 0).expect("the subset parses");
for c in &chars {
assert!(face.glyph_index(*c).is_some(), "{c:?} has no cmap entry");
}
assert!(
cut.len() * 10 < inter().len(),
"{} of {}",
cut.len(),
inter().len()
);
let packed = pack(inter(), &chars, false).unwrap();
assert_eq!((packed.ext, packed.format), ("woff2", "woff2"));
assert!(packed.bytes.len() < cut.len() && packed.bytes.starts_with(b"wOF2"));
}
#[test]
fn bytes_that_are_not_a_font_are_an_error_not_a_panic() {
assert!(pack(b"nope", &keep(&BTreeSet::new()), false).is_err());
assert!(fallback("X", false, b"nope").is_none());
}
#[test]
fn the_fallback_scales_arial_to_the_font_and_divides_its_metrics_by_that_scale() {
let f = fallback("Inter", false, inter()).unwrap();
assert_eq!((f.family.as_str(), f.local), ("Inter Fallback", "Arial"));
// Inter is a little wider than Arial and has 0.969 em ascent, -0.242 descent, no gap.
assert!((100.0..115.0).contains(&f.size_adjust), "{f:?}");
let scale = f.size_adjust / 100.0;
assert!(
(f.ascent - 96.9 / scale).abs() < 0.2 && (f.descent - 24.2 / scale).abs() < 0.2,
"{f:?}"
);
assert_eq!(f.gap, 0.0);
assert_eq!(
fallback("Fira Code", true, inter()).unwrap().local,
"Courier New"
);
}
}
+89
View File
@@ -0,0 +1,89 @@
//! Cache and fallback files for the static hosts, written beside the project's other files.
//!
//! Every framework already names its built assets after their content, so those paths can be
//! cached for a year. HTML gets no rule on purpose: Netlify, Cloudflare Pages and Vercel all
//! answer `max-age=0, must-revalidate` for it by default, and an extra catch-all rule would be
//! merged with (not replace) the asset rule on Netlify and Cloudflare.
use super::model::{Framework, Options, Render};
use std::collections::BTreeMap;
const IMMUTABLE: &str = "public, max-age=31536000, immutable";
/// The content-hashed paths of a build, as globs from the site root. `assets/` holds the fonts
/// and images the exporter names by hash; the rest is what each toolchain emits.
fn hashed(o: &Options) -> Vec<&'static str> {
let mut v = match (o.framework, o.render) {
(Framework::Astro, _) => vec!["/_astro/*"],
(Framework::Vue, Render::Static) => vec!["/_nuxt/*"],
(Framework::React, Render::Static) => vec!["/_next/static/*"],
// Angular hashes its root files (main-*.js, chunk-*.js, styles-*.css, polyfills-*.js).
(Framework::Angular, _) => vec!["/*.js", "/*.css"],
_ => vec![],
};
// Vite and the html writer put theirs in assets/ as well.
v.push("/assets/*");
v
}
/// The same globs as a Vercel `source` (path-to-regexp).
fn vercel_source(glob: &str) -> String {
match glob.strip_prefix("/*.") {
Some(ext) => format!(r"/(.*)\.{ext}"),
None => glob.replace('*', "(.*)"),
}
}
fn is_spa(o: &Options) -> bool {
o.render == Render::Spa && o.framework != Framework::Html && o.framework != Framework::Astro
}
/// `_` folders that GitHub Pages' Jekyll would otherwise hide.
fn needs_nojekyll(o: &Options) -> bool {
matches!(
(o.framework, o.render),
(Framework::Astro, _)
| (Framework::Vue, Render::Static)
| (Framework::React, Render::Static)
)
}
/// Adds `_headers`, `vercel.json`, the SPA fallback and `.nojekyll` to `files`.
pub(super) fn add(o: &Options, files: &mut BTreeMap<String, Vec<u8>>) {
// Netlify, Cloudflare and GitHub read theirs from the built folder, which the npm
// frameworks fill from `public/`; Vercel reads `vercel.json` from the project root. The
// html writer's output folder is both.
let dir = if o.framework == Framework::Html {
""
} else {
"public/"
};
let globs = hashed(o);
let mut headers = String::new();
for g in &globs {
headers.push_str(&format!("{g}\n Cache-Control: {IMMUTABLE}\n"));
}
let mut put = |name: &str, text: String| {
files.insert(format!("{dir}{name}"), text.into_bytes());
};
put("_headers", headers);
let rules: Vec<_> = globs
.iter()
.map(|g| {
serde_json::json!({
"source": vercel_source(g),
"headers": [{"key": "Cache-Control", "value": IMMUTABLE}],
})
})
.collect();
let mut vercel = serde_json::json!({"headers": rules});
if is_spa(o) {
// Served only when no file matches, so assets and real pages still win.
put("_redirects", "/* /index.html 200\n".into());
vercel["rewrites"] = serde_json::json!([{"source": "/(.*)", "destination": "/index.html"}]);
}
if needs_nojekyll(o) {
put(".nojekyll", String::new());
}
files.insert("vercel.json".into(), format!("{vercel:#}\n").into_bytes());
}

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