Continues the gradient + property-panel polish from the previous
commit and rounds out two new flows the TS app already has:
Gradient stops + effects:
- ColorTarget gains GradientStop(i) + EffectColor(i); HSV picker
preserves alpha across hue/SV drags so a transparent stop stays
transparent. Hex pill stays 6-char; alpha is reattached at commit
and the swatch sits on a 2x2 alpha checker so #00000000 reads as
transparent rather than empty.
- Effects section reflowed into card-style blocks (image #9 spec):
title + minus, X/Y and Blur/Spread 2-col grids, color row with
swatch + rgba(...) text; clicking the swatch opens an HSV picker
bound to that effect index via SetEffectColor.
- Press dispatch on both hosts anchors picker overlays at the
clicked y so they pop adjacent to the swatch instead of the top.
Image + SVG import (toolbar + Fill section "图片" row):
- New FileAction::ImportImageOrSvg / PickFillImage; persistence_image
pops rfd, decodes raster as data: URL, inserts an Image node or
rewrites the selected node's primary fill.
- ImageNode actually renders on the canvas: NodePayload + SceneNode
carry image_src, canvas_viewport_paint.rs decodes the data URL
once and hands raw bytes to RenderBackend::draw_image with a
src-hash cache id. Grey placeholder paints only when decode fails
so transparent PNGs don't get a grey matte underneath.
- SVG import ported to TS-parity (packages/pen-engine svg-parser):
recursive <g> tree walk with inherited fill/stroke/style="...",
viewBox-aware scaling with maxDim cap, multi-subpath split, raw
d preserved on PathNode. Imports land wrapped in a Group named
after the source file.
Locale-aware first run:
- settings_io detects the OS locale (LC_ALL/LANG/LC_MESSAGES with
zh-Hans/zh-Hant heuristics) and seeds editor_ui.locale before
settings.json is read; persisted user choice still wins.
- macOS bundle declares CFBundleLocalizations + AllowMixedLocalizations
so NSOpenPanel / NSSavePanel render in the same language as the
rest of the chrome.
Web host kept exhaustive across the new variants (PickFillImage,
OpenEffectColorPicker, ColorTarget::EffectColor, GradientStop). Two
new files: persistence_image.rs (file-pick handlers, ≤120 lines) and
svg_path_data.rs (path-d tokenizer + bbox + normaliser, split from
svg_import.rs to stay under the 800-line cap). 277 op-editor-core
tests pass.
Three independent CI-unblockers grouped to keep history clean:
1. op-host-desktop/chat_orchestrator.rs — add `visual_ref_enabled: false`
to the DesignRequest literal so the workspace builds after S4 added
the field. Same stub-plumbing pattern as the S3b-2 `concurrency`,
S3b-4 `append_context`, S3c `validation_enabled` additions. Real
intent-gate routing is task #27.
2. op-editor-core/render_backend.rs::fill_round_rect_radial_gradient —
#[allow(clippy::too_many_arguments)] on the trait default. The
radial-gradient hook carries (rect, radius, stops, cx_frac, cy_frac,
radius_frac, opacity) per the TS pen-renderer contract; that's the
data shape, not refactorable without splitting the trait.
3. cargo fmt --all catch-up on 6 other files (op-editor-core /
op-editor-ui / op-host-native) that pre-dated this branch and were
never run through rustfmt. Pure whitespace, no semantic change. Lets
`cargo fmt --all -- --check` pass workspace-wide.
`cargo clippy --workspace --all-targets -- -D warnings` + `cargo build
--workspace` + `cargo fmt --all -- --check` all clean. Tests across
op-orchestrator (574+1) / op-design-lint (149) / op-mcp (144) /
op-editor-core (273) / op-host-desktop (96) all green.
Wires the linear/radial gradient fill type from the property panel
through to a real skia shader on the canvas — previously switching
fill type only changed the model and the panel painted hardcoded
placeholder stops. Stop hex (with alpha preserved), angle, offset %,
and per-stop HSV picker are all live.
- SceneNode + NodePayload carry a resolved gradient body; native
skia backend builds `linear_gradient` / `radial_gradient` shaders
with TS-renderer geometry (angle - 90° ellipse projection, radius
fraction of max(w, h)).
- PropertyFocus + EditContext gain GradientAngle / GradientStopHex /
GradientStopOffset; commit, snapshot, hit-test, paint, keyboard
validation, and the property-panel layout walker thread the new
variants end-to-end.
- ColorPickerState carries a per-session alpha so the gradient-stop
picker keeps transparency across HSV drags; hex pill stays 6-char
and re-attaches the stop's existing alpha at commit. Swatch paints
over a 2×2 checker so a fully transparent stop reads as such.
- skia.rs split into a spine + sibling gradient.rs to stay under the
workspace 800-line cap.
# Conflicts:
# crates/op-editor-core/src/lib.rs
Existing EditorCommand variants are leaf-only (BatchInsertItem carries
only kind/name/x/y/w/h/fill_hex). The design orchestrator (S3a) must
apply rich nested designs — frames with children, layout, text — so
add InsertSubtree { nodes: Vec<PenNode>, parent_id }.
cmd_insert_subtree validates the parent is a container (or NONE = page
root), remaps every incoming node id to a fresh editor id via
remap_subtree_ids (so an externally-authored subtree can't collide
with live ids), and appends under the parent. The apply arm wraps it
in a history snapshot so the insert is one undo step.
NOT verified locally: op-editor-core does not currently build —
vendor/jian is pinned to unpushed commit 80121906 whose DesignMd*
types op-editor-core depends on are absent from every available jian.
The 8 InsertSubtree tests in command_subtree_tests.rs run once the
jian build is restored.
S3a Plan A.
editor_state_to_layout_scene took active_page_index from the payload, which pen_document_to_payload hardcodes to 0 — so picking a page in the LayerPanel updated the panel but never switched the canvas. Read the live EditorState.ui.active_page_index instead, clamped to the page count.
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.
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)
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)
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)
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)
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)
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 Task 7.1 final step. The `crate::document` module's shared
enum/value types were the last remnant of shell-core's dead `Document`
model. This repoints every consumer onto canonical types and deletes
the module entirely.
- Enum types (`Tool`, `AlignAction`, `FillType`, `FlexLayout`,
`PropertyTab`, `PropertyFocus`, `ColorTarget`, `Viewport`, `NodeId`,
`BooleanOp`, `ChatAnchor`, `ReorderDirection`) repointed onto their
variant-identical `op_editor_core` equivalents.
- `NodeKind` / `Effect` / `DropShadow` are paint-time scene types with
no canonical home — moved into `layout_scene.rs` (the surviving
scene type that is their only consumer). `Stroke` consumers switched
to the existing `SceneStroke`.
- `VariableTable` + friends (`Variable`, `ThemeAxis`, `ThemedValue`,
`VariableKind/Scalar/Value`) are a shell-core-local paint-time
aggregate consumed by the `LayoutScene` builder — moved to a new
crate-root `scene_vars` module.
- `editor_state_ext.rs` converters that became identity now that
widgets speak canonical types deleted (`doc_tool`, `doc_align_action`,
`doc_fill_type`, `doc_flex_layout`, `doc_property_tab`,
`doc_property_focus`). The widget-local→canonical reverse bridges
(`file_menu_choice`, `shape_choice`, `export_format`) added there.
- `op-pen-loader/bridge_enums_rev.rs` (the SC document→EC enum bridge)
deleted; hosts feed canonical widget hit-test results straight in.
- Dead `large_document_perf.rs` integration test (exercised the
removed `Document` struct directly) deleted.
Pure type-repointing — widgets/canvas/export paint identically.
With the LayoutScene builder no longer routing through the shell-core
Document model, the EditorState -> paint-Document bridge is fully
dead. Removes editor_state_bridge.rs (apply_editor_state_ui,
editor_chat_to_chat_state, editor_components_to_component_library),
bridge_ui.rs (editor_ui_to_ui_state), the forward bridge_enums.rs
translators, and payload.rs's pen_document_to_document / to_payload /
apply_payload / DocPayload<->Node converters. None had a production
caller.
payload.rs keeps the DocPayload DTOs + load_canonical (live: adapter
builds DocPayload, the LayoutScene builder reads it, persistence.rs
calls load_canonical). bridge_enums_rev.rs stays — its rev:: enum
translators are still used by the native + web hosts.
The shell-core document.rs / document/ model itself is NOT deleted:
~20 shell-core widget files + layout_scene_hit.rs still consume its
enum types (NodeKind, Tool, FillType, PropertyFocus, AlignAction, ...).
Migrating those onto op-editor-core enums is Phase 7 Task 7.2's scope.
editor_state_to_layout_scene now constructs SceneNodes straight from
the layout-resolved DocPayload (pen_document_to_payload) instead of
routing through the intermediate shell-core Document model. The
DocPayload already carries the resolved geometry + paint fields;
apply_payload's only transforms on them are lossless format
conversions, so the scene output is byte-identical (verified by the
existing layout_scene tests). Adds effects_from_payload_ref so the
builder can rebuild effects from a borrowed payload slice.
Both editor hosts now hit-test the input path against the cached
LayoutScene instead of a per-frame derived Document. Hit-test helpers
(node_at_doc_point, selection_handle_at_point, rotation_corner_at_point,
drop_target_at) moved into layout_scene_hit.rs and rewritten onto
SceneNode. The paint_doc field is removed; Document survives only in the
export renderers and the op-pen-loader payload bridge.
Three additive variable-handling fixes so the editor host can migrate
onto EditorState as a mechanical port:
- Gap 1: add `variable: Option<String>` to ColorPickerState, port
`open_color_picker_for_variable`, and route `color_picker_set_hsv` /
`close_color_picker` through `set_variable_color` when set.
- Gap 2: add `op_pen_loader::editor_state_var_table(&EditorState)`,
folding persisted variables/themes plus the transient active-theme
selection + ref caches into a shell-core VariableTable. Lives in
op-pen-loader (not op-editor-core) to keep op-editor-core wasm-clean
and free of any shell-core dependency.
- Gap 3: confirmed undo/redo of variable create/delete/rename round-
trips for free (EditorSnapshot clones the whole PenDocument); added
tests to lock it in.