Commit graph

857 commits

Author SHA1 Message Date
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
Kayshen-X a8561f55ce feat(shell): Component / ComponentLibrary data types (P1 — preserve)
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.
2026-05-14 14:57:49 +08:00
Kayshen-X 012042559d chore(shell-native): trim input.rs verbose comments (886 → 878)
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.
2026-05-14 14:53:42 +08:00
Kayshen-X 490bb86b33 feat(shell): wire Document.var_table + canonical loader populates from PenDocument
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.
2026-05-14 14:52:24 +08:00
Kayshen-X 21609664c0 chore(desktop): main.rs back under 800-line cap
Compact long comment blocks in the event-loop dispatcher. Behavior
unchanged — every trim is documentation, no logic touched. Drops:
  - ExportImage dialog-open setup: 4 lines → 1
  - Named-key shortcut preamble: 6 lines → 1
  - Cmd/Ctrl-letter shortcut preamble + Cmd+Alt boolean op comment:
    9 lines → 1
  - Cursor-move coalesce explanation: 7 lines → 1
  - Drain-before-release explanation: 4 lines → 1
  - Wheel routing explanation: 4 lines → 1

Codex stop-gate finding #3 (this file at 828 lines) addressed:
new size 799 / cap 800. `widget_host/input.rs` at 886 is the
remaining cap regression (task #49 sibling-module split tracked).

Tests: 184 shell-core + 19 shell-native still pass.
2026-05-14 14:48:47 +08:00
Kayshen-X a6bafb4453 fix(shell): boolean ops nested-source removal + drop dead export rect helper
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.
2026-05-14 14:46:28 +08:00
Kayshen-X 57931d6a82 feat(shell): VariableTable lookup + resolve helpers
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.
2026-05-14 14:35:09 +08:00
Kayshen-X 52e83b061d feat(shell): variables + themes data model (P1 — preserve + resolve)
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.
2026-05-14 14:33:00 +08:00
Kayshen-X f71e696e0b feat(desktop): wire Cmd/Ctrl+Alt+U/S/I/X to path boolean ops
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.
2026-05-14 14:29:30 +08:00
Kayshen-X e01b4eda69 feat(shell): expose boolean op as WidgetHostNative method
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.
2026-05-14 14:27:26 +08:00
Kayshen-X 05ddceca46 feat(shell): boolean path ops (Union / Subtract / Intersect / Exclude)
#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.
2026-05-14 14:26:33 +08:00
Kayshen-X cdaf3bd84a feat(shell): visible per-anchor handles on selected Path + Pen tool
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.
2026-05-14 14:22:36 +08:00
Kayshen-X 0ed2d142d8 fix(shell): anchor click-without-move pollutes undo; PDF uses uniform page size
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.
2026-05-14 14:20:27 +08:00
Kayshen-X 6ab5dd9b66 feat(shell): path-anchor drag-to-edit (Pen tool)
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.
2026-05-14 14:12:11 +08:00
Kayshen-X 8f7b75cdec feat(shell): set_path_anchor_position mutator (groundwork for drag-edit)
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.
2026-05-14 14:08:01 +08:00
Kayshen-X 6d0d8d31cd feat(shell): PDF multi-page export via skia built-in backend
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.
2026-05-14 14:06:18 +08:00
Kayshen-X 5503eefeb1 feat(shell): export dialog + multi-format raster (PNG/JPEG/WEBP) + Property-panel preview
Phase 1 — codec plumbing:
  - RasterFormat::{Png, Jpeg, Webp} enum.
  - export_raster(doc, target, format, scale) with NaN-guarded scale
    clamp; JPEG forces white background (no alpha); quality 100/92/92
    matches TS canvas.toDataURL.
  - File dialog filters expanded from {PNG, SVG} to {PNG, JPEG, WEBP, SVG}.
  - 6 unit tests cover format dispatch + alpha matrix + byte
    signatures + scale clamp.

Phase 2 — ExportDialog modal:
  - widgets/export_dialog.rs (new, ~330 lines). 5 format pills + 3 scale
    pills + Cancel / Export buttons. ExportFormat::is_implemented gates
    PDF off until Phase 4 ships real emit (codex stop-gate concern:
    PDF pill was selectable but always errored).
  - File menu "Export Image…" opens the modal first; the dialog's
    Export button queues new FileAction::ExportImageConfirm which uses
    Document.ui.export_format + export_scale.
  - Native widget host: scrim + paint after figma-import modal,
    top-most-modal hit-test slot before file-menu / canvas, Escape
    closes the dialog.
  - persistence.rs branches Confirm path on ui.export_format: PNG/JPEG/
    WEBP via export_raster, SVG via export_svg, PDF returns an
    explicit "not yet implemented" error (unreachable from UI now).
  - 8 unit tests (format coverage, hit-test per pill, button rects,
    centred-on-viewport, in-dialog contains, scale round-trip).

Phase 3 — Property panel preview:
  - paint_export_section shows live ui.export_format / export_scale
    instead of hardcoded "1x" / "PNG" placeholders.
  - PropertyPanel struct gains export_format / export_scale; populated
    by build_from_snapshot.
  - PropertyPanelAction::OpenExportDialog routes to FileAction::
    ExportImage on both native + web dispatchers.

Codex review: 2 rounds. Round 1 (Phase 1): NaN guard + nicer error
strings landed in-flight. Round 2 (Phase 2): 3 CONCERNs (Escape
unwired, PDF papercut, file-menu/dialog z-order race) + 2 NITs
(rect_contains half-open bounds, file caps) all addressed.

Tests: 195 total (177 shell-core + 12 shell-native + 6 desktop). wasm32
build clean.

Follow-ups: PDF emit (Phase 4), Property-panel section hit-test for
single-click open, input.rs / main.rs over-cap split.
2026-05-14 14:02:52 +08:00
Kayshen-X e1902ad047 feat(shell): align + distribute toolbar (multi-select-aware)
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.
2026-05-14 13:27:44 +08:00
Kayshen-X 1a194efc3d feat(shell): canonical .op loader + jian-core layout + visual fidelity pass
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.
2026-05-14 09:26:08 +08:00
Kayshen-X d2f0bc1265 fix(shell): Open empties cross-doc clipboard
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.
2026-05-12 22:46:00 +08:00
Kayshen-X 3f77778c9e fix(shell): Open resets undo history + stale UI state
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.
2026-05-12 22:37:54 +08:00