# Changelog All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). The document schema version is tracked separately from the release version; a schema change is always noted explicitly. ## [Unreleased] ### Security - Update `rmcp` from 3.1.0 to 3.9.0. This fixes RUSTSEC-2026-0457, RUSTSEC-2026-0461, and RUSTSEC-2026-0462. - Update `rusqlite` from 0.38 to 0.41, and its bundled SQLite through `libsqlite3-sys` 0.39.1. This fixes RUSTSEC-2026-0459. ## [1.13.0] — 2026-10-24 **Hue rotation and invert.** - Add `hueRotate` with finite `degrees` and `invert` with `amount` from zero to one. - Use the existing effect stack across CLI, HTTP, MCP, and reference interface controls. - Preserve alpha, stack order, undo, protected layers, version checks, and recovery. - Add EN, FR, and DE control labels and renderer controls for six targets. - Keep `schemaVersion` at 1. Version 1.12.0 preserves these effects and refuses their rendering. ## [1.12.0] — 2026-10-23 **Styled text, paragraph spacing, justified alignment, and modifier resizing.** The document schema stays at `schemaVersion` 1. - Styled text runs support exact font faces and retain unknown nested values during ordinary style updates. - Paragraph spacing adds finite, nonnegative pixels between explicit paragraphs. The default is zero. - Justified alignment expands regular spaces between words on eligible wrapped lines. Paragraph-final lines keep their natural width. - CLI, HTTP, MCP, presets, and the reference interface expose the new text properties. - Older consumers can ignore paragraph spacing and styled runs. Versions 1.0.0, 1.10.0, and 1.11.0 refuse justified alignment. - Shift preserves the resize ratio. Image corners preserve the ratio by default and resize freely with Shift. - Alt preserves the centre. Modifier changes update an active resize without pointer movement. - A resize uses one undo transaction. Text keeps its font sizes and raw runs. - Text and unsupported preview combinations keep the authoritative image while the resize outline moves. - Zero-size boxes, unsupported ancestor transforms, and parent-with-child selections omit canvas resize handles. - Tokio 1.53.3 fixes RUSTSEC-2026-0386. The dependency inventory includes the patched version. ## [1.11.0] — 2026-10-08 No 1.10.1 was released: no defect was outstanding after 1.10.0. **Gradient paints, canvas background images, and the Mantine reference interface.** The document schema stays at `schemaVersion` 1. Existing operation names and solid colour strings stay unchanged. - Linear and radial gradients apply to backgrounds, text, shape fills, strokes, and presets. - Gradient stops support colour alpha and separate stop alpha. Writes validate stops and gradient geometry. - Loads preserve unknown gradient kinds. Edits and rendering refuse their use. - Line strokes use a nonzero reference box. Markers use separate gradients on their existing geometry. - Canvas background images support `fill`, `contain`, and `cover`. The canvas colour appears below the image. - `updateCanvas` sets or clears the background image. Validation and asset pruning include its asset reference. - CLI paint inputs accept hexadecimal colours, inline JSON, and `@file.json`. - HTTP and MCP paint inputs accept existing strings and gradient objects. Colour slot values remain strings. - Gradient shadow colours and image shape fills remain typed refusals. - Builds 1.0 to 1.10 refuse gradient documents and can refuse journals with gradient operations. - Release 1.10.0 preserves canvas image metadata during normal loading. Its renderer ignores the image. - Recovery with 1.10.0 can discard canvas image metadata from newer journal entries. Use 1.11.0 for editing and recovery. - Undo followed by a new edit keeps the correct history branch. Cached snapshots retain imported asset metadata. - A refused journal append keeps existing snapshots available. Read-only opening does not change cache association files. - Per-snapshot associations recover a missing or corrupt cache index. Ambiguous branch snapshots cause a refusal. - SVG definitions encode unusual layer IDs. Existing safe IDs keep the same output. - Project creation validates paints through the shared operation layer before it writes project files. - The reference interface uses one React root and Mantine provider for controls, menus, dialogs, font controls, and login. - The existing document controller, operation queue, canvas pointer handling, and Rust preview remain connected. - Appearance supports Light, Dark, and System. System is the default. The browser stores the preference locally. - Appearance changes do not change document colours. New controls include English, French, and German translations. - Paint controls support solid colours and gradient JSON. Canvas image controls support asset selection, upload, fit, and removal. - Generated asset lists embed every browser file and permit only login dependencies before authentication. - Bundled notices include the locked runtime UI packages and icon font. - The Mantine layer tree supports hierarchy, keyboard navigation, filtering, and pointer collapse controls. - Upload controls use Mantine Dropzone and send each accepted file through the existing asset command once. - Editing controls include ancestor guards. A locked layer still permits its own unlock command. - Geometry edits update canvas handles immediately. The authoritative response rebuilds the inspector once and starts rendering first. **Inline document responses for edits.** These response fields are optional. Existing clients can keep their current requests. - A write can return the document it produced. `POST …/operations` and `POST …/operation-batches` take `?includeDocument=true`. The response then carries a `document` field, the same value `GET …/document` serves. A client that does not ask sees no change in the response. - A history read can bound its read. `GET …/history?tail=N` serves the newest `N` entries, up to 1000. `position` and `head` always describe the whole journal. The read without `tail` stays the whole journal. - Renders run on the blocking pool. A render, a write, an undo or a redo no longer holds an async worker while it rasterizes, journals, or writes the document. Previews and reads behind them keep moving. - Asset data URIs are cached per project. A render reads and encodes each asset file once, and the three renders one edit asks for share that work. A cache hit returns the same bytes an uncached build made. - The reference interface keeps its drag preview until the engine's own render of the new version arrives. The layer no longer snaps back to the older picture between the drop and the render, and a new drag can continue from the painted pixels. A refusal takes the painted pixels back at once. ## [1.10.0] — 2026-09-25 No 1.9.1 was released: no defect was outstanding after 1.9.0. The sweep ran on a local release build of the 1.9.0 tree on 2026-09-21. Every 1.9.0 capability probed clean on all three transports. **Paths, dashes and markers, the in-app update path, and a calmer interface.** `schemaVersion` stays 1. The `Operation` union does not grow. Every change is additive inside existing payloads. **A shape can be any silhouette.** A new path shape takes an SVG path string. The accepted commands are `M L H V C S A Z`, in upper or lower case, with one leading `M` and a closing `Z`. A path holds at most 30 commands and 2048 bytes. A path that breaks these rules is refused with a message that names the command and its position. The same string can clip any layer. A stroke can carry a dash pattern, a line cap and a line join. A line can carry a start and end marker from a fixed set: `arrow`, `circle` or `none`. - Presets carry the dash, cap and join fields. Markers belong to the line payload, so presets never carry them. - The oldest build that opens a document with a path shape or clip is 1.6.0. Builds 1.6.0 to 1.9.x keep the path as written, refuse to draw it, and refuse an update that touches it. **Unknown keys survive one level deeper.** A value this build does not know inside `clip`, `crop`, `stroke`, `shape` or an effect is now kept as written. An older build that edits the layer can no longer drop a field a newer build added there. **The tool tells you when a new version exists, and can install it.** - The first `serve` asks once whether to check for updates. The answer is stored in `config.toml` as `updateCheck = "off"` or `"notify"`. - A check sends one request to the release feed of this repository. It sends no identifiers and no document data. It runs at most once every 24 hours, and the result is cached in the workspace. - `assemblash upgrade --check` prints the local and the latest version. `assemblash upgrade` downloads the new version, checks it against the release `SHA256SUMS` file, replaces the binary, keeps the old one as `.old` until the next start, and restarts `serve`. A failed or interrupted download changes nothing and leaves no partial file. An install from a package manager gets advice instead of a swap. - The binary swap keeps executable permissions on Unix. - The editor shows the answer switch in Settings, and a banner that never blocks work. MCP mode never checks for updates. **The interface has room to breathe.** - A Settings dialog collects the canvas setup, the follow toggle, the font tools and the update switch. It opens from the toolbar. - The properties panel collapses into sections with clear headers. Spacing comes from one documented scale. Transitions run in 140 to 180 ms. - The layout fits a 13-inch screen. At 1280 by 800 no horizontal scrollbar appears, and panels collapse instead of squeezing. - The font selector lists installed families only. It searches, refuses a family that is not installed at the control, and offers the recorded weights of the chosen family. The empty state explains the install: one download, from the manifest built into the program, only on your click, into the workspace font store. - A project can be renamed and deleted from the editor. A project another process holds is never renamed or deleted. **The reference interface has three languages and clearer content entry.** - The interface supports English, French, and German. Language choice stays after reload. Document values and protocol text keep their original form. - The compact rail opens Text, Elements, Uploads, and Templates. Elements groups shapes, lines, and paths. Uploads accepts image and SVG files. - Selecting text opens Properties and shows its current font and sample. The font picker and Fonts panel show samples from installed font files. - A protected browser receives an HttpOnly session cookie after login. The cookie opens protected pages and API routes. Cross-origin writes fail. - Seven optional font families show bundled samples before installation. Viewing these samples starts no download. The Fonts panel accepts a short custom sample. **Every capability is reachable from both surfaces.** - Six new MCP tools: `insert_layer_tree`, `install_font_pack`, `remove_font_family`, `list_fonts`, `delete_project`, `rename_project`. The tool surface grows from 47 to 53 tools. - A write over plain HTTP returns `warnings` as the MCP tools do. The field is omitted when there is nothing to report. - The CLI gains `add-path` and `set --path --dash --cap --join --marker-start --marker-end`. **Under the hood.** - The relay cancels local work in flight when it switches to a running editor. The old bounded wait stays as a backstop. - A route conflict at server startup is a typed error, not a panic. - `scripts/agents_smoke.py` joins the release checklist. It speaks MCP over stdio and runs a two-writer stress test. ## [1.9.0] — 2026-09-18 **Interaction responsiveness, and MCP hosted by the editor.** No 1.8.1 was released: no defect was outstanding after 1.8.0. The sweep ran on the released windows-x86_64 artefact on 2026-09-16. Every 1.8.0 feature probed clean on all three transports. **The interface stops dropping rapid edits.** A fast human lost edits before: an action that arrived while another was in flight was discarded, with no message and no journal entry. Every action is queued now. The canvas echoes an edit at once, and the authoritative render reconciles it. **The editor hosts MCP.** Before, the editor and `assemblash mcp` were two processes, and each opened projects for itself. The first one to open a project locked the other out, so a person with the editor open could not let an agent work on the same project. Now the editor process serves MCP at `/mcp` over its own open projects. `assemblash mcp` sends its requests to a running editor. The editor shows an agent's edits without a reload, and a dialog in the editor shows how to connect an agent. `schemaVersion` stays **1** and the `Operation` union does not grow. The HTTP API gains two routes and the server gains the `/mcp` endpoint; nothing existing changes shape. **Oldest build that opens a 1.9.0 document: 1.0.** ### Added - **A serial action queue in the interface** (`ui/src/queue.ts`). Every action runs; none is dropped. An error in one action reports and lets the next one run. A newer edit that sets the same property of the same layer replaces an older one that has not left yet. Different properties never replace each other, and a delta — a drag, a nudge, a paste — never coalesces: each pointerup stays one journalled transaction. - **A local echo for transform edits.** The page applies a move, a resize or a rotation to its document copy at once, with the same arithmetic the drag preview already uses. This is not a second renderer. The engine's pixels stay the only truth. A refusal rolls the echo back and shows the error. - **The preview, presets and history fetches left the serial window.** An interaction now waits on the operation and the document read only. On the measurement fixture (1920 × 1080, 30 layers, loopback, released 1.8.0 binary) the blocking path falls from about 124 ms median to about 21 ms; the preview fetch was about 80 ms of the old total and now streams behind the queue, coalesced to the newest version. - **Latency instrumentation.** Each interaction records dispatch, wait, run and preview-settle timings. Read them from `window.__assemblashPerf`, or open the editor with `?perf` for a one-line overlay. The numbers above come from this counter's server-side equivalent, measured before the change. - **Tests.** `ui/queue.test.mjs` covers the queue contract without a browser: no drop, order, latest-wins, error isolation, observer safety. Two new editor journeys cover the same contract through the real interface: twenty rapid nudges produce twenty journal writes and no error, and same-property edits under load coalesce to the newest value while different properties never do. - **MCP at `/mcp` on every server** (Streamable HTTP). `assemblash serve` and the double-click launch serve it over the same state as the HTTP API: one registry of open projects, one lock per project, one writer. An agent and the editor edit one project with no `projectLocked`, in one journal, and the agent is recorded as an agent. When an agent's session ends, the editor's projects stay open. The access token applies to `/mcp` as to every route. On a loopback bind, `/mcp` also refuses a `Host` that is not a loopback name (DNS rebinding) and a browser `Origin` other than the server's own. The server prints the MCP URL on standard error. - **`assemblash mcp` relays to a running server.** On a workspace, it finds a loopback server of the same workspace and forwards the client's messages to that server's `/mcp`. It opens no project itself while the server runs. It checks about once a second: an agent that started first releases its projects when the person starts the editor, and moves back when the editor stops, in the same client session. With no server, it serves the workspace as before. `--project` does not change. - It uses a server only when the server confirms, through `GET /api/agent-access`, that it serves the same workspace. A `running.json` left by a stopped server, or copied by a synchronised folder, can name the server of another workspace on the same port. - It sends the workspace access token, also in the check, so a server that requires a token is found. - When the editor ends an idle session, the relay starts a new session and sends the request again. - Every request gets an answer: a request whose answer stream ends early, or that is open when the relay changes target, gets a JSON-RPC error. - A busy editor that misses one check is not left: the relay leaves only when the record is gone or three checks in a row fail. - **"Connect an AI agent" in the editor.** A toolbar button (and a button on the start screen) opens a dialog with copy-ready configuration: JSON for clients that start a command, TOML for Codex, and the URL. The configuration contains the full executable path and the workspace. The dialog opens by itself once, on the first launch of an empty workspace. - **`GET /api/agent-access`** returns `{ mcpUrl, executable, workspace, tokenRequired }` for that dialog. It is behind the access check and never contains the token. - **The editor follows other clients.** About every 1.5 s, while the page is visible and no action, drag or text edit is in progress, the editor reads the project list. When the open project has a newer version, it reads the document again through the action queue. A project that an agent creates appears in the project list. - **The interface queue, made safe with other clients.** - A newer edit replaces only the last waiting edit with the same key. Before, it could replace an older one behind an undo, and the undo then reverted a different change. - A refused edit restores its echo only while the document is still at the version of the snapshot. Otherwise the page reads the document again. Before, a refusal behind an accepted edit put an old version back, and every later edit was refused as a version conflict. A failure after the engine accepted an edit no longer rolls the edit back. - Opening a project is a queued action. Actions that were already waiting run on the project they were made for. - A refused preview (for example a missing font) is reported with its code. Refused preset and history reads are reported too. A skipped preview releases its image. - The page does not follow another client while the person types in a field, and it does not rebuild the open project list. - **A write reports what an agent cannot see.** A change to a text layer returns `warnings` with the same codes an export gives, so text that does not fit its box is reported when it is written, not only at export time. - **`textCoveredByLayer`, a new export warning.** A visible text layer that a solid sibling layer covers by more than half is reported. This is the mistake an agent cannot see in its own render. Boxes are compared upright, and only inside one parent, so a design with a shape behind its text says nothing. - **`set_layer_box`, a new MCP tool.** It sets a layer's position, size, and rotation in absolute values as **one** transaction that one undo reverts. Before, making a text box taller needed `move_layer` and `resize_layer`, which is two entries in the history. The `Operation` union does not change: the tool sends the existing operations in one batch. - **`export_document` no longer replaces a file silently.** A name that is already there is refused with `exportExists`, unless the call passes `overwrite`. The result says `replaced` when it did replace one. - **The editor shows connected agents.** The status bar says "1 AI agent connected" while an agent holds a session. New route `GET /api/agent-sessions` returns the count, behind the access check. - **Ctrl-C stops `serve` gracefully.** In-flight requests finish, every project is released, and the instance record is cleared, as when the page's button stops it. Before, the process died where it stood. - **A loopback server with no token refuses foreign web pages, on every route.** A `Host` that is not `localhost`, `127.0.0.1`, or `::1` gets `403 hostNotAllowed` (DNS rebinding). A request whose `Origin` is not the address it was sent to gets `403 originNotAllowed` (cross-site requests). Before, only `/mcp` checked this, and the rest of the API did not. A client that sends no `Origin` is not affected. A server with a token does not do these checks. New `allowed-hosts` in `config.toml` accepts the public name of a reverse proxy on the same computer; `/mcp` accepts the same names. - **An agent does not take the reclaimed-lock notice from the person.** On the editor's endpoint, `open_project` tells the agent that a stale lock was cleared, and the notice stays for the editor, which shows it once. - **The relay waits for running requests before it hands projects to the editor.** When the relay moves from serving the workspace itself to the editor, it waits (up to 30 s) until every request that still holds a project has ended, so the editor does not meet `projectLocked`. - **Tests.** `crates/assemblash-mcp/tests/hosted.rs` runs a bound server with `/mcp` mounted and a client written from the specification: interleaved agent and person edits in one journal, a stale version, a session that ends, a foreign `Host` and `Origin`, the token, the interface routes, shutdown with an open event stream, and the agent-access route. `crates/assemblash-mcp/tests/relay.rs` runs the real binary twice: the relay against a server that holds the project open, and an agent that started first, then the editor, then the editor stopped, a record that names another workspace, an idle session that the editor ends, and an editor that requires a token. Editor journeys cover the dialog, following an agent's edit, a refusal behind an accepted edit, a refused preview, and typing while an agent edits, and actions that wait while the person opens another project; `ui/agents.test.mjs` covers the configuration text, and `ui/queue.test.mjs` covers coalescing behind an undo. `crates/assemblash-server/tests/site.rs` covers the `Host` and `Origin` checks over a real socket, `allowed-hosts`, and a server with a token. `crates/assemblash-server/tests/stop.rs` covers the stop handle that Ctrl-C uses. The MCP write tests cover warnings on a write, one-transaction box changes, and the export refusal; the renderer covers `textCoveredByLayer`; a journey covers the connected-agent chip. ### Notes - **The defect was a silent drop, not a version conflict.** The original diagnosis guessed a 409. Reading the landed code showed the interface dropped a concurrent action before any request was sent, so a fast human could not reach the version check at all. The check still protects real cross-client races: twenty concurrent same-version batches against a local server answer one 200 and nineteen `409 versionConflict`, and the journal gains exactly one entry. - `queue.js` joins the interface assets the binary embeds (`ui.rs`) and the journey fixture's allowlist. `agents.js` joins both too. - **`projectLocked` no longer tells the reader to delete the lock file.** Agents act on error text, and a lock deleted while its process is alive gives two writers for one project. On HTTP and MCP the message now says that the editor or another agent has the project open. The command line names `assemblash unlock` for a process that is gone. The code and the `pid` detail do not change. - The MCP `instructions` tell an agent that a person may have the project open in the editor, that an `expectedVersion` conflict is then normal, and never to delete a lock file. - A loopback `assemblash serve` now records itself in the workspace (`running.json`), as the double-click launch always did. The relay needs the record. A double-click on a workspace that such a server already serves now opens that server instead of starting a second one. - The rmcp feature `transport-streamable-http-server` adds one crate to the dependency graph: `sse-stream` (MIT OR Apache-2.0). - **Security advisory RUSTSEC-2026-0285:** `rustls` moves from 0.23.43 to 0.23.45, and `rustls-webpki` to 0.103.15. The font installer reaches rustls through `ureq`. Render output does not change. - The CLI tests no longer read `ASSEMBLASH_FONT_STORE` from the environment of the person who runs them, so a personal font store cannot change their result. - The server check in `running.json` now accepts only a loopback address, and it sends the workspace token when there is one. - `ASSEMBLASH_MCP_SESSION_IDLE_SECS` sets how long an idle MCP session lives on the server, in seconds. The default is five minutes. - The MCP tool count moves from 46 to 47 with `set_layer_box`. - The project list in the editor refreshes a label when a project's layer count changes, and typing in the project search no longer stops the page from following another client. - README: the full executable path in the MCP configuration, the Codex configuration, the macOS first launch without a terminal, and the hosted endpoint. The public skill tells agents to prefer the running editor and never to delete a lock file. - Engine behaviour is unchanged. The full Rust workspace suite passes with every determinism-gate golden in place, and export bytes do not change. The CLI, HTTP and MCP surfaces only gain: the `/mcp` endpoint, the `agent-access` route, and the relay. No existing tool, route or argument changes shape. ## [1.8.0] — 2026-09-12 **Clipping and cropping.** A layer can mask itself to a rectangle or an ellipse, an image layer can show one rectangle of its source, and any layer can mirror horizontally or vertically. Circle avatars, rounded screenshots, and crops that are rectangles rather than fit modes. `schemaVersion` stays **1** and the `Operation` union does not grow: all four values are ordinary `UpdateLayer` fields. **Oldest build that opens a 1.8.0 document: 1.0** — builds 1.0 through 1.7 degrade silently through the `Extras` capture map. They keep `clip`, `crop`, `flipHorizontal` and `flipVertical` exactly as written and render the layer unclipped, uncropped and unflipped. ### Added - **`Layer.clip`: `{"shape":"rect","cornerRadius":N}` or `{"shape":"ellipse"}`.** The mask is the layer's transform box, so the clip carries no coordinates and `move`, `resize` and `rotate` mean for a mask exactly what they mean for the layer. A radius larger than the box clamps to a stadium, the same way a shape rect's does. A clip written by a newer build is preserved as written and refused when an update touches it. - **`ImageLayer.crop`: `{ x, y, width, height }` in source pixels.** It composes with `fit`: the rectangle is placed into the box as if it were the whole image, so `fill` stretches it and `contain` and `cover` work from its shape. Out-of-bounds rectangles clamp to the source. A rectangle that shares no area with the source is refused typed. A degenerate rectangle is refused by validation. - **`Transform.flipHorizontal` / `flipVertical` (default false).** A mirror about the box centre, composed with the rotation — never a negative size, which validation forbids. Mirroring text mirrors its glyphs. - **The CLI, MCP and interface carry all four.** `set --clip-rect`, `--clip-radius`, `--clip-ellipse`, `--no-clip`, `--crop X,Y,W,H`, `--no-crop`, `--flip-h`, `--flip-v`; MCP `update_layer` arguments `clip`, `clearClip`, `crop`, `clearCrop`, `flipHorizontal`, `flipVertical`; one inspector row per feature. HTTP needs nothing: `POST /operations` deserialises the union. - **An imported image now records its pixel size.** A crop is a rectangle in the source's own pixels, so the header of a PNG, JPEG, GIF or WebP is read at import and its size written to the asset record. A format this build cannot measure is imported with an unknown size, and a crop of it is refused by name rather than guessed at. An asset's `width` and `height` are pre-existing document fields, so this changes no schema. ### Fixed - **A clip and a shadow on one layer kept the shadow.** The mask is applied inside the effect stack. Measured, not assumed: with the mask beside the filter, SVG applies the clip after the effect and cuts the shadow off at the boundary — 0 ink pixels outside the box against 4390 in the shipped order. - **A rotated clipped layer is cut to the box its own coordinates name.** SVG measures a `clip-path` in the referencing element's user space, so the mask carries the inverse of the layer's rotation and stays where the document says it is — 2600 ink pixels outside the box become 0 with the inverse. ### Notes - Four new determinism gates: `clip-rounded` (including a rotated clipped layer and a clipped layer carrying a drop shadow), `clip-ellipse`, `crop` (square, letterbox, a rectangle clamped to the source, and a scale-2 export), and `flip`. Every golden from 1.7.0 is byte-identical. - The crop is emitted as a nested `` whose viewBox is the source rectangle and whose viewport is the rectangle the fit places it in. The viewBox maps but does not crop, so the placement is computed rather than left to `preserveAspectRatio`. - A layer with no clip, no crop and no flip emits the same SVG it emitted in 1.7.0, byte for byte. ## [1.7.1] — 2026-09-12 **Bugfix release: `font install` now delivers a bold face.** This release contains only the defect registered against 1.7.0. No new capability. No `schemaVersion` change. No change to the `Operation` union or any operation shape. No new surface. No dependency bump. The gate goldens do not change. ### Fixed - **A store built only through `font install` held no bold face. Every `fontWeight: 700` layer then failed with a typed `MissingFont`.** The bundled manifest pinned Noto Sans, Noto Serif and Noto Sans Mono as variable fonts. The font database sees only the 400 default face of a variable font. The 1.7.0 weight field then did not work for install-only users. The refusal was honest. It was never a substitution. The manifest now also registers a bold (700) face for each of the three `default`-pack families. Each new entry pins sha256 to a commit of the notofonts distribution. An install of a family now installs every face registered under its name. `font install --list` and `GET /api/fonts/catalogue` still answer once per family. The byte totals now include the bold faces. Rendering does not change. The engine, the goldens, and the typed refusal for a truly absent face stay the same. ## [1.7.0] — 2026-09-12 **Typographic control, plus the canonical capability surface.** Bold headlines, tracked captions, outlined display text, and text that sits where the box says it should — and one capability listing that every surface reads, so the DEF-24 drift class (render it but don't advertise it) cannot recur. `schemaVersion` stays **1** and the `Operation` union does not grow: all five typography fields are ordinary `UpdateLayer` and create-payload fields. **Oldest build that opens a 1.7.0 document: 1.0** — builds 1.0 through 1.6 degrade silently through the `Extras` capture map, rendering the regular face with no spacing, stroke or vertical alignment (per §1.3, that accepted price of staying additive is named here). ### Added - **`TextLayer.fontWeight` (100–900, default 400) and `fontStyle` (`normal` | `italic`).** Resolved against the caller's font set by *exact face*: a family whose bold is not installed is refused with a typed `MissingFont` naming family, weight and style — never a silent nearest match, because "looks roughly right" is how a wrong render gets trusted. Set everywhere a style can be set: `create`, `update`, presets, CLI `set`/`add-text`, MCP `add_text_layer`/`update_layer`, and a row in the interface. - **`TextLayer.letterSpacing` (px, default 0).** Part of measurement, not just of drawing: wrapping, `GET /api/projects/{id}/text-layout`, the overflow warnings and the export all add it per gap, so the measured line and the rendered line are the same line. - **`TextLayer.stroke` (`{ color, width }`), painted centred on the glyph outline with `paint-order="stroke"`** (D5, text half): the fill goes down first, so a stroke never eats the letter. A layer with `color: null` and a stroke is hollow display text. `update` accepts `null` to clear it, exactly like a shape's stroke. - **`TextLayer.verticalAlign` (`top` | `middle` | `bottom`, default `top`)** (D19). `top` is the behaviour every earlier build had — the baseline one ascent below the box top — so existing documents render pixel-identically. - **`TextLayer.color` is nullable.** `null` means no fill, the same bargain a shape's `fill` has; `update` clears it with `null`, and an absent key still means the pre-1.7 default of black. `null` serializes as `null`, never as an omitted key, so a hollow layer survives a round trip exactly. - **The canonical capability surface.** One listing, in core, fed by the same sources the renderer is bound to (`BlendMode::RENDERED` and the `Effect` enum), served three ways: CLI `assemblash styles --json`, HTTP `GET /api/capabilities`, and the new MCP `list_capabilities` tool. Plain `styles` prose output is unchanged. Check it before using a mode or an effect: what a build cannot render, it refuses, and now it says so before you build the document. - Four new determinism-gate documents — `text-weight`, `text-stroke`, `text-spacing`, `text-valign` — byte-identical across all six targets, beside the existing goldens, none of which moved. ## [1.6.1] — 2026-09-11 A defect sweep on 1.6.0. No schema change: `schemaVersion` stays 1, and no operation, route, tool, flag or control changed. ### Fixed - **`assemblash styles` now lists `dropShadow`.** 1.6.0 rendered the effect but the CLI's discovery listing still named only the five pre-1.6 effects, so an agent reading `styles` — the surface agents read to learn what a build can do — concluded drop shadows were unsupported and fell back to a blurred rectangle behind the layer. The listing is now derived from the `Effect` enum in core instead of a literal array in the CLI, a new effect variant fails to compile until it is given its line, and the regression test asserts every variant by an exhaustive match. The `dropShadow` entry documents its four fields (`dx`, `dy`, `blur`, `color`), notes that `dx`/`dy` at 0 is a glow, and that an `#rrggbbaa` alpha sets flood opacity. - **The `add-text --text` and `set --text` help no longer claim `` `\n` `` is a line break.** A backslash-n typed at the shell is stored as the two characters and renders as such; only `--text-file` produces a real line break. The help now says the text is taken verbatim and points at `--text-file` for a line break. Behaviour is unchanged — this corrects the documentation, not the engine. ## [1.6.0] — 2026-09-09 No 1.5.1 was released: no defect was outstanding after 1.5.0. A rectangle, an ellipse and a line stop being things you draw elsewhere and import: they are layers this document draws itself, with fill, stroke and corner radius as ordinary properties. A drop shadow joins the effect stack, and a glow is that shadow with no offset rather than a second thing to keep bit-identical. ### Added - **Shape layers, a fifth layer kind.** A `shape` layer draws a rectangle, an ellipse or a line from the document itself, so a badge, a rule or a panel no longer needs an imported file that nothing here can edit afterwards. The transform box *is* the geometry — a rect fills it, an ellipse is inscribed in it — which means `move`, `resize` and `rotate` mean for a shape exactly what they already mean for every other layer, and layout bounds stay the box. - **A line is its box.** `width` is the segment's length, `rotation` is its angle, and `height` is layout only: the line runs across the middle of the box, left edge to right edge. A second pair of endpoint coordinates would have been a second way of saying where a layer is, and every operation that moves a layer would then have meant something different for this one kind than for the other four. - **`fill` and `stroke`.** `fill` is the interior paint, `stroke` is `{ color, width }`, and either may be absent — absent meaning *no paint*, which is SVG's `fill="none"` and not black. A shape with neither is a valid document, for the same reason `opacity: 0` is one: an editor has to be able to clear one paint before choosing the other. - **A stroke is painted inward on a rect and an ellipse**, so the transform box is the visual box and nothing that reasons about layout has to know whether a shape is stroked. A line has no interior, so its stroke is centred on the segment instead. A width larger than the shorter side of the box fills the shape in the stroke colour, which is the continuous limit of the same rule — clamping the box instead would make an over-stroked shape vanish entirely one step after it still filled its box. - **One measured caveat, on strokes thinner than a pixel.** A width below one device pixel is drawn as a hairline centred on the geometric edge, and a hairline always covers a whole pixel row, so a 0.5-wide stroke spills up to half a pixel outside the box on each side. It is deterministic and identical on every target, so it is written down here rather than refused — but below width 1 the box is not quite the visual box. - **A `dropShadow` effect**, with `dx`, `dy`, `blur` and `color`. A glow is this with `dx` and `dy` at 0: there is no second primitive, because two names for one filter would be two things to keep bit-identical for no gain. An `#rrggbbaa` alpha becomes the shadow's flood opacity, so a shadow's strength is written where every other colour in the document writes it. A fractional offset is resampled by the filter and reads slightly softer; whole-pixel offsets give the crispest edge. - **Presets carry `fill` and `stroke`.** A preset that omits one leaves it alone, and there is no spelling that clears a paint from a preset: a preset says what it sets, and one that could silently remove paint would be a different kind of thing. - **A colour slot may target a shape's fill.** A template's colour slot now accepts a text layer or a shape layer and resolves to the right property when it is filled — a text layer's `color`, a shape layer's `fill` — because those are one question asked of two layer kinds. Stroke colour is not slot-able anywhere in 1.x, so a template cannot repaint an edge. - **CLI `add-rect`, `add-ellipse` and `add-line`**, each taking the `--x`, `--y`, `--width`, `--height`, `--rotation`, `--opacity` and `--layer-name` flags `add-text` already has. `add-rect` and `add-ellipse` take `--fill` (default `#000000`, or `none`), `--stroke` and `--stroke-width`, and `add-rect` also takes `--corner-radius`; `add-line` takes `--stroke` (default `#000000`) and `--stroke-width` and no fill, because a line has no interior to paint. - **`set` gains `--fill`, `--stroke`, `--stroke-width` and `--corner-radius`**, still one operation however many flags are given. `--fill none` and `--stroke none` clear a paint, and a colour on its own keeps the width the layer is already drawn with. `--stroke-width` on its own is refused on a shape that has no stroke to take a colour from: a width without a colour is not a stroke, and inventing a colour nobody asked for is the kind of quiet guess this engine does not make. - **MCP `add_shape_layer`**, taking `shape` (`rect`, `ellipse` or `line`), `cornerRadius`, `fill`, `stroke` as `{ color, width }` and `name` beside the same placement, box and write arguments as `add_text_layer` — taking the server from 44 tools to 45. `update_layer` gains `fill`, `stroke` and `cornerRadius`, with `clearFill` and `clearStroke` for removing a paint, because a JSON null in a tool argument cannot be told apart from an argument nobody sent. A layer summary reports `kind` `shape` and a `shape` field naming the geometry, so an agent listing layers can tell a rect from a line without reading the document. - **A Shapes row in the reference interface's Add panel** — Rectangle, Ellipse, Line — and a Shape inspector with Fill, Stroke, Stroke width and, for a rectangle, Corner radius, each row sending exactly one `update`. `dropShadow` is in the add-effect menu with all four of its fields, and the layer tree shows a shape's geometry in its icon. - **Two new reference documents in the render gate**, `shapes` and `shadow`, so every shape and shadow path is checked byte-for-byte on all six released targets rather than only on the machine that wrote it. ### Changed - **An asset upload may be up to 64 MiB**, the ceiling a font import has had since 1.5.0. Until now this route ran under the web framework's 2 MB default, so a photograph from a phone was refused for being an ordinary photograph. Both upload routes now answer a body over their limit with the same JSON error envelope as every other failure — status 413, code `payloadTooLarge`, and a message naming the limit in MiB — rather than the framework's plain-text refusal, because a client that learned to read this API's errors one way should not have to learn a second way for the most ordinary mistake an upload can make. ### Fixed - **A font family literally named `catalogue` or `install` can now be removed.** `GET /api/fonts/catalogue` and `POST /api/fonts/install` are fixed path segments, and a fixed segment wins over `/api/fonts/{family}`: a family with one of those two names could be imported and listed but never deleted, and the answer to trying was a 405 with nothing a client could do about it. `DELETE` now sits on both paths and removes the family of that name. ### Compatibility - `schemaVersion` stays **1**, and the `Operation` union is additive: one new `create` payload for a shape layer and three new `update` properties. No existing operation or document field changes shape. - **1.0 through 1.5 refuse a document that contains a shape layer — the whole document, not the layer.** `show`, `history` and every other command exit 1 with `` unknown variant `shape`, expected one of `text`, `image`, `group`, `svg` `` and the line and column where the read stopped. This is the first 1.x feature that makes a released build refuse a project rather than open it with less in it, which is why a new layer kind is a `.0` and is named here rather than left to be discovered. There is nothing to migrate and nothing to repair in the file: open the project with 1.6.0 or newer. - **A `dropShadow` effect is the gentler story.** Every 1.3.0-and-newer build keeps it in the document verbatim and refuses only the render, with `effect "dropShadow" is not one this build renders` naming the layer; unrelated edits to the same project still succeed, and the effect is still there afterwards. The way back is the same one: render with 1.6.0 or newer. - **Rounded corners and ellipses are emitted as cubic curves rather than as SVG arcs**, so the rasteriser's own arc conversion — which reaches the platform's maths library, where two operating systems are free to disagree in the last bit — is never on the path to a pixel. - **A layer that casts a shadow gets an explicit filter region** in user units rather than the percentage-of-bounding-box default, which is the wrong unit for an offset shadow; the trade-off is that content reaching more than half a box beyond the layer's box may have its shadow clipped, while a stack with no shadow keeps exactly the region it has always had. ## [1.5.0] — 2026-09-09 No 1.4.1 was released: no defect was outstanding after 1.4.0. Fonts become something you manage in the interface rather than in a terminal, and an imported SVG asset drawing text no loaded font can provide stops exporting a blank space and calling it a success. ### Added - **A Fonts section in the reference interface's Add panel.** It lists the installed families and their faces, imports font files from disk, and removes a family. Until now the interface's answer to a missing font was a line of text telling you to go and run `assemblash font install …` in a terminal, which is not an answer for the person the double-click launch exists for. That instruction no longer appears anywhere in the interface. - **Import takes TTF, OTF, TTC, OTC, WOFF and WOFF2.** A WOFF or WOFF2 file is decompressed on import and stored as the sfnt bytes it carries, so the hash in the index describes the file that is actually on disk rather than the container it arrived in. - **Removing a family asks first**, and the confirmation says what will happen: a project using that family reports a missing-font error until the family is imported again. Removal touches the font store and nothing else — not the operating system's fonts, and not the file you imported from. - **One button installs the bundled `default` pack** — Noto Sans, Noto Serif and Noto Sans Mono — offered only when the store is empty. The button says what it will download, and nothing is fetched until it is pressed. - After an import, an install or a removal, the font picker and the canvas refresh immediately. There is no restart. - **The font store now has an HTTP surface**, under the same access-token rule as every other route: - `GET /api/fonts` returns `{ families, faces }`. `faces` is new and additive; `families` is unchanged. - `POST /api/fonts?filename=` takes the raw bytes, up to 64 MB, and answers `201 {imported, families}` — or `200` when those exact bytes were already stored, because importing the same file twice is not an error. The refusals are `400 unsupportedFontFormat`, `400 invalidFilename` and `422 invalidFont`. Concurrent imports are serialised in the server, so two uploads that arrive together cannot cost one of them its index entry. - `DELETE /api/fonts/{family}` answers `200 {removed, families}`, or `404 unknownFontFamily`. - `GET /api/fonts/catalogue` reports what the bundled manifest offers: `packs`, and `families` with a license and a byte size, so a caller can show the cost before anyone agrees to it. - `POST /api/fonts/install` with `{"pack":"default"}` or `{"family":"…"}` answers `201 {installed, families}`, `404 unknownFontFamily`, `404 unknownFontPack`, or `502 fontInstallFailed`. **This is the one route that reaches the network, and it does so only on this explicit request.** A pack install is atomic: every file is downloaded and hash-checked before any of them is stored, so a download that fails partway leaves the store exactly as it was rather than holding half a pack. An unknown key in the install body is refused, following the 1.4.0 rule. - **`serve --reclaim-stale-locks`, and the same flag on `mcp`.** A lock file now records the machine that wrote it (`host`) beside the pid. With the flag on, the server reclaims a project lock by itself only when the lock names this machine and the process it names is provably gone; every reclaim is reported — a line in the server log, a notice in the interface, and a `lockReclaimed` export warning wherever there is an export result to carry one. The flag is off by default and switched on by the double-click / `--friendly` launch, which is the case with nobody at a terminal to run `assemblash unlock`. A lock written on another machine, or written by an older build and so carrying no `host` at all, still needs a person: the interface's confirm dialog and `assemblash unlock` are unchanged. The reason is the synced folder — a project directory that syncs between machines can hold a lock whose pid belongs to a different computer, where that number proves nothing. ### Changed - **An imported SVG asset whose text no loaded font can draw is now refused rather than exported blank.** Since 0.x, rendering an SVG asset containing `` with no matching font produced a file with that text simply missing: exit 0, zero ink, and a document that looked finished. That was the behaviour on the HTTP, MCP and interface paths and on CLI `--font-store`, while CLI `--font-dir` drew the text — so the same document produced two different pictures depending on how its fonts were supplied. This is the same argument 1.3.0 made about an operation carrying a property it does not define: a success that did nothing is a false success, and the fix is to stop reporting it as one. 1.5.0 refuses the render with a typed error naming the asset id and, where there is one, the family. The rule it applies: an asset draws text only if every `` in it names at least one non-generic font family the render loaded. Text that names no family, or only a generic one (`serif`, `sans-serif`, …), is refused too, because the pinned store never holds the renderer's fallback family — such text is guaranteed to draw nothing. **A document that used to export blank but successfully now refuses.** Name a loaded family in the asset, or outline the text before importing it. On the CLI the exit status is non-zero and no file is written; over HTTP it is `422 renderFailed`; over MCP it is a tool error. - The `svgAssetTextWithoutFont` export warning added in 1.3.0 is kept as a code, but it is no longer produced for this case. The refusal happens first, so there is no export result for a warning to sit in. ### Compatibility - `schemaVersion` stays **1**, and the existing `Operation` union is unchanged. This release adds no operation and no document field. - **1.4.0 opens every project 1.5.0 writes**, and so does every earlier 1.x build, because nothing about the on-disk document changed. The only new data written anywhere is the optional `host` in a lock file, which 1.4.0 reads and ignores. - `faces` on `GET /api/fonts` is additive, and the other four font routes are new. No existing HTTP route, CLI flag, subcommand or MCP tool changed shape. - **The one thing you may notice on an existing document is the SVG-text refusal.** A project that exported successfully while an SVG asset drew text in a family no loaded font provides will now refuse to export. Nothing in the document changed and there is nothing to migrate: load the family the asset names, name a loaded family in the asset, or outline its text and import it again. 1.4.0 still exports the same project blank. ## [1.4.0] — 2026-09-05 ### Added - Canvas editing through the reference editor, `assemblash canvas set`, MCP `update_canvas`, and HTTP `updateCanvas`. Width, height and background change in one undoable operation. Omitting background preserves it; explicit JSON `null` or CLI `--no-background` clears it. - Nine resize anchors position existing content without scaling layers. The default `top-left` leaves positions unchanged. An anchor that would move locked, protected or read-only content refuses the entire operation. ### Fixed - Unknown top-level properties are refused across all operation variants. Typed HTTP JSON request envelopes also reject unknown fields, so misspelled version, actor or export options cannot silently take their defaults. ### Compatibility - `schemaVersion` remains 1; the document fields and existing operation shapes are unchanged. The operation union gains `updateCanvas`. - **Upgrade before reopening a project whose history contains `updateCanvas`.** The released 1.3.1 binary refuses both `show` and `history` with a corrupt journal error naming the line and unknown operation. Undoing the change does not remove that journal entry. Keep the newer binary; do not edit or delete the journal to work around the refusal. ## [1.3.1] — 2026-09-05 A bugfix release on 1.3.0. Every defect is in the test and CI harness rather than in anything the program does, so this release changes no behaviour at all — and that is worth saying plainly rather than dressing up. One test could pass for the wrong reason, another could fail for the wrong reason, and a run could hang instead of reporting anything at all; a suite that does any of those is not evidence about a release, which is the only thing a suite is for. Nothing new was added, and this is the 1.3 release to install. ### Fixed - **The Model Context Protocol tests could pass against a binary built from older code.** They drive the real `assemblash` executable as a child process, which is the point of them, but the executable belongs to a different crate from the tests, so `cargo test -p assemblash-mcp` never rebuilt it: the tests ran against whatever an earlier `cargo build --workspace` had left on disk. Removing a tool from the source and running them still passed. They now build the binary themselves, once per test process and into the profile they were built for, and the same removal now fails as it should. - **A test's minimal HTTP client waited for the connection to close instead of for the response to finish**, and intermittently failed with `Connection reset by peer` on macOS after the server had answered it perfectly well. A host that closes a connection with anything still unread in its receive queue must send `RST` rather than `FIN`, so a client still reading at that moment sees a reset instead of an end. It was `POST /api/shutdown` that exposed this — its handler takes no body extractor, so the body the test sends is never read — and the failure was a matter of kernel timing, which is why the same commit passed and failed on the same runner twenty minutes apart. The client now reads until the message is complete, by `Content-Length` or by the chunked terminator, and stops there. All four copies of it are fixed, and a new assertion checks that completeness is decided by framing rather than by the close. - **A test run could hang for the best part of an hour instead of reporting.** The browser journeys for the reference interface passed all twelve of their assertions and then failed while removing the temporary Chrome profile: Chrome's helper processes outlive the one the test signals and were still writing into the directory being deleted. That failure came from the first of two cleanup steps and so skipped the second, which left the fixture web server listening — an open handle that keeps Node alive with nothing left to do. The run sat silent until somebody cancelled it by hand, and a hang reports nothing, which is worse than a failure. Removing the profile now retries and, if it still loses the race, says so rather than failing a run that has already made every assertion it came to make; the fixture server is closed whatever happens before it; and every wait in the journeys — a condition in the page, a DevTools command, the browser's launch handshake, the sockets at either end — now has an upper bound that fails the test naming what it was waiting for. Every job in both workflows also carries a time limit, so nothing else of this kind can sit unreported either. - **Release validation now exercises the executable through CLI, HTTP and MCP on all six supported targets.** The same smoke test runs before release and against checksum-verified archives downloaded after publication. It checks matching PNG bytes and warnings, refusal without mutation, actor attribution, undo and overlap queries. ### Compatibility and safety - `schemaVersion` stays **1**. - The existing `Operation` union is unchanged, and no operation changed shape. - No CLI flag or subcommand, HTTP route, MCP tool or interface control was added, changed or removed. - **A document written by 1.3.0 is unchanged by this release**: it loads and saves identically, and renders the same pixels. Export bytes include the renderer version, so their metadata changes to 1.3.1. No generated artefact moved — not the generated schema, not the TypeScript declarations, not the interface bundle. Every change in this release is a test or a CI workflow, apart from the version number and a comment in a manifest. - No server, engine or interface code was touched. In particular the socket behaviour behind the second fix is the server's correct response to `Connection: close`, and it was left alone. - Every renderer gate golden is unmoved. No dependency was bumped. ## [1.3.0] — 2026-09-04 Nothing new that the engine can draw; everything it could already draw becomes reachable from every interface. A layer property only the HTTP API could set is now a CLI flag. A query only the CLI could answer is now an HTTP route and an MCP tool. An export now says what it could not do well instead of staying quiet. And an operation carrying a property it does not define is refused, where it used to be accepted and silently discarded. ### Added - **`assemblash set`**, one command for every updatable layer property: `--name`, `--x`, `--y`, `--width`, `--height`, `--rotation`, `--opacity`, `--visible`, `--locked`, `--blend`, `--effects`, `--effects-file`, `--text`, `--text-file`, `--font`, `--size`, `--color`, `--align`, `--line-height`, `--fit`, `--asset` and `--allow-locked`. However many flags an invocation carries, it is one `update` operation: journalled once, undone once. Position and size flags are merged into the layer's current transform, so `--x` alone moves without resizing, and `--name ""` removes a name. `assemblash style` is unchanged and now calls the same builder, so the two cannot drift. - **`--line-height` on `assemblash add-text`**, and **`lineHeight` on the MCP `update_layer` tool** — the last updatable property those two surfaces could not set. - **Export warnings.** An export now reports what it could not do well: `wordBrokenMidWord` (a single word too wide for its box, split to fit), `textOverflowsBox` (laid-out text taller than the box holding it) and `svgAssetTextWithoutFont` (an imported SVG asset drawing text in a family no loaded font provides). Each carries `code`, `message` and, where one applies, `layerId`. `POST /api/projects/{id}/export` and the MCP `export_document` result carry a `warnings` array; the CLI prints one line per warning on stderr, or the whole array as JSON on stdout with the new `--warnings-json` flag on `render` and `export`. A warning is advisory: it changes no pixel and no exit status. - **`GET /api/projects/{id}/overlaps`**, returning `{"pairs": …}` in the same order as `assemblash overlaps`, narrowable with a repeated or comma-separated `?layers=`. A layer id that is not in the document is refused, not ignored. - **Four MCP tools — `create_project`, `add_svg_layer`, `render_document` and `find_overlaps`** — taking the server from 39 tools to 43. `render_document` returns the same SVG the HTTP preview route serves; `find_overlaps` returns the same pairs as the CLI and the new route; `add_svg_layer` draws an asset that was already imported, because a layer can only ever draw a sanitized one. - **Positional output paths on `assemblash render` and `assemblash export`**, beside the existing `--out`, and both now print the written path and its `sha256:` digest as one tab-separated line on stdout — the same digest form `variants` already prints — so two interfaces can be compared without hashing the files. - **Positional input files on `assemblash add-svg` and `assemblash add-image`**, beside the existing `--file`, matching `assemblash font add`. - **`assemblash preset define --properties-file`**, so a preset's JSON can come from a file rather than from a shell that may rewrite it on the way through. - **An SVG choice in the reference editor's export dialog.** Choosing SVG downloads the engine's vector render and disables the resolution row, since a scale means nothing for a vector; nothing is written into the project. - **Reference-editor inspector controls** for image and SVG `fit`, and for reordering the effect stack, where order is part of what the stack means. The font fields gained a suggestion list of the families the server reports, with free text still accepted so a document naming a family this machine lacks stays editable. An uploaded image or SVG now arrives at its own recorded dimensions, scaled down to fit the canvas, instead of always 300×200. ### Changed - The reference editor's export dialog no longer carries its bare SVG download link. The link could not carry an access token, so it failed on any server started with one, and the new format choice is the working version of the same affordance. - CI's `ui` job now runs `npm run check`, which typechecks, builds and runs the interface's unit tests and browser journeys in one step. Those tests existed but had never been invoked in CI. The check that the committed build output is the build output still runs after it, unchanged. ### Fixed - **An operation carrying a property it does not define used to succeed.** `{"op":"update","id":"layer_…","letterSpacing":4}` returned `200 OK`, bumped the document version, reported the layer as changed, and journalled an update carrying no properties at all — a false success that told a client its edit had landed when nothing had happened. `create` behaved the same way. Both now return `422` with code `operationRefused`, naming the property; the document version does not move and no journal entry is written. The known keys are derived from the same definitions the published operation schema is generated from, and the `create` check uses the payload's own `type`, so a text property on an image create is refused as precisely as a misspelt one. Three spellings that predate 0.6.0 — `font_family`, `font_size` and `line_height` on a text `create` — are still accepted, so journals written before then still replay. This affects only a newer client talking to this server. It is a refusal of what is sent, never of what is stored: no document, journal or existing client behaviour changes meaning. **MCP is unaffected** — every MCP tool builds a typed operation in Rust, so a property the operation does not define could never reach the operation layer from there. ### Compatibility and safety - `schemaVersion` stays **1**. - The existing `Operation` union is unchanged, and no operation changed shape. `set` and all four new MCP tools compile to variants that already existed. `schema/document.schema.json`, `schema/operation.schema.json` and both generated TypeScript declaration files are unchanged. - **A document written by 1.2.x is unchanged by this release**, and every 1.x build — back to 1.0.0 — reads a document produced with this release's capabilities exactly as it read one before it. No document field was added and none changed shape. - `warnings` is a new field on an export response, not on a document, so it is additive for any client that ignores unknown response fields. - The published operation schema does not state whether extra keys are allowed on `create` and `update`. The refusal lives in the operation layer, so a client author should read this behaviour rather than the schema's silence. - Export warnings are not displayed in the reference editor in this release. They are reported by the CLI, the HTTP API and the MCP server. - `svgAssetTextWithoutFont` makes a silent failure loud; it does not fix it. An imported SVG asset drawing text in a family the loaded fonts do not provide still renders that text as nothing. The check reads `font-family` attributes only — not a `style="…"` attribute and not a `