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)
Stage 3 — ports figma-types.ts + fig-parser.ts::parseFigFile.
- FigGuid / FigMatrix / FigColor / FigVec2: the geometric primitives
worth typing, each extracted from a decoded FigValue object;
FigGuid::to_key is the canonical `sessionID:localID` map key.
- BlobOrString: the `blobs` pool element (raw geometry bytes | string).
- FigmaDecodedFile { node_changes, blobs, image_files }.
- parse_fig_file: the full pipeline — fig_to_binary_parts → decode the
Kiwi schema chunk → decode the data chunk against it → extract
nodeChanges (with the fallback scan for a guid-bearing array) +
blobs. Mirrors parseFigFile / extractBlobs.
- FigValue gains generic get_f64/get_str/get_bool/get_array accessors.
op-figma 35 tests green (+5).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Stage 2 of the binary `.fig` parser — ports the `kiwi-schema` subset
the format needs.
- ByteBuffer: LEB128 var-uint/int (32 + 64-bit, zigzag), Kiwi's
exponent-rotated var-float (single-0-byte zero optimisation),
NUL-terminated UTF-8 strings, length-prefixed byte blocks.
- decode_binary_schema: the self-describing schema chunk — definitions
(enum/struct/message) + fields; native type codes resolve via
`!code` into NATIVE_TYPES, definition codes by index (forward refs
handled with a two-pass bind).
- FigValue: dynamically-typed decoded tree (the Rust stand-in for the
TS untyped `any`) with get/as_f64/as_str/as_bytes/as_array accessors.
- decode_message: dynamic decoder (the `compileSchema` + `decode*`
equivalent) — message id-prefixed fields, ordered structs, enum
ordinal→name, `byte[]` as a raw block, MAX_DEPTH recursion guard.
Roots at the `Message` definition like Figma's findDecoder.
Tested via an in-test Kiwi writer that round-trips schema + data
fixtures (enum/struct/message/arrays/byte-arrays/floats). op-figma
30 tests green (+7).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
First stage of the Figma binary `.fig` parser port (op-figma was
clipboard-JSON only; binary returned NotYetImplemented).
- zip_reader.rs: hand-rolled minimal ZIP reader — EOCD scan, central
directory walk, store (method 0) + raw-deflate (method 8) entries.
Avoids the full `zip` crate dependency tree. 512 MiB per-entry cap.
- container.rs: ports `fig-parser.ts::figToBinaryParts` — unwraps the
ZIP archive form (`canvas.fig` + `images/*`), verifies the
`fig-kiwi` magic, splits length-prefixed chunks, decompresses each
via the deflate→zstd→raw fallback chain (PNG payloads passed
through). Output: decompressed parts + embedded image map.
- deps: flate2 (miniz_oxide pure-Rust backend), ruzstd, base64 — all
license-clean + wasm32-buildable.
Modules are `allow(dead_code)` until `parse_fig` is wired in the
final stage. op-figma 23 tests green (+6).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
`property_panel_sections.rs` reached 862 lines after the effect
parameter-stepper work — over the 800-line repo file gate. Move
`paint_effects_section` + `paint_effect_row` + `paint_effect_param_row`
into a new `property_panel_effects.rs` (128 lines) and re-export
`paint_effects_section` from `property_panel_sections` so callers
keep using `sections::*`.
`property_panel_sections.rs` is now 755 lines. No behaviour change;
op-editor-ui 140 / op-host-desktop 64 tests green.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Completes the Effects-controls gap — each effect row now exposes its
editable scalar parameters:
- op-editor-core: `EffectField` enum + `EditorCommand::SetEffectParam`
+ `cmd_set_effect_param` — writes one shadow param (offset X/Y,
blur, spread) or a blur/background-blur radius; blur values clamp
to >= 0, field/effect mismatches reject.
- panels: each effect block paints a parameter row per field —
`<label> <value> [-] [+]`; the "-"/"+" steppers emit
`AdjustEffectParam` (the walker computes the post-step value from
the current one). `effects_section_height` / the action-rect
walker now take the effects slice so the variable per-kind block
height stays aligned with paint (`VisibleSections.effect_count`
retired in favour of the slice).
- both hosts dispatch `AdjustEffectParam` via `SetEffectParam`,
history-committed.
op-editor-core 233 / op-editor-ui 140 / op-host-desktop 64 tests
green.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
The property panel's Effects section painted only a header + the
"+" add affordance. It now lists the selected node's effects:
- op-editor-core: `node_effects` read accessor exposes a node's
`PenEffect` slice (container kinds via `container`, leaf shapes
directly).
- panels: `NodeSnapshot.effects` carries an `EffectSummary` per
effect; `paint_effects_section` paints one row per effect (type
label + a "✕"); `VisibleSections.effect_count` + the shared
`effects_section_height` keep paint and the action-rect walker's
y-math aligned through the now-variable-height section.
- `PropertyPanelAction::RemoveEffect(index)` — both hosts dispatch
it through `EditorCommand::RemoveNodeEffect` (history-committed).
Stage 1 of the Effects-controls gap (rows + add + remove); the
per-effect shadow/blur parameter inputs follow. op-editor-core 227 /
op-editor-ui 161 / op-host-desktop 64 tests green.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
`command_tests.rs` had grown to 906 lines past the repo's 800-line
cap as the flip / ellipse-arc / node-effect command tests landed.
Move those per-node-attribute apply tests into a sibling
`command_attr_tests.rs` (both files now well under the cap); the
core CRUD / selection / variable apply tests stay in place.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Effects were inert — the schema carries `PenEffect` (Shadow / Blur /
BackgroundBlur) but nothing could add or drop one.
- editor: `EditorCommand::AddNodeEffect` / `RemoveNodeEffect` +
appliers. `node_effects_slot` reaches the `effects` field on every
variant that has one (Frame/Group/Rectangle via `container`, the
leaf shapes directly); `add` appends a default-parameter effect,
`remove` drops by index and clears the list to `None` when empty.
- mcp: `add_node_effect` / `remove_node_effect` tools, registered on
the host server (catalog 80 -> 82).
This is the command + MCP layer of the Effects gap; the property-
panel editing UI is the remaining piece. op-editor-core 227 /
op-mcp 138 / op-host-desktop mcp tests green.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Completes the smart-guides gap on top of the align_guides geometry:
- editor: EditorUiState.active_guides — transient guide lines for the
current drag (view-only, never serialized / undone).
- host: apply_smart_guides() runs after each node-drag translate —
gathers the moving + sibling AABBs off the layout scene, calls
compute_alignment_guides, applies the snap offset, stores the guide
lines; drag release clears them.
- canvas: paints the active guide lines (magenta) over the nodes.
The "grid" half of the matrix row was already implemented
(canvas_viewport::paint_grid). op-editor-ui 140 + op-host-desktop 64
tests green.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
`align_guides.rs` — pure alignment-guide computation for node
dragging: given the moving node's AABB + sibling AABBs it returns the
guide lines to paint and the snap offset that locks the drag onto the
nearest edge/centre alignment within a threshold. Two axes resolved
independently; closest candidate per axis wins, edge-to-edge
preferred on a tie.
This is the computation core of the smart-guides gap — fully unit
tested (6 tests). Host drag-handler wiring + canvas paint of the
returned guides follow as separate steps.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Closes six verified gaps from the 2026-05-17 TS-vs-Rust gap analysis,
each build- and test-green:
- editor: SetNodeFlip + SetEllipseArc commands (+ set_node_flip /
set_ellipse_arc MCP tools) — schema already had the fields, only
the command path was missing.
- export: export_node_raster crops a raster to one node's bbox;
File -> Export is now selection-aware (single selection -> layer).
- editor: SVG import — hand-rolled parser (shapes + path M/L/H/V/
C/S/Q/T/Z) in svg_import.rs; cubic curves flatten to dense straight
anchors at import time so the renderer/pen-tool stay on their 1:1
straight-segment model. + EditorCommand::ImportSvg + import_svg tool.
- mcp: HTTP transport — mcp_serve::run_http serves MCP over a
TcpListener (--mcp-http <port> <path>); process_message is shared
with the stdio path.
- cli: new op-cli crate (binary `op`) — a dependency-free HTTP MCP
client driving every tool via `op <tool> key=value...`.
- ai: ChatRequest gains thinking/effort fields (ThinkingMode /
EffortLevel, defaults Adaptive/Low to match the TS runtime config).
MCP tool catalog 77 -> 80. Oversized files split to honour the
800-line cap (svg_import, export, mcp_serve, adapter).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
- chat_subprocess.rs: `std::os::windows::process::CommandExt` is unused
on Rust 1.94 (`creation_flags` resolves without it, like the unix
`process_group` twin) — rejected by clippy `-D warnings` on Windows.
- deny.toml: remove the tokio process/rt-multi-thread feature ban; the
native agent runtime legitimately needs them and the native cargo-deny
job tripped on it. wasm exclusion stays structural.
The op-* crate reorg never ran `cargo clippy -- -D warnings`, so the CI
lint gate failed. Fix every violation surgically: real fixes for
mechanical lints (needless_range_loop, derivable_impls, ptr_arg,
needless_borrow, doc_lazy_continuation, unused_imports, manual_find,
collapsible_match, never_loop, dead_code, complex types via type
aliases) and scoped `#[allow]` for intrusive ones (too_many_arguments
on paint helpers, result_large_err where ToolOutcome / PenNode payloads
are deliberately the Err type).
The dependency referenced a sibling working copy (../../../agent-rs)
that only exists on a local dev machine, so cargo metadata failed in
CI. vendor/agent is the same agent-rs repo as a git submodule (CI
checks out submodules recursively) and is pinned to the identical
commit, so the build is unchanged.
Phase 7.3 strangler reorg — add op-app, the thin crate that names the
editor application's composition root.
Investigation found no shared host bootstrap left to extract: the
editor-UI composition (widgets, theme, layout scene) already lives in
op-editor-ui, and each host (op-host-native / op-host-web) owns only
platform-specific backend wiring. So per YAGNI op-app stays thin — it
re-exports op-editor-ui plus the per-platform host entry point behind
its target cfg, and documents the composition. If real cross-host
wiring later emerges, it lands here.
Builds green on both native and wasm32-unknown-unknown.
Phase 7.3 strangler reorg — the final consumer (op-pen-loader) is
repointed off the openpencil-shell-core shim onto op-editor-ui (the
real source crate for the layout scene / scene-var / render-backend
facade), then the shim crate is deleted.
- op-pen-loader: openpencil-shell-core dep -> op-editor-ui;
every openpencil_shell_core:: path -> op_editor_ui::
- git rm crates/openpencil-shell-core/ (lib + jian.rs module + the
two re-export anchor tests, all superseded by op-editor-ui's own
surface; the jian.rs module had no consumers outside the shim)
- stale shell-core / shell-native comment refs in op-editor-core +
op-host-desktop manifests updated
Phase 7.3 strangler reorg — rename the native widget host and the
desktop runner crates to the op- prefix. The desktop+native merge was
declined: keeping the library / binary split preserves the mobile-
checkable op-host-native lib (cargo check -p op-host-native on iOS /
Android, relied on by check-jian-boundaries.sh + the CI mobile job),
which a folded-in winit binary would break. A clean separate rename is
purely mechanical and the brief permits it.
- openpencil-shell-native -> op-host-native (lib op_host_native)
- openpencil-desktop -> op-host-desktop crate; the shipped executable
keeps the stable openpencil-desktop [[bin]] name so release
artifacts + external CLI integrations are unaffected
- every openpencil_shell_core:: path -> op_editor_ui::
- every openpencil_shell_native:: path -> op_host_native::
- doc-comment / manual-smoke note refs updated
Phase 7.3 strangler reorg — rename the web widget host crate to the
op- prefix. The crate's openpencil-shell-core dependency is repointed
to op-editor-ui (the real source crate for the widget facade / theme
/ layout scene / scene vars / render-backend / gesture types).
- crate name: openpencil-shell-web -> op-host-web
- lib name: openpencil_shell_web -> op_host_web
- every openpencil_shell_core:: path -> op_editor_ui::
- native skia.rs include_bytes! path follows the moved assets dir
- smoke harness + lib doc-comment refs updated
Relocate the widget facade (widgets/, including the Widget trait,
render primitives, the editor-UI compositions, the CanvasViewport
center canvas, the lucide icon drawer, and editor_state_ext), the
theme tokens, the layout-resolved render scene (layout_scene +
layout_scene_hit), and the design-variable aggregation (scene_vars)
out of openpencil-shell-core into a dedicated op-editor-ui crate.
The canvas widgets (canvas_viewport*) stay inside op-editor-ui rather
than splitting into a separate op-canvas crate: they depend on the
widgets/ siblings editor_state_ext + icons and on the Widget trait, so
a clean mechanical split is not possible — per the task's explicit
allowance not to force a fragile split.
op-editor-ui's lib.rs mirrors the old shell-core crate-root re-exports
(render_backend facade types + jian gesture types + the i18n alias) so
every intra-module `crate::Color` / `crate::theme` / `crate::widgets`
path resolves unchanged — a pure relocation with no path rewrites
inside the moved modules. openpencil-shell-core becomes a thin
re-export shim (`pub use op_editor_ui::{widgets, theme, ...}`) so the
hosts keep resolving `openpencil_shell_core::*` until the Task 7.3
host rename dissolves the crate. The widgets_static integration test
moves to op-editor-ui/tests with its imports rewritten. The widget
boundary script's reverse-check path is updated to the new crate.
No behaviour change; all tests move with their code.
Relocate the AI chat layer (chat_provider.rs, chat_models.rs,
agent_settings_state.rs) out of openpencil-shell-core into a dedicated
op-ai crate. These three modules are dependency-free transport-free
data shapes — the ChatProvider trait, the ModelEntry catalog type, and
the Cmd+, settings-modal state — so they form a clean wasm32-clean
leaf crate. A fresh lib.rs declares all three as modules; chat_models'
`crate::agent_settings_state::` path stays valid. openpencil-desktop's
chat_*.rs transports + model_discovery.rs now import `op_ai::*`. Pure
relocation, no behaviour change.
Relocate mcp.rs + mcp/* + mcp_tests.rs out of openpencil-shell-core
into a dedicated op-mcp crate. The MCP tool registry + JSON-RPC stdio
layer only depends on jian-ops-schema + op-editor-core (its tools
build on EditorState / EditorCommand). mcp.rs becomes the crate's
lib.rs; the mcp/ submodule files flatten to src/ so the `pub mod`
declarations resolve unchanged, and intra-module `super::` paths stay
valid. mcp_tests.rs becomes a #[cfg(test)] mod of lib.rs with its
`super::mcp::` paths rewritten to `crate::`. openpencil-desktop's
mcp_serve.rs now imports `op_mcp::*`. Pure relocation, no behaviour
change; 138 tests move with their code.
Relocate figma.rs out of openpencil-shell-core into a dedicated
op-figma crate. The .fig detection + clipboard-node parser only
depends on jian-ops-schema (op-editor-core's PenNodeExt is test-only,
so it becomes a dev-dependency). figma.rs becomes the crate's lib.rs;
no intra-crate paths to adjust. Pure relocation, no behaviour change.
Relocate codegen.rs + codegen_targets.rs out of openpencil-shell-core
into a dedicated op-codegen crate. The code generators only depend on
jian-ops-schema + op-editor-core, so they form a clean leaf crate.
codegen.rs becomes the crate's lib.rs; crate::codegen:: paths in
codegen_targets.rs collapse to crate::. Pure relocation, no behaviour
change; tests move with their code.