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
Property-panel input editing now supports:
- Arrow keys: Up/Down step a numeric field; Left/Right move the text caret. Caret position is a real index into the draft, so typing inserts at the caret and Backspace deletes the char before it (not just append/pop).
- Layer opacity: full-width box with the localized 不透明度 label inside on the left, value next to it, % at the right edge; clipped so a long-locale label can't bleed past the half-width box.
- Fill opacity: new SolidFillBody.opacity getter / setter on the model + PropertyFocus::FillOpacity + the 100 % box in the Fill head row is now editable end-to-end.
- Effect-param values: each Drop Shadow X / Y / Blur / Spread cell is a click-to-type input box (new effect_param_focus state, FocusEffectParam action, commit_effect_param_focus_if_any path). The − / + steppers still work alongside. Web's apply_property_action makes the focus a no-op (no keyboard path on web yet) to avoid stranding focus.
Paint polish:
- Standardised input-text baselines to + 19.0 across prefix / suffix / icon helpers, the fill / stroke / opacity / hex paints, and the export section so every INPUT_HEIGHT row reads on the same baseline.
- Icon-prefixed inputs now have 10 px left padding (matching X / Y / W / H) and the icon is vertically centred ((30 - 14) / 2 = 8) instead of sitting at y + 5.
- Fill / stroke swatches vertically centred in their hex rows ((30 - 16) / 2 = 7).
- '-' / '#' caret-aware validation in apply_text so typing them at caret 0 of a non-empty draft is now a valid edit. Native input_tests seed property_caret_pos to mirror real focus state.
Codex review iterations: poison-guard the effect-param focus on web, content-clip the layer-opacity row, fix property-panel scroll clamping in paint, and a few related safety guards across hosts.
Theme-toggle button now paints a Sun glyph in dark mode (click → light) and a Moon glyph in light mode (click → dark); the Sun icon was hardcoded before. Adds Icon::Moon (lucide crescent) and threads theme_mode into TopBar.
Bumps the casement submodule with a fix for the native macOS traffic-light reposition: idempotent absolute placement against resize, baseline invalidation on fullscreen exit, and a poison guard so a transitional re-capture can't drop the lights below their default position.
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.
Export section: the scale / format dropdowns open inline select
popups (1x/2x/3x, PNG/JPEG/WEBP/SVG/PDF) instead of the Export
modal, which is now reached only via File > Export Image. Adds a
full-width Export button; popup rows highlight on hover. Pickers
open upward so they are not clipped at the panel's bottom edge.
PropertyPanel scrolls when its content overflows the viewport,
with the Design / Code tab strip pinned; scroll is clamped on
every paint + hit-test, not just on wheel events.
LayerPanel: the Pages and Layers sections get bounded heights and
independent scroll regions; layer drag/drop is gated to the
visible Layers viewport.
Splits property_panel into property_panel_action / _export and
the host press paths into property_dispatch / scroll modules to
keep every edited file under the 800-line cap.
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.
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.
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).
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.
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.
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).
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.
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)
`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)
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)
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).
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.