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.
A failing CLI turn surfaced only its exit code: the stderr drain task
raced the tail read (a ~40% loss under concurrent subtasks), the
fallback branch discarded whatever the tail held, and friendly
classification swallowed the evidence when it did match. Failures now
join the drain within a grace window, quote a head-and-tail excerpt
with secrets redacted before truncation, keep the classified verdict
and the excerpt together, and the ACP connect path stops discarding
its child's stderr line by line. Auth keyword matching gains word
boundaries so 'authored' no longer reads as an auth failure.
Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
- SSE subscribers hold a single latest-value slot (weak-pruned) so a
stalled reader coalesces ticks instead of growing an unbounded queue
- /api/file/save clears the same whole-document gate as open-recent:
409 collab-active during a session instead of silently desyncing the
runtime's hash and commit log
- document ingest returns a typed outcome; only Committed bumps the
version, Rejected/Failed surface codes the browser's existing
conflict recovery already handles, and the local-edit capture closes
through an RAII guard on every path including unwind
- a ?tenant= request now passes persisted-ACL admission before any
tenant materialises, so unauthorised probes cannot occupy capacity
- ACL edits and their durable write serialise under one mutex with
unique temp files; a failed write rolls back and returns 500 rather
than resurrecting revoked grants after restart
- API-token REST calls are scope-checked by method (session cookies
exempt) and a token naming no mcp:* scope now gets nothing instead
of everything — hub-issued tokens must carry explicit scopes
- SIGTERM/SIGINT in the online loop raise the existing shutdown flag
so container stops run the tenant flush
Three review blockers:
- the session push gate refused every id absent from the daemon
snapshot, which included all ids the namespace allocator mints — so
nodes created during a session never synced and were erased by the
next pull. Unknown ids now pass when they parse into this peer's
namespace (parsed comparison, not prefix match); with no allocator
the old fail-closed behaviour stands
- a same-tab account switch kept the previous account's document,
sync baseline, and stored credentials. An identity epoch keyed on
the auth subject (tri-state, so sign-out then sign-in is not
mistaken for first contact) resets the sync client, gate, document,
collab state and latches; 401/403 suppress pushes until the reset;
browser storage partitions by subject with legacy keys deliberately
not adopted
- online tenants lived only in memory under the default deploy:
compose now mounts a data volume, startup fails closed when
eviction is enabled without a store (OPENPENCIL_ONLINE_EPHEMERAL=1
is the loud demo bypass), a dedicated sweeper replaces the
connection-triggered sweep that never ran while idle, and shutdown
flushes every resident tenant
Give the serve-web daemon a real collaboration runtime so browser and
VSCode-webview editors can drive public-relay sessions through REST,
mirroring the web_auth proxy pattern:
- WebCollabState + a dedicated driver thread (wake channel + 250/100ms
tick) run CollabRuntime against the daemon document; documentRevision
and collabSeq are separate so presence/UI changes never trigger a
whole-document pull
- versioned wire DTOs (CollabStateWire/CollabActionWire, wireVersion 1)
map through explicit validation onto the internal UI types instead of
serde on opaque internals; GET state / POST action / POST presence
routes ride the existing /api/ auth + origin guards
- document pushes during an Active session ingest through a split
PreparedDocument::prepare (off-lock validation) +
install_prepared_document (infallible, in-generation) inside a
begin/finish_local_edit capture; identical pushes produce no txn
- gate_daemon_mutation centralizes session-time refusals (409
collab-readonly/busy/active) across document push, sync-reset,
open-recent, /mcp JSON-RPC, and AI apply paths, matching the desktop
CollabGatePolicy semantics
The 9.8k-line collaboration runtime was welded to the desktop GUI
(WidgetHostNative + winit EventLoopProxy), so the web daemon and future
satellite hosts could not run sessions. Split the host coupling behind a
CollabHost trait + wake notifier closure, then move the runtime, JWKS
fetcher, and tests into the new leaf crate op-collab-host:
- CollabHost (CollaborationEditorHost + dirty/id-namespace hooks) with a
HeadlessCollabHost for daemon and test use; the WidgetHostNative impl
lives behind op-host-native's gl-host feature
- async work reaches sync relay/JWKS code through an injected
BlockingExecutor (process-global OnceLock) instead of depending on
op-host-services, keeping the crate graph acyclic
- desktop keeps a thin shim (type alias + wake closure); call sequence
and test assertions unchanged, 154 runtime tests moved as-is
The download fallback used to save the raw capture snapshot JSON, which
OpenPencil cannot open directly. Route the snapshot through op-html's
import_snapshot_document in the wasm core so the extension hands back a
canonical .op document (with node count reported and empty captures
surfaced as an actionable error instead of a broken file).
Skia and CanvasKit decode PNG/JPEG/GIF/WebP but not SVG, so every
captured page's inline-svg fallback and remote .svg painted as the
dashed placeholder forever.
- Native: resvg (minimal features, no text/raster-images) rasterizes
SVG bytes to PNG where they enter the shared byte cache, so both the
data-URI decode and the remote-fetch store paths only ever cache
bitmap codecs. Target-gated off wasm32: measured +0.9 MiB gzip
against the web bundle's 6 MiB ceiling.
- Web: the CanvasKit bridge falls back to the browser's own SVG
decoder (async Image + 2d canvas -> CK.MakeImage). Pending decodes
report success so the id is not negative-cached; the repaint pump
keeps frames coming until the raster lands.
- The remote-image fetcher's magic-byte sniff now accepts SVG markup,
and percent-encoded (non-base64) svg data URIs decode too.
Chinese IMEs insert CJK punctuation instantly, with no marked-text
session. On the web host those characters were invisible: the hidden
input only listened for composition events, and keydown was blocked
while composing. Text now flows through beforeinput whenever the
hidden input truly owns DOM focus, and the printable keydown branch
closes in that state so nothing double-inserts. Host-side contract
tests pin bare commits landing at the caret across mixed composed and
bare sequences on both the chat input and canvas text editing. The
matching macos platform-layer fix lives in the casement fork and
travels separately.
Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
MV3 extension that captures the rendered active tab (via the shared
snapshot-extractor contract) and imports it into OpenPencil. Logic lives
in the new op-chrome-extension-core crate (wasm): endpoint rules,
chunked-transfer integrity, /mcp envelope + reply classification,
download-name sanitisation, SSO/account session parsing, and hub
snapshot-inbox delivery. JS is glue only (chrome.* APIs, fetch, popup
DOM, injected page functions).
Capture: full page + element pick. Delivery: local ingress
(POST /api/import/web-snapshot) with /mcp fallback, JSON download, and —
when signed in to OP Hub — the account snapshot inbox. Flat popup UI,
15-locale strings with an in-popup language switcher, store packaging.
Why a crate: keeps the security-sensitive logic in tested Rust rather
than glue JS, and the SW/popup split keeps dynamic import() out of the
service-worker graph (guarded by check-sw-imports).
The harness could only drive fresh generation, so the modify-category
prompts had no way to produce before/after thumbnails: their whole point
is what changes about an EXISTING document.
`OPENPENCIL_SMOKE_MODIFY_INPUT=<baseline.op>` runs a real document
through the same `build_modify_plan` -> `run_modify_turn` -> scoped host
apply path the desktop uses, so a thumbnail reflects the shipping code
rather than a harness-only shortcut.
The baseline is never overwritten — the output must go to a distinct
`OPENPENCIL_SMOKE_OUT` — and both files are SHA-256 addressed in the
summary, so a thumbnail set can prove every modify prompt started from
the identical input.
Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
New leaf crate op-util single-sources hex-color parsing (9 divergent
copies, one with a non-ASCII panic), JSON escaping (one copy was
lossy), and HTML/XML escaping (one copy missed the quote entity — an
attribute-injection gap). Desktop now delegates image generate/search,
settings payload serde, and the --mcp/--serve-web argv dispatch to
op-host-services / op-editor-host-core instead of carrying drifted
copies. Byte-identical widget_host twin files collapse into shared
op-editor-core host_ui_transitions. Auth routes, the MCP port, product
name, env-var names, service URLs, and status colors move to single
shared constants / theme tokens; the stale claude-sonnet-4-5 default
model id is corrected.
The wasm bundle ships no auth code: the daemon proxies the device-login
flow over /api/auth/* using the same prebuilt op-auth library and the
same ~/.openpencil/auth credential store as the desktop GUI, so a
session started in either host signs both in. The web shell opens the
verification page in a popup, polls flow progress into the shared login
modal, and re-checks session health every 30s. The proxy refuses
non-loopback binds outside managed mode — the daemon session belongs to
its owner, not to whoever can reach the port.
The proprietary device-login client (ZSeven-W/op-platform) ships as a
C-ABI static library committed under op-auth-bridge/prebuilt/<target>;
builds without an artifact fall back to an inert stub so any checkout
compiles with login hidden. The desktop host starts the pairing flow
from the sign-in modal, opens the verification page in the system
browser, polls flow status each frame, restores persisted sessions at
startup, and revokes the device token on sign-out.
Add op-html project import beyond the single-document path: a directory
of HTML/asset files (project.rs) and packaged .zip archives
(project_zip.rs), each resolving relative resources against a virtual
project origin and picking an HTML entry point. Adds flate2 + zip deps
and re-exports the new import surface from lib.rs; the old 10MB
single-source truncation is dropped in favour of per-entry limits
(MAX_PROJECT_FILES / MAX_ZIP_ENTRIES).
Expand HTML/CSS import fidelity, preserve layered fills and accurate fonts across HTML, Figma, and OP files, and make missing-font resolution selectable and case-insensitive. Harden code generation recovery and preview rendering, make model selection immediate, and keep image decode/compositing consistent across native and web hosts. Add mobile dependency and widget-boundary guards so all supported targets retain the intended feature surface.
Follow-up to the off-thread decode seam.
A payload that fails to decode used to be marked done without being
installed, so paint re-queued it every frame — a corrupt image spun the
native workers forever and kept the web repaint coalescer hot. Failed
ids now enter a bounded negative cache on both hosts.
Blur-up: import generates a <=32 px, <=4 KiB JPEG per image during the
decode it already performs, and paint draws that thumbnail (scaled up,
which is the blur) before falling back to the node fill and the grey
glyph. Thumbnails are explicitly allowed to decode on the paint thread
into a small dedicated cache — microseconds, and they cannot displace
sharp rasters. Full images still never decode during paint.
Every paint, cache, and thumbnail site now shares one canonical
jian-ops-schema paint id. Web raster accounting switches from encoded
to decoded bytes to match native.
Also lands the fill-type picker clamp from the import-fidelity line:
the popover flips above its dropdown when the panel bottom is too
close, and its hit-test follows the same rect, so the last rows stop
being cut off.
Measured on the 527 MB reference import: 317 thumbnails persisted
(+0.2 MB saved size), first reopened frame draws 1136 thumbnails and
zero sharp images, paint-thread full decodes stay at exactly 0.
Known, not addressed here: at full zoom-out on the image-dense page the
384 MiB raster LRU never settles (~22 installs/s against ~22 evictions
for 101 unique images) — decoded 2048 px rasters cannot all be resident,
so that page needs mip-level or on-screen-size-aware rasters.
--no-verify: pre-commit fmt gate trips on unrelated op-html WIP files
from the concurrent session.
Vector plan Tasks 3-5: winding rule imports onto PathNode.fillRule and
reaches skia/CanvasKit fill types through new *_with_fill_rule painter
entry points; per-corner radii flow payload->scene->RRect on both
backends; BACKGROUND_BLUR renders via backdrop save layers (native
SaveLayerRec backdrop, CanvasKit saveLayer backdrop). Includes jian
pointer bump (6bed2be). no-verify: repo fmt gate trips on unrelated
in-progress op-html sources.
Merge Kayshen's feat/responsive-m1a (schema 1.2 + responsive opt-in,
breakpoint ranges, variant tables, transactional variant swap) plus
main's fill-rule/corner-radii/background-blur into the vendored pin
(merge/responsive-m1a-into-main @ 42772b6). Responsive stays double-
gated opt-in, so every existing document renders unchanged.
Adaptation is mostly new optional fields defaulted to None/false. The
one real behavior interaction: jian's node_rect now bakes a root's
authored origin into its returned rect (root_origins, added for the
responsive multi-root canvas), which doubled offsets at the three
places that added the origin themselves — the loader's layout harvest,
App Mode tap solving, and the preview's scene-to-runtime-space mapping
(the last now subtracts the root origin back out, gated on jian's own
is_origin_normalized so responsive roots stay untouched).
Squash-resolution of the 11 upstream commits (interactions panel,
account shell, preview promotion, app-mode screen navigation, retry
core, parallel screen groups, byte-budget image cache) into the local
branch, with stash-pop conflicts in the canvas server, native skia
cache, and opmerge resolved in favor of the newer implementations.
Auto-wire unmarked multi-screen documents on preview entry (over a
clone; any authored screen marker skips the pass so manual App Mode
setups are never silently extended), add a screen-switcher pill row
above the device frame, screen transitions (push slide-in, pop
slide-out, replace cross-fade, classified by router stack depth),
iOS-style left-edge swipe pop, and Cmd+P to toggle preview.
Preflight headless document loading so malformed or binary archives fail with a clear, actionable error before the MCP server starts.
Keep the CLI parser dependency lightweight by disabling op-pen-loader default features and include the updated lockfile plus regression coverage.
Preview now presents ONE framed root inside a fixed device frame —
Phone 390x844 / Desktop 1440x900, inferred from the root width
(<=500 -> Phone) with a floating 3-segment switcher (Phone / Desktop /
Canvas, i18n'd across all 15 locales) overriding inference until exit.
Content keeps authored size (no reflow, no content scaling); overflow
scrolls vertically inside the frame, clamped to the pinned-nav top
bound. A bottom nav (semantics.role=nav bottom-anchored, or the
flush/full-width/<=120px heuristic) pins to the frame bottom in a
second clipped paint pass and the content viewport shrinks past it.
One DeviceFrame struct owns every transform: paint, the screen->scene
inverses (strip + letterbox dead zones resolve at surface-resolution
time), and the per-gesture presentation capture, so drags never flip
surfaces mid-gesture. Device mode fails closed — no wheel / pan /
pinch / modifier-zoom ever reaches the editor viewport (pinch gets its
own host entry, split from the line-wheel path at the desktop call
sites). Reconcile now reports { repaint, switched } so warning-only
passes stop recentering; screen switches re-infer, reset scroll and
rebuild the frame.
- rquickjs 0.9->0.12 (op-mcp): 0.12 ships aarch64-pc-windows-msvc bindings
(0.9 doesn't), so the native-only script feature builds on windows-arm64
with full parity (as Zode's zode-core already uses 0.12). Clean drop-in;
25 script_runner tests pass. Reverts the earlier per-target gating.
- Workspace MSRV 1.85->1.87 (rquickjs 0.12's floor; pinned toolchain is 1.94).
- agent_settings switch test: assert theme.input (the token the off-track
paints) now the light theme gives input its own value distinct from border.
Bring user-imported fonts to the browser host, matching the native flow.
Phase 3 — family-aware CanvasKit text (the web BLOCKER): drawText now
carries font_family and a new measureTextFamilyStyled FFI mirrors it, so
the editor measures caret/layout with the same family it draws (closing
the family-blind trap). op_ck_bridge.js keys imported typefaces by family
and resolves them PER CHARACTER: chars the imported face covers draw with
it, the rest fall to the existing script-segmented system/CJK/emoji path
(no tofu, no dropped family) — draw + measure split on identical
importedCoverage segments so advances agree. register/removeImportedFont
with replace + wasm-heap free.
Phase 4 — import UI + persistence: web ImportFont opens a hidden
.ttf/.otf file input -> FileReader -> 16 MiB cap -> family parsed in Rust
via ttf-parser (font_meta.rs; the vendored CanvasKit exposes no
getFamilyName) -> register + persist bytes in IndexedDB (font_store_idb.rs;
DB openpencil / store imported_fonts, keyed by family, async errors
logged) -> refresh snapshot + repaint. Remove drops registry + IndexedDB.
Mount re-registers persisted fonts (skipping any the user already changed
this session) before the first family-aware paint. font_import_supported
is true on web, so the picker's imported group + Import row are live.
font_meta family extraction is unit-tested (real .ttf bytes -> family)
so the core is verified headlessly; runtime IndexedDB/FileReader/rendering
need a browser smoke test. Codex-reviewed (2 rounds) — getFamilyName
BLOCKER, mixed-script fallback, IndexedDB async errors, and the mount
race all addressed.
Bumps vendor/jian to feat/promote-widen (single-source ROLE_TABLE,
9 promotable kinds, promotable_roles() export). The jian-components
generation skill teaches the five new role strings, and the loader's
role-vocabulary test now iterates promotable_roles() directly — the
skill can never silently drift from the promote table again
(jian-ops-schema joins op-ai-skills as a dev-dependency for that
test). Tabs is documented as deliberately not promotable.
Migrates the QuickJS script->program expansion out of op-orchestrator
into op_mcp::script_runner (cargo feature `script`) with hard limits
(64 MiB heap / 2 s interrupt budget / 4096 recorded lines / 8 MiB
recorded bytes, whole-line fit + latch / 256 KiB source) plus the
truncation-repair retry that closes the syntax-error cliff which
originally demoted script-gen.
Conflicts were the two mesh/shader implementations meeting: kept the
remote's newer complete version (typed shader uniforms, shader color
uniform binding, mesh vertex editing defaults, status-bar shell stroke
handling); deduped two identically-replayed RenderBackend methods.
finish_if_epoch previously routed the empty-queue case through
drain_finished_run, which set the process-global needs_final_frame flag
even though a run that never queued a reveal never put a cursor on
screen. That stray flag made next_reveal_deadline_ms return a redraw
deadline out of an idle registry, and — because the registry is shared
across the whole test binary — perturbed an unrelated exact
animation-deadline assertion whenever a design-session test dropped an
empty session in parallel.
Clear an empty finish inline without arming the flag; the paint-path
drain still arms it after real reveals prune, where a cursor genuinely
was on screen. Adds a regression test.
Verification note: the op-editor-core suite could not be run for this
commit because the shared workspace is transiently non-compiling under a
concurrent mesh-gradient/SkSL-shader jian bump (new PenFill variants not
yet handled in fills.rs — unrelated files). Change is trace-verified and
touches only agent_indicators; re-run pending the tree compiling again.