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.
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.
Rewrite raster (PNG/JPEG/WEBP), SVG and PDF export to consume the
layout-resolved `LayoutScene` instead of shell-core's `Document`. The
export call site builds the scene from the live `EditorState` via
`op_pen_loader::editor_state_to_layout_scene`, so jian's flex pass and
`$ref` fill resolution run once and the exported pixels match the
on-screen canvas painter. Rotation now pivots around `aggregate_bounds`
to match `canvas_viewport_paint.rs`; drop shadows render behind Frame /
Rect / Ellipse fills.
Drops the native host's dead `document()` / `derive_paint_doc` path now
that nothing derives a paint `Document`. Splits the SVG serializer into
`export/export_svg.rs` to stay under the 800-line cap.
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.
The PAINT path of CanvasViewport was the last widget still consuming
shell-core's Document. It now reads viewport / selection / tool /
pen-draft from op_editor_core::EditorState and walks the layout-
resolved SceneNode tree from a LayoutScene instead of Document Nodes.
- CanvasViewport::from_document → from_editor(&EditorState, &LayoutScene).
- Per-kind painter ported into a new canvas_viewport_paint.rs module
(walks SceneNode; fills are pre-$ref-resolved so no var_table lookup)
to keep canvas_viewport.rs under the 800-line cap.
- Overlay painter migrated: paint_fill_then_stroke takes &SceneNode,
paint_pen_rubber_band reads pen-draft + scene geometry.
- Both hosts cache a layout_scene field rebuilt by
editor_state_to_layout_scene alongside paint_doc when
editor_state_dirty is set.
- SceneNode gains find / aggregate_bounds derivation helpers
(pure geometry over existing fields) for the selection overlay.
The host-input hit-test helpers rotation_corner_at_point /
selection_handle_at_point keep &Document — they serve the input path,
migrated by a later task.
PropertyPanel now reads the selected node(s) off EditorState: the
NodeSnapshot is built from the canonical PenNode (geometry via
op-editor-core aggregate_bounds, fill/stroke via the fills helpers),
and the panel state (focus/draft/flex/size/fill-type/tab/export) reads
EditorState.ui + editor_ui. PropertyLabels::for_document becomes
for_editor_ui.
The widget keeps shell-core's document-typed NodeKind / PropertyFocus /
FillType / FlexLayout / PropertyTab in its paint + hit-test surface;
variant-identical converters were added to editor_state_ext so the
hosts' input/hit-test path is untouched. Added op-editor-core
fills::node_stroke_width for the Stroke section's scalar width.
Both hosts' paint passes and the input-path hit-test sites updated.
LayerPanel now walks the canonical PenNode tree on EditorState.doc
instead of shell-core's derived Document. The page rows, the
depth-flattened layer rows, collapse state (editor_ui.collapsed_layers),
selection, hover and inline-rename drafts all read from EditorState.
The widget keeps a shell-core document::NodeId in LayerItem / the
hit-test surface — both id types are string newtypes, so the walk-time
conversion is lossless and the hosts' input/hit-test path stays
untouched. Constructors renamed from_document* -> from_editor*; both
hosts' paint passes and the input-path hit-test sites updated.
The five agent-settings widget files now read op-editor-core types:
- `AgentSettings` / `AgentSettingsTab` / `McpCli` / `McpServer` /
`AgentProvider` swap from `crate::document::*` to
`op_editor_core::agent_settings::*` (and `chat::AgentProvider`).
- `AgentSettingsPanel` takes `&EditorState`; the four tab modules +
`agent_settings_i18n::t` thread `&EditorUiState` instead of
`&Document`, reading `locale` + `settings_input_draft` directly.
Both hosts' construction + hit-test call sites feed `&EditorState`;
the `SelectTab` / `Connect` / `ToggleMcpCli` / `nav_at` paths drop
their shell-core->op-editor-core enum translation shims since the
widget hit enum now carries op-editor-core types end to end.
ColorPicker, FigmaImport, LayerContextMenu and VariablesPanel now read
op-editor-core types directly instead of shell-core's `Document`:
- ColorPicker / LayerContextMenu take `&EditorState`, swapping
`ColorPickerState` / `ColorPickerDrag` / `LayerContextMenuState` /
`LayerContextTarget` to their op-editor-core equivalents. `drag_for_hit`
returns op-editor-core's `ColorPickerDrag`, dropping the host-side
shell-core->op-editor-core translation shim.
- FigmaImport stores the active `Locale` directly off `EditorUiState`.
- VariablesPanel reads `doc.variables` / `doc.themes` +
`ui.variables.active_theme`, snapshotting owned row / chip / theme
data at construction.
Construction + hit-test call sites updated in both hosts' widget_host
modules to feed `&EditorState` instead of the derived `paint_doc`.
ExportDialog::paint now reads op_editor_core::EditorUiState
(export_format + export_scale) instead of shell-core's Document.
A doc_export_format converter bridges the state-layer ExportFormat
to the widget-layer enum. Native host paint site updated.
TopBar / StatusBar / Toolbar / ShapePicker / LocalePicker / FileMenu /
AlignToolbar now read op_editor_core::EditorState (or the narrowest
sub-struct each needs) instead of shell-core's Document. A new
widgets/editor_state_ext.rs provides the theme()/t() derivations and
the Tool / ShapeChoice / AlignAction / FileMenuChoice converters.
Both hosts' paint + hit-test construction sites updated to pass
&self.editor_state.
Mirror the native host migration for `openpencil-shell-web`. The web
`WidgetHost` now holds an `op_editor_core::EditorState` authoritatively
instead of a shell-core `Document`. The ~30 shared widgets stay
`&Document`-bound and read-only — they are fed a derived `paint_doc`
snapshot rebuilt lazily by `refresh_paint_doc()` whenever
`editor_state_dirty` is set.
- Every mutation routes through an `op-editor-core` mutator + flags the
dirty bit; the snapshot re-derives once before paint / before any
hit-testing input event.
- Hit-test-then-mutate handlers refresh, hit-test on `&paint_doc`,
extract owned results, then mutate `editor_state`; shell-core hit
enums translate via `op_pen_loader::rev::*`.
- `paint` takes `&mut self` to drain the cache at the top of the pass.
- `op-editor-core` / `op-pen-loader` are optional deps behind the
`skia` feature (matching `skia-safe`) so the skia-free wasm32 CI stub
baseline stays clean — `op-pen-loader` pulls `jian-skia` transitively.
- Fix two pre-existing `--features skia` build blockers in `lib.rs`
(missing `Performance` web-sys feature, `canvas` moved before reuse).
No web-editor behaviour change — feature parity preserved.
- CursorMoved refreshes the derived paint Document before cursor_hint
so a post-mutation / pre-paint cursor move can't read stale geometry.
- apply_cursor_move refreshes the paint doc up front so every hit-test
inside it (align toolbar, color picker, layer menu, panel resize)
sees a fresh document.
- commit_variable_row_focus_if_any routes every exit through one
mark_dirty so an invalid variable edit can't leave the cache stale.
- Legacy private DocPayload files are detected and rejected with an
explicit "saved by an older version, must be re-saved" message
instead of a silent / opaque schema parse failure.
- active_page_index is persisted in a minimal .opmeta sidecar so it
survives a save / load round-trip; the .op file stays canonical.
- set_primary_fill_type carries the existing fill body across a
fill-type change (gradient stops / opacity / blend mode) instead of
overwriting it with a default body, matching shell-core behaviour.
- collapsed_layers documented as intentionally view-only at the
EditorSnapshot and editor_ui_state sites.
Flip WidgetHostNative off shell-core's Document onto
op_editor_core::EditorState. The host now owns one authoritative
state; every paint pass + hit-test reads a lazily-derived,
read-only Document snapshot (`paint_doc`), rebuilt via
op-pen-loader's pen_document_to_document + apply_editor_state_ui
whenever an `editor_state_dirty` flag is set.
Why: removes the dual-state strangler scaffold so the canonical
PenDocument model is the only editable state — input handlers
mutate EditorState, widgets stay read-only over the derived
Document. .op save now serializes editor_state.doc; load seeds
EditorState::from_document. shell-core's Document becomes a
paint-only target.
- op-editor-core: add host_support (EditorState::sample,
create_node_for_tool, replace_paths_with_polyline) + re-export
VariableScalar/VariableKind.
- shell-native: rewrite all 8 widget_host submodules onto
editor_state; split press.rs -> press_helpers.rs and keyboard
click routing -> click.rs to stay under the 800-line cap;
boolean_ops becomes a pure compute_boolean_op committed back
through EditorState.
- desktop: persistence / settings_io / chat_session /
model_discovery / main / frame move onto editor_state accessors.
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.