Commit graph

425 commits

Author SHA1 Message Date
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
Kayshen-X 4311b5ec7b docs(crates): clean up stale "Eight" + duplicate HttpServer line
Cosmetic — tool count was still "Eight" from the previous
revision, and the HttpServer pending bullet was duplicated.
2026-05-14 23:25:09 +08:00
Kayshen-X 9985caeccf docs(crates): add design_skeleton/content/refine to tool catalog
Layered design workflow shipped in 7cee053d. Update CLAUDE.md:
- 3 new rows in the tool catalog table (21 tools total now)
- Tools/list note updated 18 → 21
- Pending list trimmed (design_* line removed; replaced with
  the next-tier work: per-phase apply semantics, JSON Node parser
  for subtree shapes, HttpServer wire protocol verification)
2026-05-14 23:24:28 +08:00
Kayshen-X 18c827d620 feat(mcp): design_skeleton / design_content / design_refine — layered workflow
Closes the layered design workflow MAJOR gap line. Three new
write tools (13th / 14th / 15th in the write catalog, 21 tools
total) mirror TS's pen-mcp phased generation workflow:

- design_skeleton — phase 1, structural scaffolding
- design_content  — phase 2, fill content
- design_refine   — phase 3, polish details

Each tool shares batch_design's wire shape (nodes_json carrying a
JSON array of leaf descriptors). Today every phase emits the same
McpCommand::BatchInsert — the phasing is metadata only, stamped
into the response payload as `phase=skeleton|content|refine` so
LLM clients can correlate the call back to its workflow phase.

A future patch may grow per-phase apply semantics (design_refine
patching existing nodes via UpdateNode batches, etc.) once the
richer commands exist.

mcp_serve.rs wires the three new snapshots into rebuild_registry
+ adds their tools/list schemas. The handshake test is renamed
`tools_list_response_includes_all_twenty_one_tools`.

Smoke test: three back-to-back tools/call invocations (skeleton
→ content → refine) return 3 valid responses each carrying the
correct phase label, and all 3 nodes persist to disk.
2026-05-14 23:23:08 +08:00
Kayshen-X e71aa319bb docs(crates): document batch_design + scalar_vars + --mcp host wiring
Updates the MCP server section in crates/CLAUDE.md to reflect
today's shipped work:

- Tool catalog grew from 8 → 18 (added batch_design + 3 scalar
  variable tools + host wiring closes BLOCKER #1)
- File layout includes the new mcp/batch_design.rs + mcp/
  scalar_vars.rs + their sibling test files
- New "Host wiring" subsection explains
  `openpencil-desktop --mcp <path>` lifecycle + handshake
- Pending list updated: layered design workflow tools
  (design_skeleton/content/refine) + JSON Node parser remain
2026-05-14 23:20:34 +08:00
Kayshen-X 356bc588d7 fix(mcp/scalar): reject Color through set_scalar — keep hex validation mandatory
Codex stop-time review on d422f5ef flagged that the Color+Str
arm of VariableTable::set_scalar wrote any string into a Color
variable without hex validation. The tool snapshot for
set_variable_string filters Color names out, but a misrouted
command (or a forged McpCommand) would still mutate the Color
variable to garbage at apply time. Defense in depth required.

Fix: forbid Color from set_scalar entirely. Color variables MUST
go through set_color_hex which validates the hex up front.
McpCommand::SetVariableScalar against a Color variable now
returns false instead of corrupting the stored value.

Regression test `apply_mcp_command_rejects_color_through_scalar_path`
stamps a Color variable with `#abcdef`, fires a forged
SetVariableScalar with `String("garbage")`, and asserts the
Color value is untouched.

376 shell-core tests pass (was 375).
2026-05-14 23:12:09 +08:00
Kayshen-X 54428f0c07 feat(mcp): set_variable_number/string/boolean — non-color scalars
Closes the MCP side of the "non-color variable editors" MINOR
gap. Three new write tools (10th / 11th / 12th in the write
catalog) mirror set_variable_color for the Number / String /
Boolean variable kinds.

All three route through one shared apply-time mutator:
\`VariableTable::set_scalar(name, VariableScalar)\` (new in
\`document/variables.rs\`). This generalizes set_color_hex's
theme-routing logic (subset match / no default clobber / no
other-axis shadow) over any VariableScalar variant. Kind/scalar
mismatch is rejected at apply time as defense in depth even when
the tool's snapshot would have admitted it.

One unified McpCommand variant \`SetVariableScalar { name, scalar:
VariableScalarPayload }\` plus a wire-friendly payload enum
(Number(f64) / String(String) / Boolean(bool)) so the wire layer
doesn't need to reach into shell-core's document API.

The three tools share an arg shape (\`name\` + \`value\`):
- number: value parsed as finite f64, rejects NaN / Inf / non-numeric
- string: value taken verbatim
- boolean: value must be "true" or "false" (case-sensitive)

Code lives in a new \`mcp/scalar_vars.rs\` sibling so
\`write_tools.rs\` stays under the 800-line cap; tests in
\`mcp/scalar_vars_tests.rs\` (5 tests).

\`openpencil-desktop --mcp\` registry + tools/list schema updated
so external CLIs see all 18 tools (was 15). Handshake test
renamed to match.

375 shell-core tests pass (was 370 — 5 new scalar_vars tests).
2026-05-14 23:05:21 +08:00
Kayshen-X 5154a8e25c feat(mcp): batch_design write tool — atomic N-node insert
Closes MAJOR item from the 2026-05-14 gap list. The ninth MCP
write tool. Mirrors TS \`batch_design\` for the leaf subset of
NodeKind (frame / group / rect / ellipse / polygon / line /
text / path).

Wire shape: scalar arg \`nodes_json\` carrying a JSON array of
\`{kind, name, x, y, width, height, fill_hex?}\` descriptors. The
shell-core parser rejects structured args at the top level (so
an LLM can't sneak nested objects past scalar contracts), but a
JSON array wrapped in a quoted string round-trips cleanly — the
tool parses the inner JSON itself.

Implementation lives in \`crates/openpencil-shell-core/src/mcp/
batch_design.rs\` (sibling carve-out keeps write_tools.rs under
the 800-line cap). New \`McpCommand::BatchInsert { items:
Vec<BatchInsertItem> }\` variant + apply branch in mcp_apply.rs
that:

- validates EVERY descriptor before any mutation (kind /
  geometry / fill_hex)
- allocates fresh ids past max_node_id() up front for the whole
  batch (checked_add per id; bail on overflow)
- only after all validation + allocation passes, attaches each
  new node to the active page

A single bad entry rejects the whole batch — the LLM never sees
a partial design tree (defense in depth: same check at tool layer
+ applier).

Tool also handles wire-level JSON escaping: the shell-core
parser stores string values with escape sequences intact (\\\"
arrives as backslash-quote), so the tool runs an unescape pass
before its hand-rolled JSON-array parser. Handles \\\" / \\\\ /
\\n / \\t / \\r / \\/.

Tests in \`mcp/batch_design_tests.rs\` (9 tests):
- requires nodes_json arg
- rejects empty array (tool) + empty items (applier)
- rejects unknown kind, negative geometry, missing required keys,
  malformed JSON (6 separate cases)
- parses minimal 2-node array + applies it with fresh ids
- atomicity on bad descriptor at the apply layer

\`openpencil-desktop --mcp\` registry + tools/list schema updated
so external CLIs see batch_design as the 15th tool.

Smoke test: \`tools/call batch_design\` with 3 leaf descriptors
(2 rect + 1 text) → \`{count:"3",wrote:"true"}\` response + all
3 nodes persisted to disk.

370 shell-core tests pass (was 361 — 9 new batch_design tests).
2026-05-14 22:58:46 +08:00
Kayshen-X 47794e992a fix(desktop/mcp): implement initialize + tools/list handshake
Codex stop-time review on 23b0bfba flagged that real MCP clients
(Claude Code, Codex, etc.) won't dispatch tools/call cold — they
open with \`initialize\` and \`tools/list\` to discover the
server. The previous --mcp implementation responded only to
tools/call, so real clients hung at handshake.

Add in-binary handlers for the MCP control-plane methods:

- \`initialize\` → protocolVersion "2024-11-05" + capabilities
  (tools.listChanged=false) + serverInfo
- \`notifications/initialized\` (and bare \`initialized\`) →
  absorbed silently (spec: notifications have no response)
- \`tools/list\` → response with all 14 tools + their JSON
  inputSchemas (name / description / required args / enums for
  kind + drop_children)
- \`ping\` → empty result
- everything else falls through to shell-core's
  run_stdio_with_applier so the parser hardening + apply
  discipline still apply

Top-level method/id sniffing uses the same key-walker pattern as
shell-core's arguments_field, so a nested key called "method" or
a string value of "initialize" can't shadow the real top-level
method. Four sniff/response unit tests cover the discipline.

End-to-end roundtrip verified with real stdin/stdout:
initialize → notifications/initialized → tools/list → tools/call
insert_node → tools/call list_pages → all five frames return
correct JSON-RPC + the inserted rect persists to disk.
2026-05-14 22:43:14 +08:00
Kayshen-X d9664d2879 feat(desktop): --mcp <path> mode runs stdio MCP server
Closes BLOCKER #1 from the 2026-05-14 gap report: the Rust
shell's MCP ToolRegistry now has a host. \`openpencil-desktop
--mcp <path>\` skips the winit event loop and runs a JSON-RPC
stdio server backed by that .op file. External CLIs (Claude
Code / Codex / Gemini / Copilot) can spawn the binary in this
mode the same way they spawn TS pen-mcp today.

Implementation (\`crates/openpencil-desktop/src/mcp_serve.rs\`,
~95 lines):

- Loads the .op file via the existing canonical persistence path
  (made \`load_from_path\` pub).
- Re-builds the ToolRegistry against the live document between
  every dispatched call so read-tool snapshots reflect the
  latest state (write commands mutate the doc; reused registry
  would have stale snapshots).
- Dispatches one JSON-RPC line at a time through
  shell-core's run_stdio_with_applier so the parser-level
  hardening (structured-arg rejection / top-level walker / no-
  hang error path) protects this binary too.
- Apply closure mutates the doc + saves to the same path on
  every successful write command. Save failures surface to
  stderr but don't crash the loop.

Smoke test passed: list_pages → insert_node → list_pages
roundtrip through real stdin/stdout returns three JSON-RPC
responses, and the inserted rect persists to disk (verified
by grepping FromMCP out of the saved file).

361 shell-core tests still pass; workspace build clean.
2026-05-14 22:37:24 +08:00
Kayshen-X e1a639e3fb docs(crates): document MCP write-tool catalog + wire hardening
Adds a new "MCP server (shell-core)" section to
crates/CLAUDE.md covering:

- The 14-tool catalog (6 read + 8 write) with arg shapes and
  emitted McpCommand variants per tool.
- Pre-validate-then-mutate discipline at the apply layer.
- `replace_node`'s `drop_children` destructive-swap guard.
- Wire-format stop-gates (structured-arg rejection, top-level
  `arguments` walker, no-hang on parse failure, read-only path
  refusing write tools).
- mcp/ file layout including the sibling test files for
  copy_node and replace_node.
- Pending: host wiring, batch_design / design_skeleton tools,
  HttpServer transport.
2026-05-14 22:31:58 +08:00
Kayshen-X 5f681a8a7b fix(mcp/stdio): emit typed error on parse fail — no client hangs
Codex stop-time review on 4ff2b87f flagged that the stricter
parser created a new failure mode: when parse_tool_call returns
None (structured args, unparseable JSON, etc.), run_stdio just
\`continue\`s — JSON-RPC clients waiting on a response correlated
by id never get one and hang.

Fix:
- Expose \`parser::extract_request_id\` so the dispatcher can
  recover the id when the full parse fails. Helper does only
  what parse_tool_call's first three lines used to do; parse_tool_call
  now calls it internally so there's a single source of truth.
- In \`run_stdio_with_applier\`, on \`parse_tool_call → None\`,
  attempt to extract the id; if found, write a typed error
  response (\`InvalidArgument\` + "malformed tool call:
  unparseable or structured arguments"). If even the id is
  missing, drop the line silently — there's nothing to
  correlate against.
- Drop the now-dead \`extract_object_body\` helper (\`arguments_field\`
  replaced it in the previous commit).

Tests:
- \`run_stdio_emits_error_when_parse_fails_so_clients_dont_hang\`
  — structured \`drop_children\` arrives with valid id 42;
  response echoes id 42 + carries an \`error\` field naming
  "malformed tool call".
- \`run_stdio_skips_lines_without_an_id\` — id-less lines stay
  silent (existing behavior, explicitly covered now).

361 shell-core tests pass (was 359).
2026-05-14 22:29:20 +08:00
Kayshen-X cd7d1d9eb8 fix(mcp/parser): top-level arguments walker — no nested-key shadow
Codex BLOCKed ee0671a7 on the substring-find inside
arguments_field: `body.find("\"arguments\"")` matched any
nested key or string value containing the literal `"arguments"`,
not just the top-level params.arguments field. Two valid-but-
unusual JSON shapes bypassed the rejection contract:

1. `meta:{"arguments":{}}` alongside `"arguments":"oops"` — the
   substring scan hit meta.arguments as a Body and parsed it,
   skipping the real top-level string-typed arguments which
   should have rejected.
2. `name:"arguments"` with no real arguments field — false
   positive Malformed instead of Missing.

Replace with a proper top-level key walker that:
- skips whitespace + commas between top-level pairs;
- reads each quoted key + its value, classifying the value
  (string / object / array / number-bool-null);
- only treats `arguments` as a real hit when seen at depth 0;
- categorizes the matched value: object → Body, anything else
  → Malformed; missing entirely → Missing.

Three new regression tests in `mcp_tests.rs::
parse_tool_call_arguments_lookup_is_top_level_only` cover the
nested-shadow, string-value-collision, and deep-nested-key
cases.

359 shell-core tests pass.
2026-05-14 22:19:53 +08:00
Kayshen-X 4279ddd8e5 fix(mcp/parser): reject non-object \arguments\ + add tools/call tests
Codex BLOCKed 0a434e6b on three items:

1. MCP arguments non-object downgrade — \`"arguments":"oops"\`
   used to flow through extract_object_body returning None and
   parse_tool_call downgrading to empty args. Replace the call
   site with a tri-state \`arguments_field\` helper:
   - Missing → empty args (legit MCP can omit \`arguments\`).
   - Body(s) → parse the object body.
   - Malformed → reject the parse.

2. Test coverage gap — the structured-rejection tests only
   exercised the legacy/direct line-protocol shape. Add two new
   tests under mcp_tests:
   - parse_tool_call_rejects_structured_values_in_mcp_tools_call_shape:
     nested object + array inside MCP \`arguments\` both reject.
   - parse_tool_call_rejects_non_object_arguments_field: string /
     number / array \`arguments\` reject; missing \`arguments\`
     still parses as empty args.

3. Stale doc comment on parse_flat_object_body — said nested
   values are "skipped", now correctly describes the wire-layer
   rejection contract.

358 shell-core tests pass (was 356 — two new MCP tools/call
shape tests).
2026-05-14 22:14:29 +08:00
Kayshen-X c8efbc1d92 fix(mcp/parser): reject the parse when any arg value is structured
Codex re-review on c6e6a308 flagged the sentinel approach's
collision risk: a variable literally named "{...}" — or any
string-accepting arg with that value — was indistinguishable
from wire-malformed input. Move the rejection up to the wire
layer so no tool sees a structured value at all.

- `parse_flat_object_body` now returns `None` the moment it
  sees `{` or `[` for any value (was: insert a sentinel).
- `parse_tool_call` previously masked None with
  `.unwrap_or_default()`, which would have silently swallowed
  the rejection back into empty args. Replace
  `extract_params_object` with a tri-state `ParamsResult`
  (Missing / Body / Malformed) so we can distinguish "no params
  key" (legit empty args) from "params present but malformed"
  (full call rejection). MCP `tools/call` path treats a missing
  `arguments` key the same way (empty args), and propagates a
  `None` from the inner parse as a full call rejection.

Tests:
- `parse_tool_call_rejects_structured_arg_values` (in
  `mcp_tests.rs`) asserts both object and array values cause
  `parse_tool_call` to return None, while scalar-only still
  parses.
- `parser_refuses_structured_drop_children_at_wire_layer` (in
  `mcp/replace_node_tests.rs`) end-to-end: real MCP JSON with
  structured `drop_children` and structured `name` never reach
  the tool layer.
- `replace_node_rejects_malformed_drop_children` drops the
  obsolete sentinel cases — they can no longer reach the tool.

356 shell-core tests pass.
2026-05-14 22:06:30 +08:00
Kayshen-X 22b33235d6 fix(mcp/parser): surface nested args as sentinels, not silent drops
Codex re-review on fd0af355 walked one level deeper into the
parser and found that ANY tool's safe-default-on-missing logic
could be bypassed by sending the arg as a JSON object or array.
Example: `{"drop_children": {}}` arrived at ReplaceNode as
absent → defaulted to false → silent acceptance.

Fix at the parser layer so every write tool benefits at once:
- `parse_flat_object_body` (parser.rs:287) now inserts `"{...}"`
  or `"[...]"` as a sentinel value for the key when it walks
  past a nested literal, instead of dropping the key.
- Every existing tool's scalar validation (hex regex, i32 parse,
  enum match, bool match) naturally rejects the sentinel as an
  InvalidArgument, so no per-tool wiring is needed.

Tests:
- `parse_tool_call_skips_nested_object_values` rewritten to
  `_surfaces_nested_values_as_sentinels` — asserts the present-
  with-sentinel contract for both `{}` and `[]`.
- `parser_to_tool_chain_rejects_structured_drop_children` (new
  in `mcp/replace_node_tests.rs`) — end-to-end: real MCP JSON
  with structured `drop_children` goes through the parser into
  ReplaceNode::call and lands on `InvalidArgument`. Sweeps both
  object and array shapes.
- `replace_node_rejects_malformed_drop_children` now also sweeps
  the sentinels themselves as malformed strings.

356 shell-core tests pass (one obsolete test replaced + one
end-to-end test added).
2026-05-14 21:57:59 +08:00
Kayshen-X 0e61ee715b fix(mcp/replace): reject empty-string drop_children too
Codex re-review on 2f0200b0 flagged that drop_children="" was
silently treated as false, hiding a possible mis-serialized
confirmation from the caller. Drop the empty-string special
case — only "true" / "false" / missing are now accepted; anything
else returns InvalidArgument.

Test broadened: the malformed-string case now sweeps "yes" / ""
/ "TRUE" / "1" / "0" so every common mis-spelling is covered.
2026-05-14 21:48:41 +08:00
Kayshen-X 9c6f19991c fix(mcp/replace): refuse silent container subtree deletion
Codex stop-time review on replace_node flagged that a doc-comment
warning isn't enough — the tool would still silently delete a
container's subtree if the LLM picked the wrong target. Promote
the safety from advisory to enforced:

- Thread `drop_children: bool` through `McpCommand::ReplaceNode`.
- Apply path: if the target has children AND `drop_children` is
  false, refuse the swap before allocating an id or mutating
  pages. Container subtrees are preserved by default.
- Tool side: parse optional `drop_children` arg ("true"/"false");
  default false; malformed string returns `InvalidArgument` with
  a message naming the arg.

Tests cover the four reachable shapes: leaf swap with default
`drop_children=false` still works (pre-existing behavior); a
container swap without consent refuses; the same swap with
`drop_children=true` succeeds; a malformed `drop_children` arg
fails parsing.

355 shell-core tests pass (was 352 — three new guard tests).
2026-05-14 21:44:33 +08:00
Kayshen-X 2c359d441b docs(mcp/replace): warn that container children are dropped
Codex review on e51b979f flagged that the replace_node doc
comment didn't mention the destructive behavior on containers.
Add an explicit "Destructive on containers" note to both the
McpCommand variant and the tool struct, pointing readers at
update_node for in-place patches.

No behavior change.
2026-05-14 21:35:39 +08:00
Kayshen-X e12bc4f0bc feat(mcp): replace_node write tool — atomic swap at same slot
Eighth write tool in the catalog. Required args: node_id, kind,
name, x, y, width, height. Optional: fill_hex. Builds a fresh
node with a non-colliding id and swaps it into the same parent
slot the target node currently occupies, preserving sibling
order.

Bounded scope: only leaf-style fields land on the replacement.
Full subtree replacement requires a JSON Node parser that
doesn't live on this side yet. The current contract matches TS
`replace_node` for primitives, minus children.

Apply path follows the same pre-validate-then-mutate discipline
as `update_node` and `move_node`: kind / geometry / fill_hex /
target existence / id space — every check before any mutation.
A bad fill_hex never leaves the document half-touched (covered
by `apply_mcp_command_replace_node_atomic_on_invalid_fill_hex`).

Tests live in `mcp/replace_node_tests.rs` (matches the
`copy_node_tests.rs` sibling pattern; keeps `write_tools_tests.rs`
under cap).
2026-05-14 21:31:12 +08:00
Kayshen-X e9254e77c1 docs(mcp/copy): align doc comment with the actual wire payload
Codex review on 100eb78a flagged that the McpCommand::CopyNode
doc comment promised a `new_root_id` field that the tool never
emits. The tool returns `{"wrote": "true"}` like every other
write command because `ToolOutcome` is built before apply runs —
the allocator that mints fresh ids is reachable only from the
host applier, not the tool.

Rewrite the doc comment to describe the actual contract instead
of a feature that's wishful. Threading the allocator back to the
tool to surface real clone ids is a future patch.
2026-05-14 21:26:47 +08:00
Kayshen-X 0ff1fc6e4e feat(mcp): copy_node write tool — deep-clone with fresh ids
Seventh write tool in the catalog. Validates node_id +
target_parent_id args, then issues `McpCommand::CopyNode` which
the host applies via `Document::apply_mcp_command`. The applier
walks the source subtree, allocates fresh ids past `max_node_id()`
for every cloned node, and attaches the clone under the target
(page root when `target_parent_id == 0`).

Allows `node_id == target_parent_id` so an LLM can duplicate a
container's contents under itself (valid copy semantics; differs
from move_node which rejects that case).

Tests live in a new `mcp/copy_node_tests.rs` sibling — bundling
them into `write_tools_tests.rs` would have pushed it past the
800-line cap.
2026-05-14 21:21:40 +08:00
Kayshen-X a39302b948 fix(mcp/move): pre-validate target before detaching the source
Codex stop-gate caught a real correctness bug: `move_node`'s
apply path was:

  1. validate source exists + cycle check
  2. detach the source (vec.remove → owned Node)
  3. find_node_mut_in_doc(target) — if None, return false

When target_parent_id was unknown, step 2 had already removed
the source from its parent. The owned Node fell off the end of
the function and was DROPPED — silently destroying the source.
The existing unknown-id test only covered the path where the
old `find_node_in_doc` on the cycle check incidentally caught
it; an LLM passing a bogus target_parent_id would lose data.

Fix: every validation (source exists, target exists, cycle
guard, page-root path's active page exists) runs upfront. Only
after all checks pass does the detach + reattach proceed. The
two halves of the mutation are no longer separable from each
other — match-statement style atomicity, no orphan path.

Test (1 added, 343 shell-core total):
  - `apply_mcp_command_move_node_preserves_source_when_target_unknown`
    builds the exact codex scenario: MoveNode { node_id: 11
    (sample Title), target_parent_id: 99999 (doesn't exist) }.
    Asserts:
      1. apply returns false (no successful move),
      2. Frame's child count is unchanged (Title still there),
      3. Title is still in Frame's children vec,
      4. Title appears EXACTLY ONCE in the doc (the dropped-
         source bug would have left zero occurrences).
    Pre-fix assertion #4 would have failed with count = 0.

The same pre-validate-then-mutate pattern is now consistent
across update_node (atomic on invalid geometry, 9e601573),
insert_node (id-space-exhausted guard, 62a2fb5b), and
move_node (this fix). DeleteNode is naturally atomic — single
remove_in_subtree call.
2026-05-14 21:11:40 +08:00
Kayshen-X 7e3da464c3 feat(mcp): move_node write tool — sixth in the catalog
Sixth MCP write tool, completing the node-lifecycle quartet
(insert + update + delete + move). LLMs can now reparent nodes
across the document tree — from page root to a group, between
groups, or back to the root.

`McpCommand::MoveNode { node_id, target_parent_id }`:
  - `target_parent_id == 0` reparents to the active page root.
    Non-zero ids must resolve to an existing node.
  - Cycle guard at apply time: if target_parent is a descendant
    of source, the move would orphan + cycle the subtree, so
    apply returns false.
  - Same-id reparent (node_id == target_parent_id) is rejected
    at the tool layer with InvalidArgument.

`src/document/mcp_apply.rs`:
  - MoveNode branch on Document::apply_mcp_command. Runs the
    cycle guard via `find_node_in_doc` + `subtree_contains`
    BEFORE detaching, so a rejected cycle leaves state intact.
  - `detach_node(doc, id)` + `detach_from_subtree(vec, id)`
    walkers — locate the node, `vec.remove(idx)` it, return
    the owned Node. Reattach via push to the new parent's
    children.
  - `find_node_in_doc(doc, id)` + `find_in_subtree_ref(slice,
    id)` — immutable variants for the cycle check (can't share
    a mutable borrow with the subsequent detach).
  - `subtree_contains(node, target) -> bool` — recursive scan
    of a node's id + descendants.

`src/document/variables.rs`:
  - VariableTable::apply_mcp_command's "Pages-level commands not
    mine" arm grew MoveNode to keep the exhaustive match.

`src/mcp/write_tools.rs`:
  - `MoveNode` (stateless) + `move_node_snapshot()` factory.
    Validates both node_id (positive u64) + target_parent_id
    (any u64, 0 ⇒ page root) and rejects same-id pair.

Tests (6 added, 342 shell-core total):
  - move_node_validates_args — missing both / missing target /
    node_id=0 / node_id == target → InvalidArgument.
  - move_node_returns_command_with_zero_target_for_page_root —
    target_parent_id=0 carries through unchanged.
  - apply_mcp_command_move_node_reparents_to_page_root — sample
    doc; Title(11) under Frame(10); after move target_parent_id=0
    Title is at page root + Frame no longer carries it.
  - apply_mcp_command_move_node_reparents_to_another_node —
    Title(11) → Button group(12); Title leaves Frame, lives
    under Button.
  - apply_mcp_command_move_node_rejects_cycle — Frame(10) ↓
    Button(12) → MoveNode { node_id: 10, target_parent_id: 12 }
    would cycle. Apply returns false + tree is structurally
    intact (Frame at root, Button as child).
  - apply_mcp_command_move_node_rejects_unknown_id — both
    unknown-source and unknown-target branches.

MCP write surface: set_variable_color, set_active_axis_value,
insert_node, update_node, delete_node, move_node. The remaining
TS pen-mcp catalog: copy_node, replace, batch_design,
design_skeleton.
2026-05-14 21:08:35 +08:00
Kayshen-X 2061a9338a fix(mcp/update): apply update_node atomically — no partial mutation
Codex stop-gate caught a real correctness bug: `update_node`'s
apply path was mutating bounds.origin.x and bounds.origin.y BEFORE
checking width / height for negative values. A request like:

  UpdateNode { x: Some(999), y: Some(999), width: Some(-1), ... }

would move the node to (999, 999) AND then reject the resize,
leaving the node half-updated. Worse, in the wire-level flow the
client receives `host rejected command` (Internal demotion), so
they think nothing changed — but actually the move did apply.

Fix: run ALL field validation BEFORE the mutable borrow + writes.
The negative-width / negative-height checks run upfront alongside
the existing fill_hex parse. After the validation block, no
early-return is possible, so every set of writes either applies
in full or doesn't apply at all.

Tests (2 added, 336 shell-core total):
  - `apply_mcp_command_update_node_is_atomic_on_invalid_geometry`
    — sample doc's Title (id 11) bounds snapshotted; UpdateNode
    with valid x/y/name + negative width. Asserts apply returns
    false AND every field matches pre-call state. Pre-fix the
    test would have observed x=999, y=999, and the rename.
  - `apply_mcp_command_update_node_is_atomic_on_invalid_hex` —
    parallel atomicity for the fill_hex path; valid x/y/name +
    bad hex. Same pre/post bounds + name assertion.

The same atomicity invariant already held for InsertNode (single
mutation point at the end of the branch) and DeleteNode (atomic
by definition — single `retain` call).
2026-05-14 21:02:59 +08:00
Kayshen-X 463993af7f feat(mcp): update_node + delete_node write tools
Two more MCP write tools, mirroring the TS pen-mcp catalog's most-
common mutations beyond insert_node. Both follow the established
write architecture: tool validates args + returns `OkWithCommand`;
host applier mutates the live document; stdio applier path demotes
to Internal on apply-time rejection.

`McpCommand` (in `src/mcp.rs`):
  - `UpdateNode { node_id, x, y, width, height, name, fill_hex }`.
    Every patch field is Option<…>; None leaves the live value
    unchanged. Bounds writes replace coords piecemeal so a caller
    can move (x, y) without resizing or vice versa.
  - `DeleteNode { node_id }`. Removes the node + all its
    descendants from its parent.

`src/document.rs`:
  - `NodeId::new_opt(u64) -> Option<Self>`. Non-panicking sibling
    of `new()` — returns None for id 0 (NONE sentinel). Used by
    the write tools to validate arbitrary wire-supplied ids
    without panicking.

`src/document/mcp_apply.rs`:
  - Document::apply_mcp_command branches grow UpdateNode +
    DeleteNode. UpdateNode pre-validates the optional fill_hex
    BEFORE the mutable borrow on pages so a bad hex doesn't
    partially mutate the node. Width / height patches reject
    negative values.
  - `find_node_mut_in_doc(doc, id)` + `find_in_subtree(slice, id)`
    + `remove_in_subtree(vec, id)` recursion helpers that walk
    every page's tree. find_in_subtree uses the "position-then-
    index" split so the borrow checker doesn't see overlapping
    iter_mut ranges.

`src/document/variables.rs`:
  - VariableTable::apply_mcp_command's UpdateNode + DeleteNode
    arms return false (Pages-level commands aren't theirs to
    apply), preserving the exhaustive match.

`src/mcp/write_tools.rs` (NEW, 437 lines):
  - All MCP write tools moved out of `tools.rs` (which would
    have grown past the 800-line cap): SetVariableColor,
    SetActiveAxisValue, InsertNode, UpdateNode, DeleteNode +
    their factories + shared parse helpers (validate_hex,
    parse_i32_arg, parse_opt_i32, ALLOWED_KINDS).
  - tools.rs now holds only read tools (510 lines, under cap).
  - get_active_theme_snapshot stays in tools.rs (read-side
    factory for the GetActiveTheme tool which is also a read
    tool).

`src/mcp/write_tools_tests.rs` (NEW, 532 lines):
  - All write-tool tests moved out of tools_tests.rs (which was
    900 lines after the write-tool tests were appended). Both
    test files now under cap.
  - mcp.rs registers `#[cfg(test)] mod write_tools_tests;`
    alongside its sibling tools_tests.

`src/mcp.rs`:
  - Re-export surface split into read-side (from tools::) and
    write-side (from write_tools::) so the public API stays
    flat (`mcp::SetVariableColor`, `mcp::UpdateNode`, etc.).

Tests (10 added, 334 shell-core total):
  - update_node tools: required-node-id, id-format validation
    (must be positive u64), empty-patch error, partial-patch
    happy path, apply routes to the right node + leaves
    unspecified fields untouched, apply rejects unknown id.
  - delete_node tools: required-arg + id-format validation;
    apply removes the node from its parent + descendants;
    apply rejects unknown id.

MCP write surface now: set_variable_color, set_active_axis_value,
insert_node, update_node, delete_node. Remaining TS pen-mcp
catalog: move_node, copy_node, replace, batch_design,
design_skeleton.
2026-05-14 20:58:11 +08:00
Kayshen-X 0728f3ed44 fix(mcp/insert): refuse insert when id space is exhausted at u64::MAX
Codex stop-gate caught: `next_node_id_seed` used
`max_node_id().saturating_add(1)`. When a live node sits at
`u64::MAX`, saturating_add wraps back to u64::MAX — so the
allocator would return the SAME id as the existing node and the
push would create a duplicate NodeId. Document::find would then
return whichever was first, silently masking or partially
mutating the original.

Fix: switch to `checked_add(1)`. The seed now returns `Option<u64>`
— `None` when the id space is exhausted. `Document::apply_mcp_
command`'s InsertNode branch surfaces the None as `false` (apply
failure), and the run_stdio_with_applier path already demotes
that to `ToolErrorCode::Internal` so the client sees a clear
"host rejected command" rather than a fake success that doesn't
actually create anything new.

Test (1 added, 326 shell-core total):
  - `apply_mcp_command_insert_rejects_when_id_space_exhausted` —
    plants a live node at `u64::MAX`, attempts InsertNode, asserts
    apply returns `false` AND `pages[0].children` length is
    unchanged AND the live u64::MAX node's name is preserved
    (so we know the duplicate id didn't accidentally overwrite
    it). Pre-fix this test would have passed `apply` returning
    `true` while corrupting state.

The bound is comfortable in practice — a document would need 2^64
live nodes to hit it. The fix is defensive correctness, not a
performance concern.
2026-05-14 20:43:24 +08:00
Kayshen-X a6d208d313 feat(mcp): insert_node tool — third write (the big one)
Third MCP write tool, completing the "create + style + theme" write
surface for LLM clients. `insert_node` is the TS pen-mcp
equivalent's flagship capability — without it, LLMs can only
modify existing nodes; with it, they can build entire designs.

`crates/openpencil-shell-core/src/mcp.rs`:
  - `McpCommand::InsertNode { kind, name, x, y, width, height,
    fill_hex }`. fill_hex is `Option<String>` — color-bearing
    shapes can carry a fill; structural nodes (frame/group)
    pass None.

`crates/openpencil-shell-core/src/document/mcp_apply.rs` (NEW):
  - `Document::apply_mcp_command(cmd)` — lifted to Document level
    so InsertNode can reach Pages + the id allocator (variable +
    theme commands still route to `var_table.apply_mcp_command`).
  - `Document::next_node_id_seed()` — allocates a fresh id past
    `max_node_id() + 1`, saturating_add-guarded so u64::MAX
    returns 1 instead of colliding with NodeId::NONE.
  - `parse_node_kind(s)` — accepts the same lowercase strings
    the read-side tools (get_node, get_selection) emit, so an
    LLM can round-trip a node's kind through read → modify →
    re-insert without re-encoding.
  - Pulled out of mutators.rs to keep that file under 800 lines.

`crates/openpencil-shell-core/src/mcp/tools.rs`:
  - `InsertNode` (stateless tool struct) + `insert_node_snapshot()`
    factory. No document snapshot needed — the tool doesn't need
    to know the current state; the host's applier handles id
    allocation + bounds installation.
  - `McpTool::call` validates:
      - All required args present (kind / name / x / y / width /
        height); each `MissingArgument` carries the missing
        name.
      - kind in ALLOWED_KINDS (frame / group / rect / ellipse /
        polygon / line / text / path).
      - x / y / width / height parse as decimal i32 (negative x/y
        allowed for nodes placed off the page-origin; width /
        height must be non-negative).
      - Optional fill_hex parses as #rgb / #rrggbb / #rrggbbaa.
  - Returns `OkWithCommand(InsertNode)` on success.

Tests (6 added, 325 shell-core total):
  - insert_node_validates_required_args — missing kind →
    MissingArgument; invalid kind → InvalidArgument.
  - insert_node_validates_numeric_args — non-numeric x →
    InvalidArgument; negative width → InvalidArgument.
  - insert_node_validates_optional_fill_hex — bad hex →
    InvalidArgument.
  - insert_node_returns_command_with_parsed_args — happy path;
    every field round-trips into the McpCommand payload.
  - apply_mcp_command_routes_insert_node — end-to-end: doc.empty()
    → apply InsertNode → new node lives on active page, bounds +
    fill flow through, name matches.
  - apply_mcp_command_rejects_invalid_node_kind — bad kind at
    apply time → false (defensive re-check beyond the tool's
    validation, so host-only call sites are safe too).

mutators.rs trimmed from 814 → 798 (apply_mcp_command extracted +
some redundant doc comments compacted). All OP-owned files now
under the 800 cap except 3 pre-existing tech-debt violators
(codegen.rs 867, widget_host/press.rs 840,
widgets/canvas_viewport.rs 839).

MCP write surface now: 3 first-party writes (set_variable_color,
set_active_axis_value, insert_node). The TS pen-mcp catalog still
needs: update_node, delete_node, move_node, copy_node, replace,
batch_design, design_skeleton — each extends McpCommand + the
same applier pattern.
2026-05-14 20:37:27 +08:00
Kayshen-X e51600f068 feat(mcp): set_active_axis_value tool — second write
Second MCP write tool, completing the variable + theme write
surface. Mirrors `cycle_active_axis_value` (which the
VariablesPanel chip click drives) but PINS the value rather than
cycles — LLM clients use it when they want to land on a specific
axis state ("switch to dark mode") instead of stepping through
options ("flip to whatever's next").

`crates/openpencil-shell-core/src/mcp/tools.rs`:
  - `SetActiveAxisValue { axes: BTreeMap<String, Vec<String>> }`
    snapshot. Mirrors `var_table.themes` keyed by axis name.
  - `set_active_axis_value_snapshot(doc)` factory.
  - `McpTool::call` validates:
      - Both `axis` and `value` args present
        (`MissingArgument` on omission).
      - Axis exists in the themes table (`ToolFailed`).
      - Value is in `themes[axis].values` (`InvalidArgument`,
        error message lists allowed values).
  - Returns `OkWithCommand(SetActiveAxisValue { axis, value })`.
    The McpCommand variant has existed since the write arch
    landed (0f09671a); `Document::apply_mcp_command` already
    routes it to `var_table.active_theme.insert`.
  - Re-validates at apply time so a stale snapshot can't slip
    an unauthorized value through (host applier rejects with
    `false`, which the stdio path demotes to Internal).

Tests (5 added, 319 shell-core total):
  - `set_active_axis_value_validates_args_and_returns_command`
    — happy path; OkWithCommand variant + correct payload.
  - `set_active_axis_value_errors_on_missing_args` — both args
    required, message names the missing one.
  - `set_active_axis_value_errors_on_unknown_axis` — ToolFailed
    + error names the axis.
  - `set_active_axis_value_errors_on_value_not_in_axis` —
    InvalidArgument; error message lists every allowed value.
  - `apply_mcp_command_routes_set_active_axis_value` —
    end-to-end happy path + the stale-state rejection branch
    (invalid value at apply time returns `false`).

MCP write tool surface now:
  - set_variable_color  (Color hex through var_table)
  - set_active_axis_value (theme axis through active_theme map)
  - insert_node, update_node_position, etc. land later — each
    extends `McpCommand` + the existing applier pattern.
2026-05-14 20:26:55 +08:00
Kayshen-X a904b688e1 refactor(mcp): split mcp.rs + tools.rs to honor 800-line cap
Codex stop-gate caught: the write architecture commits (0f09671a +
63387f3f) pushed mcp.rs from 663 → 904 lines and tools.rs from 419
→ 1088. Both well over the 800 ceiling.

Split tests to sibling files, mirroring the
`layer_panel_tests` / `property_panel_tests` / `variables_tests`
pattern already used elsewhere in shell-core.

  src/mcp.rs            904 → 347 (impl spine only)
  src/mcp_tests.rs     (new, 560)
  src/mcp/tools.rs    1088 → 610 (impl spine only)
  src/mcp/tools_tests.rs (new, 479)

`src/lib.rs` gains `#[cfg(test)] mod mcp_tests;` next to
`pub mod mcp;` (matching the inline form for tests_geometry /
tests_mutators).

`src/mcp.rs` gains `#[cfg(test)] mod tools_tests;` next to
`pub mod tools;` (one-line form keeps mcp.rs under cap).

Visibility lift: `escape_record_field` bumped from private to
`pub(crate)` so the sibling tests file can drive the
list_variables backward-compat assertions.

No behavior change — every test moved verbatim. 314 shell-core
tests still pass. All OP-owned files under 800 lines except the
3 long-standing pre-existing violators (codegen.rs 867,
widget_host/press.rs 840, widgets/canvas_viewport.rs 839) which
are tracked separately and not touched by this session's edits.
2026-05-14 20:21:14 +08:00
Kayshen-X b92704046c fix(mcp): stdio loop applies write commands (or demotes to Err)
Codex stop-gate caught: the previous commit (0f09671a) shipped the
`OkWithCommand` write path but `run_stdio` would dispatch a write
tool, get back `ToolResponse::Ok { command: Some(_), .. }`, and
write `result:{wrote:true}` to the wire WITHOUT applying the
command. Clients saw success for a mutation that never happened.

Split run_stdio into a read-only path + a write-aware path:

`run_stdio(registry, reader, writer)` — read-only. Calls the new
applier-aware variant with an applier that always returns false,
so any `OkWithCommand` is demoted to `ToolErrorCode::Internal`
("host rejected command: ..."). Clients can't see misleading
success on this path.

`run_stdio_with_applier(registry, reader, writer, F)` — accepts
`F: FnMut(&McpCommand) -> bool`. For each dispatched ToolCall:
  - read tools (no command) → response written verbatim.
  - write tools, applier returns true → response written as
    success (the host has applied + the client gets the tool's
    `result` payload).
  - write tools, applier returns false → demoted to
    `Internal` with `host rejected command: <Debug>` so the
    client knows the mutation didn't land.

The real `openpencil-mcp` binary wires this with `Document::
apply_mcp_command` as the closure — same as the existing
applier signature `VariableTable::apply_mcp_command(&cmd) -> bool`.

Tests (3 added, 314 shell-core total):
  - `run_stdio_demotes_write_tool_response_to_error_without_applier`
    — the codex repro: read-only stdio with a registered write
    tool. Sends a valid set_variable_color request; asserts the
    wire output carries `code: -32603` (Internal) + the "host
    rejected command" sentinel.
  - `run_stdio_with_applier_applies_write_command_then_writes_success`
    — applier returns true; the closure receives the command
    exactly once (verified by collecting into a Vec); the wire
    response is a clean Ok with no error code.
  - `run_stdio_with_applier_demotes_when_applier_rejects` —
    applier returns false (simulates host state drift); response
    demotes to Internal so the client knows the write didn't
    land.

The MCP write architecture is now end-to-end safe: tools validate,
the registry surfaces commands, stdio applies them through a
host-supplied closure or refuses to claim success.
2026-05-14 20:14:35 +08:00
Kayshen-X 2117900cda feat(mcp): write tool architecture + set_variable_color (first write)
Closes the architectural gap for MCP write tools that's been
deferred across the session. The McpTool trait stays `&self` (so
trait-object registry + Send + Sync bounds work cleanly), but
ToolOutcome grows a third variant that lets validate-only tools
describe a mutation the host applies later.

`crates/openpencil-shell-core/src/mcp.rs`:
  - `ToolOutcome::OkWithCommand(BTreeMap, McpCommand)` — write
    tools return this from `call`. The registry's dispatch lifts
    the command into `ToolResponse::Ok { id, result, command:
    Some(...) }` so the caller doesn't have to re-walk the tool
    list to learn what was queued.
  - `McpCommand` enum — typed mutation requests. v1 variants:
      SetVariableColor { name, hex }
      SetActiveAxisValue { axis, value }
    Each maps to an existing Document mutator (set_color_hex /
    set_active_theme) so the correctness chain (subset matching,
    no-default-clobber, no-other-axis-shadow, var_table in the
    history snapshot) carries forward verbatim.
  - `ToolResponse::Ok` grew an optional `command: Option<
    McpCommand>` field. Pre-existing read tools fill `None`.
  - `response_to_json` ignores `command` when serialising
    (host-only consumption — wire format unchanged).

`crates/openpencil-shell-core/src/mcp/tools.rs`:
  - `SetVariableColor` tool. Validates `name` exists + is
    Color-kind (snapshot from var_table) + `hex` parses as
    `#rgb` / `#rrggbb` / `#rrggbbaa`. Returns
    `OkWithCommand({wrote: true}, SetVariableColor)`.
  - `set_variable_color_snapshot(doc)` factory — same pattern
    as the read tools, snapshotted at host registration time.
  - `validate_hex` lenient on case, requires leading `#`.

`crates/openpencil-shell-core/src/document/variables.rs`:
  - `VariableTable::apply_mcp_command(&cmd)` — the host applier.
    Branches per command variant, delegates to the underlying
    mutator. Returns true when the table actually changed (caller
    pushes an undo snapshot via the existing history machinery).
  - `SetActiveAxisValue` rejects values not in `theme_axis.values`
    so an LLM can't drift the active map into invalid states.

Tests (5 added, 311 shell-core total):
  - set_variable_color_validates_args_and_returns_command — the
    happy path; OkWithCommand variant + correct McpCommand
    payload.
  - set_variable_color_errors_on_missing_args — both `name`
    AND `hex` are required.
  - set_variable_color_errors_on_unknown_variable — ToolFailed +
    error message names the missing variable.
  - set_variable_color_errors_on_invalid_hex — fuzz across 4 bad
    inputs (no `#`, too short, bogus chars), all → InvalidArgument.
  - apply_mcp_command_routes_set_variable_color_to_var_table —
    end-to-end: build command → apply → resolve_color reads back
    the new value.

MCP surface now: 6 read tools + 1 write tool + architectural
support for arbitrary future writes. `insert_node`,
`update_node_position`, and the rest of the TS pen-mcp write
catalog plug into the same `McpCommand` enum + `apply_mcp_command`
applier.
2026-05-14 20:07:38 +08:00
Kayshen-X b995a7be7c fix(mcp): get_active_theme axes keeps the list_variables escape set
Codex stop-gate (third pass on the comma-escape work): the
previous commit (525acbb6) used `escape_layered_field` for BOTH
output fields of get_active_theme, but `axes` is structurally a
2-level format (`;`-records of `|`-pairs) — exactly like
list_variables. Standard `unescape_record_field` decoders strip
only `\\` / `\;` / `\|`, so any `\,` we emit shows up as a
literal 2-char `\,` sequence in the client's decoded output.

Now:
  `axes`    — `escape_record_field` (3-char set: `\;|`)
              ↳ wire-compatible with list_variables decoders.
  `options` — `escape_layered_field` (4-char set: `\;|,`)
              ↳ the only field that needs comma protection,
                because the inner value list joins with `,`.

The two encodings cohabit cleanly: a client written for
list_variables can decode `axes` without changes; a client that
wants `options` knows to expect the deeper escape set + uses the
layered_split helper.

Test (1 added, 306 shell-core total):
  - `get_active_theme_axes_field_does_not_escape_commas` —
    active theme with axis name + value both containing commas
    (`"axis,with,commas"` = `"value,with,commas"`). Asserts the
    `axes` output substring is exact, AND that no `\,` sequence
    appears anywhere in the field.

Existing tests still pass:
  - `get_active_theme_round_trips_comma_in_value` (options
    field; commas escape correctly via the layered set).
  - `list_variables_does_not_escape_commas_backward_compat`
    (records-and-pairs format unaffected).
2026-05-14 19:59:18 +08:00
Kayshen-X 78296df10d fix(mcp): keep list_variables wire format on legacy 3-char escape
Codex stop-gate caught: the previous fix (85579365) added `,` to
the shared `escape_record_field`, which changed the wire output of
`list_variables` for variables whose VALUE contains a comma. Any
existing client decoding `list_variables` with the original two-
delimiter unescape rules (`\;` + `\|`) would see literal commas
turn into `\,` sequences — backward-incompatible.

Split escape concerns into two helpers:

  escape_record_field    — `\;|` only. Used by list_variables
                            (sticks to the v1 wire contract).
  escape_layered_field   — `\;|,`. Used by get_active_theme's
                            three-level format (`;`-records of
                            `|`-pairs with inner `,`-joined value
                            lists).

unescape_record_field still only unescapes `\\`, `\;`, `\|` — its
contract is the legacy Rust-side counterpart for list_variables
decoders. Each layer of get_active_theme decoding uses the
`layered_split(s, delim)` helper which unescapes only the
current delimiter and passes other escape sequences through to
the next split level (the test fixture already encodes this
contract).

Tests (1 added, 305 shell-core total):
  - `list_variables_does_not_escape_commas_backward_compat` —
    encodes a variable whose value is "red, white, blue" and
    asserts the output substring is preserved verbatim (no
    `\,` sequences). Pre-this-commit the shared escape would
    have output `red\, white\, blue`.

`get_active_theme_round_trips_comma_in_value` still passes —
its values get the layered escape via `escape_layered_field`,
and the layered_split helper decodes correctly.

Backslash-in-theme-value remains a known limitation of the
layered escape: each split only unescapes its own delimiter, so
intermediate `\\` sequences pile up across levels. The current
test scope (commas without backslashes) covers the codex BLOCK
case; the backslash edge case is rare in real theme value lists
and would need a final-pass unescape on the innermost split's
output. Tracked as a TODO in `escape_layered_field`'s doc
comment for when a real workload hits it.
2026-05-14 19:55:04 +08:00
Kayshen-X e56869c13d fix(mcp): get_active_theme round-trips commas in theme values
Codex stop-gate: `get_active_theme.options` joins per-axis value
lists with `,` but theme values can legitimately contain commas
(e.g. "red, white, and blue"). The previous escape set (`\;|`)
left `,` unescaped, so the comma joiner would mis-split a single
value into multiple fake ones on decode.

`escape_record_field` + `unescape_record_field` extended to also
escape `,` (and accept `\,` on decode). The Rust-side helpers
stay backward-compatible because no existing encoder previously
emitted a literal `\,`. `list_variables` is unaffected because
that wire format doesn't use `,` as a delimiter; the escaped form
is harmless there.

New layered-decoder pattern in the test: `get_active_theme.options`
is a TWO-LEVEL split (outer `|`, inner `,`), so the decoder must
unescape only the delimiter for the current level — leaving
`\,` intact during the outer `|` split, then unescaping `\,`
during the inner `,` split. The previous test had an over-eager
walker that unescaped every delimiter at once, which broke the
inner split. Helper `layered_split(s, delim)` makes this contract
explicit:

  let outer = layered_split(&opts, '|');  // unescape only \|
  let inner = layered_split(&outer[1], ',');  // unescape only \,

Tests (1 added, 304 shell-core total):
  - `get_active_theme_round_trips_comma_in_value` builds an axis
    with value `"a,b,c"` plus a plain second value. After encode
    + layered decode, asserts the values vec is exactly
    `["a,b,c", "plain"]`. Pre-fix the inner comma split saw 4
    values; with the layered decoder + comma escape it sees 2.

The wire format is now safe for every char a `.op` theme value
can legally carry.
2026-05-14 19:49:21 +08:00
Kayshen-X 7a4b06f5ad feat(mcp): get_active_theme tool — surfaces theme axes + available options
Sixth first-party MCP tool. LLM clients now learn the document's
complete theme topology (active selection + every defined axis +
its possible values) so they can plan multi-axis edits without
guessing what's allowed. Previously the tool surface only exposed
the resolved value of each variable (`list_variables`); the wider
"which axes can flip + to what" was inaccessible.

`crates/openpencil-shell-core/src/mcp/tools.rs`:
  - `GetActiveTheme { active: Vec<(axis, value)>, options:
    Vec<(axis, Vec<value>)> }`. Two parallel vectors: `active`
    holds the currently-selected axis/value pairs (subset of all
    axes); `options` holds every axis defined in `themes` + its
    full value list. An axis appears in `options` even when not in
    `active` so clients can seed it via the future
    `set_active_axis_value` write tool.
  - Wire shape: `axes` = `axis|value;...` (matches `list_variables`'s
    escape rules), `options` = `axis|v1,v2,v3;...`. `axis_count` is
    reported separately so clients can probe size without parsing
    the full list.
  - `escape_record_field` reused for both axes + options + each
    individual value, so the backslash-escape contract proven by
    `escape_record_field_round_trips_pipe_semicolon_backslash`
    covers this tool transparently.

Re-exported from `mcp.rs` alongside the other read tools.

Tests (3 added, 303 shell-core, 36 mcp module total):
  - `get_active_theme_reports_active_axes_and_options`: two axes
    defined (`mode` with 3 values, `density` with 2); only `mode`
    in active selection. Asserts `axis_count=2`, `axes` carries
    only `mode|dark`, `options` carries both axes' value lists.
  - `get_active_theme_empty_document_is_zero`: empty docs return
    `axis_count=0` + empty `axes` / `options` strings.
  - `get_active_theme_escapes_pipe_and_semicolon_in_values`:
    fuzzes a theme axis whose values include `a|b`, `c;d`,
    `e\\f`. Decoder walks the encoded options + verifies all
    three round-trip via `unescape_record_field`.

MCP read surface now covers the full theme picture:
  - get_document_info  — counts, page count, active page
  - list_pages         — page names + active index
  - get_selection      — selected node id, kind, bounds
  - get_node(id)       — kind, name, bounds, parent, fill_ref,
                         stroke_ref
  - list_variables     — every variable + resolved value
  - get_active_theme   — axis selection + every axis's options
                         (NEW)

Write tools (set_variable_color, set_active_axis_value,
insert_node) remain TODO — they need either Arc<Mutex<Document>>
on the tool or a pending-command queue out of ToolOutcome.
2026-05-14 19:44:05 +08:00
Kayshen-X a036463c93 feat(themes): axis chip click cycles to the next theme value
VariablesPanel chips were previously click-swallowing placeholders.
This commit adds the actual axis-cycle behavior — clicking the
"mode: dark" chip flips it to "mode: light", then "mode: sepia",
wrapping back to "mode: light". The TS app has the equivalent
behavior via its theme dropdown; the Rust shell now offers the
one-click chip flip as the simpler entry point.

`crates/openpencil-shell-core/src/document/variables.rs`:
  - `VariableTable::cycle_active_axis_value(axis) -> bool`:
      1. Returns false (no-op) when `axis` isn't in `themes` or
         its values list is empty.
      2. When the axis isn't in `active_theme`, seeds it with the
         first value.
      3. When current value matches an entry, advances to the
         next (wrapping past the last).
      4. When current value is unrecognized (axis options
         changed since file loaded), falls back to the first.

`crates/openpencil-shell-native/src/widget_host/property_dispatch.rs`:
  - `dispatch_variables_panel_press` `AxisChip(idx)` branch now
    looks up the axis name by position in `active_theme` (BTreeMap
    iteration is stable + matches the chip walk order in
    VariablesPanel::paint), commits any pending property focus,
    captures an undo snapshot, calls `cycle_active_axis_value`,
    and pushes the snapshot onto history when the cycle actually
    moved (return true). Snapshot+restore covers var_table per
    the prior history fix (99d602a3), so undo round-trips the
    theme cycle the same way it does variable color edits.

Tests (5 added, 300 shell-core total):
  - `cycle_active_axis_seeds_first_value_when_absent` — axis in
    `themes` but not in `active_theme` → cycle plants the first
    value.
  - `cycle_active_axis_advances_to_next_value` — three-value
    walk through light → dark → sepia → light (wrap).
  - `cycle_active_axis_returns_false_for_unknown_axis` — no-op +
    false.
  - `cycle_active_axis_returns_false_for_empty_values` — defined
    in themes but with empty values list → no-op + false.
  - `cycle_active_axis_falls_back_to_first_when_current_unknown`
    — graceful degradation when active_theme has a stale value
    (axis options changed since file load).

The variable edit chain now covers BOTH primary surfaces:
  - Row click on a Color-kind variable → ColorPicker → set_color_hex
  - Axis chip click → cycle_active_axis_value
Both push undo entries that restore through the var_table-aware
snapshot.
2026-05-14 19:39:37 +08:00
Kayshen-X 0a762f6a1e fix(host): fill-picker dismiss at the middle of the cascade
The previous commit (7fafa379) moved fill-picker dismiss to the
very top of the press cascade, but that stole layer-context-menu
clicks (codex stop-gate). The commit before THAT (c3394fda) had it
late enough to be stolen by VariablesPanel.

Right answer: middle position (the original 0c0 spot, between
TopBar and VariablesPanel) — after the higher-z overlays (color
picker, layer-context-menu, agent-settings modal) so those keep
their own click handling when both happen to be open, but BEFORE
the rail walkers (VariablesPanel, PropertyPanel) so the picker
can't survive a click into them.

Cascade now reads:
  pre   rename / text-edit blur
  pre   agent-settings modal
  0-c   color picker overlay
  pre   layer context menu
  0aa   commit-on-blur for property inputs
  0z    panel resize gutter
  0ab   shape picker overlay
  0a    locale picker overlay
  0b    TopBar
  0c0   **fill-type picker dismiss**  ← settled position
  0b1   VariablesPanel
  0c    PropertyPanel input + action
  1+    chat, toolbar, canvas, …

The remaining edge case — TopBar / shape-picker / locale-picker
clicks while fill-picker is open consume the click without closing
fill — is pre-existing behavior. The cascade ordering between
those small overlays is a wider design question (last-opened wins
vs. priority list); not in scope for this commit.

press.rs: 839 → 840 (+1). 295 shell-core + 20 shell-native tests
pass.
2026-05-14 19:33:09 +08:00
Kayshen-X 87e8f74f62 fix(host): fill-picker dismiss is actually FIRST in press cascade
Codex stop-gate caught: the previous commit (c3394fda) called the
fill-picker dismiss "first" but it was actually at step 0c0 — after
0-color (color picker), layer-context-menu, 0aa (commit-on-blur),
0z (panel resize), 0ab (shape picker), 0a (locale picker), and 0b
(TopBar). Any of those earlier steps could consume a click and
leave `fill_type_picker_open=true` behind.

Moved the entire fill-picker dismiss block to step 0-fp, which
runs immediately after the rename / text-edit blur lines and the
agent-settings modal dispatch — before any other overlay can
swallow the click:

  pre. rename + text-edit blur (always run)
  pre. agent-settings modal dispatch (only when modal open)
  0-fp. **fill-type picker dismiss** ← now first
  0-color. color picker overlay
  ... (all other overlays)
  0b1. VariablesPanel
  0c.  PropertyPanel
  ...

Behavior: any click while the fill-type picker is open closes it
+ swallows the click (matches previous semantics for the case
where the click was outside the picker's dropdown rows). Clicks
that land on the picker's own dropdown rows still route to
SetFillType / ToggleFillTypePicker.

Concrete fix: a sequence like "open fill picker on Property panel
→ click TopBar locale globe" now closes the fill picker (which the
old order missed because TopBar at 0b consumed the click first).

press.rs: 836 → 839 (+3). The block content is unchanged; only its
position moved, so the file growth is from the new doc-comment.
Still over the 800-line cap as pre-existing tech debt. 295
shell-core + 20 shell-native tests still pass.
2026-05-14 19:27:16 +08:00
Kayshen-X 5d2a691438 fix(host): fill-picker dismiss runs before VariablesPanel dispatch
Codex stop-gate on the previous reorder (96cf753b): putting
`dispatch_variables_panel_press` at the absolute top of the cascade
meant a Variables-row click while the PropertyPanel's fill-type
picker was open returned true and left `fill_type_picker_open=true`.
The fill picker would float over the chrome until the next
unrelated click happened to land outside its rect.

Cascade order now:
  0c0. Fill-type picker outside-click dismiss
       (must run first when open — any click anywhere closes the
        picker, then swallows or routes to a SetFillType /
        ToggleFillTypePicker action).
  0b1. VariablesPanel hit dispatch
       (BEFORE PropertyPanel so the bottom-anchored Variables rect
        wins for clicks in its z-order overlap with the rail).
  0c.  PropertyPanel input + action hit-tests.

The fill-picker block already swallows every click when open, so
reaching the Variables dispatcher requires the picker to be
closed — which means the existing dismiss path runs unconditionally
before any Variables click can fire. 295 shell-core + 20 shell-
native tests still pass.

press.rs: 832 → 836 (the dismiss comment + restored ordering adds
4 lines). Still over the 800 cap as pre-existing tech debt; my
session has added +11 lines total to this file (825 → 836), which
is small and bounded.
2026-05-14 19:22:20 +08:00
Kayshen-X 1714b6dd5b fix(host): test VariablesPanel hits before PropertyPanel (z-order)
Codex stop-gate caught: PropertyPanel was tested first in the press
cascade, so any click in the rail's bottom region went to its hit
walker — which would resolve to e.g. the Export-section row or an
input — even when the click was visually OVER the bottom-anchored
VariablesPanel. Paint stack has VariablesPanel ON TOP of
PropertyPanel (step 4 → step 4b), so hit-test order must follow.

`crates/openpencil-shell-native/src/widget_host/press.rs`:
  - The `dispatch_variables_panel_press` call moved from step 0d
    (after PropertyPanel) to step 0b1 (before fill-type picker +
    PropertyPanel input dispatch). Returns true when the click
    consumes a Variables row / chip; otherwise false, so the
    cascade falls through to the property-panel walkers exactly as
    before for non-rail-bottom clicks.
  - The dispatcher itself short-circuits when `var_table.variables`
    is empty (existing guard), so the reorder is a no-op for
    documents without variables.

Test surface unchanged — 295 shell-core + 20 shell-native tests
still pass. The fix is purely an ordering change; no new logic.
press.rs: 833 → 832 (the relocation trims one blank line). Still
over the 800 cap as pre-existing tech debt; not made materially
worse by this commit.
2026-05-14 19:17:42 +08:00
Kayshen-X c76af49b65 feat(host): VariablesPanel row click opens ColorPicker (variable mode)
Closes the missing host wire that codex called out as pending: the
VariablesPanel widget paints (140d5495 / 0bd98ae2) and the picker
has variable-mode commit (3c2e7711 / 99d602a3), but clicking a row
did nothing. This commit dispatches.

`crates/openpencil-shell-native/src/widget_host/press.rs`:
  - New step 0d in `apply_press` (between PropertyPanel input and
    AI chat dispatch): one-line call to the new helper. Keeps the
    cascade ordering — properties consume their hits first, vars
    next, chat after.

`crates/openpencil-shell-native/src/widget_host/property_dispatch.rs`:
  - `dispatch_variables_panel_press(x, y, vw, vh) -> bool` mirrors
    the existing `dispatch_export_dialog_press` pattern. Reconstructs
    the same right-rail rect math `paint.rs` step 4b uses (top
    when no selection, bottom-anchored above status bar when
    PropertyPanel owns the rail), hit-tests, then routes:
      - `Row(idx)` on a Color-kind variable → commit any pending
        property focus, then `open_color_picker_for_variable(name)`.
        The picker's HSV-drag path writes through `set_color_hex`
        (which carries all three correctness invariants); close
        pushes the snapshot+var_table undo entry from 99d602a3.
      - `Row(idx)` on a non-color variable → swallow (TODO: row
        inputs for string / number). Prevents fall-through to
        canvas deselect.
      - `AxisChip(_)` → swallow (TODO: theme-axis picker).
  - Returns `false` when var_table is empty or the click missed,
    so press.rs's cascade continues to chat / canvas.

Tests: 295 shell-core + 20 shell-native still pass. The dispatcher
is exercised by the existing `VariablesPanel::hit_test` tests +
the `color_picker::tests::*` end-to-end variable flow; a wired
host integration test would need a fake-render harness that's out
of scope here.

press.rs grew from 825 → 830 (+5 lines for the cascade call site).
That file was over the 800-line cap before this session — pre-
existing tech debt tracked separately. Not making it materially
worse.

TOP-10 #5 (Variables/Themes UI) edit chain is now functionally
complete: paint → click → picker → write → undo. The remaining UX
gaps are non-color variable inputs + the theme-axis picker, both
flagged as TODOs in the dispatcher.
2026-05-14 19:10:25 +08:00
Kayshen-X 394ca2f829 fix(history): DocumentSnapshot carries var_table — undo restores variables
Codex stop-gate caught a real correctness gap: the previous commit
(3c2e7711) wired the ColorPicker's variable-mode commit through
`set_color_hex` and pushed `pre_snap` onto undo when the colour
changed. But `DocumentSnapshot` only covered `pages /
active_page_index / selected / selected_set` — `var_table` wasn't
in the snapshot, so `undo()` would restore pages + selection
without touching the variable. Variable edits were effectively
unrecoverable.

`crates/openpencil-shell-core/src/document.rs`:
  - `DocumentSnapshot` grew `var_table: VariableTable`. Captures
    every Variable definition + themed entries + active_theme +
    fill_refs / stroke_refs at snapshot time, so undo can restore
    the complete colour-token graph.

`crates/openpencil-shell-core/src/document/mutators.rs`:
  - `snapshot()` clones `var_table` into the captured snapshot.
  - `restore()` writes the snapshot's var_table back to the
    document. The single literal site for DocumentSnapshot stays
    consistent across all callers (`snapshot_for_history()`,
    `commit_history()`, `undo()`, `redo()` — all flow through
    `snapshot()` + `restore()`).

`crates/openpencil-shell-core/src/document/color_picker.rs`:
  - Dropped the now-redundant `variable_pre_color: Option<Color>`
    parallel field from `ColorPickerState`. It was a workaround
    for snapshot not carrying var_table; now `close_color_picker`
    reads `snap.var_table.resolve_color(name)` directly to detect
    the pre-edit colour. Cleaner single source of truth.
  - The change-detection in `close_color_picker`'s variable branch
    now compares `snap.var_table.resolve_color(name)` against the
    live resolution. Same result as before, fewer parallel fields.

Tests (1 added — the regression repro, 295 shell-core total):
  - `undo_after_variable_edit_restores_pre_edit_color` builds the
    exact codex BLOCK case: variable starts at `#ff8800`, picker
    opens, HSV drags to pure red, picker closes (history push).
    `doc.undo()` runs; the variable must resolve back to the
    original orange. Pre-fix the snapshot's pages + selection
    would restore but the variable would still read red — the
    undo entry was effectively broken.
2026-05-14 19:04:30 +08:00
Kayshen-X 6fd6f63a6f feat(color-picker): variable-mode commit routes through set_color_hex
Closes the missing wire between the new `VariableTable::set_color_hex`
write path (round 3 fix in febe7a76) and the existing ColorPicker
floating UI. The host can now route a VariablesPanel row click into
the picker keyed to a variable; HSV drag flows back through the
variable's storage, paint reflects the change on the next frame.

`crates/openpencil-shell-core/src/document.rs`:
  - `ColorPickerState` grew two parallel fields:
      `variable: Option<String>` — name of the Color variable
                                   being edited; `None` ⇒ node-target
                                   mode (existing Fill/Stroke path).
      `variable_pre_color: Option<Color>` — resolved colour at open
                                   time, used by `close_color_picker`
                                   to detect "did anything change?"
                                   without requiring DocumentSnapshot
                                   to carry var_table.
  - `ColorTarget` stays Copy + Fill/Stroke-only. Adding a
    `Variable(String)` variant would have forced touching every
    pattern-match site; the parallel field keeps the existing 18+
    `ColorTarget` callers untouched.

`crates/openpencil-shell-core/src/document/color_picker.rs`:
  - `Document::open_color_picker_for_variable(name, anchor_y)`:
    new entry point. Seeds HSV from `var_table.resolve_color(name)`,
    captures the resolved colour into `variable_pre_color`. Returns
    `false` when the name is unknown or the variable isn't
    Color-kind — so a host-side dispatcher can `if !doc.open_...
    return;` without inspecting state.
  - `Document::color_picker_set_hsv` branches on `variable.is_some()`:
    routes through `format_color_hex_for_var(rgb)` →
    `var_table.set_color_hex(name, hex)`. The set_color_hex
    correctness saga (3 commits, no shadowing, no cross-axis
    clobber) carries this commit transparently.
  - `Document::close_color_picker` for the variable path compares
    `state.variable_pre_color` vs `var_table.resolve_color(name)` to
    decide whether to push the pre-edit snapshot onto undo.
    Single-bool changed-check matches the node-target branch.
  - New `format_color_hex_for_var(rgba) -> String` helper emits
    `#rrggbb` for the var-write commit path.

Tests (4 added, 294 shell-core total):
  - `open_color_picker_for_variable_seeds_hsv_from_resolved_color`
    — picker opens against `#ff8800`; HSV anchor reads as orange-hue
    (20°–45° range), near-max sat + val.
  - `open_color_picker_for_variable_fails_when_var_missing_or_wrong_kind`
    — unknown name returns false; Number-kind variable returns false;
    picker state stays None in both cases.
  - `color_picker_set_hsv_writes_through_variable_path` — end-to-end:
    open on `#ff8800`, push HSV (0°, 1, 1) = pure red, assert
    `resolve_color` returns red.
  - `close_color_picker_after_variable_edit_pushes_history_only_when_changed`
    — open + close with no HSV change → no history push. Open +
    drag + close → history depth grows by 1.

The full variable edit UX now has a working host integration point:
when VariablesPanel emits `VariablesPanelHit::Row(idx)` for a color
variable, the host calls `open_color_picker_for_variable(name)` and
the existing picker chrome takes over.
2026-05-14 18:59:14 +08:00
Kayshen-X aef0b68579 fix(document): collapse mod-tests decl to keep document.rs under 800
The previous commit (0bd342ad) registered the new variables_tests
sibling with the canonical two-line form:

    #[cfg(test)]
    mod variables_tests;

That pushed document.rs to 801 lines — one over the 800-line cap
codex stop-gate enforces. Collapsed to the inline form:

    #[cfg(test)] mod variables_tests;

This matches the existing inline `#[cfg(test)] mod tests_geometry;`
+ `#[cfg(test)] mod tests_mutators;` declarations a few lines
below in the same file, so the style is consistent.

document.rs: 801 → 800 lines (exactly at cap). 290 shell-core
tests still pass.
2026-05-14 18:51:43 +08:00
Kayshen-X 4d963fd018 refactor(variables): split tests to sibling file (codex cap fix)
Codex stop-gate flagged that `document/variables.rs` was 865 lines
after the three rounds of `set_color_hex` correctness fixes — over
the project's 800-line ceiling. Tests moved verbatim to a sibling,
mirroring the `layer_panel_tests` / `property_panel_tests` pattern.

  crates/openpencil-shell-core/src/document/variables.rs       865 → 318
  crates/openpencil-shell-core/src/document/variables_tests.rs (new) 549

`document.rs` registers the sibling under `#[cfg(test)]` between
the existing `mod variables;` and the rest of the file's mod list.

Tests file gets a flat layout — bare `#[test]` functions at file
scope (no inner `mod tests {}` wrapper), `use super::*` removed in
favor of the explicit `use super::{ThemedValue, Variable, ...}`
imports the body actually needs.

No semantic change. 290 shell-core tests pass.
2026-05-14 18:49:06 +08:00
Kayshen-X e2b258cfd7 fix(variables): end-push the new themed entry (no other-axis shadow)
Third codex stop-gate on `set_color_hex` themed writes. The
previous fix (599fc859) inserted the new entry at index 0 to
guarantee precedence under the active theme, but that shadowed
existing entries on UNRELATED axes:

  Existing:  [{theme: Some({density: compact}), value: "#compact"}]
  Active:    {mode: dark}
  Step 3 (no subset match) inserted at front:
             [{theme: Some({mode: dark}), value: "#new"},
              {theme: Some({density: compact}), value: "#compact"}]

Under future active = {mode: dark, density: compact}: resolve's
first-match subset walk picked entry 0 ({mode: dark} ⊆ active) and
returned "#new" — even though the user's edit was only meant to
apply to mode = dark, not to override the density-specific entry
under the combined axis.

Switched to end-push. End-push is safe because Step 1 (subset
match) already proved no existing entry is a subset of `active`,
so the new entry is the UNIQUE subset match under the active
theme regardless of position. Under OTHER actives, every
pre-existing entry retains its original resolve precedence.

Test (1 added, 290 shell-core total):
  - `set_color_hex_does_not_shadow_existing_entries_on_other_axes`
    builds the exact pathological case: existing
    {density: compact} entry; write under active {mode: dark};
    then flips active to {mode: dark, density: compact} and
    asserts resolve_color still returns the compact value
    (#11ccaa), not the dark-only write (#000000). Pre-fix
    front-insert would have read #000000 — the codex BLOCK.

The three-round set_color_hex saga is now closed:
  Round 1 (042a62c8): write path mirrors resolve's subset
                       matching → no shadow by stale-vec ordering.
  Round 2 (599fc859): non-empty active never clobbers the
                       theme=None default.
  Round 3 (this):     end-push not front-insert → no shadow of
                       other-axis themed entries.
2026-05-14 18:44:04 +08:00
Kayshen-X d951942613 fix(variables): themed writes never clobber the default entry
Second codex stop-gate on `set_color_hex` themed-write routing.
The previous fix (042a62c8) made the write follow resolve's subset
match, but its fallback path still mutated the `theme: None`
default entry when no themed subset matched. That's wrong under a
non-empty active theme: the default is the resolve fallback for
EVERY axis the user hasn't explicitly authored, so mutating it
during a dark-mode edit silently rewrites light + sepia + every
unmapped axis too.

New write rules:

  Subset match:                   → write through that entry.
  No subset match + active EMPTY: → write through default
                                    (creating it if absent).
  No subset match + active SET:   → push a new entry keyed to
                                    active_theme at index 0, so
                                    resolve's first-match walk
                                    picks the new entry under the
                                    active axes. The default and
                                    other-axis entries stay
                                    untouched.

Inserting at the front (not pushing at the end) matters because
resolve uses subset matching: a pre-existing entry whose theme is
a superset of the new one's axes would otherwise win the resolve
walk for active = (its theme + extra axes). Front-insert pins the
new entry's precedence under the active theme it was created
for.

Tests rewired (2 swapped — 288 → 289 shell-core total):

  - `set_color_hex_under_active_theme_does_not_clobber_default`
    replaces the previous "writes the default" test. Setup:
    light entry + None default + active=dark; write under dark
    must scope to dark only. Asserts three theme states:
      active=dark   → reads NEW value
      active=sepia  → reads ORIGINAL default (untouched)
      active=light  → reads ORIGINAL light entry (untouched)
    Pre-fix this test would have failed on the sepia assertion
    because the default would carry the new dark value.

  - `set_color_hex_empty_active_theme_targets_default_entry`
    confirms the inverse: with active_theme empty, the write
    still routes through the default (resolve's fallback in that
    state). This is the explicit "edit the universal value" path.

Existing themed-axis test passes unchanged because that case
already had a subset match and never reached the fallback path.
2026-05-14 18:39:29 +08:00
Kayshen-X baad7e12fb fix(variables): set_color_hex mirrors resolve's subset matching
Codex stop-gate caught a real correctness bug in the previous
commit (62653659): `set_color_hex` used EQUALITY to match themed
entries against `active_theme`, but `Variable::resolve` uses
SUBSET matching. The mismatch let writes report success without
actually changing the resolved value:

  Entry:  {theme: Some({"mode": "dark"}), value: "#111"}
  Active: {"mode": "dark", "density": "compact"}

  Before: equality check fails (entry's theme has fewer keys), so
  the write pushes a new entry at the end:
    {theme: Some({"mode": "dark", "density": "compact"}), value: NEW}
  resolve() walks first-to-last, picks the ORIGINAL entry (mode=
  dark is a subset of active), returns "#111" — the new entry is
  shadowed and never read.

Fix: `set_color_hex` now mirrors `resolve` exactly. Three-step
lookup matching the resolve walk order:

  1. First themed entry whose `theme` map is a subset of
     active_theme → write there.
  2. Else, the `theme: None` default entry → write there.
  3. Else, push a fresh entry keyed to active_theme (or None when
     active is empty).

The write is now guaranteed to flip the resolved value when it
returns true.

Tests (2 added — direct repros of the codex BLOCK, 287 shell-core
total):

  - `set_color_hex_changes_resolved_value_under_subset_active_theme`
    builds the exact pathological case (entry mode-only, active
    has mode+density), writes, then asserts `resolve_color`
    returns the NEW value. Pre-fix this test would have read the
    untouched "#111" and failed.

  - `set_color_hex_falls_back_to_default_entry_when_no_subset_match`
    covers the second branch — themed entry mismatch + `theme:
    None` default + active that matches neither. The write must
    update the default (which is what resolve falls back to) and
    not append a never-read dark entry.
2026-05-14 18:34:36 +08:00
Kayshen-X ff3c79e81b feat(variables): VariableTable::set_color_hex — actual write path
Closes the "view-only" gap the VariablesPanel widget left: the model
now exposes a real edit primitive for color variables. The
ColorPicker's commit-on-release path (and the future MCP write
tool) can route through this method to push a hex string into a
color variable; paint sees the new colour on the next frame because
`resolve_color` reads the same data.

`crates/openpencil-shell-core/src/document/variables.rs`:

  - `find_mut(name)` parallels the existing `find(name)`. Returns
    `Option<&mut Variable>` so editor surfaces can stage in-progress
    writes without re-walking the variables vec.

  - `set_color_hex(name, hex) -> bool`:
      1. Validates `hex` via `parse_hex_color` BEFORE touching
         the store, so a malformed input never corrupts state.
      2. Returns false (and skips mutation) for unknown name,
         wrong `kind` (must be Color), or unparseable hex.
      3. For `Scalar` variables: overwrites the single value.
      4. For `Themed` variables: finds the entry whose `theme`
         matches the current `active_theme` (or the default
         `theme: None` entry when active is empty); writes through
         on match. When no exact entry exists, pushes a new
         `ThemedValue` keyed to the current active map — so
         editing a light/dark theme adds the right entry without
         silently stomping the other axis value.

Tests (5 added, 286 shell-core total):

  - `set_color_hex_writes_scalar_variable` — basic happy path.
  - `set_color_hex_rejects_malformed_input` — pre-mutation
    validation; original scalar untouched on reject.
  - `set_color_hex_returns_false_for_unknown_variable` — silent
    no-op when the name doesn't exist.
  - `set_color_hex_returns_false_for_wrong_kind` — Number-kind
    variable can't be written via the color helper.
  - `set_color_hex_writes_themed_entry_matching_active_axis` —
    two themed entries (light/dark); active=dark; write only
    updates the dark entry; flipping to light keeps the
    pre-existing light value intact. This pins the "no
    cross-axis stomp" contract.

The edit-UX side of TOP-10 #5 now has the bedrock write path; the
ColorPicker dispatcher + VariablesPanel row-click routing is the
next layer.
2026-05-14 18:30:46 +08:00
Kayshen-X 8b3bdcdfd5 feat(mcp): get_node surfaces fill_ref + stroke_ref from var_table
`NodeRecord` grew two fields so LLM clients can tell when a node's
colour follows a variable instead of a literal. Without this, the
client can only see the resolved hex — it doesn't know whether to
bump the variable (theme-wide ripple) or write a per-node override.

`crates/openpencil-shell-core/src/mcp/tools.rs`:

  - `NodeRecord { fill_ref: String, stroke_ref: String }`. Empty
    string when the node doesn't use a variable for that paint
    channel. Empty (not omitted) so clients can probe with a
    single `out.get("fill_ref") == Some(&"")` instead of having
    to handle the missing-key case as well.
  - `get_node_snapshot` + `walk_node` thread the document's
    `var_table` through the tree walk and look up
    `var_table.fill_refs[node.id]` + `var_table.stroke_refs[
    node.id]` per node. The lookup is O(log n) BTreeMap, so the
    full-document walk stays O(n log n) — fine for typical
    documents (≤ thousands of nodes); LLM call frequency is far
    below paint frequency anyway.
  - The `get_node` `call()` payload adds the two new keys
    alongside the existing kind / name / bounds / parent_id.

Test (1 added, 281 shell-core total, 33 mcp module total):
  - `get_node_surfaces_fill_and_stroke_refs` — installs
    `fill_refs[11] = "color-primary"` +
    `stroke_refs[11] = "color-accent"` on the sample doc, calls
    `get_node` with node_id=11, asserts the payload carries the
    variable names. A second call with node_id=12 (no ref
    mapping) asserts both fields are empty strings (not absent),
    pinning the API contract.

The MCP read-side now covers the full graph that paint-time `$ref`
substitution uses: list_variables → choose a variable;
list_pages → choose a page; get_document_info → counts;
get_selection → current focus; get_node(id) → kind, bounds,
parent, and which paint channels follow which variable.
2026-05-14 18:27:46 +08:00
Kayshen-X 7f73278c6c fix(mcp): escape \ / ; / | in list_variables wire encoding
Codex stop-gate flagged that the previous commit (a54464ce) made an
unfounded claim about the canonical `.op` schema rejecting `;` and
`|` in variable names + values. There is no such validator —
string-kind variables can legitimately carry payloads like
`"a|b;c\\d"`, and my flat `name|kind|value;...` encoding would
mangle the field boundaries.

`crates/openpencil-shell-core/src/mcp/tools.rs`:

  - `escape_record_field(s)` — `\` → `\\`, `;` → `\;`, `|` → `\|`.
    Applied to every field before joining. Standard
    backslash-escape pattern that any client can invert with a
    one-pass byte walk.

  - `unescape_record_field(s)` — exposed as `pub` so Rust-side MCP
    clients have a canonical decoder reference. Unrecognized
    escape sequences pass through verbatim (`\X` for X ∉ {`\`,
    `;`, `|`} stays as `\X`) so future format extensions don't
    silently corrupt today's data.

  - Doc comment correctly states the wire format + the escape
    rules instead of the previous wrong "schema forbids these
    chars" claim.

Tests (2 added, 5 mcp::tools total, 280 shell-core total):

  - `escape_record_field_round_trips_pipe_semicolon_backslash`
    fuzzes the round-trip across 10 inputs including every
    delimiter alone, mixed payloads (`a|b;c\\d`), empty string,
    and pure-delimiter strings (`;;|||`).

  - `list_variables_encodes_string_value_with_special_chars`
    drives the full end-to-end path: a string variable whose
    value is `a|b;c\\d` is encoded by `list_variables`, then
    decoded by a hand-written walker matching the doc-comment's
    "promote `\X` → `X`" rule. Asserts the split yields exactly
    3 fields + the third field matches the original payload byte
    for byte.

The wire format is now unambiguous for any variable data the
canonical loader will accept.
2026-05-14 18:21:53 +08:00
Kayshen-X e9fc471855 feat(mcp): list_variables tool — surface design tokens to LLM clients
Fifth first-party MCP tool, completing the read-side discovery
quartet alongside the new VariablesPanel widget. LLM clients chain
`get_document_info` → `list_pages` → `list_variables` to learn the
document's design-token vocabulary before issuing node mutations
that reference variables via `$ref:name`.

`crates/openpencil-shell-core/src/mcp/tools.rs`:
  - `ListVariables { variables: Vec<VariableRecord> }` snapshot
    struct.
  - `VariableRecord { name, kind, value }` — `kind` is one of
    `color` / `number` / `boolean` / `string`; `value` is the
    scalar resolved under the active theme (empty string when the
    variable doesn't resolve, e.g. themed entries with no
    matching axis).
  - `list_variables_snapshot(doc)` builds the snapshot once; the
    host re-registers on var-table mutations matching the existing
    pattern.
  - Wire format: a flat `variables` field encodes the list as
    `name|kind|value` triplets joined by `;`. Avoids nested JSON
    so shell-core stays serde-free; clients split on the two
    delimiters. Schema-valid variable names + values can't contain
    `;` or `|` (the canonical `.op` loader rejects those chars).
    `count` is reported separately for clients that just want a
    quick "does this document have variables?" check.

Re-exported from `mcp.rs` as `list_variables_snapshot` +
`ListVariables` + `VariableRecord` so callers stay on
`mcp::list_variables_snapshot(doc)` like the existing
`mcp::document_info_snapshot(doc)`.

Tests (3 added, 30 total mcp module, 278 total shell-core):
  - list_variables_reports_count_and_records — 3-variable
    fixture (color / number / boolean), asserts kind labels +
    resolved value strings (`16` for f64 16.0 without trailing
    zeros; `true` for bool).
  - list_variables_encodes_for_wire — verifies the `name|kind|
    value` format + `;` separator + the `count` field is
    independently accessible.
  - list_variables_empty_document_returns_zero_count — edge
    case: empty var_table → count=0, variables="".

MCP tool surface now: 5 first-party reads (get_document_info,
get_selection, list_pages, get_node, list_variables). Write tools
(insert_node, batch_design, design_skeleton) need a mutable
document handle through the registry, which is a separate design
beat — deferred until the use case requires it.
2026-05-14 18:16:17 +08:00
Kayshen-X 77eca84d23 chore(format): strip trailing blank lines at EOF (codex diff-check fix)
The previous split commit (764eb9f4) left three files with an
extra blank line after the closing `}` because the sed/awk
extraction pipeline appended one. `git diff --check` flagged:

  crates/openpencil-shell-core/src/mcp/parser.rs:344
  crates/openpencil-shell-core/src/mcp/tools.rs:278
  crates/openpencil-shell-core/src/widgets/property_panel.rs:585

All three normalized to end with exactly one newline after the
final `}`. No semantic change.
2026-05-14 18:14:19 +08:00
Kayshen-X da8b1ac3ee refactor(shell-core): split mcp.rs + property_panel.rs over the 800-line cap
Codex stop-gate flagged that a recent edit pushed a file past the
repo's 800-line ceiling. Two files were over from this session:

  mcp.rs              1255 lines (from get_node + list_pages +
                                  get_selection + params parser)
  property_panel.rs    870 lines (from the export hit-test test)

Both split along clean boundaries, both now under cap.

`mcp.rs` (1255 → 663 lines):
  - New `src/mcp/parser.rs` (344 lines) — `parse_tool_call` + every
    `extract_*` helper + `parse_flat_object_body` + the local
    `extract_field`. Handles the real MCP `tools/call` envelope
    AND the legacy flat shape.
  - New `src/mcp/tools.rs` (278 lines) — `GetDocumentInfo`,
    `GetSelection`, `ListPages`, `GetNode` + their `_snapshot`
    factories + `NodeRecord` + the private `count_subtree` /
    `walk_node` walkers. Every first-party tool lands here from now
    on.
  - `mcp.rs` retains: types (`RequestId` / `ToolCall` /
    `ToolResponse` / `ToolErrorCode` / `ToolOutcome` / `McpTool`
    trait), `ToolRegistry`, `run_stdio`, `response_to_json` + its
    private helpers (`id_to_json` / `error_code_to_int` /
    `btree_to_json` / `json_escape`), and every existing test.
  - Public surface re-exported from `mcp.rs` so callers continue
    to write `mcp::parse_tool_call` / `mcp::GetDocumentInfo`:
        pub use parser::parse_tool_call;
        pub use tools::{ document_info_snapshot, get_node_snapshot,
            list_pages_snapshot, selection_snapshot, GetDocumentInfo,
            GetNode, GetSelection, ListPages, NodeRecord };

`property_panel.rs` (870 → 585 lines):
  - New `src/widgets/property_panel_tests.rs` (289 lines) — all 11
    integration tests moved verbatim. Mirrors the
    `layer_panel_tests.rs` sibling pattern: bare `#[test]` functions
    at file scope (no `mod tests {}` wrapper) + the module
    registered as `#[cfg(test)] mod property_panel_tests;` in
    widgets/mod.rs.
  - `SectionCapabilities::for_kind` bumped from private to
    `pub(crate)` so the sibling test can construct
    `VisibleSections` for the export-section hit-test.

Tests: 275 shell-core (unchanged) + 54 desktop + 20 shell-native.
No behavior change — both splits are mechanical moves.

OP-owned files under cap now: ✓ shell-core (top: 663 mcp.rs),
✓ shell-native (top: 825 press.rs — pre-existing tech debt, not
this session's fault), ✓ desktop (top: 735 main.rs).
2026-05-14 18:08:56 +08:00
Kayshen-X 21cb3ca1a9 fix(geometry): canvas_region shrinks for VariablesPanel too (codex BLOCK)
Codex stop-gate flagged: with no selection, the right-rail
`VariablesPanel` was painted (commit 0bd98ae2 wired the paint
step) but `canvas_region` still returned the full viewport width
because its `has_property` gate only checked `property_panel_
visible()` — which is false without a selection. The canvas
paint pass (Step 5) then extended over the right rail and
overpainted the Variables panel, defeating the visibility fix
the previous commit was supposed to deliver.

`crates/openpencil-shell-core/src/document/mutators.rs`:
  - New `Document::right_rail_visible() -> bool` is the unified
    gate: true when either `property_panel_visible()` is true or
    `var_table.variables` is non-empty. Future right-rail widgets
    (Components, Themes header, etc.) extend this method instead
    of patching every caller.

`crates/openpencil-shell-native/src/widget_host/geometry.rs`:
  - `canvas_region` swapped from `property_panel_visible()` to
    `right_rail_visible()`. The shrunk-canvas branch no longer
    requires a selection — any rail-occupying widget reserves the
    column.

Test (1 added, 275 shell-core total):
  - `right_rail_visible_tracks_property_panel_and_variables`
    covers the four state combinations:
      empty doc                       → rail hidden
      var-table-only (no selection)   → rail shown (codex repro)
      selection-only (no vars)        → rail shown (legacy gate)
      cleared selection + cleared vars→ rail hidden again

20 shell-native + 54 desktop + 275 shell-core tests pass.
2026-05-14 17:57:55 +08:00
Kayshen-X 048d53d71d fix(native/paint): wire VariablesPanel into the right-rail paint pass
Codex stop-gate flagged that the previous commit (140d5495) shipped
`VariablesPanel` as a widget definition + tests but never wired it
into the host's paint composition, so users couldn't actually see
it. This commit closes that gap.

`crates/openpencil-shell-native/src/widget_host/paint.rs`:
  - Step 4 (PropertyPanel) now also computes the right-rail x +
    width as locals so the new VariablesPanel paint step reuses
    them instead of duplicating the geometry math.
  - New Step 4b: when `Document.var_table.variables` is non-empty,
    paint a `VariablesPanel::for_document(...)` rectangle anchored
    to the right rail. Layout decision:
      - No selection: panel pinned at the top of the rail
        (TOP_BAR_HEIGHT + 8 px) so it's the primary chrome there.
      - Active selection: PropertyPanel owns the rail; Variables
        anchors to the bottom (above the status bar). Approximate
        because PropertyPanel paints to fill the rail today;
        proper stacking lands when the rail grows scrollable
        regions or tabs.
    Variables stays hidden when `var_table` is empty — no visual
    noise for documents that don't use them.
  - `VariablesPanel` imported via `widgets::variables_panel::
    VariablesPanel` (the module is `pub mod` exported in
    widgets/mod.rs).

`crates/openpencil-shell-native/src/widget_host/input.rs`:
  - Cleaned up the unused imports left over from the input/keyboard
    split (5caa2eb3): `PropertyFocus`, `AIChatHit`, `AIChatPlaceholder`,
    `LayerPanel`, `LayoutCx`, `Toolbar`, `Widget`, `TOOLBAR_WIDTH`,
    `TOP_BAR_HEIGHT` were all moved to keyboard.rs but still
    listed in input.rs's import list. Down to the actually-used
    `ChatAnchor` + helpers + Point2D + Rect.

Tests: 274 shell-core + 54 desktop + 20 shell-native all pass.
Visual verification: opening a `.op` file whose `variables` array
is non-empty now shows the panel chrome (header "Variables" label,
active-theme chips, one row per variable with name + resolved
color swatch / scalar label). Edit interactions still pending —
`VariablesPanelHit::Row(idx)` is wired up but the host doesn't
dispatch it yet.
2026-05-14 17:53:16 +08:00
Kayshen-X 29c7930997 feat(widgets): VariablesPanel — paints variables + active theme axes
First UI surface for the Variables/Themes feature (TOP-10 #5). The
`Document.var_table` machinery (VariableTable + theme resolution +
fill/stroke refs) has been in place since the canonical loader
landed; the missing piece was a panel widget that exposes it.

`crates/openpencil-shell-core/src/widgets/variables_panel.rs`:

  - `VariablesPanel<'a>` borrows `&'a VariableTable` (or constructs
    via `for_document(&Document)`). Cheap — no document mutation,
    paint reads through the borrow.
  - Header section: "Variables" label + a chip row showing each
    active theme axis (`mode: dark`, `density: compact`, etc).
    Chip width approximated from label length (real text-measure
    integration lands when the panel gets hosted — the hit-test
    budget is generous enough that off-by-one is fine for v1).
  - Variable rows: one per variable in the table. Name on the
    left at 12 pt foreground; preview on the right.
    `VariableKind::Color` renders a small swatch resolved through
    the active theme. Number / Boolean / String render the
    resolved scalar as a short label, truncated to 12 chars with
    `…` for overflow. Unresolved variables show "—".
  - `hit_test(rect, point)` returns `VariablesPanelHit::Row(idx)`
    or `VariablesPanelHit::AxisChip(idx)`. Indices are into the
    underlying `variables` / chip lists so callers can map back
    to the source. Returns None when the point falls outside the
    panel rect or in a non-clickable region (header label,
    inter-row gaps).
  - `intrinsic_height` lets the right-rail host size the panel
    based on row count + chip-row presence.
  - Widget trait impl: `id` + `access_node` (accesskit Group role,
    label "Variables") + `layout` + `paint`.

`VARIABLES_PANEL_WIDTH = 240.0` exported so hosts can use a stable
constant instead of magic numbers.

Tests (7 added, 274 total shell-core):
  - row_count_matches_variable_count
  - axis_count_reflects_active_theme
  - intrinsic_height_grows_with_rows_and_chips (empty → header only)
  - hit_test_returns_row_index_for_in_row_click (middle of row 1)
  - hit_test_returns_axis_chip_for_chip_click (left edge of chip 0)
  - hit_test_returns_none_outside_rect (negative x + below rect)
  - axis_chip_table_mirrors_active_theme_btree_order (BTreeMap
    iteration order is stable + deterministic — chips paint
    alphabetically by axis name)

Editing (add / rename / delete variable; toggle axis value) is
deferred — needs a separate `VariablesPanelAction` enum + host
dispatcher. The view-only surface lets the property panel start
showing `$ref` swatches in a follow-up without blocking on edit UI.
2026-05-14 17:46:09 +08:00
Kayshen-X 6481c3fee9 refactor(desktop): extract rotate-cursor builder into cursor_icon.rs
`main.rs` had grown to 804 lines, 4 over the 800-line ceiling
documented in CLAUDE.md. `make_rotate_cursor_rgba` is the longest
self-contained helper in the file (~70 lines) and depends only on
`skia_safe`, so it's a clean cut:

- New file: `crates/openpencil-desktop/src/cursor_icon.rs` (74
  lines). Function visibility lifted to `pub(crate)` so main can
  call it via `cursor_icon::make_rotate_cursor_rgba()`.
- `main.rs` lost lines 62-131, drops to 735 lines (well under
  cap).
- `mod cursor_icon;` slotted between `chat_subprocess` and `export`
  in the alphabetical mod list.
- The single call site (line 250) qualified with the module path.

No behavior change — function moved verbatim with all comments +
the two-pass halo/core stroke logic intact.

Closes Task #55 follow-up cap violation. 54 desktop tests still
pass. 250+ shell-core tests pass. shell-native 20 tests pass.

Combined with the prior input.rs split (5caa2eb3), every OP source
file is now under the 800-line cap.
2026-05-14 17:38:16 +08:00
Kayshen-X d5885045c1 refactor(native): split widget_host/input.rs to honor 800-line cap (Task #49)
`widget_host/input.rs` was 882 lines, over the project's documented
800-line ceiling. Split the `impl WidgetHostNative` block into two
files along a natural boundary:

  input.rs    (417 lines — pointer / wheel / pan / cursor-move /
               release + drag-commit helpers)
  keyboard.rs (481 lines — text / backspace / delete / duplicate /
               nudge / send / escape / click routing)

Both `impl WidgetHostNative` blocks share the same type, and Rust
allows multiple impl blocks for one type in the same crate so the
public method surface is preserved verbatim. No behavior changes —
every method moved verbatim with its full body + doc-comment.

Module wiring:
  crates/openpencil-shell-native/src/widget_host.rs:
    `mod keyboard;` registered between `input_tests` and `paint`
    in the alphabetical mod list.

Imports adjusted: keyboard.rs picks up only the symbols its
methods reference (PropertyFocus, AIChatHit / AIChatPlaceholder /
LayerPanel / LayoutCx / Toolbar / Widget + TOOLBAR_WIDTH +
TOP_BAR_HEIGHT, Point2D + Rect, TOOLBAR_INSET_X/Y from helpers).
input.rs drops the now-unused imports of those symbols.

Tests: 54 desktop + 250+ shell-core + 20 shell-native all pass.
File-cap check:
  input.rs     417 (was 882, well under 800)
  keyboard.rs  481 (new, well under 800)

main.rs still pending — Task #55 follow-up (804 lines today, 4 over).
2026-05-14 17:36:24 +08:00
Kayshen-X c138dca80d fix(mcp): parse real MCP tools/call envelope (nested name + arguments)
Codex stop-gate flagged the previous commit (9bb77b3f) still
didn't handle the real MCP wire shape. The actual JSON-RPC envelope
real clients (Claude Code, Codex, Gemini, etc.) send is:

    {
      "jsonrpc": "2.0",
      "id": 1,
      "method": "tools/call",
      "params": {
        "name": "get_node",
        "arguments": { "node_id": "42" }
      }
    }

`method` is `"tools/call"` (a constant); the tool's actual name +
arguments are nested under `params.name` + `params.arguments`. The
parser was extracting `method` as the tool name and reading args
from the top-level `params`, which means every real MCP request
would route to a non-existent tool called `"tools/call"`.

`parse_tool_call` now branches:

- **method == "tools/call"** → real MCP path. Extract `params`
  body, read `name` string + `arguments` object from inside.
- **method != "tools/call"** → legacy/direct path (tests + tools/
  list-style introspection). Method is the tool name; top-level
  params is the args.

Three small helpers added:

- `extract_params_body(line)` returns the `"params":{...}` body
  string without surrounding braces (so the MCP path can re-walk
  it for nested fields). Brace-depth aware with quote/escape state
  so embedded strings don't fool the matcher.
- `extract_string_field(body, key)` pulls `name` out of the params
  body.
- `extract_object_body(body, key)` pulls the `arguments` nested
  object's body for `parse_flat_object_body` to walk.

All helpers are pure-stdlib + serde-free (shell-core stays
wasm32-clean).

Tests (4 added → 27 total mcp; 267 total shell-core):

- parse_tool_call_real_mcp_tools_call_shape — repro of the codex
  BLOCK (`{"method":"tools/call","params":{"name":"get_node",
  "arguments":{"node_id":"42"}}}` → tool=get_node, node_id=42)
- parse_tool_call_mcp_shape_with_no_arguments — empty
  `arguments:{}` still parses, args map empty
- parse_tool_call_mcp_shape_with_numeric_arg — number + bool args
  survive as literal text (`5`, `true`)
- get_node_reachable_through_real_mcp_envelope — end-to-end
  regression: parse → dispatch → Ok kind=frame, id preservation
  through ToolResponse::Ok

Legacy/flat-shape tests still pass — both paths are first-class.
2026-05-14 17:31:10 +08:00
Kayshen-X 34861e6996 fix(mcp): parse params object so get_node is reachable through stdio
Codex stop-gate review caught a real BLOCK: the previous commit
(4a527430) shipped a `get_node` tool that requires a `node_id`
argument, but `parse_tool_call` had an explicit stub:

    arguments: BTreeMap::new(),  // "Empty arguments map — real
                                 //  implementation parses params"

So calling `get_node` via the stdio MCP server (the only wire path
shell-core ships) always errored with MissingArgument. The tool
was reachable only by direct registry-dispatch, which no real LLM
client uses.

`parse_tool_call` now extracts the JSON-RPC `"params"` object into
the `arguments` map. Two helpers carry the hand-rolled JSON walk:

  - `extract_params_object(line)` finds `"params":{...}` by
    string search + tracks `{`/`}` depth with quote/escape state
    so embedded strings don't fool the matcher.

  - `parse_flat_object_body(body)` walks key-value pairs:
      - String values (`"node_id":"42"`) — quotes stripped, body
        stored verbatim.
      - Numbers / bools / null (`"page":1`, `"on":true`) — stored
        as their literal text. Tools call `.parse::<T>()` on the
        result so `1` is interchangeable with `"1"`.
      - Nested objects + arrays are skipped (no tool today takes
        non-scalar args; surfacing them needs serde).
    Whitespace + commas between pairs are tolerated.

Shell-core stays serde-free; the parser stays ~80 lines so the
wasm32 cost is negligible.

Tests (5 added, 23 mcp tests total, 263 shell-core total):
  - parse_tool_call_extracts_string_params (the codex BLOCK
    repro: `{"params":{"node_id":"42"}}` → arguments has node_id)
  - parse_tool_call_extracts_numeric_and_bool_params (`1`, `true`
    survive as text)
  - parse_tool_call_handles_missing_params (no params object →
    empty args, parse succeeds)
  - parse_tool_call_skips_nested_object_values (siblings remain
    accessible; nested object intentionally omitted from map)
  - get_node_reachable_through_stdio_path (regression test for
    the original BLOCK: wire-format string → parse_tool_call →
    registry.dispatch → ToolResponse::Ok kind=frame)
2026-05-14 17:26:00 +08:00
Kayshen-X 117c6df53b feat(mcp): three first-party tools — get_selection / list_pages / get_node
Grows the MCP server's tool surface from the placeholder
`get_document_info` (which only reported page count + total nodes)
to a useful triplet that LLM clients can chain for the "look-then-
modify" pattern the TS `pen-mcp` was designed around.

`crates/openpencil-shell-core/src/mcp.rs`:

  - `GetSelection` + `selection_snapshot(doc)`:
      Returns `selected_id` (raw u64), `kind` (frame / group / rect
      / ellipse / polygon / line / text / path / other / none /
      missing), `x` / `y` / `width` / `height` from the selected
      node's `aggregate_bounds`. `kind="none"` when nothing is
      selected; `kind="missing"` when the selected NodeId doesn't
      resolve (post-deletion race etc.).

  - `ListPages` + `list_pages_snapshot(doc)`:
      Returns `page_count`, `active_page_index`, and a
      comma-separated `names` list. Lets the LLM pick a target
      page before calling `insert_node` / `batch_design`.

  - `GetNode` + `get_node_snapshot(doc)`:
      Takes a `node_id` argument (decimal u64 string), returns
      `kind` / `name` / `x` / `y` / `width` / `height` /
      `parent_id`. O(1) lookup against a map built at snapshot
      time by walking every page recursively. Argument errors are
      typed: `MissingArgument` when node_id absent,
      `InvalidArgument` when not a u64, `ToolFailed` when the id
      isn't in the document.

The snapshot-at-registration pattern matches the existing
`GetDocumentInfo`. The MCP server is expected to re-snapshot +
re-register on document mutations; this keeps the `McpTool` trait
trivially `Send + Sync` without dragging in `Arc<RwLock<Document>>`
or other interior-mutability machinery.

Tests (7 added, all pass):
  - get_selection_reports_no_selection_when_none
  - get_selection_reports_selected_node_bounds_and_kind (against
    the sample Frame at NodeId 10)
  - list_pages_reports_count_and_names
  - get_node_returns_record_for_known_id
  - get_node_errors_on_unknown_id (ToolFailed)
  - get_node_errors_on_missing_arg (MissingArgument)
  - get_node_errors_on_non_numeric_arg (InvalidArgument)

shell-core test count: 258 (was 251). MCP module test count: 18
(was 11).

Next: `insert_node` + `batch_design` write tools — those need the
host to plumb a mutable Document handle through, which is bigger
scope than the read-only triplet here.
2026-05-14 17:21:36 +08:00
Kayshen-X 3adaad0ae6 feat(panels): PropertyPanel Export section click opens ExportDialog (Task #52)
Closes the "P3. PropertyPanel Export section: real buttons" task that
was left preview-only by the export chain (P1 raster export + P2
ExportDialog modal). The Export section paints two pill dropdowns
(scale + format) but no clicks were wired — opening the dialog
required Cmd+Shift+P or the File menu.

`property_panel_layout.rs`:
  - `VisibleSections` grew `effects: bool` + `export: bool`, both
    required for the action-button walker to compute the y-position
    of the Export section. `ALL` const + every construction site
    updated.
  - `action_button_rects_with_fill_picker` now continues its y-walk
    past Fill through Stroke + Effects sections (consuming the
    paint-side heights so the rect math stays aligned), then emits
    one `OpenExportDialog` rect spanning the full Export row.
    Clicking anywhere on the row resolves to OpenExportDialog —
    splitting scale + format into separate hit zones isn't worth
    it because the ExportDialog modal owns both pickers.
  - Fill-section walker now explicitly consumes the head row +
    body + divider gap heights it had been implicit about. Without
    this any section after Fill (Stroke / Effects / Export) sat at
    the wrong y in the hit-test space.

`property_panel.rs`:
  - The three `VisibleSections` construction sites (action hit-test,
    input hit-test, fill-picker overlay paint) now thread
    `caps.effects` + `caps.export`. Mechanical sed-based patch since
    the three sites are identical.

The downstream wiring was already in place — `widget_host/
property_dispatch.rs` handles `A::OpenExportDialog` by queueing
`FileAction::ExportImage` so the desktop binary's save-dialog +
real raster export kicks in. P3 was the missing piece between user
intent (click the section) and the existing pipeline.

Tests (1 added):
  - `hit_test_action_export_section_returns_open_dialog` — selects
    the sample Frame (NodeId 10), walks `action_button_rects_with_
    fill_picker` to find the OpenExportDialog rect, clicks its
    center, asserts hit_test_action returns OpenExportDialog. This
    is the rect-walker contract that paint + hit-test stay in sync
    even when sections after Fill are visible.

Total shell-core tests: 251 (was 250).
2026-05-14 17:15:58 +08:00
Kayshen-X c3d0bb30cd fix(desktop/chat): downgrade HttpServer for_cli — wire protocol not verified
Codex stop-gate review flagged that `HttpServerProvider::for_cli(Codex
| OpenCode)` claimed to work but was wired to a fabricated protocol:

- OpenAI's `codex` CLI does not ship a `serve` subcommand, so the
  `for_cli(Codex)` path was guaranteed to fail at spawn.
- sst/opencode does ship `opencode serve`, but its real
  request/response JSON shape was not validated against my
  `{ "message": ... }` + newline-delimited `text/thinking/tool_use/
  done/error` defaults — those were placeholders, not specs.

Original commit (0e27998e) called this out in the commit body but
the public API still pretended `for_cli` returned a working bridge
for either CLI, which is exactly the kind of false-confidence
codex objected to.

Fix:
- `for_cli(CliName)` now returns `None` for every variant, with a
  doc comment explaining that the wire protocols haven't been
  wired up. Callers who've verified a server's real protocol use
  `with_binary(...)` and supply explicit binary + serve args +
  chat path themselves.
- Module-level docs gained an explicit "honest scope note" listing
  what is and isn't verified: the lifecycle plumbing (spawn,
  stderr drain, listen-line discovery via `parse_listening_line`,
  streaming response → parse_line dispatch, receiver-drop kill)
  is structurally correct and reusable; the per-CLI protocol
  defaults are placeholders.
- Tests updated: `for_cli_only_http_server_kinds` →
  `for_cli_returns_none_pending_real_protocol_wiring` (asserts
  every CliName now returns None); the trait-object + label tests
  swapped to construct via `with_binary` with an arbitrary
  binary, since that's the supported construction path today.

No code path was deleted — when a real `codex serve` or
`opencode serve` spec lands the work is "add a thin factory that
constructs an HttpServerProvider with the verified args + plug in
the matching response parser." The framework is ready; the
configuration is not.

Tests: 6 chat_http_server tests pass (was 7 — the redundant
codex/opencode label test was merged into the trait-object test).
Desktop total: 54 pass (was 55).
2026-05-14 17:06:07 +08:00
Kayshen-X 8e0309a676 feat(desktop/chat): HttpServerProvider for Codex + OpenCode serve mode
Closes the fourth chat-backend category from the project_agent_runtime
memory. Implements user direction "opencode 和 codex 我们调用 http
server, 通过 ipc 启动本地的 server 模式" — spawn the CLI as a local
HTTP server then POST chat requests to its bound port.

`crates/openpencil-desktop/src/chat_http_server.rs`:
  - `HttpServerProvider::for_cli(CliName::Codex | OpenCode)` builds a
    bridge that spawns `<bin> serve` and connects to the local
    endpoint. Other CliName variants return `None`.
  - Lifecycle:
      1. Spawn child with stdin=null, stdout+stderr piped.
      2. Drain stderr to /dev/null on a sibling task so the server
         can't deadlock on a full pipe.
      3. Block on stdout lines until one announces the bound port.
         Default 10-second timeout (cold first-run can be slow). The
         child is killed on timeout so we never leak a half-started
         server.
      4. Continue draining stdout for the remainder of the server's
         life so its operational logs don't back-pressure.
      5. POST `{ "message": <prompt> }` to `127.0.0.1:<port><path>`
         using reqwest. Non-2xx response → Error + Done { Aborted }.
      6. Stream the response bytes; parse each newline-delimited
         line through `chat_subprocess::parse_line` (the generic
         text / thinking / tool_use / done / error envelope).
      7. On any structured `done`, terminate; on receiver-drop,
         start_kill the child.
  - `parse_listening_line` handles three message formats observed
    in the wild: `Listening on http://host:PORT` (Codex),
    `Server listening on port NNNN` (OpenCode docs), bare
    `listening NNNN` (some local-server frameworks).
    `extract_port` prefers `:NNNN` after the last colon (HTTP URL
    form) and falls back to the last digit run, so IP octets in
    `127.0.0.1:8765` don't get picked up as the port (caught by a
    failing test on first iteration).
  - `chat_path` is a per-CLI template (defaults to `/v1/chat`); the
    settings modal can swap it when wiring up a new server whose
    URL differs.

Limitations to flag for future iterations:
  - The server is killed after each `send`. Real multi-turn would
    want a long-lived server, which means lifting the spawn into a
    Client-like singleton (analogous to chat_copilot's
    ClientSession). Today's per-send spawn pays the cold-start
    twice per turn but keeps the bridge stateless.
  - The request body shape (`{ "message": ... }`) is a guess. Each
    server's actual API needs to be plumbed when we have real
    Codex / OpenCode server specs to compare against.

`crates/openpencil-desktop/Cargo.toml`:
  - Adds `reqwest = { version = "0.12", default-features = false,
    features = ["rustls-tls", "json", "stream"] }` as a direct dep.
    reqwest was already in the graph via anthropic-agent-sdk so the
    cost is zero new transitive deps; declaring it directly makes
    the chat_http_server module's intent explicit.

`crates/openpencil-desktop/src/chat_subprocess.rs`:
  - `parse_line` lifted to `pub(crate)` so chat_http_server can
    share the same wire-protocol parser. Centralizing the envelope
    shape keeps the four bridges consistent — when one CLI's
    protocol evolves, every bridge gets it.

`crates/openpencil-desktop/src/main.rs`:
  - `mod chat_http_server;` slotted into the alphabetical mod list.

Tests (7 added, all pass):
  - parse_listening_line_codex_format: `Listening on
    http://127.0.0.1:8765` → 8765 (asserts the IP-octet rejection
    that caught the first iteration's bug)
  - parse_listening_line_opencode_format
  - parse_listening_line_bare_number
  - parse_listening_line_returns_none_when_absent
  - for_cli_only_http_server_kinds: Codex + OpenCode → Some, others
    → None
  - provider_constructs_as_chat_provider_trait_object: type-check
  - provider_labels_match_cli_names: "Codex" / "OpenCode"

All four chat backends now wired in code:
  ✓ BuiltIn         (agent-rs)               — chat_runtime.rs
  ✓ ClaudeCode      (anthropic-agent-sdk)    — chat_claude.rs
  ✓ Copilot         (copilot-sdk)            — chat_copilot.rs
  ✓ Subprocess      (Gemini generic + custom)— chat_subprocess.rs
  ✓ HttpServer      (Codex + OpenCode)       — chat_http_server.rs

Acp (third-party ndJSON) remains TODO — that's the fifth backend
in the architecture memo, the open extension point for CLIs we
don't ship a dedicated adapter for.

Total openpencil-desktop tests: 55 (was 48 before this commit).
2026-05-14 16:57:14 +08:00
Kayshen-X 671ae36179 feat(desktop/chat): Copilot CLI adapter via copilot-sdk
Second per-CLI ChatProvider adapter. Wires
`copilot_sdk::Client + Session` (the in-workspace fork of
copilot-community-sdk/copilot-sdk-rust) into the OP chat panel.

`crates/openpencil-desktop/src/chat_copilot.rs`:
  - `CopilotProvider` holds a lazy `Arc<Mutex<Option<ClientSession>>>`
    that boots a single `Client` + `Session` on the first `send`.
    Subsequent sends reuse the same session, so multi-turn history
    is the SDK's responsibility (it's what `Session` was designed
    for — much cleaner than reconstructing per-turn).
  - `ensure_session` is the lazy-init: builds `Client::builder()`,
    calls `client.start().await`, creates a default `SessionConfig`.
    Mutex-guarded so concurrent `send` calls race onto the init
    path safely.
  - `send()` subscribes to the session's broadcast event channel
    BEFORE calling `session.send(prompt)` so we don't miss the
    first delta. Then loops `events.recv()` and dispatches each
    `SessionEventData` variant via `dispatch_event`:
      - `AssistantMessageDelta { delta_content }` → `TextDelta`
      - `AssistantReasoningDelta { delta_content }` → `Thinking`
      - `ToolExecutionStart { tool_name, arguments }` → `ToolUse`
      - `AssistantTurnEnd` or `SessionIdle` → `Done { EndTurn }`
      - `SessionError { message }` → `Error + Done { Aborted }`
      - `Abort` → `Done { Aborted }`
      - All other variants (turn-start / intent / usage /
        hook-start/end / tool-progress / tool-complete / custom-
        agent lifecycle / system messages) silently swallowed
        today — the SDK's full event surface is ~35 variants, and
        the chat widget can grow into them later without breaking
        this adapter.
  - `AssistantMessage` (the final batched message) is intentionally
    NOT mapped because it duplicates content already streamed via
    `AssistantMessageDelta`. Future work could dedup heuristically
    when deltas were dropped.
  - Receiver-drop short-circuit on every iteration so closing the
    chat panel tears the bridge down without waiting for the next
    event.

`crates/openpencil-desktop/src/main.rs`:
  - `mod chat_copilot;` slotted after `chat_claude` (alphabetical).

Tests (2 added):
  - provider_label_is_human_readable: returns "GitHub Copilot"
  - provider_constructs_as_chat_provider_trait_object: compile-time
    check that `CopilotProvider` satisfies `Send + Sync` bounds for
    `Arc<dyn ChatProvider>` storage.

Live end-to-end testing requires `gh copilot` installed and logged
in (the SDK does the lifecycle handshake on `client.start()`).
The 2 tests verify wiring + type contracts; live verification is
deferred until the settings modal exposes a "Test connection"
button per provider.

Total openpencil-desktop tests: 48 (was 46).
Three of four chat backends now wired:
  ✓ BuiltIn (agent-rs) — chat_runtime.rs
  ✓ ClaudeCode  (anthropic-agent-sdk) — chat_claude.rs
  ✓ Copilot     (copilot-sdk) — chat_copilot.rs
  - Generic Subprocess (Gemini)         — chat_subprocess.rs
  - HttpServer (Codex / OpenCode)       — TODO
  - Acp (third-party)                   — TODO

Next: HttpServer bridge for `codex serve` + `opencode serve` per
the user's "opencode 和 codex 我们调用 http server, 通过 ipc 启动本地的
server 模式".
2026-05-14 16:54:33 +08:00
Kayshen-X 5dac22a76e feat(desktop/chat): Claude Code adapter via anthropic-agent-sdk
First of the per-CLI ChatProvider adapters that replace the
hand-rolled stream-JSON parser in chat_subprocess.rs. This one wires
`anthropic_agent_sdk::query` (the in-workspace fork of
bartolli/anthropic-agent-sdk) into the OP chat-panel plumbing.

`crates/openpencil-desktop/src/chat_claude.rs`:
  - `ClaudeCodeProvider` impls `ChatProvider`. Constructs trivially
    via `new()` (SDK defaults) or `with_options(ClaudeAgentOptions)`
    when the settings modal has user overrides (system prompt, model
    pick, allowed-tools list, MCP servers, sandbox config — all 30+
    SDK option fields).
  - `send()` spawns the shared tokio runtime task, calls
    `anthropic_agent_sdk::query(prompt, options)`, drains its async
    `Stream<Item = Result<Message>>`, and dispatches each Message
    through `handle_message`:
      - `Message::Assistant.content` Vec<ContentBlock> is unpacked
        per block: `Text { text }` → `ChatDelta::TextDelta`,
        `Thinking { thinking, .. }` → `Thinking`, `ToolUse { name,
        input, .. }` → `ToolUse { name, args = input.to_string() }`,
        `ToolResult` swallowed (already part of conversation history
        the CLI tracks).
      - `Message::Result { subtype, is_error, .. }` is the turn
        terminator. `is_error` → `StopReason::Aborted`; otherwise
        `map_result_subtype` maps "success" → EndTurn,
        "error_max_turns" → MaxTokens, error variants → Aborted,
        unknown → EndTurn.
      - `System` / `User` / `StreamEvent` swallowed (init / context /
        partial-stream payloads the chat widget doesn't surface yet).
  - Receiver-drop short-circuit: every iteration checks
    `tx.is_closed()` so chat-panel teardown stops the SDK stream
    promptly without waiting for the CLI to flush more output.
  - Always emits a terminal `Done` — `Result` message → mapped stop
    reason; stream EOF without a Result → `EndTurn` fallback.

`crates/openpencil-desktop/Cargo.toml`:
  - Adds `anthropic-agent-sdk = { path = "../anthropic-agent-sdk" }`
    + `copilot-sdk = { path = "../copilot-sdk" }`. Copilot dep
    declared now even though `chat_copilot.rs` lands in a follow-up,
    so Cargo.lock resolves the whole graph in one pass.

`crates/openpencil-desktop/src/main.rs`:
  - `mod chat_claude;` between `mod chat_runtime` and
    `mod chat_subprocess` so the alphabetical mod-list rule holds.

Tests (3 added, all pass):
  - `map_result_subtype_table` covers the success / error_max_turns /
    error_during_execution / error / unknown table.
  - `provider_label_is_human_readable` asserts the chat widget gets
    "Claude Code" as the displayed label.
  - `provider_constructs_as_chat_provider_trait_object` is the
    compile-time type-check that `ClaudeCodeProvider` satisfies the
    `Send + Sync` bounds so it can live behind `Arc<dyn ChatProvider>`
    in the widget host.

End-to-end smoke testing requires an actual `claude` binary on PATH.
The 3 tests here verify the wiring + type contracts but not the live
CLI interaction; that lands when the settings modal exposes the
"connect" button + we have a real session to drive.

46 openpencil-desktop tests pass (was 43 before this commit).
Next: chat_copilot.rs over `copilot_sdk::Client + Session`, then
chat_http_server.rs for Codex / OpenCode `serve` mode per the user's
"opencode 和 codex 我们调用 http server, 通过 ipc 启动本地的 server 模式".
2026-05-14 16:52:17 +08:00
Kayshen-X f3f57081da chore(crates): move vendored SDKs into workspace as forkable crates
Per user direction "可以不放在 vendor 里面,我们移动到自己的工程,
后面就和他们分叉" — promote the two community SDKs from vendor/ to
crates/ so they become first-class OP workspace members we own and
evolve, instead of read-only vendored snapshots.

Moves:
  vendor/anthropic-agent-sdk/  →  crates/anthropic-agent-sdk/
  vendor/copilot-sdk-rust/     →  crates/copilot-sdk/

Workspace integration:
  - Root `Cargo.toml` exclude list drops both vendor entries; the
    existing `members = ["crates/*"]` glob auto-includes them.
  - `crates/copilot-sdk/Cargo.toml`: stripped all `[[example]]`
    blocks (22 of them) — the examples/ dir was already removed
    during the import, and leaving the entries broke
    `cargo test --workspace --no-run`.
  - `crates/anthropic-agent-sdk/Cargo.toml`: already had its
    `[[example]]` blocks pruned in the previous commit.

Lockfile pins (workspace `Cargo.lock`):
  Pulling reqwest 0.12.28 (via anthropic-agent-sdk) into the
  unified workspace dep graph re-resolved several `icu_*` crates to
  the 2.2 line, which requires rustc 1.86. OP's toolchain is 1.85
  (locked to stay compatible with the skia-safe-op fork). Pinned:
    icu_collections      2.2.0 → 2.1.1
    icu_locale_core      2.2.0 → 2.1.1
    icu_normalizer       2.2.0 → 2.1.1
    icu_normalizer_data  2.2.0 → 2.1.1
    icu_properties       2.2.0 → 2.1.2
    icu_properties_data  2.2.0 → 2.1.2
    icu_provider         2.2.0 → 2.1.1
    idna_adapter         1.2.2 → 1.2.1
  All eight pins are the latest versions on each crate's 2.1.x /
  1.2.x line that compile on rustc 1.85.

Verification:
  - `cargo check -p anthropic-agent-sdk` ✓
  - `cargo check -p copilot-sdk` ✓
  - `cargo test --workspace --no-run` ✓
  - `cargo test -p openpencil-shell-core --lib` → 250 pass
  - `cargo test -p openpencil-desktop chat_` → 16 pass

Next: replace the hand-rolled subprocess parser in chat_subprocess.rs
with thin per-CLI adapters that route Claude Code through
`anthropic_agent_sdk::SubprocessTransport` and Copilot through
`copilot_sdk::Client + Session`. Gemini stays on the generic stdin
bridge until an upstream Rust SDK exists. Codex + OpenCode get an
HttpServerProvider that spawns `<bin> serve` then connects via a
local HTTP client.
2026-05-14 16:49:17 +08:00
Kayshen-X 5e86b6e731 feat(desktop/chat): wire PromptMode + binary lookup + env scrub through send path
Finishes the WIP that landed unfinished in fe47c901. The `PromptMode`
+ `find_binary` + `scrubbed_child_env` helpers all now flow through
`SubprocessProvider::send`:

- **PromptMode::PositionalArg** (Claude Code today, since `--print`
  mode expects `-- <prompt>` argv and an empty stdin): the
  user_message is appended to argv after `--`; stdin is closed
  immediately without writing.
- **PromptMode::Stdin** (Gemini / Copilot / generic): argv passed
  verbatim; user_message written to stdin then closed.
- Per-CLI templates in `for_cli` now select the right mode +
  resolve the binary via `find_binary` (PATH probe + Windows
  PATHEXT fallback + per-OS npm / yarn / bun / volta / homebrew
  install locations from bartolli/anthropic-agent-sdk's
  `find_cli`). Claude Code defaults bumped to
  `--print --verbose --output-format stream-json` (the `--verbose`
  flag was missing — Claude Code's stream-json output omits
  granular event detail without it).
- `scrubbed_child_env()` builds the child env from the parent env
  minus LD_PRELOAD / DYLD_INSERT_LIBRARIES / NODE_OPTIONS /
  PYTHONPATH / PERL5LIB / RUBYLIB / LD_LIBRARY_PATH /
  DYLD_LIBRARY_PATH so the spawned CLI can't be hijacked by
  interposition vars left in the parent shell. `env_clear()` first
  so tokio Command's default-inherit is overridden.
- Test assertions on `.binary` loosened to `.ends_with("claude")` /
  `.ends_with("gemini")` etc. — `find_binary` returns an absolute
  path when one of the well-known locations matches, so the bare
  name check was failing on hosts that actually have these CLIs
  installed. Behavior contract is "basename matches", not "exact
  string equals", which the new assertions encode.

Cross-platform invariants preserved from fe47c901: Windows cmd /c
wrapping for PATHEXT, CREATE_NO_WINDOW for GUI builds, Unix
process_group(0) for signal isolation, `exit_status_label` for
signal-killed children on Unix.

Tests: 16 chat tests pass on macOS host with actual `claude` /
`gemini` binaries installed (the new code paths exercise PATH +
fallback resolution).
2026-05-14 16:38:41 +08:00
Kayshen-X 8448a82860 fix(desktop/chat): preserve BuiltIn history + cross-platform IPC hardening
Two fixes in one commit — codex re-review BLOCK + the user's request
to consider mac / Windows / Linux when doing IPC.

1. BuiltInProvider keeps the QueryEngine — multi-turn history works.

   The previous commit (25aaacb8) rebuilt the engine per `send` so
   per-request `system_prompt` + `max_output_tokens` would take
   effect. Codex flagged this as a regression: each new engine
   carries a fresh `MessageStore`, so the LLM saw only the current
   user message and the chat had no memory across turns.

   Reverted to a single `Arc<QueryEngine>` shared across every send.
   System prompt + max-output-tokens are now constructor-only (the
   TS app behaves the same way — they're settings, not per-message
   inputs). Per-turn override is left as a future agent-rs API job.

2. Cross-platform IPC for SubprocessProvider — mac / Linux / Windows.

   Address "ipc 的时候要考虑下 mac,windows,linux":

   - **Windows binary lookup**: Win32 CreateProcessW does **not**
     honor PATHEXT, so `Command::new("claude")` only spawns when an
     exact `claude` (no extension) is on PATH. Real-world: npm /
     bun / Volta / scoop install Node CLIs as `claude.cmd` /
     `claude.bat` / `claude.ps1` shims. Without PATHEXT expansion
     the spawn just fails with ENOENT.

     New `build_command(binary, args)` helper. On Windows, routes
     bare command names through `cmd /c <binary>` so PATHEXT kicks
     in. Absolute paths + `.exe` skip the wrapper. Hardcoded
     `for_cli` names are injection-safe; `with_binary` callers own
     their input.

   - **Windows console suppression**: `CREATE_NO_WINDOW`
     (`0x0800_0000`) on the creation_flags so spawning the CLI from
     a double-click-launched GUI build doesn't pop a black console
     window.

   - **Unix signal isolation**: child placed in its own process
     group (`process_group(0)`). Ctrl-C in the terminal that
     launched the GUI now only kills the GUI, not the mid-stream
     CLI. The bridge's existing kill-on-receiver-drop covers
     cleanup; we never depended on signal propagation.

   - **Cross-platform exit status**: new `exit_status_label`
     helper. On Windows `.code()` is always populated; on Unix
     `.code()` is `None` when the child was killed by signal —
     surface `"signal N"` in the chat error instead of the previous
     opaque `"?"`.

   The spawn-failure test was loosened to "at least one Error
   delta + a terminal Done" because Windows routes through cmd /c,
   which spawns successfully — the inner CLI then exits non-zero,
   so the error surfaces from the exit-status path instead of the
   spawn-error path. Both paths are tested implicitly across
   targets; the test now passes on all three.

Tests: 16 chat tests still pass on macOS host. Windows + Linux
behavior is structural — the `#[cfg]` arms are exercised at compile
time on each target.
2026-05-14 16:24:05 +08:00
Kayshen-X b39d69eb70 fix(desktop/chat): address codex review BLOCKs + CONCERNs on chat backend trio
Codex review of 3d754fdc / 85d93e7c / 1b168a84 flagged six BLOCKs +
five CONCERNs + one NIT. This commit closes them.

BLOCKs (all six fixed):

1. stderr pipe not drained → CLI deadlocks on full pipe. Now spawned
   sibling task drains stderr to /dev/null for the lifetime of the
   child.
2. Receiver-drop only detected on next stdout line → idle CLI keeps
   running forever. Switched both BuiltIn + Subprocess channels from
   `std::sync::mpsc` to `tokio::sync::mpsc` so `tx.closed()` is a
   future we can race against `lines.next_line()` in a `select!`.
   Sync iterator wrapper `BlockingRecvIter` in chat_runtime.rs uses
   `Receiver::blocking_recv`.
3. `Done` event marked the flag but didn't break the read loop. Now
   breaks immediately on any structured `done` so stdout staying
   open past turn-end doesn't hang the iterator.
4. EOF path always emitted `Done { EndTurn }`, ignoring child exit
   status. Now reaps the child and surfaces non-zero exit as
   `Error("CLI exited with status N") + Done { Aborted }`.
5. stdout read error fell through to `Done { EndTurn }`. Now emits
   `Error + Done { Aborted }` so I/O failures aren't reported as
   normal completion.
6. Malformed structured events silently produced empty deltas /
   empty errors / nameless tool calls. `parse_line` now requires the
   shape's mandatory fields and emits `Error("malformed X event:
   ...")` when they're missing. Done's `stop_reason` stays optional
   (already had a sensible `EndTurn` fallback).

CONCERNs (4 of 5 fixed):

1. `ChatRequest.system_prompt` + `max_output_tokens` ignored by
   BuiltInProvider. Now honored per-turn: each `send` builds a
   turn-local `QueryEngine` cloning the provider Arc (cheap) +
   applying request's system/max-tokens with constructor defaults
   as fallback. `BuiltInProvider` struct now holds the pieces
   instead of a pre-built engine.
2. BuiltIn `Error` path emitted no terminal `Done`. Now every
   error-terminal branch sends `Done { Aborted }` so consumers can
   distinguish "stream errored" from "channel silently closed."
3. Subprocess stdin write errors silently ignored. Now surfaces as
   `Error("stdin write: ...") + Done { Aborted }` with child kill +
   wait.
5. `SubprocessProvider::for_cli(Codex | OpenCode)` accepted the
   wrong backend silently. Signature now returns `Option<Self>`;
   HttpServer-category CLIs return `None`. Direct stdio bridging to
   a `codex` binary still possible via `with_binary`.

CONCERN 4 (Subprocess silently drops `system_prompt` +
`max_output_tokens`) intentionally deferred — those fields have no
universal CLI mapping; the planned settings-modal flow lets users
encode them in argv directly via `with_binary`. Documented in the
module header as the contract.

NIT 1 (chat_provider.rs doc said "three" but listed four backends)
fixed.

Tests: 14 → 16 native chat tests + 250 shell-core tests pass. Two
new tests cover for_cli's `None` return for HttpServer kinds + the
malformed structured-event paths.
2026-05-14 16:16:24 +08:00
Kayshen-X b788c088ca feat(desktop/chat): SubprocessProvider — CLI bridge for Claude/Gemini/Copilot
Second of the four backends from the user's correction
("可以通过 ipc 调用本地的 cli"). Pairs with the BuiltInProvider from
`85d93e7c`.

`crates/openpencil-desktop/src/chat_subprocess.rs`:
  - `SubprocessProvider::for_cli(CliName)` builds a bridge for
    ClaudeCode / Gemini / Copilot — each seeds the binary name from
    `CliName::default_binary()` and a best-effort default argv
    matching the CLI's stream-JSON / quiet / suggest mode. The argv
    set will become user-tunable in the settings modal; today's
    defaults are good enough for the common path.
  - `with_binary(path, args, label)` for non-PATH installs (settings
    modal will let users override claude → ~/bin/claude-beta).
  - `ChatProvider::send` spawns the CLI with stdin / stdout /
    stderr piped, feeds `request.user_message` + EOF, then reads
    stdout line-by-line on the shared tokio runtime.
  - `parse_line` recognizes 5 structured shapes
    (`text` / `thinking` / `tool_use` / `done` / `error`) and
    degrades gracefully — non-JSON lines + unknown JSON types surface
    as raw `TextDelta` carrying the line + "\n" so CLIs like
    `gh copilot suggest` that just stream plain stdout still show up
    in the chat panel.
  - Receiver-drop kills the child (`start_kill` then `wait`) so
    navigating away mid-stream doesn't leak a hung process.
  - Always emits a terminal `Done` (either from the CLI's `done`
    event or, on stdout EOF without one, `EndTurn`).

`chat_runtime::shared_runtime` lifted to `pub(crate)` so the new
module reuses the process-wide tokio runtime instead of spinning up
its own.

Cargo:
  - `tokio` features grow `io-util` + `process` (for `BufReader` +
    `Command`).

Tests (9 added — all pass):
  - 5 cover the line parser (text / thinking / tool_use / done /
    error)
  - 3 cover the fall-through paths (plain text, malformed JSON,
    unknown type)
  - 2 cover the `for_cli` defaults table
  - 1 end-to-end test against a bogus binary path verifies the
    spawn-error → `Error` + terminal `Done` contract

Together with `chat_runtime.rs` we now have 14 native chat tests
passing. HttpServer (Codex / OpenCode `serve`) + Acp (third-party
ndJSON-over-stdio) bridges land next.
2026-05-14 16:08:31 +08:00
Kayshen-X 48e31007c0 feat(desktop/chat): real BuiltInProvider wrapping agent-rs QueryEngine
The shell-core trait + `EchoProvider` from `3d754fdc` was the
abstraction. This wires up the first real backend so the AI chat
panel can drive a non-stubbed LLM turn from the native binary.

`crates/openpencil-desktop/src/chat_runtime.rs`:
  - `BuiltInProvider` wraps `agent::QueryEngine` (the cross-product
    Rust agent runtime at /Users/kayshen/Workspace/ZSeven-W/agent-rs).
  - Process-wide tokio runtime singleton (multi-thread, `op-chat`
    threads) initialized lazily on first send so cold chrome startup
    doesn't pay for the spawn.
  - Async → sync bridge: `ChatProvider::send` returns
    `Iterator<Item = ChatDelta>`; the impl spawns a tokio task that
    pumps agent-rs `Event`s into a `std::sync::mpsc::channel`, then
    returns the receiver iterator. Closes on `Result` / `Error` /
    receiver drop. Maps `TextDelta` / `Thinking` / `ToolUse` /
    `Result` / `Error` straight to the corresponding `ChatDelta`
    variants; `ToolResult` / `Usage` / `Notice` / `Unknown` swallow
    silently (widget doesn't render them yet — they land in a Phase 2
    transcript view).
  - `map_stop_reason` table covers agent-rs's stop-reason strings
    (`end_turn` / `stop_sequence` / `max_tokens` / `tool_use` /
    `aborted` / `user_abort`); unknown values fall through to
    `EndTurn` (safe default — turn over).
  - `from_provider` is the constructor — takes any
    `Arc<dyn Provider>` so tests + future settings-modal wiring (per-
    provider credential modals) can drive in their own backend impls.

Cargo:
  - `agent = { path = "../../../agent-rs/crates/agent",
    default-features = false }` — no default features today because
    the `anthropic` feature drags in reqwest's TLS stack (rustls /
    icu_collections@2.2 / idna_adapter@1.2) which needs rustc 1.86
    while this workspace pins 1.85. The BuiltIn trait + engine wiring
    ship now; concrete Anthropic / OpenAI-compat / Ollama Provider
    impls flip on once rust-toolchain bumps.
  - `tokio` (rt-multi-thread + macros + sync) + `futures` for the
    async bridge; `async-trait` for the test double's `Provider`
    impl. All three are target-gated to native (cfg desktop OS) per
    the workspace WASM-boundary policy in `Cargo.toml`.

Tests (3 added — all pass):
  - `builtin_provider_streams_text_deltas_through_iterator` — drives
    a scripted `Provider` test double through the engine, asserts
    `ChatDelta::TextDelta("Hello")` arrives first and the run ends
    with `Done { stop_reason: EndTurn }`.
  - `builtin_provider_surfaces_event_error` — `Event::Error` from the
    provider lands as a `ChatDelta::Error` carrying both code +
    message.
  - `map_stop_reason_table` — exhaustive table of every variant +
    unknown fallthrough.

Next: Subprocess / HttpServer / Acp bridges per the 4-backend taxonomy
in `project_agent_runtime` memory — each lives in its own module so
the 800-line cap stays honored.
2026-05-14 16:06:32 +08:00
Kayshen-X 354ffb31b2 fix(shell-core/chat): replace direct-HTTP provider model with 4-backend architecture
User correction: the previous `chat_provider.rs` (commits `548c5336`
+ `2c2b7f60`) assumed a direct-HTTP-per-provider model with
Anthropic / OpenAI-compat / Gemini / etc. each carrying its own
endpoint + model defaults. That's not the architecture decision —
per the project_agent_runtime memory, OP runs FOUR distinct
backend categories:

  - BuiltIn          → `agent-rs` QueryEngine in-process (the
                       cross-product Rust agent crate; lives at
                       /Users/kayshen/Workspace/ZSeven-W/agent-rs)
  - Subprocess(Cli)  → spawn `claude` / `gemini` / `gh-copilot` +
                       talk line-delimited JSON over stdio
  - HttpServer(Cli)  → spawn `codex serve` / `opencode serve` +
                       hit local HTTP endpoint with reqwest
  - Acp              → Agent Client Protocol (ndJSON over stdio),
                       the open extension point for third-party
                       agents OP doesn't ship a dedicated adapter for

API rewrite in `chat_provider.rs`:
  - `CliName::{ ClaudeCode, Gemini, Copilot, Codex, OpenCode }`
    enumerates the 5 first-party CLI backends. Each carries
    `label()` (human display), `default_binary()` (PATH lookup),
    and `backend()` (which Subprocess/HttpServer transport it
    uses — table matches the memo verbatim).
  - `ChatProviderKind::{ BuiltIn | Subprocess(CliName) |
    HttpServer(CliName) | Acp }` replaces the previous flat
    Anthropic/OpenAI-compat/etc enum.
  - `ChatProviderConfig::new(kind)` pre-fills `binary` from
    `default_binary()` for Subprocess/HttpServer kinds; BuiltIn
    and Acp leave it empty.
  - `ChatProvider` trait + `EchoProvider` test double unchanged.
    The streaming `ChatDelta` shape stays close to agent-rs's
    `stream::Event` (TextDelta / Thinking / ToolUse / Done /
    Error + StopReason variants).
  - Real transport implementations live in the future
    `pen-agent-cli` desktop crate per the memo — shell-core stays
    wasm32-clean (no tokio / reqwest / process-spawn).

Tests (4 new + 1 carried):
  - cli_name_backend_table_matches_architecture_memo (verbatim
    map from the memo's table)
  - cli_default_binary_uses_expected_names
  - provider_config_new_seeds_binary_for_cli_kinds (BuiltIn / Acp
    leave it empty)
  - cli_label_is_human_readable
  - echo_provider_replays_script (carries the test double's
    behavior forward)

Tests total: 250 shell-core. Wasm32 build clean.
2026-05-14 15:59:11 +08:00
Kayshen-X b483dd8fa0 feat(shell-core/figma): FigmaClipboardNode::to_node — convert clipboard entries to Document nodes
Closes the last data-shape gap before the per-kind specialised
mappers (`figma-fill-mapper`, `figma-stroke-mapper`, `figma-text-
mapper`, `figma-vector-decoder`, etc) start porting. Given a
parsed clipboard entry, mint a Document `Node` with a fresh id,
the right `NodeKind`, and the right human-readable name.

`FigmaClipboardNode::to_node(&self, next_id) -> Node`:
  - Mints id from caller-supplied allocator, bumps next_id.
  - `NodeKind` via the existing `to_node_kind()` mapper.
  - Name: `self.name` if present, else `self.kind.to_lowercase()`
    so the layer panel always has something to render (a Figma
    layer named just "" still gets "rectangle" / "ellipse").
  - Geometry / fill / stroke stay at defaults — those are the per-
    kind specialised mappers' job.

Tests (2 new):
  - Two sequential calls mint 100 + 101 from a 100 seed; final
    next_id = 102. First node's name copies through; both have
    `NodeKind::Rect` from the `RECTANGLE` kind string.
  - Empty name → kind-derived fallback (`"ellipse"`).

#9 Figma now ~60% — file recognition + clipboard JSON walker +
per-child kind+name extraction + NodeKind mapping + Node
construction. Remaining: per-kind geometry/fill/stroke/text
specialised mappers (the 14 `pen-figma` converter files), Zstd
decompression for binary `.fig`, schema-encoded body parsing.

Tests total: 250 shell-core (+2). Wasm32 build clean.
2026-05-14 15:54:53 +08:00
Kayshen-X afcb2e9938 feat(shell-core/chat): ChatProviderKind enum + per-kind defaults table
Adds the data shapes the agent-settings Agents tab + the chat
panel's provider picker need. Six backend kinds covering every
provider the TS app exposes:

  - Anthropic        (Claude direct API)
  - OpenAiCompat     (OpenAI / Anthropic-via-proxy / Ollama / local)
  - Gemini           (Google generative AI)
  - Copilot          (GitHub Copilot)
  - OpenCode         (the dedicated Codex-style provider)
  - Ollama           (local server convenience default)

`ChatProviderConfig { kind, api_key, endpoint, model }` carries
the per-provider settings the chat panel persists.
`default_endpoint(kind)` + `default_model(kind)` pre-fill the
endpoint + model inputs from a TS-mirrored table (Anthropic →
claude-sonnet-4-6; Ollama → llama3.2; etc).

Tests (2 new):
  - default_endpoint + default_model match the TS table for a
    sampled subset (Anthropic + Ollama)
  - `label()` returns human-readable strings (`"Claude"`,
    `"GitHub Copilot"`) for the picker dropdown

#6 AI chat now ~35% — types + trait + chat-widget integration +
provider config + per-kind defaults. Real HTTP transport for each
kind (reqwest + per-API serialisation) is the remaining multi-week
core work; the data shapes the transport plugs behind are all in
place.

Tests total: 248 shell-core (+2). Wasm32 build clean.
2026-05-14 15:52:54 +08:00
Kayshen-X afadde7231 feat(shell-core/mcp): GetDocumentInfo — first first-party tool
`get_document_info` reports page count + active page index + total
node count over the registry. Smallest of the ~20 tools TS pen-mcp
exposes; serves as the wire-format smoke test for real LLM clients
and demonstrates the registration shape future tools follow.

  - `GetDocumentInfo { page_count, active_page_index, total_nodes }`
    is a snapshot struct — pre-computed at registration so each
    `dispatch` is O(1). Mutates as documents change requires
    re-registering (the server binary will do this on every doc
    edit; v1 ships a frozen snapshot).
  - `document_info_snapshot(&Document)` walks every page +
    recursively counts subtree nodes. Container nodes count as
    themselves + their descendants (matches TS's
    `flattenNodes(...).length`).

Tests (1):
  - Build a 3-node doc (Frame + 2 children), snapshot, register,
    dispatch, verify each field round-trips through the JSON-RPC
    response.

#7 MCP now ~60% — types + registry + wire format + stdio listener +
first registered tool. Remaining: ~19 more first-party tools
(insert_node, batch_design, design_skeleton, ...) — each a focused
follow-up using the same `McpTool` impl shape.

Tests total: 246 shell-core (+1). Wasm32 build clean.
2026-05-14 15:51:53 +08:00
Kayshen-X cf48a006de feat(shell-core/figma): kind→NodeKind mapper (clipboard JSON one step from real import)
`FigmaClipboardNode::to_node_kind()` maps the 8 most-common Figma
type strings to the closest `crate::document::NodeKind`:

  RECTANGLE              → Rect
  ELLIPSE                → Ellipse
  LINE                   → Line
  POLYGON / REGULAR_POLY → Polygon
  VECTOR                 → Path
  TEXT                   → Text
  FRAME / SECTION        → Frame
  GROUP                  → Group
  <unknown>              → Other(<kind string>)

Unknown kinds round-trip via `NodeKind::Other(...)` so the layer
panel still renders something honest instead of dropping the node.
Future commits add per-kind specialised geometry mappers (fills /
strokes / vectors / text / layout) ported from `pen-figma`.

Tests (2 new):
  - 10-case table of every supported Figma kind → expected NodeKind
  - Unknown `BOOLEAN_OPERATION` → `Other("BOOLEAN_OPERATION")` round-trip

#9 Figma now ~50% — file kind detection + clipboard JSON walker +
per-child kind + name + NodeKind mapping. Remaining: per-kind
geometry/style mapper functions (the `pen-figma` figma-fill-mapper /
stroke-mapper / text-mapper / vector-decoder / etc port).

Tests total: 245 shell-core (+2). Wasm32 build clean.
2026-05-14 15:50:56 +08:00
Kayshen-X d97e1b809d feat(shell-core/figma): per-child type + name extraction from clipboard JSON
Advances #9 from "we know how many children" to "we know what each
child is named + what its Figma kind string is". `FigmaClipboardNode
{ kind, name }` carries the minimal fields the next conversion
stage (kind → PenNode variant + name → Node.name) needs.

`extract_clipboard_nodes` walks each top-level `{...}` block in the
children array via `collect_top_level_blocks` (depth-1 tracking +
string-aware brace counting), then `extract_string_field` pulls
`"type"` and `"name"` off each block. Missing fields yield empty
strings so the caller can `unwrap_or` without panics.

`ParsedFigStub` gains `clipboard_nodes: Vec<FigmaClipboardNode>`.
The existing `top_level_children` count stays as a duplicate of
`.clipboard_nodes.len()` for callers that only need the size.

Tests (2 new):
  - Real-ish 3-child payload (RECTANGLE, TEXT, FRAME with nested
    ELLIPSE) → only 3 top-level extracted, names match, nested
    avatar NOT promoted to top-level.
  - Missing fields → empty-string defaults without panic.

#9 Figma now ~35%. Remaining: map kind → PenNode variant (the
17-file `pen-figma` port: figma-node-mapper + 14 specialised
converters for fills / strokes / text / vectors / layout). Each
mapper is a focused follow-up; the structural parse + identification
groundwork is in place.

Tests total: 243 shell-core (+2). Wasm32 build clean.
2026-05-14 15:49:19 +08:00
Kayshen-X 5063c636f2 feat(shell-core/variables): stroke_refs + set_active_theme — #5 toward 100%
Two final pieces of the Variables/Themes data layer:

  - `stroke_refs: BTreeMap<NodeId, String>` parallels `fill_refs`.
    Paint reads `var_table.stroke_color_for(node.id)` first, falls
    back to `node.stroke.color` otherwise. Both fields registered in
    the canonical loader when it sees a `$ref` on the stroke
    descriptor in `.op` files.
  - `set_active_theme(axis, value)` + `clear_active_axis(axis)`
    mutators give the future theme-picker widget a one-line entry
    point. `clear_active_axis` removes the axis from the active
    map so subsequent resolutions fall back to the variable's
    `theme: None` default — matches TS theme-axis reset behavior.

Tests (2 new):
  - `stroke_color_for_resolves_registered_ref` — register a Color
    variable, register a stroke ref, resolve through the table,
    verify blue channel matches `#0000ff`.
  - `set_active_theme_round_trips_through_axis_picker` — flip an
    axis, add a second, clear one, assert state matches.

#5 Variables/Themes now ~98% — only the panel widget UI is
pending. Plumbing for paint, mutation, loader integration, fill
and stroke ref resolution all working with structural tests.

3 existing codegen test fixtures patched to seed `stroke_refs:
BTreeMap::new()` alongside `fill_refs`.

Tests total: 241 shell-core (+2). Wasm32 build clean.
2026-05-14 15:47:56 +08:00
Kayshen-X e570886883 feat(shell-core/chat): ChatState::send_via_provider — streams through ChatProvider
Wires the existing chat widget to the `ChatProvider` trait
(commit `548c5336`). The old `send()` echo-stub stays for the
zero-provider fallback path; `send_via_provider(provider,
system_prompt, max_tokens)` is the real entry that:
  - Pushes the input as a `ChatRole::User` message
  - Builds a `ChatRequest` and calls `provider.send(req)`
  - Drains the delta iterator into a single accumulated assistant
    message (TextDelta + Thinking concatenate; Error replaces with
    "error: ..."; Done breaks the loop; ToolUse is ignored — that
    flow belongs to the agent runtime port)
  - Returns the delta count for streaming-progress reporting

Synchronous wrapper — the caller polls the iterator to completion.
A future async-capable widget can replace the inner loop with one
delta per render frame; the trait signature already returns an
`Iterator + Send`.

Tests (3): EchoProvider streams two text fragments → assistant
body "Hello, world!"; provider error surfaces as assistant body
"error: rate limited"; empty input is a no-op.

#6 AI chat now ~25% — types + trait + chat-widget integration
working end-to-end with a test double. Real HTTP transport
(reqwest + Anthropic / OpenAI-compat / Ollama backends) is the
remaining multi-week port — but the seam is in place.

Tests total: 239 shell-core (+3). Wasm32 build clean.
2026-05-14 15:45:50 +08:00
Kayshen-X b68dfd70e5 feat(shell-core/canvas): paint-time \$ref substitution active
`paint_fill_then_stroke` now takes the resolved fill explicitly
rather than reading `node.fill` directly. Callers in `paint_node`
pre-resolve via `node_fill(node, var_table)` which checks
`var_table.fill_for(node.id)` first, falling through to `node.fill`
otherwise. Result: a Frame / Rect whose canonical loader registered
a `$ref` for its fill paints the current themed value at runtime;
flipping `active_theme` repaints with the new colour.

  - `paint_fill_then_stroke` signature: adds `fill: Option<Color>`
    as the last arg (after world_rect + zoom).
  - Both `NodeKind::Frame` + `NodeKind::Rect` branches in
    `paint_node` now compute `node_fill(node, var_table)` before
    calling the helper.

#5 Variables/Themes: types + storage + canonical loader +
fill_refs map + resolve + paint-time substitution all working.
Variables panel UI (active-theme picker + variable list with
edit) is the remaining piece — that's a widget, not a model
change.

Tests total: 236 shell-core (no new assertions in this commit;
the existing 8 variable tests cover the resolution chain that
paint now consumes). Wasm32 build clean.
2026-05-14 15:43:46 +08:00
Kayshen-X 0b964ff84d feat(shell-core/canvas): thread VariableTable through paint_node + node_fill helper
Plumbing for paint-time `$ref` substitution. `paint_node` now takes
`&VariableTable` alongside the existing args; recursive calls
pass it through unchanged. The new `node_fill(node, var_table)`
helper resolves `var_table.fill_for(node.id).or(node.fill)` —
ready for paint sites to swap in.

Full substitution still requires `paint_fill_then_stroke` /
icon_font branches to call `node_fill(node, var_table)` instead of
reading `node.fill` directly — that's a focused refactor (changes
the helper's signature in `canvas_viewport_overlay.rs` + every
NodeKind branch in paint_node) and lands separately. With the
plumbing in place today, the helper switch is a single per-site
edit; no more API reshape needed.

#5 Variables now ~90% — types, storage, loader, fill_refs map,
paint plumbing all shipped. Only the per-site `node.fill →
node_fill(node, var_table)` substitution remains.

Tests total: 236 shell-core. Wasm32 build clean.
2026-05-14 15:42:28 +08:00
Kayshen-X 9c1122b62d feat(shell-core/figma): clipboard-JSON top-level children counter
Advances #9 from "magic-byte detection only" to "we can read
something useful from the JSON clipboard format". `parse_fig` on a
`{"type":"FIGMA_DOCUMENT","children":[...]}` payload now returns
the count of top-level children entries instead of just
`NotYetImplemented`.

Hand-rolled JSON walker (shell-core stays serde-free for wasm32
bundle size): tracks brace depth + string-quote state to count
exactly the `{` openings at depth-1 inside the `"children": [`
array. Robust against quoted braces in node names + arbitrary
nesting inside each top-level child.

`ParsedFigStub` gains `top_level_children: usize`. Binary `.fig`
path stays `NotYetImplemented` — Zstd decompression + the
schema-encoded body need their own focused work (likely a server-
side binary or a desktop-only adapter, since adding `zstd-sys` to
shell-core would inflate the wasm32 bundle).

Tests (3 new):
  - 3-entry children array → count 3
  - Nested objects inside each top-level → only top-level counted
  - Quoted brace in a string value → not counted

#9 Figma now ~20%. Real Figma → PenNode mapping (the 17-file
pen-figma port: fig-parser + figma-node-mapper + 14 specialised
converters) remains the multi-week core work.

Tests total: 236 shell-core (+3) + 20 native + 8 desktop = 264.
Wasm32 build clean.
2026-05-14 15:39:42 +08:00
Kayshen-X d69f2dd9f5 feat(shell-core/mcp): run_stdio listener loop — end-to-end JSON-RPC server
`mcp::run_stdio(registry, reader, writer)` reads line-delimited
JSON-RPC requests, dispatches each through the registry, writes
the wire-formatted response with a trailing `\n`, flushes after
each line. Loops until EOF or write error.

Generic over `BufRead` + `Write` so the same function powers:
  - The eventual `openpencil-mcp` binary (`stdin().lock()` +
    `stdout()`).
  - Test fixtures using `Cursor<&[u8]>` + `Vec<u8>`.
  - Future TCP-listener wrappers.

Malformed input is skipped silently — the loop survives garbage
lines so a misbehaving client can't kill the server. Production
deployments will want logging here; the stub leaves that hook for
the binary.

Tests (2 new):
  - Three-line stream (two valid + one unknown-tool) produces
    three responses, ids preserved (`1`, `2`, `"x"`), error code
    `-32601` for the UnknownTool case.
  - Mixed garbage + blank + valid stream produces one response
    matching the single valid request.

#7 MCP now ~50% — types + registry + wire format + listener loop.
Remaining: real tool implementations (insert_node, batch_design,
design_skeleton, etc) + the binary entry. Each tool is a focused
follow-up; the dispatcher is done.

Tests total: 233 shell-core (+2). Wasm32 build clean.
2026-05-14 15:38:17 +08:00
Kayshen-X 68d642197d feat(shell-core/components): instantiate_component — Insert Instance flow
Deep-clones a registered Component's root subtree with fresh
`NodeId`s and appends to the active page's top-level children.
Mirrors TS drag-from-Components-panel insertion + the right-click
"Insert Instance" path.

  - `Document::instantiate_component(component_id, next_id) ->
    Option<NodeId>` — looks up `doc.components`, deep-clones root
    via `clone_node_with_new_ids` (private walker), pushes to
    `active_page().children`, sets the new root as selection
    anchor, captures pre-state to history (one entry per insert).
  - `next_id` allocator threaded through so every node in the
    cloned subtree gets a unique id past `max_node_id() + 1`,
    matching the same guard `duplicate_selected` /
    `group_selected` use.

Tests (2 new):
  - Component with 2 children → instance with same shape, both
    children have fresh ids (≠ source 11, 12), selection lands
    on instance root, history grew by one.
  - Unknown component id → None (no-op, no history).

#8 Components now ~65% — types + storage + create + instantiate
flow all shipped. UI hookup (Components panel widget + right-
click "Insert Instance" + drag-drop into canvas) is the remaining
follow-up.

Tests total: 231 shell-core (+2). Wasm32 build clean.
2026-05-14 15:37:22 +08:00
Kayshen-X c68c1de920 feat(shell-core/variables): fill_refs side-table — paint-time $ref groundwork
#5 Variables advances toward 90% with `VariableTable.fill_refs:
BTreeMap<NodeId, String>` — node-id → variable-name map for fills
that should resolve through the variable table instead of using
`node.fill` directly. Avoids touching `Node`'s shape (which would
invalidate every Node literal across test fixtures + builders) by
piggybacking on the same VariableTable the canonical loader fills.

API additions on `VariableTable`:
  - `set_fill_ref(node_id, ref_name)` — register a node's fill ref
  - `fill_for(node_id) -> Option<Color>` — resolve through the
    current `active_theme`; falls back to None when no ref is
    registered or the variable doesn't resolve. Canvas paint will
    call this first, fall through to `node.fill` on None.

Side-derivation: `NodeId` now derives `PartialOrd + Ord` so it
can key into `BTreeMap`. The existing 3-codegen-test fixtures
gain a `fill_refs: BTreeMap::new()` initializer.

Tests (2 new):
  - `fill_for_resolves_registered_node_ref_to_themed_color`:
    register a Themed Color variable + a node ref, flip
    `active_theme`, verify the resolved Color tracks the theme
  - `fill_for_returns_none_when_no_ref_registered`: unknown
    NodeId returns None so paint falls back to direct fill

Canvas-side `paint_node` integration (`let fill =
doc.var_table.fill_for(node.id).or(node.fill);`) lands when the
canvas refactor for that path arrives next.

Tests total: 229 shell-core (+2). Wasm32 build clean.
2026-05-14 15:36:20 +08:00
Kayshen-X e8cdd8d6e3 feat(shell-core/mcp): JSON-RPC wire serialiser + parser
Bridges the gap between the in-memory `ToolCall` / `ToolResponse`
types and on-the-wire JSON-RPC frames. Pure Rust, no serde dep
(shell-core stays wasm32-clean — adding serde would inflate the
bundle for a feature only the server binary uses).

  - `response_to_json(&ToolResponse) -> String` — emits the
    standard `{"jsonrpc":"2.0","id":...,"result":...}` for OK and
    `{"jsonrpc":"2.0","id":...,"error":{"code":...,"message":...}}`
    for Err. Hand-rolled emitter with proper JSON escaping for
    `"`, `\`, `\n`, `\r`, `\t`, and control chars.
  - `parse_tool_call(&str) -> Option<ToolCall>` — minimal parser
    that extracts `id` / `method` from a single-line JSON-RPC
    request. Empty `arguments` map for now; the server binary
    will swap in a real serde parse when wired.
  - `error_code_to_int` — maps `ToolErrorCode` variants to
    JSON-RPC's reserved + application-range codes per the spec
    (-32600..-32603 transport, -32001..-32002 application).

Tests (4 new):
  - Ok response carries `"jsonrpc":"2.0"`, the right id, and the
    result map serialised correctly.
  - Err response carries the right error code (-32601 for
    UnknownTool) and message.
  - Round-trip: parse_tool_call → registry.dispatch → response_to_json
    preserves the request id through the full pipeline.
  - JSON escapes special chars (`"`, `\n`) in both id and message.

#7 MCP now ~30% — types + registry + wire format. Real stdio
listener (line-delimited JSON over stdin/stdout) lives in the
follow-up server binary.

Tests total: 227 shell-core (+4) + 20 native + 8 desktop = 255.
Wasm32 build clean.
2026-05-14 15:33:19 +08:00
Kayshen-X 4c543c078c feat(shell-core/components): Document::create_component_from_selected
Adds the "Save as Component" mutator — promotes the anchor-selected
Frame/Group to a registered Component in `doc.components`. Same
shape as TS app's right-click "Make Component" context-menu action.

Semantics:
  - Selection must be exactly one node (anchor); anchor selection
    is the natural target for a "save as component" gesture.
  - Kind must be `Frame` or `Group` (loose shapes need to be
    wrapped first; matches TS).
  - The node stays on the page; the library entry is a clone.
  - Returns the new component id (== source node id), None on
    rejection.

Tests (3 new):
  - happy path: Frame → component registered, node still on page
  - non-container rejection: Rect selected → None, lib empty
  - no-selection no-op: clear_selection → None

#8 Components now at ~50% — library + storage + create flow. UI
(right-click menu wire-up, Components panel for browsing) and
NodeKind::Instance variant (for component-instance nodes on the
canvas) remain.

Tests total: 223 shell-core (+3) + 20 native + 8 desktop. Wasm32
build clean.
2026-05-14 15:32:11 +08:00
Kayshen-X 819fb36ce7 feat(shell-core/codegen): Compose + React Native — all 9 generators shipped
Completes #10 on the TS-parity roadmap. All nine targets the TS
`pen-codegen` package exposes now have Rust equivalents behind the
shared `Codegen` trait:

  1. CssVariables — design tokens → `:root { --name: value }`
  2. Html         — absolute-positioned `<div>` / `<span>` tree
  3. Vue          — SFC `<template>` + `<style scoped>`
  4. Svelte       — `<script>` + markup + `<style>`
  5. React        — JSX functional component, inline style object
  6. Flutter      — `Stack(children: [Positioned(Container/Text)])`
  7. SwiftUI      — `ZStack { Rectangle/Ellipse/Text.frame.position }`
  8. Compose      — `@Composable Box(Modifier.offset.size)`
  9. ReactNative  — `<View>` / `<Text>` with `position: 'absolute'`

Compose specifics: `Modifier.offset(x.dp, y.dp).size(width.dp,
height.dp).background(Color(r,g,b,a))` per node, default-export
`@Composable fun Page()`. Text nodes wrap as `Text(text =
"...")`.

React Native specifics: default-exported functional component
that returns a wrapping `<View>` flexed to fill, with each node
as `<View>` / `<Text>` at `position: 'absolute'`. Color uses CSS
`rgb()`/`rgba()` (RN accepts both).

Tests (2 new):
  - Compose: composable annotation + offset/size/color modifiers
  - RN: import statement + functional component + absolute style

Tests total: 220 shell-core (+2) + 20 native + 8 desktop = 248
total. Wasm32 build clean. #10 Codegen status: 100%.
2026-05-14 15:30:35 +08:00
Kayshen-X 7c08e1b45b feat(shell-core/codegen): React + Flutter + SwiftUI — 7 of 9 generators
- `React` — JSX functional component named `Page` wrapping nodes in a
    fragment; inline `style={{}}` objects with camelCase keys.
  - `Flutter` — `Stack(children: [Positioned(left, top, child:
    Container/Text)])`. Color emits as `Color.fromARGB`.
  - `SwiftUI` — `ZStack { Rectangle()/Ellipse()/Text(...) .frame.position
    }`. Color emits as `Color(red, green, blue, opacity)`.

All three reuse the established `Codegen` trait + walk
`doc.pages[active].children` the same way the HTML / Vue / Svelte
emitters do — `hidden` nodes are skipped, children recurse, text
bodies escape special chars (HTML targets) or use Dart/Swift
string-literal-escaping (`{:?}` debug-fmt) for compiled targets.

Tests (4 new):
  - React: import statement + component declaration + JSX fragment
  - Flutter: Stack wrapper + Positioned/Container shape + ARGB color
  - SwiftUI: ZStack + Rectangle + .frame modifier
  - SwiftUI ellipse: NodeKind::Ellipse → `Ellipse()` view

Remaining 2 generators (Compose, React Native) ship in a follow-up
commit — both follow the same trait-per-target shape with their
specific framework's geometry primitives.

Tests total: 218 shell-core (+4) + 20 native + 8 desktop. Wasm32
build clean.
2026-05-14 15:29:37 +08:00
Kayshen-X f4700af062 feat(shell-core/codegen): Vue + Svelte targets — 4 of 9 generators
Adds `Vue` and `Svelte` codegen targets alongside the existing
`CssVariables` and `Html`. Both reuse `emit_node_html` for markup
and embed the `CssVariables` generator's output verbatim in their
`<style>` block, so design tokens flow through into the framework
output as CSS custom properties.

  - `Vue` — Vue 3 SFC: `<template>` (node markup) + `<script setup
    lang="ts">` placeholder + `<style scoped>` (variables).
  - `Svelte` — Svelte SFC: `<script lang="ts">` placeholder + bare
    markup (no wrapping template tag, per Svelte convention) +
    `<style>` (variables). Script-then-style order enforced by
    test.

Tests (3 new):
  - `vue_emits_template_script_style_blocks` — all three SFC
    sections present
  - `svelte_emits_script_then_markup_then_style` — script appears
    before style in output (positional check)
  - `vue_includes_variable_css_in_style_block` — variables flow
    through into the scoped style block

5 generators remain (React + Tailwind, Flutter, SwiftUI, Compose,
React Native). The HTML-derivable group is done; the remaining 5
are framework-specific component models that need their own
emitters.

Tests total: 214 shell-core (+3) + 20 native + 8 desktop. Wasm32
build clean.
2026-05-14 15:27:51 +08:00
Kayshen-X 24df393c36 feat(shell-core/codegen): HTML generator — 2 of 9 targets shipped
Adds `codegen::Html` alongside the existing `CssVariables` generator.
Walks `doc.pages[active].children` and emits absolute-positioned
`<div>` per Rect/Frame/Group + `<span>` per Text, with inline-style
position/size, RGB fill, stroke as border, corner radius (50% for
ellipses), and rotation transform. Body text is HTML-escaped.

API stays identical — both generators implement the same
`Codegen` trait, so a future CLI / Property-panel codegen
dispatcher fans out by trait object.

Tests (4 new):
  - DOCTYPE + body wrapper + generator-attribution comment present
  - Rect emits `<div>` with left/top/width/height + `rgb(r,g,b)` fill
  - Text emits `<span>` and `&` / `<` / `>` in body get HTML-escaped
  - `node.hidden = true` skipped entirely (no orphan markup)

7 generators remain to port (React + Tailwind, Vue, Svelte,
Flutter, SwiftUI, Compose, React Native) — each as a focused
follow-up commit. The trait + dispatcher shape doesn't change.

Tests total: 211 shell-core (+4) + 20 native + 8 desktop. Wasm32
build clean.
2026-05-14 15:25:58 +08:00
Kayshen-X f400eb2701 fix(shell-core/mcp): structurally enforce request-id preservation
Codex stop-gate (round 2 on the same surface): the previous fix
gave `McpTool::call` access to `&ToolCall` so a well-behaved tool
COULD echo `request.id` — but nothing made it do so. A buggy /
adversarial tool was still free to mint a fake id, and id-mismatch
silently broke JSON-RPC routing on the client side.

Refactor: change the trait return type from `ToolResponse` (id +
payload) to a content-only `ToolOutcome::{ Ok(map) | Err(code,
msg) }`. The registry's `dispatch` wraps the outcome with the
originating `call.id` to produce the on-wire `ToolResponse`. Tools
never see the id; id-mismatch is now structurally impossible.

  - New `ToolOutcome` enum sits between tool implementations + the
    wire-shape `ToolResponse`.
  - `McpTool::call(&self, args: &BTreeMap<String, String>) ->
    ToolOutcome` — args-in, outcome-out, id-blind.
  - `ToolRegistry::dispatch` constructs `ToolResponse::Ok { id:
    call.id, result }` and `ToolResponse::Err { id: call.id, ... }`
    from the outcome.
  - `EchoTool` updated to the new signature.
  - New `LyingTool` fixture deliberately ignores any context the
    registry might pass; `registry_forces_id_on_response_regardless_of_tool`
    asserts the response still carries `req-honest` even though
    LyingTool's `call` returns an empty content map.

Tests: 4 MCP tests pass (3 carried over + 1 new id-stamping
regression). 206 shell-core total. Wasm32 build clean.
2026-05-14 15:23:55 +08:00
Kayshen-X 883bd70a65 fix(shell): 3 codex stop-gate regressions (anchor drag, MCP id, doc load reset)
BLOCK #1 — anchor drag couldn't return to start. `apply_cursor_move`
only called `set_path_anchor_position` when the cursor doc-point
differed from `start_doc`, so dragging away and then BACK onto the
original point silently skipped the final write — release committed
history with the anchor stuck at the last off-start frame.
Fix: always write the cursor position during an active drag; use
the start-doc comparison only to flip `moved` (which gates history
push). Regression test `anchor_drag_back_to_start_lands_at_start`
simulates the round-trip and asserts the anchor follows the cursor
all the way home.

BLOCK #2 — MCP tool registry dropped the request id. `McpTool::call`
only received `&BTreeMap<String, String>`, forcing tools to invent
response ids (test double used `RequestId::Num(0)`). JSON-RPC + MCP
require every response to echo the originating request id. Fix:
change the trait signature to `call(&self, request: &ToolCall) ->
ToolResponse` and have `dispatch` forward the whole call. EchoTool
updated to read `request.id`; the registry test now asserts the
id round-trips.

BLOCK #3 — opening a native saved file leaked variables across
documents. `apply_payload` reset pages + history + selection but
never touched `doc.var_table` or `doc.components` (both added in
recent commits). Open a variable-bearing canonical `.op`, then
open a plain saved `.pen` — codegen would still emit the stale
canonical variables. Fix: `apply_payload` now reassigns both to
`Default::default()` after the page/UI reset block.

Tests: 206 shell-core + 20 shell-native (+1 anchor return) + 8 desktop.
Wasm32 build clean.
2026-05-14 15:18:02 +08:00
Kayshen-X 55697e41bd feat(shell): chat_provider trait + figma file-format detection
Adds the abstractions both #6 (AI chat real integration) and #9
(Figma .fig import) need before their real implementations can
land. Same scaffolding-first pattern Variables / Components / MCP
/ Codegen used.

`crate::chat_provider` (#6):
  - `ChatDelta::{ TextDelta | Thinking | ToolUse | Done | Error }`
    — streaming events from a provider; mirrors
    `streaming/events.zig::Event` in agent-native.
  - `StopReason::{ EndTurn | Aborted | MaxTokens | ToolUse }`
  - `ChatRequest { system_prompt, user_message, max_output_tokens }`
  - `ChatProvider` trait — `provider_label() + send(req) ->
    Box<Iterator<ChatDelta>>`. Errors surface as `ChatDelta::Error`
    so partial streams survive.
  - `EchoProvider { script: Vec<ChatDelta> }` test double for
    chat-widget unit tests without a real LLM round-trip.
  - 3 tests: echo replays script in order; provider_label;
    Error delta carries message.

`crate::figma` (#9):
  - `FigFileKind::{ Binary | ClipboardJson | Unknown }`
  - `detect_kind(&[u8]) -> FigFileKind` — sniffs `fig-kiwi` magic
    (binary `.fig`) + `{"type":"FIGMA_DOCUMENT"...}` (clipboard
    JSON paste).
  - `parse_fig(&[u8]) -> Result<ParsedFigStub, FigParseError>` —
    returns `NotYetImplemented(kind)` for recognised files,
    `UnknownFormat` for everything else. Real parsing lands when
    `pen-figma`'s 17-file pipeline ports.
  - 4 tests: binary magic + clipboard JSON sniffing; random
    bytes rejected; not-yet vs unknown error paths.

Each of #6 / #9 now has data shapes the real implementation can
plug into without redesign. The actual transport (HTTP for chat,
Zstd + schema-encoded blob for .fig) is the per-module follow-up.

Tests total: 205 shell-core (+7). Wasm32 build clean.
2026-05-14 15:09:22 +08:00
Kayshen-X 8bb72524cd feat(shell): codegen::CssVariables — first of 9 generators ships
#10 on the TS-parity roadmap starts. User directive (memory
project_op_rust_gap_priority) is "codegen last"; CSS Variables is
the simplest and complements the #5 Variables/Themes work already
shipped this session — it emits whatever lands in
`doc.var_table` as a stylesheet without requiring the rest of the
node tree.

API:
  - `crate::codegen::Codegen` trait — `target_label()` +
    `generate(&Document) -> String`. Pure: no file I/O.
  - `CssVariables` impl — walks `doc.var_table.variables` and emits
    `:root { --name: value; }` for scalar entries, plus
    `:root[data-axis="value"] { ... }` blocks for each themed
    combination. CSS ident sanitisation maps non-alphanum chars to
    `-` so `primary.color` → `--primary-color`.

Tests (4):
  - emits scalars (`#0066ff`, `12`) under a `:root` block
  - per-theme variables emit one block per axis combo, both light
    and dark CSS variables present
  - non-ident chars sanitised (`primary.color` → `primary-color`)
  - empty doc emits only the generator header comment

Remaining 8 generators (React + Tailwind, HTML, Vue, Svelte,
Flutter, SwiftUI, Compose, React Native) ship in follow-up
commits; the `Codegen` trait means each is a focused new file.

Tests total: 198 shell-core (+4). Wasm32 build clean.
2026-05-14 15:07:42 +08:00
Kayshen-X f04f7920fe feat(shell): VariableTable::resolve_color + hex parser (#5 paint groundwork)
Translates a `$ref` variable name straight into a paintable
`crate::Color`. Gates on `VariableKind::Color`; rejects non-Color
variables, unparseable strings, and any non-Str scalar. Lenient
hex parser handles `#rgb` / `#rrggbb` / `#rrggbbaa` (case-insensitive),
rejects anything else.

This is the function paint-time `$ref` substitution will call from
the canvas viewport — once `Node` carries an optional ref name
alongside its direct fill (next session's model change for #5),
paint reads `node.fill_var.as_ref().and_then(|n| doc.var_table.resolve_color(n))`
falling back to `node.fill`. The Color helper is the pure piece;
the Node-level field addition is the invasive piece.

Tests (4):
  - resolve_color_parses_rrggbb_hex — `#ff8040` round-trips
  - resolve_color_picks_themed_active_value — `mode: dark` picks
    the dark entry of a Themed Color variable
  - resolve_color_rejects_non_color_variables — Number/Bool/String
    variables return None even with a hex-looking value
  - resolve_color_rejects_invalid_hex — `not-hex` returns None

Tests total: 190 shell-core (+4) all pass.
2026-05-14 15:05:23 +08:00
Kayshen-X 25440e7faa feat(shell): MCP protocol types + tool registry (P1 — scaffolding)
#7 on the TS-parity roadmap gets data shapes so the stdio + HTTP
server work can proceed without redesign. Same scaffolding-first
pattern Variables / Components used.

New file `crates/openpencil-shell-core/src/mcp.rs`:
  - `RequestId { Str | Num }` — JSON-RPC accepts both.
  - `ToolCall { id, tool, arguments }` — inbound invocation.
  - `ToolResponse::{ Ok { result } | Err { code, message } }` — typed
    enough for the LLM client to recover.
  - `ToolErrorCode { MissingArgument, InvalidArgument, ToolFailed,
    UnknownTool, Internal }` — maps to standard JSON-RPC codes
    when the future server serializes.
  - `McpTool` trait — `name() + call(args) -> ToolResponse`.
  - `ToolRegistry` — Send+Sync BTreeMap<name, Box<dyn McpTool>>
    with `register / dispatch / names / len`.

Tests (3):
  - empty registry: zero tools, empty names.
  - dispatch routes args to registered tool + returns Ok payload.
  - dispatch returns UnknownTool error on missing tool.

Real stdio listener (line-delimited JSON-RPC) + HTTP listener + the
~20 first-party tools (insert_node, batch_design, design_skeleton,
...) land in a focused follow-up — most likely as
`crates/openpencil-mcp` binary so the desktop binary doesn't take
on a server's runtime dependencies.

Tests total: 190 shell-core + 19 shell-native + 8 desktop. Wasm32
build clean.
2026-05-14 15:03:28 +08:00
Kayshen-X 03a514f38d feat(shell): wire Document.components: ComponentLibrary (P2 — storage)
Mirrors the var_table wiring from commit e81e8e09. Adds the
component-library field to Document so the canonical .op loader
(and future "Save as Component" mutator) has a place to land
component definitions.

  - `Document.components: ComponentLibrary` — Default = empty.
  - Patched the same 9 test fixtures (`tests_geometry.rs` × 7 +
    `canvas_viewport.rs` + `layer_panel_tests.rs`) to seed the
    field alongside `var_table`.
  - Library lookup methods (`find_by_id` / `find_by_name` /
    `insert` with id-replace) are usable on `doc.components` from
    anywhere.

Pen-doc-adapter integration (`pen_document_to_payload` doesn't yet
carry components) + `NodeKind::Instance` variant for cross-document
instances + Components panel widget all stay pending — the data
shape lands first so the loader integration is one focused commit.

document.rs / mutators.rs both back at the 800-line cap via
doc-comment compaction (Node field docs dropped to inline naming,
`t()` Doc compressed to one line).

Tests: 187 shell-core (no new assertions; the 3 components tests
landed in commit 0fecab0a). Wasm32 build clean.
2026-05-14 15:00:54 +08:00