Commit graph

425 commits

Author SHA1 Message Date
Kayshen-X 87e9655a59 feat(canvas): wire smart guides into node dragging
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)
2026-05-17 13:17:16 +08:00
Kayshen-X f65346c72e feat(editor): add smart-guide alignment geometry
`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)
2026-05-17 13:07:50 +08:00
Kayshen-X c18ad5a776 feat: close six TS-parity gaps in the Rust shell
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)
2026-05-17 13:03:11 +08:00
Kayshen-X 6a29fb17c7 docs: translate remaining Chinese source comments to English 2026-05-17 09:33:41 +08:00
Kayshen-X 9b8f4baf19 fix(ci): drop redundant windows CommandExt import + tokio feature ban
- 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.
2026-05-17 02:01:05 +08:00
Kayshen-X 6f4ab59313 fix: clear clippy -D warnings across the workspace
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).
2026-05-17 01:26:29 +08:00
Kayshen-X 90c1cc4360 style: apply cargo fmt across the workspace 2026-05-17 00:26:15 +08:00
Kayshen-X 81e8e79b99 fix(ci): point op-host-desktop's agent dep at the vendor/agent submodule
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.
2026-05-17 00:12:09 +08:00
Kayshen-X 4b8e0ce956 refactor(rust): add op-app composition-root crate
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.
2026-05-16 23:57:03 +08:00
Kayshen-X 8733f45e2d refactor(rust): dissolve openpencil-shell-core re-export shim
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
2026-05-16 23:54:33 +08:00
Kayshen-X 1e0b3cab7a refactor(rust): rename native + desktop hosts to op-host-* crates
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
2026-05-16 23:49:58 +08:00
Kayshen-X 65c2aa055a refactor(rust): rename openpencil-shell-web host to op-host-web
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
2026-05-16 23:42:49 +08:00
Kayshen-X 5f68ab3c1a refactor: extract op-editor-ui crate
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.
2026-05-16 23:33:47 +08:00
Kayshen-X 0dc231a5e9 refactor: extract op-ai crate
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.
2026-05-16 23:24:29 +08:00
Kayshen-X e2e287c008 refactor: extract op-mcp crate
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.
2026-05-16 23:21:17 +08:00
Kayshen-X 9a0112b9a9 refactor: extract op-figma crate
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.
2026-05-16 23:17:05 +08:00
Kayshen-X 0cb56ecd9e refactor: extract op-codegen crate
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.
2026-05-16 23:14:45 +08:00
Kayshen-X a18ca7bbfe refactor: delete shell-core's document module
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.
2026-05-16 23:07:57 +08:00
Kayshen-X 4abe80213f refactor(op-pen-loader): delete dead Document-bridge code
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.
2026-05-16 22:42:44 +08:00
Kayshen-X 16a31251b5 refactor(op-pen-loader): build LayoutScene directly from PenDocument
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.
2026-05-16 22:37:46 +08:00
Kayshen-X 43cbf2131d refactor(electron): migrate export renderers off Document onto LayoutScene
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.
2026-05-16 22:28:27 +08:00
Kayshen-X 5dde9e8741 refactor(host): hit-test input against LayoutScene, drop per-frame paint_doc
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.
2026-05-16 22:18:31 +08:00
Kayshen-X 0b545e8b20 refactor(canvas): flip CanvasViewport paint onto LayoutScene + EditorState
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.
2026-05-16 21:49:29 +08:00
Kayshen-X 9fb8a42036 feat(layout-scene): add layout-resolved render scene type + builder 2026-05-16 21:35:03 +08:00
Kayshen-X e9861a0e21 refactor(panels): migrate PropertyPanel onto op-editor-core EditorState
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.
2026-05-16 21:20:42 +08:00
Kayshen-X 9049946794 refactor(panels): migrate LayerPanel onto op-editor-core EditorState
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.
2026-05-16 21:12:31 +08:00
Kayshen-X 62c5f02f0a refactor(editor): migrate AIChat panel onto op-editor-core chat state
`AIChatPlaceholder` + the model-picker dropdown now read op-editor-core
types directly:

- `ChatState` / `ChatRole` / `ChatMessage` / `ModelEntry` /
  `AgentProvider` swap from shell-core (`document::*` /
  `chat_models::*` / `agent_settings_state::*`) to
  `op_editor_core::chat::*`.
- `AIChatPlaceholder::from_editor[_at]` takes `&EditorState`, reading
  `state.chat` + resolving chrome labels through `editor_state_ext`'s
  `translate` / `theme_for` instead of `Document::t` / `Document::theme`.

Both hosts' paint + click + press call sites feed `&EditorState`. The
desktop model-discovery worker keeps emitting shell-core `ModelEntry`s,
already translated to op-editor-core's at the `editor_state.chat`
write boundary in `model_discovery.rs` — no change needed there.
2026-05-16 20:59:39 +08:00
Kayshen-X 9fe4ef5192 refactor(editor): migrate AgentSettings modal onto op-editor-core state
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.
2026-05-16 20:55:51 +08:00
Kayshen-X 7a4f958ff5 refactor(editor): migrate four modal widgets onto op-editor-core state
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`.
2026-05-16 20:48:55 +08:00
Kayshen-X 3e9f2bb26e refactor(editor): migrate ExportDialog onto EditorUiState
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.
2026-05-16 20:33:20 +08:00
Kayshen-X 0567e4f9f0 refactor(editor): migrate chrome-leaf widgets onto EditorState
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.
2026-05-16 20:27:44 +08:00
Kayshen-X e9960e6944 refactor(editor): make EditorState the web host's single source of truth
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.
2026-05-16 20:13:42 +08:00
Kayshen-X 1b310c714a fix(editor): address host-migration review concerns (stale-doc hit-tests, dirty-mark, legacy load, view-state persist, fill payload)
- 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.
2026-05-16 19:53:08 +08:00
Kayshen-X f0bb56b6cd refactor(editor): make EditorState the native host's single source of truth
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.
2026-05-16 19:29:34 +08:00
Kayshen-X 58346c2a9c feat(op-editor-core,op-pen-loader): host-migration prerequisites (fill-type, chat-model, layer-collapse, panel predicates, reverse enum bridge) 2026-05-16 18:54:04 +08:00
Kayshen-X 302c9750d6 feat(op-pen-loader): bridge EditorState UI/chat/components into a paint Document 2026-05-16 18:34:27 +08:00
Kayshen-X a79dbaf0e3 feat(op-editor-core): close variable/theme gaps for the host migration
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.
2026-05-16 18:19:35 +08:00
Kayshen-X 77f89b6ef8 refactor: extract op-pen-loader (canonical .op → Document) into a shared crate 2026-05-16 18:04:46 +08:00
Kayshen-X 88e155b324 refactor(op-editor-core): rename UiChrome → EditorUiState (drop the 'chrome' term) 2026-05-16 17:51:39 +08:00
Kayshen-X ec5ac70118 feat(op-editor-core): extend EditorState into a full state superset of Document 2026-05-16 17:47:53 +08:00
Kayshen-X b9d7032474 refactor(host): scoped EditorState bridge on WidgetHostNative
Phase 6 strangler scaffolding: WidgetHostNative now holds an
op_editor_core::EditorState alongside the legacy shell-core Document
so the ~30 widgets can migrate onto the canonical model one group at
a time while the workspace stays build-green.

The bridge is intentionally minimal — Document remains the source of
truth (every un-migrated widget paints from it and every apply_*
mutates it), and editor_state is not yet wired into paint or input.
There is no Document -> PenDocument converter, and the desktop
pen_doc_adapter only goes the other way (baking flex layout into AABB
rects, which is irreversible), so a per-frame round-trip would be
unsound. Each later 6.x task adds its own per-group sync point as it
switches a widget group's paint/input onto editor_state.

Field, accessors and dependency are all marked TEMPORARY — deleted in
Phase 7 when Document dies and editor_state becomes the host's only
state.
2026-05-16 17:30:44 +08:00
Kayshen-X 8078a70d8c refactor(figma): port figma import onto PenNode
FigmaClipboardNode::to_node now produces canonical
jian_ops_schema::PenNode instead of shell-core's flat Node. Maps the
8 common Figma kinds onto Frame/Group/Rectangle/Ellipse/Line/Polygon/
Path/Text variants; unmapped kinds fall back to Frame with the
original kind preserved in base.role since PenNode has no Other
escape hatch. Tests ported to assert on PenNode variants + base
accessors. The import stays in shell-core (Option B, in-place port).
2026-05-16 17:21:35 +08:00
Kayshen-X fa6fe21ed5 refactor(codegen): port codegen onto PenDocument 2026-05-16 17:15:44 +08:00
Kayshen-X 39162ad08e refactor(mcp): port MCP server onto op-editor-core EditorCommand
Port the in-process MCP server off shell-core's legacy `Document` /
`McpCommand` onto `op_editor_core::EditorState` / `EditorCommand`. The
module physically stays inside `openpencil-shell-core` (an `op-mcp`
crate extraction is a later Phase-7 task).

- Read tools snapshot `EditorState` (canonical `PenDocument`); write
  tools emit `op_editor_core::EditorCommand` applied via
  `EditorState::apply`. Node ids are now canonical `.op` schema
  strings, not the old `u64`.
- Component commands surface a clean `ToolFailed` "known gap" error —
  `op-editor-core` has no component registry yet. Same for
  `set_node_collapsed` (`NodeFlag::Collapsed` has no schema field).
- `mcp_serve.rs` loads the `.op` file straight into an `EditorState`
  (plain `jian-ops-schema` deserialization) and saves the
  `PenDocument` back on every successful write.
- Delete the orphaned legacy apply path (`document/mcp_apply*.rs` +
  `Document`/`VariableTable::apply_mcp_command`); the widgets that use
  `Document` are untouched.
- Split `tools.rs` → `tools.rs` + `read_tools.rs` and
  `component_tools.rs` → `component_tools.rs` + `page_tools.rs` to
  hold the 800-line cap.
2026-05-16 17:07:42 +08:00
Kayshen-X 6cd2b5cd8c feat(op-editor-core): EditorCommand DTO + EditorState::apply 2026-05-16 16:32:44 +08:00
Kayshen-X 3e04f9a261 feat(op-editor-core): port color/align/variables/rename mutators 2026-05-16 16:21:12 +08:00
Kayshen-X 9d9862a099 feat(op-editor-core): port editing mutators onto EditorState 2026-05-16 16:10:36 +08:00
Kayshen-X 58a4ee58e0 feat(op-editor-core): EditorState on jian PenDocument 2026-05-16 15:54:32 +08:00
Kayshen-X 8f8b36b76f docs: fix stale i18n + renderer paths flagged by Codex review 2026-05-16 15:45:54 +08:00
Kayshen-X 01731d6af8 refactor(document): migrate test code to string NodeId 2026-05-16 15:42:46 +08:00
Kayshen-X 0174aba93a refactor(render): move RenderBackend trait into op-editor-core 2026-05-16 14:36:09 +08:00
Kayshen-X b67fe5fbcc chore: scaffold op-editor-core crate 2026-05-16 14:32:53 +08:00
Kayshen-X 0ab38e1ca0 fix(i18n): point convert-locales.py output at the op-i18n crate 2026-05-16 14:25:58 +08:00
Kayshen-X 08686c3b95 refactor(i18n): extract op-i18n crate (with Locale) from openpencil-shell-core 2026-05-16 14:13:38 +08:00
Kayshen-X 182ec78416 docs: fix stale anthropic-agent-sdk path in chat_claude.rs comment 2026-05-16 13:20:02 +08:00
Kayshen-X 5b6cf39775 chore: vendor anthropic-agent-sdk out of crates/ 2026-05-16 13:14:20 +08:00
Kayshen-X 0a90f13aae chore: delete dead pen-* stub crates + openpencil-app 2026-05-16 12:45:07 +08:00
Kayshen-X 76c598a23d feat(ai): adopt the official github-copilot-sdk for the Copilot bridge
Replace the in-workspace copilot-sdk fork with the official
`github-copilot-sdk` crate (crates.io 0.1). Rewrite chat_copilot.rs
against its API: Client::start + a streaming SessionConfig whose
SessionHandler forwards `assistant.message_delta` / `session.error`
events into the ChatProvider channel, one client+session per turn.
Delete the crates/copilot-sdk fork directory. Build + 72 desktop
tests pass on the Rust 1.94 toolchain.
2026-05-16 10:26:44 +08:00
Kayshen-X 542ec113d1 feat(panels): wire the Effects section "+" to add a drop shadow
The property panel's 效果 header drew a "+" icon that did nothing.
Add a PropertyPanelAction::AddEffect, emit its hit rect over the
icon in the layout walker (no paint/layout change — the icon was
already painted), and dispatch it in both hosts to
Document::add_drop_shadow_to_selected. Clicking "+" now appends a
drop shadow that renders + persists.
2026-05-16 10:00:50 +08:00
Kayshen-X c0f503e99a feat(document): add_drop_shadow_to_selected mutator
Append a default drop-shadow effect (CSS-style 0 4px 8px
rgba(0,0,0,0.25)) to the selected node — the building block the
property panel's 效果 "+" action will call. Guarded on a real,
editable selection like the sibling set_selected_* mutators.
2026-05-16 03:27:52 +08:00
Kayshen-X 6e3404dcab feat(canvas): gaussian-blur drop-shadow on the web backend
Mirror the native fill_drop_shadow override on the wasm web
backend with skia MaskFilter::blur so drop-shadow effects paint as
a real soft shadow on both native and web — keeping the two
shells visually consistent.
2026-05-16 02:47:53 +08:00
Kayshen-X 6292556ccb feat(canvas): real gaussian blur for native drop-shadow paint
The fill_drop_shadow RenderBackend primitive only had the default
un-blurred approximation. Override it on the native backend with
skia's MaskFilter::blur (CSS blur-radius → sigma = radius/2) so
drop-shadow effects paint as a real soft shadow on desktop.
2026-05-16 02:46:45 +08:00
Kayshen-X 7a90583073 fix(ai): parse CLI JSON-RPC responses robustly across stdio framing
The Copilot / Codex response parsers ran serde on the whole stdout
line, so a `Content-Length:` header prefix or a glued next-frame
header suffix would fail the parse. Add brace-counted (string- and
escape-aware) JSON-object extraction so discovery works whether the
CLI replies newline-delimited or Content-Length-framed.
2026-05-16 02:39:06 +08:00
Kayshen-X e87d69cec4 fix(ai): Content-Length-frame Copilot stdio JSON-RPC requests
The Copilot discovery wrote newline-delimited requests to
`copilot --stdio`; the official copilot-sdk frames requests with
`Content-Length` headers. The CLI happens to accept both, but
relying on that leniency is fragile. Write proper LSP-style
Content-Length frames so we speak the protocol the SDK defines.
Verified live against the installed `copilot` CLI.
2026-05-16 02:28:07 +08:00
Kayshen-X 3eca00a099 feat(ai): query Copilot models via the official CLI JSON-RPC protocol
Copilot discovery now drives `copilot --stdio` over JSON-RPC
(connect → models.list) — the same wire protocol (version 3) the
official github-copilot-sdk uses. Speaking the protocol directly
rather than linking the SDK crate keeps the workspace on Rust 1.85
(the SDK crate requires 1.94). Documented model names stay as the
fallback when the CLI is installed but the query doesn't answer.
2026-05-16 02:07:35 +08:00
Kayshen-X 3c78d7d68f feat(ai): query Codex models via the official app-server protocol
Codex discovery now drives `codex app-server` over JSON-RPC stdio
(initialize → initialized → model/list), the official App Server
API — more accurate and stable than reading models_cache.json,
which may be stale or absent. The cache stays as a fallback, then
a minimal placeholder when codex is installed but neither source
answers. A reader thread + deadline keeps a hung server from
blocking discovery.
2026-05-15 23:40:54 +08:00
Kayshen-X ee40986d97 feat(ai): model-picker dropdown in the chat panel
Replace the chat panel's agent-cycling chip with a real model
picker: the chip shows the selected model's provider brand logo +
display name, and clicking it opens an upward dropdown listing
chat.available_models grouped by provider with a selected check
(mirrors the TS ai-chat-model-selector). Selecting a model also
re-syncs chat_selected_agent to its provider so the chat transport
targets the matching CLI. Outside clicks dismiss the picker.
2026-05-15 23:34:08 +08:00
Kayshen-X 5bdd74d624 feat(ai): discover chat models from installed CLIs
The chat panel never had a model list — shell-core is transport-free
and cannot query a CLI. Add desktop-side cross-platform discovery:
Codex's models_cache.json (real, file-based), `opencode models`
(real, subprocess), and the documented CLI model names for Claude /
Gemini / Copilot which expose no listing interface — each gated on
the CLI actually being installed (PATH walk with platform exe
extensions). Discovery runs on a worker thread and drains into
chat.available_models. shell-core keeps only the ModelEntry type.

Move the per-frame paint pass to frame.rs to keep main.rs under cap.
2026-05-15 23:24:55 +08:00
Kayshen-X 76ca5efb21 feat(canvas): paint node drop-shadow effects behind fills
Node.effects already round-trips through persistence and canonical
import, but the canvas never rendered it. Add a fill_drop_shadow
RenderBackend primitive (default impl approximates with a translucent
fill; native + web will override with skia MaskFilter blur) and paint
each DropShadow behind Frame / Rect / Ellipse fills, offset + blur
scaled by viewport zoom.
2026-05-15 23:09:42 +08:00
Kayshen-X a15f63f551 fix(import): parse canonical rgba() shadow colours instead of dropping them
Canonical .op shadow colours use functional rgba()/rgb() notation, which
the hex-only parser rejected — valid shadows were imported as opaque
black, then dropped entirely after the prior fix. Add parse_css_color
covering both hex and functional forms. Move the effect-conversion
helpers into persistence_effects.rs so pen_doc_adapter.rs stays under
the 800-line cap.
2026-05-15 23:03:11 +08:00
Kayshen-X 3ba7d6cd6b fix(import): drop shadow on unparseable colour, not opaque black
`shadows_from_canonical` fell back to `[0,0,0,1.0]` (opaque black)
when `parse_hex` rejected a shadow colour — so an `rgba()`,
named, or short-hex colour imported as a wrong solid-black
shadow (codex stop-gate). Switch the fallback to `filter_map`
`None`: an unparseable colour drops that shadow entirely, which
is honest (no shadow) rather than misleading (wrong shadow).
Matches `first_solid_color`'s existing `parse_hex(...)?` discipline.
2026-05-15 22:52:28 +08:00
Kayshen-X e8a5422578 fix(import): carry canonical drop-shadow effects on .op import
`pen_doc_adapter::node_to_payload` dropped every `PenEffect` —
a `.op` authored with shadows lost them on import (codex stop-gate;
the inverse of the save-path effects round-trip just added).

`shadows_from_canonical` reads each variant's effects (Frame /
Group / Rectangle carry them on `container`, leaf shapes directly;
IconFont / Ref have none) and maps `PenEffect::Shadow` →
`ShadowPayload`. Blur / background-blur are skipped — the shell's
`Effect` model is drop-shadow-only today.
2026-05-15 22:44:41 +08:00
Kayshen-X 1bed264d1f feat(document): add Effect (drop shadow) data model + persistence
First slice of the Effects gap — the property panel's 效果 section
was a header-only stub with no data behind it.

- `Effect` / `DropShadow` types in `document.rs`; `Node.effects:
  Vec<Effect>` (offset_x/y + blur + color, doc-px). Both `Node`
  builders + `deep_clone_with_new_ids` carry it.
- `persistence_effects.rs` — `ShadowPayload` + `effects_to_payload`
  / `effects_from_payload`; `NodePayload.effects` is `#[serde(default)]`
  so legacy `.op` files still load. Carved into its own module so
  `persistence.rs` stays under the 800-line cap.
- Round-trip tested through the real serde JSON path (2 tests).

858 workspace tests pass. Still pending (own slices): canvas
drop-shadow paint (needs a blur primitive on `RenderBackend`),
the property-panel 效果 editing rows, and a `set_node_shadow`
MCP tool. Shadows currently round-trip through save/load but do
not yet render.
2026-05-15 22:33:13 +08:00
Kayshen-X d9ada6cc11 docs(mcp): correct read-tool count 17→18 in catalog 2026-05-15 22:10:00 +08:00
Kayshen-X 5373b83b09 docs(mcp): refresh tool catalog 21→77 + add AI chat section
- crates/CLAUDE.md MCP catalog was stale at 21 tools; replace the
  outdated table with the current 77-tool categorized summary and
  point at mcp_serve.rs TOOL_SCHEMAS as the source of truth.
- Refresh the mcp/ file-layout list (node_attr_tools,
  selected_ops_tools, extra_read_tools, json_serializer, …).
- New "AI chat (real provider integration)" section documenting
  begin_send / ChatProvider / chat_session / the model chip and
  the Codex/OpenCode honest-error behavior.
- chat_session: gate the test-only `finished()` accessor behind
  `#[cfg(test)]` to drop a dead-code warning.
2026-05-15 22:06:42 +08:00
Kayshen-X 1f617269e6 fix(ai): drop in-flight chat session on unwired-agent error path
When a send for an unwired agent (Codex / OpenCode) landed while a
previous turn was still streaming, `launch_if_pending` wrote the
error bubble but left `current` pointing at the old session — so
the next `pump` streamed the prior agent's deltas straight into
the fresh error message. Clear `current` before writing the error.
2026-05-15 21:51:39 +08:00
Kayshen-X 71967d8474 fix(ai): surface error for unwired chat agents instead of rerouting
`provider_for_agent` silently substituted Claude Code when the
user picked Codex or OpenCode — so the transcript looked like the
chosen CLI answered when it hadn't. Codex stop-gate.

It now returns `Option`: `None` for Codex / OpenCode (HTTP-server
transport not yet bridged). `launch_if_pending` writes an explicit
`error: … not wired yet` into the assistant bubble and starts no
session, so the user knows their pick didn't run.
2026-05-15 21:48:09 +08:00
Kayshen-X 22b6da60a8 feat(ai): model chip selects among connected CLI agents
The chat panel's bottom-toolbar chip was a hardcoded "Default"
label. It now shows the selected CLI agent and cycles through the
connected ones on click.

- `UiState.chat_selected_agent` — index into `AgentProvider::ALL`
  (Claude Code / Codex CLI / OpenCode / GitHub Copilot / Gemini).
- `Document::cycle_chat_agent` — advances to the next *connected*
  agent (`agent_settings.connected`); walks all 5 when none are
  connected so the user can pre-pick before connecting.
- `AIChatHit::CycleModel` — the chip's left 150 px of the input
  toolbar; `apply_click` routes it to `cycle_chat_agent`.
- The chip label now renders `AgentProvider::name()` instead of
  the static "Default".
- `chat_session::provider_for_agent` routes the selected agent to
  its `ChatProvider`: Claude Code → SDK adapter, Copilot / Gemini
  → subprocess transport. Codex / OpenCode (HTTP-server CLIs whose
  bridge isn't wired yet) fall back to Claude Code.

856 workspace tests pass (+2: cycle walks connected / wraps,
cycle with nothing connected walks all five).
2026-05-15 21:39:25 +08:00
Kayshen-X 4ae4ce77f0 feat(ai): wire AI chat to real Claude Code provider
The chat send path was hardwired to a stub (`ChatState::send` →
"(stub) Got it"). The real providers existed but main.rs never
called them.

- `ChatState::begin_send()` — native-host send: pushes the user
  message + an empty assistant bubble, raises `pending_send`. The
  web shell keeps the offline `send()` stub.
- `chat_session.rs` — `ChatSession` runs the turn on a background
  thread (`ChatProvider::send` is a blocking iterator; draining it
  on the UI thread would freeze the window). `poll()` is
  non-blocking; `launch_if_pending` / `pump` are the desktop glue.
- main.rs — Enter key + Send-button click drain `pending_send`
  into a `ChatSession` against `ClaudeCodeProvider`; the winit
  loop pumps deltas into the transcript, waking ~30 fps mid-turn.

A missing / unauthenticated `claude` CLI now surfaces as an
`error: claude query: …` message instead of a fake stub reply.

854 workspace tests pass (+4: chat_session stream/error,
begin_send push/empty).
2026-05-15 21:29:26 +08:00
Kayshen-X b104dbc83a feat(mcp): expand tool catalog to 77 + variable CRUD + persistence fix
Grows the Rust shell's MCP surface from 60 to 77 tools and closes
several parity gaps with the Electron version:

- Node attribute writers: set_node_font_size / font_weight /
  stroke_hex / stroke_width / fill_hex / name
- Selection + clipboard: set_selection_set, toggle_node_selection,
  align_selected, copy_selected, cut_selected, paste_clipboard
- Theme: cycle_active_axis_value
- Read: get_node_children
- Variable CRUD: create_variable / delete_variable / rename_variable
  (mutators on VariableTable + 3 MCP tools)

Variable persistence fix: DocPayload never serialized the variable
table, so every set_variable_* / CRUD change was dropped on the MCP
save path. New persistence_variables module round-trips the full
VariableTable (variables / themes / active_theme / refs).

Codex stop-gate fixes folded in:
- selection apply paths scoped to the active page (set_selection,
  set_selection_set, toggle_node_selection rejected off-page ids)
- cut_selected is atomic — clipboard rolls back on failed delete
- get_node_children distinguishes unknown id (ToolFailed) from a
  known leaf / empty container (count=0)

File-cap refactors: mcp.rs / mcp_apply.rs / component_tools.rs /
mutators.rs split into sibling modules (node_attr_tools,
selected_ops_tools, mcp_apply_node_attrs, mcp_apply_selected,
json_serializer, clipboard, extra_read_tools) — all under 800 lines.

850 workspace tests pass.
2026-05-15 20:49:04 +08:00
Kayshen-X 659393668e feat(mcp): reorder_page write tool — final piece of pages CRUD
33 tools total. Move a page from one index to another;
target is clamped to [0, page_count). Mirrors TS
`reorderPage(from, to)`.

Wire shape:
  args: { "from": "<u32>", "to": "<u32>" }
  result: { "wrote": "true" }
  command: `McpCommand::ReorderPage { from, to }`

New `Document::reorder_page(from, to) -> bool` mutator that
adjusts `active_page_index` to track the moved page (or the
shift from neighbor pages moving past).

Pages MCP surface now complete:
- list_pages, set_active_page (navigation)
- add_page, duplicate_page (create)
- rename_page (update)
- reorder_page (reorder)
- delete_page (delete)

Desktop --mcp registry + tools/list schema + exact-count test
updated for 33 tools.
2026-05-15 06:24:56 +08:00
Kayshen-X 0232329d7b feat(mcp): rename_page / delete_page / duplicate_page — full page CRUD
Rounds out the pages MCP surface. 32 tools total now. Full
LLM-driven page lifecycle:

- list_pages (already existed)
- set_active_page (commit 1af880c2)
- add_page (commit d45af32f)
- rename_page (new — also adds Document::rename_page mutator)
- delete_page (new)
- duplicate_page (new)

Wire shape:
  rename_page: { "index": "<u32>", "name": "<non-empty>" }
  delete_page: { "index": "<u32>" }
  duplicate_page: { "index": "<u32>" }
  result: { "wrote": "true" }

New `Document::rename_page(idx, name)` mutator in
page_mutators.rs (peer of start_rename_page which is the UI
inline-rename flow). Rejects out-of-range indices + empty /
whitespace-only names so list_pages never returns an unusable
label.

Apply branches delegate to existing `remove_page` / `duplicate_page`
mutators. Variables table rejects all three Pages-level commands
in its defense-in-depth guard.

Desktop --mcp registry + tools/list schema + exact-count test
updated for 32 tools.
2026-05-15 05:33:44 +08:00
Kayshen-X 85ea875765 feat(mcp): add_page write tool — 29th tool, LLM-driven page CRUD
Append a fresh empty page + switch the active page to it. No
args. Mirrors TS `addPage()`. Applier returns false on
id-space exhaustion at `max_node_id() + 1`.

Wire shape:
  args: {}
  result: { "wrote": "true" }
  command: `McpCommand::AddPage`

Companion to set_active_page (commit 1af880c2): LLMs can now
create + navigate pages without touching the UI. Future patches
can add rename_page / delete_page / duplicate_page to complete
the page CRUD (Document already has the mutators).

Desktop --mcp registry + tools/list schema + exact-count test
updated for 29 tools.
2026-05-15 05:13:25 +08:00
Kayshen-X 750434646d feat(mcp): set_active_page write tool + components/page split
28th tool in the catalog. set_active_page lets LLMs switch which
page is the active target for subsequent inserts / batch_design /
design_* commands. Required arg: `index` (0-based u32). The
applier rejects out-of-range indices.

New `Document::set_active_page(idx) -> bool` mutator in
page_mutators.rs (peer of add_page / duplicate_page / remove_page).
Clears selection on switch so stale-selection ops don't carry
across pages.

Companion refactor: write_tools.rs was at 900 lines (over the
800-line cap) after the components CRUD shipped. Split out
components/page-related tools to `mcp/component_tools.rs`
(InstantiateComponent / CreateComponent / DeleteComponent /
RenameComponent / SetActivePage). write_tools.rs back to 686.

Desktop --mcp registry + tools/list schema + exact-count test
updated for 28 tools.
2026-05-15 04:22:16 +08:00
Kayshen-X 99ab4b6298 feat(mcp): get_component read tool — richer per-id component detail
27th tool in the catalog. Completes the components MCP read
surface: list_components returns the catalog (name + id pairs);
get_component drills into one entry and returns:
- name (display name)
- kind ("frame" / "group" / etc — the component's root NodeKind)
- leaf_count (sum of leaves in the prototype subtree)

LLM workflow: list_components to discover the catalog →
get_component to size each candidate → instantiate_component to
drop a clone.

Wire shape:
  args: { "component_id": "<positive u64>" }
  result: { name, kind, leaf_count } or ToolFailed when the id
  doesn't resolve.

The snapshot factory computes leaf_count at registration time
so the tool stays `&self`. NodeKind enum mapping shared with
the rest of the MCP surface ("frame" / "group" / "rect" / etc).

Desktop --mcp registry + tools/list schema + exact-count test
updated for 27 tools.
2026-05-15 03:27:36 +08:00
Kayshen-X 64b08457a8 feat(mcp): rename_component write tool — full CRUD-U on components
Adds the missing update verb to the components registry CRUD.
26 tools total now. The full lifecycle on the MCP side:

- list_components (read)
- create_component (create)
- rename_component (update — name only today; the prototype
  subtree stays whatever node was promoted at create time)
- instantiate_component (clone onto page)
- delete_component (delete)

Wire shape:
  args: { "component_id": "<positive u64>", "name": "<string>" }
  result: { "wrote": "true" }
  command: `McpCommand::RenameComponent { component_id, name }`

New `ComponentLibrary::rename(id, new_name) -> bool` mutator
rejects empty / whitespace-only names so list_components never
returns a stub a user can't recognize. Apply layer also
defensively rejects unknown ids.

Desktop --mcp registry + tools/list schema + exact-count test
updated for 26 tools.
2026-05-15 03:08:36 +08:00
Kayshen-X 5adc7807f6 feat(mcp): delete_component write tool — full CRUD on components
Completes the components registry MCP lifecycle:
- list_components (read)
- create_component (create from existing Frame/Group node)
- instantiate_component (drop a clone onto the active page)
- delete_component (remove from registry)

25 tools total. Live instances already on the page are NOT
affected by delete — they're independent clones; the registry
only holds the prototype that future instantiate_component
calls would have used.

Wire shape:
  args: { "component_id": "<positive u64>" }
  result: { "wrote": "true" }
  command: `McpCommand::DeleteComponent { component_id }`

New `ComponentLibrary::remove(id) -> bool` mutator returns
whether a component was removed; apply layer propagates that
so the LLM can tell whether the id resolved.

Desktop --mcp registry + tools/list schema + exact-count test
all updated for 25 tools.
2026-05-15 03:05:22 +08:00
Kayshen-X 1d5a5cde05 feat(mcp): create_component write tool — promote node to component
Closes the components MCP lifecycle: list_components (read),
create_component (promote a node to a component),
instantiate_component (drop a clone onto the active page). 24th
tool in the catalog.

Wire shape:
  args: { "node_id": "<positive u64>", "name": "<string>" }
  result: { "wrote": "true" }
  command: `McpCommand::CreateComponent { node_id, name }`

New `Document::create_component_from_node` mutator (peer of
the existing `create_component_from_selected`) takes the target
id explicitly instead of reading `self.selected`, because MCP
sessions have no UI selection state. Both share the same
container-kind guard (Frame / Group only) and replace-on-
duplicate-id semantics via `ComponentLibrary::insert`.

Apply path rejects unknown ids + non-container kinds.
Variables table rejects InstantiateComponent + CreateComponent
in its Pages-level guard (defense in depth).

Desktop --mcp registry + tools/list schema + exact-count test
all updated for 24 tools.

LLM workflow:
1. list_components to see what's registered
2. create_component on a Frame / Group to register it
3. instantiate_component to drop a clone of any registered component
2026-05-15 03:02:11 +08:00
Kayshen-X 8fa555445d feat(mcp): instantiate_component write tool — drop component onto page
Closes the matching write surface to list_components. 23rd tool
in the catalog. LLM clients can now (a) list registered
components via list_components and (b) drop a clone of any of
them onto the active page via instantiate_component.

Wire shape:
  args: { "component_id": "<positive u64 id from list_components>" }
  result: { "wrote": "true" }
  command: `McpCommand::InstantiateComponent { component_id }`

The apply path routes through the existing
`Document::instantiate_component` mutator, which deep-clones
the component's root subtree with fresh ids past
`max_node_id()`, appends it to the active page, pushes a
history snapshot, and selects the new instance root. Returns
false when the component id is unknown.

Desktop --mcp registry + tools/list schema + exact-count test
all updated; handshake test now expects 23 tools.

Components panel UI is still pending (a future patch adds a
right-rail Components section); the MCP surface is the
LLM-driven workflow alternative until then.
2026-05-15 02:58:53 +08:00
Kayshen-X 83fa496124 feat(mcp): list_components read tool — surface registered components
Surfaces the existing Document.components ComponentLibrary via
MCP so LLM clients can discover what reusable Frame / Group
subtrees the user has saved. 22nd tool in the catalog (was 21).

Wire shape:
  count — total component count
  components — `;`-records of `name|id`, with the standard
    2-level escape on each side (matches list_variables /
    list_pages wire convention).

Closes the read-only MCP surface for components. Instance
insertion via MCP (the matching write tool) requires a JSON
Node descriptor and is the natural follow-up; the data model +
`Document::instantiate_component` already exist.

Desktop --mcp registry + tools/list schema updated; test
renamed to all_twenty_two_tools with exact-count guard intact.
2026-05-15 02:55:44 +08:00
Kayshen-X cf5b300714 fix(variables): commit row focus on every state-mutating shortcut
Codex re-review on 3891ea07 BLOCKed 8 more bypass paths.

Fix them in three groups:

1. input_active() (widget_host/input.rs:13) now includes
   variable_row_focus, so apply_copy / apply_cut / apply_paste /
   apply_select_all / apply_reorder (which already gate on
   input_active) automatically block while a row is focused —
   no need for per-method commits.

2. State-mutating shortcuts that DON'T gate on input_active() get
   explicit `commit_variable_row_focus_if_any()` at the top:
   - apply_undo / apply_redo / apply_group / apply_ungroup
     (shortcuts.rs:55–104)
   - apply_toggle_code_panel (shortcuts.rs:122)
   - apply_toggle_agent_settings (shortcuts.rs:132)
   - apply_set_tool (shortcuts.rs:144)
   - apply_boolean_op (widget_host.rs:338)
   - apply_delete (keyboard.rs:149) — special: pops a char from
     the variable draft instead of falling through to selection
     delete, matching the apply_backspace behavior

3. Desktop entry points in main.rs that bypass apply_press AND
   the shortcut methods (Cmd+S / Cmd+O / Cmd+Shift+S / Cmd+Shift+P):
   call new `commit_variable_row_focus_if_any_pub()` proxy on
   the host before persistence::handle_*.

The proxy is exposed because the internal helper is
`pub(in crate::widget_host)`.

377 shell-core tests still pass.
2026-05-15 02:49:12 +08:00
Kayshen-X 7a9781e6a7 fix(variables): commit row focus on right-click + Cmd+J chat toggle
Codex re-review on 86167224 BLOCKed two more bypass paths:

- Right-click → `apply_right_press` (press.rs:71) opens layer
  context menus and returns without going through apply_press
  or apply_text. Add `commit_variable_row_focus_if_any()` at
  the top.
- Cmd+J → `apply_toggle_chat` (shortcuts.rs:106) flips chat.focused
  directly. Add the same commit before the toggle so the chat
  takeover doesn't leave keystrokes routed into the variable
  draft.

The same row re-click + escape cascade paths stay clean.
2026-05-15 02:39:11 +08:00
Kayshen-X 8aff86234c fix(variables): commit row focus at top of apply_press — covers all hit targets
Codex re-review on 939c9742 BLOCKed: piggy-backing the
variable-row commit onto commit_property_focus_if_any only
covered call sites that ran when property_focus was already
set. A standalone variable_row_focus (no property focus) +
click on canvas / toolbar / topbar / chat / layer rail
bypassed the commit entirely, keeping keystrokes routed into
the variable draft.

Move the commit to the top of apply_press, BEFORE every
hit-test branch. Property-focus commit-on-blur still gated
on the property panel's x boundary (different layout — the
property panel sits below the variables panel in the same
right rail; clicks on the property panel area shouldn't
commit a variable edit and vice-versa, but the variables
editor lives within the right rail so any non-right-rail
click is "outside" for it, and any property-panel click is
"outside" for it too via the down-stream hit-test cascade).

Chat clicks enter through apply_press first (they call
apply_click as a sub-step, lines 438 + 527), so the top-of-
apply_press commit also closes the chat-tab focus leak that
codex flagged as B2.
2026-05-15 02:32:47 +08:00
Kayshen-X 50faf20fa3 fix(variables): commit row focus on every outside-click path
Codex stop-time review on 0ccf10b9 flagged that the new
variable_row_focus state stayed set when the user clicked
outside the variables panel (canvas, toolbar, etc.), causing
subsequent keystrokes to leak into the variable draft instead
of routing to the canvas / chat / other surfaces.

Root cause: the existing 9+ outside-click handlers called
`commit_property_focus_if_any` but not the new
`commit_variable_row_focus_if_any`. Rather than patching all 9
call sites, piggyback on `commit_property_focus_if_any` — it
now commits the variable-row focus first, then proceeds to its
own logic. Every existing outside-click commit path now covers
both editor surfaces in one call.

The explicit double-call at the Number/String row click site
(property_dispatch.rs:308–309) is now redundant but not wrong,
so leave as documentation that the order matters when seeding
a fresh focus.
2026-05-15 02:27:00 +08:00
Kayshen-X b3732450f7 feat(variables): inline editor for Number / String variable rows
Closes MINOR #8 non-color variable editors UI gap. Clicking a
Number or String variable row in the VariablesPanel now opens
an inline edit field on that row.

Surface:

- `Document.ui.variable_row_focus: Option<VariableRowFocus>` with
  Number(idx) / String(idx) variants — new enum in document.rs
- Reuses `property_input_draft` as the buffer (single caret-blink
  anchor + input handler tier serves both PropertyFocus and
  VariableRowFocus)
- `VariablesPanel` carries `editing_row` + `editing_draft`
  snapshots from doc.ui in `for_document`
- Paint: when the row idx matches `editing_row`, replace the
  value-preview label with the draft text + a thin foreground
  underline as the active-focus affordance (no caret blink yet —
  the property panel's caret machinery isn't shared; the
  underline is sufficient discoverability)
- Input dispatch (`widget_host/keyboard.rs`):
  * apply_text: new branch BEFORE property_focus. Number accepts
    digit / leading `-` / one `.`; String accepts any non-control
    char. Both push to property_input_draft.
  * apply_backspace: new branch BEFORE property_focus, pops draft
  * apply_send: routes to new `commit_variable_row_focus_if_any`
  * apply_escape: clears focus + draft (before property_focus
    tier so the escape cascade still feels right)
- Commit helper `commit_variable_row_focus_if_any` in
  `property_dispatch.rs`: parses the draft per kind (f64 parse
  for Number — rejects NaN/Inf; raw string for String), calls
  `VariableTable::set_scalar`, pushes a history snapshot on success
  so undo restores the prior value.

Color rows still open the color picker (unchanged); Boolean rows
still toggle on click (unchanged); Number / String now have an
inline editor instead of a no-op.

377 shell-core tests still pass.
2026-05-15 02:23:23 +08:00
Kayshen-X d91166ccda test(perf): 1000-node regression guards for max_node_id / hit-test / batch_insert
Coarse perf regression test for the MINOR "1000+ node doc render
pan/zoom not benchmarked" gap. Three integration tests in
`crates/openpencil-shell-core/tests/large_document_perf.rs`:

- max_node_id × 100 on 1000-leaf doc < 50 ms (debug build)
- node_at_doc_point × 100 across the grid < 25 ms (debug build)
- apply_mcp_command(BatchInsert) of 1000 descriptors < 100 ms

Bounds are deliberately generous on debug builds — the job is to
catch a future 10× regression, not measure wall-clock precision.
Real benchmarks via criterion still pending (criterion is not a
workspace dep yet); these guards are the bounded alternative.

All three pass on the current build.
2026-05-15 02:16:18 +08:00
Kayshen-X 32ef03907a feat(variables): click on boolean variable row toggles the value
Bounded slice of the non-color variable editor UI gap. Clicking
a Boolean-kind variable row in the VariablesPanel now toggles
its value through `VariableTable::set_scalar`, honoring the
active-theme routing (subset match / no default clobber / no
other-axis shadow) so a flip under theme=dark writes only the
dark entry.

Color rows still open the color picker; Number / String rows
are explicit no-ops (the MCP path covers writes today via
set_variable_number / _string — an inline text input on the
row needs a draft buffer + caret + input dispatch that's not
worth the scope tonight).

Each toggle pushes a history snapshot so undo restores the prior
value. 377 shell-core tests still pass.
2026-05-15 02:14:37 +08:00
Kayshen-X 79cf73df19 feat(variables): theme axis dropdown — direct value pick instead of cycle
Closes MINOR #9 theme axis dropdown gap. Chip-click semantics
change from "cycle to next value" to "toggle dropdown for direct
pick", matching TS's behavior where users see all axis values in
a menu and can pick any one.

Surface change:
- `Document.ui.axis_dropdown_open: Option<String>` (axis name)
- `VariablesPanelHit::AxisDropdownItem { axis, value }` new hit kind
- `VariablesPanel` carries `dropdown_open` snapshot from doc.ui
- Paint: dropdown overlay anchored under the open chip; popover
  card with rounded border, per-value rows, active-value
  highlight via theme.muted
- Hit-test: dropdown overlay takes top-most priority so a click
  on a value row wins over the chip / row beneath
- Host wiring (`property_dispatch.rs`):
  * AxisChip click toggles axis_dropdown_open (same chip closes,
    different chip switches)
  * AxisDropdownItem click calls VariableTable::set_active_theme +
    closes the dropdown + pushes a history snapshot

Test: `axis_dropdown_hit_routes_to_named_value` asserts that with
themes=[mode: light/dark/system] and the dropdown open, hits on
rows 0 and 2 return the correct (axis, value) pair.

377 shell-core tests pass (was 376).
2026-05-15 02:12:32 +08:00
Kayshen-X 62d36ef06e feat(settings/system): honest "Up to date" auto-update banner
Closes the auto-update banner MINOR gap. TS's electron-updater
shows a status banner with a dot + label; the Rust shell now
matches the visual surface while staying honest about the
backend:

- Green status dot + "Up to date" label paint side-by-side with
  the "Auto-update" header in the System tab card.
- Description line: "Running the latest installed build. No
  update channel is wired in this build — the next release
  ships through your package manager / .dmg / .exe."
- Existing "not yet wired" sub-line kept as the smaller
  follow-up so a user wondering "why no Check button?" sees
  the answer in-place.
- Three new i18n keys (settings.system.upToDate / .upToDateDescription
  / .checkForUpdates) with EN + ZH translations.
- Card height grew 64 → 88 px to fit the new lines.

No togglable switch — the previous module comment was explicit
that flipping a boolean would lie to the user. No real
network check happens. When the updater backend lands, this
can grow back into a check-now button + real status field on
Document.ui.
2026-05-14 23:38:03 +08:00
Kayshen-X 3d9dab0f20 fix(desktop/mcp): tighten design_* schema descriptions + exact-count test
Codex stop-gate review on 7cee053d flagged two concerns:

1. design_skeleton/content/refine tools/list schema descriptions
   could mislead LLM clients into inferring differentiated apply
   semantics from the phase label. Per the internal comment in
   batch_design.rs, all three currently dispatch to BatchInsert
   with phase metadata only — but the schema description is what
   clients see, not the comment. Add explicit "Apply behavior is
   identical to batch_design today (phase is metadata only)" to
   each of the three schema descriptions.

2. The new tools_list test only verified each tool name via
   `contains`. A future tool addition could silently pass this
   test without updating the expected count. Add an
   `assert_eq!(TOOL_SCHEMAS.len(), 21)` exact-count guard with
   a clear error message ("add the new tool to this test").
2026-05-14 23:30:47 +08:00