Wraps the release binary in a minimal `OpenPencil.app`
(Info.plist + icon) so a dev run gets the proper Dock name +
icon — an unbundled binary shows the raw executable name and a
generic icon, and the runtime objc2 fallback in `macos_app.rs`
can't fully override that. Run the binary from inside the bundle
(`OpenPencil.app/Contents/MacOS/openpencil-desktop`) and macOS
picks up the bundle identity.
Run bare, the non-bundled binary shows in the Dock as the raw
`openpencil-desktop` executable name with a blank icon — no
`Info.plist` to read `CFBundleName` / the icon from.
New `macos_app::apply()` sets both at startup via objc2:
`NSProcessInfo::setProcessName` for the Dock / menu-bar name and
`NSApplication::setApplicationIconImage` (from an embedded
`assets/icon.png`) for the Dock tile. `[package.metadata.bundle]`
also gains `icon`, so a packaged `.app` carries it natively.
objc2-app-kit / -foundation are pinned to the 0.2 line winit /
casement already lock.
cargo-deny failed on `clipboard-win` / `error-code` — pulled in by
`arboard`'s Win32 clipboard path — which are BSL-1.0. The Boost
Software License is permissive + OSI-approved; add it to the
licenses allow-list.
Hide the native title bar and let the TopBar own the window
chrome — the Electron `titleBarStyle: 'hidden'` recipe:
- macOS keeps a real `NSWindow` (rounded corners, shadow,
edge-resize, key-window responsiveness) with the title bar made
transparent + emptied; the native traffic-light buttons stay,
pushed down via casement's `with_traffic_light_inset` to centre
in the 40 px TopBar. Windows / Linux drop decorations and the
TopBar paints its own close / minimise / maximise dots.
- The TopBar reserves a left inset for the controls; it collapses
in macOS fullscreen (native lights hide), tracked by a
per-frame `window.fullscreen()` poll. `window_control_at`
returns `None` on macOS so a fullscreen click on a left-edge
app icon can't trigger a window control.
- A press on the TopBar's blank area drags the window.
TopBar chip also gains one brand icon per connected agent + an
`N agent[s] · M MCP` status (new `topbar.agentPlural` across all
15 locales).
Bumps the vendor/casement submodule to 5ad98f1c.
A click on the colour swatch inside the Fill / Stroke hex input
now opens the picker. Previously only the head-row swatch did, and
the Stroke section had no picker hit-test at all. The head-row
swatch was dropped as a trigger in a follow-up — the hex-row
swatch is the intuitive target. `hit_test_action` runs before the
hex-input focus hit-test, so a swatch click opens the picker
instead of focusing the hex field.
The infinite-canvas grid painted one `fill_round_rect` per dot —
~1200+ separate skia draw ops every frame, the dominant cost of an
empty-canvas pan / drag. New `RenderBackend::fill_dots` collects
the dot centres and the native backend draws them in a single
`Canvas::draw_points` (round-capped points); other backends keep a
`fill_oval` loop via the default impl.
Also: `NativeBackend::fill_rect` went through `Paint::solid`, which
hardcodes `opacity: 1.0` and dropped the colour's alpha — a
translucent fill (the 12% marquee-selection band) painted fully
opaque. It now carries alpha through `Paint.opacity` like
`stroke_rect` does.
Three chat-panel fixes that share `ai_chat_panel.rs` / `input.rs` /
`scroll.rs`, so they land together:
- Model picker scoped to connected agents: discovery still probes
every installed CLI into `chat.discovered_models`, but the picker
lists only providers the user connected (`rebuild_available_models`,
re-run on connect-toggle + discovery). Connect state persists in
settings.json. The dropdown is height-capped, scrolls (wheel /
trackpad) with a thumb, and tints the hovered row.
- Chat input wraps: long input flows across up to 3 visible rows,
clipped + bottom-anchored, instead of overflowing the panel edge.
- perf: a canvas pan / zoom no longer marks the layout scene dirty
— it only moves the viewport transform, so re-running the taffy
layout solve + skia text measurement every drag frame was pure
waste. The repaint still re-applies the viewport.
Cmd+C / Cmd+V / Cmd+X did document node-clipboard ops regardless
of focus, so there was no way to paste a prompt into the AI chat
input. They now branch on chat focus: with the chat input focused
they read/write the OS text clipboard (`arboard`), otherwise the
node clipboard as before.
- `clipboard.rs` — thin best-effort arboard wrapper.
- `WidgetHostNative::chat_input_paste` / `chat_input_cut` — append
/ cut on the focused chat buffer.
The TopBar agent chip hardcoded `agent_count: 0`, so it always
painted the empty 'Agents & MCP' set-up affordance even after the
user connected a provider in Settings → Agents. It now reflects
the real count of connected providers (`agent_settings.connected`).
The chip's '{N} agent' label keyed `topbar.agentSingular`, which
was missing from every locale table — the chip rendered the raw
key. Added the key to all 15 locales.
A fresh launch seeded the demo `sample()` document — a Frame with
a 'Hello OpenPencil' title and a 'Click me' button group. New
`EditorState::starter()` returns just one empty Frame (selected),
and both hosts open with it; `sample()` stays as the widget-test
fixture. input_tests retarget their `n11` selections to the
starter frame's `n10`.
Newer Claude Code CLIs emit stream message types the bundled SDK
was not compiled against — `rate_limit_event` being the one that
surfaced — and serde aborted the whole chat stream on the first
one ("unknown variant rate_limit_event").
The `Message` enum gains a `#[serde(other)] Unknown` catch-all so
any unmodelled `type` deserializes cleanly instead of failing the
parse; `hooks.rs` and `chat_claude.rs` match it as a silent no-op
(an unknown event carries no hook payload and no chat turn).
The pre-commit `bun run format` step was reformatting upstream
casement files (Cargo.toml indent, .swcrc / changelog .md / CI yml
flow), leaving the submodule working tree dirty after every commit
that touched openpencil. Listing vendor/casement alongside the
other submodules in .prettierignore stops the formatter from
walking it — same as vendor/agent and vendor/jian.
`muda` is gated to macOS / Windows so the Linux backend stub
returns `None` from `poll()` and never constructs a `MenuAction`
variant. clippy's `-D dead_code` then fires on every variant on
Linux. Adding a target-gated `#[cfg_attr(…, allow(dead_code))]`
silences it there while keeping the lint live on macOS / Windows
where the variants actually need to stay reachable.
Use `messages.iter().enumerate().skip(start)` instead of the
explicit index loop `for i in start..messages.len()`; clippy's
`-D warnings` gate on Rust 1.94 trips on the former. Also bump
the casement submodule to da0bf09 so its .gitattributes forces
LF for source files (fixes Windows CI `cargo fmt --check` on
the vendored fork).
The casement crate was depended on through a sibling-repo path
(`../../../winit`) that only existed on the maintainer's machine,
so CI couldn't load the workspace manifest and every Rust Check
job died with "failed to read winit/Cargo.toml".
Vendoring it as a real submodule under `vendor/casement` (matching
the `vendor/jian` pattern, picked up by CI's `submodules:
recursive` checkout) closes that gap. The renamed GitHub repo
`ZSeven-W/casement` (was `ZSeven-W/winit`) tracks the `op-file-open`
branch — `feat(macos): drain_opened_file_urls` + the package rename
landed there as commit 5877fa83.
- `.gitmodules`: add vendor/casement.
- Root Cargo.toml: exclude vendor/casement from the workspace glob
(it's its own workspace).
- op-host-native + op-host-desktop: path = "../../vendor/casement".
Closes the architectural piece of the "MCP element toolset" P1 gap
(TS pen-mcp ships ~100 add_card_*/add_toast_* element tools).
- op-editor-core: new `EditorCommand::InstantiateKitComponent`
variant + applier branch that calls
`EditorState::instantiate_kit_component` with the requested
drop point (defaults to (0, 0)).
- op-mcp: `element_tools.rs` — `InsertKitComponent` per-component
tool returning `OkWithCommand(_, InstantiateKitComponent)`;
`insert_kit_component_tools(state)` walks every loaded kit;
`element_tool_schemas(state)` emits the matching tools/list
JSON. Tool names sanitize dashes: `insert_btn_primary`,
`insert_card_basic`, etc.
- op-host-desktop: `rebuild_registry` chains the dynamic tools
in; `tools_list_response` takes EditorState and appends the
dynamic schemas next to TOOL_SCHEMAS.
- op-editor-core: tidies the empty-pages `ensure_pages` guard to
`is_none_or`.
Result: 6 starter-kit components → 6 working MCP tools. The 100-tool
catalog parity is now a data fill-in (more components in op-editor-
core/uikit.rs auto-register as more MCP tools).
Closes the last TS-vs-Rust parity gap — the Rust shell lacked the
TS `component-browser-panel.tsx` (UIKit library browser +
click-to-instantiate).
- op-editor-core: `uikit.rs` with `UIKit` / `KitComponent` /
`ComponentCategory` types + a built-in starter kit (6 components
spanning button/input/card/nav/layout/feedback) as PenNode
templates; `EditorState::instantiate_kit_component` deep-clones
with fresh ids and translates the whole subtree to the drop point
(children carry document-absolute coords).
- op-editor-ui: `component_browser_panel.rs` floating draggable
panel — header (close), category pills (filtered to non-empty),
3-col card grid with name + scaled preview rect; kit-id + search
filters applied.
- op-host-native: paint at §11.5 (below the Design-MD panel),
`dispatch_component_browser_press`, drag lifecycle, shared
`over_topmost_panel` helper covers both top-most panels across
wheel / pan / right-press / cursor_hint / layer-hover / 4
overlay-hover blocks + align hover + stale-hover clear.
- op-host-desktop: View-menu toggle, `drain_component_browser_insert`
places at the viewport centre.
- op-editor-core: `active_children`/`active_children_mut` /
`ensure_pages` symmetric `pages: Some([])` fallback —
inserts land in `doc.children` and survive a subsequent
`add_page` (which migrates them into Page 1).
- op-i18n: new `componentBrowser.empty` × 15 locales.
Bumps vendor/jian for the rebased `DesignMdSpec` schema + the
`jian pack` designMd-filter for packaged apps.
`document_fingerprint` only sees the committed document, so an
in-progress text-input draft — a half-typed property field or
variable-row value — was invisible to the pull's edit detection and
to `document_is_dirty`. A reload then dropped the draft silently.
Add `WidgetHostNative::commit_pending_input_pub` (commits property +
variable-row focus) and call it before the reconciliation checks:
in `confirm_document_reload` (covering pull / branch switch / merge),
before the during-pull edit comparison in `poll_git_pull_job`, and
in `save_tracked_document`. A pending draft now counts as an edit
and is saved / discarded / kept by explicit choice.
The Pull confirm ran at spawn time, but a `git pull` resolves
asynchronously on a worker thread — the user can keep editing the
document while it runs. The post-pull reload then discarded those
during-pull edits with no prompt.
Capture a document fingerprint when the pull is spawned; in
`poll_git_pull_job`, compare it against the document at reload time.
An unchanged document reloads silently as before; a document edited
during the pull goes back through the unsaved-changes confirm so the
edits are saved / discarded / kept by explicit choice.
The Commit and Pull paths ignored the editor's in-memory document,
so they could act on stale disk state:
- Commit staged the last-saved file. With unsaved edits open, the
commit captured stale content, not what the user saw. It now saves
the document first (flushing pending inline edits) and skips the
commit if that write fails.
- Pull rewrites the tracked .op on disk but never reloaded the
editor or guarded unsaved edits. It now confirms via the
unsaved-changes prompt before starting, and `poll_git_pull_job`
reloads the document after a fast-forward / merge so the editor
reflects the pulled state (a conflict leaves unparsable markers,
so the panel shows merge-in-progress instead).
Mirrors the reload discipline already used by branch switch / merge.
GitSession binds an op_git::GitRepo to the currently-open document,
rebinding whenever the document path changes — the Git panel reads
it for branch / status / history and drives commits, the worktree
merge orchestrator and diffs through it.
Network- and scan-bound git work (pull, status, diff / show) runs on
worker threads (GitPullJob / GitStatusJob / GitDiffJob) drained on a
later frame, so a large repository or a slow remote never freezes the
UI; an open panel re-snapshots every 2 s to stay current with
external changes. A clean branch merge reloads the document from
disk; a conflicting one surfaces the quarantined ConflictBag in a
dialog. main.rs is split — git_host.rs + keyboard_input.rs — to keep
it under the 800-line cap.
The floating Git panel — opened from the View menu — shows branch,
working-tree status and recent commits, and offers commit / refresh /
pull plus one-click branch switching. Clicking the status line, a
commit row or a conflicted file opens an in-panel scrollable
unified-diff viewer (the panel widens to 620 px with ▲/▼/✕ controls
and per-line colouring). Each non-current branch row carries a "⤵"
button that requests an isolated worktree merge.
GitPanelState / GitDiffView / GitPanelAction are plain data on
op-editor-core so the widget layer stays wasm-clean — it never calls
git itself. Diff rendering is split into git_panel_diff.rs and the
native press dispatch into git_press.rs to honour the 800-line cap;
the panel is hit-tested before the right-rail blocks so its wide
diff mode cannot lose clicks to the property rail underneath.
`casement` is ZSeven-W's winit fork (sibling repo, referenced by
local path for now). It adds the macOS open-documents Apple-event
hook that upstream winit lacks, needed for Finder double-click open.
The `package = "casement"` key keeps the `winit` import name so all
`use winit::…` stays unchanged.
glutin-winit is dropped: it hard-depends on the upstream `winit`
package, which would pull a second, incompatible winit into the
tree. Its only use — GlWindow::build_surface_attributes — is replaced
by a direct glutin SurfaceAttributesBuilder call in provider.rs.
Drives the user's installed `git` via std::process::Command — the
same approach as the TS app's git-sys backend — so no libgit2/git2 C
dependency enters the workspace.
Covers repo discovery / init, working-tree status, staging, commit,
branch list / create / switch, history + diff, remotes, SSH keys and
credential storage. Adds a worktree-isolated merge orchestrator: a
branch merge runs in a throwaway detached worktree so a conflicting
.op merge never writes conflict markers into the live document — a
clean merge is fast-forwarded back, a conflicting one is reported as
a file-granular ConflictBag with the live tree left pristine.
A chat send fired while a turn was still streaming left the
interrupted turn's assistant bubble with streaming = true forever —
it never reached the terminal Done that clears the flag, so the
panel kept animating a caret on a stale message. begin_send now
clears every streaming flag before pushing the new bubble, keeping
the invariant that only the trailing assistant message streams.
Add five native-platform features to the winit desktop host
(op-host-desktop), closing the gap with the Electron app:
- Native menu bar (muda) — File / Edit / View / Help plus the macOS
app menu; selections route to the same host actions the keyboard
shortcuts use. Gated to macOS / Windows — muda needs GTK, which
this winit build does not link, so Linux keeps the in-canvas File
menu.
- Auto-update — a background probe of the GitHub releases API
reports status into the settings System tab; a found update
offers to open the download page, and a "Check for Updates" menu
item re-runs the probe.
- File association — argv parsing opens a .op / .pen document on
launch; [package.metadata.bundle] declares the OS-level handler.
- Window-state persistence — position / size / maximized restore
across restarts, with an off-screen guard for monitor changes.
- Drag-and-drop — dropping a .op / .pen file opens it.
Codex review round 1 findings (1 MAJOR + 3 MINOR) all addressed:
monitor-aware restore, failed-startup geometry guard, single-flight
update probe, case-insensitive extension match.
Also sink the agent-settings modal's hand-maintained EN/ZH string
table into the canonical 15-locale op-i18n tables, so the settings
chrome (including the new auto-update strings) is fully translated;
agent_settings_i18n.rs is now a thin op-i18n adapter.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
Restructure the floating AI chat transcript from flat text bubbles
into a structured view:
- ChatMessage carries thinking text, tool calls and images plus
per-block collapsed flags and a streaming flag. Image ids come
from a process-global counter so a fresh ChatState cannot collide
with the native decode cache.
- ChatPoll splits provider deltas into text / thinking / tool_calls;
apply_poll_to_message folds them into the in-flight assistant
message and clears streaming on the terminal Done. The no-provider
error path also ends the stream and drops staged attachments.
- New RenderBackend::draw_image (default no-op) backed by a bounded
FIFO decode cache + aspect-fit in the native skia backend; web
degrades to a framed placeholder.
- New ai_chat_transcript widget: deterministic layout shared by
paint and hit-test (no live text measurement), collapsible
thinking / tool-call blocks, a streaming caret + typing-dot
animation, and image thumbnails.
Reviewed with Codex (3 rounds to clean).
CI's Rust Check runs `cargo clippy --workspace --all-targets -- -D
warnings`; the fmt failure had masked it, so accumulated lints surfaced
once formatting was fixed. Resolve them:
- op-acp / op-ai-skills (this change set): while-let loop, redundant
struct update, and `should_implement_trait` allows on the Option /
infallible token parsers.
- Pre-existing in op-editor-core / op-figma, swept so the workspace
gate is clean: redundant `drop`, `too_many_arguments` allow,
collapsible `if let`, a type alias for a complex tuple, manual
`Iterator::find`, and an `approx_constant` test value.
Lint fixes only — no behaviour change.
Addresses the remaining round-4 codex findings (the `short_src`
byte-slice panic + the arc-handle reverse-iteration fixes already
landed via an earlier sweep).
- `cmd_set_ellipse_arc` clamps `sweep_angle` to ±360° — an API /
MCP sweep beyond a full turn just over-draws; it now persists a
sane single-revolution value.
- Path hit-test (`point_in_node`) follows the flattened, bezier-
aware outline instead of the bounding box: a curved or thin path
no longer selects empty bbox space, a zero-height stroked path
stays clickable, and a filled closed path is hittable across its
interior (new `point_in_polygon` even-odd test).
- The viewport-less `apply_release` now commits / clears
`path_anchor_drag` + `arc_handle_drag` (parity with
`apply_release_with_viewport`) so a drag can't leak across that
release path.
op-editor-core 242 / op-editor-ui 157 / op-host-native 21 tests green.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
CI's Rust Check runs a workspace-wide `cargo fmt --check`. Reformat the
new op-acp / op-ai-skills crates and the Part A chat changes to rustfmt
canon. Also sweeps two files an earlier commit left non-compliant
(canvas_viewport_paint.rs, op-pen-loader/adapter.rs) so the workspace
check is clean. Formatting only — no behaviour change.
ChatRequest gains an attachments field; ChatState carries a per-turn
thinking mode, effort level and staged attachments (capped at 4 files /
5 MiB, attachment-only sends allowed). The chat panel grows a controls
row (thinking / effort / attach) and a dedicated attachment-chip row,
with hit-test and paint sharing one input-block origin.
All five providers consume the knobs — Claude maps thinking onto the
SDK token budget, Copilot onto reasoning_effort, the CLI / built-in
transports prepend an in-band directive, the HTTP transport adds body
fields; attachments spill to a private per-turn temp dir (cleaned on
drop) or inline base64. Also wires op-ai-skills into the built-in
provider and op-acp in as the AcpProvider chat backend.
Port of the TS packages/pen-ai-skills engine: a hand-rolled frontmatter
parser (inline / multi-line / next-line / block-list YAML forms), phase
filter + keyword/flag intent matching, token budgeting, the resolve
pipeline, design-context + generation-history memory, and style-guide
selection/parsing. The 95-file skill markdown corpus is embedded
verbatim via include_dir; the crate stays wasm-clean.
Port of the TS packages/pen-acp: ndJSON transport, a hand-rolled
JSON-RPC engine (request-id correlation, session/update notification
routing, session/request_permission auto-approval, and a pending-request
drain on EOF so a dead agent fails fast instead of timing out), and an
AcpConnection driving initialize / session/new / session/prompt over
local stdio or a remote WebSocket. The event adapter maps ACP session
updates onto the chat panel's ChatDelta vocabulary.
The round-3 closed-path fill left the stroke loop unconditional, so
a closed path with a fill but no explicit stroke was filled AND
outlined in the fill colour. Now the stroke paints only for an
explicit `node.stroke`, or — with no stroke — only when the path is
unfilled (so a bare path stays visible). op-editor-ui 176 tests green.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Addresses the 3 CONCERNs from the third codex review.
- `signed_sweep`: a negative-sweep drag that collapses onto the fixed
endpoint now snaps to a full -360° circle instead of 0° (mirrors
`norm_sweep`'s positive 0 → 360 rule) — a negative arc no longer
silently loses its sign in that degenerate case.
- Path fallback sizing (`path_to_payload`): an unsized path now
derives its width/height from the handle-aware
`path_bounds_from_anchors` (cubic extrema included) instead of the
endpoint-only point bbox, so handles bowing past the anchors no
longer under-size the scene node.
- Closed-path fill: a closed `Path` with a fill now paints its
enclosed area via `fill_polygon` over the flattened outline — was
stroked-only, so authored fills rendered as bare outlines.
Verified against op-editor-core 239 / op-pen-loader 21 / op-editor-ui
155 (op-host-native not test-built — an unrelated in-progress
AIChat thinking/effort change in the working tree leaves its
`AIChatHit` match non-exhaustive; `signed_sweep` is a pure-fn change).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Addresses the 4 CONCERNs from the second codex review.
- No-op undo: arc + path/handle drags now mutate nothing until the
cursor first travels — a press-release leaves the document and
undo stack untouched. Once the drag has moved, every event keeps
writing (so a drag back to the start point still lands), gated on
`is_move || already_moved`. This closes the hole where a ghost-
handle press-release created a handle with no undo entry.
- Negative arc sweep: `signed_sweep` keeps the sign of the arc being
dragged, so an MCP-authored counter-clockwise (negative) sweep no
longer flips to the major arc under a canvas drag.
- Closed paths: `path_closed` threaded through NodePayload +
SceneNode; `flatten_path` appends the last-anchor → first-anchor
closing segment (cubic or straight) so a closed canonical path
draws its closing edge.
op-editor-core 235 / op-pen-loader 21 / op-editor-ui 148 /
op-host-native 64 / op-host-desktop 21 tests green.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
A negative `sweep_angle` covers the angular range `[start + sweep,
start]`; the hit-test compared against `sweep.abs()` from `start`,
rejecting points that are actually inside such an arc. Normalise to
a forward sweep before the angle-window test.
Self-review follow-up to the arc/pen-handle work (the codex second
review stalled mid-run). op-editor-ui 148 tests green.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Addresses the first codex review of the arc + pen-handle features.
BLOCKs:
- Rotation: the arc + pen-handle overlays and the host hit-tests now
account for node rotation. The overlay paint wraps in save/rotate
like the selection overlay; the hit-tests + drag math un-rotate the
cursor into the node's local frame via `rotate_point`.
- Arc ellipse hit-test: a pie / arc / donut now hit-tests against the
actual sector — the missing wedge + the donut hole are no longer
selectable (was always the full oval).
- Arc no-op undo: `ArcHandleDragState` gains `start_doc`; the move
handler gates `moved` on real cursor travel so a press-release
pushes no undo entry.
CONCERNs:
- Path bounds: the bezier handle-aware bounds algorithm moved into a
shared `op_editor_core::path_bounds`; `refit_path_bounds` uses it
(and is now called after handle / point-type edits) so the loader's
absolutize scale stays 1.0 — a handle no longer rescales the path.
- `cmd_set_ellipse_arc` now honours the locked/hidden editability
guard like the other geometry mutators.
- A full-ring donut strokes its two concentric ovals instead of the
polygon, so the radial seam is not drawn.
op-editor-core 235 / op-editor-ui 148 / op-pen-loader 21 /
op-host-native 64 / op-host-desktop 21 tests green.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Completes pen bezier-handle editing on top of the Stage-1 data model.
- canvas_viewport_paint: `flatten_path` — Path nodes with anchor
control handles render as tessellated cubic Beziers (16 steps /
segment); handle-free paths keep the straight `points` polyline.
- canvas_viewport: the Pen-tool anchor overlay now draws each
anchor's two control handles (line + dot), with a faint "ghost"
dot offset from the anchor when a handle is unset — grab it to
create the handle. `path_handle_positions` resolves real / ghost
handle positions, shared with the host hit-test.
- op-host-native: `path_anchor_hit` now distinguishes anchor body
vs handle_in / handle_out (`AnchorDragTarget`); `PathAnchorDragState`
carries the target, the anchor's fixed position, and the Shift
state. The move handler drags the anchor or a handle — a handle
drag sets the anchor's point type on first motion (Shift =
independent/broken, else mirrored/smooth) so `set_path_anchor_handle`
mirrors the opposite handle. Release commits history only on
actual motion.
op-editor-ui 148 / op-host-native 64 / op-host-desktop 21 /
op-pen-loader 21 tests green (+3 flatten-path units).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Stage 1 of pen bezier-handle editing — the data + command layer.
- op-editor-core: `EditorState::set_path_anchor_handle` (set / clear
an anchor's in/out handle; a `Mirrored` anchor keeps both handles
collinear) + `set_path_anchor_point_type` (switching to `Mirrored`
snaps the handles collinear). New `PathHandleSide` enum.
- op-pen-loader: `AnchorPayload` (absolute-coord anchor + resolved
handles + point-type code) on `NodePayload.path_anchors`;
`absolutize_path_anchors` now resolves the schema's anchor-relative
handle deltas into the same absolute frame as `points`.
- op-editor-ui: `SceneAnchor` + `ScenePointType` on
`SceneNode.path_anchors` so the painter + host can read the
editable handles.
op-editor-core 234 / op-pen-loader 21 / op-editor-ui 145 tests green
(+3 handle-setter units).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Adds the canvas drag handles that author an ellipse arc — start
angle, sweep (end) angle, and the donut inner-radius.
- op-editor-ui: `ArcHandle` enum + `arc_handle_positions` (doc-space
positions of the 3 handles for an Ellipse scene node), re-exported
from `widgets`. The canvas overlay paints them as filled
primary-tinted dots for a single-selected Ellipse with the Select
tool.
- op-host-native: `ArcHandleDragState` + `arc_handle_hit` (checked
before the resize handles since the sweep grip can overlap the
right-mid resize handle). Press starts the drag with a history
snapshot; each move recomputes start/sweep/inner from the cursor
via `arc_drag_command` and re-applies `SetEllipseArc` (no-history
apply); release commits the snapshot only when the arc changed.
Dragging the start handle keeps the end fixed; a sweep collapsing
to 0 snaps to a full 360° circle.
op-editor-ui 145 / op-host-native 64 / op-host-desktop 21 tests
green (+2 arc-handle-position units).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Ellipse nodes with authored arc geometry (`start_angle` /
`sweep_angle` / `inner_radius`) were always painted as a full oval —
the arc fields never reached the painter.
- NodePayload + SceneNode gain `arc_start_angle` / `arc_sweep_angle`
/ `arc_inner_radius`; threaded through `ellipse_to_payload` and
`node_payload_to_scene`.
- canvas_viewport_paint: new `arc_polygon` tessellator (pie wedge =
centre + outer arc; donut sector = outer arc + reversed inner arc)
+ `paint_ellipse` — full oval when no arc is authored, otherwise a
filled/stroked polygon. A 360° sweep with no donut hole still
short-circuits to the plain oval path.
Prep for the arc canvas drag-handle UI. op-editor-ui 143 /
op-pen-loader 21 / op-host-desktop 64 tests green.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Addresses the 4 BLOCKs from the second codex review.
- kiwi: schema field-count pre-allocation is capped at the remaining
buffer size (a hostile count no longer forces a huge Vec alloc).
- kiwi: array-length validation + pre-alloc now use the *remaining*
byte count rather than the total buffer length (tighter bound).
- zip_reader: the aggregate-size budget is checked against the
central directory's declared uncompressed size *before* the entry
is decompressed, not after.
- zip_reader: stored (uncompressed) entries are now subject to the
per-entry MAX_ENTRY_SIZE cap, matching the deflate path.
op-figma 84 tests green; clean build.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Addresses the first codex review of the binary `.fig` parser.
BLOCKs:
- kiwi: 64-bit varint now uses Kiwi's terminal-byte rule (eight
7-bit groups then a final full-8-bit byte) — the old `& 127` mask
on every byte corrupted u64 values above 2^56.
- kiwi: an invalid schema definition kind (> 2) is now rejected
instead of silently treated as a message.
- kiwi: array decode rejects a length exceeding the buffer size —
guards against a hostile zero-byte-element array spinning the
decode loop billions of times.
- zip_reader: aggregate 2 GiB decompression budget + 10k entry cap
on top of the existing per-entry limit (zip-bomb defence).
CONCERNs:
- detect_kind now recognises the `PK\x03\x04` ZIP magic as Binary —
the common Figma export form (`canvas.fig` + `images/` in a ZIP)
was being rejected before container.rs could unwrap it.
- resolve_style_references now also resolves style refs inside
instance `symbolData.symbolOverrides` entries.
- kiwi: enum field type codes are no longer resolved (unused; kiwi
writes 0) so a stray code can't reject a valid schema.
Plus a zip-wrapped end-to-end test + the misleading zstd test rename.
op-figma 84 tests green (+1); clean build.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Stage G — the binary `.fig` parser is now end-to-end functional;
`parse_fig` no longer returns NotYetImplemented for binary input.
- image_resolver.rs: resolve_image_blobs walks the converted document
and replaces `__blob:N` / `__hash:HEX` image-fill placeholders with
base64 `data:` URLs (MIME sniffed from magic bytes).
- lib.rs: new `parse_fig_binary(bytes, name, layout_mode) -> FigImport`
runs the whole pipeline — container split → Kiwi decode → tree
build → node conversion → image resolution. `parse_fig`'s Binary
arm delegates to it; `FigParseError::NotYetImplemented` retired in
favour of `Binary(String)`. Entry points re-exported.
- binary_e2e_tests.rs: assembles a real fig-kiwi container from
scratch (hand-built Kiwi schema + data chunks, deflate-compressed)
and asserts the full pipeline yields a PenDocument with the
rectangle at the right position/size + canonical-JSON round-trip.
op-figma 83 tests green (+6); clean build, no warnings.
Closes the §2.1 P0 gap — Figma binary `.fig` parsing.
🤖 Generated with [Claude Code](https://claude.com/claude-code)