Closes the remaining review findings:
- an online conflict auto-accept stashes the overwritten document
(single slot, cleared on identity change) and tells the user the
remote version was applied and Undo restores their work — the apply
was already undoable via the initialized() latch, now pinned by
tests on both sides of the seam and named in all 15 locales
- every wasm daemon request goes through one tenant-aware URL builder
(idempotent stamping at construction and dispatch), fixing the AI
transport, model catalog, and image panel requests that dropped the
?tenant= parameter on shared pages
- tenant restore and eviction persistence no longer touch the
process-global thumbnail registry, so two restored accounts cannot
capture or overwrite each other's image state
- document pushes parse and validate before the state lock; only the
baseVersion re-check, gate, and infallible install run under it
- 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
Tier-1 online collaboration: an owner grants accounts into their
tenant's ACL (grant/revoke/list under /api/share/), and a visitor
addresses it per request with ?tenant=<ownerId> — query rather than
header because EventSource cannot set headers, and share routes always
administer the caller's own tenant so a grant is not re-shareable.
The wasm shell threads the parameter through its four XHR helpers and
the event stream in one place. A shared tenant has no collaboration
session, so the 409 auto-resolve now also accepts when the daemon
advertises serveMode online (the daemon's counter is the total order
and SSE is already delivering the newer document; the old latch would
freeze a visitor permanently). Evicted tenants persist document + ACL
under sha256(user_id) directories via atomic writes — written before
the registry remove so no instant lacks both copies; unwritable
tenants stay resident and unloadable files are set aside as .corrupt
rather than overwritten. Also restores the Dockerfile entrypoint
literal the M2 CMD restructure dropped (its CI test only ran in the
workspace suite) and splits live_sync_glue under the file cap.
A public bearer client previously reached the full 160-tool catalog,
including save_document (fs::write at a caller-chosen path), the
process-global codegen plan store, and host diagnostics. Online mode
now classifies every tool by surface in one reviewable table and denies
the 17 that touch the daemon host (filesystem, outbound fetch,
unscoped globals, diagnostics, future process spawn) in both tools/list
and tools/call — refusal happens before argument parsing, so traversal
payloads never reach a handler. mcp:read/mcp:write scopes gate write
tools ahead of dispatch (deny-then-scope, so a filesystem tool reports
denial rather than inviting a bigger token); session cookies keep full
scope and unclassified static tools fail the build. Local and managed
catalogs are unchanged. The deployment-boundaries script now refuses
direct invocation instead of green-exiting with every helper missing.
HubVerifier resolves both credential shapes onto the tenant registry —
Authorization: Bearer through the hub's internal token introspection
(shared-secret header, constant-time compared) and the op_hub_session
cookie through /api/v1/session — so a browser and an MCP token for the
same account share one document. Verdicts cache under SHA-256 keys
(session 60s, token min(300s, expiry), definitive negatives 15s);
upstream failures are never cached so a hub blip cannot become a 15s
outage, and a configured-but-unbuildable hub fails 503 rather than
silently downgrading to the dev verifier. Cookie-authenticated writes
now require an allowlisted Origin (bearer exempt), CORS echoes the
allowlist instead of *, and the web image gains online mode, a non-root
user, and a healthcheck.
--online turns the single-user daemon into an identity-keyed
multi-account host while leaving local and managed byte-identical
(the legacy serve_one is preserved under cfg(test) to prove it):
- TenantRegistry maps verified user ids to per-tenant WebCanvasState +
SseHub; leases are taken under the registry lock so a live
connection can never race eviction, and eviction compare-and-removes
with the M4 persistence seam marked in place
- ServeMode routes through 11 const predicates, all true for
local/managed; online locks down file IO, device-login proxy,
settings persistence, indicators, the POST / alias, generic
shutdown, sync-reset (safe no-op), LAN/relay collab actions (SSRF
surface), and the process-global thumbnail registry
- credentials arrive as Bearer or cookie via new HttpRequest capture;
IdentityVerifier starts with a static env-mapped verifier (hub
introspection lands in M2); missing verifier fails closed with 503
- /api/ai/* routes moved verbatim to connection_ai_routes.rs to keep
connection.rs under the file cap
POST /api/collab/avatar resolves a roster participant key to image
bytes via the daemon's public-only HTTPS client, so the browser never
sees signed CDN URLs. The handler lives on the connection thread beside
the account avatar proxy (bounded IO stays off the editor mutex) and
stands in for the desktop's frame-loop avatar pump. VS Code's daemon
contract test now pins the /api/collab auth gate (401 bare, 200 with
the managed token), and the crate map documents the collab-host split.
Browser-side consumption needs an avatarRevision on the participant
wire and an install seam in collab_avatar_runtime; until then web
participants keep the initials fallback.
The locator HSM signer is a unix-socket daemon with no Windows target,
but its unconditional std::os::unix imports failed cargo check on the
windows runner — the socket server and secure-file layers are now
cfg(unix) with a stub main. The transfer-idle heartbeat test pinned its
handshake windows to the same 400ms as the deadline under test, so a
loaded runner could flake the connect phase; the idle deadline (and the
validation-capped handshake windows) now sit at 2s.
Completes the id-namespace handshake on the wasm host: drag-create,
group, boolean-op polyline, duplicate, paste, alt-drag clone, figma
deep-clone import, and layer-context duplicate all route through the
session allocator when one is active, with the standalone counter as
the non-session path. Exhaustion surfaces the ResourceLimit notice and
leaves the document untouched; figma clones stage into a local buffer
so a mid-loop failure cannot land half a paste. The layer-context flow
gains an allocator-aware entry point (split into layer_context_flow.rs
to stay under the file cap; import paths unchanged).
keyboard_input.rs sat at 802 lines, failing the 800-line cap check in
the collab security boundary gate. Pure code motion of the MT.3
chat-tab launch/drain cluster into keyboard_chat_tabs.rs.
The mutation self-test has been red since the pairing-code hardening
landed: five new gate anchors (PeerIdentityPolicy, cross-account
admission tests, guest confirmation coverage, VerifiedRelayTokenClaims)
were added without fixture counterparts, and the op-collab-host
extraction moved the runtime paths the fixtures still wrote to. Fixture
files now mirror the scanned layout (including a cfg(test)-pathed
admission_tests module), so all 57 mutation cases pass.
Second collaboration increment on the wasm side:
- a 409 version conflict during an Active session now auto-resolves by
accepting the sequenced daemon document (rejected local edits stay
recoverable via the discarded-edit projection); every other phase
keeps the explicit-resolve latch, with the decision pinned by tests
- the collab tick subscribes to /api/mcp/events via EventSource and
feeds the same version/collabSeq latch as the poll, with exponential
backoff down to polling when the stream drops
- Cmd+Z during a session routes through a synchronous RequestUndo wire
command (claimed => session undo, else local); redo raises the M1
Unsupported notice
- the session wire carries the peer id namespace and the web host gains
a collab id allocator (canvas create wired; remaining creation sites
stay behind the PR4 push gate until converted)
The chrome-extension manifest check crashed jq (exit 2) inside the guard
self-test because the test fixtures never created the manifest, turning
the expected status-1 stale-version verdicts into harness errors. A
missing manifest is now reported as a normal version-sync failure, and
the fixture repo writes a manifest carrying the fixture version.
collab_sync drives the already-complete web collab surface against the
PR3 daemon routes, mirroring web_auth_sync:
- adaptive re-arming timer (400ms idle / 150ms in session); collabSeq
rides the existing version probe and never triggers a document pull
- pending panel actions post as CollabActionWire with single-flight and
409 collab-busy retry; presence uploads throttle to 100ms
- session-time pushes that would mint collaboration-invalid local node
ids fail closed until the daemon echoes them (namespace handshake is
the PR5+ follow-up); mount treats the daemon's sync-reset 409 as
completion instead of a wasted retry
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
advance_generation takes the cfg(test) avatar registry lock, but tests
that drive it already hold the same non-reentrant mutex, so the runtime
test suite self-deadlocked. Guard entry with a thread-local depth count.
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
Entering the editor or opening a document now docks the AI panel to a
compact 400x48 bar on the canvas's bottom edge — sparkle, the draft or
placeholder, a dimmed model name, and a send disc — and one click expands
it with the input focused. The policy lives in the single document-open
funnel so every entry point inherits it, with one exception: a document
opened while a turn is still streaming keeps the panel up.
The bar is a dock, not a float — it ignores any dragged panel position —
and the whole strip is one target; the model name caps at 45% of the row
so the placeholder always keeps the larger share down to the narrowest
canvas. The old header-only collapsed state retires: is_minimized() maps
persisted collapsed flags onto the bar, and paint locks the bar's weight
(radius, type sizes, one 5%-alpha shadow) so it cannot quietly thicken.
Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
The 720x720 dialog with a 200px side nav becomes a shrink-to-fit modal of
up to 1100x850 with a horizontal icon-tab bar and a full-width content
column. Every tab opens on a hero title with a one-line summary, and one
shared row module supplies the layout language: hairline-separated rows,
right-aligned controls, green iOS-style switches (the one jian Switch call
site gets a success-coloured primary token).
The agents tab lists providers with status pills — connected, not
connected, checking — beside the existing connect and disconnect actions;
the model roster in its hero comes from the shipped presets rather than a
second hand-written list. The MCP tab reshapes into per-CLI toggle rows
with a startup footnote and a custom-config section with a labelled copy
button; the system tab gains a light/dark segmented control writing the
same theme state the top bar toggles. Hit-tests and paints for providers
now share one body-top helper, removing a drifted hand-computed offset.
Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
The gallery's fields were never in input_active(), the one list that says
who owns the keyboard. The platform therefore never opened a composition
session (IME input produced nothing at all), nine letters fell through to
the single-letter tool shortcuts — typing "t" in the search field switched
tools — and the web host, which also lacked an apply_text arm, dropped
every character. The fix is registry-level on both hosts: keyboard
ownership, IME commit/preedit routing ahead of stale canvas text edits,
candidate-window anchoring at the real caret, and copy reading the panel's
own selection. Tests split gate (who owns the keyboard) from routing
(where text lands), because routing-only tests stayed green through the
whole failure.
Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
The bottom filmstrip duplicated the rail's slides tab, so it is deleted
outright; its board-listing model survives as deck_boards, which both the
rail and the presenter keep using. The slides tab itself goes permanent —
any page with boards lists them, not just tagged deck documents — and rows
get a fixed-height box that letterboxes each board on its own aspect, so
phone screens and dashboards line up instead of stretching the column.
The tab row measures its labels and drops to icons (active tab keeps its
label) when the text genuinely cannot fit, sized by real overflow rather
than a width threshold so a third tab enables it everywhere for free.
Making the tab permanent would also have detonated a latent resize bug:
the rail's hover tier claimed every in-rail cursor move with no exemption
for a live drag, so a resize could widen the rail but never narrow it.
Panel-resize now runs ahead of the hover tiers as pointer capture, and the
web ladder learns native's z-order for an open model picker over the rail.
Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
The scene template center was a fixed 720x554 dialog centred on the canvas
region, which a 240px rail visibly pushed off the window's centre. It now
insets from the whole viewport under a full-window scrim, flows 2/3/4
columns by width with card heights derived from a 16:10 preview (the old
0.62 cap that letterboxed every preview is gone), caps the content column
at 1680, and ships previews rebaked at 1024x640.
Template cards grow two actions: "add to canvas" appends the template's
boards into the current document in one transaction — template variables
are namespaced per template so two decks can no longer recolour each other,
and batch insertion no longer swallows a pre-existing empty root — while a
pristine starter still opens the template directly. "Generate from this"
pins the template's style guide, narrows the scene filter, and focuses the
topic field behind a clearable "based on" chip, so the existing pipeline
generates in that template's look. The web host also gains the missing
open/adopt drain; clicking a template there previously did nothing.
Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
The planning corpus taught six-slide decks by accident: all five outline
examples happened to be six steps long, so models copied the length along
with the shape. Outlines now differ in length, an explicit slide count in
the prompt is a hard constraint, and topic scale picks the band otherwise.
The fallback plan gains a real deck arm (per-slide screen labels, explicit
count honoured) instead of degrading to a single scrolling page, and
planning stream errors now log their cause instead of dropping it.
Weak models also lose whole slides to one dotted object key
(justify.content:); a repair rung now camel-cases keys that could never
parse, running only after eval has already failed so intact scripts are
never rewritten.
Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
The MCP spec says a ping result is empty, but both ping formatters put
the OpenPencil discovery identity (server/mode/token) at the result top
level. Gemini CLI validates ping with the TS SDK's strict EmptyResultSchema
and marked the server disconnected with 'Unrecognized keys: server, mode,
token' (issue #199). _meta is the spec's sanctioned extension point and
the only key the strict schema permits, so the identity now rides there;
the op CLI reads _meta first and falls back to the legacy top-level shape
so it still discovers a running pre-0.8.3 editor. (A pre-0.8.3 op CLI
cannot discover a 0.8.3 live editor — the CLI ships with the app, so only
a stale op on PATH hits this.)
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).
Some antivirus engines heuristically flag the self-signed, low-prevalence
Windows installer (issue #198). Give downloaders a way to confirm assets
are exactly what CI built: a checksum manifest plus signed SLSA provenance
verifiable via gh attestation verify, with triage + code-signing policy
docs linked from the README and release notes.
- Full-page captures walk the viewport through the page first (bounded
7s march) so loading=lazy images, IntersectionObserver reveals and
content-visibility sections exist before the extractor reads the
DOM, then capture from the top — the one scroll position where
fixed/sticky chrome rests at its page coordinates — and restore the
user's scroll afterwards.
- beginCapture tears down a still-armed element-picker overlay: a
full-page capture does not go through the pick flow, and the armed
highlight box and hint banner imported as page content. The overlay
also marks itself for the extractor to skip (belt and braces).
- The .op download blob is application/octet-stream: Chrome's download
pipeline second-guessed the unknown .op suffix against the JSON MIME
type and renamed the file to .json.
A capture of a real page (search results, marketing sites) came in
with smeared paragraphs, invisible gradient headings, mistinted icons
and a mid-page navbar. Each had a distinct root cause in how the
extractor read the DOM or how the importer re-laid it out:
- Fold gates: Chrome computes the -webkit-line-clamp idiom as
flow-root, and undecorated single-line inline-block chips (search
result dates) are text flow — both blocked the inline fold and left
per-child union rects painting over each other.
- Partial folding: consecutive inline-flow children between
block-level siblings fold as runs, so a paragraph interrupted by a
list still folds the text around it. Script/style sources and
display:none subtrees are excluded from the folded segments.
- Per-line splitting: a wrapped bare text run that cannot fold is
emitted as one node per line box (binary search over character
offsets), each at its true rect — the union box anchored every line
at the block's left edge and overlapped whatever shared those lines.
- Line counting: vertical-band grouping with a mostly-overlapping
criterion, so nested-span fragments and bidi runs do not over-report
and tight display leading (line-height 1.05 under a CJK glyph box)
does not merge real lines into one.
- Single-line leading clamp: the capture measures glyph boxes, so the
page's half-leading is already in the captured y; re-applying a
vertically-centring line-height (40px footers) pushed runs a dozen
pixels below their neighbours.
- Gradient text: background-clip:text moves the box fill onto the
descendants' glyphs (first gradient stop) instead of painting a bar
over transparent text; -webkit-text-fill-color wins over color.
- Icon paint: each shape's computed fill/stroke is inlined into the
serialized svg clone — a standalone data URI loses the stylesheet
rule that actually painted the icon, and currentColor then leaked
the inherited theme accent.
- Multi-colour flat svg art becomes one path node per consecutive
same-fill group instead of an undecodable raster fallback.
- position:fixed subtrees capture in viewport coordinates; adding the
scroll offset dropped a scrolled page's navbar into mid-document.
- The element-picker overlay marks itself data-openpencil-ui and the
extractor skips it.
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.