CHANGELOG.md
Rama main · réplica de lectura del repositorio original.
# 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 `<svg>` 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=<name>` 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
`<text>` 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 `<text>` 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 `<style>` block.
- Every renderer gate golden is unmoved: this release adds no paint path.
- Every existing CLI flag and subcommand, HTTP route and MCP tool still works.
`--out`, `--file` and `assemblash style` are all still accepted.
## [1.2.1] — 2026-09-04
A bugfix release on 1.2.0. Five defects found by auditing the released build
against what it claims to do, four of them cases where something was quietly
discarded or quietly duplicated rather than reported. Nothing new was added,
and this is the 1.2 release to install.
### Fixed
- **A font that names its family in more than one language arrived as more
than one family.** `assemblash font add` wrote one index record per
name-table language record, so a font naming itself in English and Japanese
filled `font list` with families nobody had installed. One file's face is now
one record, under the name the English record gives, or the first record when
there is none.
- **The same font bytes under a different filename were added twice.** A stored
record compared equal only when its `source` and `license` matched too, so
re-adding a renamed copy — or the same file again with `--license` filled in
— appended a second row describing a file already in the store. Records are
now matched on what they identify: family, style, weight, file, hash and face
index. The first import's source and licence are kept.
- **SVG import destroyed the accessibility metadata an author had written.**
`role`, `aria-label`, `aria-labelledby` and `aria-describedby` were not on
the import allowlist and were stripped without appearing in the removal
report. They are now kept. None of them references anything outside the
document, so nothing about the import threat model changes.
- **`prune_unused_assets` treated an `svg` layer's asset as an orphan** and
would have deleted a file the document was still drawing. It now counts every
layer kind that names an asset, and does so exhaustively, so a layer kind
added later cannot be forgotten here silently.
- **The published schema described `blendMode` wrongly.** Its description still
read "Reserved (v0.5): only `normal` is rendered today", fourteen releases
after fourteen modes started rendering, and that sentence was in
`schema/document.schema.json` and `schema/document.d.ts` as well as the Rust.
It now says what the renderer does, including why `color-dodge` and
`color-burn` are deliberately absent and what happens to a mode this build
does not draw.
### Changed
- Removed `drawInspectorLegacy` from the reference interface. It was superseded
and called from nowhere; the committed bundle is smaller by its size.
- `crates/assemblash-renderer/tests/fonts/TwoFamilyNames-Subset.ttf`, a Noto
Sans subset carrying two Unicode family-name records, is committed as a test
fixture for the first of the font-store fixes. Like the other fixtures there
it is under the SIL Open Font License 1.1; it is renamed rather than keeping
the original family name, because it is a modified version. `subset.py`
rebuilds it from the committed Latin subset.
### 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.2.0 is unchanged by this release**: it loads,
saves, renders and exports exactly as it did, byte for byte. The only change
to a published artefact is a corrected description string in the generated
schema and TypeScript declarations, which no document depends on.
- Every renderer gate golden is unmoved. No dependency was bumped.
- Documents whose SVG assets were imported by an earlier build are unaffected;
the accessibility fix applies to imports made from now on, and re-importing
the original file recovers what an earlier import dropped.
## [1.2.0] — 2026-08-30
Assemblash is easier to recognize, understand, and use without changing the
stable document or operation contracts. This release integrates the finished
identity, tightens the editor's everyday interaction details, and turns the
repository into a complete public entry point with a real editable example.
### Added
- The finished Assemblash mark in the reference editor and public repository,
using the same compact layered identity at application and documentation
sizes.
- A neutral launch-card example under `examples/launch-card`, stored as an
ordinary structured project with sanitized SVG assets and operation history.
Its README image is exported by Assemblash itself and can be reproduced with
the manifest-pinned Noto Sans font.
- A public `skills/assemblash/SKILL.md` for agents working with Assemblash
documents or integrations. Tool-local `.agents/` installations remain
ignored and are rejected by repository hygiene checks if committed.
- Direct README paths for questions, reproducible bug reports, feature ideas,
deployment guidance, schemas, and the editable example project.
### Changed
- Rewrote the README around the released product: a short explanation, real
quick start, six-target compatibility table, interface choices, architecture,
safety model, stability promise, current limits, and contribution workflow.
- Simplified the editor's zoom controls, improved contrast and scrollbar
treatment, and made an empty Layers panel explain how to create the first
layer instead of presenting an unexplained blank state.
- Aligned the private UI package metadata, security policy, issue form, agent
guidance, and release packaging with the product release. Release archives
now carry the public image assets used by their README.
- Expanded ignore and CI hygiene rules so local AI workspaces, credentials,
private downstream content, build output, and sync artifacts stay outside the
public repository and Docker build context.
### Compatibility and safety
- `schemaVersion` stays **1**.
- The existing `Operation` union is unchanged.
- Existing 1.0 and 1.1 documents and clients require no migration.
- The example was created through the validated CLI and re-exported
byte-identically from its committed document, assets, history, and pinned
font bytes.
## [1.1.0] — 2026-08-27
The reference interface is now a practical canvas-first editor while the
stable document schema and operation API remain unchanged. Existing 1.0
documents and clients continue to work without migration.
### Added
- A unified editor shell with one Add panel, direct on-canvas text editing, a
contextual toolbar, and one docked Properties/Layers/History panel.
- Heading, subheading, and body presets; image and SVG drop/upload flows;
synchronized canvas/layer selection; inline layer rename, visibility, lock,
grouping, reordering, and protected/read-only states.
- Separate horizontal and vertical canvas centring, nine canvas anchors, six
selection alignments, distribution, ordering, and numeric transform fields.
- A shared accessible context menu for canvas and layer rows, standard
clipboard/grouping/nudge shortcuts, multi-selection handles, marquee and
additive selection, smart guides, zoom, fit, 100%, and responsive panels.
- `POST /api/projects/{id}/operation-batches` for atomic UI commands. Existing
operations are validated and journalled as one transaction; the
project-local `insertLayerTree` clipboard macro expands into ordinary
operations with fresh IDs and validated asset references.
- `GET /api/projects/{id}/text-layout` and pinned-font text measurement so a
resized text box wraps and grows to its content instead of stretching glyphs.
- Explicit PID-matched stale-lock recovery at
`POST /api/projects/{id}/recover-lock`.
- Offline browser journeys for the compiled editor plus focused geometry,
export, API, batch, wrapping, and lock-recovery tests.
### Changed
- Dragging, resizing, and rotating update locally while the pointer moves and
commit one operation batch when interaction ends. Interactive previews render
only the pixels needed at the displayed scale; final export stays full-size
and deterministic.
- Rotated selection boxes, resize handles, snapping, alignment, and
multi-selection bounds use visible geometry. A rotated resize follows the
layer's local axes and keeps the opposite anchor fixed.
- Export has an explicit workflow with named output sizes while retaining
original-size PNG and SVG output from the Rust renderer.
- Development builds use light optimization so local raster previews respond
quickly without disabling debug assertions.
### Fixed
- Text overflow, stretched text during resize, delayed drag feedback, editing
overlays that did not rotate, and selection boxes that did not follow their
layers.
- Duplicate editing/creation surfaces, overlapping panels, inaccessible
arrangement actions, inert sidebar modes, and mobile panel overflow.
- Project-open failures caused by stale process locks. Recovery removes only
the exact claim the user was shown and never steals a changed or live lock.
- A missing `geometry.js` server allowlist entry that prevented the editor
module—and therefore both sidebars—from initializing in the real binary.
### Compatibility and safety
- `schemaVersion` stays **1**.
- The existing `Operation` union is unchanged.
- Existing single-operation endpoints and clients remain supported.
- Batch commands preserve expected-version checks, protected/read-only layer
rules, atomic rollback, deterministic replay, and one-step undo/redo.
## [1.0.0] — 2026-08-06
**Assemblash is 1.0.** Not because it is finished, but because what you build
against will not move under you.
`1.0.0` promises exactly two things:
- **The document schema is stable.** `schemaVersion` has been 1 since v0.1.0
and has never needed a migration. Fields have only ever been added, with
defaults, and unknown keys survive a load-and-save cycle — which is
property-tested, not merely intended.
- **The operation API is stable.** The operation set has grown; no existing
operation has changed shape.
**Breaking either now requires a MAJOR release, with a migration.**
It is not a claim of feature completeness. What exists is listed in
`README.md`; what does not is said there too.
### What 1.0 contains
Everything released through v0.17.0, unchanged: the document model and its
thirteen layer operations, layout operations, an append-only journal with undo
and redo across restarts, protected and read-only layers, file locking and
expected-version conflict checks, a hash-pinned font store with twelve OFL
families, deterministic SVG and PNG rendering, fourteen blend modes, a
non-destructive effect stack with seeded grain, presets, templates that can be
authored as well as filled, a workspace that stays usable at two hundred
projects, a local HTTP API, an MCP server with read and write tools, a
reference web interface, and binaries for six targets plus a `scratch`
container.
### The three limits, stated plainly
- **Fourteen blend modes, not sixteen.** `color-dodge` and `color-burn`
rasterize correctly but do not produce bit-identical bytes on every target.
Reproducibility is what the rest of the engine rests on — including the
content hashes a variants batch is checked by — so both are refused with a
typed error rather than drawn. Revisit if the upstream renderer changes.
- **AI adapters (PRD use case D) are out of scope by design.** No adapter
ships and the core never requires an AI provider. Adapters are the post-1.0
extension point, not a missing feature.
- **Authentication is one shared token; identity belongs in front of it.** A
non-loopback bind refuses to start without a token, which is compared in
constant time, never logged, and never put in a URL. There are no users, no
roles, and no revocation beyond rotating it. For OIDC or SSO, put a reverse
proxy in front — the token authenticates, it does not encrypt.
### Verified for this release
All fourteen MVP acceptance criteria (PRD §12) and primary use cases A, B, C,
and E (PRD §6) are demonstrated by real execution against released artifacts,
not by argument. Every release from v0.1.0 was tested on all six targets in CI
before it was tagged, and every one was verified afterwards by downloading the
binary and running it. The `scratch` container builds and runs.
## [0.17.0] — 2026-08-06
Templates can be authored, not just filled. Closes the last functional caveat
in the evidence file.
No document-model change: `slots` already existed. `schemaVersion` stays **1**,
and three new operations are additive to the operation enum, exactly as the
three preset operations were in 0.15.0.
### Added
- **`defineSlot`, `updateSlot`, `removeSlot`** as ordinary operations —
journalled, undoable, dry-runnable, and version-checked, because that is what
every operation gets. Before this, `slots` had no operation that set it, so
authoring a template meant editing `document.json`: the only workflow in the
product with no journal, no undo, and no audit trail.
- **A slot may not be aimed at protected or read-only chrome**, refused at
definition time. Filling one was already impossible — a fill is an `Update` —
but *offering* it was not, and an opening that always refuses when filled is
worse than no opening, because it looks like a promise.
- The same checks refuse a name already taken, an empty name, a layer that is
not in the document, and a kind that does not match the layer it names.
`updateSlot` faces every one of them, so an update cannot produce a slot a
definition would have refused.
- `assemblash slot define|update|remove`, the same three operations over HTTP
and MCP, and an inspector affordance that offers the selected layer as a slot
and lists and removes the document's slots.
- The journal now names the slot and preset operations rather than printing
them as a generic `operation`.
### Changed
- **Deleting a layer a slot offers is refused**, naming the slots in the way.
A dangling slot makes the document invalid, so doing nothing was never an
option; cascading would let an agent delete a layer and silently break a
contract other callers are filling, with no room in the `removed` outcome to
say it had. The fix is one `removeSlot`, and the error says which.
### Verified
- A template authored, filled, and undone **without a single hand edit**: the
slot definition undoes byte for byte, and redo puts it back.
- Every refusal, through the binary: protected chrome, read-only chrome, a
missing layer, a kind mismatch, a duplicate name, and deleting a slot's
layer — including when the layer is a child of the group being deleted.
- A refused definition leaves the document byte-identical.
- The scale test now stops the first server before restarting one over the
same workspace, and compares the **thumbnail bytes** across the rebuild as
well as the list and the search. Checking only the list and the search left a
hole: a rebuilt cache could have answered those correctly and still produced
a different picture. Found by running the v0.16.0 exit test by hand against
the released artifact.
## [0.16.0] — 2026-08-06
A workspace holding two hundred projects stays usable, and the bundled font
manifest covers more than the Notos.
No document-model change. `index.db` has its own schema version, and drift
there is a rebuild rather than a migration — which is the whole point of a
cache.
### Added
- **`index.db`, a cache and never a source of truth.** Delete it and nothing
is lost: it rebuilds by scanning `projects/`. Corruption, or a schema this
build does not recognise, is not an error anybody sees — it is a reason to
throw the file away and build it again. Absent entirely, every route falls
back to a directory scan and the product behaves exactly as before.
- **Search, recents, and thumbnails in the project browser.** Searching
happens in the engine against the cache, so a large workspace is never sent
to the page for the page to look through. Thumbnails are rendered on demand
and cached **against the document version they were made from**, so a stale
thumbnail is impossible rather than merely unlikely.
- `GET /api/projects?query=&limit=`, `GET /api/projects/recent`, and
`GET /api/projects/{id}/thumbnail.png`.
- **Seven more families in the bundled font manifest** — Inter, Roboto, Open
Sans, Montserrat, Playfair Display, Lora, and JetBrains Mono — hash-pinned
at the commit the manifest already names, exactly like the Notos. Twelve
families in total, all OFL.
### Verified
- **Two hundred projects**, over a real socket: listed, searched by id and by
name, and thumbnailed, with the second request for a thumbnail served from
the cache byte for byte.
- **Deleting `index.db` changes nothing** — the same list, the same search
results, and the same thumbnail bytes, both from the running server and from
one restarted over the rebuilt cache. If that ever stops being true, the
cache has become a second copy of the truth and has to come back out.
- A corrupt file and a file from an unknown schema are both rebuilt rather
than reported.
- A project written behind the server's back is still found, because the cache
is refreshed when a listing is asked for rather than trusted.
- Every new font family was downloaded from the pinned commit, hashed, and
checked for the family name a document has to spell.
- **The container still builds from `scratch` and still runs**: SQLite is
compiled in and links statically against musl. The image grew from 7 MB to
8.65 MB, which is the honest price of the cache; `README.md` now says 9 MB.
## [0.15.0] — 2026-08-06
Presets: named style bundles, defined, applied, and undone like anything else.
Document `schemaVersion`: **1 (unchanged)**. `presets` is additive with a
default, like `slots` and `effects` before it.
### Added
- **Presets live in the document.** A project directory stays portable, and
the same document must not render differently depending on what else is
installed next to it — the exact failure the font store exists to prevent.
The cost, stated plainly: sharing a preset between projects means copying it.
- **A preset is the properties of an update**: font family, size, colour,
alignment, line height, opacity, blend mode, effect stack. Applying one
builds that same `UpdateLayer` and hands it to the operation layer, which is
what makes "a preset renders identically to the same properties set by hand"
true by construction rather than by hope. Deliberately no transform: a style
is not a position, and a preset that moved layers would be a template.
- Three operations — `definePreset`, `deletePreset`, `applyPreset` — all
journalled, undoable, and dry-runnable. Applying to a protected layer is
refused by the check that already guards every other mutation.
- `assemblash preset define|list|delete|apply`, `GET
/api/projects/{id}/presets` alongside the operation endpoint,
`list_presets`/`define_preset`/`delete_preset`/`apply_preset` over MCP, and
a preset list in the interface's inspector with a "save style as preset"
control.
### Verified
- **A preset applied is pixel-identical to the same properties set by hand** —
compared as decoded pixels, both in the renderer's tests and through the
binary on two identically built projects.
- Define, apply, undo, redo: the document comes back byte for byte at every
step, and defining a preset does not change any picture.
- **Deleting a preset changes no picture**: applying one sets properties, it
does not create a link.
- A preset that sets nothing, or that names a blend mode or effect this build
cannot draw, is refused when it is defined rather than when it is finally
applied.
- Applying an unknown preset says what the document does have.
## [0.14.0] — 2026-08-06
A non-destructive effect stack, and the rest of the CSS blend modes. Every
mode and effect named here was checked against the pixels it produces before
it was claimed.
Document `schemaVersion`: **1 (unchanged)**. Both changes are additive —
`blendMode` gains enum values and `effects`, reserved since schema version 1,
gains a shape. A document written by 0.13.0 loads here unchanged, and one
written here loads there with its effects preserved verbatim.
### Added
- **Effects, per layer, never baked**: `brightness`, `contrast`,
`saturation`, `blur`, and `grain`, applied in the order they are listed. The
document keeps the numbers and the pixels are derived from them every
render, so an effect is as reversible as any other property.
- **Grain is seeded** — the seed lives in the document, not in the run, so the
same document produces the same noise on every machine and in every render.
A renderer that promises byte-identical output cannot have noise any other
way.
- **The remaining reproducible blend modes**: overlay, darken, lighten,
hard-light, soft-light, difference, exclusion, hue, saturation, color, and
luminosity, joining normal, multiply, and screen — fourteen in all.
- `assemblash style` sets a layer's blend mode and effect stack;
`assemblash styles` lists what this build actually renders. The same fields
are on `update_layer` over MCP and on the operation endpoint over HTTP, and
the interface's inspector has a blend picker and effect rows.
### Changed
- **A blend mode this build does not render is now refused rather than drawn
as `normal`.** Before 0.14.0 an unrecognised mode was quietly composited
normally; a picture that silently ignores what it was told to do is worse
than no picture. The document still keeps the value — that round trip is
unchanged — and setting one through an operation is refused up front, so it
cannot get into a document through this build at all. The same rule applies
to an effect type this build does not know.
### Not shipped, and why
- **`color-dodge` and `color-burn` are refused, not rendered.** They
rasterize, and they look right. They are not *bit-identical across targets*:
with the same document and the same fonts, the x86_64 macOS runner produced
different bytes from the other five. Both are built on a division that
saturates near zero, which is precisely where a machine's dispatch can
change the arithmetic. NFR-1 — same document, same fonts, same pixels on
every target — is what the rest of this engine rests on, including the
content hashes a variants batch is checked by, so a mode that quietly breaks
it on one machine is worse than a mode that says no. Both still round-trip
through a document untouched, and both refuse with the same typed error as a
mode from a future build. This was found by CI, not reasoned about: the
golden set is now one document per mode so a disagreement names the mode.
### Verified
- **Every one of the fourteen blend modes composites**, checked against the
actual pixels of a two-square overlap rather than against the markup.
- **Each effect does what its name says**, in sRGB: brightness 1.5 takes
(64, 128, 192) to (96, 192, 255), contrast 0 is flat mid grey, saturation 0
is grey. **Every effect's neutral value changes nothing**, so turning one
down to zero is the same as not having it.
- **Order matters and is respected**: brightening then desaturating is a
different picture from desaturating then brightening.
- **Grain is repeatable and seed-dependent**, lightens as well as darkens, and
cannot paint outside the layer it grains.
- **Setting a stack is one journalled update, and undo restores both the
document and the render byte for byte** — run through the binary.
- Styling a protected layer is refused by the same check that refuses every
other mutation, because it is an ordinary `update`.
- **Cross-target determinism**: a golden document per rendered mode, plus one
covering every effect, are in the gate — so CI compares hashes on Windows,
Linux, and macOS across x86_64 and ARM64, and every mode this release claims
has been shown to produce identical bytes on all six.
- **No existing golden moved.** The additions to `goldens.json` are additions
only: nothing about how existing documents render changed.
## [0.13.0] — 2026-08-06
Templates in the interface: the one thing 0.12.0 shipped that the page could
not do. Filling a template and rendering a batch of variants are now available
everywhere — command line, HTTP, MCP, and the browser.
Document `schemaVersion`: **1 (unchanged)**. Nothing in the document model
changed; `slots` shipped in 0.12.0.
### Added
- **A template panel in the reference interface**, shown only for a project
that declares slots. The form is generated from the slot definitions the
engine reports: text slots get a text field, colour slots a colour picker,
and image slots a list of the project's own assets plus an import button.
Required slots are marked and a slot's description is its help text.
- **Preview and batch from the page.** Preview renders one filled result;
the batch renders many, from rows built in the form or from a values file —
the same JSON file `assemblash variants --values` takes, so a batch that
works at the command line works in the page.
- **A gallery** of the produced PNGs, each with its size, byte count, content
hash, and a download link.
- **`GET /api/projects/{id}/exports/{file}.png`** — reads back a PNG the
engine wrote into a project's `exports/`. The caller supplies a file name,
never a path: the stem goes through the same check that named it, so the
only files reachable are ones this engine wrote, in the one directory it
writes them to.
### Verified
- **The command line and the HTTP API render identical variants**, hash for
hash, in an automated test that runs both as real processes: `assemblash
variants` and `assemblash serve` on the same project, fonts, and values.
- **In a real browser against the released artifact**: a template chosen from
the project list, slots filled, a batch rendered, and the gallery's hashes
compared with what `assemblash variants` printed for the same values — the
same hashes, variant for variant.
- What the gallery shows is what the batch made: the bytes served for each
variant hash to the value the batch reported, not to a re-render that
merely resembles it.
- A required slot left empty is refused in the engine's own words, and the
panel is hidden entirely for a project that is not a template.
## [0.12.0] — 2026-08-05
Templates with named slots — PRD use case C, one of the product's primary use
cases — and the interface essentials 0.9.0 cut.
Document `schemaVersion`: **1 (unchanged)**. `slots` is additive with a
default, exactly like `blendMode`, `effects`, and `runs` before it: a document
written by 0.11.0 loads here unchanged, and one written here loads there with
its slots preserved verbatim. Bumping the version would have broken every
existing reader in exchange for a migration with nothing to do.
### Added
- **Templates.** A document may name some of its own layers as slots —
`headline`, `logo` — so a script or an agent supplies content without
knowing layer ids, and **without being able to touch anything that was not
offered**.
- **Filling is ordinary operations.** A slot fill is an `Update` handed to the
same `Session::apply` everything else uses, which is why a slot pointing at
a protected layer is refused: not by a check templates remembered to make,
but by the one every route to a protected layer already passes through.
- **`render_variants`**: one template, N sets of values, N PNGs into the
project's `exports/`, each reported with its content hash and traceable to
the template's id and version. The template is never modified — variants are
filled on a copy — so a batch of fifty leaves the project as it found it.
- Exposed on every surface: `assemblash slots` and `assemblash variants` on the
command line, `GET /api/projects/{id}/slots` and
`POST /api/projects/{id}/variants` over HTTP, and `list_slots`,
`fill_template`, and `render_variants` over MCP.
- **`UpdateLayer` can change an image layer's asset.** Image slots need it, and
"swap the picture in this layer" was an obvious operation the engine did not
have. The asset must already be in the document.
- **Interface essentials**: image upload from the page, keyboard shortcuts
(arrows nudge, shift-arrows nudge by ten, Delete removes, Ctrl/Cmd+Z undo and
redo), and selection handles for layers **inside groups**.
### Verified
- **The exit test, over MCP against the released binary**: one template, four
value sets, four different and correct exports; the same values render
byte-identical bytes on a second run; the template's own file is untouched.
- **The protected chrome is pixel-identical in every variant**, compared row by
row rather than by whole-file hash — which could not tell "the chrome
survived" from "nothing changed at all".
- A slot aimed at a protected layer is refused, and nothing is written.
- A value for a slot that does not exist is refused rather than ignored: a
typo must not produce a variant that looks finished and is missing the
change you meant.
### Notes
- Slots are `text`, `image`, or `color`. Positions, sizes, and fonts are not
fillable: a template that let a caller move things is a document, and there
is already an API for that.
- Handles for a layer inside a **rotated** group are still not drawn. This
build composes translations, and a handle a few degrees out of place is
worse than no handle.
- Template filling from the interface did not make this release. It is
available on the command line, over HTTP, and over MCP.
## [0.11.0] — 2026-08-05
Self-hosting works. PRD §16.14 — the last open product decision — is resolved
as **access token plus explicit bind**, and this is it built.
Document `schemaVersion`: **1** (unchanged).
### Added
- **`--bind <address>`**, and a `bind` key in the workspace configuration. The
default is `127.0.0.1` and needs no token and no setup: anyone who can reach
that socket is already on the machine, where the projects are ordinary
readable files.
- **A non-loopback bind refuses to start without an access token.** Not a
warning — a refusal, because a server that bound a network and carried on
serving would publish the workspace to it, and the flag that did so would
not have looked like it was going to. The error says exactly what to run.
- **`assemblash token show | rotate | clear`.** The token lives in the
workspace configuration and nowhere else; there is deliberately no `--token`
argument anywhere, because a secret on a command line is a secret in shell
history and in every process listing on the machine.
- **`Authorization: Bearer` for the API**, and a **one-time browser login** for
the interface: paste the token once, it is kept in that tab, and every
request carries it as a header. Even the canvas image is fetched rather than
pointed at, so the token never reaches a URL.
- **The Docker image is genuinely servable.** It binds `0.0.0.0` so a published
port reaches it — which means it requires a token, which is the safety
property rather than an obstacle. `compose.yaml` publishes on loopback by
default.
- **`DEPLOYMENT.md`**: how to get a token, what it protects, and reverse-proxy
configurations for Caddy, Traefik, and nginx — with the point stated plainly
that the token authenticates and does not encrypt.
### Notes
- Comparison is constant time; a rejected token is never echoed back, never
logged, and never placed in a URL.
- The interface's own files are behind the token too. A page that loaded and
then failed every call would be a worse way to learn a token is needed than
not loading at all. The login page is the one exception, because it is how a
token gets into the browser.
- Port fallback (trying port 0 when the configured port is taken) applies to
loopback only. A server meant to be reachable at a known address should say
it could not start rather than move quietly to another port.
- There are no accounts and no built-in OIDC. Identity belongs in the reverse
proxy, which is where TLS belongs too.
## [0.10.1] — 2026-08-05
Two findings from an independent verification of the 0.10.0 release. Both are
things that made a correct system feel broken.
### Fixed
- **`assemblash mcp --project <dir>` left its lock file behind.** The sessions
it opened lived in a `static`, and a static is never dropped — so the lock
outlived the process and the project could not be reopened until someone ran
`assemblash unlock`. That is a puzzle to hand a person whose agent simply
closed a pipe. The registry is now owned by the server and released
explicitly when the client goes away, rather than as a side effect of
ownership working out. (`--workspace` mode was never affected; both are now
covered by a regression test that spawns the real binary.)
- **`add-text` accepts `--font-store`** and checks the family against it,
naming what *is* installed when it does not match. Naming a font the store
does not have used to succeed and then fail at export several commands
later, looking like a rendering problem rather than a typo. The flag is
optional and reads `ASSEMBLASH_FONT_STORE`; without it nothing changes.
## [0.10.0] — 2026-08-05
Packaging, and the promise that someone who has never opened a terminal can
start this and stop it. The last milestone of the v0.x ladder.
Document `schemaVersion`: **1** (unchanged).
### Added
- **macOS in CI**, x86_64 and arm64. The macOS workspace path had been a
unit-tested branch since 0.6.0 and the renderer gate had never run there;
both execute now. **The gate's committed hashes match on macOS**, so the
same document plus the same font files produce the same pixels on three
operating systems and two architectures.
- **Release binaries for all six targets.**
- **Friendly mode.** Launching the binary with no arguments at all — which is
what a double-click does — creates the workspace if it is not there, serves,
falls back to a port the OS picks if the configured one is taken, opens a
browser, and prints the URL for anyone who wants it.
- **Stop without a terminal.** A shutdown button in the interface, and the
endpoint behind it, with a graceful stop that finishes in-flight requests
and releases every project lock. Offered only by a server started for a
person: a plain `serve`, a service manager, or a container refuses, because
it owns its own lifetime.
- **Single-instance detection.** A second launch finds the server already
running and opens its URL instead of starting a rival on another port. A
claim left behind by a crashed process is checked before it is believed.
- **Docker**, from `scratch`: a 7 MB image with one statically linked binary,
no shell and no libc.
- **`DEPENDENCIES.md`** — the inventory §18 asks for, generated from
`cargo metadata` and drift-tested, so a dependency cannot be added without
appearing in it.
- The end-to-end smoke test PRD §17 specifies, as one test on every CI target:
create, add text and image layers, save, reload, preview, **one MCP mutation
through a real protocol conversation with a real child process**, undo, and
export.
### Fixed
- **Undo was broken for any project with an imported image.** Importing an
asset changes the document but is not an operation, so history never saw it
— and undo, which replays operations onto the nearest snapshot, replayed the
image layer onto a snapshot with no such asset and failed with a dangling
reference. Present since 0.3.0; found by the §17 smoke test on its first
run.
### Verified
- **PRD §17's smoke test is green on all six targets.**
- **A non-technical user starts and stops it without a terminal**: the
released binary launched by double-click created its workspace, served, and
opened a browser; the interface was used; and the Stop button ended the
process. A second launch opened the running server rather than starting a
rival.
- The whole pipeline runs inside the scratch container: create, add text,
install a font over the network, export a PNG.
### Notes
- **The Docker image's HTTP server is reachable only on the container's own
loopback**, because the server binds `127.0.0.1` and PRD §16.14 — whether to
expose it more widely, and what authentication that needs — is still open.
`--network host` works on Linux; the CLI and MCP surfaces are unaffected.
This is the one thing in the image that a decision, not code, is blocking.
- On Windows a double-click briefly shows a console window: the binary is a
console-subsystem executable, and changing that would hide the output the
command-line surface needs. Said here rather than left to be discovered.
## [0.9.0] — 2026-08-05
The reference interface, served by the binary. And the last way undo could
lose work, closed.
Document `schemaVersion`: **1** (unchanged).
### Added
- **A reference web interface**, embedded in the binary and served at `/` by
`assemblash serve`: project browser, canvas, layer tree, inspector, insert
text, drag to move and resize, group and delete, undo and redo, history, and
export. Written in TypeScript against the declarations generated from the
Rust types.
- **No canvas library and no second renderer** (PRD §16.3). The canvas is the
engine's own render, shown as an image, with plain DOM elements over it for
selection and handles.
- Layers marked `protected`, `readOnly`, or `locked` are shown as such and
their inputs are disabled, with the reason on hover. The engine refuses them
either way; saying so is what the interface owes a person.
- `GET /api/projects/{id}/preview.svg` — the same render one step before
rasterization — and `POST /api/projects/{id}/export`, which writes a PNG
into the project's own `exports/` directory.
- `assemblash serve --ui-dir` serves the interface from a directory, for
working on it without rebuilding the binary.
- `tsc` and the interface build now run in CI, and fail if the committed
`ui/dist` is not what the build produces — closing the drift-test-only debt
0.6.0 recorded.
### Fixed
- **Undo could destroy a hand edit.** Undo rebuilds from a snapshot, and a
document edited outside the journal was a state no snapshot had seen, so the
first undo after a hand edit silently restored the document from before it.
Opening a project for writing now records a diverged document before
anything else happens, so undo returns to what the user last saw. Hand
editing is supported (FR-9) — and it is the only way `protected` and
`readOnly` get set at all.
- **The generated TypeScript dropped the fields a tagged union sits beside.**
`Layer` is a union of payloads *plus* the properties every layer has; the
emitter kept only the union, so a client typed against it would not know a
layer had an `id` or a `transform`. Found by the interface being the first
thing to actually compile against those types.
- The MCP server's instructions had lost their line breaks in 0.8.0 and read
as one run-on paragraph with stray spacing.
### Verified
- **MVP criterion 12 — the interface opens, edits, and exports the same
document format used by the API.** Driven in a real browser against the
released binary: it opened a project the CLI and the API had written, added
a layer, changed its text, moved it, exported a PNG, and undid a change —
and the MCP server then read exactly the same document back.
- **What the canvas shows is what the export contains**, byte for byte,
because they are the same render. Asserted in CI, not argued.
- The interface serves only the files this build carries: a path is never
taken from a request.
- A hand edit survives a later undo; `open_read_only` still never writes.
### Notes
- The canvas shows the *rasterized* render rather than the SVG, and that is
the decision working rather than a departure from it: a browser handed the
SVG would re-render it with its own fonts instead of the pinned files in the
store, so the preview would differ from the export exactly where this
project cares most.
- `ui/dist` is committed. The binary embeds it, so `cargo build` and
`cargo install --git` must work with no Node involved; CI is what keeps the
committed copy honest.
- Handles are drawn for top-level layers. A layer inside a group is positioned
relative to it, and guessing at a transform chain the drag would then have
to invert is worse than not offering it.
## [0.8.0] — 2026-08-05
MCP writes. An agent can now change a document — reversibly, with a version
check, and never through a protected layer.
Document `schemaVersion`: **1** (unchanged).
### Added
- **Twenty mutating MCP tools**, named rather than one generic
`apply_operation`: `add_text_layer`, `add_image_layer`, `update_layer`,
`move_layer`, `resize_layer`, `rotate_layer`, `reorder_layer`,
`group_layers`, `ungroup_layer`, `duplicate_layer`, `delete_layer`,
`set_layer_visible`, `set_layer_locked`, `rename_layer`, `align_layers`,
`center_on_canvas`, `distribute_layers`, `snap_layer`, `undo`, `redo`, and
`export_document`.
- Every one carries all four safeguards FR-13 asks for — `dryRun`,
`expectedVersion`, protected-layer checks, and a transaction id in the
result — implemented once, in one function every tool goes through.
- `open_project` selects the project later calls act on, so a conversation
does not repeat its name. Every tool still takes an explicit `project`,
which wins.
- `export_document` writes a PNG into the project's `exports/` directory and
reports the path.
- Server instructions now describe the version check, the dry run, and which
layers refuse changes.
### Fixed
- **Ungrouping a group could modify a protected layer inside it.** Ungrouping
rebases every child's transform, which is a change to each child, but only
the group itself was checked. Deleting such a group was already refused;
dissolving it was not. Found by the protected-layer exit test trying every
mutating tool against a protected layer.
- **A project assembled by hand could not be undone.** A directory with a
`document.json` and no history has no snapshot to rebuild from, so its very
first undo failed. The first mutation now establishes the base from the
state before it — which is exactly what undoing it must return to.
- `get_layer` took `layer_id` while every other tool took `layerId`. It now
takes `layerId`, and still accepts the old spelling.
### Verified
- **MVP criterion 10 — a local MCP client applies a reversible layer
operation.** A real client, driving the actual binary over a real stdio
pipe: dry run changes nothing, the real call returns a transaction id, a
stale `expectedVersion` is refused, and **undo restores `document.json` byte
for byte**. Redo brings it back.
- **MVP criterion 11 — protected and locked layers cannot be modified through
normal agent tools.** Seventeen mutating calls aimed at a protected layer,
every one refused with `operationRefused` and the document byte-identical
afterwards; the same for a locked layer. The same tools succeed on an
ordinary layer, so the refusals are about the flags and not about the calls.
- Duplicating a protected layer is *allowed* — it does not touch the original
— and the copy is protected too, so it cannot be used to obtain an editable
clone.
- No tool can set or clear `protected`, and there is no generic
`apply_operation` escape hatch. Both are asserted, not assumed.
- An export name that is really a path is refused.
### Notes
- There is no tool that imports a file from a path: that is the unrestricted
filesystem access FR-13 rules out. `add_image_layer` references an asset
already in the document, and assets arrive through the CLI or the HTTP API.
- The MCP actor is always recorded as an agent. A transport that could claim
to be a human would make the audit trail a fiction.
## [0.7.0] — 2026-08-05
The MCP server, read-only. The first release an agent can point at a canvas.
Document `schemaVersion`: **1** (unchanged).
### Added
- **`assemblash mcp`**: a Model Context Protocol server over stdio, built on
the official Rust SDK. It is the third transport over the one operation
layer, after the command line and the HTTP API, and it shares the HTTP API's
open-project registry and its machine-readable error codes rather than
growing its own.
- Seven read tools: `list_projects`, `get_document_state`, `list_layers`,
`get_layer`, `validate_document`, `get_history`, and `get_canvas_preview`
(a real PNG, as an image content block).
- `--workspace` serves a workspace and its tools take a project name;
`--project <path>` serves one arbitrary folder and the project argument
becomes optional — the headless and home-lab flow, unchanged.
- Server instructions that say what Assemblash is, which mode this server is
in, and that these tools do not write.
### Verified
- **MVP criterion 9 — at least one local MCP client can inspect the
document.** An integration test spawns the *actual* `assemblash` binary as a
child process and drives it over a real stdio pipe with the SDK's client
half: initialize, list tools, call every tool, and compare the document that
comes back with the one on disk. It runs on Windows and Linux, x86_64 and
aarch64.
- The same server was additionally driven by the **official TypeScript MCP
SDK** — a separate implementation in a separate language — reading the
project list, the layers, the document, the validation report, and a
480×220 PNG preview.
- The tool list contains **nothing that writes**, and no `get_selection`.
- A project name that is really a path is refused with `invalidProjectId`
before it reaches the filesystem, over MCP as over HTTP.
- A failure leaves standard output empty: the protocol owns it.
### Notes
- **Read-only by design.** FR-13 divides MCP capabilities into read-only and
mutating and orders them. The write tools — with dry run, expected versions,
protected-layer checks, and undo transaction ids — arrive in 0.8.0.
- `list_projects` ships here rather than with the other workspace-aware tools:
it is read-only, and without it a client has no way to name the thing it
wants to inspect. `open_project`, which is stateful, is still 0.8.0.
- Tool results are objects, never bare arrays — `{"projects": [...]}` rather
than `[...]`. MCP requires a tool's output schema to describe an object, and
a strict client refuses a server that sends anything else. Found by pointing
the TypeScript SDK at it; there is now a test that every tool's schemas are
objects.
- Selection is client state (amended FR-7). There is no `get_selection` tool
and never will be; tools take explicit layer ids.
## [0.6.0] — 2026-08-05
The local HTTP API, and a home for the data it manages. This is the first
network-facing surface, and it listens on `127.0.0.1` only.
Document `schemaVersion`: **1** (unchanged).
### Added
- **Workspace.** On first run the binary creates an OS-appropriate data
directory — `%APPDATA%\Assemblash`, `$XDG_DATA_HOME/assemblash` or
`~/.local/share/assemblash`, `~/Library/Application Support/Assemblash` —
holding `config.toml`, `fonts/`, and `projects/`. `ASSEMBLASH_WORKSPACE`
overrides it. A project directory stays portable: the workspace is a default
location, not a container.
- **HTTP API** (`assemblash serve`) over the same operation layer everything
else uses: projects, document, history, validation, one operations endpoint
taking an `Operation` with an optional expected version and a dry-run flag,
undo and redo, asset upload, and PNG preview. Fonts come from the
workspace store.
- Every failure answers with one envelope — `{"error":{"code","message",
"details"}}` — with a stable machine-readable code, including a body that
does not parse (FR-12).
- A `ProjectId` type: a project name is checked to be a single ordinary
directory name before anything joins it to a path, so there is no code path
that can be handed `../../etc` (PRD §10.1).
- Published **JSON Schemas** for the document and for operations, served by
the API from the same generator that writes the committed copies, plus
generated **TypeScript declarations** at `schema/*.d.ts`.
- `assemblash workspace` prints (and creates) this machine's workspace.
### Fixed
- `NewLayerKind::Text` sent `font_family`, `font_size`, and `line_height` in an
otherwise camelCase wire format. `rename_all` on an enum renames its
variants, not the fields of a struct variant, and nothing noticed while the
only caller built the value in Rust. They are now `fontFamily`, `fontSize`,
and `lineHeight`; the old spellings are still accepted, so history journals
written before 0.6.0 keep replaying.
### Verified
- **Path-escape attempts are rejected**: through a project id, a URL-encoded
separator, a drive letter, a UNC path, a reserved device name, and an
uploaded filename. Nothing lands outside the workspace.
- **A stale expected version gives a 409** naming the expected and actual
versions, and leaves the document byte-identical.
- **First run creates a valid workspace**, checked by running it on Windows
and Linux. The macOS location is a pure function of `HOME` and is unit
tested rather than executed — macOS joins the CI matrix in 0.10.0.
- An uploaded file is stored under its content hash; the client's filename
contributes only an extension, and no scratch file is left behind.
- A dry run reports what would happen and changes nothing.
- Rendering through the API refuses a family the store does not have, and two
previews of an unchanged document are byte-identical.
### Notes
- **`127.0.0.1` only, and not configurable.** Exposing the API to a network
needs an answer to authentication first (PRD §16.14, open), and a settings
key is how that decision gets made by accident.
- The server opens each project once and keeps the session, so it is the
single writer for what it has open. Another process holding the same project
is a structured 409 with the offending pid, never a wait.
- Not in this release, and not implied by it: browser auto-open, single-
instance detection, a shutdown button, and `index.db`. Those belong to the
milestones that need them.
- CI does not compile the generated TypeScript with `tsc` — it is checked
structurally and for drift against the Rust types. The reference UI in 0.9.0
is what will compile it in anger.
## [0.5.0] — 2026-08-05
Fonts and render depth. The document has been reproducible since 0.1.0, but a
document is only half the input to a render — this release pins the other half.
Document `schemaVersion`: **1** (unchanged — `blendMode` has been in the schema
since 0.1.0; this release renders it, and widens what it accepts).
### Added
- **Font store**: a directory where every font file is named by the hash of its
own bytes, with an `index.json` recording family, style, weight, hash,
provenance, and licence. `verify` re-hashes everything and names the file that
changed — a font swapped behind the engine's back would otherwise change the
pixels without changing any document.
- TTF, OTF, TrueType collections, WOFF, and WOFF2 import. Web fonts are
decompressed on the way in and the *decompressed* bytes are what is stored and
hashed, so nothing decompresses anything while rendering.
- **One-time installer** for a pinned set of OFL families. What may be installed
is a manifest committed in this repository, pinned to one commit of the
upstream font project and to the sha256 of each file; a download that does not
match is refused rather than stored. It runs only when asked — rendering never
reaches the network, and fonts already installed keep working with none.
- Blend modes `normal`, `multiply`, and `screen` are rendered. A group whose
child blends is isolated, so a blend does not reach past the group it is in.
- `assemblash font add`, `list`, `install`, `verify`, `licenses`, and `remove`;
`--font-store` on `render` and `export`, also readable from
`ASSEMBLASH_FONT_STORE`.
### Changed
- **The first text baseline now sits one ascent below the layer box, read from
the font file** — 0.1.0 used one whole font size everywhere, which was
consistent but not typography. Every rendered image moves slightly; the gate
goldens were regenerated deliberately and the images reviewed.
- `render` and `export` refuse a document that uses text when no fonts are
named, rather than falling back to the old placement rule. Two commands that
place text differently depending on how they were called is worse than one
that insists on being told.
- A `blendMode` this build does not render — `overlay`, say, from a later
version — now loads and round-trips verbatim instead of failing to parse. It
composites normally and is never handed to the renderer.
- `cargo-deny` allows `CDLA-Permissive-2.0`, which covers the Mozilla CA root
certificate data the installer needs to verify an HTTPS download.
### Verified
- The same document plus the same font bytes hash to the same PNG on Windows
and Linux, x86_64 and aarch64.
- A family the store does not have is a structured error at every level — store,
renderer, and command line — and never a substitution.
- Installing verifies the manifest's hash: a tampered download is refused and
nothing is written.
- Multiply and screen composite correctly through the whole path from
`blendMode` to pixels, and an isolated group contains its child's blend.
- The store's index is byte-identical whatever order fonts were imported in.
### Notes
- No font binaries ship inside the executable. The deployment story is one small
static binary, font files are megabytes each, and a sha256 in a committed
manifest pins them at least as tightly as embedding would.
- The store has no default location yet; it is named explicitly. The workspace
in 0.6.0 gives it a home.
- FR-7 is **complete at thirteen operations**. `select` is not the fourteenth
and never was: selection is a client concern, not document state, and the PRD
was amended accordingly. Earlier entries in this file that say "13 of 14"
describe what was known at the time.
## [0.4.1] — 2026-08-04
### Fixed
- `assemblash history` printed `operation` for the layout operations instead
of naming them. The audit trail is meant to say what was done, so a fallback
label for operations the printer had not been taught about defeated the
point. Found by using the released 0.4.0 binary.
## [0.4.0] — 2026-08-04
Layout operations. Typed geometry an agent can use instead of guessing at
positions (R2).
Document `schemaVersion`: **1** (unchanged — this milestone adds operations,
not fields).
### Added
- `Align` (left, right, top, bottom, centre horizontally, centre vertically),
`CenterOnCanvas`, `Distribute`, and `SnapTo`, applied through the operation
layer like everything else: validated, journalled, and undoable.
- `get_bounding_box` and `find_overlaps` as read-only queries returning data.
They are not operations and are not journalled.
- `assemblash align`, `center`, `distribute`, `snap`, `bounds`, and
`overlaps`.
- Bounding boxes now account for rotation, by taking the extent of a layer's
rotated corners. Grouping uses the same maths, so wrapping a tilted layer no
longer produces a container too small to hold it — the approximation 0.2
left behind with a note.
### Verified
- Aligning is idempotent, is deterministic across identical documents, and
never changes a width, a height, or a rotation.
- Centring puts the set's bounding box on the canvas centre and keeps the
layers' positions relative to each other.
- Distributing leaves equal gaps whatever order the ids arrive in.
- `find_overlaps` agrees with a brute-force check, is symmetric, and never
reports a layer against itself.
- Every layout operation undoes to a byte-identical document.
### Notes
- Three behaviours the property tests forced, each documented where it lives:
movements below a billionth of a pixel are not movements (otherwise
aligning twice never settles); distribution measures its span from the
extent of all the layers, not the first and last in sorted order; and gaps
are never negative, so layers that do not fit end up touching rather than
being reordered.
- A layer inside a **rotated** group is refused with a typed error rather than
placed wrongly. This build composes translations; a position quietly wrong
by a few degrees would be worse than saying no.
- Layout operations take explicit `ids` lists and do not use a selection.
`select` remains unimplemented — 13 of the 14 operations FR-7 lists.
## [0.3.0] — 2026-08-04
History and the safety core. Every mutation is now recorded, reversible, and
refusable.
Document `schemaVersion`: **1** (unchanged — `version`, `protected`, and
`readOnly` are additive with defaults, and documents written by 0.1.0 and
0.2.0 still load unchanged).
### Added
- Append-only journal at `history/journal.jsonl`: one JSON object per line,
recording the operation, the actor kind (`human`, `agent`, `script`,
`adapter`), a timestamp, and the layers touched (PRD §10.5). It is never
rewritten, so it stays greppable and cannot be quietly revised.
- Undo and redo, across restarts. Rebuilding a state takes the nearest
snapshot and replays operations forward, reusing the ids the journal
recorded — which is what makes undo byte-identical rather than merely
equivalent.
- Transaction ids on every entry, so a write can be undone by id (FR-13).
- `Session`: opening a project takes a lock file, checks expected versions
(PRD §10.3), and orders writes so a crash cannot lose work.
- Document `version`, incremented per mutation. A caller that passes the
version it last read gets a structured conflict instead of overwriting work
it never saw.
- Layer `protected` and `readOnly` flags (PRD §10.2), enforced in the
operation layer. Deleting a group is refused if any layer inside it is
protected.
- `assemblash undo`, `redo`, `history`, and `unlock`; `--actor`,
`--actor-name`, and `--expect-version` on the editing commands.
### Verified
- **Apply then undo produces a byte-identical document**, including after
closing and reopening the project, and for arbitrary-length histories.
- **A protected layer rejects every mutation** — delete, move, resize,
rotate, rename, show/hide, lock, group, reorder — and the document is
unchanged after each refusal (MVP criterion 11).
- **A process killed mid-write recovers cleanly**, on Windows and Linux,
x86_64 and aarch64. The test aborts a real `assemblash` process at an exact
point in the write path — after the journal append, and between writing the
temporary document file and renaming it — then reopens the project and
checks nothing was lost or corrupted.
### Notes
- There is deliberately **no operation to set `protected` or `readOnly`**. An
agent that could unprotect a layer is an agent that is not held by
protection at all; the flags are set in the document, and later by an
authorized surface.
- Opening a project reconciles against the document's recorded version rather
than by comparing content, so editing `document.json` by hand remains
supported (FR-9). Hand edits are not in the journal, so they are not part of
undo.
- Asset import is not undoable: reversing a file copy would mean deciding
whether to delete the user's file.
- `select` is still not implemented — 13 of the 14 operations FR-7 lists.
## [0.2.0] — 2026-08-04
The full layer model. Every mutation of a document now goes through one
operation layer, which is what the HTTP API and the MCP server will be
transports over rather than parallel implementations of.
Document `schemaVersion`: **1** (unchanged — the new layer kind is additive,
and documents written by 0.1.0 still load unchanged).
### Added
- Operation layer (`assemblash-core::ops`). Applying is transactional: the
operation runs against a copy, the result is validated, and only then is it
written back. A refused operation leaves the document exactly as it was,
never half-applied. Operations are serializable, so the value an agent sends
is the value that can be journalled.
- Dry run (PRD §10.4): what an operation would do, without doing it.
- Thirteen of the fourteen operations FR-7 lists: create, update, delete,
duplicate, move, resize, rotate, reorder, group, ungroup, show/hide,
lock/unlock, rename. Grouping boxes layers by their bounding box and re-bases
them, so the picture does not move; ungrouping puts them back.
- SVG layer type, referencing an imported SVG asset.
- Locked layers refuse changes unless a request says explicitly to override,
which is also the only way to unlock one.
- `assemblash add-svg`, and the CLI now reports what an SVG import removed.
### Security
- Imported SVGs are rewritten to an allowlisted subset of themselves before
they are stored. Scripts, event handlers, `foreignObject`, and references to
anything outside the file are removed; only same-document fragments and
`data:` image URIs survive. A DOCTYPE is refused outright, which closes off
entity expansion.
- Deeply nested markup is refused before it reaches the XML parser. Found by
the no-panic property tests: a few hundred bytes of nested elements
overflows the stack inside the parser, and a stack overflow aborts the
process rather than returning an error.
- Sanitising happens at import, so everything under a project's `assets/`
directory is safe by construction.
### Verified
- Property tests: arbitrary sequences of operations either succeed and leave a
valid document, or are refused and leave it byte-identical. Group then
ungroup restores every layer's position. A layer cannot be moved inside
itself. Duplicating a group mints new ids for the whole copied subtree.
- No-panic tests: hostile operations — ids that do not exist, indices past the
end, NaN, infinity, `f64::MAX` — applied singly and in sequence, and
arbitrary bytes fed to the SVG importer. Only a valid document or a typed
error is accepted.
- A document written by 0.1.0 still loads and validates.
### Not included
- `select`, the fourteenth FR-7 operation. Whether selection belongs to the
document or to each client changes the API, the MCP tools, and the UI, so it
is a product decision rather than something to settle while writing code. It
is not implemented, and FR-7 is therefore not complete.
## [0.1.0] — 2026-08-04
First release with running code: the Phase 0 vertical slice (PRD §13). It
proves the rendering approach and nothing more — there is no HTTP API, no MCP
server, no undo, and no user interface yet.
Document `schemaVersion`: **1** (initial).
### Added
- Cargo workspace: `assemblash-core` (document model, validation, storage),
`assemblash-renderer` (SVG and PNG), and the `assemblash` binary.
- Document schema version 1: canvas, assets, and a nested layer tree of text,
image, and group layers, with z-order, transforms, and opacity. Unknown JSON
keys are preserved through a load-and-save cycle. Reserved fields
(`blendMode`, `effects`, `constraints`, text `runs`) exist with defaults and
round-trip untouched; nothing interprets them yet.
- JSON Schema for the document, generated from the types and committed at
`schema/document.schema.json`.
- Validation reporting every problem in one pass as typed errors (NFR-4):
dimensions, opacity, colours, duplicate ids, dangling asset references, and
asset paths that try to leave the project (PRD §10.1).
- Project storage (FR-9): `document.json` plus an `assets/` directory. Saves
are written to a temporary file and renamed into place. Assets are copied in
and stored under their content hash, which also detects a file edited behind
the engine's back.
- Rendering: document to SVG as a pure function, then SVG to PNG through
resvg. Exported PNGs carry document id, schema version, and renderer version
as metadata (FR-11), plus an optional caller-supplied timestamp.
- Fonts are loaded only from files named by the caller. There is no system
font fallback anywhere in the pipeline; a missing family is an error, never
a substitution.
- `assemblash` command line tool: `new`, `add-text`, `add-image`, `render`,
`export`, `show`. Scaffolding for the spike, not the intended interface.
- CI on Windows and Linux, x86_64 and aarch64 (NFR-2), with formatting,
clippy, and a dependency licence allowlist (R8) enforced.
### Verified
- The Phase 0 renderer gate passes on all four CI targets: a document survives
save, reload, and re-render with an identical image; six reference renders
hash bit-identically across both operating systems and both architectures;
Arabic joins and runs right-to-left, Japanese draws, and combining
diacritics compose; a screen blend mode and a Gaussian blur rasterize
correctly.
- Release binaries are attached for the same four targets. macOS is not yet
built or tested.
### Decided
- License: Apache-2.0 (PRD decision 16.11).
- Versioning: SemVer releases with an independent integer document
`schemaVersion` (PRD decision 16.13).
- Implementation stack: Rust single-binary engine; SVG-first rendering via
`resvg`/`tiny-skia`; embedded-then-HTTP API; MCP over stdio
(PRD decisions 16.1, 16.2, 16.5, 16.6 — full rationale in PRD §16.1).
- The renderer choice is settled: resvg passed the Phase 0 gate, so the Skia
fallback is not being taken.