Mirror the native host migration for `openpencil-shell-web`. The web
`WidgetHost` now holds an `op_editor_core::EditorState` authoritatively
instead of a shell-core `Document`. The ~30 shared widgets stay
`&Document`-bound and read-only — they are fed a derived `paint_doc`
snapshot rebuilt lazily by `refresh_paint_doc()` whenever
`editor_state_dirty` is set.
- Every mutation routes through an `op-editor-core` mutator + flags the
dirty bit; the snapshot re-derives once before paint / before any
hit-testing input event.
- Hit-test-then-mutate handlers refresh, hit-test on `&paint_doc`,
extract owned results, then mutate `editor_state`; shell-core hit
enums translate via `op_pen_loader::rev::*`.
- `paint` takes `&mut self` to drain the cache at the top of the pass.
- `op-editor-core` / `op-pen-loader` are optional deps behind the
`skia` feature (matching `skia-safe`) so the skia-free wasm32 CI stub
baseline stays clean — `op-pen-loader` pulls `jian-skia` transitively.
- Fix two pre-existing `--features skia` build blockers in `lib.rs`
(missing `Performance` web-sys feature, `canvas` moved before reuse).
No web-editor behaviour change — feature parity preserved.
- CursorMoved refreshes the derived paint Document before cursor_hint
so a post-mutation / pre-paint cursor move can't read stale geometry.
- apply_cursor_move refreshes the paint doc up front so every hit-test
inside it (align toolbar, color picker, layer menu, panel resize)
sees a fresh document.
- commit_variable_row_focus_if_any routes every exit through one
mark_dirty so an invalid variable edit can't leave the cache stale.
- Legacy private DocPayload files are detected and rejected with an
explicit "saved by an older version, must be re-saved" message
instead of a silent / opaque schema parse failure.
- active_page_index is persisted in a minimal .opmeta sidecar so it
survives a save / load round-trip; the .op file stays canonical.
- set_primary_fill_type carries the existing fill body across a
fill-type change (gradient stops / opacity / blend mode) instead of
overwriting it with a default body, matching shell-core behaviour.
- collapsed_layers documented as intentionally view-only at the
EditorSnapshot and editor_ui_state sites.
Flip WidgetHostNative off shell-core's Document onto
op_editor_core::EditorState. The host now owns one authoritative
state; every paint pass + hit-test reads a lazily-derived,
read-only Document snapshot (`paint_doc`), rebuilt via
op-pen-loader's pen_document_to_document + apply_editor_state_ui
whenever an `editor_state_dirty` flag is set.
Why: removes the dual-state strangler scaffold so the canonical
PenDocument model is the only editable state — input handlers
mutate EditorState, widgets stay read-only over the derived
Document. .op save now serializes editor_state.doc; load seeds
EditorState::from_document. shell-core's Document becomes a
paint-only target.
- op-editor-core: add host_support (EditorState::sample,
create_node_for_tool, replace_paths_with_polyline) + re-export
VariableScalar/VariableKind.
- shell-native: rewrite all 8 widget_host submodules onto
editor_state; split press.rs -> press_helpers.rs and keyboard
click routing -> click.rs to stay under the 800-line cap;
boolean_ops becomes a pure compute_boolean_op committed back
through EditorState.
- desktop: persistence / settings_io / chat_session /
model_discovery / main / frame move onto editor_state accessors.
Three additive variable-handling fixes so the editor host can migrate
onto EditorState as a mechanical port:
- Gap 1: add `variable: Option<String>` to ColorPickerState, port
`open_color_picker_for_variable`, and route `color_picker_set_hsv` /
`close_color_picker` through `set_variable_color` when set.
- Gap 2: add `op_pen_loader::editor_state_var_table(&EditorState)`,
folding persisted variables/themes plus the transient active-theme
selection + ref caches into a shell-core VariableTable. Lives in
op-pen-loader (not op-editor-core) to keep op-editor-core wasm-clean
and free of any shell-core dependency.
- Gap 3: confirmed undo/redo of variable create/delete/rename round-
trips for free (EditorSnapshot clones the whole PenDocument); added
tests to lock it in.
Phase 6 strangler scaffolding: WidgetHostNative now holds an
op_editor_core::EditorState alongside the legacy shell-core Document
so the ~30 widgets can migrate onto the canonical model one group at
a time while the workspace stays build-green.
The bridge is intentionally minimal — Document remains the source of
truth (every un-migrated widget paints from it and every apply_*
mutates it), and editor_state is not yet wired into paint or input.
There is no Document -> PenDocument converter, and the desktop
pen_doc_adapter only goes the other way (baking flex layout into AABB
rects, which is irreversible), so a per-frame round-trip would be
unsound. Each later 6.x task adds its own per-group sync point as it
switches a widget group's paint/input onto editor_state.
Field, accessors and dependency are all marked TEMPORARY — deleted in
Phase 7 when Document dies and editor_state becomes the host's only
state.
FigmaClipboardNode::to_node now produces canonical
jian_ops_schema::PenNode instead of shell-core's flat Node. Maps the
8 common Figma kinds onto Frame/Group/Rectangle/Ellipse/Line/Polygon/
Path/Text variants; unmapped kinds fall back to Frame with the
original kind preserved in base.role since PenNode has no Other
escape hatch. Tests ported to assert on PenNode variants + base
accessors. The import stays in shell-core (Option B, in-place port).
Port the in-process MCP server off shell-core's legacy `Document` /
`McpCommand` onto `op_editor_core::EditorState` / `EditorCommand`. The
module physically stays inside `openpencil-shell-core` (an `op-mcp`
crate extraction is a later Phase-7 task).
- Read tools snapshot `EditorState` (canonical `PenDocument`); write
tools emit `op_editor_core::EditorCommand` applied via
`EditorState::apply`. Node ids are now canonical `.op` schema
strings, not the old `u64`.
- Component commands surface a clean `ToolFailed` "known gap" error —
`op-editor-core` has no component registry yet. Same for
`set_node_collapsed` (`NodeFlag::Collapsed` has no schema field).
- `mcp_serve.rs` loads the `.op` file straight into an `EditorState`
(plain `jian-ops-schema` deserialization) and saves the
`PenDocument` back on every successful write.
- Delete the orphaned legacy apply path (`document/mcp_apply*.rs` +
`Document`/`VariableTable::apply_mcp_command`); the widgets that use
`Document` are untouched.
- Split `tools.rs` → `tools.rs` + `read_tools.rs` and
`component_tools.rs` → `component_tools.rs` + `page_tools.rs` to
hold the 800-line cap.
Codex review of Task 1.2 found four dangling references to the deleted
stub crates (pen-types/core/engine/codegen/figma + openpencil-app):
- package.json: drop the five -p <crate> args from cargo:wasm-check
- rust-release.yml: restore the build job, re-pointed at the real
openpencil-desktop crate so release-draft has artifacts to publish
- check-jian-boundaries.sh: 4 -> 3 invariants in the success message
- README.md: remove the deleted crate rows from the crate-list table
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.
The official github-copilot-sdk requires rust-version 1.94 +
edition 2024; the workspace was pinned to 1.85. Bump the toolchain
so the SDK can be adopted for the Copilot connection. Verified:
full native build, wasm32 web check, and shell-core tests all pass
on 1.94.1 with no new errors.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
`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.
`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.
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.
- 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.
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.