Commit graph

1096 commits

Author SHA1 Message Date
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
Kayshen-X 87df5afcbb feat(shell): Save / Save As / Open document via .pen / .op dialog
Closes the largest TS parity gap (#1 / #2 in the audit): the
document was completely non-persistent — every restart lost the
canvas tree. Wire up native Save / Save As / Open through rfd
dialogs so the user picks the file path themselves (per the
audit conversation: "随意保存").

- new `crates/openpencil-desktop/src/persistence.rs`:
  - `DocPayload` / `PagePayload` / `NodePayload` / `StrokePayload`
    DTOs with serde derives (hand-rolled JSON shape so shell-core
    stays serde-free; Color / Rect / Point2D come from external
    crates that don't carry serde derives)
  - `to_payload` / `apply_payload` + `kind_to_string` /
    `str_to_kind` cover all 9 NodeKind variants including
    NodeKind::Other(String)
  - `save_as_dialog` / `open_dialog` use `rfd::FileDialog` with
    a single combined "OpenPencil" filter covering both `.pen`
    and `.op` extensions — both load via Open and save into
    either at the user's choice
  - `save_to_path` writes through a sibling `.tmp` + rename so a
    mid-write crash never leaves a half-written document on disk
  - `handle_save` / `handle_save_as` / `handle_open` package the
    rfd + state flow + title refresh so the desktop key handler
    stays a one-liner per shortcut
- new `WidgetHostNative::document()` / `document_mut()` accessors
  — `pub(in crate::widget_host)` field stays internal otherwise
- `DesktopApp` gains `current_path: Option<PathBuf>`; window title
  updates to `<filename> — OpenPencil` after Save / Open
- keyboard bindings: Cmd+S (save-in-place or fall through to Save
  As when no path), Cmd+Shift+S (force Save As), Cmd+O (open
  dialog). All three remain enabled when the settings modal is
  focused; bypass the modal-focused editor-shortcut block because
  they never type into the port field
- format spec: `{ version: 1, active_page_index: N, pages: [...] }`
  — bump `CURRENT_VERSION` + add a migration branch in
  `apply_payload` when the schema grows
2026-05-12 22:31:39 +08:00
Kayshen-X d63c957c3b fix(shell): settings port focus blocks editor shortcuts
apply_text already swallows non-digits while a settings input is
focused, but the desktop key handler dispatches editor shortcuts
(Cmd+D, Cmd+G, Cmd+Z, Cmd+A, arrow nudges, Delete, [ / ]) on
SEPARATE paths that bypass apply_text — so typing a `d` while
editing the port still duplicated the selected node, arrow keys
nudged it, etc.

- new `WidgetHostNative::settings_focus_active()` public helper
- `input_active()` already-private check also picks up the modal
  focus so single-letter tool switches gate cleanly
- desktop key handler reads `settings_focus_active()` once per
  event and stamps `&& !settings_focused` on every editor branch
  (Delete, arrows, Cmd-letter combos, Cmd-Shift-letter combos,
  bracket reorder). Cmd+, stays unguarded so the user can always
  toggle the modal itself.
2026-05-12 22:16:22 +08:00
Kayshen-X d109ed206e docs(shell): refresh crates/CLAUDE.md for v0.8.0 surface
Catch crates/CLAUDE.md up with the v0.8.0 work that landed across
the last few sessions:

- document/ split: add pen.rs, color_picker.rs, grouping.rs,
  page_mutators.rs to the split rationale
- UiState diagram: add settings_input_draft, agent_settings (focus,
  tab, connected, mcp_server, mcp_cli_enabled, images_*,
  hover_provider), color_picker, pen_in_progress + pen_cursor_doc,
  layer_context_menu, page_context_menu, property_tab
- Node: document corner_radius field + the round-rect rendering
  threshold; mention NodeKind::Path (multi-anchor polylines)
- RenderBackend: note new fill_svg_path primitive for brand logos
- Widgets table: add brand_icons, color_picker, layer_context_menu,
  agent_settings_* rows
- PropertyFocus: list PositionR as a wired variant
- New sections: Settings modal (Cmd+,), Settings input editing,
  Pen tool, Color picker (HSV), Layer right-click + drag-into-
  container, Hot-path optimizations (VecDeque history, static
  i18n, viewport culling, redraw scheduler, cursor coalescing,
  font cache prewarm), Native widget_host layout expanded to 8
  submodules including property_dispatch.rs + shortcuts.rs
2026-05-12 22:04:08 +08:00
Kayshen-X 82e91b9a4f fix(shell): settings port focus swallows non-digit keys
While the MCP port field was focused, only digits routed into the
draft and any other character fell through to the next handler —
so typing a letter would land in chat / rename / text-edit
(whichever happened to be active). Tighten both native + web
`apply_text`: while `agent_settings.focus.is_some()` swallow ALL
keys, accepting digits into the draft and returning false for
everything else.
2026-05-12 22:00:55 +08:00
Kayshen-X b3ff4cb435 fix(shell-web): wire MCP port keyboard input + defocus
The prior commit only added the FocusMcpPort *click* dispatch on
web — keyboard text/backspace/Enter/Escape routing was still
chat-only, so the port field would highlight on click but reject
every keystroke and never commit. Mirror the native path:

- `apply_text` / `apply_backspace` / `apply_send` / `apply_escape`
  on the web `WidgetHost` route to `settings_input_draft` whenever
  `agent_settings.focus` is set
- new `commit_settings_focus` helper parses the draft, clamps the
  port to ≥1024, writes it back, clears focus + draft
- Close / Outside / SelectTab / FocusMcpPort transitions in the
  web press dispatcher commit any pending draft before changing
  state, so typed values aren't silently dropped
2026-05-12 21:54:32 +08:00
Kayshen-X 53c75368ec fix(shell): web dispatcher handles FocusMcpPort
Native press dispatcher learned FocusMcpPort in the previous
commit but the web shell's parallel match was left without that
arm — a click on the port field on web would silently fall through
to no-op. Add the same focus + seed-draft flow on the web side
so cross-platform behaviour stays identical.
2026-05-12 21:48:19 +08:00
Kayshen-X 33b44de80f feat(shell): editable MCP server port in settings modal
The port field on the MCP tab was display-only — clicking did
nothing and the user couldn't pick a different port. Wire it to a
proper focus / draft / commit cycle:

- new `SettingsFocus` enum (currently just `McpPort`; OAuth client
  id/secret will follow) + `AgentSettings.focus: Option<SettingsFocus>`
- `UiState.settings_input_draft: String` holds the in-progress text
  (lives on UiState because `AgentSettings` is `Copy`)
- McpHit::FocusPort hit on the port-field rect, dispatched from
  the native press handler — seeds draft from current port value
- keyboard routing: digits-only via `apply_text` (cap 5 chars),
  `apply_backspace` pops, `apply_send` commits, `apply_escape`
  cancels (one Escape clears focus + draft, second closes modal)
- commit parses u16 and clamps to ≥1024 so the user can't pick a
  root-only port
- modal Close / Outside / tab-switch press all commit any pending
  draft before transitioning so a typed value isn't silently lost
- focused field gets a primary-tinted border + static caret bar
  past the digits
2026-05-12 21:40:24 +08:00
Kayshen-X 576c2fe4e1 fix(shell): pen-tool undo restores pre-pen state
`start_pen_path` was pushing the new Path node onto the page first
and only then calling `snapshot_for_history()` — the snapshot ended
up including the new node, so a subsequent undo restored "node
already present" and the path stayed on screen.

Take the snapshot before any mutation; finish_pen_path still
gates the push on `anchor_count >= 2` so single-anchor paths
don't pollute the undo stack.
2026-05-12 21:32:07 +08:00
Kayshen-X d69e9e2eaf feat(shell): editor v0.8.0 batch — layer panel, pen, color picker, brand icons, settings modal, corner radius, perf
restores the 3 reset commits (drag-into-container, layer right-click,
page menu) plus this session's new editor features + performance pass.

layer panel
- cross-parent drag-into-container reparenting with floating ghost
- right-click context menu on layer rows (rename / duplicate / delete /
  group / ungroup / lock / hide)
- right-click context menu on page tabs (rename / duplicate / delete)
- split layer_panel.rs into spine + walkers + paint + tests under
  the 800-line cap

editor features
- pen tool: NodeKind::Path multi-anchor polylines with rubber-band
  preview tracking cursor doc-coords
- HSV color picker overlay (Cmd-Shift-C or fill/stroke swatch click):
  sat/value box + hue strip + hex input, anchored HSV across
  RGB-rounding cycles
- Property panel: Design / Code tab toggle (Cmd-Shift-C) — new
  property_panel_code module
- corner radius: Node.corner_radius field + PropertyFocus::PositionR
  wired through snapshot/commit/seed; canvas Rect paint switches to
  fill_round_rect/stroke_round_rect when radius > 0.5 doc-px

brand icons
- new RenderBackend::fill_svg_path on both native (Canvas::draw_path
  Fill paint) and web backends
- widgets/brand_icons.rs with Claude / OpenAI / Gemini / Copilot
  filled-path glyphs (verbatim from apps/web/src/components/icons/*-
  logo.tsx) + paint_opencode_logo terminal-frame primitive
- agent provider cards swap Lucide approximations for real brand
  logos

settings modal (Cmd+,)
- 880×640 modal with sidebar nav (Agents / MCP / Images / System)
  + scrollable right pane + dim scrim
- Agents tab: 5 provider cards with brand logos, hover-to-reveal
  "断开连接" on connected cards, "+ 添加服务商 / + 添加 Agent" actions
  aligned + inset to clear close X
- MCP tab: server status card (running/stopped indicator + port +
  start/stop button) + 2×3 toggle grid for terminal CLI integrations
- Images tab: Image Search status + collapsible Advanced (Openverse
  OAuth client id/secret + Register link + Test) + Image Generation
  section with empty-state hint
- System tab: read-only Auto-update status card (no real updater
  backend yet — surfacing a togglable switch would lie to the user)
- new widgets/agent_settings_i18n.rs hand-maintained EN/ZH key
  table (~50 keys); generated i18n/{en,zh_cn,...}.rs untouched
- AgentProvider.subtitle_key() drops hard-coded Chinese subtitles
  + drops the fabricated "fini.yang@gmail.com" account line
- web shell paints + dispatches the modal alongside native
- cursor over modal stays on Default pointer (no Move on sidebar
  nav rows)

performance
- history VecDeque (O(1) pop_front, capped at 100) replaces O(n)
  Vec::remove(0)
- redraw scheduler: track dirty flag + skip paint when cursor-move
  produces no visible state change (kills first-click chip flicker)
- cursor-move coalescing: drain pending_cursor_move on
  RedrawRequested + press/release/right-press
- viewport culling: off-screen leaf nodes skip paint with 64px margin
  for stroke/handle overflow
- font cache prewarm: NativeBackend::new walks ~50 chrome CJK
  codepoints through FontMgr::match_family_style_character at startup
  → first cross-tab paint stops stuttering
- i18n returns &'static str (PropertyLabels Copy struct, ~19
  String allocs/frame removed)
- TopBar chip 12px/char + 16px-pad CJK-safe hit area

polish
- close X smaller (16×16) + section-action text right-inset to clear it
- Advanced chevron → lucide ChevronDown/Right (was Unicode v/>)
- Register link arrow → lucide ArrowUpRight (was Unicode ↗)
- INPUT_RADIUS 6→8 for more visible rounded inputs
- dot-status alignment on Image Search header (centred to status
  text optical centre, not title baseline)

testing
- 167 tests passing across shell-core (136) + shell-native (21) +
  document (6) + walkers (4)
- 800-line cap holds on every file
- native + web targets both build clean
2026-05-12 21:26:27 +08:00
Kayshen-X 2c6d7f5864 fix(shell): drag preview matches commit (codex stop-gate)
Codex caught a real bug: the drop indicator painted at one row,
but on release the source could land at a different row. When
dragging downward past other rows, the indicator was at row N
but the source ended up at row N-1, because `commit_layer_drag`
calls `extract_node` first (shifting other rows up by one) and
THEN inserts at the anchor — but `paint`'s drop_target_at was
computing the indicator y in the PRE-extract layout.

Fix: while a drag is active, build the LayerPanel with the
dragged source's entire subtree excluded. This mirrors the
post-commit layout, so the indicator y the user sees and the
y the source lands at are by construction the same.

- New `LayerPanel::from_document_with_drag_source(doc, source)`
  constructor; uses a new `walk_excluding` walker that skips
  the source's subtree.
- Native + web paint paths call the new constructor when
  `layer_drag.active`, computing drop_target against the
  trimmed layout AND painting the trimmed row stack (so the
  user sees the visual collapse mid-drag too).
- Native + web `commit_layer_drag` also build the trimmed
  panel for `drop_target_at` lookup, so preview and commit
  read from the same layout.
- New regression test `drop_indicator_matches_post_commit_
  layout_when_dragging_down`: drags A from top, drops before
  D, asserts `indicator_y` matches A's new row top in the
  rebuilt panel exactly.

136 tests pass (was 135); cargo fmt + boundary check clean;
zero files over 800.
2026-05-12 05:33:18 +08:00
Kayshen-X 48580f613c test(shell-core): paint-output integration test for multi-select
Codex flagged that `panel.capabilities()` assertions don't actually
prove `paint()` uses the result. Closed the loop with a real
paint-output test:

- Inline `CountingBackend` (RenderBackend impl) records draw_text
  and fill_round_rect call counts.
- `multi_select_paint_diverges_from_full_section_paint` paints
  a multi-select panel + a single-select Frame panel through it
  and asserts the op counts differ. If `paint()` regressed to
  bypass `capabilities()` and call `for_kind(Frame)` for the
  multi case, the two paints would emit identical ops and the
  test would fail.

Floor sanity: asserts the multi panel still emits ≥ 6 text ops
(headers + Position/Size labels) and ≥ 1 round-rect (Size section
W/H inputs). Without the multi carve-out the Size section was
hidden, so this directly catches the bug codex caught earlier.

135 tests pass; cargo fmt + boundary check clean.
2026-05-12 04:06:42 +08:00