Backspace, forward-delete, and arrow keys were falling through to the
canvas when the Scene Template (Asset) Center was open, so the search
field could not be edited and the selection behind the overlay could
be deleted. Route these keys to the gallery's focused field for native
parity and add regression tests.
Nine new style guides and the projector-board corpus additions grew the deck
skill set: a deck prompt now resolves 13293 tokens with `design-principles`
(438) included, so at 13200 the Step 3 knapsack dropped `design-principles`
while the report still showed headroom — exactly the ordering alarm
`no_skill_loses_the_budget_race_while_the_budget_has_room` guards. Per that
test's own guidance ("the fix is the phase budget, not this assertion"), raise
Generation to 13500, keeping ~200 tokens of margin over the measured 13293.
The reviewed protector localizes `rust_eh_personality` in the hardened Mach-O/ELF
prebuilts, so those archives no longer carry the symbol and link into a Rust host
with no clash — the namespacing shim is a correct no-op there (the host build
already links them). The MSVC COFF pass-through still carries the symbol and must
be namespaced. Assert namespacing only for archives that contain the host
personality, and exercise the idempotency path against the COFF archive that
still has it. Fixes the multi-platform "Test (host, Linux)" failure introduced
by the hardened v0.8.4 artifacts.
Resolving an earlier stash conflict picked a stale variant of the boundary
mutation-test fixtures: it used op-host-desktop/src/collab_runtime paths (the
runtime actually lives at op-collab-host/src/runtime, which the gate and
-cases.sh reference) and dropped the pairing-code location blocks the gate
requires in the locator ingress fixtures. Restore both so the baseline
'minimal safe boundary' test and the renewal mutation cases pass again; all 61
mutation tests green.
The tag pinned jian at 453bd70 (2026-08-08), but the code calls
LayoutScene::node_path_at_doc_point_for_fill, only added to jian in 2edfc36
(2026-08-09) — so `nix build …#openpencil` failed to compile. The committed
flake.lock was also older than the flake.nix pins for jian and casement, so
`nix flake show` errored without --no-write-lock-file.
Bump the jian input to 2edfc36 and regenerate flake.lock (jian + casement now
match their flake.nix pins, with correct narHashes). Fixes#208.
Add production deployment configs for the collab relay and locator: region-split
compose (CN/global), direct nginx gateways and location maps, the CN docker-user
firewall install/verify/validate tooling and systemd unit, and an SoftHSM-backed
locator variant. Extend the collab security/deployment boundary checks to cover
the new artifacts.
The CN application host address is a placeholder (10.0.0.10); substitute the real
private address at deploy time.
The notes described a purely corrective release, which stopped being true
once deck export, the scene-template catalogue, the style-guide listing and
batch frame export landed on the same version.
Exporting every frame on a page meant calling export_item once per frame
and inventing file names; the editor's own batch has resolved collisions,
capped long AI-authored names and skipped hidden frames since v0.8.3.
export_frames reuses plan_frame_exports rather than re-deriving any of it,
so an agent writes what the Export panel writes.
Partial failure is reported, not raised: one unrenderable frame must not
throw away the files that did land.
get_deck_boards is the read half. Slideshow control is deliberately absent:
entering preview is a host mode transition rather than document state, and
a file-backed MCP session has no window to present in. The board list is
the part an agent can act on — verifying a deck before exporting one.
get_style_guide answers "give me a guide matching this" and searches the
shipped corpus only, so an agent choosing an asset could neither see what
exists nor reach the DESIGN.md files the user imported — the material most
worth picking was invisible to MCP entirely.
list_style_guides merges both, imports first as the Asset Center paints
them, and keeps them apart by id so an import cannot take a shipped guide's
place. Passing id returns that one guide with its markdown, which is what
closes the loop for imports.
No data moved: the canonical registries already live in op-ai-skills, and
only the swatch colours and summary line were ever widget-layer concerns.
The tool reads the host's imported files, so it is a LocalFilesystem
surface and denied online for the same reason list_theme_presets is.
The catalogue shipped in v0.8.3 behind File > New from template, so an
agent could only ever start from a blank frame. The 16:9 deck templates
live in it too, which makes it the entry point to the presentation
workflow rather than a convenience.
Adopting a template is its own EditorCommand instead of an authored
subtree insert: a template's boards and the palette they resolve against
have to land in one transaction, and OkWithCommand carries exactly one
command. AdoptSceneTemplate reuses adopt_template_boards, which already
encodes the decision a host without a document loader needs — take over an
untouched starter page, append anywhere else.
The two exhaustive classifiers both demanded a verdict, as designed: the
command is batchable, and it stays an unsupported bulk write inside a
collaboration session.
The PowerPoint, self-contained HTML and slide-per-page PDF exporters
shipped in v0.8.3 but were reachable only from the desktop File menu, so
an agent could build a deck over MCP and then had no way to hand it to
anyone.
Add export_deck across all three surfaces. The destination argument is
outputPath, not filePath: filePath is reserved workspace-wide for the .op
document a call targets and is intercepted before dispatch, so it would
route the call at a document that does not exist.
The tool writes a file rather than returning base64 — a deck is a
multi-megabyte artifact and the caller wants something shareable — which
makes it a LocalFilesystem surface, denied on the online /mcp for the same
reason save_document is. The CLI resolves a relative --output against the
caller's working directory, since the daemon's is not the caller's.
Delivery guidance goes in the slides skill rather than deck-contract,
which is already at its token budget.
v0.8.3's published binaries could never start the public relay; this
release carries that fix, the build guard that keeps a malformed injected
hub from shipping again, the control-plane failure classification, and the
update-check fallback.
The prebuilt op-auth libraries are version-pinned through their signed
provenance, so a 0.8.4 build requires 0.8.4 artifacts; the 0.8.3 archives
are refused rather than silently linked.
The anonymous GitHub API allows 60 requests per hour per source IP, so
users behind a shared egress can find the quota already spent by strangers.
The probe then reported "cannot reach the release server, check your
network connection" on a network that was working.
The releases Atom feed is served by github.com rather than api.github.com
and is not on that quota, so it answers when the API will not. The API
stays the first choice for its richer response; the feed only covers the
case where the API refuses to answer at all. Drafts never appear in it,
which is the rule the API path already applied.
Every locator call ended in map_err(|_| RelayUnavailable), so an expired
collaboration ticket, a rate-limited hub, an unreachable network and a
malformed response all reached the user as one sentence. Two of those are
not temporary and one is the user's own sign-in state.
Split the HTTP status classes at the control-plane client, map 401/403 to
the existing ticket-expired notice and 429 to a new rate-limited failure,
and leave only genuine transport and protocol faults as relay-unavailable.
This also gives collab.error.rateLimited its first real producer.
Report each stage failure on one credential-free line: only the payload-free
failure enum and a &'static str variant tag are formatted, never the error's
own Display, which is free to grow payload fields later.
The published v0.8.3 binaries carried a leading space in both injected hub
endpoints, so the strict runtime endpoint policy parsed them as no usable
hub and the public relay could never start in either region.
The existing guard could not catch it: repository secrets are not exposed
to the CI test job, so the assertion took its "nothing injected" branch on
every run, while the release job that does receive them ran no assertion.
Validate in a build script instead, where neither job can skip it.
A malformed injected endpoint is also a broken build rather than an
outage, so it now reports as not configured instead of temporarily
unavailable — the old copy invited waiting for something that could never
resolve on its own.
editor_ui_state.rs reached 804 lines, tripping the boundary check's 800-line file cap. Rewrap four trailing field doc comments (text unchanged) to land at 800.
The reviewed obfuscator that also performed standard hardening was never produced, so ABI-v2/v3 archives could not ship. Add an explicit, signature-bound signed-unobfuscated profile and accept ABI v3 (relay token) across the build-time validator, the audit gate, and the packager. The archive stays Ed25519-signed and ABI-pinned; private Rust symbol strings/paths/debug are retained by design and declared as hardening=op-auth-signed-unobfuscated-v1.
The signed-in account dropdown gains an "MCP Tokens" row that opens the
hub portal's /mcp-tokens page in a new tab, so an online user can mint a
per-account MCP token for external clients. It shows only in the
hub-served multi-tenant editor (gated on the ?tenant= param); the native
desktop never sets the flag — its local MCP is tokenless — and a
self-hosted serve-web without a tenant leaves it hidden. New key
account.mcpToken across all 15 locales.
The local desktop and a self-hosted serve-web daemon now admit any caller
that clears the Host/Origin boundary — a bare MCP client (Codex, an agent
runner) that has only the URL no longer needs the X-OpenPencil-Token the
CLI reads from the discovery file. The token added friction without
closing a hole: it was published in ~/.openpencil/.op-mcp-port and the
ping reply, readable by any local process anyway. The DNS-rebinding
boundary (Host/Origin screening) and CollabGatePolicy are unchanged, and
the online multi-tenant daemon keeps its own per-account Bearer auth in a
separate request loop — this only relaxes the local endpoint.
Writing a stub and exec'ing it from sixteen threads at once lets one
thread's still-open write fd ride another thread's fork() and hold the
freshly-written stub open for write, so execve reports "Text file busy"
and the turn surfaces a spawn error instead of the child's stderr. That
is a parallel write-then-exec harness artifact, not the drain behaviour
under test, and it flaked the stress case on the emulated aarch64
runner. turn_error now retries past the microsecond ETXTBSY window.
The real production check runs a HSM signing round trip that only
compiles on unix (a cfg(not(unix)) stub returns UnsupportedPlatform),
so its imports, the fixed_expired_claims helper and the
EXPIRED_NOT_BEFORE_UNIX constant are all unused on windows and tripped
clippy -D warnings there. Gate them with cfg(unix), matching the
existing UnixHsmRelayLocatorSigner import. Verified clean via
cargo xwin clippy for x86_64-pc-windows-msvc and unchanged on unix.
MCP clients such as the VS Code extension reject a tool whose inputSchema has an array property without the items keyword ("tool parameters array type must have items"). Add items to the sections/children arrays of design_skeleton and design_content, and add a test enforcing every array property declares items.
The new image content block widened ToolResponse::Ok far past its Err
variant, so clippy -D warnings failed to compile op-mcp. Box the field
(Option<Box<ImageContent>>) so the variant sizes are close again; the
serializer reads it through auto-deref and only the two constructors and
two test builders needed Box::new.
The get_screenshot MCP tool serialized its PNG through OkJson, which
wraps the payload in a text content block. Vision-capable MCP clients
(Copilot, Claude Code) therefore received a large base64 string as text
instead of an image, making the screenshot unusable for visual reasoning
(openpencil issue #204).
- Add ToolOutcome::OkImageContent carrying base64 + mime_type plus an
optional metadata JSON string
- Thread an optional ImageContent through ToolResponse::Ok and emit it
as an MCP {"type":"image","data":...,"mimeType":...} content
block in tool_response_to_json before any text block
- get_screenshot now returns OkImageContent (image/png) while retaining
image_base64 in its text metadata for the in-app chat-agent path
- Update the get_screenshot schema description to state the use case
(PNG image for visual verification) without the base64 implementation
detail
The reaped-turn drain wait assumed the pipe was at EOF so the task
"returns at once", but the wait is really for the drain TASK to be
scheduled on a saturated runtime. Under the concurrent stress test (and
the orchestrator's parallel turns) that scheduling latency outran the
two-second bound, the tail read back empty, and a child that explained
itself on stderr surfaced as "(no output captured)". Raise the grace to
thirty seconds: it only has to outlast scheduler starvation while still
capping a genuinely wedged reader. Stress test green 5/5 locally.
Add RELEASE_NOTES/v0.8.3.md (real-time collaboration, presentation
decks, runtime asset slimming, device-level theme, the Chrome web
capture inbox) and mirror the feature/roadmap updates — plus the Chrome
Web Store link — across README.md and all 14 translations.
Add the 1024px OpenPencil brand icon, bump to 0.8.3, fill in
categories / keywords / homepage / bugs / license / galleryBanner, and
replace the internal-path dev stub README with a real feature listing
grounded in what the extension does (custom .op editor, MCP config, AI
skills, code generation, chat participant).
The collab security-boundary scanner excludes external test modules
from its production-source checks, but only recognized the explicit
`#[cfg(test)] #[path = "..."] mod x;` form. A plain
`#[cfg(test)] mod production_check_tests;` (default filename) fell
through and its deterministic test signing seeds tripped the
"signing seed leaked into production source" rule. Resolve the default
`name.rs` / `name/mod.rs` sibling too. All 57 boundary mutation tests
still pass.
A jian bump made unpainted container bodies opt out of the click
hit-test, which also excluded empty placeholder boxes from image-drop
resolution — a drop over one inserted a fresh node instead of filling
it. Resolve the drop target through the new fill hit-test variant that
keeps empty bodies in the path, and bump the jian pin to carry it.
The shared widget layer enqueues an avatar fetch per unresolved peer,
but only the desktop host drained that queue — on wasm nobody did, so
every collaborator stayed on their initials fallback while the document,
cursors and presence synced fine. The web host now drains the queue each
frame and resolves each peer through the daemon's POST /api/collab/avatar
proxy (a direct CDN fetch is blocked by the wasm CSP), bounded to three
in flight. The self-account avatar keeps its separate web_auth_sync path
and never enters this queue.
Brings every preview back to what the current binary produces for the
current document: 80 frame and overview PNGs and 18 card JPEGs. Most
follow the tracking re-alignment and the board re-wrap, but a share of
them had drifted purely because the renderer was rebuilt — the audit
does not distinguish, and does not need to, since the invariant is
simply that a saved image equals a fresh render byte for byte.
Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
Preview PNGs and card JPEGs go stale two ways. Editing a document and
forgetting to refresh is the obvious one. The other leaves no trace at
all: the renderer changes, the document does not, and every previously
saved image is silently wrong while git is clean, the tests are green
and the gate passes. That is what happened to three minimal-keynote
frames whose content matched HEAD exactly but whose pixels no longer
did, because the desktop binary had been rebuilt underneath them.
Nothing but re-rendering and comparing bytes can see the second case, so
that is the only criterion here. It rests on rendering being
deterministic — two runs of one document must be byte-identical, which
`--selftest` checks; if that ever stops holding, this tool's verdicts
are void and the renderer is the thing to fix.
Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
Fifteen templates carried negative tracking past the cap the previous
rule failed to enforce — 51 text nodes in all, and nothing else in the
documents moves. minimal-keynote's four are hand-written literals in its
generator, so that one is fixed at the source rather than only in its
output; the rest come out of the shared `text()` clamp.
Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
The negative-tracking cap was stated as `round(fontSize * -0.02)`, and
rounding it was wrong in both directions: at 76px it let -2 through when
the real cap is 1.52, and at 72px it failed a legitimate -1.4 because the
cap rounded down to 1. A rule that both over- and under-fires is not a
threshold worth tuning, so the comparison is now against the ratio itself
with fractional values allowed.
Two narrowing decisions come with it. The cap applies only to runs that
actually contain Han characters — Latin and numeric display (page
numbers, stat values) legitimately sets -0.03 to -0.05em, and 160 nodes
across the library were being flagged for it. And the 64px boundary is
gone, because below 48px the ratio is already self-limiting.
The clamp sits in `text()`, the one entry every generator goes through:
a single tracking literal is routinely shared by several sizes, so
fixing call sites one at a time is guaranteed to miss some. `trackcheck`
makes the rule enforceable outside the two generators that imported the
kit, which is how the drift got in unnoticed.
Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
Sub-agent prompts budgeted first and compacted second, so the knapsack
paid for skills the compaction was about to delete — and when the bill
came due it evicted the deck material instead. A weak model then designed
a deck with no deck guidance at all, silently, because the corpus on disk
was still correct. The order is now compaction first, and the wrapper
that made the wrong order one call away is gone rather than kept for
symmetry.
`deck-contract` joins the sub-agent allow-set for the same reason its two
neighbours are there, and the deck budget arm asks the single classifier
instead of comparing widths inline: the hand-rolled bound had no aspect
gate, so a tall 1920-wide page claimed the deck budget and spent it on
slide teaching it could not use.
Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
Deck centring fired only when the prompt said "deck", so the agentic
loop — which has no prompt to read — left every board top-stacked with a
blank lower half. The geometric half of the judgement is now unioned in
at the point of use, covering the paths that have no request to consult.
Centring is an intent-tier move, though: an asymmetric board can be
exactly what the author composed, and the explicit-`justifyContent`
guard does not catch an author who simply placed content and never set a
distribution. So the geometric half is gated on the roots being this
run's own output, which the fresh and append paths can prove and the
whole-document finalize cannot. The prompt half stays ungated — a user
who asked for a deck stated the intent themselves.
Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
The horizontal-overflow floor sets `clipContent` on a row whose children
do not fit. On a scrolling screen that is recoverable — the content can
still be scrolled back into view. On a projector board it is content the
audience never learns exists, and the honest fixes (shorten it, re-type
it, split the page) are all decisions this pass cannot make.
So a board now reports the overflow and leaves the row visibly too wide.
The report travels as an echo rather than a repair record, because
nothing was repaired and a record would claim credit for a fix that
never happened: the finalize path notes it onto the user-visible
summary, and a sub-task, which has no summary to write to, logs it.
Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
Every threshold the geometry collectors use is tuned for a screen, and
the deck collectors that need their own floors land next. Giving them
the form through the single classifier now means each arrives without
measuring the root's width for itself.
No collector branches on the form yet. The debug line is there because
until one does, "the deck floors did not fire" and "the board was never
classified as a board" are the same symptom from the outside.
Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
The design lint detectors need the same answer the repair passes do —
what kind of surface is this root — and the detectors sit in a crate
below the orchestrator, so the judge has to live there too. Otherwise
each detector would re-derive the form from a width comparison of its
own, which is precisely the drift the single classifier exists to stop.
Nothing changes behaviourally: the orchestrator re-exports the types so
every existing import path still resolves, `detect_all` classifies its
own root, no detector branches on the form yet, and the plan layer's
mobile-width constant now aliases the classifier's band instead of
carrying a second copy of the number.
Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
Deck output kept inheriting page reflexes — screen-sized type, page
margins, a row clipped instead of split — because nothing in the corpus
stated the laws a 16:9 board is read under. `deck-contract.md` states
them once (back-row type floors, density budgets, narrative arc, the
slop bans), and six style guides give the new deck templates the same
authored voice when a request names one.
The generation budget moves to 13200 so the deck material is additive
rather than evicting the knowledge skills it depends on, and a standing
test pins the report to the knapsack's own accounting — a
budget-exhausted drop now provably means the skill did not fit.
The CJK kerning rule lands on minimal-keynote, whose display tracking
was tight enough to collide glyph side bearings at large sizes.
Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
Every multi-board template laid its boards out in one long horizontal
strip, so opening a seven-board carousel dropped the user onto a canvas
they had to pan sideways to survey. Three per row matches the deck kit,
and the extra row gap is deliberate: the canvas draws frame names at a
fixed screen-space offset, so at fit-to-screen zoom a plain column gap
lets the second row's labels sit on top of the boards above them.
knowledge-carousel additionally takes hard line breaks in two body
paragraphs. Greedy wrapping put a comma at the start of a line and left
a lone full stop on the last one, which are both CJK line-break faults;
authored breaks remove the engine's freedom to reintroduce them.
Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
Wires the new decks into the catalog, the preview grid and all fifteen
locales, so they are reachable rather than merely present on disk.
The preview cards ship as JPEGs like the rest, which grows the staged
scene-template payload; both figures in the asset notes are refreshed
so the wasm budget stays auditable against a real number.
Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x