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.
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.
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.
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.
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.
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.
`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.
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.
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.
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.
`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.
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.
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.
`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.
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.
#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.
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.
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.
- `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.
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.
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.
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.
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.
#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.
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.
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.
Drops the storage layer for #8 Components onto shell-core so the
canonical `.op` loader has somewhere to land design-system data
when it ships. Same scaffold-first pattern Variables/Themes used.
New file `document/components.rs`:
- `Component { id, name, root: Node }` — one reusable design
fragment; `root` is the subtree future Instance-NodeKind will
clone on insert.
- `ComponentLibrary { components: Vec<Component> }` — per-document
registry. `find_by_id` / `find_by_name` for lookup;
`insert(c)` replaces on duplicate id (TS parity with the
'Save as Component' overwrite path).
Re-exported from `crate::document::{Component, ComponentLibrary}`.
Document field wiring + canonical loader integration land in a
follow-up alongside the `NodeKind::Instance` variant (needs the
match-arm sweep across canvas paint / pen_doc_adapter / serializer).
Tests (3): find_by_id matches by id; find_by_name returns the
first hit; insert replaces an existing entry on id collision.
document.rs back at the 800-line cap (was 805 after the mod
declarations) via doc-comment compaction on Node — fields kept
self-documenting via name.
Tests total: 187 shell-core (+3) + 19 shell-native + 8 desktop.
Wasm32 build clean.
Compact three multi-line comment blocks in `widget_host/input.rs`
that were narrating implementation details Codex already covers
inline elsewhere:
- path-anchor `moved` flag explanation: 4 lines → 1
- align-toolbar hover sync rationale: 4 lines → 1
- settings-input keyboard ownership: 3 lines → 1
Net 8 lines saved; file is still 78 over the 800-line cap because
the remaining bulk is real code (apply_text dispatch, apply_release
drag-clearing chain, apply_cursor_move's 6-branch drag detection).
A proper sibling-module split — moving keyboard handlers to
`widget_host/keyboard.rs` and clipboard ops to `widget_host/clipboard.rs`
— is task #49 and stays a clean focused refactor for the next pass.
Tests: 19 native still pass. No behavior change.
Closes the gap between `VariableTable` (commits `62b08b93` /
`dbfd2f1a`) and the canonical `.op` loader: opening a file now
preserves `.variables` + `.themes` straight onto
`Document.var_table` instead of dropping them at the door.
- `Document.var_table: VariableTable` field — Default = empty
table. Patched 9 test fixtures (`tests_geometry.rs` × 7 +
`canvas_viewport.rs` + `layer_panel_tests.rs`) to seed with
`VariableTable::default()` alongside `history`.
- `pen_doc_adapter::build_var_table(&PenDocument) -> VariableTable`
maps `jian_ops_schema::variable::*` → shell-core types: the
enums are isomorphic (Color/Number/Boolean/String;
Bool/Num/Str; Scalar/Themed). Theme axes copy through directly.
- `persistence.rs` open path: when the canonical branch fires,
`build_var_table` runs alongside `pen_document_to_payload`. The
result is held in a local `Option<VariableTable>` because
`apply_payload` doesn't know about variables; after it resets
the document, the held value is assigned to `doc.var_table`.
Round-trip: load → `doc.var_table.find("color-1")` returns the
right `Variable`, `doc.var_table.resolve("color-1")` returns the
themed value under the current `active_theme` (empty default —
picks the `theme = None` entry, mirroring `Variable::resolve`'s
fallback). UI for switching `active_theme` (Variables panel) +
paint-time `$ref` substitution are the remaining follow-ups for #5.
Tests: 184 shell-core + 19 shell-native (no new assertions in this
commit; variables algorithm tests landed in `dbfd2f1a`). Desktop
builds clean.
Codex stop-gate BLOCK #1: `boolean_ops::apply_boolean_op` looked up
source paths recursively (via `active_page().find()`) but only
removed them from the top-level `page.children` list. When the
sources lived inside a Group or Frame, the originals stayed in
their parent's children while the result was appended at the
canvas root — duplication + orphans.
Fix: replace the top-level `retain` call with a recursive
`remove_nodes_recursively` walker that drops any node whose id is
in the source set from every children Vec depth-first. New
regression test `boolean_op_removes_nested_paths_not_just_top_level`
wraps two paths in a Group, runs Union, and asserts:
- the Group still exists but is empty
- one result Path lives at top level (total page.children = 2)
Codex stop-gate BLOCK #2: `property_panel_sections::export_section_rect`
was added in commit `5bde95b9` (PropertyPanel preview pills) but
never wired into `hit_test_action`, leaving the visible Export
section unable to open the new ExportDialog. The helper is dead
code in the meantime. Drop it; replace with a comment marking the
follow-up. Existing UX (File menu → Export image / Cmd+Shift+P)
still opens the dialog. Tracking via task #52.
Codex stop-gate finding #3 (`main.rs` 828 / `input.rs` 886 over
the 800-line cap) is real but stylistic — already tracked as task
#55 (sibling-module split). No functional impact; deferred so this
commit stays scoped to the data-corruption + dead-code fixes.
Tests: 184 shell-core + 19 shell-native (+1 nested boolean ops
regression). Wasm32 build clean.
Adds a `VariableTable { variables, themes, active_theme }` struct
that owns the canonical `.op` variable + theme registry. Looks up
by name (`table.find("color-1")`) and resolves through the active
theme (`table.resolve("color-1") -> Option<&VariableScalar>`).
`active_theme` is a `BTreeMap<String, String>` mapping axis to
selected value (e.g. `{"mode": "dark"}`); empty map falls back to
the default `theme = None` entry of every Themed variable.
Both `Variable::resolve` (single var) and `VariableTable::resolve`
(registry-level) are now testable in isolation; the canonical
loader's job in the next session is to populate one of these
tables from `PenDocument.variables` + `.themes`.
Document field wiring deferred: 9 test fixtures construct
`Document { ... }` literals across `tests_geometry.rs` /
`layer_panel_tests.rs` / canvas viewport tests; threading a new
field through them is a separate, mechanical commit. Until then,
the variable table lives as a free-standing type that adapters can
hold on the side.
Tests: 184 shell-core pass; both `document.rs` + `mutators.rs`
sit at exactly the 800-line cap.
Lays in the data shapes the canonical `.op` loader needs to
round-trip designs that depend on `$ref` color tokens + multi-axis
themes. Mirrors `jian_ops_schema::variable` so loader integration
in a follow-up is a direct field map.
New types in `document/variables.rs` + re-exported from
`crate::document`:
- `VariableKind { Color, Number, Boolean, String }`
- `VariableScalar { Bool(bool), Num(f64), Str(String) }` (untagged
over the on-disk `"#ff0000"` / `12.5` / `true` shapes)
- `ThemedValue { value, theme: Option<BTreeMap<String, String>> }`
— one entry per (axis, value) combination
- `VariableValue::Scalar | Themed(Vec<ThemedValue>)`
- `Variable { name, kind, value }` — owns its resolution: passes
an `active_theme: &BTreeMap<String, String>` and returns the
`VariableScalar` whose theme map is the subset that matches.
Falls back to the entry with `theme = None`. Empty `Themed([])`
returns None.
- `ThemeAxis { name, values }` — placeholder for the Variables
panel's theme picker (paint UI lands later).
Tests (4):
- scalar_variable_resolves_regardless_of_theme — `$color-1`
always returns the literal regardless of active axis.
- themed_variable_picks_active_axis — light/dark color tokens
resolve correctly for each `mode`.
- themed_variable_falls_back_to_default_when_no_match — `theme:
None` is the safety net when active axis isn't enumerated.
- themed_variable_returns_none_when_empty_and_no_default — empty
`Themed([])` returns None instead of panicking.
Follow-ups:
- Wire `Document.variables: Vec<Variable>` + `.themes` +
`.active_theme` fields (waiting on cap headroom in document.rs).
- Canonical loader integration: `pen_doc_adapter` reads
`PenDocument.variables / .themes` into the new fields.
- Paint-time `$ref` resolution: walk nodes pre-paint, replace
`Color { ref: ... }` with the resolved variable scalar.
- Variables panel widget in the Right rail.
Tests: 184 shell-core (+4) all pass. document.rs back at 800-line cap
after compacting FileAction + BooleanOp doc comments.
Adds `alt_modifier` tracking on the desktop runner (alongside the
existing `zoom_modifier` + `shift_modifier`). New keyboard-event
arm matches `Cmd/Ctrl + Alt + <letter>` and dispatches to
`WidgetHostNative::apply_boolean_op`:
- U → Union
- S → Subtract
- I → Intersect
- X → Exclude
Mirrors TS `apps/web/src/hooks/use-edit-shortcuts.ts` (Ctrl+Alt+U/S/I)
plus the canonical Paper.js Exclude shortcut (Ctrl+Alt+X). With ≥ 2
Path nodes selected, the user can now reach all four boolean
operations from the keyboard without going through a toolbar.
The dispatch is gated on `!shift_modifier` so future Shift-variants
(e.g. Cmd+Alt+Shift+U for "subtract from instead of union into")
stay reserved.
Tests: 180 shell-core + 18 shell-native (boolean op tests still pass
against the underlying mutator). Wasm32 build clean.
Adds `apply_boolean_op(op)` on the native widget host so downstream
callers (keyboard shortcuts in main.rs, future toolbar buttons,
menu items) can dispatch a path boolean op with a one-line call.
Wraps `boolean_ops::apply_boolean_op` and threads the host's
`next_node_id` allocator through so the result Path mints a fresh
id that can't collide with existing nodes.
Tests: 18 native (no new assertions in this commit; the existing
4 boolean_ops tests cover the underlying mutator). Wasm32 build
unaffected — the method is desktop-only.
#2 on the TS-parity roadmap lands. Skia's built-in `Path::op`
backed by `SkPathOps` does the heavy lifting; the mutator lives in
`shell-native` (not shell-core) so the web bundle stays skia-free.
API:
- `openpencil_shell_core::document::BooleanOp` — Union / Subtract /
Intersect / Exclude (mirrors TS Paper.js' four ops).
- `openpencil_shell_native::boolean_ops::apply_boolean_op(
doc, op, next_id) -> bool`
Filters the selection to Path nodes (Rect/Frame/etc are ignored
so a mixed selection still composes paths). Requires ≥ 2 Path
sources; otherwise no-op + no history. Builds skia paths via
PathBuilder.{move_to, line_to, close}, folds via Path::op,
extracts result points from the PathIterRec stream (Move/Line
take pts[0]; Quad/Conic take pts[1]; Cubic takes pts[2]; Close
is dropped). Builds the result Path node inheriting the first
source's fill + stroke, recomputes its bounds, replaces the
source paths in the active page, and pushes one history entry.
Tests (4):
- union_of_two_overlapping_squares_collapses_to_one_path —
proves the source pair is removed + one new Path appears + the
bounds are non-empty + history grew by one.
- intersect_keeps_overlap_region — verifies the 10×10 overlap
bounds of two 20×20 squares offset by (10, 10).
- boolean_op_requires_two_path_nodes — single-Path selection
no-ops without touching history.
- boolean_op_skips_non_path_nodes_in_selection — mixed Path +
Rect selection still composes the two Paths; Rect survives.
Keyboard-shortcut + toolbar wiring lands in a follow-up so this
commit stays focused on the mutator. Curves in the result degrade
to their endpoint (TS Paper.js has the same v1 behavior; full
anchor-with-handles model arrives with #3 follow-up).
Tests total: 180 shell-core + 18 shell-native (+4) + 8 desktop
export. Wasm32 build clean.
Closes the loop on the anchor-drag interaction (commits `814d05ca`
+ `6daaaf62`): when the selected node is `NodeKind::Path` AND the
Pen tool is active, the canvas now paints a small white-filled +
primary-stroked 8 px circle at each `node.points[i]`. These match
exactly the doc-space coords that `path_anchor_hit` checks against,
so the user can SEE the targets the drag honours instead of
guessing.
Added in canvas_viewport.rs section 4b — between the selection
overlay (4) and the canvas-state restore — so the dots paint on top
of the path outline but underneath any modal overlays.
Behavior matrix:
- non-Path selected: no handles
- Path selected, Select tool: no handles (existing 8 resize +
rotation handles still paint as before)
- Path selected, Pen tool: per-anchor handles (one per
`node.points` entry)
Existing 180 shell-core + 14 shell-native tests still pass. Wasm32
build clean.
Codex CONCERN #1: a press-release on an anchor handle with no
cursor motion in between still pushed the pre-drag snapshot, adding
a no-op entry to the undo stack that made the next Cmd-Z appear
inert. Fix: PathAnchorDragState gains `start_doc: Point2D` +
`moved: bool`. apply_cursor_move flips `moved` true only when the
cursor's document-space position differs from the start by > 0.001
doc-px. apply_release_with_viewport pushes the snapshot only when
`moved == true`; otherwise drops the state without touching
history. Two regression tests:
- anchor_press_release_without_motion_does_not_push_history
(seeds moved=false; release leaves history unchanged + returns
!consumed)
- anchor_drag_with_motion_pushes_one_history_entry (seeds
moved=true; release pushes exactly one entry + returns consumed)
Codex CONCERN #2: PDF pages were sized to each page's own content
bounds, producing heterogeneous page sizes that caused viewer
zoom/scroll to jump between pages. Fix: export_pdf takes the union
of all page bounds (max width + max height + 16-pt margin) and
emits every page at that uniform size. Each page's content is
positioned inside the frame at its own (origin.x, origin.y) so the
visual layout is unchanged — only the page frame becomes
consistent.
Tests: 180 shell-core + 14 shell-native + 8 desktop/export.
wasm32 build clean.
Wires the `set_path_anchor_position` mutator (groundwork commit
`814d05ca`) into the canvas hit-test + drag dispatch.
Geometry helper `path_anchor_hit(x, y, vw, vh)` in
`widget_host/geometry.rs` returns `Some((node_id, anchor_index))`
when the press lands inside an 8-screen-pixel circle around an
existing anchor of the selected Path node, with the Pen tool
active. Radius scales with viewport zoom so the hit box stays a
constant screen size.
Press dispatch (`widget_host/press.rs::apply_press`) — Pen tool
branch checks `path_anchor_hit` BEFORE the existing
add-anchor / start-path code path. Hit → captures pre-drag history
snapshot, seeds `path_anchor_drag` state. Miss → falls through to
existing author-anchor behaviour.
Cursor move (`widget_host/input.rs::apply_cursor_move`) — slot
between node-drag and marquee-drag: snaps the picked anchor to the
cursor's document-space coords via the mutator.
Release (`widget_host/input.rs::apply_release_with_viewport`) —
slot between node-drag and marquee-drag: pushes the pre-drag
snapshot so the user gets a single Cmd+Z to revert the whole
drag.
State struct `PathAnchorDragState { node_id, anchor_index,
pre_drag_snapshot }` lives in `widget_host.rs` (debug + clone, not
copy because DocumentSnapshot owns its pages Vec).
Tests: all 12 native input tests still pass (existing coverage
exercises the press/move/release path on adjacent drag types so any
regression on those would surface). 180 shell-core tests pass.
Wasm32 build clean.
Follow-ups: visual handles (paint per-anchor dots on selected Path
when Pen tool is active so the user sees what to grab); web parity;
codex review of the chain.
Adds `Document::set_path_anchor_position(node_id, index, pos)` —
moves one anchor on an existing Path node to a new doc-space
position and recomputes the node's bounding box. Mirrors the
gesture the eventual anchor-drag interaction will use: pick an
anchor by index, snap it to the cursor, re-fit bounds. History is
the caller's responsibility (anchor drag pushes one snapshot per
drag-start, not per cursor-move).
Defensive: is_editable gate (locked/hidden nodes ignored), index
range check, NodeKind::Path-only via path_points_mut_walk.
Tests:
- moves a known anchor + verifies bounds re-fit (y range now
covers the new max).
- out-of-range index returns false.
- non-Path node returns false.
UI wiring (canvas hit-test on anchor handles + drag dispatch) lands
in the next pass. This commit just makes the mutation primitive
available so subsequent UI work can call into it without reworking
the document layer.
Phase 4 — closes the last gap in the ExportDialog. Each PenPage
becomes one PDF page laid out at its content bounding box plus a
16-pt margin. Empty pages are skipped; all-empty docs return
'nothing to export' to match the raster export convention.
Mechanism: `skia_safe::pdf::new_document(&mut buf, None)` returns
the document; per-page `begin_page` / `end_page` reuses the same
`paint_node` pipeline as the raster path, so every variant the PNG
exporter handles (Rect / Ellipse / Polygon / Line / Path / Frame /
Group with rotation + corner radius) emits as real vector PDF ops —
glyphs and shapes stay selectable and zoom-clean. The TS app hand-
rolls a PDF stream (Catalog + Pages + Image XObjects with DCTDecode
JPEG blobs); skia's backend produces a smaller, sharper file.
Wiring:
- `export.rs::page_bounds` + `paint_node` exposed as `pub(crate)`
so the new `export_pdf.rs` sibling can reuse them.
- `Cargo.toml`: skia-safe `pdf` feature flag enabled.
- `persistence.rs::ExportImageConfirm` PDF branch now calls
`export_pdf::export_pdf` instead of returning the placeholder
error.
- `ExportFormat::is_implemented` now returns true for every variant
so the dialog stops greying out the PDF pill.
Tests: 2 unit tests in `export_pdf.rs` cover `%PDF-` header +
`%%EOF` trailer for a 2-page doc, plus the all-empty failure path.
197 total tests pass.
Phase 5 codex review still pending; will land in the next pass.
Document::align_selected covers 6 align actions (left / center-h / right
/ top / center-v / bottom) and 2 distribute actions (horizontal /
vertical center-spacing). Reference frame is the union of selection
bounds for 2+ nodes, the parent container for a single selection
(top-level no-ops). Ancestor-in-set dedup mirrors translate_selected so
a frame + child selection only shifts the frame; descendants cascade.
History pushes only when at least one node actually moved.
Floating AlignToolbar widget appears when selection_count >= 2; centered
horizontally above the canvas with a 56-px reserve so it never overlaps
the vertical Toolbar column. Hidden entirely when the canvas can't host
both. Hover state lives on Document.ui.align_toolbar_hover and clears on
every selection-count drop. Hit-test sits before apply_click on both
native + web so visible buttons always win clicks. Hover sync runs AFTER
all drag branches in cursor_move so an active node-drag isn't stolen by
a hover update.
8 lucide d-strings (align-start/center/end-vertical/horizontal +
horizontal/vertical-distribute-center) added to icons_data.rs from
lucide-react@0.545.0. Codex stop-gate reviewed three times to BLOCK-free.
Tests: 25 align (mutator + widget) + 1 native drag-interception
regression. Closes the v0.8.0 align/distribute roadmap item.
Pivot the desktop's Open path to the canonical `jian-ops-schema`
parser and route layout through `jian-core::LayoutEngine` so files
saved by the TS editor, Jian apps, or any tool emitting the
canonical schema load through the shared parser + paragraph shaper.
Loader (pen_doc_adapter.rs + pen_doc_path_bounds.rs)
- All 12 PenNode variants → NodePayload, with each root's authored
(base.x, base.y) added to harvested rects so multi-design files
(e.g. pencil-demo.op's 14 mockups) spread across the canvas.
- Path anchors port `getPathBoundsFromAnchors` — endpoints + Bezier
handles + cubic-derivative extrema — so curved paths scale into
their (width, height) the way the canonical renderer paints.
- jian-skia's `SkiaMeasure` plugged in via
`LayoutEngine::with_backend(...)`, replacing the ~10% character-
count heuristic with real paragraph-shaper metrics. Wrap/layout
now agree with paint instead of cascading 10% errors.
- Numeric-string fontWeight (`"700"`, `"normal"`, ...) parsed in
both jian-core and the desktop adapter; expanded keyword table
covers black/heavy/extralight/extrabold/demibold/hairline/etc.
- Version-tolerant `load_canonical` retries with `version` rewritten
to `"1.0"` so legacy `version: "2.8"` files still load.
Text + icon rendering
- `Node.text_wrap` gated on `textGrowth: fixed-width` — single-line
by default so font-fallback overshoot doesn't break lines the TS
app shows on one line.
- CJK-aware `wrap_text` (canvas_viewport_overlay.rs) — per-char CJK
breaks, word breaks for Latin, blank-line preservation, explicit
`\n` splits. Takes a weight param.
- `RenderBackend::measure_text_weighted` added with NativeBackend +
WebBackend overrides so wrap measurement matches weighted paint.
- icons.rs + new icons_data.rs sibling cover ~75 lucide variants
for first-party `iconFontName` names from pen-core element-builders
(trending-up/down, compass, refresh-cw, layout-dashboard, users,
package, zap, sliders-horizontal, activity, loader, focus,
chart-line, settings-2, arrow-right, check-circle, alert-triangle,
alert-octagon, sticky-note, bar-chart-2, bold/italic/underline,
shopping-cart/bag, send, message-circle, rocket, menu, credit-card,
x-circle, mail, smartphone, chrome, apple, user, ...). Unknown
names stroke a dot fallback (FALLBACK_ICON_D) instead of a block.
All d-strings copied from lucide-react@0.545.0.
- `Icon::from_name(&str)` resolves kebab-case + common aliases.
- Synthetic bold via PaintStyle::StrokeAndFill for weights ≥600 on
both native and web (single-weight bundles can't serve a real
bold variant).
Chrome polish
- Hover state on file menu / locale picker / shape picker / layer
panel rows / AgentSettings nav + provider cards. Host's
apply_cursor_move updates each per its open state.
- File menu compacted (row 30, header 22, no `…` suffix on actions),
recent file names truncate with a CJK-aware helper.
- `rfd::MessageDialog` on every failed Open / OpenRecent / Save /
SaveAs / ExportImage with bilingual (EN/ZH) title + path + detail.
OpenRecent failures prune the stale entry.
- `figma_import.rs` modal honest-stub (Coming soon copy, brand glyph),
TopBar Folder+Chevron compound + Figma button.
- Settings sidebar nav + provider cards tinted on hover.
- Recent-files panel polished to single-line names with age column.
Tests
- pen_doc_adapter_tests.rs (sibling via #[path]) — 19 cases covering
multi-root canvas offsets, shape size fallbacks, path anchor
absolutize + Bezier extrema, fixed-width wrap, numeric-string
weights, login.op + pencil-demo.op fixture loads.
- canvas_viewport_overlay.rs wrap_tests — 7 cases: ASCII / CJK /
CJK+Latin / explicit-newline / blank-line / weighted advances.
- icons.rs first_party_icon_font_names_all_resolve guards 27+
authored names against placeholder regressions.
File-cap discipline
- pen_doc_adapter.rs split into mod + path-bounds sibling + tests
sibling.
- icons.rs split into mod + icons_data.rs sibling so the catalogue
can grow without busting the cap.
- canvas_viewport_overlay.rs absorbs wrap_text + UniformBackend /
WeightedBackend test stubs.
Sub-modules
- vendor/jian advanced for `resolve_weight` numeric-string parsing.
Codex caught: `clipboard: Vec<Node>` survives Open with nodes
carrying NodeIds from the previous doc. Pasting them into the
freshly-loaded doc would re-introduce ids the new allocator
doesn't know about (or collide with freshly-loaded rows). Empty
the clipboard alongside the history + UI resets so paste in the
new doc starts from empty.
Codex caught: `apply_payload` only swapped pages + active page +
cleared selection — it left `history.past` / `history.future`
holding snapshots of the OLD doc, plus every UI slot that may
carry a `NodeId` from the old tree (pen_in_progress,
text_editing, layer_rename, color_picker target, property_focus,
agent_settings_drag, layer_context_menu). After Open the user
could Cmd+Z back into the previous doc, or a stale `pen_in_progress
= Some(NodeId)` from before the load would point at a non-existent
row on the next press.
Wipe history both directions and clear every NodeId-carrying UI
slot + drafts + open dropdowns so the loaded doc starts on a
clean slate.