25 Commits

Author SHA1 Message Date
Marcos Paulo 3c74772e68 refactor: clear the clippy warnings the fixes added
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J9PiVCQxc2QYmArFq9VBUX
2026-11-06 19:26:55 -03:00
Marcos Paulo d2a4c073de fix(site): say layer(s) in the breakpoint warnings again
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J9PiVCQxc2QYmArFq9VBUX
2026-11-06 19:26:55 -03:00
Marcos Paulo dbf1be976f fix(lint): placeholder gray, duplicate reading order, button role needs alt or text
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J9PiVCQxc2QYmArFq9VBUX
2026-11-06 19:26:55 -03:00
Marcos Paulo 6f0b8121b0 fix(html): decode the common named character references
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J9PiVCQxc2QYmArFq9VBUX
2026-11-06 19:26:55 -03:00
Marcos Paulo 06f96776fe fix(components): padding and font size count as variant differences
The axis error also gives the reason a variant was left out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J9PiVCQxc2QYmArFq9VBUX
2026-11-06 19:26:55 -03:00
Marcos Paulo 55b8e7f696 fix(site): match phone-frame layers by unique name when the path differs
A desktop grid wrapping cards in Row layers hid them on the phone. Wrappers around name-matched layers become display: contents, and warnings name the layers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J9PiVCQxc2QYmArFq9VBUX
2026-11-06 19:26:55 -03:00
Marcos Paulo c0509d51b2 fix(site): a landmark tag is not inferred inside the same landmark
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J9PiVCQxc2QYmArFq9VBUX
2026-11-06 19:26:55 -03:00
Marcos Paulo a2b6e23873 fix(site): a frame with absolute children is positioned for them
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J9PiVCQxc2QYmArFq9VBUX
2026-11-06 19:26:55 -03:00
Marcos Paulo bb202ce984 fix(site): inferred headings share a level per style and prices are not headings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J9PiVCQxc2QYmArFq9VBUX
2026-11-06 19:26:55 -03:00
Marcos Paulo 5318f48329 fix(site): pin the tailwind packages to an exact version
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J9PiVCQxc2QYmArFq9VBUX
2026-11-06 19:26:55 -03:00
Marcos Paulo bc36bfe26d refactor: clear the two clippy warnings the merged branches added
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J9PiVCQxc2QYmArFq9VBUX
2026-11-06 12:32:29 -03:00
Marcos Paulo 76eb0dc16a docs: roadmap statuses and tool count for text styles, variants, gradients, accessibility, page moves, remote images and breakpoints
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J9PiVCQxc2QYmArFq9VBUX
2026-11-06 12:32:29 -03:00
Marcos Paulo e4bdb80c6d docs(skills): text styles for the type scale
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J9PiVCQxc2QYmArFq9VBUX
2026-11-06 12:32:29 -03:00
Marcos Paulo f06debd70d feat(text): named text styles that update every layer using them
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 12:32:29 -03:00
Marcos Paulo 3763b88b1d test(site): 20px is text-xl in tailwind
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 12:32:29 -03:00
Marcos Paulo 5d05de8cd7 docs(skills): variant axes, gradient bindings, remote images, page moves and accessibility in the skills
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 12:32:29 -03:00
Marcos Paulo 9f9c3a083d feat(a11y): alt text, roles and reading order on layers, linted and exported
Adds set_accessibility, an a11y field per layer (page format v5, only written
when used), four lint rules and explicit overrides in export_site.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 12:32:29 -03:00
Marcos Paulo c6ff26ae04 test(site): check the tailwind breakpoint output for both sizes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 12:32:29 -03:00
Marcos Paulo a0f7e0fe87 feat(mcp): move layers between pages with their references, fetch remote images in write_html
Moving to another page remaps prototype links and connector ends inside the moved
subtree and clears (with a warning) those that would cross pages. write_html fetches
public http(s) images (no private addresses, size cap, timeout, image types only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 12:32:29 -03:00
Marcos Paulo 172b91749a feat(components): multi-axis variant sets and gradient stops bound to variables
merge_variants reads names like "Size=M, State=hover" and makes one
enum property per axis; instances of every variant move to the base
with a value per axis, so swapping one axis keeps the others and any
overrides. bind_variable takes "gradient-stop-N": the stop follows
variable modes and exports as var(--x). Bindings are ordinary entries,
so 0.10.0 opens such pages and keeps the stop's fixed colour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 12:32:29 -03:00
Marcos Paulo e83ee929d6 feat(site): merge frames named 'Name @mobile' into one page with media queries
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 12:32:29 -03:00
Marcos Paulo fa954cb121 docs(skills): translation, import_html and breakpoint frame names in the papercraft skill
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J9PiVCQxc2QYmArFq9VBUX
2026-11-06 12:32:29 -03:00
Marcos Paulo b1eda87316 docs(readme): releases ship Windows, macOS and Linux
site / publish (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J9PiVCQxc2QYmArFq9VBUX
2026-11-06 11:25:23 -03:00
Marcos Paulo 2298ab659e fix(release): put the portable zip's files at its top level
Compress-Archive given a folder stores the folder itself, so the
unpacked update had papercraft.exe one level down and the updater
(0.10.0 included) refused it as "the update does not contain the app".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J9PiVCQxc2QYmArFq9VBUX
2026-11-06 11:25:23 -03:00
Marcos Paulo f365c964bd feat(release): package complete Windows downloads
Publish an installer, portable app and companion CLI/relay assets, and label each download clearly. Tighten nested layer rows and surface page details in the sidebar.
2026-11-06 11:25:23 -03:00
58 changed files with 2205 additions and 180 deletions
+11 -7
View File
@@ -37,16 +37,20 @@ jobs:
with:
shared-key: papercraft-release
save-if: false
- name: Build and zip
- name: Install WiX Toolset
shell: pwsh
run: |
cargo build --release --locked --bins
$v = $env:TAG.TrimStart('v')
New-Item -ItemType Directory -Force dist | Out-Null
$exes = 'papercraft','papercraft-cli','papercraft-mcp','papercraft-relay' | ForEach-Object { "target/release/$_.exe" }
Compress-Archive -Path $exes -DestinationPath "dist/papercraft-$v-windows-x86_64.zip"
$wixPath = Join-Path $env:RUNNER_TEMP 'wix'
dotnet tool install --tool-path $wixPath wix --version 5.0.2
$wixPath | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
- name: Build Windows packages
shell: pwsh
run: |
$env:PAPERCRAFT_VERSION = $env:TAG.TrimStart('v')
./packaging/windows/package.ps1 -Arch x64
Copy-Item target/windows-package/x64/papercraft.exe "dist/release/papercraft-$env:PAPERCRAFT_VERSION-windows-x64-portable.exe"
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with: { name: windows, path: dist/* }
with: { name: windows, path: dist/release/* }
macos:
if: github.server_url == 'https://github.com'
+7 -4
View File
@@ -119,7 +119,7 @@ Projects live in `~/Documents/papercraft/<name>/` (`project.json`, one JSON file
## Agents
PaperCraft exposes **104 tools** (`src/mcp/tools.rs`) three ways. They run the same code inside the app.
PaperCraft exposes **106 tools** (`src/mcp/tools.rs`) three ways. They run the same code inside the app.
| Way | Notes |
|---|---|
@@ -191,13 +191,16 @@ Releases are at [github.com/nxtime/papercraft/releases](https://github.com/nxtim
| Build | File |
|---|---|
| Windows (x86_64) | `papercraft-<version>-windows-x86_64.zip`: `papercraft.exe`, `papercraft-cli.exe`, `papercraft-mcp.exe` and `papercraft-relay.exe` |
| Windows installer | `papercraft-<version>-windows-x64.msi` |
| Windows portable app | `papercraft-<version>-windows-x64-portable.exe` (app only) or `papercraft-<version>-windows-x64-portable.zip` (app, CLI, MCP bridge and relay) |
| Windows CLI tools | `papercraft-cli-<version>-windows-x64.zip` (`papercraft-cli.exe` and `papercraft-mcp.exe`) |
| Windows relay | `papercraft-relay-<version>-windows-x64.zip` (`papercraft-relay.exe`) |
| macOS app (Apple silicon) | `papercraft-<version>-macos-aarch64.dmg`, containing PaperCraft.app |
| Linux (x86_64) | `papercraft-<version>-linux-x86_64.tar.gz` |
| CLI tools, macOS | `papercraft-cli-<version>-macos-aarch64.zip` (`papercraft-cli` and `papercraft-mcp`) |
| CLI tools, Linux | `papercraft-cli-<version>-linux-x86_64.tar.gz` |
The Windows zip is unsigned: SmartScreen may ask you to confirm the first run. macOS builds are ad-hoc signed and not notarized: on another Mac, open the app with right-click, Open the first time. Other platforms: build from source.
The Windows downloads are unsigned: SmartScreen may ask you to confirm the first run. macOS builds are ad-hoc signed and not notarized: on another Mac, open the app with right-click, Open the first time. Other platforms: build from source.
### Build from source
@@ -284,7 +287,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 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.
* **Releases** cover Windows x64, macOS on Apple silicon and Linux x86_64; other platforms build from source. The Windows downloads are unsigned and the macOS builds are not notarized.
* **PDF** text is outlined, not selectable.
## Project layout
+11 -11
View File
@@ -20,9 +20,9 @@ Agents can already build a design system through the tools; people cannot yet ma
| 5 | Lint in the UI | **done** | Live Lint section in Design tab; click issue to select & zoom, or click `Fix` / `Fix all`. |
| 6 | Variable modes | **done** | Mode selector + create/delete modes in Design tab and via `set_variables {mode}` / `set_variable_mode`. |
| 7 | Number variables (spacing, radius, font size) | **done** | Typed number, string and boolean variables in collections with groups and aliases; fields bind (size, gap, padding, radius, font size, line height, opacity, visible) in the inspector and `bind_variable`. |
| 8 | Stroke and gradient variables | partial | Strokes and text color bind to variables. Gradients keep fixed colors. |
| 9 | Text styles | todo | Named type styles (Heading / L, Body / M) that update every text layer using them. |
| 10 | Component variants and properties | partial | Main components carry text, bool, icon and enum properties; `merge_variants` folds per-state components into one. Multi-axis variant sets todo. |
| 8 | Stroke and gradient variables | **done** | Strokes, text color and gradient stops (`bind_variable` `gradient-stop-N`) bind to variables. |
| 9 | Text styles | **done** | `set_text_style`: named type styles (Heading / L, Body / M) that update every text layer using them, in one undo step. |
| 10 | Component variants and properties | **done** | Main components carry text, bool, icon and enum properties; `merge_variants` folds per-state components into one, with one enum per axis for names like `Size=M, State=hover`. |
| 11 | Swap instance, detach by tool | **done** | `swap_instance` and `detach_instance` MCP tools + UI controls in Design tab and Inspector. |
| 12 | Cross-page and shared libraries | **done** | Instances of a main on any page; publish a project as a library, enable, update and disable it in other projects (Libraries modal, `*_library` tools). |
| 13 | Export design tokens | **done** | `get_variables` with `format: "css"`, `"json"` (W3C DTCG + modes), or `"tailwind"` (`@theme`), plus Copy CSS / Copy JSON in Design tab. |
@@ -32,14 +32,14 @@ Agents can already build a design system through the tools; people cannot yet ma
| # | Item | Status | Notes |
|---|---|---|---|
| 15 | MCP and CLI tool surface | **done** | 104 tools, one undo step per call, `papercraft-cli list / schema / call`. |
| 15 | MCP and CLI tool surface | **done** | 106 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. |
| 17 | Accessibility annotations | **done** | `set_accessibility`: alt text, decorative, roles, heading levels and reading order on layers, checked by `lint_design` and used by `export_site`. |
| 18 | Code export from frames | **done** | `get_jsx` supports `format: "inline"`, `"tailwind"` (Tailwind utility classes), and `"html"` (HTML + CSS rules with `:root` variables). |
| 19 | Plugin and skill marketplace | todo | Skills are copied by hand today. |
| 20 | Screenshot diffing | **done** | `diff_frames` tool (`src/mcp/diff.rs`) for structural/property frame diffing + `diff_rgba` pixel diffing with highlight PNG. |
| 21 | Moving nodes between pages | todo | `move_nodes` stays on one page. |
| 22 | Remote images in `write_html` | todo | Only local paths and `data:` URIs. |
| 21 | Moving nodes between pages | **done** | `move_nodes {toPageId}` keeps instances linked, notes attached, and remaps prototype links and connectors. |
| 22 | Remote images in `write_html` | **done** | http(s) images are fetched from public addresses only, with redirects re-checked, a 10 MB cap and a 15 s deadline. |
| 46 | Design from the CLI without the app | **done** | `papercraft-cli call --offline` and `shell` run the tools in-process on the project store; screenshots use a GPU or the software adapter. |
## 3. Design features
@@ -47,10 +47,10 @@ Agents can already build a design system through the tools; people cannot yet ma
| # | Item | Status | Notes |
|---|---|---|---|
| 23 | Prototyping links and flows | **done** | Prototype tab in the inspector; Present mode follows links with a camera glide. Smart transitions and hover states todo. |
| 24 | Responsive constraints and breakpoint preview | partial | Constraints (left, right, stretch, center, scale) exist in the editor; there is no breakpoint preview or per-size variants. |
| 24 | Responsive constraints and breakpoint preview | partial | Constraints exist in the editor; `export_site` merges frames named `Home @mobile` or `Home @768` into one page with media queries. No breakpoint preview in the editor. |
| 25 | Shadows, blur and effects | partial | Drop shadow is an inspector effect. Layer and background blur todo (needs a renderer pass). |
| 26 | Margin and grid layout | partial | Wrapped-line cross alignment, per-line fill/align, and `layout_grid` track layout (`src/layout.rs`) implemented; CSS margin still ignored. |
| 27 | Font choice | todo | Inter only. |
| 27 | Font choice | **done** | `install_font` adds a .ttf or .otf to the project; `font-family` applies it to a text layer. |
| 28 | Rotation | todo | Nodes are axis-aligned. |
| 29 | Overlay, dodge, burn, light blend modes on the canvas | todo | Exact in SVG and PDF export, approximated as Normal on the canvas. |
| 30 | Rounded frame clipping | todo | Clipping is a rectangle. |
@@ -63,7 +63,7 @@ Agents can already build a design system through the tools; people cannot yet ma
| 32 | Version history | **done** | Named and automatic saved versions per page: rename, restore, delete (`src/versions.rs`). Restore now includes variables and tokens; compare todo. |
| 33 | Figma import | **done** | `.fig` binary (Kiwi wire decoder + zip container) and Figma REST API JSON import in `src/io/fig.rs`. |
| 34 | Multiplayer and cloud sync | partial | Optional shared projects through the relay, with merge of concurrent edits to one page. Presence (cursors, selections) todo. Stays optional. |
| 35 | Release builds for Windows and Linux | partial | Windows x86_64 zips are released and `packaging/windows` builds an MSI; Linux x86_64 is not in the current releases. |
| 35 | Release builds for Windows and Linux | **done** | Releases ship Windows (MSI and portable zip), macOS and Linux builds. |
## 5. Quality
@@ -81,7 +81,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. |
| 41 | Agent presence on the canvas | partial | Layers an agent touches get a fading outline and a cursor labelled with the verb and client; the chat can stop its own run. Pausing an external MCP client is todo. |
| 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`. |
+3 -2
View File
@@ -5,7 +5,7 @@
.DESCRIPTION
Produces, in $env:DIST (default: dist/release):
papercraft-<version>-windows-<arch>.msi per-machine installer (WiX v5)
papercraft-<version>-windows-<arch>-portable.zip papercraft.exe + papercraft-cli.exe + papercraft-mcp.exe
papercraft-<version>-windows-<arch>-portable.zip all four executables: app, CLI, MCP bridge and relay
papercraft-cli-<version>-windows-<arch>.zip papercraft-cli.exe + papercraft-mcp.exe standalone
.EXAMPLE
@@ -104,7 +104,8 @@ foreach ($f in 'README.md', 'LICENSE', 'LICENSE-MIT', 'LICENSE-APACHE', 'ATTRIBU
}
$Zip = Join-Path $Dist "papercraft-$Version-windows-$Arch-portable.zip"
Remove-Item -Force $Zip -ErrorAction SilentlyContinue
Compress-Archive -Path $Portable -DestinationPath $Zip
# The files themselves, not their folder: the in-app updater looks for papercraft.exe at the top.
Compress-Archive -Path (Join-Path $Portable '*') -DestinationPath $Zip
# ---- cli zip -----------------------------------------------------------------------------------
$CliPortable = Join-Path $TargetDir "windows-package\papercraft-cli-$Version-windows-$Arch"
+2 -2
View File
@@ -23,8 +23,8 @@
<Icon Id="PapercraftIcon.ico" SourceFile="$(var.IconPath)" />
<Property Id="ARPPRODUCTICON" Value="PapercraftIcon.ico" />
<Property Id="ARPURLINFOABOUT" Value="https://git.marcospaulo.dev.br/masi/papercraft" />
<Property Id="ARPURLUPDATEINFO" Value="https://git.marcospaulo.dev.br/masi/papercraft" />
<Property Id="ARPURLINFOABOUT" Value="https://github.com/nxtime/papercraft" />
<Property Id="ARPURLUPDATEINFO" Value="https://github.com/nxtime/papercraft/releases/latest" />
<StandardDirectory Id="ProgramFiles6432Folder">
<Directory Id="INSTALLFOLDER" Name="PaperCraft" />
+1 -1
View File
@@ -52,7 +52,7 @@ if (-not $haveCert -and -not $haveAzure) {
}
$script:SignTool = Find-SignTool
$common = @('sign', '/v', '/fd', 'SHA256', '/td', 'SHA256', '/d', 'PaperCraft', '/du', 'https://git.marcospaulo.dev.br/masi/papercraft')
$common = @('sign', '/v', '/fd', 'SHA256', '/td', 'SHA256', '/d', 'PaperCraft', '/du', 'https://github.com/nxtime/papercraft')
$tmp = Join-Path ([IO.Path]::GetTempPath()) "papercraft-sign-$([IO.Path]::GetRandomFileName())"
New-Item -ItemType Directory -Path $tmp | Out-Null
+1 -1
View File
@@ -332,7 +332,7 @@
<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>
<p>Windows builds are 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>
+13 -3
View File
@@ -9,7 +9,7 @@ const ICONS = {
};
const PLATFORMS = [
{ key: 'windows', name: 'Windows', arch: 'x86_64', note: 'App, CLI, MCP bridge and relay in one zip.' },
{ key: 'windows', name: 'Windows', arch: 'x86_64', note: 'Choose the MSI installer, standalone app, full portable bundle, CLI tools or relay.' },
{ 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.' }
];
@@ -46,7 +46,14 @@ const formatDate = iso => new Date(iso).toLocaleDateString('en-GB', { day: 'nume
const fileType = name => (name.endsWith('.tar.gz') ? 'tar.gz' : name.split('.').pop());
const fileLabel = name => (name.includes('-cli-') ? 'CLI tools' : 'Desktop app');
const fileLabel = name => {
if (name.includes('-relay-')) return 'Relay server';
if (name.includes('-cli-')) return 'CLI + MCP tools';
if (name.endsWith('.msi')) return 'Installer';
if (name.includes('-portable.exe')) return 'Portable app';
if (name.includes('-portable.zip')) return 'Portable bundle';
return 'Desktop app';
};
const loadRelease = async () => {
try {
@@ -105,7 +112,10 @@ const renderRelease = release => {
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-'));
const platformAssets = OS ? release.assets.filter(a => a.name.includes(`-${OS}-`)) : [];
const primary = platformAssets.find(a => a.name.endsWith('.msi'))
|| platformAssets.find(a => a.name.includes('-portable.exe'))
|| platformAssets.find(a => !a.name.includes('-cli-') && !a.name.includes('-relay-'));
if (primary) {
document.getElementById('hero-dl').href = primary.url;
@@ -93,6 +93,9 @@ Per screen and component, pass, fail or n/a:
**Robust**
* Controls are named for their role (layer names, code links to real components), so the code
hand-off keeps them buttons and inputs, not divs.
* Images have alt text or are marked decorative, icon buttons have a name, heading levels do not
skip, and the reading order matches the visual one. Evidence: `lint_design` rules `image-alt`,
`interactive-name`, `heading-skip`, `reading-order`; the fix is `set_accessibility`.
## copy
+1 -1
View File
@@ -91,7 +91,7 @@ Sizes: **12, 14, 16, 20, 24, 32, 40, 48, 64.** `lint_design` flags anything else
* Error: message under the field in `danger`, with an icon or text, not color alone.
* Loading: skeleton blocks in `surface-2` in the shape of the content.
* Variants are separate components in PaperCraft (`Button / Primary`, `Button / Disabled`); keep the names parallel so they sort together.
* Keep a design-system page (swatches from the variables, the type scale, every component with its states). See the recipe in the `papercraft` skill.
* Keep a design-system page (swatches from the variables, the type scale as text styles, every component with its states). See the recipe in the `papercraft` skill.
## 8. Naming
+11 -7
View File
@@ -66,7 +66,7 @@ Lessons from redesigning a 3-page, 130-screen project with up to six agents at o
## 3. Naming
* Layers: `Noun` or `Component / Variant` (`Button / Primary`, `Tab / Today`, `Card / Task`, `Icon / gear`). Slashes group the layers panel.
* Screens (artboards): the screen's name (`Sign in`, `Task detail`), not `Frame 12`. Add a size suffix only when you have several breakpoints (`Landing / 1440`).
* Screens (artboards): the screen's name (`Sign in`, `Task detail`), not `Frame 12`. For another width of the same screen, name the frame `<screen> @mobile` or `<screen> @768` (the widest window it applies to) and keep its layer names identical: `export_site` merges it into the same page as media queries.
* Variables: roles, not hues (`surface`, `text-muted`, `on-accent`), kebab-case.
* Lint flags default names (`Frame`, `Rectangle`, `Group`) on layers that have children.
@@ -123,7 +123,7 @@ Full flows: [recipes.md](references/recipes.md).
| Tokens | `get_variables` (`format` css, json or tailwind), `set_variables` (`collection`, `type`, `mode`, `alias`), `create_collection`, `delete_variables`, `set_variable_mode`, `bind_variable` |
| 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 |
| 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. Names like `Button / Size=M, State=hover` make one enum per axis (Size, State); `set_instance_props` then picks a value per axis |
| 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 |
@@ -137,7 +137,11 @@ Full flows: [recipes.md](references/recipes.md).
| Design rules (design-playbook) | `design_rules` (`query` or `rule` id; needs the `playbook` CLI; cite ids in the report) |
| Report a PaperCraft problem | `report_issue` (a draft the user sends; synthetic repro only) |
| Check quality | `lint_design` (`changedOnly: true` or `since` between edits, a full pass at the end; `overflow` flags layers past their artboard or clipping parent) |
| Type scale | `set_text_style {action: "set", name: "Heading / L", fontSize, bold, lineHeight, letterSpacing, font}` once per step of the scale, then `{action: "apply", name, nodeIds}` on the text layers; changing a style later updates every layer that follows it (one undo step). `get_variables` lists them as `textStyles` |
| Accessibility | `set_accessibility {items:[{nodeId, alt, decorative, role, level, order}]}` on images, icon buttons, headings and nav; `lint_design` then checks `image-alt`, `interactive-name`, `heading-skip` and `reading-order`, and `export_site` uses them |
| Export | `export` (PNG, SVG, PDF to files), `export_combined_pdf`, `export_presentation` (a client deck: cover, contents, a slide per screen) |
| Translate | `get_strings` reads a page's text; `set_strings` moves it into a string collection with a mode per language, fills a language or a pseudo-locale; `set_variable_mode` previews it |
| Start from another tool's page | `import_html` opens an exported `.html` (or `.zip` with its CSS and images) as layers |
| 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` |
@@ -145,13 +149,13 @@ Full flows: [recipes.md](references/recipes.md).
## 6. Known limits (design around them)
* **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.
* **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, and keep the sizes as named text styles (`set_text_style`).
* **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.
* **Components work across pages**: `create_instance` takes a main from any page or enabled library (`componentId` or `componentKey` from `get_components`) and follows later edits. To share with other projects, `publish_library` there and `enable_library` here; `update_library` pulls a newer publish.
* **No variants or properties**: one component per variant (`Button / Primary`, `Button / Secondary`). `swap_instance` and `detach_instance` exist.
* **Remote images are not fetched.** Use a local path or `data:` URI.
* **Variants** are enum properties on one main component (`merge_variants`, one enum per axis). Each axis needs at least two variants that differ from the base in that axis alone; a corner variant (`L` + `hover`) is assumed to be the sum of its axes.
* **Remote images**: `write_html` fetches public http(s) PNG, JPEG, GIF or WebP (up to 10 MB; private and local addresses are refused). A failed fetch is a warning, not an error: check `warnings`.
* **One project open at a time**; moving layers between pages is not supported.
* **Text boxes with fixed widths can wrap earlier than in a browser.** Check headlines in the screenshot and widen.
* **Instances are skipped by lint**; lint the main component.
@@ -159,7 +163,7 @@ Full flows: [recipes.md](references/recipes.md).
* **An instance keeps the size of its main.** Overriding text with a longer string clips it (a `Saved` chip made from a `Chip` main loses its right padding; a `Share` button from a wider/narrower main sits off-centre). After `set_text_content` on instance text, `update_styles {width:"fit-content"}` on the instance root and on its text layers, or pass `style="width:fit-content;justify-content:center"` on `<x-paper-instance>`.
* **Text inside nested frames**: `set_text_content` needs the Text node id. Walk `get_children` down from the instance; instances copy the main's structure (`Meta > Label`, not a flat list).
* **No icon-colour follow for stroke sets** (Lucide, Tabler): the colour is frozen at write time, so a mode switch (Mocha to Latte) leaves icons as they were. Use a fill set (Phosphor, Material) when a design must switch modes.
* **Pages**: `create_page`, `rename_pages`, `delete_pages` (needs `confirm: true` when the page has layers; the last page stays). `move_nodes` accepts `toPageId` to copy between pages.
* **Pages**: `create_page`, `rename_pages`, `delete_pages` (needs `confirm: true` when the page has layers; the last page stays). `move_nodes` accepts `toPageId` to move between pages: links and connectors among the moved layers follow them, references across pages are cleared (listed in `warnings`), and undo is one step per page.
* **`get_screenshot` of `root` on a wide page** is scaled to fit and unreadable. Screenshot one artboard (or one screen slot) at a time. Hoisted components are not inside an artboard screenshot.
* **CLI saves images to the cwd** as `<tool>.png`, or to `-o FILE`. The CLI writes the file itself; on older builds an `out` path made the app write it and could freeze the app behind a macOS privacy prompt, so update the CLI with the app (`tools/install-local.sh` installs both).
* **`swap_instance` renames the instance root to the new main's name** unless the name was overridden. Re-apply custom layer names after a batch swap (`rename_nodes`).
@@ -173,7 +177,7 @@ Full flows: [recipes.md](references/recipes.md).
| `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. |
| A color did not change with the theme | It was written as hex. Use `var(--name)` on fills and text; bind strokes and gradient stops with `bind_variable` (`field: "stroke"`, `"gradient-stop-N"`). |
| `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. |
| `papercraft-cli --online` says "did not answer in time" | The app is blocked: look for a macOS dialog or an in-app modal and ask the user to answer it. Don't retry in a loop and don't restart the app. |
@@ -46,6 +46,7 @@ The writers share these behaviours:
- 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.
- Breakpoints: name a narrower frame of the same screen `Home @mobile` (or `Home @768`) next to the frame `Home`. They become one page: the wider frame is the base, and the narrower frame's differing declarations are written as `@media (max-width: 390px) { :root .layer { ... } }` (the width is the suffix when it is a number, else the frame's own width; several variants cascade widest first). Layers match by layer name path (same-named siblings by order), so keep names identical across the frames. A layer with no layer at its path falls back to the narrow-frame layer of the same name when the name is unique in both frames (so cards wrapped in `Row 1` on the desktop and flat on the phone still match; the wrapper becomes `display: contents` there). Other layers missing from the narrow frame get `display: none` there; layers only the narrow frame has are left out. Warnings name the layers. Copy and pictures come from the base frame. Works with css, scss and tailwind (the element keeps a class carrying the media rules next to its utilities); CSS-in-JS stylings use the base frame and warn. A variant with no matching base, not narrower than it, or sharing a width with another variant is exported as its own page with a warning. Links to a variant frame go to the merged page.
- 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`.
+31 -2
View File
@@ -357,7 +357,7 @@ Parse HTML with inline styles into layers. Write incrementally: each call should
- 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 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.
- <img src> takes paper-asset:///abs/path, file:// URLs, absolute paths, data: URIs or public http(s) URLs (PNG, JPEG, GIF or WebP up to 10 MB; private and loopback addresses are refused and a failed download is a warning). <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.
- layer-name="..." names layers; <x-paper-clone node-id="ID" style="..."/> clones an existing node (supports cross-page `<page>:<id>`).
Returns the ids of the created top-level layers and the styles that were ignored.
@@ -425,7 +425,7 @@ Deep-clone nodes, including across pages (via `toPageId` or a cross-page `parent
### `move_nodes`
Move nodes keeping their ids (or onto another page via `toPageId` or a cross-page `parentId` / `before` / `after`). Each move is {nodeId, before: siblingId}, {nodeId, after: siblingId} or {nodeId, parentId, toPageId?, index?} (parentId "root" = the page root). Moves apply in order. In auto-layout parents this changes the visual order; in free parents it changes stacking and keeps the world position.
Move nodes keeping their ids (or onto another page via `toPageId` or a cross-page `parentId` / `before` / `after`). Each move is {nodeId, before: siblingId}, {nodeId, after: siblingId} or {nodeId, parentId, toPageId?, index?} (parentId "root" = the page root). Moves apply in order. In auto-layout parents this changes the visual order; in free parents it changes stacking and keeps the world position. Moving to another page copies the layers there (new ids; each page has its own undo history) and keeps component links and links between the moved layers; prototype links and connector ends that would cross pages are cleared and reported in `warnings`.
| Argument | Type | Required | Notes |
|---|---|---|---|
@@ -530,6 +530,24 @@ Bind a field of layers to a variable (or detach it). `field`: stroke, text-color
| `variable` | string \| null | | |
### `set_text_style`
Named type styles of a page ("Heading / L", "Body / M"); get_variables lists them as `textStyles`. `action` set: create the style `name` or change the fields given (fontSize is required for a new one); every layer that follows it is updated at once, and a style edit replaces local font size, font, bold, italic, line height and letter spacing on those layers (character spans stay). delete: removes the style; layers keep their look and stop following it. apply: `nodeIds` text layers follow `name` (omitted or null detaches, keeping the look). Editing a layer's font properties by hand does not detach it: the next style change overwrites them. One undo step per call.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `action` | string | yes | set, delete or apply. |
| `name` | string | | |
| `fontSize` | number | | px |
| `font` | string \| null | | Family added with install_font; null = Inter. |
| `bold` | boolean | | |
| `italic` | boolean | | |
| `lineHeight` | number \| null | | Multiple of the font size (1.5); null = font default. |
| `letterSpacing` | number | | px per character |
| `nodeIds` | array of string | | apply: text layers. |
| `pageId` | string | | |
### `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.
@@ -898,3 +916,14 @@ Call when done creating or editing. Saves the project.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `nodeIds` | array of string | | Optional nodes you worked on. |
### `merge_variants` (variant sets)
Variant names of the form `Size=M, State=hover` (component names or `values`) build a variant set: one enum property per axis, each read from the variants that differ from the base in that axis alone, so axes combine additively. `set_instance_props` picks a value per axis; overrides survive the swap. The result lists `axes`.
### `set_accessibility`
Sets or clears accessibility annotations on layers: alt text, decorative mark, role, heading level and reading order. `get_node_info` shows them as `accessibility`; `lint_design` checks them (`image-alt`, `interactive-name` for a clickable or `button`/`link` layer with no visible text or alt (the layer name is not exported), `heading-skip`, `reading-order` for a disagreeing or duplicate `order`); `export_site` writes them instead of inferring. All items are checked before any is applied.
| Argument | Type | Required | Notes |
|---|---|---|---|
| `items` | array | yes | Objects `{nodeId, alt?, decorative?, role?, level?, order?, clear?}`. `alt`: text alternative (or the name of an icon button). `decorative`: ornament, needs no alt. `role`: button, link, heading, list, nav, main, header, footer, aside or section. `level`: 1-6, only with role heading. `order`: reading position among siblings (frames without auto layout), lower first. null clears a field; `clear: true` removes all first. |
+28 -1
View File
@@ -10,7 +10,7 @@
//! `Editor::design` without pushing history, so design and caches cannot disagree.
use super::Editor;
use crate::model::{Color, Node, NodeId, NodeKind};
use crate::model::{Color, Node, NodeId, NodeKind, Paint};
use crate::variables::{
self, COLORS_COLLECTION, DEFAULT_MODE, DesignSystem, Error, Kind, PageModes, TOKENS_COLLECTION,
Value,
@@ -304,6 +304,11 @@ impl Editor {
| "padding-left" => n.layout.is_some(),
"font-size" | "line-height" | "text-color" => n.kind.font_size().is_some(),
"text" => n.kind.text().is_some(),
f if gradient_stop(f).is_some() => {
let i = gradient_stop(f).unwrap_or(0);
let mut probe = n.clone();
top_stops_mut(&mut probe).is_some_and(|s| i < s.len())
}
_ => true,
};
if want.is_some() && !ok {
@@ -380,8 +385,22 @@ fn css_renames(before: &DesignSystem, next: &DesignSystem) -> BTreeMap<String, S
// ------------------------------------------------------------------ field bindings
/// The variable kind a bindable field takes, or None for an unknown field.
/// `gradient-stop-2`: the third colour stop of the node's top gradient fill.
pub fn gradient_stop(field: &str) -> Option<usize> {
field.strip_prefix("gradient-stop-")?.parse().ok()
}
/// The stops of the top gradient fill (the one that shows over the others).
fn top_stops_mut(n: &mut Node) -> Option<&mut Vec<crate::model::Stop>> {
n.style.fills.iter_mut().rev().find_map(|p| match p {
Paint::Linear { stops, .. } | Paint::Radial { stops, .. } => Some(stops),
_ => None,
})
}
fn field_kind(field: &str) -> Option<Kind> {
Some(match field {
f if gradient_stop(f).is_some() => Kind::Color,
"stroke" | "text-color" => Kind::Color,
"width" | "height" | "gap" | "padding" | "padding-top" | "padding-right"
| "padding-bottom" | "padding-left" | "radius" | "font-size" | "line-height"
@@ -423,6 +442,14 @@ fn set_bound_text(n: &mut Node, new: &str) {
/// 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 {
f if gradient_stop(f).is_some() => {
let i = gradient_stop(f).unwrap_or(0);
if let Some(c) = cache.variables.get(name)
&& let Some(s) = top_stops_mut(n).and_then(|s| s.get_mut(i))
{
s.color = *c;
}
}
"stroke" => {
if let Some(c) = cache.variables.get(name) {
n.style.stroke = Some(*c);
+1
View File
@@ -14,6 +14,7 @@ pub mod shortcuts;
pub mod snap;
pub mod structure;
pub mod text_edit;
pub mod text_styles;
pub mod vector_ops;
pub mod whiteboard;
+1
View File
@@ -22,6 +22,7 @@ pub fn override_key(field: &str) -> &'static str {
"x" | "y" | "position" => "position",
"width" | "height" | "size" => "size",
"fill" | "fills" | "color" | "text-color" => "fills",
f if super::design::gradient_stop(f).is_some() => "fills",
"stroke" | "stroke width" | "stroke align" | "dash" | "cap" | "join" => "stroke",
"radius" | "radii" => "radius",
"opacity" | "blend" => "opacity",
+136
View File
@@ -0,0 +1,136 @@
//! Named type styles on a page: define, change, delete and apply. Each call is one undo step.
use super::Editor;
use crate::model::{NodeId, TypeStyle};
impl Editor {
/// Create or replace style `name`; every layer that follows it takes the new values.
/// Returns how many layers changed.
pub fn set_text_style(&mut self, name: &str, style: TypeStyle) -> usize {
self.begin("Set text style");
self.tx().snapshot_variables();
let mut changed = Vec::new();
for id in followers(&self.doc, name) {
if let Some(n) = self.tx().node_mut(id)
&& style.apply(name, n)
{
changed.push(id);
}
}
self.doc.text_styles.insert(name.to_string(), style);
for id in &changed {
self.fit_text(*id);
}
self.commit();
changed.len()
}
/// Remove style `name`; layers that followed it keep their current look and stop following.
/// None when there is no such style.
pub fn delete_text_style(&mut self, name: &str) -> Option<usize> {
if !self.doc.text_styles.contains_key(name) {
return None;
}
self.begin("Delete text style");
self.tx().snapshot_variables();
let users = followers(&self.doc, name);
for id in &users {
if let Some(n) = self.tx().node_mut(*id) {
n.text_style.style_ref = None;
}
}
self.doc.text_styles.remove(name);
self.commit();
Some(users.len())
}
/// Make text layers follow style `name` (None: stop following, keeping the look). Returns
/// the layers changed; non-text nodes are skipped.
pub fn apply_text_style(&mut self, ids: &[NodeId], name: Option<&str>) -> Result<usize, String> {
let style = match name {
Some(n) => Some((n, self.doc.text_styles.get(n).cloned().ok_or_else(|| format!("no text style `{n}`"))?)),
None => None,
};
self.begin("Apply text style");
let mut changed = Vec::new();
let mut tx = self.tx();
for id in ids {
let Some(n) = tx.node_mut(*id) else { continue };
let ok = match &style {
Some((name, s)) => s.apply(name, n),
None => n.text_style.style_ref.take().is_some(),
};
if ok {
changed.push(*id);
}
}
for id in &changed {
self.fit_text(*id);
}
self.commit();
Ok(changed.len())
}
}
fn followers(doc: &crate::model::Document, name: &str) -> Vec<NodeId> {
let mut ids: Vec<NodeId> = doc
.nodes
.values()
.filter(|n| n.text_style.style_ref.as_deref() == Some(name))
.map(|n| n.id)
.collect();
ids.sort();
ids
}
#[cfg(test)]
mod tests {
use super::*;
use crate::geom::Rect;
use crate::model::NodeKind;
fn text_node(e: &mut Editor, s: &str) -> NodeId {
let kind = NodeKind::Text { text: s.into(), font_size: 16.0, auto_width: true, align: Default::default() };
e.add_node(crate::model::Node::new(NodeId(0), kind, Rect::new(0.0, 0.0, 50.0, 20.0)), None)
}
fn big() -> TypeStyle {
TypeStyle { font_size: 40.0, font: None, bold: true, italic: false, line_height: Some(1.1), letter_spacing: 1.0 }
}
#[test]
fn changing_a_style_updates_followers_in_one_undo_step() {
let mut e = Editor::default();
let (a, b) = (text_node(&mut e, "A"), text_node(&mut e, "B"));
e.set_text_style("H", big());
assert_eq!(e.apply_text_style(&[a], Some("H")), Ok(1));
assert_eq!(e.doc.get(a).unwrap().kind.font_size(), Some(40.0));
assert_eq!(e.doc.get(b).unwrap().kind.font_size(), Some(16.0));
let bigger = TypeStyle { font_size: 60.0, ..big() };
assert_eq!(e.set_text_style("H", bigger), 1);
assert_eq!(e.doc.get(a).unwrap().kind.font_size(), Some(60.0));
e.undo();
assert_eq!(e.doc.get(a).unwrap().kind.font_size(), Some(40.0));
assert_eq!(e.doc.text_styles["H"].font_size, 40.0);
e.undo();
e.undo();
assert!(e.doc.text_styles.is_empty());
}
#[test]
fn deleting_detaches_but_keeps_the_look_and_unknown_names_fail() {
let mut e = Editor::default();
let a = text_node(&mut e, "A");
assert!(e.apply_text_style(&[a], Some("nope")).is_err());
e.set_text_style("H", big());
e.apply_text_style(&[a], Some("H")).unwrap();
assert_eq!(e.delete_text_style("H"), Some(1));
let n = e.doc.get(a).unwrap();
assert_eq!((n.text_style.style_ref.clone(), n.kind.font_size()), (None, Some(40.0)));
assert_eq!(e.delete_text_style("H"), None);
e.undo();
assert_eq!(e.doc.get(a).unwrap().text_style.style_ref.as_deref(), Some("H"));
assert!(e.doc.text_styles.contains_key("H"));
}
}
+1 -1
View File
@@ -306,7 +306,7 @@ impl<'a> Tx<'a> {
}
}
fn snapshot_variables(&mut self) {
pub(crate) fn snapshot_variables(&mut self) {
if self.rec.variables_before.is_none() {
self.rec.variables_before = Some(self.doc.var_state());
}
+75
View File
@@ -0,0 +1,75 @@
//! `set_accessibility`: alt text, role, heading level and reading order on layers.
use super::exec::{page_arg, resolve, text, Args};
use super::ToolResult;
use crate::editor::Editor;
use crate::model::a11y::ROLES;
use crate::model::{A11y, NodeId};
use serde_json::{json, Value};
/// The layer's annotations after applying one item; `None` when nothing is left.
fn apply(old: Option<&A11y>, u: &Args) -> Result<Option<A11y>, String> {
let mut a = if u.bool("clear") == Some(true) { A11y::default() } else { old.cloned().unwrap_or_default() };
// An explicit null clears a field; a missing key leaves it alone.
let given = |k: &str| u.0.contains_key(k);
if given("alt") {
a.alt = u.str("alt").filter(|t| !t.is_empty()).map(str::to_owned);
}
if let Some(d) = u.bool("decorative") {
a.decorative = d;
}
if given("role") {
a.role = match u.str("role") {
None => None,
Some(r) if ROLES.contains(&r) => Some(r.to_owned()),
Some(r) => return Err(format!("`role` must be one of {}, not `{r}`", ROLES.join(", "))),
};
}
if given("level") {
a.level = match u.0.get("level") {
Some(Value::Number(n)) => Some(n.as_u64().filter(|l| (1..=6).contains(l)).ok_or("`level` must be 1 to 6")? as u8),
Some(Value::Null) | None => None,
Some(_) => return Err("`level` must be a number from 1 to 6".into()),
};
}
if given("order") {
a.order = match u.0.get("order") {
Some(Value::Number(n)) => Some(n.as_u64().and_then(|o| u32::try_from(o).ok()).ok_or("`order` must be a whole number, 0 or more")?),
Some(Value::Null) | None => None,
Some(_) => return Err("`order` must be a whole number, 0 or more".into()),
};
}
if a.role.as_deref() != Some("heading") {
if a.level.is_some() && given("level") {
return Err("`level` needs role `heading`".into());
}
a.level = None;
}
if a.decorative && a.alt.is_some() {
return Err("a decorative layer has no alt text: give `alt` or `decorative`, not both".into());
}
Ok((!a.is_empty()).then_some(a))
}
/// Applies every item or none: a refused item changes nothing.
pub(super) fn set_accessibility(ed: &mut Editor, a: &Args) -> ToolResult {
let page = page_arg(ed, a)?;
let mut set: Vec<(usize, NodeId, Option<A11y>)> = Vec::new();
for u in a.arr("items")? {
let u = Args(u.as_object().ok_or("each item must be an object")?);
let (p, id) = resolve(ed, u.req_str("nodeId")?, page)?;
let new = apply(ed.page_doc(p).get(id).and_then(|n| n.a11y.as_ref()), &u)?;
set.push((p, id, new));
}
let n = set.len();
for (p, id, new) in set {
ed.with_page(p, |ed| {
ed.edit("Accessibility", |tx| {
if let Some(node) = tx.node_mut(id) {
node.a11y = new;
}
})
});
}
text(json!({"updated": n}))
}
+18 -1
View File
@@ -1433,6 +1433,20 @@ fn text_align_name(a: TextAlign) -> &'static str {
}
}
/// A node's fills as JSON; the top gradient's bound stops (`gradient-stop-N`) carry their variable.
fn fills_json(doc: &Document, n: &Node) -> Vec<Value> {
let mut fills: Vec<Value> = n.style.fills.iter().map(|f| paint_json(doc, f)).collect();
if let Some(top) = n.style.fills.iter().rposition(|p| p.stops().is_some()) {
for (field, v) in &n.bindings {
let Some(i) = crate::editor::design::gradient_stop(field) else { continue };
if let Some(stop) = fills[top]["stops"].get_mut(i) {
stop["var"] = json!(format!("--{}", v.trim_start_matches("--")));
}
}
}
fills
}
/// One fill layer as JSON: solid colours keep their variable, gradients keep their stops, images their asset.
fn paint_json(doc: &Document, p: &Paint) -> Value {
let _ = doc;
@@ -1741,7 +1755,7 @@ fn build_spec_tree(
let mut style = json!({
"fill": fill_hex,
"fillVar": fill_var,
"fills": n.style.fills.iter().map(|f| paint_json(doc, f)).collect::<Vec<_>>(),
"fills": fills_json(doc, n),
"stroke": stroke.map(|c| c.to_hex()),
// A bound stroke (`bind_variable` field `stroke`) is written as its variable, like fills.
"strokeVar": stroke.and(n.bindings.get("stroke")).map(|v| format!("--{}", v.trim_start_matches("--"))),
@@ -2151,6 +2165,9 @@ fn collect_tokens(n: &Value, used: &mut std::collections::BTreeSet<String>) {
}
for f in n["style"]["fills"].as_array().into_iter().flatten() {
add(&f["var"]);
for st in f["stops"].as_array().into_iter().flatten() {
add(&st["var"]);
}
}
add(&n["style"]["fillVar"]);
add(&n["style"]["strokeVar"]);
+18 -4
View File
@@ -12,6 +12,7 @@ use crate::model::{
Align, AutoLayout, BlendMode, Color, Direction, ImageMode, Node, NodeKind, Paint, Sizing, Stop,
TextAlign,
};
use std::collections::BTreeMap;
use serde_json::{Map, Value};
/// An ordered list of `(camelCaseProperty, value)` pairs.
@@ -463,10 +464,15 @@ pub fn gradient(s: &str) -> Option<Paint> {
})
}
fn gradient_css(p: &Paint) -> Option<String> {
/// `vars` maps a stop index to the colour variable it follows (`gradient-stop-N` bindings).
fn gradient_css(p: &Paint, vars: &BTreeMap<usize, String>) -> Option<String> {
let stops = |s: &[Stop]| {
s.iter()
.map(|s| format!("{} {}%", color_css(s.color), (s.pos * 100.0).round()))
.enumerate()
.map(|(i, s)| {
let c = vars.get(&i).map_or_else(|| color_css(s.color), |v| format!("var(--{v})"));
format!("{c} {}%", (s.pos * 100.0).round())
})
.collect::<Vec<_>>()
.join(", ")
};
@@ -1938,18 +1944,26 @@ pub fn computed(n: &Node, parent_layout: Option<&AutoLayout>, is_root: bool) ->
}
}
_ => {
let top = n.style.fills.iter().rposition(|p| p.stops().is_some());
let stop_vars: BTreeMap<usize, String> = n
.bindings
.iter()
.filter_map(|(f, v)| Some((crate::editor::design::gradient_stop(f)?, v.trim_start_matches("--").to_owned())))
.collect();
let no_vars = BTreeMap::new();
let layers: Vec<String> = n
.style
.fills
.iter()
.enumerate()
.rev()
.filter_map(|p| match p {
.filter_map(|(i, p)| match p {
Paint::Solid { color, var } => Some(
var.as_ref()
.map_or_else(|| color_css(*color), |v| format!("var(--{v})")),
),
Paint::Image { asset, .. } => Some(format!("url(paper-asset://{asset})")),
g => gradient_css(g),
g => gradient_css(g, if Some(i) == top { &stop_vars } else { &no_vars }),
})
.collect();
if layers.len() == 1 && matches!(n.style.fills[0], Paint::Solid { .. }) {
+99 -37
View File
@@ -136,7 +136,7 @@ impl<'a> Args<'a> {
self.str(k)
.ok_or_else(|| format!("missing required string argument `{k}`"))
}
fn num(&self, k: &str) -> Option<f64> {
pub(super) fn num(&self, k: &str) -> Option<f64> {
self.get(k).and_then(|v| match v {
Value::Number(n) => n.as_f64(),
Value::String(s) => s.trim().trim_end_matches("px").parse().ok(),
@@ -153,7 +153,7 @@ impl<'a> Args<'a> {
.and_then(Value::as_array)
.ok_or_else(|| format!("missing required array argument `{k}`"))
}
fn strs(&self, k: &str) -> Result<Vec<String>, String> {
pub(super) fn strs(&self, k: &str) -> Result<Vec<String>, String> {
Ok(self
.arr(k)?
.iter()
@@ -262,7 +262,7 @@ pub(crate) fn named_layers(ed: &Editor, args: &Value) -> Vec<(usize, NodeId)> {
}
/// Resolve several ids that must all live on one page.
fn resolve_all(
pub(super) fn resolve_all(
ed: &Editor,
keys: &[String],
default_page: usize,
@@ -285,6 +285,8 @@ fn resolve_all(
/// Run one tool. Errors are returned as messages for the agent.
pub fn call(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult {
let r = dispatch(host, name, args);
#[cfg(not(target_arch = "wasm32"))]
super::remote_image::end_call();
super::incremental::after_call(host.editor(), name, args, r)
}
@@ -366,6 +368,7 @@ fn dispatch(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult {
"merge_variants" => merge_variants(ed, &a),
"set_prototype_links" => super::prototype::set_prototype_links(ed, &a),
"set_screens" => set_screens(ed, &a),
"set_accessibility" => super::a11y::set_accessibility(ed, &a),
"export" => export(host, &a),
"export_combined_pdf" => export_pdf(host, &a),
"export_presentation" => export_presentation(host, &a),
@@ -400,6 +403,7 @@ fn dispatch(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult {
"bind_variable" => bind_variable(ed, &a),
"get_strings" => super::strings::get_strings(ed, &a),
"set_strings" => super::strings::set_strings(ed, &a),
"set_text_style" => super::text_styles::set_text_style(ed, &a),
"detach_instance" => detach_instance(ed, &a),
"diff_frames" => diff_frames(ed, &a),
"search_icons" => search_icons(&a),
@@ -1150,6 +1154,9 @@ fn node_info(ed: &mut Editor, a: &Args) -> ToolResult {
if let Some(s) = screen_json(ed, page, id) {
v["screen"] = s;
}
if let Some(a11y) = &n.a11y {
v["accessibility"] = json!(a11y);
}
Ok(super::budget::json_out(v, a.num("maxTokens"), "use get_children for the child ids"))
}
@@ -2944,7 +2951,7 @@ impl Parent {
/// Resolve an image reference (paper-asset://, file://, absolute path, data: URI) to a
/// registered asset id.
fn image_asset(src: &str) -> Result<String, String> {
pub(super) fn image_asset(src: &str) -> Result<String, String> {
let src = src.trim();
let bytes = if let Some(data) = src.strip_prefix("data:") {
let (meta, payload) = data.split_once(',').ok_or("malformed data: URI")?;
@@ -2954,9 +2961,11 @@ fn image_asset(src: &str) -> Result<String, String> {
payload.as_bytes().to_vec()
}
} else if src.starts_with("http://") || src.starts_with("https://") {
return Err(format!(
"{src}: remote images aren't fetched; download it and use paper-asset:///absolute/path"
));
#[cfg(not(target_arch = "wasm32"))]
let fetched = super::remote_image::fetch(src);
#[cfg(target_arch = "wasm32")]
let fetched = Err(format!("{src}: remote images can't be fetched in the browser; use a data: URI"));
fetched?
} else {
let path = src
.strip_prefix("paper-asset://")
@@ -4968,6 +4977,9 @@ fn get_variables(ed: &mut Editor, a: &Args) -> ToolResult {
if !doc.tokens.is_empty() {
v["tokens"] = json!(doc.tokens);
}
if !doc.text_styles.is_empty() {
v["textStyles"] = super::text_styles::styles_json(doc);
}
if !doc.modes.is_empty() {
v["mode"] = json!(doc.mode);
v["modes"] = json!(doc.modes.keys().collect::<Vec<_>>());
@@ -6173,7 +6185,7 @@ fn template_props(doc: &Document, id: NodeId, template: &str, a: &Args) -> Resul
}
/// Style keys compared between variants, with the value that means "none" when a side lacks it.
const VARIANT_KEYS: [(&str, &str); 9] = [
const VARIANT_KEYS: [(&str, &str); 13] = [
("backgroundColor", "transparent"),
("background", "transparent"),
("color", ""),
@@ -6183,6 +6195,10 @@ const VARIANT_KEYS: [(&str, &str); 9] = [
("opacity", "1"),
("fontWeight", "400"),
("borderRadius", "0px"),
("padding", "0px"),
("fontSize", ""),
("lineHeight", ""),
("letterSpacing", ""),
];
/// A component's layers by path of names (`Item > Label`); icon names are matched as one name so
@@ -6240,7 +6256,15 @@ fn merge_variants(ed: &mut Editor, a: &Args) -> ToolResult {
let text_of_state = |real: &str| include_text || text_layers.iter().any(|t| real == t || real.ends_with(&format!(" > {t}")));
let given = a.strs("values").unwrap_or_default();
let names: Vec<String> = ids.iter().enumerate().map(|(i, id)| given.get(i).cloned().unwrap_or_else(|| doc.get(*id).unwrap().name.rsplit(" / ").next().unwrap_or_default().trim().to_owned())).collect();
let value_of = |i: usize, _: NodeId| names[i].clone();
// Names like `Size=M, State=hover` build a variant set: one enum property per axis.
let coords: Vec<BTreeMap<String, String>> = names
.iter()
.map(|n| n.split(',').filter_map(|p| p.split_once('=')).map(|(k, v)| (k.trim().to_owned(), v.trim().to_owned())).collect())
.collect();
let axis_names: Vec<String> = if names.iter().all(|n| n.contains('=')) { coords[0].keys().cloned().collect() } else { Vec::new() };
if coords.iter().any(|c| !axis_names.is_empty() && c.keys().ne(axis_names.iter())) {
return Err("every variant name must give the same axes, e.g. `Size=M, State=hover`".into());
}
let base_paths = variant_paths(&doc, base);
let parent_layout = |id: NodeId| doc.get(id).and_then(|n| n.parent).and_then(|p| doc.get(p)).and_then(|p| p.layout);
let style_of = |id: NodeId| css::computed(doc.get(id).unwrap(), parent_layout(id).as_ref(), false);
@@ -6248,6 +6272,7 @@ fn merge_variants(ed: &mut Editor, a: &Args) -> ToolResult {
// (real key, property) -> value per variant (by index in `kept`).
let mut kept: Vec<(usize, NodeId)> = Vec::new();
let mut table: BTreeMap<(String, String), Vec<Value>> = BTreeMap::new();
let axis_value = |axis: &str, i: usize| coords[i].get(axis).cloned().unwrap_or_else(|| names[i].clone());
for (i, id) in ids.iter().enumerate() {
let paths = variant_paths(&doc, *id);
// Differences an option cannot carry (layout, size, spacing) would be lost: leave it out.
@@ -6324,11 +6349,30 @@ fn merge_variants(ed: &mut Editor, a: &Args) -> ToolResult {
let base_paths = variant_paths(&doc, base);
let parent_layout = |id: NodeId| doc.get(id).and_then(|n| n.parent).and_then(|p| doc.get(p)).and_then(|p| p.layout);
let style_of = |id: NodeId| css::computed(doc.get(id).unwrap(), parent_layout(id).as_ref(), false);
let n = kept.len();
let mut record = |key: String, prop: &str, at: usize, v: Value| {
table.entry((key, prop.to_owned())).or_insert_with(|| vec![Value::Null; n])[at] = v;
let base_at = kept.iter().position(|(_, id)| *id == base).ok_or("the base was left out")?;
// One axis per property. Its options are read from the variants that differ from the base in
// that axis alone, so the axes combine independently.
let axes: Vec<(String, Vec<usize>)> = if axis_names.is_empty() {
vec![(prop.clone(), (0..kept.len()).collect())]
} else {
let base_coords = &coords[kept[base_at].0];
axis_names
.iter()
.map(|ax| (ax.clone(), (0..kept.len()).filter(|&at| coords[kept[at].0].iter().all(|(k, v)| k == ax || base_coords[k] == *v)).collect()))
.collect()
};
for (at, (_, id)) in kept.iter().enumerate() {
let mut defs: Vec<crate::model::PropDef> = Vec::new();
for (axis, subset) in &axes {
if subset.len() < 2 {
return Err(format!("axis `{axis}` needs at least two variants that differ from the base only in it. {}", warnings.join("; ")));
}
let n = subset.len();
let mut tbl: BTreeMap<(String, String), Vec<Value>> = BTreeMap::new();
let mut record = |key: String, prop: &str, at: usize, v: Value| {
tbl.entry((key, prop.to_owned())).or_insert_with(|| vec![Value::Null; n])[at] = v;
};
for (at, (_, id)) in subset.iter().map(|&k| kept[k]).enumerate() {
let id = &id;
let paths = variant_paths(&doc, *id);
let mut pairs: Vec<(String, NodeId, Option<NodeId>)> = vec![("$root".into(), base, Some(*id))];
for (bk, real, b) in &base_paths {
@@ -6366,43 +6410,48 @@ fn merge_variants(ed: &mut Editor, a: &Args) -> ToolResult {
}
}
// Only what differs between the variants becomes part of the options.
table.retain(|_, vals| vals.iter().any(|v| *v != vals[0]) && vals.iter().all(|v| !v.is_null()));
let options: Vec<crate::model::PropOption> = kept
tbl.retain(|_, vals| vals.iter().any(|v| *v != vals[0]) && vals.iter().all(|v| !v.is_null()));
let options: Vec<crate::model::PropOption> = subset
.iter()
.map(|&k| kept[k])
.enumerate()
.map(|(at, (i, id))| {
.map(|(at, (i, _))| {
let mut set = Map::new();
for ((layer, key), vals) in &table {
for ((layer, key), vals) in &tbl {
let e = set.entry(layer.clone()).or_insert_with(|| json!({}));
e[key.as_str()] = vals[at].clone();
}
crate::model::PropOption { value: value_of(*i, *id), set: Value::Object(set) }
crate::model::PropOption { value: axis_value(axis, i), set: Value::Object(set) }
})
.collect();
let base_value = kept.iter().find(|(_, id)| *id == base).map(|(i, id)| value_of(*i, *id)).ok_or("the base was left out")?;
let base_slot = subset.iter().position(|&k| k == base_at).ok_or("the base was left out")?;
let base_value = axis_value(axis, kept[base_at].0);
// The base already has this enum (merged before): its options stay, taking the base's look
// for what the new variants change, and the new variants are added to them.
let existing = doc.get(base).unwrap().props.iter().find(|d| d.name == prop && d.kind == "enum").cloned();
let base_at = kept.iter().position(|(_, id)| *id == base).unwrap();
let existing = doc.get(base).unwrap().props.iter().find(|d| d.name == *axis && d.kind == "enum").cloned();
let (options, default) = match &existing {
Some(d) => {
let mut opts = d.options.clone();
for o in &mut opts {
let set = o.set.as_object_mut().map(std::mem::take).unwrap_or_default();
let mut set = set;
for ((layer, key), vals) in &table {
for ((layer, key), vals) in &tbl {
let e = set.entry(layer.clone()).or_insert_with(|| json!({}));
if e.get(key.as_str()).is_none() {
e[key.as_str()] = vals[base_at].clone();
e[key.as_str()] = vals[base_slot].clone();
}
}
o.set = Value::Object(set);
}
opts.extend(options.into_iter().enumerate().filter(|(at, _)| *at != base_at).map(|(_, o)| o));
opts.extend(options.into_iter().enumerate().filter(|(at, _)| *at != base_slot).map(|(_, o)| o));
(opts, d.default.clone())
}
None => (options, Some(base_value.clone())),
};
defs.push(crate::model::PropDef { name: axis.clone(), kind: "enum".into(), options, default, ..Default::default() });
table.extend(tbl);
}
let base_had: Vec<String> = defs.iter().filter(|d| doc.get(base).unwrap().props.iter().any(|e| e.name == d.name && e.kind == "enum")).map(|d| d.name.clone()).collect();
let family = doc.get(base).unwrap().name.rsplit_once(" / ").map(|(f, _)| f.to_owned());
let base_key = node_key(ed, page, base);
// The base becomes the family: its name, and the property.
@@ -6412,8 +6461,8 @@ fn merge_variants(ed: &mut Editor, a: &Args) -> ToolResult {
if let Some(f) = &family {
m.name = f.clone();
}
m.props.retain(|d| d.name != prop);
m.props.push(crate::model::PropDef { name: prop.clone(), kind: "enum".into(), options, default, ..Default::default() });
m.props.retain(|d| !defs.iter().any(|n| n.name == d.name));
m.props.extend(defs.clone());
})
});
// Every instance of a merged variant moves to the base, then takes its value.
@@ -6421,7 +6470,7 @@ fn merge_variants(ed: &mut Editor, a: &Args) -> ToolResult {
let mut moved = 0;
for p in 0..ed.pages.len() {
let want = if p == page { None } else { Some(Source::Page(base_page_id.clone())) };
let mut by_value: BTreeMap<String, Vec<String>> = BTreeMap::new();
let mut by_value: BTreeMap<BTreeMap<String, String>, Vec<String>> = BTreeMap::new();
let mut swaps = Vec::new();
// What each moving instance shows now, so its own texts and its nested instances' own
// values (a checked checkbox) survive the move.
@@ -6445,10 +6494,14 @@ fn merge_variants(ed: &mut Editor, a: &Args) -> ToolResult {
nested.push((n.id, values));
}
// Instances of a base that already had the enum keep their own value.
let value = match (&existing, *id == base) {
(Some(_), true) => n.prop_values.get(&prop).cloned().unwrap_or_else(|| base_value.clone()),
_ => value_of(*i, *id),
};
let value: BTreeMap<String, String> = defs
.iter()
.map(|d| {
let kept_own = *id == base && base_had.contains(&d.name);
let v = if kept_own { n.prop_values.get(&d.name).cloned() } else { None };
(d.name.clone(), v.unwrap_or_else(|| axis_value(&d.name, *i)))
})
.collect();
by_value.entry(value).or_default().push(key);
}
let pid = ed.pages[p].id.clone();
@@ -6460,7 +6513,7 @@ fn merge_variants(ed: &mut Editor, a: &Args) -> ToolResult {
}
for (value, keys) in by_value {
moved += keys.len();
let args = json!({"nodeIds": keys, "values": {prop.as_str(): value}, "pageId": pid});
let args = json!({"nodeIds": keys, "values": value, "pageId": pid});
if let Err(e) = set_instance_props(ed, &Args(args.as_object().unwrap())) {
warnings.push(e);
}
@@ -6497,7 +6550,7 @@ fn merge_variants(ed: &mut Editor, a: &Args) -> ToolResult {
let args = json!({"nodeIds": gone, "pageId": ed.pages[page].id});
delete_nodes(ed, &Args(args.as_object().unwrap()))?;
}
let mut v = json!({"component": base_key, "name": family, "property": prop, "options": kept.iter().map(|(i, id)| value_of(*i, *id)).collect::<Vec<_>>(), "changes": table.keys().map(|(l, k)| format!("{l}: {k}")).collect::<Vec<_>>(), "instancesMoved": moved, "layersAdded": grown});
let mut v = json!({"component": base_key, "name": family, "property": prop, "options": defs.iter().flat_map(|d| d.options.iter().map(|o| o.value.clone())).collect::<Vec<_>>(), "axes": defs.iter().map(|d| json!({"name": d.name, "options": d.options.iter().map(|o| o.value.clone()).collect::<Vec<_>>()})).collect::<Vec<_>>(), "changes": table.keys().map(|(l, k)| format!("{l}: {k}")).collect::<Vec<_>>(), "instancesMoved": moved, "layersAdded": grown});
if !warnings.is_empty() {
v["warnings"] = json!(warnings);
}
@@ -7121,6 +7174,7 @@ fn move_nodes(ed: &mut Editor, a: &Args) -> ToolResult {
};
let moves = a.arr("moves")?;
let mut done = 0;
let mut warnings: Vec<String> = Vec::new();
for m in moves {
let m = Args(m.as_object().ok_or("each move must be an object")?);
let (src_page, id) = resolve(ed, m.req_str("nodeId")?, def)?;
@@ -7222,14 +7276,17 @@ fn move_nodes(ed: &mut Editor, a: &Args) -> ToolResult {
// Notes on the moved nodes go with them instead of staying behind detached.
let sub: HashSet<NodeId> = src_doc.subtree(id).into_iter().collect();
let notes: Vec<u64> = src_doc.annotations.iter().filter(|n| n.target.is_some_and(|t| sub.contains(&t))).map(|n| n.id).collect();
ed.with_page(src_page, |ed| {
ed.edit("Move", |tx| {
let mut cleared = ed.with_page(src_page, |ed| {
let cleared = ed.edit("Move", |tx| {
for n in &notes {
tx.delete_annotation(*n);
}
let cleared = super::move_refs::clear_incoming(tx, &sub);
tx.remove(id);
cleared
});
ed.selection.retain(|s| ed.doc.get(*s).is_some());
cleared
});
let pairs = ed.with_page(dest_page, |ed| {
ed.edit("Move", |tx| {
@@ -7254,15 +7311,20 @@ fn move_nodes(ed: &mut Editor, a: &Args) -> ToolResult {
let (new_root, pairs) =
clone_subtree_from_doc(tx, &src_doc, id, target, idx, offset, Some((&ids.0, &ids.1)), true);
copy_annotations(tx, &src_doc.annotations, &pairs);
let out = super::move_refs::remap_outgoing(tx, &pairs.iter().copied().collect());
relayout(tx, target.or(Some(new_root)));
pairs
(pairs, out)
})
});
let (pairs, out) = pairs;
cleared.links += out.links;
cleared.connector_ends += out.connector_ends;
warnings.extend(cleared.warning(&node_key(ed, src_page, id)));
relink_moved(ed, src_page, dest_page, &pairs.into_iter().collect());
}
done += 1;
}
text(json!({"moved": done}))
text(if warnings.is_empty() { json!({"moved": done}) } else { json!({"moved": done, "warnings": warnings}) })
}
/// After nodes moved from `src` to `dest` page (`moved`: old id to new id), instances anywhere
+41 -25
View File
@@ -35,6 +35,41 @@ const VOID: &[&str] = &[
"wbr",
];
/// Named references common in pasted pages; there is no entity-table crate in the tree, and the
/// full HTML5 list (2000+) is not worth carrying.
const NAMED: &[(&str, char)] = &[
("amp", '&'), ("lt", '<'), ("gt", '>'), ("quot", '"'), ("apos", '\''), ("nbsp", '\u{a0}'),
("ensp", '\u{2002}'), ("emsp", '\u{2003}'), ("thinsp", '\u{2009}'), ("shy", '\u{ad}'),
("middot", '·'), ("bull", '•'), ("hellip", '…'), ("mdash", '—'), ("ndash", '–'),
("lsquo", '‘'), ("rsquo", '’'), ("sbquo", '‚'), ("ldquo", '“'), ("rdquo", '”'), ("bdquo", '„'),
("laquo", '«'), ("raquo", '»'), ("lsaquo", '‹'), ("rsaquo", '›'), ("prime", '′'), ("Prime", '″'),
("copy", '©'), ("reg", '®'), ("trade", '™'), ("times", '×'), ("divide", '÷'), ("minus", '−'),
("plusmn", '±'), ("deg", '°'), ("micro", 'µ'), ("para", '¶'), ("sect", '§'), ("dagger", '†'),
("euro", '€'), ("pound", '£'), ("yen", '¥'), ("cent", '¢'), ("curren", '¤'),
("iexcl", '¡'), ("iquest", '¿'), ("frac12", '½'), ("frac14", '¼'), ("frac34", '¾'),
("sup1", '¹'), ("sup2", '²'), ("sup3", '³'), ("ne", '≠'), ("le", '≤'), ("ge", '≥'),
("asymp", '≈'), ("infin", '∞'), ("sum", '∑'), ("radic", '√'), ("larr", '←'), ("rarr", '→'),
("uarr", '↑'), ("darr", '↓'), ("harr", '↔'), ("hearts", '♥'), ("star", '☆'), ("check", '✓'),
("Agrave", 'À'), ("Aacute", 'Á'), ("Acirc", 'Â'), ("Atilde", 'Ã'), ("Auml", 'Ä'), ("Aring", 'Å'),
("AElig", 'Æ'), ("Ccedil", 'Ç'), ("Egrave", 'È'), ("Eacute", 'É'), ("Ecirc", 'Ê'), ("Euml", 'Ë'),
("Igrave", 'Ì'), ("Iacute", 'Í'), ("Icirc", 'Î'), ("Iuml", 'Ï'), ("Ntilde", 'Ñ'),
("Ograve", 'Ò'), ("Oacute", 'Ó'), ("Ocirc", 'Ô'), ("Otilde", 'Õ'), ("Ouml", 'Ö'), ("Oslash", 'Ø'),
("Ugrave", 'Ù'), ("Uacute", 'Ú'), ("Ucirc", 'Û'), ("Uuml", 'Ü'), ("Yacute", 'Ý'), ("szlig", 'ß'),
("agrave", 'à'), ("aacute", 'á'), ("acirc", 'â'), ("atilde", 'ã'), ("auml", 'ä'), ("aring", 'å'),
("aelig", 'æ'), ("ccedil", 'ç'), ("egrave", 'è'), ("eacute", 'é'), ("ecirc", 'ê'), ("euml", 'ë'),
("igrave", 'ì'), ("iacute", 'í'), ("icirc", 'î'), ("iuml", 'ï'), ("ntilde", 'ñ'),
("ograve", 'ò'), ("oacute", 'ó'), ("ocirc", 'ô'), ("otilde", 'õ'), ("ouml", 'ö'), ("oslash", 'ø'),
("ugrave", 'ù'), ("uacute", 'ú'), ("ucirc", 'û'), ("uuml", 'ü'), ("yacute", 'ý'), ("yuml", 'ÿ'),
];
fn numeric(n: &str) -> Option<char> {
let code = match n.strip_prefix("#x").or_else(|| n.strip_prefix("#X")) {
Some(hex) => u32::from_str_radix(hex, 16).ok()?,
None => n.strip_prefix('#')?.parse().ok()?,
};
char::from_u32(code)
}
/// Decode character references.
pub fn decode_entities(s: &str) -> String {
if !s.contains('&') {
@@ -52,31 +87,7 @@ pub fn decode_entities(s: &str) -> String {
continue;
};
let name = &rest[1..end];
let ch = match name {
"amp" => Some('&'),
"lt" => Some('<'),
"gt" => Some('>'),
"quot" => Some('"'),
"apos" => Some('\''),
"nbsp" => Some('\u{a0}'),
"middot" => Some('·'),
"bull" => Some('•'),
"hellip" => Some('…'),
"mdash" => Some('—'),
"ndash" => Some('–'),
"copy" => Some('©'),
"reg" => Some('®'),
"trade" => Some('™'),
"times" => Some('×'),
"rarr" => Some('→'),
"larr" => Some('←'),
"check" => Some('✓'),
n if n.starts_with("#x") || n.starts_with("#X") => u32::from_str_radix(&n[2..], 16)
.ok()
.and_then(char::from_u32),
n if n.starts_with('#') => n[1..].parse().ok().and_then(char::from_u32),
_ => None,
};
let ch = NAMED.iter().find(|(k, _)| *k == name).map(|(_, c)| *c).or_else(|| numeric(name));
match ch {
Some(c) => {
out.push(c);
@@ -315,6 +326,11 @@ mod tests {
}
}
#[test]
fn decodes_the_common_named_entities() {
assert_eq!(decode_entities("&ldquo;Hi&rdquo; &copy; caf&eacute; &#65;&#x42; &euro;5 &bogus;"), "“Hi” © café AB €5 &bogus;");
}
#[test]
fn parses_nested_elements_attrs_and_text() {
let h = parse(
+21
View File
@@ -71,6 +71,12 @@ fn solid(n: &Node) -> Option<(Color, Option<&str>)> {
}
}
/// The gray the app gives a new shape and an image whose download failed: its own placeholder,
/// not a colour somebody chose.
fn is_default_shape_gray(n: &Node, c: Color) -> bool {
matches!(n.kind, crate::model::NodeKind::Rect | crate::model::NodeKind::Ellipse) && c == Color::rgb(0xd9, 0xd9, 0xd9)
}
/// What a node's top fill looks like behind its children: a solid's color, a gradient's stops
/// averaged; `Some(None)` for an image, whose color is unknown.
fn fill_color(n: &Node) -> Option<Option<Color>> {
@@ -284,6 +290,7 @@ pub fn lint(doc: &Document, roots: &[NodeId], scale: &[f64]) -> Vec<Issue> {
&& var.is_none()
&& !bound_text
&& c.a == 255
&& !is_default_shape_gray(n, c)
{
if let Some((name, _)) = vars.iter().find(|(_, v)| **v == c) {
add(
@@ -319,6 +326,9 @@ pub fn lint(doc: &Document, roots: &[NodeId], scale: &[f64]) -> Vec<Issue> {
out.extend(theme_copies(doc, roots));
let all: Vec<NodeId> = roots.iter().flat_map(|r| doc.subtree(*r)).collect();
out.extend(super::lint_overflow::overflow(doc, &all));
for r in roots {
out.extend(super::lint_a11y::a11y(doc, &doc.subtree(*r)));
}
out
}
@@ -626,6 +636,17 @@ mod tests {
assert!(!collapsed(&doc));
}
#[test]
fn the_apps_own_placeholder_gray_is_not_hard_coded() {
let mut doc = Document::default();
doc.variables.insert("border".into(), Color::rgb(0xd9, 0xd9, 0xd9));
let id = doc.alloc_id();
let n = Node::new(id, crate::model::NodeKind::Rect, crate::geom::Rect::new(0.0, 0.0, 40.0, 20.0));
doc.nodes.insert(id, n);
doc.root.push(id);
assert!(lint(&doc, &[id], &DEFAULT_SCALE).iter().all(|i| i.rule != "hard-coded-color"));
}
#[test]
fn text_colour_binding_is_not_hard_coded() {
let mut doc = Document::default();
+211
View File
@@ -0,0 +1,211 @@
//! The accessibility lint rules, over the annotations from `set_accessibility`:
//! `image-alt` (a picture with neither alt text nor a decorative mark), `interactive-name` (a
//! clickable layer with no text, alt or real name), `heading-skip` (explicit headings that jump
//! a level) and `reading-order` (an explicit order that disagrees with the visual one).
use super::lint::Issue;
use crate::model::{Document, LinkAction, Node, NodeId, NodeKind, Paint};
/// Issues for the layers in `ids`, which must be in document order (one artboard's subtree).
pub fn a11y(doc: &Document, ids: &[NodeId]) -> Vec<Issue> {
let mut out = Vec::new();
let mut last_heading: Option<u8> = None;
for id in ids {
let Some(n) = doc.get(*id).filter(|n| n.instance.is_none() && doc.is_effectively_visible(*id)) else {
continue;
};
let issue = |rule, message: String, fix: &str| Issue { rule, severity: "warning", node: *id, message, fix: fix.into() };
let a = n.a11y.as_ref();
if is_picture(n) && !a.is_some_and(|a| a.decorative || a.alt.is_some()) {
out.push(issue(
"image-alt",
format!("picture `{}` has no alt text", n.name),
"set_accessibility {alt:\"what it shows\"}, or {decorative:true} if it is only ornament",
));
}
let acts = n.link.is_some()
|| matches!(n.link_action, Some(LinkAction::Url { .. }))
|| a.and_then(|a| a.role.as_deref()).is_some_and(|r| r == "button" || r == "link");
if acts && !has_name(doc, n) {
out.push(issue(
"interactive-name",
format!("`{}` is clickable but has no visible text or alt text (the layer name is not exported)", n.name),
"set_accessibility {alt:\"what it does\"} (and a role such as button or link)",
));
}
if let Some(level) = a.filter(|a| a.role.as_deref() == Some("heading")).map(|a| a.level.unwrap_or(2)) {
if let Some(prev) = last_heading.filter(|prev| level > prev + 1) {
out.push(issue(
"heading-skip",
format!("heading `{}` is h{level} after h{prev}", n.name),
"use the next level down, or add the missing heading",
));
}
last_heading = Some(level);
}
if let Some(message) = order_mismatch(doc, n) {
out.push(issue("reading-order", message, "change the `order` values to follow the layout (top to bottom, then left to right), or move the layers"));
}
}
out
}
/// A rect, ellipse or frame whose only fill is a picture: what the site export writes as `<img>`.
fn is_picture(n: &Node) -> bool {
matches!(n.kind, NodeKind::Rect | NodeKind::Ellipse | NodeKind::Frame { .. })
&& matches!(n.style.fills.as_slice(), [Paint::Image { .. }])
}
/// Text inside or an alt text; the layer name does not reach the export.
fn has_name(doc: &Document, n: &Node) -> bool {
n.a11y.as_ref().is_some_and(|a| a.alt.as_deref().is_some_and(|t| !t.is_empty()))
|| doc.subtree(n.id).iter().filter_map(|c| doc.get(*c)).any(|c| c.visible && c.kind.text().is_some_and(|t| !t.trim().is_empty()))
}
/// Children with an explicit order, read in that order, against where they sit on the page.
fn order_mismatch(doc: &Document, n: &Node) -> Option<String> {
let mut kids: Vec<(u32, (i64, i64), NodeId)> = n
.children
.iter()
.filter_map(|c| {
let k = doc.get(*c).filter(|k| k.visible)?;
let r = doc.world_rect(*c)?;
Some((k.a11y.as_ref()?.order?, (r.y.round() as i64, r.x.round() as i64), *c))
})
.collect();
if kids.len() < 2 {
return None;
}
kids.sort_by_key(|k| k.0);
if let Some(w) = kids.windows(2).find(|w| w[0].0 == w[1].0) {
return Some(format!("two children of `{}` share `order` {}", n.name, w[0].0));
}
let mut visual = kids.clone();
visual.sort_by_key(|k| k.1);
let (first, _) = kids.iter().zip(&visual).find(|(a, b)| a.2 != b.2)?;
Some(format!(
"the reading order of `{}` disagrees with the layout (starting at `{}`)",
n.name,
doc.get(first.2).map_or("", |k| k.name.as_str())
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::geom::Rect;
use crate::model::A11y;
fn add(doc: &mut Document, parent: Option<NodeId>, kind: NodeKind, r: (f64, f64), name: &str) -> NodeId {
let id = doc.alloc_id();
let mut n = Node::new(id, kind, Rect::new(r.0, r.1, 100.0, 40.0));
n.name = name.into();
n.parent = parent;
doc.nodes.insert(id, n);
match parent {
Some(p) => doc.nodes.get_mut(&p).unwrap().children.push(id),
None => doc.root.push(id),
}
id
}
fn set(doc: &mut Document, id: NodeId, a: A11y) {
doc.nodes.get_mut(&id).unwrap().a11y = Some(a);
}
fn rules(doc: &Document) -> Vec<&'static str> {
a11y(doc, &doc.paint_order()).iter().map(|i| i.rule).collect()
}
fn board(d: &mut Document) -> NodeId {
add(d, None, NodeKind::Frame { clip: true }, (0.0, 0.0), "Board")
}
fn text(d: &mut Document, parent: NodeId, y: f64, label: &str) -> NodeId {
let kind = NodeKind::Text { text: label.into(), font_size: 16.0, auto_width: true, align: Default::default() };
add(d, Some(parent), kind, (0.0, y), label)
}
#[test]
fn a_picture_needs_alt_text_or_a_decorative_mark() {
let mut d = Document::default();
let b = board(&mut d);
let p = add(&mut d, Some(b), NodeKind::Rect, (0.0, 0.0), "Hero photo");
d.nodes.get_mut(&p).unwrap().style.fills = vec![Paint::Image { asset: "a".into(), mode: Default::default(), opacity: 1.0 }];
assert_eq!(rules(&d), ["image-alt"]);
set(&mut d, p, A11y { alt: Some("A fish".into()), ..Default::default() });
assert!(rules(&d).is_empty());
set(&mut d, p, A11y { decorative: true, ..Default::default() });
assert!(rules(&d).is_empty());
}
#[test]
fn a_clickable_layer_needs_a_name() {
let mut d = Document::default();
let b = board(&mut d);
let btn = add(&mut d, Some(b), NodeKind::Frame { clip: false }, (0.0, 0.0), "Frame 3");
d.nodes.get_mut(&btn).unwrap().link = Some(b);
assert_eq!(rules(&d), ["interactive-name"]);
set(&mut d, btn, A11y { alt: Some("Close".into()), role: Some("button".into()), ..Default::default() });
assert!(rules(&d).is_empty());
set(&mut d, btn, A11y::default());
text(&mut d, btn, 0.0, "Buy");
assert!(rules(&d).is_empty(), "text inside names it");
}
#[test]
fn a_button_role_needs_alt_or_text_whatever_the_layer_is_called() {
let mut d = Document::default();
let b = board(&mut d);
let btn = add(&mut d, Some(b), NodeKind::Frame { clip: false }, (0.0, 0.0), "Close button");
set(&mut d, btn, A11y { role: Some("button".into()), ..Default::default() });
assert_eq!(rules(&d), ["interactive-name"]);
set(&mut d, btn, A11y { role: Some("button".into()), alt: Some("Close".into()), ..Default::default() });
assert!(rules(&d).is_empty());
}
#[test]
fn siblings_may_not_share_a_reading_order() {
let mut d = Document::default();
let b = board(&mut d);
let top = text(&mut d, b, 0.0, "Top");
let bottom = text(&mut d, b, 100.0, "Bottom");
for id in [top, bottom] {
set(&mut d, id, A11y { order: Some(1), ..Default::default() });
}
assert_eq!(rules(&d), ["reading-order"]);
}
#[test]
fn explicit_headings_may_not_skip_a_level() {
let mut d = Document::default();
let b = board(&mut d);
let mut heading = |y, level| {
let t = text(&mut d, b, y, "Title");
set(&mut d, t, A11y { role: Some("heading".into()), level: Some(level), ..Default::default() });
};
heading(0.0, 1);
heading(50.0, 2);
heading(100.0, 2);
assert!(rules(&d).is_empty());
let t = text(&mut d, b, 150.0, "Deep");
set(&mut d, t, A11y { role: Some("heading".into()), level: Some(4), ..Default::default() });
assert_eq!(rules(&d), ["heading-skip"]);
}
#[test]
fn an_explicit_reading_order_must_follow_the_layout() {
let mut d = Document::default();
let b = board(&mut d);
let top = text(&mut d, b, 0.0, "Top");
let bottom = text(&mut d, b, 100.0, "Bottom");
let order = |o| A11y { order: Some(o), ..Default::default() };
assert!(rules(&d).is_empty(), "no order set, nothing to compare");
set(&mut d, top, order(1));
set(&mut d, bottom, order(2));
assert!(rules(&d).is_empty());
set(&mut d, top, order(2));
set(&mut d, bottom, order(1));
assert_eq!(rules(&d), ["reading-order"]);
}
}
+6
View File
@@ -26,9 +26,14 @@ pub mod html;
pub mod import_html;
pub mod incremental;
pub mod lint;
pub mod lint_a11y;
pub mod lint_overflow;
mod move_refs;
mod a11y;
mod prototype;
#[cfg(not(target_arch = "wasm32"))]
mod remote_image;
#[cfg(not(target_arch = "wasm32"))]
pub mod imagegen;
#[cfg(not(target_arch = "wasm32"))]
pub mod delegate;
@@ -38,6 +43,7 @@ pub mod shell;
pub mod refs;
pub mod rewrite;
pub mod strings;
pub mod text_styles;
pub mod room;
pub mod screen_fit;
#[cfg(not(target_arch = "wasm32"))]
+93
View File
@@ -0,0 +1,93 @@
//! References between layers (click links, connector ends) when a layer moves to another page.
//! Ids are per page, so a reference that does not travel with the layer would end up naming an
//! unrelated layer, or none. Such references are cleared and counted.
use crate::history::Tx;
use crate::model::{LinkAction, NodeId, NodeKind};
use std::collections::{HashMap, HashSet};
/// References that could not move along and were cleared.
#[derive(Debug, Default, PartialEq)]
pub struct Cleared {
pub links: usize,
pub connector_ends: usize,
}
impl Cleared {
pub fn warning(&self, what: &str) -> Option<String> {
(self.links + self.connector_ends > 0).then(|| {
format!(
"{what}: cleared {} prototype link(s) and {} connector end(s) that cannot cross pages",
self.links, self.connector_ends
)
})
}
}
/// On the source page, before the subtree is removed: layers outside it that link to, or have
/// connector ends bound to, a layer inside it lose those references.
pub fn clear_incoming(tx: &mut Tx, gone: &HashSet<NodeId>) -> Cleared {
let mut out = Cleared::default();
let ids: Vec<NodeId> = tx.doc().nodes.keys().copied().filter(|i| !gone.contains(i)).collect();
for id in ids {
let Some(n) = tx.doc().get(id) else { continue };
let hit = |t: &NodeId| gone.contains(t);
let link = n.link.as_ref().is_some_and(hit);
let overlay = matches!(&n.link_action, Some(LinkAction::Overlay { target }) if hit(target));
let ends = match &n.kind {
NodeKind::Connector { start, end, .. } => [start, end].iter().filter(|b| b.is_some_and(|b| hit(&b.node))).count(),
_ => 0,
};
if !(link || overlay || ends > 0) {
continue;
}
let n = tx.node_mut(id).expect("node listed above");
if link {
n.link = None;
}
if overlay {
n.link_action = None;
}
if let NodeKind::Connector { start, end, .. } = &mut n.kind {
for b in [start, end] {
if b.is_some_and(|b| hit(&b.node)) {
*b = None;
}
}
}
out.links += usize::from(link) + usize::from(overlay);
out.connector_ends += ends;
}
out
}
/// On the destination page, after the copy: references inside the moved subtree follow the
/// `old -> new` map; those to layers left behind are cleared.
pub fn remap_outgoing(tx: &mut Tx, map: &HashMap<NodeId, NodeId>) -> Cleared {
let mut out = Cleared::default();
for new in map.values() {
let Some(n) = tx.node_mut(*new) else { continue };
if let Some(t) = n.link {
match map.get(&t) {
Some(m) => n.link = Some(*m),
None => (n.link, out.links) = (None, out.links + 1),
}
}
if let Some(LinkAction::Overlay { target }) = &mut n.link_action {
match map.get(target) {
Some(m) => *target = *m,
None => (n.link_action, out.links) = (None, out.links + 1),
}
}
if let NodeKind::Connector { start, end, .. } = &mut n.kind {
for b in [start, end] {
let Some(bind) = b.as_mut() else { continue };
match map.get(&bind.node) {
Some(m) => bind.node = *m,
None => (*b, out.connector_ends) = (None, out.connector_ends + 1),
}
}
}
}
out
}
+247
View File
@@ -0,0 +1,247 @@
//! Fetching `http(s)` images for `write_html` (`<img src>` and CSS `background-image`).
//!
//! The URL comes from an agent, so the fetch must not become a way into the person's network:
//! only http and https, only public addresses (checked on the addresses the connection is then
//! pinned to, so DNS cannot change its answer in between), redirects re-checked hop by hop, a
//! size cap, a deadline and image content types only. Each URL is fetched once per tool call.
use std::cell::RefCell;
use std::collections::HashMap;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::time::Duration;
const MAX_BYTES: usize = 10 * 1024 * 1024;
const TIMEOUT: Duration = Duration::from_secs(15);
const MAX_REDIRECTS: usize = 3;
/// What the image decoder accepts (`assets::register`).
const IMAGE_TYPES: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
thread_local! {
static FETCHED: RefCell<HashMap<String, Result<Vec<u8>, String>>> = RefCell::new(HashMap::new());
// Tests serve from loopback; nothing outside `cfg(test)` can turn this on.
#[cfg(test)]
static ALLOW_PRIVATE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// Forget the images fetched by the call that just ended.
pub fn end_call() {
FETCHED.with(|f| f.borrow_mut().clear());
}
/// The bytes at `url`, fetched at most once per tool call (failures are remembered too).
pub fn fetch(url: &str) -> Result<Vec<u8>, String> {
if let Some(done) = FETCHED.with(|f| f.borrow().get(url).cloned()) {
return done;
}
#[cfg(test)]
let allow_private = ALLOW_PRIVATE.with(|a| a.get());
#[cfg(not(test))]
let allow_private = false;
let result = fetch_uncached(url, allow_private);
FETCHED.with(|f| f.borrow_mut().insert(url.to_string(), result.clone()));
result
}
/// Runs on its own thread and runtime: tool calls may come from inside a Tokio runtime, where
/// blocking on another would panic.
fn fetch_uncached(url: &str, allow_private: bool) -> Result<Vec<u8>, String> {
let url = url.to_string();
let shown = url.clone();
std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().map_err(|e| e.to_string())?;
rt.block_on(async {
tokio::time::timeout(TIMEOUT, get(&url, allow_private)).await.unwrap_or_else(|_| Err("timed out".into()))
})
})
.join()
.unwrap_or_else(|_| Err("the fetch failed unexpectedly".into()))
.map_err(|e| format!("{shown}: {e}"))
}
async fn get(start: &str, allow_private: bool) -> Result<Vec<u8>, String> {
let mut url = reqwest::Url::parse(start).map_err(|e| format!("bad URL: {e}"))?;
for _ in 0..=MAX_REDIRECTS {
if !matches!(url.scheme(), "http" | "https") {
return Err("only http and https images can be fetched".into());
}
if !url.username().is_empty() || url.password().is_some() {
return Err("URLs with credentials are not fetched".into());
}
let host = url.host_str().ok_or("the URL has no host")?.to_string();
let port = url.port_or_known_default().ok_or("the URL has no port")?;
let literal = host.trim_matches(['[', ']']).parse::<IpAddr>().ok();
let addrs: Vec<SocketAddr> = match literal {
Some(ip) => vec![SocketAddr::new(ip, port)],
None => tokio::net::lookup_host((host.as_str(), port)).await.map_err(|e| format!("could not resolve {host}: {e}"))?.collect(),
};
if addrs.is_empty() || (!allow_private && addrs.iter().any(|a| !is_public(a.ip()))) {
return Err("private, loopback and link-local addresses are not fetched".into());
}
let mut client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(TIMEOUT)
.no_proxy()
.user_agent(concat!("PaperCraft/", env!("CARGO_PKG_VERSION")));
if literal.is_none() {
client = client.resolve_to_addrs(&host, &addrs);
}
let response = client.build().map_err(|e| e.to_string())?.get(url.clone()).send().await.map_err(|e| e.to_string())?;
if response.status().is_redirection() {
let next = response.headers().get(reqwest::header::LOCATION).and_then(|l| l.to_str().ok()).ok_or("a redirect without a location")?;
url = url.join(next).map_err(|e| format!("bad redirect: {e}"))?;
continue;
}
if !response.status().is_success() {
return Err(format!("HTTP {}", response.status()));
}
let kind = response.headers().get(reqwest::header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).unwrap_or("");
let kind = kind.split(';').next().unwrap_or("").trim().to_ascii_lowercase();
if !IMAGE_TYPES.contains(&kind.as_str()) {
return Err(format!("`{kind}` is not a PNG, JPEG, GIF or WebP image"));
}
return read_capped(response).await;
}
Err("too many redirects".into())
}
async fn read_capped(mut response: reqwest::Response) -> Result<Vec<u8>, String> {
let too_big = || format!("larger than {} MB", MAX_BYTES / (1024 * 1024));
if response.content_length().is_some_and(|n| n > MAX_BYTES as u64) {
return Err(too_big());
}
let mut buf = Vec::new();
while let Some(chunk) = response.chunk().await.map_err(|e| e.to_string())? {
if buf.len() + chunk.len() > MAX_BYTES {
return Err(too_big());
}
buf.extend_from_slice(&chunk);
}
Ok(buf)
}
fn is_public(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => public_v4(v4),
IpAddr::V6(v6) => match v6.to_ipv4_mapped() {
Some(v4) => public_v4(v4),
None => {
let s0 = v6.segments()[0];
!(v6.is_loopback() || v6.is_unspecified() || v6.is_multicast() || s0 & 0xfe00 == 0xfc00 || s0 & 0xffc0 == 0xfe80)
}
},
}
}
fn public_v4(ip: Ipv4Addr) -> bool {
let o = ip.octets();
// 0/8 "this network", 100.64/10 carrier-grade NAT and 240/4 reserved have no std helper.
!(ip.is_private()
|| ip.is_loopback()
|| ip.is_link_local()
|| ip.is_broadcast()
|| ip.is_multicast()
|| ip.is_documentation()
|| o[0] == 0
|| (o[0] == 100 && o[1] & 0xc0 == 64)
|| o[0] >= 240)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read, Write};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
type Route = (&'static str, &'static str, String, Vec<u8>);
fn png() -> Vec<u8> {
let mut out = std::io::Cursor::new(Vec::new());
image::RgbaImage::new(3, 2).write_to(&mut out, image::ImageFormat::Png).unwrap();
out.into_inner()
}
/// Serves `routes` (path, status line, extra headers, body) on loopback; returns the base
/// URL and a request counter.
fn serve(routes: Vec<Route>) -> (String, Arc<AtomicUsize>) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let hits = Arc::new(AtomicUsize::new(0));
let count = hits.clone();
std::thread::spawn(move || {
for mut conn in listener.incoming().flatten() {
let (mut req, mut buf) = (Vec::new(), [0u8; 1024]);
while !req.windows(4).any(|w| w == b"\r\n\r\n") {
let Ok(n @ 1..) = conn.read(&mut buf) else { break };
req.extend_from_slice(&buf[..n]);
}
count.fetch_add(1, Ordering::SeqCst);
let line = String::from_utf8_lossy(&req);
let path = line.split_whitespace().nth(1).unwrap_or("/").to_string();
let (status, headers, body) = routes
.iter()
.find(|r| r.0 == path)
.map(|r| (r.1, r.2.clone(), r.3.clone()))
.unwrap_or(("404 Not Found", String::new(), vec![]));
let head = format!("HTTP/1.1 {status}\r\n{headers}Content-Length: {}\r\nConnection: close\r\n\r\n", body.len());
conn.write_all(head.as_bytes()).and_then(|()| conn.write_all(&body)).ok();
}
});
(base, hits)
}
fn image_route(path: &'static str) -> Route {
(path, "200 OK", "Content-Type: image/png\r\n".into(), png())
}
#[test]
fn only_public_addresses_pass() {
for ip in ["8.8.8.8", "93.184.216.34", "2606:4700::1111"] {
assert!(is_public(ip.parse().unwrap()), "{ip}");
}
for ip in [
"127.0.0.1", "10.0.0.1", "172.16.5.5", "192.168.1.1", "169.254.169.254", "0.0.0.0", "100.64.0.1", "224.0.0.1",
"255.255.255.255", "::1", "::", "fe80::1", "fc00::1", "fd12::1", "::ffff:127.0.0.1", "::ffff:192.168.0.1",
] {
assert!(!is_public(ip.parse().unwrap()), "{ip}");
}
}
#[test]
fn loopback_is_refused_without_the_test_override_and_never_contacted() {
let (base, hits) = serve(vec![image_route("/a.png")]);
let err = fetch(&format!("{base}/a.png")).unwrap_err();
assert!(err.contains("private, loopback"), "{err}");
assert_eq!(hits.load(Ordering::SeqCst), 0);
for bad in ["ftp://example.com/a.png", "file:///etc/passwd", "http://user:pw@example.com/a.png", "http://localhost/a.png", "http://[::1]/a.png"] {
assert!(fetch(bad).is_err(), "{bad}");
}
end_call();
}
#[test]
fn fetches_once_follows_redirects_and_registers_the_image() {
ALLOW_PRIVATE.with(|a| a.set(true));
let (base, hits) = serve(vec![image_route("/a.png"), ("/r", "302 Found", "Location: /a.png\r\n".into(), vec![])]);
let url = format!("{base}/r");
let first = crate::mcp::exec::image_asset(&url).unwrap();
let second = crate::mcp::exec::image_asset(&url).unwrap();
assert_eq!(first, second);
assert_eq!(hits.load(Ordering::SeqCst), 2, "one redirect hop and one image, fetched once for two uses");
end_call();
}
#[test]
fn refuses_other_content_types_oversize_bodies_and_errors() {
ALLOW_PRIVATE.with(|a| a.set(true));
let (base, _) = serve(vec![
("/page", "200 OK", "Content-Type: text/html\r\n".into(), b"<html>".to_vec()),
("/svg", "200 OK", "Content-Type: image/svg+xml\r\n".into(), b"<svg/>".to_vec()),
("/big", "200 OK", "Content-Type: image/png\r\n".into(), vec![0; MAX_BYTES + 1]),
]);
for path in ["/page", "/svg", "/big", "/missing"] {
assert!(fetch(&format!("{base}{path}")).is_err(), "{path}");
}
end_call();
}
}
+2 -2
View File
@@ -136,9 +136,9 @@ fn package_json(site: &Site) -> Value {
"typescript": "~6.0.2"
});
if site.options.styling == Styling::Tailwind {
dev["@tailwindcss/postcss"] = json!("^4.3.4");
dev["@tailwindcss/postcss"] = json!("4.3.4");
dev["postcss"] = json!("^8.5.32");
dev["tailwindcss"] = json!("^4.3.4");
dev["tailwindcss"] = json!("4.3.4");
}
json!({
"name": PROJECT,
+1
View File
@@ -67,6 +67,7 @@ fn class(
("background".into(), "var(--brand)".into()),
],
overrides,
media: Vec::new(),
pages: pages.iter().copied().collect::<BTreeSet<_>>(),
root_of,
}
+1 -1
View File
@@ -19,7 +19,7 @@ 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")];
[("@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";
+1
View File
@@ -82,6 +82,7 @@ fn site(styling: Styling, multi: bool) -> Site {
name: "home".into(),
decls: vec![("color".into(), "red".into())],
overrides: vec![],
media: vec![],
pages: [0].into(),
root_of: Some(0),
}],
+144
View File
@@ -0,0 +1,144 @@
//! Responsive breakpoints: frames of one screen at several widths become one page. A frame
//! named `Home @mobile` (or `Home @768`) joins the frame named `Home`; the narrower frame's
//! differences are written as `@media (max-width: ..)` rules.
use crate::model::{Document, NodeId};
use std::collections::HashMap;
/// A narrower design of a page: its frame and the largest window width it applies to.
pub type Variant = (NodeId, u32);
const PATH_SEP: char = '\u{1f}';
/// `(page frame, its narrower variants widest first)` per page, and a warning per frame that
/// could not join one (it stays a page of its own).
pub fn group(doc: &Document, ids: Vec<NodeId>) -> (Vec<(NodeId, Vec<Variant>)>, Vec<String>) {
let name = |id: NodeId| {
doc.get(id)
.map(|n| n.name.trim().to_owned())
.unwrap_or_default()
};
let width = |id: NodeId| doc.get(id).map_or(0.0, |n| n.rect.w);
let mut groups: Vec<(NodeId, Vec<Variant>)> = Vec::new();
let mut warnings = Vec::new();
let mut variants = Vec::new();
for id in ids {
match name(id).split_once(" @") {
Some((base, suffix)) => {
variants.push((id, base.trim().to_lowercase(), suffix.trim().to_owned()))
}
None => groups.push((id, Vec::new())),
}
}
for (id, base, suffix) in variants {
let label = name(id);
let found: Vec<usize> = (0..groups.len())
.filter(|&g| name(groups[g].0).to_lowercase() == base)
.collect();
let [g] = found[..] else {
let why = if found.is_empty() {
"no frame has that name before ` @`"
} else {
"several frames have that name before ` @`"
};
warnings.push(format!(
"`{label}`: {why}, so it is exported as its own page"
));
groups.push((id, Vec::new()));
continue;
};
let max = suffix.parse::<u32>().unwrap_or(width(id).round() as u32);
let taken = groups[g].1.iter().any(|v| v.1 == max);
if width(id) >= width(groups[g].0) || taken {
let why = if taken {
"another variant already applies at that width"
} else {
"it is not narrower than the frame it belongs to"
};
warnings.push(format!(
"`{label}`: {why}, so it is exported as its own page"
));
groups.push((id, Vec::new()));
continue;
}
groups[g].1.push((id, max));
}
for g in &mut groups {
g.1.sort_by_key(|b| std::cmp::Reverse(b.1));
}
(groups, warnings)
}
/// Where `id` sits under `root`: the layer names from the root down, each with its index among
/// same-named siblings. The same screen in another frame has the same path for the same layer.
pub fn path(doc: &Document, root: NodeId, id: NodeId) -> String {
let mut segs = Vec::new();
let mut cur = id;
while cur != root {
let Some(n) = doc.get(cur) else { break };
let Some((p, parent)) = n.parent.and_then(|p| doc.get(p).map(|pn| (p, pn))) else {
break;
};
let k = parent
.children
.iter()
.filter(|c| doc.get(**c).is_some_and(|s| s.name == n.name))
.position(|c| *c == cur)
.unwrap_or(0);
segs.push(format!("{}#{k}", n.name.replace(PATH_SEP, " ")));
cur = p;
}
segs.reverse();
segs.join(&PATH_SEP.to_string())
}
/// The path of the parent layer; empty for a child of the root.
pub fn parent_path(path: &str) -> &str {
path.rsplit_once(PATH_SEP).map_or("", |p| p.0)
}
/// Every layer of `root`'s subtree by path.
pub fn paths(doc: &Document, root: NodeId) -> HashMap<String, NodeId> {
doc.subtree(root)
.into_iter()
.map(|id| (path(doc, root, id), id))
.collect()
}
/// Layers of the wide frame with no layer at their path in the narrow one, paired with the
/// narrow layer of the same name when that name is unique in both frames (and the narrow layer
/// is not already matched by path). Covers a card wrapped in a `Row` on the desktop and a direct
/// child on the phone. The value is the narrow layer and its path.
pub fn by_name(doc: &Document, wide: NodeId, narrow: NodeId) -> HashMap<NodeId, (NodeId, String)> {
let wide_paths = paths(doc, wide);
let narrow_paths = paths(doc, narrow);
let names = |root: NodeId| {
let mut m: HashMap<String, Vec<NodeId>> = HashMap::new();
for id in doc.subtree(root).into_iter().filter(|i| *i != root) {
if let Some(n) = doc.get(id) {
m.entry(n.name.clone()).or_default().push(id);
}
}
m
};
let (in_wide, in_narrow) = (names(wide), names(narrow));
let mut out = HashMap::new();
for (name, w) in &in_wide {
let ([w], Some([n])) = (&w[..], in_narrow.get(name).map(Vec::as_slice)) else { continue };
let (wp, np) = (path(doc, wide, *w), path(doc, narrow, *n));
if !narrow_paths.contains_key(&wp) && !wide_paths.contains_key(&np) {
out.insert(*w, (*n, np));
}
}
out
}
/// "`A`, `B`, `C` and 4 more": the first few layer names for a warning.
pub fn name_list(names: &[String]) -> String {
const SHOWN: usize = 3;
let shown: Vec<String> = names.iter().take(SHOWN).map(|n| format!("`{n}`")).collect();
match names.len().saturating_sub(SHOWN) {
0 => shown.join(", "),
more => format!("{} and {more} more", shown.join(", ")),
}
}
+1 -1
View File
@@ -84,7 +84,7 @@ fn stylesheet(site: &Site, out: &mut Output) -> Result<String, String> {
"package.json".into(),
package_json(
"tailwindcss -i src/input.css -o assets/site.css --minify",
&[("@tailwindcss/cli", "^4.0.0"), ("tailwindcss", "^4.0.0")],
&[("@tailwindcss/cli", "4.3.4"), ("tailwindcss", "4.3.4")],
)?,
);
out.next_steps.push("Run `npm install`, then `npm run build` in the output folder: Tailwind scans the pages and writes assets/site.css, which every page links. Run it again after changing a page.".into());
+15 -4
View File
@@ -5,6 +5,7 @@
mod angular;
mod astro;
mod breakpoints;
mod contrast;
mod fluid;
mod fontsub;
@@ -88,11 +89,12 @@ pub fn export(host: &mut dyn Host, a: &Value) -> ToolResult {
};
let ids = pick_frames(ed, page, a)?;
let (locales, docs) = variants(ed, page, a, lang)?;
let frames = name_frames(&docs[0], ids);
let (groups, group_warnings) = breakpoints::group(&docs[0], ids);
let frames = name_frames(&docs[0], groups);
let titles = localized(a, "title", &locales)?;
let descriptions = localized(a, "description", &locales)?;
let built = tree::build(tree::Params {
let mut built = tree::build(tree::Params {
docs: &docs,
frames: &frames,
locales: &locales,
@@ -100,6 +102,14 @@ pub fn export(host: &mut dyn Host, a: &Value) -> ToolResult {
title: &titles,
description: &descriptions,
});
built.warnings.splice(0..0, group_warnings);
if matches!(
options.styling,
Styling::VanillaExtract | Styling::StyledComponents | Styling::Emotion
) && built.style.classes.iter().any(|c| !c.media.is_empty())
{
built.warnings.push("breakpoint frames (` @`) are not written for CSS-in-JS stylings: every page uses its widest frame; use css, scss or tailwind for responsive rules".into());
}
let site = Site {
options,
site_title: titles[0]
@@ -249,12 +259,12 @@ fn pick_frames(ed: &Editor, page: usize, a: &Value) -> Result<Vec<NodeId>, Strin
/// File and component names from frame names: the first page is `index`, the rest slugs, made
/// unique.
fn name_frames(doc: &Document, ids: Vec<NodeId>) -> Vec<Frame> {
fn name_frames(doc: &Document, ids: Vec<(NodeId, Vec<breakpoints::Variant>)>) -> Vec<Frame> {
let mut taken = BTreeSet::from(["index".to_owned()]);
let mut components = BTreeSet::new();
ids.into_iter()
.enumerate()
.map(|(i, id)| {
.map(|(i, (id, variants))| {
let name = doc.get(id).map(|n| n.name.clone()).unwrap_or_default();
let stem = if i == 0 {
"index".to_owned()
@@ -267,6 +277,7 @@ fn name_frames(doc: &Document, ids: Vec<NodeId>) -> Vec<Frame> {
name,
stem,
component,
variants,
}
})
.collect()
+2
View File
@@ -127,6 +127,8 @@ pub struct Frame {
pub stem: String,
/// PascalCase, unique, valid as a TypeScript, Vue, Angular or Astro identifier.
pub component: String,
/// Narrower designs of this screen, widest first (see `breakpoints`).
pub variants: Vec<super::breakpoints::Variant>,
}
/// One language or mode of the site.
+3 -3
View File
@@ -710,11 +710,11 @@ impl<'a> Kit<'a> {
vec![],
vec![
if next {
("@tailwindcss/postcss", "^4.3.4")
("@tailwindcss/postcss", "4.3.4")
} else {
("@tailwindcss/vite", "^4.3.4")
("@tailwindcss/vite", "4.3.4")
},
("tailwindcss", "^4.3.4"),
("tailwindcss", "4.3.4"),
],
),
Styling::VanillaExtract if next => (
+1
View File
@@ -50,6 +50,7 @@ fn class(name: &str, decls: &[(&str, &str)], pages: &[usize], root_of: Option<us
.map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
.collect(),
overrides: Vec::new(),
media: Vec::new(),
pages: pages.iter().copied().collect::<BTreeSet<_>>(),
root_of,
}
+65 -12
View File
@@ -62,8 +62,18 @@ pub fn is_container(n: &Node) -> bool {
matches!(n.kind, NodeKind::Frame { .. } | NodeKind::Group)
}
/// The landmark or button of a frame or group: the author's role when set, else its name's.
pub fn landmark_of(n: &Node) -> Option<&'static str> {
match n.a11y.as_ref().and_then(|a| a.role.as_deref()) {
Some(r @ ("button" | "nav" | "main" | "header" | "footer" | "aside" | "section")) => {
landmark(r)
}
_ => landmark(&n.name),
}
}
pub fn is_button(n: &Node) -> bool {
is_container(n) && landmark(&n.name) == Some("button")
is_container(n) && landmark_of(n) == Some("button")
}
pub fn has_link(n: &Node) -> bool {
@@ -82,11 +92,18 @@ fn in_action(doc: &Document, root: NodeId, id: NodeId) -> bool {
false
}
/// A short amount or number ("$29", "R$ 49/mês"): a figure, never a heading.
fn price_like(text: &str) -> bool {
let has_digit = text.chars().any(|c| c.is_ascii_digit());
let money = text.chars().any(|c| matches!(c, '$' | '€' | '£' | '¥'));
text.chars().count() <= 20 && has_digit && (money || !text.chars().any(char::is_alphabetic))
}
/// Whether a layer below `root` sits in a header or nav: its text is the brand, not a heading.
fn in_chrome(doc: &Document, root: NodeId, id: NodeId) -> bool {
let mut cur = doc.get(id).and_then(|n| n.parent);
while let Some(n) = cur.and_then(|p| doc.get(p)) {
if is_container(n) && matches!(landmark(&n.name), Some("header" | "nav")) {
if is_container(n) && matches!(landmark_of(n), Some("header" | "nav")) {
return true;
}
cur = if n.id == root { None } else { n.parent };
@@ -155,7 +172,10 @@ pub fn semantics(doc: &Document, root: NodeId) -> Sem {
} else if words(&n.name).any(|w| w == "heading") && explicit_h1.is_none() {
explicit_h1 = Some(*id);
sem.heads.insert(*id, 1);
} else if text_name(&n.name).is_none() && !in_action(doc, root, *id) {
} else if text_name(&n.name).is_none()
&& !in_action(doc, root, *id)
&& !price_like(text)
{
let y = doc.world_rect(*id).map_or(0.0, |r| r.y) - top;
candidates.push(T {
id: *id,
@@ -220,17 +240,50 @@ pub fn semantics(doc: &Document, root: NodeId) -> Sem {
sem.subtitles.insert(id);
}
}
// The outline never skips a level downward: h1 then h3 becomes h1 then h2.
// The outline never skips a level downward: h1 then h3 becomes h1 then h2. Guessed headings
// of one size share one level, so identical siblings do not drift down one by one.
let sizes_of: HashMap<NodeId, i64> = order
.iter()
.map(|(id, size, _)| (*id, (size * 100.0).round() as i64))
.collect();
let mut settled: HashMap<i64, u8> = HashMap::new();
let mut above = None;
for (id, ..) in &order {
if let Some(level) = sem.heads.get_mut(id) {
if let Some(a) = above {
*level = (*level).min(a + 1);
let Some(level) = sem.heads.get_mut(id) else {
continue;
};
let cap = above.map_or(6, |a: u8| a + 1);
if guessed.contains(id) {
let size = sizes_of[id];
let settled_level = *settled.entry(size).or_insert((*level).min(cap));
*level = settled_level.min(cap);
} else {
*level = (*level).min(cap);
}
above = Some(*level);
}
explicit(doc, &layers, &mut sem);
sem
}
/// The author's role and heading level win over every guess above.
fn explicit(doc: &Document, layers: &[NodeId], sem: &mut Sem) {
for id in layers {
let Some(a) = doc.get(*id).and_then(|n| n.a11y.as_ref()) else {
continue;
};
match a.role.as_deref() {
Some("heading") => {
sem.subtitles.remove(id);
sem.heads.insert(*id, a.level.unwrap_or(2).clamp(1, 6));
}
above = Some(*level);
Some(_) => {
sem.subtitles.remove(id);
sem.heads.remove(id);
}
None => {}
}
}
sem
}
/// A layer named `main`, else the root when no header or footer is split off, else the tallest
@@ -238,7 +291,7 @@ pub fn semantics(doc: &Document, root: NodeId) -> Sem {
fn main_of(doc: &Document, root: NodeId, layers: &[NodeId]) -> Option<NodeId> {
let named = |id: &&NodeId| {
doc.get(**id)
.is_some_and(|n| is_container(n) && landmark(&n.name) == Some("main"))
.is_some_and(|n| is_container(n) && landmark_of(n) == Some("main"))
};
if let Some(id) = layers.iter().skip(1).find(named) {
return Some(*id);
@@ -252,13 +305,13 @@ fn main_of(doc: &Document, root: NodeId, layers: &[NodeId]) -> Option<NodeId> {
.collect();
let chrome = |n: &&Node| {
matches!(
landmark(&n.name),
landmark_of(n),
Some("header" | "footer" | "nav" | "aside")
)
};
if !kids
.iter()
.any(|n| matches!(landmark(&n.name), Some("header" | "footer")))
.any(|n| matches!(landmark_of(n), Some("header" | "footer")))
{
return Some(root);
}
+58 -9
View File
@@ -46,6 +46,8 @@ pub struct Class {
pub decls: Decls,
/// Only the declarations a locale changes (a removed one is `initial`).
pub overrides: Vec<(usize, Decls)>,
/// `(max-width px, declarations)` a narrower frame of the same screen changes, widest first.
pub media: Vec<(u32, Decls)>,
/// Pages whose markup uses the class.
pub pages: BTreeSet<usize>,
/// The class of that page's root frame (holds the responsive rule).
@@ -71,7 +73,7 @@ pub struct StyleModel {
}
/// What makes two classes the same: declarations, per-locale overrides and the page root.
type Signature = (Decls, Vec<(usize, Decls)>, Option<usize>);
type Signature = (Decls, Vec<(usize, Decls)>, Vec<(u32, Decls)>, Option<usize>);
/// Collects classes while the pages are built.
#[derive(Default)]
@@ -96,9 +98,10 @@ impl Classes {
page: usize,
decls: Decls,
overrides: Vec<(usize, Decls)>,
media: Vec<(u32, Decls)>,
root_of: Option<usize>,
) -> String {
let key = (decls.clone(), overrides.clone(), root_of);
let key = (decls.clone(), overrides.clone(), media.clone(), root_of);
if let Some(&i) = self.by_sig.get(&key) {
self.list[i].pages.insert(page);
return self.list[i].name.clone();
@@ -109,6 +112,7 @@ impl Classes {
name: name.clone(),
decls,
overrides,
media,
pages: BTreeSet::from([page]),
root_of,
});
@@ -252,7 +256,7 @@ impl StyleModel {
out
}
/// Only the locale overrides of the classes `keep` accepts.
/// Only the locale and breakpoint overrides of the classes `keep` accepts.
pub fn overrides_css(&self, prefix: &str, keep: impl Fn(&Class) -> bool) -> String {
let mut out = String::new();
for c in self.classes.iter().filter(|c| keep(c)) {
@@ -267,6 +271,18 @@ impl StyleModel {
);
}
}
for (max, decls) in &c.media {
// `:root` out-ranks a Tailwind utility on the same element, whatever the sheet order.
out.push_str(&format!(
"@media (max-width: {max}px) {{
"
));
block(&format!(":root .{}", c.name), decls, prefix, " ", &mut out);
out.push_str(
"}
",
);
}
}
out
}
@@ -396,9 +412,33 @@ mod tests {
#[test]
fn identical_declarations_share_a_class_named_after_the_first_layer() {
let mut c = Classes::default();
let a = c.get("Card Title", None, 0, d(&[("color", "red")]), vec![], None);
let b = c.get("Other", None, 1, d(&[("color", "red")]), vec![], None);
let other = c.get("Card Title", None, 0, d(&[("color", "blue")]), vec![], None);
let a = c.get(
"Card Title",
None,
0,
d(&[("color", "red")]),
vec![],
vec![],
None,
);
let b = c.get(
"Other",
None,
1,
d(&[("color", "red")]),
vec![],
vec![],
None,
);
let other = c.get(
"Card Title",
None,
0,
d(&[("color", "blue")]),
vec![],
vec![],
None,
);
assert_eq!(
(a.as_str(), b.as_str(), other.as_str()),
("card-title", "card-title", "card-title-2")
@@ -422,17 +462,26 @@ mod tests {
#[test]
fn scss_nests_page_rules_under_the_root_and_guards_interpolation() {
let mut c = Classes::default();
c.get("Home", None, 0, d(&[("width", "100%")]), vec![], Some(0));
c.get(
"Home",
None,
0,
d(&[("width", "100%")]),
vec![],
vec![],
Some(0),
);
c.get(
"Title",
None,
0,
d(&[("content", "\"#{x}\"")]),
vec![(1, d(&[("color", "red")]))],
vec![],
None,
);
c.get("Shared", None, 0, d(&[("a", "b")]), vec![], None);
c.get("Shared", None, 1, d(&[("a", "b")]), vec![], None);
c.get("Shared", None, 0, d(&[("a", "b")]), vec![], vec![], None);
c.get("Shared", None, 1, d(&[("a", "b")]), vec![], vec![], None);
let m = StyleModel {
classes: c.list,
scopes: vec![String::new(), ":root:lang(pt-BR)".into()],
+183 -15
View File
@@ -5,6 +5,7 @@
//! locale-scoped rules, and anything else (structure, images) keeps the first locale's version
//! with a warning.
use super::breakpoints;
use super::fluid;
use super::media::{self, Media};
use super::model::{Frame, Locale, Page};
@@ -54,7 +55,7 @@ pub struct Element {
pub link: Option<LinkTarget>,
/// Tailwind utilities for this element, empty unless styling is Tailwind.
pub tw: String,
/// The class carries per-locale rules, so Tailwind output keeps it next to `tw`.
/// The class carries per-locale or breakpoint rules, so Tailwind output keeps it next to `tw`.
pub has_overrides: bool,
pub children: Vec<Child>,
}
@@ -155,6 +156,19 @@ fn describe(doc: &Document, f: &Frame) -> String {
struct Cx {
in_link: bool,
in_button: bool,
/// Landmark tags of the ancestors, one bit each (`LANDMARKS`): a landmark never nests in itself.
landmarks: u8,
}
const LANDMARKS: [&str; 4] = ["header", "nav", "footer", "aside"];
/// A narrower frame of the page being walked.
struct Bp {
max: u32,
paths: HashMap<String, NodeId>,
/// Wide layers matched by unique name instead of path (see `breakpoints::by_name`).
renamed: HashMap<NodeId, (NodeId, String)>,
hidden: Vec<String>,
}
struct Walk<'a> {
@@ -178,6 +192,10 @@ struct Walk<'a> {
sem: Sem,
hero_seen: bool,
fixed_layout: usize,
/// The page's narrower frames, widest first.
bps: Vec<Bp>,
/// Paths of the page frame's layers that every walked layer matched against the variants.
bp_seen: HashSet<String>,
/// Per page: the face `(family, bold, italic)` of its h1.
h1_faces: Vec<Option<(String, bool, bool)>>,
}
@@ -207,6 +225,8 @@ impl<'a> Walk<'a> {
sem: Sem::default(),
hero_seen: false,
fixed_layout: 0,
bps: Vec::new(),
bp_seen: HashSet::new(),
h1_faces: Vec::new(),
}
}
@@ -233,17 +253,57 @@ impl<'a> Walk<'a> {
}));
self.hero_seen = false;
self.fixed_layout = 0;
self.bp_seen.clear();
self.bps = f
.variants
.iter()
.map(|(vid, max)| Bp {
max: *max,
paths: breakpoints::paths(doc, *vid),
renamed: breakpoints::by_name(doc, f.id, *vid),
hidden: Vec::new(),
})
.collect();
let root = self
.node(f.id, Cx::default(), true)
.unwrap_or_else(|| Element::new("div"));
if self.fixed_layout > 0 {
self.warnings.push(format!("{}: {} frame(s) without auto layout are absolutely positioned and will not reflow on a phone", f.name, self.fixed_layout));
}
self.warn_breakpoints(f);
root
}
/// What the variants could not express: layers only one of the frames has.
fn warn_breakpoints(&mut self, f: &Frame) {
for (bp, (vid, _)) in self.bps.iter().zip(&f.variants) {
let name = self.docs[0].get(*vid).map_or("", |n| n.name.as_str());
let mut only_there: Vec<String> = bp
.paths
.iter()
.filter(|(k, _)| {
!self.bp_seen.contains(*k)
&& !bp.renamed.values().any(|(_, p)| p == *k)
&& (k.is_empty() || self.bp_seen.contains(breakpoints::parent_path(k)))
})
.filter_map(|(_, id)| self.docs[0].get(*id).map(|n| n.name.clone()))
.collect();
only_there.sort();
if !bp.hidden.is_empty() {
self.warnings.push(format!("{} / {name}: {} layer(s) of the widest frame are missing here and are hidden at {}px and below ({})", f.name, bp.hidden.len(), bp.max, breakpoints::name_list(&bp.hidden)));
}
if !only_there.is_empty() {
self.warnings.push(format!("{} / {name}: {} layer(s) exist only in this frame and are left out ({}); add them to the widest frame to show them", f.name, only_there.len(), breakpoints::name_list(&only_there)));
}
}
}
fn finish(mut self, pages: Vec<Page>, p: &Params) -> Built {
let roots: Vec<NodeId> = p.frames.iter().map(|f| f.id).collect();
let roots: Vec<NodeId> = p
.frames
.iter()
.flat_map(|f| std::iter::once(f.id).chain(f.variants.iter().map(|v| v.0)))
.collect();
for f in media::missing_families(&self.docs[0], &roots) {
self.warnings.push(format!("font `{f}` is not installed, so its text is drawn in Inter; install it before exporting to keep the typeface"));
}
@@ -345,6 +405,7 @@ impl<'a> Walk<'a> {
let inner = Cx {
in_link: cx.in_link || link.is_some(),
in_button: cx.in_button || button,
landmarks: cx.landmarks,
};
let mut el = self.element(id, n, inner, is_root, link.is_some() && button)?;
let mut out = match link {
@@ -366,6 +427,7 @@ impl<'a> Walk<'a> {
self.page,
vec![("display".into(), "contents".into())],
Vec::new(),
Vec::new(),
None,
);
}
@@ -384,10 +446,13 @@ impl<'a> Walk<'a> {
/// A link or button with no text or described picture in it is announced as just "link" or
/// "button", so the layer (or icon) name becomes its label.
fn label(&mut self, el: &mut Element, n: &Node) {
let name = codegen::icon_of(&self.docs[0], n).map_or_else(
|| n.name.trim().to_owned(),
|(_, icon)| icon.replace('-', " "),
);
let name = match explicit_alt(n).filter(|a| !a.is_empty()) {
Some(alt) => alt,
None => codegen::icon_of(&self.docs[0], n).map_or_else(
|| n.name.trim().to_owned(),
|(_, icon)| icon.replace('-', " "),
),
};
if is_generic_name(&name) {
let what = if el.tag == "button" { "button" } else { "link" };
self.warnings.push(format!("{} / {}: a {what} with no text and a generic layer name has no accessible name; rename the layer to say what it does", self.page_name, n.name));
@@ -401,7 +466,11 @@ impl<'a> Walk<'a> {
fn link(&mut self, n: &Node, in_link: bool) -> Option<LinkTarget> {
let label = format!("{} / {}", self.page_name, n.name);
let target = if let Some(to) = n.link {
match self.frames.iter().position(|f| f.id == to) {
match self
.frames
.iter()
.position(|f| f.id == to || f.variants.iter().any(|v| v.0 == to))
{
Some(i) => {
self.resolved += 1;
LinkTarget::Page(i)
@@ -476,7 +545,7 @@ impl<'a> Walk<'a> {
let tag = if self.sem.main == Some(id) {
"main"
} else {
match semantic::landmark(&n.name).filter(|_| semantic::is_container(n)) {
match semantic::landmark_of(n).filter(|_| semantic::is_container(n)) {
Some("button")
if !own_link
&& !self.docs[0]
@@ -487,7 +556,12 @@ impl<'a> Walk<'a> {
{
"button"
}
Some(t @ ("header" | "nav" | "footer" | "section" | "aside")) => t,
Some(t @ ("header" | "nav" | "footer" | "section" | "aside")) => {
match LANDMARKS.iter().position(|l| *l == t) {
Some(bit) if cx.landmarks & (1 << bit) != 0 => "div",
_ => t,
}
}
Some("main") => "section",
_ => "div",
}
@@ -499,15 +573,36 @@ impl<'a> Walk<'a> {
if n.is_frame() && !n.children.is_empty() && n.layout.is_none() {
self.fixed_layout += 1;
}
if let Some(r @ ("link" | "list")) = n.a11y.as_ref().and_then(|a| a.role.as_deref()) {
el.attrs.push(("role".into(), r.into()));
}
self.style(&mut el, id, n, is_root, false);
for c in &n.children {
if let Some(child) = self.node(*c, cx, false) {
let mut inner = cx;
if let Some(bit) = LANDMARKS.iter().position(|l| *l == tag) {
inner.landmarks |= 1 << bit;
}
for c in self.reading_order(n) {
if let Some(child) = self.node(c, inner, false) {
el.children.push(Child::El(child));
}
}
el
}
/// Children in the order a screen reader meets them. Only a frame without auto layout may
/// reorder: its children are placed by position, so the source order is free; elsewhere
/// it also decides where they sit.
fn reading_order(&self, n: &Node) -> Vec<NodeId> {
let mut kids = n.children.clone();
if n.layout.is_none() {
let doc = &self.docs[0];
let key = |c: &NodeId| doc.get(*c).and_then(|k| k.a11y.as_ref()).and_then(|a| a.order);
// Unnumbered layers keep their place after the numbered ones.
kids.sort_by_key(|c| key(c).unwrap_or(u32::MAX));
}
kids
}
fn text(&mut self, id: NodeId, n: &Node, cx: Cx, is_root: bool) -> Element {
let NodeKind::Text {
text, auto_width, ..
@@ -592,7 +687,9 @@ impl<'a> Walk<'a> {
let y = self.docs[0].world_rect(id).map_or(0.0, |r| r.y) - self.top;
let hero = !self.hero_seen && y < FOLD_PX;
self.hero_seen |= hero;
let alt = if is_generic_name(&n.name) {
let alt = if let Some(a) = explicit_alt(n) {
a
} else if is_generic_name(&n.name) {
self.warnings.push(format!("{} / {}: the layer has a generic name, so the image has an empty `alt`; rename the layer to describe the picture", self.page_name, n.name));
String::new()
} else {
@@ -633,7 +730,7 @@ impl<'a> Walk<'a> {
let mut el = Element::new("img");
el.attrs = vec![
("src".into(), self.media.svg(&svg)),
("alt".into(), String::new()),
("alt".into(), explicit_alt(n).unwrap_or_default()),
];
push_size(&mut el, n);
self.style(&mut el, id, n, is_root, true);
@@ -655,7 +752,8 @@ impl<'a> Walk<'a> {
}
}
}
el.has_overrides = !overrides.is_empty();
let media = self.breakpoint_decls(id, &base, is_root, replaced);
el.has_overrides = !overrides.is_empty() || !media.is_empty();
if self.tailwind {
el.tw = self.tailwind_classes(n, is_root, replaced);
}
@@ -666,7 +764,62 @@ impl<'a> Walk<'a> {
let name = root.map(|p| self.roots[p].clone());
el.class = self
.classes
.get(&n.name, name, self.page, base, overrides, root);
.get(&n.name, name, self.page, base, overrides, media, root);
}
/// What each narrower frame changes on layer `id`, matched by layer path; a layer the
/// frame lacks is hidden at its width.
fn breakpoint_decls(
&mut self,
id: NodeId,
base: &Decls,
is_root: bool,
replaced: bool,
) -> Vec<(u32, Decls)> {
if self.bps.is_empty() {
return Vec::new();
}
let root = self.frames[self.page].id;
let key = breakpoints::path(&self.docs[0], root, id);
let parent_found =
|bp: &Bp| key.is_empty() || bp.paths.contains_key(breakpoints::parent_path(&key));
let mut out = Vec::new();
for k in 0..self.bps.len() {
let matched = (self.bps[k].paths.get(&key).copied())
.or_else(|| self.bps[k].renamed.get(&id).map(|(n, _)| *n));
let changed = match matched {
Some(vid) => {
let d = self.decls(0, vid, is_root, replaced).unwrap_or_default();
let mut changed = diff(base, &d);
if is_root {
// The page keeps its desktop frame's width rule; a phone column is not centred on a tablet.
changed.retain(|(p, _)| p != "max-width" && p != "margin");
}
changed
}
// A wrapper the narrow frame lacks, around layers it has flat: dissolve it, so they
// lay out in its parent and take their narrow style.
None if parent_found(&self.bps[k]) => {
let wraps = self.docs[0]
.subtree(id)
.into_iter()
.any(|d| d != id && self.bps[k].renamed.contains_key(&d));
if wraps {
vec![("display".to_owned(), "contents".to_owned())]
} else {
let name = self.docs[0].get(id).map_or_else(String::new, |n| n.name.clone());
self.bps[k].hidden.push(name);
vec![("display".to_owned(), "none".to_owned())]
}
}
None => Vec::new(),
};
if !changed.is_empty() {
out.push((self.bps[k].max, changed));
}
}
self.bp_seen.insert(key);
out
}
fn tailwind_classes(&self, n: &Node, is_root: bool, replaced: bool) -> String {
@@ -797,6 +950,12 @@ impl<'a> Walk<'a> {
if layer_text(n).contains('\n') {
set(out, "white-space", "pre-line".into());
}
// Absolute children are placed against the nearest positioned ancestor: this layer.
let places_children = n.layout.is_none()
|| n.children.iter().any(|c| doc.get(*c).is_some_and(|c| c.layout_child.absolute));
if places_children && !n.children.is_empty() && !out.iter().any(|d| d.0 == "position") {
set(out, "position", "relative".into());
}
if is_root && n.layout.is_some() {
// A page takes the window's width and at least its height, so its background reaches
// every edge, and grows instead of clipping. A screen narrower than a desktop stays a
@@ -840,6 +999,15 @@ impl<'a> Walk<'a> {
}
}
/// The author's alt text: `Some("")` for a decorative picture, `None` when nothing was set.
fn explicit_alt(n: &Node) -> Option<String> {
let a = n.a11y.as_ref()?;
if a.decorative {
return Some(String::new());
}
a.alt.clone()
}
fn layer_text(n: &Node) -> String {
match &n.kind {
NodeKind::Text { text, .. } | NodeKind::Sticky { text, .. } => text.clone(),
+1 -1
View File
@@ -14,7 +14,7 @@ pub(super) fn write(cx: &Cx, out: &mut Output) -> Result<(), String> {
match styling {
Styling::Scss => dev.push(("sass", "^1.107.1")),
Styling::Tailwind => {
dev.extend([("@tailwindcss/vite", "^4.3.4"), ("tailwindcss", "^4.3.4")])
dev.extend([("@tailwindcss/vite", "4.3.4"), ("tailwindcss", "4.3.4")])
}
_ => {}
}
+2
View File
@@ -82,6 +82,7 @@ fn site(render: Render, styling: Styling, multi: bool) -> Site {
0,
vec![("width".into(), "100%".into())],
vec![],
vec![],
Some(0),
);
let style = StyleModel {
@@ -370,6 +371,7 @@ fn css_in_a_style_block_cannot_end_it() {
0,
vec![("content".into(), "\"</style><script>${x}`\"".into())],
vec![],
vec![],
Some(0),
);
s.style.classes = c.list;
+1 -1
View File
@@ -24,7 +24,7 @@ pub(super) fn write(cx: &Cx, out: &mut Output) -> Result<(), String> {
match styling {
Styling::Scss => dev.push(("sass", "^1.107.1")),
Styling::Tailwind => {
dev.extend([("@tailwindcss/vite", "^4.3.4"), ("tailwindcss", "^4.3.4")])
dev.extend([("@tailwindcss/vite", "4.3.4"), ("tailwindcss", "4.3.4")])
}
_ => {}
}
+71
View File
@@ -0,0 +1,71 @@
//! `set_text_style`: define, change, delete and apply the page's named type styles.
use super::ToolResult;
use super::exec::{Args, node_key, page_arg, resolve_all, text};
use crate::editor::Editor;
use crate::model::TypeStyle;
use serde_json::{Value, json};
/// The page's styles as JSON, for `get_variables`.
pub(super) fn styles_json(doc: &crate::model::Document) -> Value {
serde_json::to_value(&doc.text_styles).unwrap_or(Value::Null)
}
/// `current` with the fields the call names replaced. `lineHeight` is a multiple of the size;
/// null clears it. A new style needs `fontSize`.
fn merged(current: Option<&TypeStyle>, a: &Args) -> Result<TypeStyle, String> {
let mut s = match (current, a.num("fontSize")) {
(Some(c), _) => c.clone(),
(None, Some(size)) => TypeStyle { font_size: size, font: None, bold: false, italic: false, line_height: None, letter_spacing: 0.0 },
(None, None) => return Err("a new style needs `fontSize`".into()),
};
if let Some(v) = a.num("fontSize") {
s.font_size = v;
}
if a.0.contains_key("font") {
s.font = a.str("font").filter(|f| !f.is_empty()).map(String::from);
}
if let Some(v) = a.bool("bold") {
s.bold = v;
}
if let Some(v) = a.bool("italic") {
s.italic = v;
}
if a.0.contains_key("lineHeight") {
s.line_height = a.num("lineHeight");
}
if let Some(v) = a.num("letterSpacing") {
s.letter_spacing = v;
}
if s.font_size <= 0.0 || s.line_height.is_some_and(|l| l <= 0.0) {
return Err("`fontSize` and `lineHeight` must be above 0".into());
}
Ok(s)
}
pub(super) fn set_text_style(ed: &mut Editor, a: &Args) -> ToolResult {
let page = page_arg(ed, a)?;
let action = a.req_str("action")?;
let name = a.str("name").filter(|n| !n.is_empty());
match action {
"set" => {
let name = name.ok_or("`name` is required")?;
let style = merged(ed.page_doc(page).text_styles.get(name), a)?;
let updated = ed.with_page(page, |ed| ed.set_text_style(name, style.clone()));
text(json!({"name": name, "style": style, "layersUpdated": updated}))
}
"delete" => {
let name = name.ok_or("`name` is required")?;
let detached = ed.with_page(page, |ed| ed.delete_text_style(name)).ok_or_else(|| format!("no text style `{name}`"))?;
text(json!({"deleted": name, "layersDetached": detached}))
}
"apply" => {
let keys = a.strs("nodeIds")?;
let (page, ids) = resolve_all(ed, &keys, page)?;
let n = ed.with_page(page, |ed| ed.apply_text_style(&ids, name))?;
let changed: Vec<String> = ids.iter().map(|i| node_key(ed, page, *i)).collect();
text(json!({"style": name, "layersChanged": n, "nodeIds": changed}))
}
_ => Err(format!("unknown action `{action}` (set, delete, apply)")),
}
}
+47 -6
View File
@@ -781,6 +781,10 @@ pub fn definitions() -> Vec<Value> {
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. \
Responsive: a frame named `Home @mobile` (or `Home @768`, the number is the largest \
window width it applies to; else its own width) joins the wider frame named `Home` \
(matched by layer names); it adds @media (max-width) rules, layers it lacks are \
hidden; not written for CSS-in-JS stylings. \
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: references/export-site.md in the \
@@ -850,7 +854,8 @@ pub fn definitions() -> Vec<Value> {
- Boxes: background(-color/-image: colors, linear/radial gradients, url()), border \
(and border-top/right/bottom/left), border-radius, opacity, overflow:hidden, \
mix-blend-mode, width/height (px, %, fit-content), min-width/max-width/min-height/max-height.\n\
- <img src> takes paper-asset:///abs/path, file:// URLs, absolute paths or data: URIs. \
- <img src> takes paper-asset:///abs/path, file:// URLs, absolute paths, data: URIs or public http(s) URLs (PNG, JPEG, GIF or WebP up to 10 MB; \
private and loopback addresses are refused and a failed download is a warning). \
<svg> markup is imported as vector layers.\n\
- <x-paper-icon name=\"set:name\" size=\"20\" color=\"var(--accent)\"/> places an installed icon (search_icons); \
<x-paper-instance component=\"Name\" component-id=\"ID\" layer-name=\"...\"/> places a component instance; \
@@ -1277,6 +1282,24 @@ pub fn definitions() -> Vec<Value> {
&["links"],
false,
),
tool(
"set_accessibility",
"Set or clear accessibility annotations on layers: `alt` (text alternative of a picture, or the accessible name of an icon button), `decorative` (true: ornament, no alt needed), `role` (button, link, heading, list, nav, main, header, footer, aside, section; overrides what the layer name suggests), `level` (1-6, with role heading) and `order` (reading position among siblings, lower first; applies to frames without auto layout). A field set to null is cleared; `clear: true` removes every annotation first. get_node_info shows them as `accessibility`. lint_design checks them (image-alt, interactive-name, heading-skip, reading-order) and export_site writes them instead of inferring. All items are checked before any is applied; one undo step.",
json!({
"items": {"type": "array", "items": {"type": "object", "properties": {
"nodeId": {"type": "string"},
"alt": {"type": ["string", "null"]},
"decorative": {"type": "boolean"},
"role": {"type": ["string", "null"], "enum": ["button", "link", "heading", "list", "nav", "main", "header", "footer", "aside", "section", null]},
"level": {"type": ["integer", "null"], "minimum": 1, "maximum": 6},
"order": {"type": ["integer", "null"], "minimum": 0},
"clear": {"type": "boolean"},
}, "required": ["nodeId"]}},
"pageId": page_id(),
}),
&["items"],
false,
),
tool(
"set_screens",
"Declare frames as screens (one device viewport of the product, not a section, slot or \
@@ -1301,7 +1324,7 @@ pub fn definitions() -> Vec<Value> {
(fills, colours, borders, weight, radius, visibility, icons; texts with includeText) \
become its option, every instance on every page moves to the base with its value, \
the base takes the family name and the emptied variants are deleted. A variant with \
layers the base lacks is left out (reported).",
layers the base lacks is left out (reported). Variant names of the form \"Size=M, State=hover\" (the same axes in each, via the component names or `values`) build a variant set: one enum property per axis, read from the variants that differ from the base in that axis alone (so the others combine additively); set_instance_props picks a value per axis.",
json!({
"componentIds": {"type": "array", "items": {"type": "string"}, "description": "The variants (same page)."},
"name": {"type": "string", "description": "The property's name (Active, Status, State)."},
@@ -1344,7 +1367,7 @@ pub fn definitions() -> Vec<Value> {
),
tool(
"bind_variable",
"Bind a field of layers to a variable (or detach it). `field`: stroke, text-color, \
"Bind a field of layers to a variable (or detach it). `field`: stroke, text-color, gradient-stop-N (colour variable for stop N, from 0, of the layer's top gradient fill; follows modes and exports as var(--x) inside the gradient), \
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 \
@@ -1356,6 +1379,21 @@ pub fn definitions() -> Vec<Value> {
&["nodeIds", "field"],
false,
),
tool(
"set_text_style",
"Named type styles of a page (\"Heading / L\", \"Body / M\"); get_variables lists them as `textStyles`. `action` set: create the style `name` or change the fields given (fontSize is required for a new one); every layer that follows it is updated at once, and a style edit replaces local font size, font, bold, italic, line height and letter spacing on those layers (character spans stay). delete: removes the style; layers keep their look and stop following it. apply: `nodeIds` text layers follow `name` (omitted or null detaches, keeping the look). Editing a layer's font properties by hand does not detach it: the next style change overwrites them. One undo step per call.",
json!({"action": {"type": "string", "enum": ["set", "delete", "apply"]},
"name": {"type": "string"},
"fontSize": {"type": "number", "description": "px"},
"font": {"type": ["string", "null"], "description": "Family added with install_font; null = Inter."},
"bold": {"type": "boolean"}, "italic": {"type": "boolean"},
"lineHeight": {"type": ["number", "null"], "description": "Multiple of the font size (1.5); null = font default."},
"letterSpacing": {"type": "number", "description": "px per character"},
"nodeIds": ids("apply: text layers."),
"pageId": page_id()}),
&["action"],
false,
),
tool(
"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.",
@@ -1422,7 +1460,10 @@ pub fn definitions() -> Vec<Value> {
`parentId` / `before` / `after`). Each move is {nodeId, before: siblingId}, {nodeId, \
after: siblingId} or {nodeId, parentId, toPageId?, index?} (parentId \"root\" = the \
page root). Moves apply in order. In auto-layout parents this changes the visual \
order; in free parents it changes stacking and keeps the world position.",
order; in free parents it changes stacking and keeps the world position. Moving to another page \
copies the layers there (new ids; each page has its own undo history) and keeps \
component links and links between the moved layers; prototype links and connector \
ends that would cross pages are cleared and reported in `warnings`.",
json!({"moves": {"type": "array", "minItems": 1, "items": {
"type": "object",
"properties": {
@@ -1589,11 +1630,11 @@ fn classify(name: &str, args: &Value) -> Option<bool> {
"create_page" | "rename_pages" | "delete_pages" | "create_artboard" | "write_html" | "import_html"
| "create_shape" | "update_styles" | "set_text_content" | "rename_nodes"
| "annotate" | "set_annotation_template" | "upsert_annotation_template" | "delete_annotation_template" | "update_annotation" | "delete_annotations" | "add_feedback" | "resolve_feedback" | "set_variables" | "set_tokens"
| "create_collection" | "delete_variables" | "bind_variable" | "set_strings"
| "create_collection" | "delete_variables" | "bind_variable" | "set_strings" | "set_text_style"
| "enable_library" | "update_library" | "disable_library"
| "set_variable_mode" | "import_design_system" | "import_components" | "create_component"
| "create_instance" | "swap_instance" | "detach_instance" | "delete_nodes" | "apply_ui_kit" | "set_code_link"
| "set_component_props" | "set_instance_props" | "merge_variants" | "set_prototype_links" | "set_screens"
| "set_component_props" | "set_instance_props" | "merge_variants" | "set_prototype_links" | "set_accessibility" | "set_screens"
| "duplicate_nodes" | "move_nodes" | "undo" | "redo" => true,
_ => return None,
})
+34
View File
@@ -0,0 +1,34 @@
//! Accessibility annotations on a layer: what a screen reader should say and in which order.
//! Everything is optional; a layer that sets none carries no `a11y` at all.
use serde::{Deserialize, Serialize};
/// Roles an author may force on a layer (the site export writes them as the matching tag, or as
/// a `role` attribute for `link` and `list`).
pub const ROLES: [&str; 10] =
["button", "link", "heading", "list", "nav", "main", "header", "footer", "aside", "section"];
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct A11y {
/// Text alternative of a picture (or the accessible name of an icon button).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub alt: Option<String>,
/// The layer is ornament: assistive tech skips it, so it needs no alt text.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub decorative: bool,
/// One of [`ROLES`], overriding what the layer's name suggests.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
/// Heading level 1..=6 when the role is `heading`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub level: Option<u8>,
/// Position among its siblings in the reading order (lower reads first).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub order: Option<u32>,
}
impl A11y {
pub fn is_empty(&self) -> bool {
*self == A11y::default()
}
}
+9
View File
@@ -2,6 +2,7 @@
use super::annotation::Annotation;
use super::node::{Color, Node, NodeId};
use super::type_style::TypeStyle;
use crate::geom::{Rect, V2};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
@@ -26,6 +27,10 @@ pub struct Document {
/// substituted for `var(--name)` when styles are written.
#[serde(default)]
pub tokens: BTreeMap<String, String>,
/// Named type styles ("Heading / L") that text layers can follow. Absent from page files
/// when empty.
#[serde(default)]
pub text_styles: BTreeMap<String, TypeStyle>,
/// Notes that explain the design. Absent from page files when empty.
#[serde(default)]
pub annotations: Vec<Annotation>,
@@ -42,6 +47,7 @@ impl Default for Document {
modes: BTreeMap::new(),
mode: None,
tokens: BTreeMap::new(),
text_styles: BTreeMap::new(),
annotations: Vec::new(),
}
}
@@ -54,6 +60,7 @@ pub struct VarState {
pub modes: BTreeMap<String, BTreeMap<String, Color>>,
pub mode: Option<String>,
pub tokens: BTreeMap<String, String>,
pub text_styles: BTreeMap<String, TypeStyle>,
}
impl Document {
@@ -63,6 +70,7 @@ impl Document {
modes: self.modes.clone(),
mode: self.mode.clone(),
tokens: self.tokens.clone(),
text_styles: self.text_styles.clone(),
}
}
@@ -71,6 +79,7 @@ impl Document {
self.modes = s.modes.clone();
self.mode = s.mode.clone();
self.tokens = s.tokens.clone();
self.text_styles = s.text_styles.clone();
}
pub fn get(&self, id: NodeId) -> Option<&Node> {
+4
View File
@@ -1,3 +1,4 @@
pub mod a11y;
pub mod annotation;
pub mod annotation_layout;
pub mod document;
@@ -5,9 +6,12 @@ pub mod link;
pub mod node;
pub mod paint;
pub mod rough;
pub mod type_style;
pub use a11y::A11y;
pub use document::{Document, VarState};
pub use link::LinkAction;
pub use type_style::TypeStyle;
pub use node::{
Binding, CodeLink, PropDef, PropOption, Color, Comment, FillRule, Head, Node, NodeId, NodeKind, PathPoint, Route, Shadow,
StrokePoint, Style, SubPath, TextAlign,
+4
View File
@@ -461,6 +461,9 @@ pub struct Node {
/// An instance root's property values, by property name.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub prop_values: BTreeMap<String, String>,
/// Alt text, role and reading order for assistive tech (`set_accessibility`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub a11y: Option<super::A11y>,
}
/// One property of a component. `kind`: `text` (the text of `layer`), `bool` (shows `layer`),
@@ -614,6 +617,7 @@ impl Node {
scroll_y: 0.0,
props: Vec::new(),
prop_values: BTreeMap::new(),
a11y: None,
}
}
+3
View File
@@ -329,6 +329,9 @@ pub struct TextStyle {
pub letter_spacing: f64,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub spans: Vec<TextSpan>,
/// Name of the document text style this layer follows (see `Document::text_styles`).
#[serde(skip_serializing_if = "Option::is_none")]
pub style_ref: Option<String>,
}
impl TextStyle {
+39
View File
@@ -0,0 +1,39 @@
//! Named type styles: the text properties a layer takes from "Heading / L" or "Body / M".
use super::node::{Node, NodeKind};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TypeStyle {
pub font_size: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub font: Option<String>,
#[serde(default)]
pub bold: bool,
#[serde(default)]
pub italic: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub line_height: Option<f64>,
#[serde(default)]
pub letter_spacing: f64,
}
impl TypeStyle {
/// Copy the properties onto a text layer and record the reference. A style change calls
/// this again for every layer that follows it, so local edits of these fields are replaced;
/// character spans (partial bold, colors) are kept. Returns false for non-text nodes.
pub fn apply(&self, name: &str, n: &mut Node) -> bool {
let NodeKind::Text { font_size, .. } = &mut n.kind else {
return false;
};
*font_size = self.font_size;
let ts = &mut n.text_style;
ts.font = self.font.clone();
ts.bold = self.bold;
ts.italic = self.italic;
ts.line_height = self.line_height;
ts.letter_spacing = self.letter_spacing;
ts.style_ref = Some(name.to_string());
true
}
}
+50 -8
View File
@@ -8,7 +8,7 @@
use crate::geom::Camera;
use crate::variables::{PAGE_TEMPLATE_KEY, PageModes};
use crate::model::annotation::Annotation;
use crate::model::{Color, Document, Node, NodeId};
use crate::model::{Color, Document, Node, NodeId, TypeStyle};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::{BTreeMap, HashMap};
@@ -27,20 +27,41 @@ pub const PROJECT_VERSION: u32 = 3;
/// Project migrations, indexed by source version: `PROJECT_STEPS[0]` turns v1 into v2. The
/// manifest itself does not change; the design system is built on load from the pages, and
/// pages are packed as they are saved.
const PROJECT_STEPS: &[fn(Value) -> Value] = &[|v| v, |v| v];
const PROJECT_STEPS: &[fn(Value) -> Value] = &[|v| v, |v| v, |v| v];
/// v1: legacy single file. v2: page file inside a project. v3: fill layers (`fills`).
/// v4: text can name a font, and layers can carry a link action (URL, back, overlay, close); an
/// older app would drop either on save, so it must refuse the page.
pub const PAGE_VERSION: u32 = 4;
/// What a page that uses neither is saved as, so a v4 app does not lock older ones out of pages
/// it merely touched (see [`needs_v4`]).
/// v5: layers can carry accessibility annotations (`a11y`). v6: named text styles (`text_styles`,
/// a layer's `style_ref`). An older app would drop either on save.
pub const PAGE_VERSION: u32 = 6;
const PAGE_V5: u32 = 5;
/// What a page that uses nothing newer is saved as, so a newer app does not lock older ones out
/// of pages it merely touched (see [`page_version`]).
const PAGE_BASE_VERSION: u32 = 3;
const PAGE_V4: u32 = 4;
/// Whether a page holds something an older app would drop on save: a named font or a link action.
fn needs_v4(nodes: &[Node]) -> bool {
nodes.iter().any(|n| n.text_style.font.is_some() || n.link_action.is_some())
}
fn needs_v6(doc: &Document) -> bool {
!doc.text_styles.is_empty() || doc.nodes.values().any(|n| n.text_style.style_ref.is_some())
}
/// The version a page is written as: the oldest one that can hold everything in it.
fn page_version(doc: &Document, nodes: &[Node]) -> u32 {
if needs_v6(doc) {
PAGE_VERSION
} else if nodes.iter().any(|n| n.a11y.is_some()) {
PAGE_V5
} else if needs_v4(nodes) {
PAGE_V4
} else {
PAGE_BASE_VERSION
}
}
#[derive(Debug)]
pub enum FormatError {
Io(std::io::Error),
@@ -167,6 +188,8 @@ struct PageFile {
/// Active mode id per collection id.
#[serde(default, rename = "collectionModes", skip_serializing_if = "BTreeMap::is_empty")]
modes_by_collection: BTreeMap<String, String>,
#[serde(default, rename = "textStyles", skip_serializing_if = "BTreeMap::is_empty")]
text_styles: BTreeMap<String, TypeStyle>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
annotations: Vec<Annotation>,
/// This page's annotation template (overrides the project default).
@@ -175,7 +198,7 @@ struct PageFile {
}
/// Page migrations, indexed by source version: `PAGE_STEPS[0]` turns v1 into v2.
const PAGE_STEPS: &[fn(Value) -> Value] = &[page_v1_to_v2, page_v2_to_v3, |v| v];
const PAGE_STEPS: &[fn(Value) -> Value] = &[page_v1_to_v2, page_v2_to_v3, |v| v, |v| v, |v| v];
/// Formats accepted as older versions of a page.
const PAGE_ALIASES: &[&str] = &[LEGACY_FORMAT];
@@ -218,7 +241,7 @@ pub fn page_to_json_modes(p: &PageData, modes: &PageModes) -> String {
nodes.sort_by_key(|n| n.id);
let f = PageFile {
format: PAGE_FORMAT.into(),
version: if needs_v4(&nodes) { PAGE_VERSION } else { PAGE_BASE_VERSION },
version: page_version(&p.doc, &nodes),
id: p.id.clone(),
name: p.name.clone(),
camera: p.camera,
@@ -229,6 +252,7 @@ pub fn page_to_json_modes(p: &PageData, modes: &PageModes) -> String {
modes: p.doc.modes.clone(),
mode: p.doc.mode.clone(),
tokens: p.doc.tokens.clone(),
text_styles: p.doc.text_styles.clone(),
modes_by_collection: modes.iter().filter(|(k, _)| *k != PAGE_TEMPLATE_KEY).map(|(k, v)| (k.clone(), v.clone())).collect(),
annotations: p.doc.annotations.clone(),
annotation_template: modes.get(PAGE_TEMPLATE_KEY).cloned(),
@@ -263,7 +287,7 @@ fn is_current_page(s: &str) -> bool {
version: Option<u32>,
}
serde_json::from_str::<Head>(s)
.is_ok_and(|h| h.format.as_deref() == Some(PAGE_FORMAT) && matches!(h.version, Some(PAGE_VERSION | PAGE_BASE_VERSION)))
.is_ok_and(|h| h.format.as_deref() == Some(PAGE_FORMAT) && matches!(h.version, Some(PAGE_VERSION | PAGE_V5 | PAGE_V4 | PAGE_BASE_VERSION)))
}
fn page_from_file(mut f: PageFile) -> Result<(PageData, PageModes), FormatError> {
@@ -281,6 +305,7 @@ fn page_from_file(mut f: PageFile) -> Result<(PageData, PageModes), FormatError>
modes: f.modes,
mode: f.mode,
tokens: f.tokens,
text_styles: f.text_styles,
annotations: f.annotations,
};
doc.validate().map_err(FormatError::Invalid)?;
@@ -478,6 +503,23 @@ mod tests {
assert!(page_to_json(&p).contains("\"version\":3"), "an older app can still read it");
p.doc.nodes.get_mut(&NodeId(1)).unwrap().text_style.font = Some("Varela Round".into());
assert!(page_to_json(&p).contains("\"version\":4"));
p.doc.nodes.get_mut(&NodeId(1)).unwrap().a11y = Some(crate::model::A11y { decorative: true, ..Default::default() });
let json = page_to_json(&p);
assert!(json.contains("\"version\":5"), "accessibility needs v5");
assert!(page_from_json(&json).unwrap().doc.get(NodeId(1)).unwrap().a11y.is_some());
}
#[test]
fn text_styles_make_a_v6_page_and_roundtrip() {
let plain = r#"{"format":"paper-rs-page","version":4,"id":"a","name":"P","next_id":2,"root":[1],"nodes":[
{"id":1,"name":"T","type":"rect","rect":{"x":0,"y":0,"w":5,"h":5}}]}"#;
let mut p = page_from_json(plain).unwrap();
assert!(page_to_json(&p).contains("\"version\":3"), "no styles: old format");
let st = TypeStyle { font_size: 32.0, font: None, bold: true, italic: false, line_height: Some(1.2), letter_spacing: 0.0 };
p.doc.text_styles.insert("Heading / L".into(), st.clone());
let json = page_to_json(&p);
assert!(json.contains("\"version\":6"));
assert_eq!(page_from_json(&json).unwrap().doc.text_styles["Heading / L"], st);
}
#[test]
+12 -6
View File
@@ -1952,6 +1952,10 @@ fn layer_matches(doc: &crate::model::Document, id: NodeId, needle: &str) -> bool
/// Room a layer row's eye or lock button takes while shown, and how far it slides in from.
const TRAIL_W: f32 = 20.0;
const TRAIL_SLIDE: f32 = 12.0;
const LAYER_DEPTH_INDENT: f32 = 11.0;
const LAYER_DISCLOSURE_LANE: f32 = 16.0;
const LAYER_ICON_SIZE: f32 = 14.0;
const LAYER_ICON_LANE: f32 = 18.0;
fn layer_row(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, id: NodeId, depth: usize) {
let Some(n) = ed.doc.get(id).cloned() else {
@@ -1982,7 +1986,7 @@ fn layer_row(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, id: NodeId, depth:
}
let fg = if n.visible { p.text } else { p.secondary };
let fg2 = p.secondary;
let mut x = row.left() + 4.0 + depth as f32 * 14.0;
let mut x = row.left() + 4.0 + depth as f32 * LAYER_DEPTH_INDENT;
let cy = row.center().y;
let sq = |x: f32, s: f32| {
egui::Rect::from_center_size(egui::pos2(x + s / 2.0, cy), egui::vec2(s, s))
@@ -1991,7 +1995,7 @@ fn layer_row(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, id: NodeId, depth:
// Disclosure chevron.
if !n.children.is_empty() {
let open = !st.collapsed.contains(&id);
let r = sq(x, 14.0);
let r = sq(x, LAYER_ICON_SIZE);
let cr = ui.interact(
r.expand(2.0),
ui.id().with(("disclose", id)),
@@ -2013,15 +2017,15 @@ fn layer_row(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, id: NodeId, depth:
}
}
}
x += 16.0;
theme::paint_icon(ui.painter(), sq(x, 14.0), node_icon(&n), theme::accent());
x += LAYER_DISCLOSURE_LANE;
theme::paint_icon(ui.painter(), sq(x, LAYER_ICON_SIZE), node_icon(&n), theme::accent());
if n.open_comments() > 0 {
let at = egui::pos2(x + 14.0, cy - 6.0);
let at = egui::pos2(x + LAYER_ICON_SIZE, cy - 6.0);
ui.painter().circle_filled(at, 4.5, Color32::WHITE);
ui.painter()
.circle_filled(at, 3.0, Color32::from_rgb(250, 189, 47));
}
x += 20.0;
x += LAYER_ICON_LANE;
// Trailing eye / lock buttons: always shown when hidden or locked, otherwise on hover. A
// button takes its room only while shown, sliding in from the row's edge, so names use the
@@ -2102,6 +2106,8 @@ fn layer_row(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, id: NodeId, depth:
clip.galley(egui::pos2(x, cy - galley.size().y / 2.0), galley, fg);
}
let resp = resp.on_hover_text(&n.name);
// Right-click acts on the row's layer: it joins the selection first unless already in it.
if resp.secondary_clicked() && !selected {
ed.end_text_edit();
+6 -1
View File
@@ -172,7 +172,12 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
},
p.secondary,
);
if resp.on_hover_text("Show all pages").clicked() {
let page_info = format!(
"{} pages · current: {} · click to switch or manage pages",
ed.pages.len(),
ed.page_name()
);
if resp.on_hover_text(page_info).clicked() {
st.pages.collapsed = !st.pages.collapsed;
st.pages.query.clear();
}
+57
View File
@@ -131,6 +131,33 @@ fn per_state_components_merge_into_one_with_an_enum() {
assert_eq!(doc.get(third).unwrap().prop_values["Active"], "Pedidos");
}
#[test]
fn a_size_axis_differing_in_padding_and_font_size_merges() {
let mut h = host();
let mut mains = Vec::new();
for (name, pad, font) in [("Button / Size=S", 8, 12), ("Button / Size=M", 16, 16)] {
let m = call(&mut h, "create_artboard", json!({"name": name, "styles": {"width": "120px", "height": "80px", "display": "flex", "padding": format!("{pad}px")}}));
call(&mut h, "write_html", json!({"targetNodeId": m["id"], "html": format!("<span layer-name=\"Label\" style=\"font-size:{font}px\">Go</span>")}));
mains.push(call(&mut h, "create_component", json!({"nodeId": m["id"]}))["id"].clone());
}
let r = call(&mut h, "merge_variants", json!({"componentIds": mains}));
assert_eq!(r["axes"][0]["options"], json!(["S", "M"]), "{r}");
assert!(r.get("warnings").is_none(), "{r}");
}
#[test]
fn an_axis_error_gives_the_reason_a_variant_was_left_out() {
let mut h = host();
let mut mains = Vec::new();
for (name, justify) in [("Box / Size=S", "flex-start"), ("Box / Size=M", "flex-end")] {
let m = call(&mut h, "create_artboard", json!({"name": name, "styles": {"width": "360px", "height": "60px", "display": "flex", "justifyContent": justify}}));
call(&mut h, "write_html", json!({"targetNodeId": m["id"], "html": "<span layer-name=\"Text\">Oi</span>"}));
mains.push(call(&mut h, "create_component", json!({"nodeId": m["id"]}))["id"].clone());
}
let e = exec::call(&mut h, "merge_variants", &json!({"componentIds": mains})).unwrap_err();
assert!(e.contains("justifyContent"), "{e}");
}
#[test]
fn a_variant_with_another_layout_is_left_out_of_a_merge() {
let mut h = host();
@@ -257,3 +284,33 @@ fn merging_keeps_a_rows_own_nested_values() {
assert_eq!(fill_hex(&h, named(&h, row, "Check")), "#0f5132", "the row's checked box stays checked");
assert!(h.ed.doc.get(named(&h, row, "Mark")).unwrap().visible, "with its mark");
}
#[test]
fn a_two_axis_variant_set_combines_axes_and_keeps_overrides_on_swap() {
let mut h = host();
let mut mains = Vec::new();
for (name, bg, radius) in [
("Btn / Size=M, State=default", "#eeeeee", 8),
("Btn / Size=M, State=hover", "#cccccc", 8),
("Btn / Size=L, State=default", "#eeeeee", 16),
("Btn / Size=L, State=hover", "#cccccc", 16),
] {
let m = call(&mut h, "create_artboard", json!({"name": name, "styles": {"width": "100px", "height": "30px", "display": "flex", "background": bg, "borderRadius": format!("{radius}px")}}));
call(&mut h, "write_html", json!({"targetNodeId": m["id"], "html": "<span layer-name=\"Label\" style=\"font-size:12px\">Go</span>"}));
mains.push(call(&mut h, "create_component", json!({"nodeId": m["id"]}))["id"].clone());
}
let screen = call(&mut h, "create_artboard", json!({"name": "S", "styles": {"width": "400px", "height": "60px", "display": "flex"}}));
let corner = num(&call(&mut h, "create_instance", json!({"componentId": mains[3], "parentId": screen["id"]}))["id"]);
let r = call(&mut h, "merge_variants", json!({"componentIds": mains}));
assert_eq!(r["axes"], json!([{"name": "Size", "options": ["M", "L"]}, {"name": "State", "options": ["default", "hover"]}]), "{r}");
let n = h.ed.doc.get(corner).unwrap();
assert_eq!((n.prop_values["Size"].as_str(), n.prop_values["State"].as_str()), ("L", "hover"));
assert_eq!((fill_hex(&h, corner), n.style.corner_radius), ("#cccccc".into(), 16.0));
// Another axis value changes only its own look, and a colour override survives.
let key = format!("{}:{}", &h.ed.pages[0].id[..6], corner.0);
call(&mut h, "set_instance_props", json!({"nodeIds": [key], "values": {"Size": "M"}}));
let n = h.ed.doc.get(corner).unwrap();
assert_eq!((fill_hex(&h, corner), n.style.corner_radius), ("#cccccc".into(), 8.0));
call(&mut h, "set_instance_props", json!({"nodeIds": [key], "values": {"State": "default"}}));
assert_eq!(fill_hex(&h, corner), "#eeeeee");
}
+59
View File
@@ -443,3 +443,62 @@ fn a_component_loop_across_pages_is_refused() {
// The same component twice, one inside the other, is not a loop.
call(&mut h, "create_instance", json!({"pageId": b, "componentId": pill, "parentId": boxc["id"]}));
}
#[test]
fn moving_layers_to_another_page_remaps_links_and_clears_what_cannot_follow() {
use papercraft::geom::{Rect, V2, v2};
use papercraft::model::{Binding, Head, Node, NodeKind, Route};
let mut h = host();
let a = call(&mut h, "get_basic_info", json!({}))["pages"][0]["id"].clone();
let b = call(&mut h, "create_page", json!({"name": "B"}))["pageId"].clone();
let mk = |h: &mut exec::SessionHost, name: &str| call(h, "create_artboard", json!({"pageId": a, "name": name,
"styles": {"width": "50px", "height": "50px"}}));
let (one, two, stay) = (mk(&mut h, "One"), mk(&mut h, "Two"), mk(&mut h, "Stay"));
// One -> Two stays valid; One -> Stay and Stay -> Two cannot cross pages.
call(&mut h, "set_prototype_links", json!({"links": [{"nodeId": one["id"], "to": two["id"]}, {"nodeId": stay["id"], "to": two["id"]}]}));
let bind = |n: &Value| Binding { node: num(n), fixed: V2::ZERO, inside: false };
let conn = |h: &mut exec::SessionHost, s: &Value, e: &Value| {
let kind = NodeKind::Connector { points: vec![V2::ZERO, v2(10.0, 10.0)], start: Some(bind(s)), end: Some(bind(e)),
route: Route::default(), start_head: Head::None, end_head: Head::Arrow };
h.ed.add_node(Node::new(NodeId(0), kind, Rect::new(0.0, 0.0, 10.0, 10.0)), None)
};
let inner = conn(&mut h, &one["id"], &two["id"]);
let outer = conn(&mut h, &two["id"], &stay["id"]);
let res = call(&mut h, "move_nodes", json!({"moves": [
{"nodeId": one["id"], "parentId": "root", "toPageId": b},
{"nodeId": two["id"], "parentId": "root", "toPageId": b}]}));
assert!(res["warnings"].as_array().unwrap().len() >= 2, "{res}");
let (da, db) = (h.ed.page_doc(0), h.ed.page_doc(1));
let find = |d: &papercraft::model::Document, n: &str| d.nodes.values().find(|x| x.name == n).unwrap().clone();
let (nb_one, nb_two) = (find(db, "One"), find(db, "Two"));
assert_eq!(nb_one.link, None, "One was moved before Two, so its link had to be cleared");
assert_eq!(nb_two.link, None);
assert_eq!(find(da, "Stay").link, None, "a link to a moved layer is cleared");
for d in [da, db] {
for n in d.nodes.values() {
if let NodeKind::Connector { start, end, .. } = &n.kind {
for x in [start, end].into_iter().flatten() {
assert!(d.get(x.node).is_some(), "no dangling connector end: {n:?}");
}
}
}
}
let _ = (inner, outer);
}
#[test]
fn moving_a_linked_subtree_in_one_call_keeps_internal_links() {
let mut h = host();
let a = call(&mut h, "get_basic_info", json!({}))["pages"][0]["id"].clone();
let b = call(&mut h, "create_page", json!({"name": "B"}))["pageId"].clone();
let frame = call(&mut h, "create_artboard", json!({"pageId": a, "name": "Wrap", "styles": {"width": "200px", "height": "100px", "display": "flex"}}));
call(&mut h, "write_html", json!({"pageId": a, "targetNodeId": frame["id"], "html": "<div layer-name=\"Target\" style=\"width:40px;height:40px\"></div><div layer-name=\"Btn\" style=\"width:40px;height:40px\"></div>"}));
let doc = h.ed.page_doc(0);
let find = |d: &papercraft::model::Document, n: &str| d.nodes.values().find(|x| x.name == n).unwrap().id;
let (t, btn) = (find(doc, "Target"), find(doc, "Btn"));
h.ed.with_page(0, |ed| ed.edit("link", |tx| { tx.node_mut(btn).unwrap().link = Some(t); }));
call(&mut h, "move_nodes", json!({"moves": [{"nodeId": frame["id"], "parentId": "root", "toPageId": b}]}));
let db = h.ed.page_doc(1);
let (nt, nbtn) = (find(db, "Target"), find(db, "Btn"));
assert_eq!(db.get(nbtn).unwrap().link, Some(nt), "the link follows the copy");
}
+237
View File
@@ -32,6 +32,10 @@ fn call(h: &mut exec::SessionHost, name: &str, args: Value) -> Value {
}
}
fn call_err(h: &mut exec::SessionHost, name: &str, args: Value) -> String {
exec::call(h, name, &args).err().unwrap_or_else(|| panic!("{name} should fail"))
}
fn num(id: &Value) -> papercraft::model::NodeId {
papercraft::model::NodeId(
id.as_str()
@@ -1959,6 +1963,66 @@ fn site_scss_and_tailwind_write_sources_a_package_json_and_next_steps() {
}
}
/// Exports one artboard filled with `markup` as html and returns its page.
fn site_page_of(markup: &str) -> String {
let mut h = host();
let board = call(&mut h, "create_artboard", json!({"name": "Home", "styles": {"width": "1200px", "height": "900px"}}));
call(&mut h, "write_html", json!({"targetNodeId": board["id"], "mode": "insert-children", "html": markup}));
let dir = site_out();
call(&mut h, "export_site", json!({"path": dir}));
let files = read_site(&dir);
let page = format!("{}
{}", site_text(&files, "index.html"), site_text(&files, &stylesheet(&files)));
std::fs::remove_dir_all(&dir).unwrap();
page
}
#[test]
fn site_a_frame_with_free_layout_is_positioned_for_its_absolute_children() {
let page = site_page_of("<div layer-name=\"Floating badges\" style=\"position:relative;width:400px;height:80px\"><p layer-name=\"Badge one\" style=\"position:absolute;left:0;top:0;font-size:14px\">New</p><p layer-name=\"Badge two\" style=\"position:absolute;left:120px;top:30px;font-size:14px\">Beta</p></div>");
let rule = page.split('}').find(|r| r.contains(".floating-badges")).unwrap_or_else(|| panic!("{page}"));
assert!(rule.contains("position: relative"), "{page}");
}
#[test]
fn site_headings_of_identical_siblings_share_a_level_and_prices_are_not_headings() {
let q = |t: &str| format!("<div style=\"display:flex;flex-direction:column\"><p style=\"font-size:20px;font-weight:700\">{t}</p><p style=\"font-size:16px\">An answer that reads like a sentence of copy.</p></div>");
let page = site_page_of(&format!(
"<div><p style=\"font-size:48px;font-weight:700\">Title</p></div><div><p style=\"font-size:40px\">Questions</p></div>{}{}{}{}<p style=\"font-size:32px\">$29</p><p style=\"font-size:32px\">R$ 49/mês</p>",
q("One?"), q("Two?"), q("Three?"), q("Four?")
));
for t in ["One?", "Two?", "Three?", "Four?"] {
assert!(page.contains(&format!(">{t}</h3>")), "{t} in {page}");
}
assert!(!page.contains(">$29</h") && !page.contains(">R$ 49/mês</h"), "{page}");
}
#[test]
fn site_a_landmark_is_not_inferred_inside_the_same_landmark() {
let page = site_page_of("<div layer-name=\"Nav\" style=\"display:flex\"><p layer-name=\"Nav home\" style=\"font-size:14px\">Home</p></div><div layer-name=\"Footer\" style=\"display:flex\"><div layer-name=\"Footer links\" style=\"display:flex\"><p style=\"font-size:14px\">Docs</p></div></div>");
assert_eq!(page.matches("<nav").count(), 1, "{page}");
assert_eq!(page.matches("<footer").count(), 1, "{page}");
}
#[test]
fn site_tailwind_packages_are_pinned_to_an_exact_version_in_every_framework() {
let (mut h, _) = two_frame_site();
for (framework, render) in [("html", "static"), ("react", "static"), ("react", "spa"), ("vue", "static"), ("vue", "spa"), ("angular", "static"), ("astro", "static")] {
let dir = site_out();
call(&mut h, "export_site", json!({"path": dir, "framework": framework, "render": render, "styling": "tailwind"}));
let pkg: Value = serde_json::from_str(&site_text(&read_site(&dir), "package.json")).unwrap();
let mut seen = 0;
for section in ["dependencies", "devDependencies"] {
for (name, ver) in pkg[section].as_object().into_iter().flatten().filter(|(n, _)| n.contains("tailwindcss")) {
seen += 1;
assert!(ver.as_str().is_some_and(|v| v.chars().all(|c| c.is_ascii_digit() || c == '.')), "{framework} {render}: {name} {ver}");
}
}
assert!(seen >= 2, "{framework} {render}: {pkg}");
std::fs::remove_dir_all(&dir).unwrap();
}
}
/// Exports "Headline" bound to a string with `pt` as its pt-BR text; returns the stylesheet and
/// the font files `(path, bytes)` of the export.
fn site_with_pt_text(pt: &str) -> (String, Vec<(String, usize)>) {
@@ -2316,3 +2380,176 @@ fn site_host_files_cache_hashed_assets_and_fall_back_for_single_page_apps() {
std::fs::remove_dir_all(&dir).unwrap();
}
}
/// "Home" at 1440 and "Home @mobile" at 390, plus a lone "Orphan @x"; the phone has no Aside.
fn breakpoint_site(extra: Value) -> (std::collections::BTreeMap<String, Vec<u8>>, Value) {
breakpoint_site_of(&[
("Home", "1440px", "0px", "<p layer-name=\"Title\" style=\"font-size:20px\">Hi</p><p layer-name=\"Aside\" style=\"font-size:12px\">Side</p>"),
("Home @mobile", "390px", "1600px", "<p layer-name=\"Title\" style=\"font-size:14px\">Hi</p>"),
("Orphan @x", "390px", "2200px", "<p>Lost</p>"),
], extra)
}
fn breakpoint_site_of(frames: &[(&str, &str, &str, &str)], extra: Value) -> (std::collections::BTreeMap<String, Vec<u8>>, Value) {
let mut h = host();
for &(name, width, left, html) in frames {
let ab = call(&mut h, "create_artboard", json!({"name": name, "styles": {"width": width, "height": "600px", "left": left}}));
call(&mut h, "write_html", json!({"targetNodeId": ab["id"], "mode": "insert-children", "html": html}));
}
let dir = site_out();
let mut args = json!({"path": dir});
args.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
let r = call(&mut h, "export_site", args);
let files = read_site(&dir);
std::fs::remove_dir_all(&dir).unwrap();
(files, r)
}
#[test]
fn site_frames_named_at_something_join_their_page_as_media_queries() {
let (files, r) = breakpoint_site(json!({}));
let pages: Vec<_> = files.keys().filter(|k| k.ends_with(".html")).collect();
assert_eq!(pages, ["index.html", "orphan-x.html"], "{pages:?}");
let css = site_text(&files, &stylesheet(&files));
assert!(css.contains("@media (max-width: 390px) {\n :root .title {\n font-size: 14px;"), "{css}");
assert!(css.contains(":root .aside {\n display: none;"), "{css}");
let warnings = r["warnings"].to_string();
assert!(warnings.contains("`Orphan @x`") && warnings.contains("exported as its own page"), "{warnings}");
assert!(warnings.contains("hidden at 390px and below"), "{warnings}");
}
#[test]
fn a_phone_frame_matches_layers_by_name_when_the_desktop_wraps_them_in_rows() {
let card = |n: u8, size: u8| format!("<p layer-name=\"Feature {n}\" style=\"font-size:{size}px\">F{n}</p>");
let desktop = format!("<div layer-name=\"Row 1\">{}{}</div><div layer-name=\"Row 2\">{}</div>", card(1, 20), card(2, 20), card(3, 20));
let phone = format!("{}{}{}", card(1, 14), card(2, 14), card(3, 14));
let (files, r) = breakpoint_site_of(&[("Home", "1440px", "0px", &desktop), ("Home @mobile", "390px", "1600px", &phone)], json!({}));
let css = site_text(&files, &stylesheet(&files));
assert!(css.contains(":root .feature-1 {
font-size: 14px;"), "cards take their phone style: {css}");
assert!(css.contains("display: contents;") && !css.contains("display: none;"), "rows dissolve instead of hiding the cards: {css}");
let warnings = r["warnings"].to_string();
assert!(!warnings.contains("only in this frame") && !warnings.contains("hidden at"), "{warnings}");
}
#[test]
fn breakpoint_warnings_name_the_layers() {
let (_, r) = breakpoint_site(json!({}));
assert!(r["warnings"].to_string().contains("(`Aside`)"), "{r}");
}
#[test]
fn site_breakpoints_work_in_tailwind_and_are_skipped_with_a_warning_in_css_in_js() {
let (files, _) = breakpoint_site(json!({"styling": "tailwind"}));
let all: String = files.iter().filter(|(k, _)| k.ends_with(".css") || k.ends_with(".html")).map(|(_, v)| String::from_utf8_lossy(v).into_owned()).collect();
assert!(all.contains("@media (max-width: 390px)") && all.contains("font-size: 14px") && all.contains("text-xl"), "the phone size wins under 390, the desktop utility stays: {all}");
let (_, r) = breakpoint_site(json!({"framework": "react", "styling": "emotion"}));
assert!(r["warnings"].to_string().contains("CSS-in-JS"), "{r}");
}
#[test]
fn a_gradient_stop_bound_to_a_colour_variable_follows_the_mode_and_exports_as_var() {
let mut h = host();
let ab = call(&mut h, "create_artboard", json!({"name": "Hero", "styles": {"width": "200px", "height": "100px", "background": "linear-gradient(180deg, #ff0000, #0000ff)"}}));
call(&mut h, "set_variables", json!({"mode": "Light", "variables": {"brand": "#112233"}}));
call(&mut h, "set_variables", json!({"mode": "Dark", "variables": {"brand": "#aabbcc"}}));
call(&mut h, "set_variable_mode", json!({"mode": "Light"}));
let id = num(&ab["id"]);
let e = exec::call(&mut h, "bind_variable", &json!({"nodeIds": [ab["id"]], "field": "gradient-stop-5", "variable": "brand"})).unwrap_err();
assert!(e.contains("does not apply"), "{e}");
call(&mut h, "bind_variable", json!({"nodeIds": [ab["id"]], "field": "gradient-stop-1", "variable": "brand"}));
let stop = |h: &exec::SessionHost| h.ed.doc.get(id).unwrap().style.fills[0].stops().unwrap()[1].color.to_hex();
assert_eq!(stop(&h), "#112233");
call(&mut h, "set_variable_mode", json!({"mode": "Dark"}));
assert_eq!(stop(&h), "#aabbcc");
let styles = call(&mut h, "get_computed_styles", json!({"nodeIds": [ab["id"]]}));
let bg = styles[ab["id"].as_str().unwrap()]["background"].as_str().unwrap().to_owned();
assert!(bg.contains("var(--brand"), "{bg}");
// Detaching keeps the current colour and exports it fixed again.
call(&mut h, "bind_variable", json!({"nodeIds": [ab["id"]], "field": "gradient-stop-1"}));
call(&mut h, "set_variable_mode", json!({"mode": "Light"}));
assert_eq!(stop(&h), "#aabbcc");
}
#[test]
fn a11y_annotations_are_set_read_linted_and_exported() {
let (mut h, _) = two_frame_site();
let key = |h: &exec::SessionHost, name: &str| {
let id = h.ed.doc.nodes.values().find(|n| n.name == name).unwrap().id;
exec::node_key(&h.ed, 0, id)
};
let (hero, head, go) = (key(&h, "Hero"), key(&h, "Headline"), key(&h, "Go"));
let lint = |h: &mut exec::SessionHost| call(h, "lint_design", json!({}))["issues"].to_string();
assert!(lint(&mut h).contains("image-alt"), "a picture without alt text is flagged");
call(&mut h, "set_accessibility", json!({"items": [
{"nodeId": hero, "alt": "A bowl of fish and chips"},
{"nodeId": head, "role": "heading", "level": 3},
{"nodeId": go, "order": 0},
]}));
assert!(!lint(&mut h).contains("image-alt"));
let info = call(&mut h, "get_node_info", json!({"nodeId": head}));
assert_eq!(info["accessibility"], json!({"role": "heading", "level": 3}), "{info}");
// Only a frame placed by position (no auto layout) may reorder its children.
let board = h.ed.doc.nodes.values().find(|n| n.name == "Home").unwrap().id;
h.ed.doc.nodes.get_mut(&board).unwrap().layout = None;
let dir = site_out();
call(&mut h, "export_site", json!({"path": dir}));
let home = site_text(&read_site(&dir), "index.html");
assert!(home.contains("alt=\"A bowl of fish and chips\""), "{home}");
assert!(home.contains("<h3") && home.contains("Fish &amp; Chips"), "{home}");
assert!(home.find("href=\"pricing.html\"") < home.find("<h3"), "explicit order puts the link first: {home}");
std::fs::remove_dir_all(&dir).unwrap();
// Decorative replaces alt text; clearing removes the annotation (and the key from the node).
call(&mut h, "set_accessibility", json!({"items": [{"nodeId": hero, "alt": null, "decorative": true}]}));
assert!(!lint(&mut h).contains("image-alt"));
call(&mut h, "set_accessibility", json!({"items": [{"nodeId": hero, "clear": true}]}));
assert!(call(&mut h, "get_node_info", json!({"nodeId": hero})).get("accessibility").is_none());
for bad in [
json!({"nodeId": hero, "role": "banner"}),
json!({"nodeId": hero, "level": 7, "role": "heading"}),
json!({"nodeId": hero, "level": 2}),
json!({"nodeId": hero, "alt": "x", "decorative": true}),
] {
assert!(exec::call(&mut h, "set_accessibility", &json!({"items": [bad.clone()]})).is_err(), "{bad}");
}
}
#[test]
fn a_text_style_updates_every_layer_that_follows_it_in_one_undo_step() {
let mut h = host();
card(&mut h);
let id = |h: &exec::SessionHost, name: &str| h.ed.doc.nodes.values().find(|n| n.name == name).unwrap().id.0.to_string();
let size = |h: &exec::SessionHost, name: &str| h.ed.doc.nodes.values().find(|n| n.name == name).unwrap().kind.font_size().unwrap();
let title = id(&h, "Title");
let body_size = size(&h, "Body");
let r = call(&mut h, "set_text_style", json!({"action": "set", "name": "Heading / L", "fontSize": 40, "bold": true, "lineHeight": 1.2}));
assert_eq!(r["layersUpdated"], 0);
assert!(call_err(&mut h, "set_text_style", json!({"action": "set", "name": "Nope"})).contains("fontSize"));
assert!(call_err(&mut h, "set_text_style", json!({"action": "apply", "name": "Missing", "nodeIds": [title.clone()]})).contains("no text style"));
let r = call(&mut h, "set_text_style", json!({"action": "apply", "name": "Heading / L", "nodeIds": [title.clone()]}));
assert_eq!(r["layersChanged"], 1);
assert_eq!(size(&h, "Title"), 40.0);
assert_eq!(size(&h, "Body"), body_size, "only the applied layer follows");
assert!(h.ed.doc.nodes.values().find(|n| n.name == "Title").unwrap().text_style.bold);
assert_eq!(call(&mut h, "get_variables", json!({}))["textStyles"]["Heading / L"]["font_size"], 40.0);
let r = call(&mut h, "set_text_style", json!({"action": "set", "name": "Heading / L", "fontSize": 56}));
assert_eq!(r["layersUpdated"], 1);
assert_eq!(r["style"]["bold"], true, "fields not given are kept");
assert_eq!(size(&h, "Title"), 56.0);
call(&mut h, "undo", json!({}));
assert_eq!(size(&h, "Title"), 40.0);
call(&mut h, "redo", json!({}));
let r = call(&mut h, "set_text_style", json!({"action": "delete", "name": "Heading / L"}));
assert_eq!(r["layersDetached"], 1);
assert_eq!(size(&h, "Title"), 56.0, "a detached layer keeps its look");
assert!(h.ed.doc.text_styles.is_empty());
assert!(h.ed.doc.nodes.values().all(|n| n.text_style.style_ref.is_none()));
}