Commit graph

59 commits

Author SHA1 Message Date
Kayshen-X 0948e97de9 feat(panels,canvas): editable gradients/effects + SVG/image import + locale-aware dialogs
Continues the gradient + property-panel polish from the previous
commit and rounds out two new flows the TS app already has:

Gradient stops + effects:
- ColorTarget gains GradientStop(i) + EffectColor(i); HSV picker
  preserves alpha across hue/SV drags so a transparent stop stays
  transparent. Hex pill stays 6-char; alpha is reattached at commit
  and the swatch sits on a 2x2 alpha checker so #00000000 reads as
  transparent rather than empty.
- Effects section reflowed into card-style blocks (image #9 spec):
  title + minus, X/Y and Blur/Spread 2-col grids, color row with
  swatch + rgba(...) text; clicking the swatch opens an HSV picker
  bound to that effect index via SetEffectColor.
- Press dispatch on both hosts anchors picker overlays at the
  clicked y so they pop adjacent to the swatch instead of the top.

Image + SVG import (toolbar + Fill section "图片" row):
- New FileAction::ImportImageOrSvg / PickFillImage; persistence_image
  pops rfd, decodes raster as data: URL, inserts an Image node or
  rewrites the selected node's primary fill.
- ImageNode actually renders on the canvas: NodePayload + SceneNode
  carry image_src, canvas_viewport_paint.rs decodes the data URL
  once and hands raw bytes to RenderBackend::draw_image with a
  src-hash cache id. Grey placeholder paints only when decode fails
  so transparent PNGs don't get a grey matte underneath.
- SVG import ported to TS-parity (packages/pen-engine svg-parser):
  recursive <g> tree walk with inherited fill/stroke/style="...",
  viewBox-aware scaling with maxDim cap, multi-subpath split, raw
  d preserved on PathNode. Imports land wrapped in a Group named
  after the source file.

Locale-aware first run:
- settings_io detects the OS locale (LC_ALL/LANG/LC_MESSAGES with
  zh-Hans/zh-Hant heuristics) and seeds editor_ui.locale before
  settings.json is read; persisted user choice still wins.
- macOS bundle declares CFBundleLocalizations + AllowMixedLocalizations
  so NSOpenPanel / NSSavePanel render in the same language as the
  rest of the chrome.

Web host kept exhaustive across the new variants (PickFillImage,
OpenEffectColorPicker, ColorTarget::EffectColor, GradientStop). Two
new files: persistence_image.rs (file-pick handlers, ≤120 lines) and
svg_path_data.rs (path-d tokenizer + bbox + normaliser, split from
svg_import.rs to stay under the 800-line cap). 277 op-editor-core
tests pass.
2026-05-23 23:11:38 +08:00
Fini 8cff5abaf5 feat(ai): implement op-design-lint Rust crate (S1)
Port the pen-ai-skills diagnostics layer to a new pure Rust crate
`op-design-lint`: 14 design-lint detectors, the detect_all aggregator,
apply_fixes / detect_and_fix, and golden parity tests against the TS
oracle. Wire it into op-mcp as the read-only debug_validation_report
tool, gated by OPENPENCIL_DEBUG_TOOLS=1.

Detectors: empty_paths, unexpected_rotation, excessive_frame_effects,
invisible_containers, text_explicit_heights, text_effect,
text_corner_radius, text_stroke, text_bg_contrast, edge_section_padding,
stacked_horizontal_padding, sibling_inconsistencies (+ check_consistency),
detect_all.

Also includes: node_util shared helpers + pen-core color/visibility
ports, node_mut field accessors, set_property issue->node mutation
dispatch, golden fixture corpus + TS dump script, structural-parity
test, a CI golden-drift guard, and the gitignore fix so the fixture
docs/ dir is tracked.

This branch's per-commit history was squashed: the original 28 commits
carried fabricated timestamps and could not be honestly reconstructed,
so the work is recorded as a single commit at its real completion time.
2026-05-23 18:39:08 +08:00
Fini 3ff8d92601 Merge branch 'v0.8.0-new' of github.com:ZSeven-W/openpencil into v0.8.0-new 2026-05-23 14:29:26 +08:00
Kayshen-X 2efca5aa3b feat(topbar): sun/moon theme toggle icon + traffic-light reposition fixes
Theme-toggle button now paints a Sun glyph in dark mode (click → light) and a Moon glyph in light mode (click → dark); the Sun icon was hardcoded before. Adds Icon::Moon (lucide crescent) and threads theme_mode into TopBar.

Bumps the casement submodule with a fix for the native macOS traffic-light reposition: idempotent absolute placement against resize, baseline invalidation on fullscreen exit, and a poison guard so a transitional re-capture can't drop the lights below their default position.
2026-05-23 12:59:02 +08:00
Fini 7f5152ac07 feat(orchestrator): concurrent executor — semaphore + serialized replay
Task B2 of S3b-2: implements `run_concurrent` — the concurrent executor
that drives screen-group workers via a tokio Semaphore (RAII permits,
FIFO-fair, cap = effective_concurrency), collects per-worker buffered
EditorCommands, replays them into the real DocSink in subtask-plan-index
order, and fan-ins Progress events via mpsc channel.

Also splits concurrent.rs test block into concurrent_tests.rs (STEP 0)
to keep both files under the 800-line ceiling, wired via
`#[path = "concurrent_tests.rs"] mod tests` (same pattern as plan_repair).

Updated run_screen_group_worker signature: now takes Arc<Semaphore> +
mpsc::UnboundedSender<Progress> instead of &mut dyn FnMut(Progress).

Added CountingLlm to test_support for semaphore-cap verification.
Added tokio (sync + rt + macros) to op-orchestrator Cargo.toml.

208 tests pass; clippy -D warnings clean; fmt clean.
concurrent.rs: 432 lines; concurrent_tests.rs: 727 lines.
2026-05-23 04:06:51 +08:00
Fini 599e9b4ffa feat(orchestrator): run — planning mode-rotation retry loop
Replace the single hardcoded-Rich planning call with the mode-rotation
retry loop (S3b-1b Task C2). Resolves tier via model_profile, loops
attempt_modes(tier) ([Rich]/[Rich,Minimal]/[Rich,Minimal,Compact]),
handles stream-error rotation, abort short-circuit, parse failure
continue/fall-through, compact forced_style_guide_name backfill, and
exhaustion → build_fallback_plan. Adds tracing dep for warn! diagnostics.
2026-05-23 02:48:00 +08:00
Fini 1fa2d9415a Merge branch 's3a-orchestrator' into v0.8.0-new 2026-05-23 01:27:30 +08:00
Fini b1c3136deb feat(editor): EditorCommand::InsertSubtree — nested PenNode subtree insert
Existing EditorCommand variants are leaf-only (BatchInsertItem carries
only kind/name/x/y/w/h/fill_hex). The design orchestrator (S3a) must
apply rich nested designs — frames with children, layout, text — so
add InsertSubtree { nodes: Vec<PenNode>, parent_id }.

cmd_insert_subtree validates the parent is a container (or NONE = page
root), remaps every incoming node id to a fresh editor id via
remap_subtree_ids (so an externally-authored subtree can't collide
with live ids), and appends under the parent. The apply arm wraps it
in a history snapshot so the insert is one undo step.

NOT verified locally: op-editor-core does not currently build —
vendor/jian is pinned to unpushed commit 80121906 whose DesignMd*
types op-editor-core depends on are absent from every available jian.
The 8 InsertSubtree tests in command_subtree_tests.rs run once the
jian build is restored.

S3a Plan A.
2026-05-22 22:56:10 +08:00
Kayshen-X a550af4e7f feat(figma): wire .fig binary import into the desktop host
Add op-figma as a dependency and implement FileAction::ImportFigma — an rfd .fig picker parses the binary file via parse_fig_binary and re-seeds EditorState.

run_action now returns a 3-state ActionOutcome instead of a bool: an import returns PathChangedUnsaved so it is treated as unsaved work (close still prompts) while the Git session is rebound to the now-pathless document. mark_document_saved is split so the rebind can run without refreshing the dirty baseline.
2026-05-22 22:55:47 +08:00
Kayshen-X b979b97b54 feat(desktop): set the Dock name + icon for the dev binary
Run bare, the non-bundled binary shows in the Dock as the raw
`openpencil-desktop` executable name with a blank icon — no
`Info.plist` to read `CFBundleName` / the icon from.

New `macos_app::apply()` sets both at startup via objc2:
`NSProcessInfo::setProcessName` for the Dock / menu-bar name and
`NSApplication::setApplicationIconImage` (from an embedded
`assets/icon.png`) for the Dock tile. `[package.metadata.bundle]`
also gains `icon`, so a packaged `.app` carries it natively.

objc2-app-kit / -foundation are pinned to the 0.2 line winit /
casement already lock.
2026-05-22 16:13:00 +08:00
Kayshen-X f1c45d6b9c feat(chat): OS clipboard copy/paste for the chat input
Cmd+C / Cmd+V / Cmd+X did document node-clipboard ops regardless
of focus, so there was no way to paste a prompt into the AI chat
input. They now branch on chat focus: with the chat input focused
they read/write the OS text clipboard (`arboard`), otherwise the
node clipboard as before.

- `clipboard.rs` — thin best-effort arboard wrapper.
- `WidgetHostNative::chat_input_paste` / `chat_input_cut` — append
  / cut on the focused chat buffer.
2026-05-22 11:40:30 +08:00
Kayshen-X 6e012049e4 feat(editor): add Design-MD panel and complete i18n coverage
Design-MD panel — closes the last TS-vs-Rust parity gap:
- New floating op-editor-ui design_md_panel + design_md_markdown
  renderer: collapsible theme/colour/typography/component/layout/
  notes sections, colour swatches, inline markdown highlighting.
- op-editor-core: parse_design_md parser + panel UI state.
- Host: View-menu toggle, top-most paint + hit-test, drag,
  .md import/export; bumps the vendor/jian DesignMdSpec pointer.

i18n coverage:
- Git panel, merge/conflict/error dialogs and GitError messages
  routed through op-i18n; git locale tables split into
  <base>_git.rs under the 800-line cap.
- Closed 10 app-wide hardcoded-string gaps (AI chat, Figma import,
  file menu, layer context menu, property-panel fill, update /
  file-picker / load-error dialogs, accesskit labels).
2026-05-19 22:29:22 +08:00
Kayshen-X 64a5934dcf feat(desktop): add op-git crate for in-app version control
Drives the user's installed `git` via std::process::Command — the
same approach as the TS app's git-sys backend — so no libgit2/git2 C
dependency enters the workspace.

Covers repo discovery / init, working-tree status, staging, commit,
branch list / create / switch, history + diff, remotes, SSH keys and
credential storage. Adds a worktree-isolated merge orchestrator: a
branch merge runs in a throwaway detached worktree so a conflicting
.op merge never writes conflict markers into the live document — a
clean merge is fast-forwarded back, a conflicting one is reported as
a file-granular ConflictBag with the live tree left pristine.
2026-05-18 21:45:00 +08:00
Kayshen-X e775fd49c4 feat(desktop): winit shell platform integration
Add five native-platform features to the winit desktop host
(op-host-desktop), closing the gap with the Electron app:

- Native menu bar (muda) — File / Edit / View / Help plus the macOS
  app menu; selections route to the same host actions the keyboard
  shortcuts use. Gated to macOS / Windows — muda needs GTK, which
  this winit build does not link, so Linux keeps the in-canvas File
  menu.
- Auto-update — a background probe of the GitHub releases API
  reports status into the settings System tab; a found update
  offers to open the download page, and a "Check for Updates" menu
  item re-runs the probe.
- File association — argv parsing opens a .op / .pen document on
  launch; [package.metadata.bundle] declares the OS-level handler.
- Window-state persistence — position / size / maximized restore
  across restarts, with an off-screen guard for monitor changes.
- Drag-and-drop — dropping a .op / .pen file opens it.

Codex review round 1 findings (1 MAJOR + 3 MINOR) all addressed:
monitor-aware restore, failed-startup geometry guard, single-flight
update probe, case-insensitive extension match.

Also sink the agent-settings modal's hand-maintained EN/ZH string
table into the canonical 15-locale op-i18n tables, so the settings
chrome (including the new auto-update strings) is fully translated;
agent_settings_i18n.rs is now a thin op-i18n adapter.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-18 08:29:36 +08:00
Kayshen-X 4adfa38bae feat(ai): chat image attachments + per-provider thinking/effort
ChatRequest gains an attachments field; ChatState carries a per-turn
thinking mode, effort level and staged attachments (capped at 4 files /
5 MiB, attachment-only sends allowed). The chat panel grows a controls
row (thinking / effort / attach) and a dedicated attachment-chip row,
with hit-test and paint sharing one input-block origin.

All five providers consume the knobs — Claude maps thinking onto the
SDK token budget, Copilot onto reasoning_effort, the CLI / built-in
transports prepend an in-band directive, the HTTP transport adds body
fields; attachments spill to a private per-turn temp dir (cleaned on
drop) or inline base64. Also wires op-ai-skills into the built-in
provider and op-acp in as the AcpProvider chat backend.
2026-05-17 22:44:28 +08:00
Kayshen-X 155f9daee7 feat(figma): wire parse_fig binary pipeline + image resolver
Stage G — the binary `.fig` parser is now end-to-end functional;
`parse_fig` no longer returns NotYetImplemented for binary input.

- image_resolver.rs: resolve_image_blobs walks the converted document
  and replaces `__blob:N` / `__hash:HEX` image-fill placeholders with
  base64 `data:` URLs (MIME sniffed from magic bytes).
- lib.rs: new `parse_fig_binary(bytes, name, layout_mode) -> FigImport`
  runs the whole pipeline — container split → Kiwi decode → tree
  build → node conversion → image resolution. `parse_fig`'s Binary
  arm delegates to it; `FigParseError::NotYetImplemented` retired in
  favour of `Binary(String)`. Entry points re-exported.
- binary_e2e_tests.rs: assembles a real fig-kiwi container from
  scratch (hand-built Kiwi schema + data chunks, deflate-compressed)
  and asserts the full pipeline yields a PenDocument with the
  rectangle at the right position/size + canonical-JSON round-trip.

op-figma 83 tests green (+6); clean build, no warnings.

Closes the §2.1 P0 gap — Figma binary `.fig` parsing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-17 16:23:19 +08:00
Kayshen-X 3fd1f20540 feat(figma): binary .fig container + decompression layer
First stage of the Figma binary `.fig` parser port (op-figma was
clipboard-JSON only; binary returned NotYetImplemented).

- zip_reader.rs: hand-rolled minimal ZIP reader — EOCD scan, central
  directory walk, store (method 0) + raw-deflate (method 8) entries.
  Avoids the full `zip` crate dependency tree. 512 MiB per-entry cap.
- container.rs: ports `fig-parser.ts::figToBinaryParts` — unwraps the
  ZIP archive form (`canvas.fig` + `images/*`), verifies the
  `fig-kiwi` magic, splits length-prefixed chunks, decompresses each
  via the deflate→zstd→raw fallback chain (PNG payloads passed
  through). Output: decompressed parts + embedded image map.
- deps: flate2 (miniz_oxide pure-Rust backend), ruzstd, base64 — all
  license-clean + wasm32-buildable.

Modules are `allow(dead_code)` until `parse_fig` is wired in the
final stage. op-figma 23 tests green (+6).

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-17 15:50:15 +08:00
Kayshen-X c18ad5a776 feat: close six TS-parity gaps in the Rust shell
Closes six verified gaps from the 2026-05-17 TS-vs-Rust gap analysis,
each build- and test-green:

- editor: SetNodeFlip + SetEllipseArc commands (+ set_node_flip /
  set_ellipse_arc MCP tools) — schema already had the fields, only
  the command path was missing.
- export: export_node_raster crops a raster to one node's bbox;
  File -> Export is now selection-aware (single selection -> layer).
- editor: SVG import — hand-rolled parser (shapes + path M/L/H/V/
  C/S/Q/T/Z) in svg_import.rs; cubic curves flatten to dense straight
  anchors at import time so the renderer/pen-tool stay on their 1:1
  straight-segment model. + EditorCommand::ImportSvg + import_svg tool.
- mcp: HTTP transport — mcp_serve::run_http serves MCP over a
  TcpListener (--mcp-http <port> <path>); process_message is shared
  with the stdio path.
- cli: new op-cli crate (binary `op`) — a dependency-free HTTP MCP
  client driving every tool via `op <tool> key=value...`.
- ai: ChatRequest gains thinking/effort fields (ThinkingMode /
  EffortLevel, defaults Adaptive/Low to match the TS runtime config).

MCP tool catalog 77 -> 80. Oversized files split to honour the
800-line cap (svg_import, export, mcp_serve, adapter).

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-17 13:03:11 +08:00
Kayshen-X 4b8e0ce956 refactor(rust): add op-app composition-root crate
Phase 7.3 strangler reorg — add op-app, the thin crate that names the
editor application's composition root.

Investigation found no shared host bootstrap left to extract: the
editor-UI composition (widgets, theme, layout scene) already lives in
op-editor-ui, and each host (op-host-native / op-host-web) owns only
platform-specific backend wiring. So per YAGNI op-app stays thin — it
re-exports op-editor-ui plus the per-platform host entry point behind
its target cfg, and documents the composition. If real cross-host
wiring later emerges, it lands here.

Builds green on both native and wasm32-unknown-unknown.
2026-05-16 23:57:03 +08:00
Kayshen-X 8733f45e2d refactor(rust): dissolve openpencil-shell-core re-export shim
Phase 7.3 strangler reorg — the final consumer (op-pen-loader) is
repointed off the openpencil-shell-core shim onto op-editor-ui (the
real source crate for the layout scene / scene-var / render-backend
facade), then the shim crate is deleted.

- op-pen-loader: openpencil-shell-core dep -> op-editor-ui;
  every openpencil_shell_core:: path -> op_editor_ui::
- git rm crates/openpencil-shell-core/ (lib + jian.rs module + the
  two re-export anchor tests, all superseded by op-editor-ui's own
  surface; the jian.rs module had no consumers outside the shim)
- stale shell-core / shell-native comment refs in op-editor-core +
  op-host-desktop manifests updated
2026-05-16 23:54:33 +08:00
Kayshen-X 1e0b3cab7a refactor(rust): rename native + desktop hosts to op-host-* crates
Phase 7.3 strangler reorg — rename the native widget host and the
desktop runner crates to the op- prefix. The desktop+native merge was
declined: keeping the library / binary split preserves the mobile-
checkable op-host-native lib (cargo check -p op-host-native on iOS /
Android, relied on by check-jian-boundaries.sh + the CI mobile job),
which a folded-in winit binary would break. A clean separate rename is
purely mechanical and the brief permits it.

- openpencil-shell-native -> op-host-native (lib op_host_native)
- openpencil-desktop -> op-host-desktop crate; the shipped executable
  keeps the stable openpencil-desktop [[bin]] name so release
  artifacts + external CLI integrations are unaffected
- every openpencil_shell_core:: path -> op_editor_ui::
- every openpencil_shell_native:: path -> op_host_native::
- doc-comment / manual-smoke note refs updated
2026-05-16 23:49:58 +08:00
Kayshen-X 65c2aa055a refactor(rust): rename openpencil-shell-web host to op-host-web
Phase 7.3 strangler reorg — rename the web widget host crate to the
op- prefix. The crate's openpencil-shell-core dependency is repointed
to op-editor-ui (the real source crate for the widget facade / theme
/ layout scene / scene vars / render-backend / gesture types).

- crate name: openpencil-shell-web -> op-host-web
- lib name: openpencil_shell_web -> op_host_web
- every openpencil_shell_core:: path -> op_editor_ui::
- native skia.rs include_bytes! path follows the moved assets dir
- smoke harness + lib doc-comment refs updated
2026-05-16 23:42:49 +08:00
Kayshen-X 43cb0fdf7e chore: sync Cargo.lock for the op-* crate extractions
Record the op-codegen / op-figma / op-mcp / op-ai / op-editor-ui
package entries in the workspace lockfile.
2026-05-16 23:35:10 +08:00
Kayshen-X e9960e6944 refactor(editor): make EditorState the web host's single source of truth
Mirror the native host migration for `openpencil-shell-web`. The web
`WidgetHost` now holds an `op_editor_core::EditorState` authoritatively
instead of a shell-core `Document`. The ~30 shared widgets stay
`&Document`-bound and read-only — they are fed a derived `paint_doc`
snapshot rebuilt lazily by `refresh_paint_doc()` whenever
`editor_state_dirty` is set.

- Every mutation routes through an `op-editor-core` mutator + flags the
  dirty bit; the snapshot re-derives once before paint / before any
  hit-testing input event.
- Hit-test-then-mutate handlers refresh, hit-test on `&paint_doc`,
  extract owned results, then mutate `editor_state`; shell-core hit
  enums translate via `op_pen_loader::rev::*`.
- `paint` takes `&mut self` to drain the cache at the top of the pass.
- `op-editor-core` / `op-pen-loader` are optional deps behind the
  `skia` feature (matching `skia-safe`) so the skia-free wasm32 CI stub
  baseline stays clean — `op-pen-loader` pulls `jian-skia` transitively.
- Fix two pre-existing `--features skia` build blockers in `lib.rs`
  (missing `Performance` web-sys feature, `canvas` moved before reuse).

No web-editor behaviour change — feature parity preserved.
2026-05-16 20:13:42 +08:00
Kayshen-X f0bb56b6cd refactor(editor): make EditorState the native host's single source of truth
Flip WidgetHostNative off shell-core's Document onto
op_editor_core::EditorState. The host now owns one authoritative
state; every paint pass + hit-test reads a lazily-derived,
read-only Document snapshot (`paint_doc`), rebuilt via
op-pen-loader's pen_document_to_document + apply_editor_state_ui
whenever an `editor_state_dirty` flag is set.

Why: removes the dual-state strangler scaffold so the canonical
PenDocument model is the only editable state — input handlers
mutate EditorState, widgets stay read-only over the derived
Document. .op save now serializes editor_state.doc; load seeds
EditorState::from_document. shell-core's Document becomes a
paint-only target.

- op-editor-core: add host_support (EditorState::sample,
  create_node_for_tool, replace_paths_with_polyline) + re-export
  VariableScalar/VariableKind.
- shell-native: rewrite all 8 widget_host submodules onto
  editor_state; split press.rs -> press_helpers.rs and keyboard
  click routing -> click.rs to stay under the 800-line cap;
  boolean_ops becomes a pure compute_boolean_op committed back
  through EditorState.
- desktop: persistence / settings_io / chat_session /
  model_discovery / main / frame move onto editor_state accessors.
2026-05-16 19:29:34 +08:00
Kayshen-X a79dbaf0e3 feat(op-editor-core): close variable/theme gaps for the host migration
Three additive variable-handling fixes so the editor host can migrate
onto EditorState as a mechanical port:

- Gap 1: add `variable: Option<String>` to ColorPickerState, port
  `open_color_picker_for_variable`, and route `color_picker_set_hsv` /
  `close_color_picker` through `set_variable_color` when set.
- Gap 2: add `op_pen_loader::editor_state_var_table(&EditorState)`,
  folding persisted variables/themes plus the transient active-theme
  selection + ref caches into a shell-core VariableTable. Lives in
  op-pen-loader (not op-editor-core) to keep op-editor-core wasm-clean
  and free of any shell-core dependency.
- Gap 3: confirmed undo/redo of variable create/delete/rename round-
  trips for free (EditorSnapshot clones the whole PenDocument); added
  tests to lock it in.
2026-05-16 18:19:35 +08:00
Kayshen-X 77f89b6ef8 refactor: extract op-pen-loader (canonical .op → Document) into a shared crate 2026-05-16 18:04:46 +08:00
Kayshen-X ec5ac70118 feat(op-editor-core): extend EditorState into a full state superset of Document 2026-05-16 17:47:53 +08:00
Kayshen-X b9d7032474 refactor(host): scoped EditorState bridge on WidgetHostNative
Phase 6 strangler scaffolding: WidgetHostNative now holds an
op_editor_core::EditorState alongside the legacy shell-core Document
so the ~30 widgets can migrate onto the canonical model one group at
a time while the workspace stays build-green.

The bridge is intentionally minimal — Document remains the source of
truth (every un-migrated widget paints from it and every apply_*
mutates it), and editor_state is not yet wired into paint or input.
There is no Document -> PenDocument converter, and the desktop
pen_doc_adapter only goes the other way (baking flex layout into AABB
rects, which is irreversible), so a per-frame round-trip would be
unsound. Each later 6.x task adds its own per-group sync point as it
switches a widget group's paint/input onto editor_state.

Field, accessors and dependency are all marked TEMPORARY — deleted in
Phase 7 when Document dies and editor_state becomes the host's only
state.
2026-05-16 17:30:44 +08:00
Kayshen-X 39162ad08e refactor(mcp): port MCP server onto op-editor-core EditorCommand
Port the in-process MCP server off shell-core's legacy `Document` /
`McpCommand` onto `op_editor_core::EditorState` / `EditorCommand`. The
module physically stays inside `openpencil-shell-core` (an `op-mcp`
crate extraction is a later Phase-7 task).

- Read tools snapshot `EditorState` (canonical `PenDocument`); write
  tools emit `op_editor_core::EditorCommand` applied via
  `EditorState::apply`. Node ids are now canonical `.op` schema
  strings, not the old `u64`.
- Component commands surface a clean `ToolFailed` "known gap" error —
  `op-editor-core` has no component registry yet. Same for
  `set_node_collapsed` (`NodeFlag::Collapsed` has no schema field).
- `mcp_serve.rs` loads the `.op` file straight into an `EditorState`
  (plain `jian-ops-schema` deserialization) and saves the
  `PenDocument` back on every successful write.
- Delete the orphaned legacy apply path (`document/mcp_apply*.rs` +
  `Document`/`VariableTable::apply_mcp_command`); the widgets that use
  `Document` are untouched.
- Split `tools.rs` → `tools.rs` + `read_tools.rs` and
  `component_tools.rs` → `component_tools.rs` + `page_tools.rs` to
  hold the 800-line cap.
2026-05-16 17:07:42 +08:00
Kayshen-X 0174aba93a refactor(render): move RenderBackend trait into op-editor-core 2026-05-16 14:36:09 +08:00
Kayshen-X b67fe5fbcc chore: scaffold op-editor-core crate 2026-05-16 14:32:53 +08:00
Kayshen-X 08686c3b95 refactor(i18n): extract op-i18n crate (with Locale) from openpencil-shell-core 2026-05-16 14:13:38 +08:00
Kayshen-X 5b6cf39775 chore: vendor anthropic-agent-sdk out of crates/ 2026-05-16 13:14:20 +08:00
Kayshen-X 0a90f13aae chore: delete dead pen-* stub crates + openpencil-app 2026-05-16 12:45:07 +08:00
Kayshen-X 76c598a23d feat(ai): adopt the official github-copilot-sdk for the Copilot bridge
Replace the in-workspace copilot-sdk fork with the official
`github-copilot-sdk` crate (crates.io 0.1). Rewrite chat_copilot.rs
against its API: Client::start + a streaming SessionConfig whose
SessionHandler forwards `assistant.message_delta` / `session.error`
events into the ChatProvider channel, one client+session per turn.
Delete the crates/copilot-sdk fork directory. Build + 72 desktop
tests pass on the Rust 1.94 toolchain.
2026-05-16 10:26:44 +08:00
Kayshen-X 8e0309a676 feat(desktop/chat): HttpServerProvider for Codex + OpenCode serve mode
Closes the fourth chat-backend category from the project_agent_runtime
memory. Implements user direction "opencode 和 codex 我们调用 http
server, 通过 ipc 启动本地的 server 模式" — spawn the CLI as a local
HTTP server then POST chat requests to its bound port.

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

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

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

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

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

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

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

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

Total openpencil-desktop tests: 55 (was 48 before this commit).
2026-05-14 16:57:14 +08:00
Kayshen-X 5dac22a76e feat(desktop/chat): Claude Code adapter via anthropic-agent-sdk
First of the per-CLI ChatProvider adapters that replace the
hand-rolled stream-JSON parser in chat_subprocess.rs. This one wires
`anthropic_agent_sdk::query` (the in-workspace fork of
bartolli/anthropic-agent-sdk) into the OP chat-panel plumbing.

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

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

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

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

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

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

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

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

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

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

Next: replace the hand-rolled subprocess parser in chat_subprocess.rs
with thin per-CLI adapters that route Claude Code through
`anthropic_agent_sdk::SubprocessTransport` and Copilot through
`copilot_sdk::Client + Session`. Gemini stays on the generic stdin
bridge until an upstream Rust SDK exists. Codex + OpenCode get an
HttpServerProvider that spawns `<bin> serve` then connects via a
local HTTP client.
2026-05-14 16:49:17 +08:00
Kayshen-X 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 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 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 db69fc5fc7 refactor(shell): promote inspector_window to openpencil-desktop binary crate
The native runner outgrew the `examples/` slot — it owns DPI tracking,
caret-blink animation timer, panel-resize cursor, the full Cmd+wheel /
PinchGesture / Pixel/LineDelta dispatch table, etc. None of that is a
sample, so it's been promoted to a real crate.

* New crate `crates/openpencil-desktop/` with a single `[[bin]]`
  target. Depends on `openpencil-shell-native` (lib) + winit +
  skia-safe (gl), gated to macOS / Linux / Windows.
* `examples/inspector_window.rs` removed; equivalent code lives at
  `crates/openpencil-desktop/src/main.rs` with the structs renamed
  (DesktopApp / paint) and the doc-block rewritten as a runner spec.
* Run command: `cargo run -p openpencil-desktop --release`. Old
  command (`--example inspector_window`) is gone.
* Workspace glob `crates/*` already picks up the new crate, no
  Cargo.toml workspace edit needed.
* Docs: crates/CLAUDE.md updated with the new crate row and runner
  section retitled "Desktop binary". Top-bar layout test renamed +
  uses the TOP_BAR_HEIGHT constant so future height tweaks stop
  breaking it.
2026-05-10 19:50:10 +08:00
Kayshen-X 2bb49d96bd feat(shell-web): Phase C1 — pure DOM event mapping modules
Lands the four pure W3C → Jian gesture mappers that Phase C2's
browser listeners will consume:

- `event:⌨️:map_keyboard_parts(key, code, location, repeat,
  pressed, modifiers, is_composing) -> KeyEvent` — W3C
  KeyboardEvent.key/code/location string lookups produce KeyValue
  (Char / Named / Unidentified) + KeyCode enum + KeyLocation enum.
  Phase C1 covers KeyA-Z, Digit0-9, Enter / Escape / Tab / Space /
  Backspace / Delete / arrows; Home/End/PageUp/PageDown/F-keys/
  modifier physical codes (ShiftLeft etc) extend the table in C2
  (codex C1 NIT-2 deferred).
- `event::ime::{composition_start, composition_update, composition_end}`
  + `utf16_selection_to_utf8` helper. The helper walks
  `text.char_indices()` accumulating `len_utf16()` to remap UTF-16
  code-unit offsets (what `CompositionEvent.getTargetRanges()`
  hands us) to UTF-8 byte offsets (what jian-core's `ImeKind::
  CompositionUpdate { selection: Range<usize> }` requires per spec
  §2.4). Mis-ordered range returns None; out-of-range bounds clamp
  to text.len(). CJK (你好), surrogate pairs (🙂), mixed-encoding
  (aé), zero-length selections, and empty text are all covered.
- `event::pointer::map_wheel(position, dx, dy, dz, mode, mods,
  timestamp: Instant) -> WheelEvent` — flips W3C deltaY sign so
  widget code reads Jian-internal positive-up. Phase C1
  intentionally takes `timestamp` as a parameter rather than
  calling `Instant::now()` internally; `std::time::Instant::now()`
  panics on wasm32-unknown-unknown ("time not implemented on this
  platform") and the C2 listener will fill the timestamp from a
  polyfill (web_time::Instant). Made the mapper pure to keep the
  panic locus in the listener glue, where the polyfill lives.
- `event::focus::map_focus(gained, node_id_hint,
  related_node_id_hint) -> FocusEvent` — pure pass-through; the
  W3C target → WidgetId correlation work lives in C2 alongside
  the DOM mirror id registry (Phase D groundwork).

Tests (`tests/dom_event_mapping.rs`, 22 tests, all native):
- keyboard: 5 tests + 1 empty-string-key fallback test (codex C1
  NIT-3) — key/code/location preservation, named key, location
  decoding, is_composing propagation, multi-codepoint &
  empty-string Unidentified fallback
- ime: 8 tests — start/update/end shapes, UTF-16→UTF-8 remap for
  CJK / surrogate pair / mixed encoding / zero-length / no-selection
  / mis-ordered range / out-of-range clamp / empty text
- wheel: 4 tests — Y sign flip, X no-flip, mode decoding, deltaZ
  passthrough
- focus: 2 tests — gained=true with both hints, blur with no
  related target

Plumbing:
- shell-web grows a direct path-with-version `jian-core` dep.
  shell-core re-exports `gesture::*` but not `geometry::*`, and
  the wheel mapper builds `WheelEvent.position` from
  `jian_core::geometry::Point::new(...)`. Same path-with-version
  pattern as shell-core's own jian-core dep.
- `pub mod event;` is NOT cfg-gated to skia — the mappers are pure
  and useful on the wasm32-clean stub baseline too.

Plan-vs-implementation deviations (deliberate, all kept narrow):
- `map_keyboard_parts` adds `is_composing: bool` parameter (jian-core
  KeyEvent struct REQUIRES the field per spec §2.4).
- `map_focus` adds `related_node_id_hint: Option<u64>` parameter
  (jian-core FocusEvent struct field; plan body missed it).
- `map_wheel` takes `timestamp: Instant` parameter instead of
  calling `Instant::now()` internally (avoids wasm32-unknown-unknown
  runtime panic; pure mapper).
- `event/pointer.rs` covers ONLY wheel; full PointerEvent mapping
  (kind / phase / buttons / pressure) lives in C2 alongside listener
  registration since that's where the W3C PointerEvent surface meets
  the runtime context.

Verification:
- `cargo test -p openpencil-shell-web --test dom_event_mapping` —
  22/22 passing
- `cargo check -p openpencil-shell-web --target
  wasm32-unknown-unknown --no-default-features --features web` —
  green (compile guard)
- `EMSDK=$HOME/.emsdk bash tools/check-wasm-bundle.sh` — PASS
  - 0 env.* imports
  - 615 616 bytes gzip = 58% of 1 MiB ceiling (no growth — event
    modules dead-code-eliminated when not called)
- `bash tools/check-widget-boundary.sh` — PASS (event/ doesn't
  violate F1-F4)

Codex iterate review: 1 round → GO with 1 deferred CONCERN
(Instant source for C2 — already documented inline) + 3 NITs
(deltaX comment wording, additional KeyCode entries, extra
edge-case tests). NIT-1 and NIT-3 fixed in this commit; NIT-2
deferred to C2.
2026-05-09 21:49:00 +08:00
Kayshen-X b6efa324d1 feat(shell-core): Phase B1 — Widget trait + recording test harness
Adds the widget facade that B2 inspector widgets and Phase C event
handling will plug into. Logic-bearing widget code lives in
shell-core (per spec §1.4); shell-native + shell-web only own their
RenderBackend impls + DOM event mapping + accesskit DOM mirror.

What's added:
- `widgets::Widget` trait with `id` / `layout` / `paint(&self,...)` /
  `access_node` methods. Phase B widgets are static — `paint` is
  `&self`, mutable per-widget state lives in `*State` structs that
  B2 lands. Phase C will extend the trait with a `&mut self` event
  method for input handling.
- `widgets::WidgetId(pub u64)` plus a `pub const ROOT_WIDGET_ID =
  WidgetId(0)` and a `WidgetId::new(id)` constructor with
  `debug_assert!(id != 0)`. The tuple constructor stays public so
  pattern matching + `const` contexts keep working; `::new` is the
  conventional path that surfaces the root-id reservation in debug
  builds. (Codex B1 R1 NIT-7 — make the convention compiler-visible
  before Phase C tree routing lands.)
- `widgets::PaintCx<'a> { backend: &'a mut dyn RenderBackend }` and
  `widgets::LayoutCx { available_width, dpi }` — frame-scoped paint
  context + layout-time context. The `&mut dyn` indirection lets
  shell-native + shell-web reuse the widget code without
  monomorphising over the concrete backend.
- `widgets::LayoutBox { rect: Rect }` with `Debug + Clone + Copy +
  PartialEq` derives.
- A `rect(x, y, w, h)` constructor convenience used by tests + B2.

Test harness (`tests/widgets_static.rs`):
- `RecordingBackend` impl `RenderBackend` counting each call.
- `paint_cx_dispatches_through_dyn_backend` — verifies fill_rect /
  stroke_rect / save / translate / clip_rect / restore all dispatch
  via `&mut dyn RenderBackend`.
- `widget_trait_dispatches_layout_and_paint` — minimal `StubWidget`
  proves the trait shape compiles; asserts layout result, paint
  dispatch count, `WidgetId::new(7)` round-trip, `ROOT_WIDGET_ID.0
  == 0`, and `access_node().role() == Role::GenericContainer`. Real
  semantic roles (TreeItem / EditableText / etc) land with B2.

Plumbing:
- `accesskit = "0.24"` added to shell-core deps to match shell-web's
  pin (the version compatible with shell-native's accesskit_winit
  Step 1a usage). Codex B1 R1 Q3 flagged that shell-native does not
  yet pull accesskit; this is acknowledged as a Phase C tracked item
  — verify the same version when DOM mirror / native a11y wires up.
- `Rect` now derives `PartialEq` so `LayoutBox` can use the same
  derive. `Eq` is intentionally NOT derived (Vec2 carries floats);
  comment in render_backend.rs explains.

Plan-vs-implementation deviations (deliberate, all kept narrow):
- Plan B1 step 2 declares `pub mod {dropdown, prop_row, text_input,
  tree};` + re-exports inside widgets/mod.rs. Omitted here because
  those modules don't exist until B2; declaring them now would
  break the B1 standalone build. Top-block plan mini-patch
  convention applies (override sketches in body).
- Plan didn't enumerate the accesskit dep + `Rect: PartialEq`
  deltas — added with rationale comments.

Verification:
- `cargo test -p openpencil-shell-core` — green
- `cargo check -p openpencil-shell-core --target
  wasm32-unknown-unknown` — green (shell-core stays wasm32-clean
  per spec §1.2)
- `cargo check -p openpencil-shell-native` — green (no regression)

Codex iterate review: 4 rounds → GO. Round 1 CONCERN (3 items),
Round 2 CONCERN (1 stale comment), Round 3 CONCERN (comment vs
test body mismatch), Round 4 GO clean. Q3 (accesskit_winit
alignment) carries to Phase C as informational.
2026-05-09 21:45:00 +08:00
Kayshen-X 4b8bb136e7 chore(workspace): patch skia-bindings + skia-safe to vendor/skia-safe-op
Wires the workspace at vendor/skia-safe-op (committed in the previous
commit) via [patch.crates-io] so every consumer of skia-safe /
skia-bindings — both the wasm32-unknown-unknown shell-web bundle and
the macOS / Linux / Windows shell-native desktop binary — resolves
through the fork on every target.

[patch.crates-io] is workspace-global, NOT target-scoped; cargo does
not natively support per-target patches, so this is the accepted
blast radius. The fork is byte-identical to upstream rust-skia 0.97.0
except for the new `wasm_unknown` platform module + its single new
dispatch arm; native builds resolve to the same upstream platform
modules they did before. Verified `cargo check -p
openpencil-shell-native` builds through the fork unchanged.

Trade-off: upstream rust-skia patches no longer flow until we
re-vendor; Cargo.lock records `path` sources for skia-bindings /
skia-safe rather than `registry+...`. The full rationale block is
inline in Cargo.toml.

The Cargo.lock delta also pins js-sys 0.3.97 → 0.3.94 / web-sys
0.3.97 → 0.3.94 — this is the transitive consequence of pinning
wasm-bindgen = "=0.2.117" on shell-web (last 0.2.x release that
compiles on the workspace's Rust 1.85 toolchain; 0.2.120+ requires
1.86). Documented in shell-web/Cargo.toml.

Step 1b §3.2 P0.5B Run path, sub-phase C-hard.1.
2026-05-09 21:06:00 +08:00
Fini af9292d8f5 fix(ai): classify Type 0 components as non-mobile to skip phone chrome
Why: "Design a profile card" through MiniMax-M2.7 produced a 375×803 mobile
screen with auto-injected status bar, because the planner skill listed
"profiles" as a Type 2 single-task screen and the orchestrator's
isMobileScreen heuristic ran on width≤480 alone.

What: design-type.md + decomposition.md add Type 0 (single component:
card / badge / chip / modal) with width=400 height=0 1 subtask no chrome.
isMobileFullScreen helper extracted to orchestrator-plan-classify.ts and
required by both orchestrator.ts and orchestrator-sub-agent.ts so the
two paths can't drift on what "mobile" means (Codex review caught this
when only orchestrator.ts had the new check).

Verified with same MiniMax + same prompt: 400×320 component, 8 nodes,
firstChildRole=card, no status-bar.
2026-05-09 21:00:00 +08:00
Kayshen-X b74f8f8f32 chore(workspace): patch skia-bindings + skia-safe to vendor/skia-safe-op
Wires the workspace at vendor/skia-safe-op (committed in the previous
commit) via [patch.crates-io] so every consumer of skia-safe /
skia-bindings — both the wasm32-unknown-unknown shell-web bundle and
the macOS / Linux / Windows shell-native desktop binary — resolves
through the fork on every target.

[patch.crates-io] is workspace-global, NOT target-scoped; cargo does
not natively support per-target patches, so this is the accepted
blast radius. The fork is byte-identical to upstream rust-skia 0.97.0
except for the new `wasm_unknown` platform module + its single new
dispatch arm; native builds resolve to the same upstream platform
modules they did before. Verified `cargo check -p
openpencil-shell-native` builds through the fork unchanged.

Trade-off: upstream rust-skia patches no longer flow until we
re-vendor; Cargo.lock records `path` sources for skia-bindings /
skia-safe rather than `registry+...`. The full rationale block is
inline in Cargo.toml.

The Cargo.lock delta also pins js-sys 0.3.97 → 0.3.94 / web-sys
0.3.97 → 0.3.94 — this is the transitive consequence of pinning
wasm-bindgen = "=0.2.117" on shell-web (last 0.2.x release that
compiles on the workspace's Rust 1.85 toolchain; 0.2.120+ requires
1.86). Documented in shell-web/Cargo.toml.

Step 1b §3.2 P0.5B Run path, sub-phase C-hard.1.
2026-05-09 20:59:20 +08:00
Kayshen-X 136274a3ec chore(vendor): bump jian submodule to d5d358e (Step 1b §3.2 P0.5A)
Picks up the keyboard/IME/focus event additions + W3C wheel deltaMode
landed in jian commit d5d358e. shell-core re-exports of the new types
land in the next commit; this commit only moves the pointer + Cargo.lock.

cargo test -p openpencil-shell-core --test gesture_re_export → 6/6 PASS
against the pinned submodule.
2026-05-08 22:03:08 +08:00
Kayshen-X c1ce879582 feat(shell-native): SharedSkiaContext + NativeBackend over Jian DrawOp
Step 1a Task 2 (spec v19 §3 / §5.2.1, plan v7).

- `SharedSkiaContext`: own GL stack + Skia DirectContext + Surface,
  `Option<>`-field idempotent teardown, `with_frame(|canvas, glow|)`
  callback, lifecycle hooks (on_pause/on_resume/on_low_memory) with
  Android surface drop contract; tracing spans + events on every
  per-frame entry point.
- `GlContextProvider` trait + `GlutinProvider` desktop impl + iOS /
  Android stubs; trait carries no `Send` bound (per spec §3.1).
- `CanvasViewportStub::render_into(&Canvas)` deliberately pollutes
  STENCIL_TEST + blend func to verify chrome-paint isolation.
- `NativeBackend`: frame-scoped methods mirroring OP `RenderBackend`
  trait surface (no direct trait impl in 1a; Step 1c+ wraps via
  `WithCanvas<'a>` newtype). Translates `fill_rect / stroke_rect /
  draw_text / clip_rect / save / restore / translate` to
  `jian_core::render::DrawOp` and submits via
  `jian_skia::SkiaBackend::draw_on_canvas`. Public `draw_op` helper +
  `to_jian_color` / `to_jian_rect` converters.
- Tests:
  - `teardown_idempotent.rs` — teardown × 3 + lifecycle hook idempotence.
  - `memory_loop.rs` — 100 × create/begin_frame/present/teardown × 3
    with sysinfo RSS budget < 5 %.
  - `tracing_spans.rs` — `tracing-test` (no-env-filter) catches
    begin_frame / with_frame / present / resize / teardown / on_pause /
    on_resume / on_low_memory events.
  - `raster_composition.rs` — chrome-only fill_rect on raster surface,
    pixel-asserts red + black + untouched-bg.
  - `raster_text_smoke.rs` — "Hello 你好" through textlayout feature,
    asserts visible glyph rasterisation.
  - `gpu_smoke.rs` — Linux EGL pbuffer (non-ignored) + macOS invisible
    winit window (graceful inconclusive when off main thread; full
    path runs from `cargo run --example basic_window`) + Windows
    `#[ignore]` per spec §8.1.
  - `gpu_chrome_stub_composition.rs` — chrome+stub on the same GL
    surface, asserts chrome pixels survive stub's GL pollution.
- Cargo.toml: add `jian-core` direct dep + `tracing` / `thiserror`
  workspace deps; dev-deps `sysinfo`, `tracing-test` (with
  `no-env-filter`), Linux-only `khronos-egl` + `libloading`.

`cargo build`, `cargo test`, `cargo clippy --all-targets -- -D warnings`,
`cargo fmt --all -- --check` all green on macOS.
2026-05-05 21:06:00 +08:00