diff --git a/CLAUDE.md b/CLAUDE.md index 02cdcfeab..8b474a2c6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,8 +40,10 @@ crates/ ├── op-host-services/ Headless serve-web / MCP daemon lib (shared by desktop + web-server) ├── op-host-web-server/ Thin GL-free web-server binary ├── op-cli/ `op` command-line tool +├── op-util/ Dependency-free leaf: hex-colour parsing + JSON / XML escaping └── op-mcp / op-ai / op-ai-skills / op-codegen / op-orchestrator / op-figma / - op-git / op-opmerge / op-pen-loader / op-design-lint / op-i18n / op-smoke / … + op-git / op-opmerge / op-pen-loader / op-design-lint / op-i18n / op-html / + op-auth-bridge / op-smoke / … packages/ ├── op-web-sdk/ Read-only `.op` web viewer SDK (wraps the op-host-web wasm bundle) @@ -53,7 +55,7 @@ Data flow, canvas engine, Document/EditorCommand model, MCP layered-design workf ## Code Style -- Single files must not exceed **800 lines** — split into smaller modules when they grow beyond this. +- Single files must not exceed **800 lines** — split into smaller modules when they grow beyond this. The workspace currently has **zero violations**; the convention is a spine (public surface + `mod` declarations) plus sibling files, with re-exports keeping import paths stable. - One component/widget per file, single responsibility. - `.rs` filenames use snake_case; `.ts`/`.tsx` (SDK) use kebab-case. - Source comments (`.rs`/`.ts`/`.toml`) in **English** (spec/plan markdown + test CJK fixtures may keep Chinese). diff --git a/crates/CLAUDE.md b/crates/CLAUDE.md index 6aa7b5b35..370d5527a 100644 --- a/crates/CLAUDE.md +++ b/crates/CLAUDE.md @@ -15,6 +15,18 @@ Native + web editor chrome implemented in Rust against jian-skia. This is the ** > are **retired** — the browser renders through the official CanvasKit skia WASM > (loaded separately), so the Rust wasm bundle is pure logic; the **"Web runner" > section below is stale** (it describes the old wasm32 skia-raster + EMSDK path). +> +> **2026-07 dedupe + split campaign (`839f474a..d2d8104c`) — second stale-path +> layer.** Three structural changes ripple through the sections below. (1) The +> canonical editor state is `EditorState` in **`op-editor-core/src/state.rs`**; +> there is no `document/` directory in any crate, so every `shell-core/src/document/*.rs` +> path in the "Document model", pen-tool, colour-picker, and hot-path sections +> names a file that no longer exists — the behaviour they describe is still +> accurate, the paths are not. (2) The native↔web `widget_host` fork is +> **single-sourced** — see "Shared host logic" below for where the real logic +> lives. (3) The 800-line cap now holds **workspace-wide with zero violations**, +> so nearly every module named below is a spine plus sibling files; a path that +> resolves to a directory instead of a single file is the split, not a rename. ``` crates/ @@ -25,18 +37,30 @@ crates/ ├── op-host-web/ Browser bundle entry: wasm32-unknown-unknown cdylib, CanvasKit renderer ├── op-host-desktop/ Desktop binary `openpencil-desktop` (winit + skia-safe GL); also the `--serve-web` daemon ├── op-cli/ `op` command-line tool +├── op-util/ Dependency-free leaf: hex-colour parsing + JSON / XML escaping └── … op-mcp / op-ai / op-ai-skills / op-codegen / op-orchestrator / op-figma / op-git / op-opmerge / op-pen-loader / op-design-lint / op-config-store / - op-process-io / op-acp / op-i18n / op-rpc-transport / op-smoke + op-process-io / op-acp / op-i18n / op-rpc-transport / op-smoke / + op-host-services / op-host-web-server / op-html / op-auth-bridge / op-web-sdk ``` +`op-util` is the workspace's single source for three helpers that had drifted +into many copies: `hex_color` (nine divergent implementations, one of which +panicked on non-ASCII input), `json_escape` (one copy was lossy), and +`xml_escape` (one copy omitted the quote entity — an attribute-injection gap). +It must stay dependency-free and wasm32-clean: it sits in the browser host's +build graph via op-editor-core / op-editor-ui. Reach for it instead of writing +a local `parse_hex` / `escape_json` again. + `vendor/jian/` is a submodule providing the rendering primitive layer (`jian-skia` Skia adapter, `jian-host-desktop` GL plumbing, `jian-core` event types + taffy layout, `jian-scene` canonical render scene — `LayoutScene` + hit-test + path geometry, consumed by OP and any jian app); `vendor/casement` is the winit fork; `vendor/agent` is the cross-product Rust agent runtime. All are referenced by path in the workspace Cargo.toml. ## Key invariants - **op-editor-ui (widgets) stays wasm32-clean** (spec v19 §1.2). No skia-safe / winit / accesskit_winit. The `RenderBackend` trait is the only seam between widget code and platform. - **Widget code lives in op-editor-ui only.** Hosts (op-host-native `widget_host.rs`, op-host-web `widget_host.rs`) are the ONLY files allowed to call `op_editor_ui::widgets::*`. Boundary script: `tools/check-widget-boundary.sh`. -- **Max 800 lines per file** — same rule as the TS workspace. `property_panel.rs` is split into 5 files (see PropertyPanel row in the widget table). `widget_host.rs` (shell-native) is split into a slim spine + 8 sibling submodules under `widget_host/` (see "Native widget_host layout" below). `document.rs` is split into a spine + sibling submodules under `document/` — types in `document.rs`, `impl Document` in `mutators.rs`, free walkers + `ReorderDirection` in `walkers.rs`, chat types in `chat.rs`, pen-tool ops in `pen.rs`, color-picker state in `color_picker.rs`, group/ungroup ops in `grouping.rs`, page CRUD in `page_mutators.rs`, tests split into `tests_mutators.rs` + `tests_geometry.rs`. +- **Max 800 lines per file — zero violations workspace-wide.** As of `d2d8104c` no `.rs` file in `crates/` exceeds the cap, and the sibling-module split is the universal shape: a spine keeps the public surface and `mod` declarations, cohesive clusters move into siblings, and re-exports keep every import path and test name stable. Splits are pure code motion — when you split, do not also change behaviour. Test modules follow the same rule (`foo_tests.rs`, or a `foo/tests/` directory when the tests themselves outgrow the cap). Check with `find crates -name '*.rs' -exec wc -l {} + | awk '$1>800'`. +- **Blocking on a future from sync host code goes through `op_host_services::chat_runtime::block_on_anywhere`.** A bare `Runtime::block_on` (or a privately-built current-thread runtime) aborts with "runtime within runtime" when the caller happens to sit on a tokio worker. `block_on_anywhere` picks the safe strategy for whichever context it is called from; it is the only sanctioned entry point and is exercised by tests for the no-runtime, multi-thread-worker, and borrowing-non-`Send`-future cases. +- **Fallible paths carry typed error enums, not `String`.** The whole workspace is converted (80+ enums; `Result<_, String>` survives at exactly two documented boundary sites). Find the domain's enum in its `*_error.rs` / `error.rs` sibling module (e.g. `CliError`, `ProgramError`, `WebCanvasError`, `McpServeError`, `ExportError`, `McpLiveError`, `DocIoError`, `ImageGenerateError`). The pattern to copy: one enum per failure domain in its own sibling module, structured fields instead of pre-formatted text, a `Display` impl that reproduces the previous message **byte-identically** (so user-visible strings and their tests don't move), and `From` impls that collapse the `map_err` adapters at the call sites. New fallible code should introduce or reuse an enum rather than add another `Result<_, String>`. - **Web bundle ceiling: 6 MiB gzip + 0 env.\* imports.** Enforced by `tools/check-wasm-bundle.sh`. The CanvasKit bundle carries the full app logic (codegen AI pipeline, Figma parser, AI/live-sync), so the ceiling sits well above the retired skia raster path's 1 MiB (~4.5 MiB today). The 0 env.\* guard still holds — CanvasKit needs no libc shim. ## Document model (`shell-core/src/document/`) @@ -101,7 +125,7 @@ Mutators on `Document`: | Toolbar | Vertical floating column — Select / shape slot / Text / Frame / Hand | `toolbar.rs` | | ShapePicker | Toolbar shape-slot dropdown (Rect / Ellipse / Polygon / Line / Pen / Icon / Import) | `shape_picker.rs` | | CanvasViewport | Center — node tree + grid + viewport transform | `canvas_viewport.rs` | -| PropertyPanel | Right rail — 设计/代码 tabs + 10 sections + interactive inputs (X/Y/W/H/R, hex, stroke width) + flex/size toggles + fill-type picker | `property_panel.rs` + `property_panel_sections.rs` + `property_panel_inputs.rs` + `property_panel_layout.rs` + `property_panel_fill.rs` | +| PropertyPanel | Right rail — 设计/代码 tabs + 10 sections + interactive inputs (X/Y/W/H/R, hex, stroke width) + flex/size toggles + fill-type picker | `property_panel.rs` + the `property_panel_*.rs` family + `property_panel/`, `property_panel_snapshot/`, `property_panel_tests/` | | AIChatPlaceholder | Floating — chat with drag + 4-corner snap + collapse pill | `ai_chat_panel.rs` | | LocalePicker | TopBar Globe-button dropdown (15 native names + Check) | `locale_picker.rs` | | StatusBar | Floating bottom-right — zoom controls | `status_bar.rs` | @@ -111,7 +135,7 @@ Mutators on `Document`: | LayerContextMenu | Right-click overlay on layer rows + page tabs (Rename / Duplicate / Delete / Group / Ungroup / Lock / Hide; subset on page tabs) | `layer_context_menu.rs` | | AgentSettings | Cmd+, modal — 880×640 with sidebar nav (Agents / MCP / Images / System) + scrollable right pane | `agent_settings_panel.rs` + `agent_settings_{i18n,images,mcp,system}.rs` | | theme | shadcn-dark palette tokens (incl. `canvas_surface`) | `theme.rs` | -| i18n | 15 canonical locale catalogs (765 main + 325 Git = 1,090 direct keys each) in the `op-i18n` crate | `op-i18n/src/i18n/{en,zh_cn,zh_tw,ja,ko,fr,es,de,pt,ru,hi,tr,th,vi,id}.rs` | +| i18n | 15 canonical locale catalogs, 1,105 direct keys each, sharded main → `_git` → `_panel` in the `op-i18n` crate | `op-i18n/src/i18n/{en,zh_cn,zh_tw,ja,ko,fr,es,de,pt,ru,hi,tr,th,vi,id}{,_git,_panel}.rs` | ## Theme + i18n @@ -122,13 +146,15 @@ Mutators on `Document`: - 15 supported locales (matches TS dropdown order): EnUs / ZhCn / ZhTw / Ja / Ko / Fr / Es / De / Pt / Ru / Hi / Tr / Th / Vi / Id. Each carries a `display_name()` (English / 简体中文 / 繁體中文 / 日本語 / 한국어 / Français / Español / Deutsch / Português / Русский / हिन्दी / Türkçe / ไทย / Tiếng Việt / Bahasa Indonesia). - TopBar Globe-button is a 44 px-wide compound (globe + chevron-down) opening a `LocalePicker` dropdown — clicking a row sets `Document.ui.locale` and closes; clicking outside (or the Globe again) closes silently. The picker paints as the top-most overlay so it covers chat / status / canvas. - Multi-script chrome strings (한국어 / हिन्दी / ไทย / Tiếng Việt) render against per-codepoint typeface lookups (`FontMgr::match_family_style_character` cached per `i32` in `NativeBackend`), with each string broken into contiguous-typeface segments before draw. -- Locale tables are hand-maintained in Rust. Add or change the corresponding direct entry in all relevant main/Git locale modules, then run: +- Locale tables are hand-maintained in Rust, and each locale is **three files, chained by fall-through**: the main table `.rs` ends its `match` with `_ => return super::_git::lookup(key)`, the Git shard ends with `_ => return super::_panel::lookup(key)`, and the `_panel` overflow shard ends with `_ => return None`. The catalog is 1,105 direct keys per locale (`catalog_integrity_tests.rs` asserts the exact number). + + **Adding a new key: put it in `_panel.rs`, in all 15 locales.** The main tables sit at or just under the repo's 800-line ceiling (`zh_cn.rs` is exactly 800, `en.rs` 798), so a new entry there does not fit — the `_panel` shards exist precisely to absorb overflow and have room. Only edit a main or `_git` table when you are changing the wording of a key that already lives there. Then bump the expected count in `catalog_integrity_tests.rs` and run: ```sh cargo test -p op-i18n ``` - Catalog integrity tests enforce the exact cross-locale key set, reject duplicate or unsupported match arms, and require every translation to preserve the English placeholder set. `tools/convert-locales.py` is a deliberate failing shim because its retired TypeScript source no longer exists. Keep each locale file under the repository's 800-line ceiling. Runtime lookup still falls back through English and then the raw key for debug visibility, while the tests prevent shipped locale gaps. + Catalog integrity tests enforce the exact cross-locale key set across all three shards, reject duplicate or unsupported match arms, and require every translation to preserve the English placeholder set. The `i18n/mod.rs` tests were split into sibling modules alongside it (`tests.rs`, `catalog_integrity_tests.rs`, plus per-feature key guards: `figma_property_panel_key_tests.rs`, `html_import_key_tests.rs`, `missing_fonts_key_tests.rs`, `preview_device_key_tests.rs`, `vector_fidelity_property_keys.rs`) — add a feature's key guard next to those rather than growing `mod.rs`. `tools/convert-locales.py` is a deliberate failing shim because its retired TypeScript source no longer exists. Runtime lookup still falls back through English and then the raw key for debug visibility, while the tests prevent shipped locale gaps. ## Toolbar shape-tool dropdown @@ -187,13 +213,16 @@ The hit-test walker `action_button_rects_with_fill_picker(panel_rect, visible, f ### File split -`property_panel_sections.rs` was split into 5 files to honor the 800-line ceiling: +The panel is a `property_panel_*.rs` family in `op-editor-ui/src/widgets/` (plus the `property_panel/`, `property_panel_snapshot/`, and `property_panel_tests/` directories) — one file per section or concern, all under the 800-line ceiling. The load-bearing entries: - `property_panel.rs` — `PropertyPanel`, snapshot, `SectionCapabilities`, hit-test entry points. - `property_panel_sections.rs` — section paint routines + `PropertyLabels` + `EditContext`. - `property_panel_inputs.rs` — shared paint helpers (label / divider / input variants), layout constants, `format_color_hex`, `to_jian_color`. - `property_panel_layout.rs` — `VisibleSections` / `SizeFlags` / `fill_body_height` + the two layout walkers. -- `property_panel_fill.rs` — fill-type label table, picker overlay, head row, all 4 body variants. +- `property_panel_fill.rs` (+ `_fill_body`, `_fill_image_body`, `_fill_picker`) — fill-type label table, picker overlay, head row, body variants. +- `property_panel_dispatch.rs` (+ `_support`), `property_panel_commit.rs`, `property_panel_layout_ops.rs` — the **host-shared** action dispatch / draft lifecycle / layout writers; see "Shared host logic" below. These are not paint code and are called by both hosts. + +Everything else (`_code*`, `_effects`, `_export`, `_stroke`, `_text`, `_typography`, `_image_*`, `_instance`, `_interactions`, `_icon`, `_compositing`, …) follows the same one-concern-per-file rule. `PropertyLabels::for_document(doc)` resolves every section title (位置/弹性布局/尺寸/图层/填充/描边/效果/导出), the 设计/代码 tabs, the 创建组件 button, and the size checkboxes (填充宽/高 / 适应宽/高 / 裁剪内容) via `Document::t`, falling back to English when a key isn't in the TS locale table. @@ -210,16 +239,30 @@ resize / dpi_scale ## Native widget_host layout -`crates/op-host-native/src/widget_host.rs` is a slim spine (~265 lines) holding the public surface — `WidgetHostNative` struct, drag-state structs, `CursorHint` enum, `PanelResizeKind` enum, the constructor and tiny accessors (`set_now_ms` / `chat_focused` / `next_animation_deadline_ms`) — plus `mod` declarations for the sibling submodules under `widget_host/`: +`crates/op-host-native/src/widget_host.rs` is a spine (~690 lines) holding the public surface — the `WidgetHostNative` struct, `CursorHint` / `PanelResizeKind` enums, the constructor and small accessors (`set_now_ms` / `chat_focused` / `next_animation_deadline_ms`) — plus the `mod` declarations for ~130 sibling submodules under `widget_host/`. Its web twin, `op-host-web/src/widget_host.rs` (~500 lines), has the same shape. The drag-state structs are **not** declared here any more: both hosts share them from `op_editor_core::host_drag_state`. -| File | Purpose | -| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `widget_host/frame_backend.rs` | `NativeFrameBackend` (`RenderBackend` impl over `NativeBackend` + `&Canvas`) | -| `widget_host/helpers.rs` | `parse_hex_color` / `color_to_hex` / `rect_contains` / `resize_bounds` + the inset / gutter / width constants | -| `widget_host/geometry.rs` | `impl WidgetHostNative` — canvas-region / panel-resize hover / cursor hint / picker rect math | -| `widget_host/input.rs` | `impl WidgetHostNative` — `apply_wheel` / `_pan_gesture` / `_cursor_move` / `_release[_with_viewport]` / `_text` / `_backspace` / `_send` / `_escape` / `_property_action` / `commit_property_focus_if_any` / `_click` | -| `widget_host/press.rs` | `impl WidgetHostNative` — `apply_press` + `create_node_for_active_tool` (largest single method; routes through 10 hit-test layers) | -| `widget_host/paint.rs` | `impl WidgetHostNative::paint` — full editor-UI composition pass | +The directory is too large to table exhaustively; the naming scheme tells you where to look: + +| Pattern | Contents | +| --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `press.rs` + `press_ctx.rs` + `press_*_tiers.rs` | The `apply_press` hit-test ladder — spine, per-event scratch struct, and the ordered tier bodies (see "Hit-test order") | +| `input.rs` + `cursor_move_ctx.rs` + `cursor_move_*.rs` | The `apply_cursor_move` hover ladder, same spine/ctx/tier shape (native only; web's cursor path lives in `cursor_input.rs`) | +| `keyboard.rs` + `keyboard_*.rs` | Key dispatch split by concern — caret, clipboard, delete, escape, send, IME | +| `*_dispatch.rs` | Thin platform arms over the shared `op-editor-ui` dispatch flows (property, image panel, settings, font picker) | +| `paint.rs`, `paint_pan_cache.rs`, `scene_state.rs` | The composition pass and its caches | +| `host_lifecycle.rs`, `host_requests.rs` | Host boot / teardown and outbound request plumbing carved off the spine | +| `geometry.rs`, `overlay_rects.rs`, `helpers.rs` | Thin forwarders to the shared geometry in `op-editor-ui` plus the few genuinely platform-specific rects | +| `*_tests.rs` / `*_tests/` | Per-concern test siblings; a directory means the tests themselves outgrew the cap | + +`NativeFrameBackend` (the `RenderBackend` impl over `NativeBackend` + `&Canvas`) moved out of `widget_host/` — it lives in `op-host-native/src/backend/frame_backend.rs` alongside `frame_offscreen.rs` and the `skia/` split. + +### The three input ladders + +`apply_cursor_move` (native) and both hosts' `apply_press` used to be single 900–1150 line methods. Each is now a short spine that calls `Option`-returning tier helpers in ascending priority order, with per-event scratch state in a `PressCtx` / `CursorMoveCtx` struct instead of a long argument list. A tier returning `Some(consumed)` ends the event; `None` falls through to the next tier. + +**The call order in the spine *is* the behaviour.** The decomposition was verified mechanically — every tier body is byte-identical to its original line range and the spines invoke tiers in strictly ascending original order — so reordering a call is a behaviour change, not a refactor. Read "Hit-test order" below before touching either spine. + +All struct fields and intra-module helpers are scoped `pub(in crate::widget_host)` so submodule `impl` blocks can reach them while the public surface stays minimal. ### Keyboard shortcuts @@ -236,7 +279,7 @@ Native (`openpencil-desktop`) + web (`shell-web`) both dispatch the following P1 | `Escape` | `apply_escape` | One layer per press, in priority order: property-focus → locale picker → shape picker → fill-type picker → chat focus → selection. | | `Enter` | `apply_send` | Commits property edit or sends chat. | -All struct fields and intra-module helpers are scoped `pub(in crate::widget_host)` so submodule `impl` blocks can reach them while the public surface stays minimal. Each file is under 480 lines. +The shared implementation of these lives in `op_editor_core::host_keyboard_transitions` + `host_escape_transitions`; the host `keyboard*.rs` files are the platform arms. ## Desktop binary (`openpencil-desktop/`) @@ -270,17 +313,31 @@ Smoke: `crates/openpencil-shell-web/smoke/step-1b.html` — start `python3 -m ht ## Hit-test order -Hit-test runs in REVERSE paint order so the topmost overlay always wins: +**This section is the canonical statement of the ladder** — the tier modules point back here by name. Hit-test runs in REVERSE paint order so the topmost overlay always wins, and each spine's call order encodes it literally. -1. TopBar (sidebar toggle button) — also eats other top-bar gaps -2. AI chat panel (DragHandle starts drag; FocusInput / Send / Example / ToggleCollapse defer to apply_click) -3. Toolbar (button hits dispatch tools; gaps inside the bounding rect eat clicks) -4. apply_click → LayerPanel rows / Page rows + chat-defocus (skipped when sidebar collapsed) -5. Empty canvas press → clear `selected` (collapses RightPanel) + start pan-drag +`apply_press` (native `widget_host/press.rs`) — the prelude first blur-commits an in-flight layer rename and text edit, with one exception: a press *inside* the edited text node places the caret instead of committing (TS textarea parity). Then: + +1. `press_topmost_overlay_tiers` — modals, floating panels, context menus +2. commit-on-blur for property-panel inputs + the variable-row inline editor, then resolve Git-panel / chat-model-picker ownership (the floating Git panel paints over the right rail, so every rail tier below skips a click inside its rect) +3. `press_menu_modal_tiers` — shape picker; file / export / figma / login / account modals; import + locale dropdowns +4. `press_rail_overlay_tiers` — image-fill popover, StatusBar, resize gutter, the model-picker slice above the TopBar +5. `press_top_bar_tier` — TopBar chrome and its blank-press gaps +6. `press_preview_tier` — Preview (Play) mode swallows everything below +7. `press_property_overlay_tiers` → `press_panel_dispatch_tiers` → `press_property_panel_tier` +8. `press_git_and_chat_tiers` +9. `press_toolbar_tiers` +10. `press_layer_and_click_tiers` — LayerPanel rows / Page rows + chat-defocus +11. `press_canvas_tier` → `press_canvas_select_tier` — empty-canvas press clears selection (collapsing the right rail) and starts the pan / marquee drag + +Web's `apply_press` runs the same order with two extra tiers split out (`press_import_locale_tiers`, `press_variables_tiers`, `press_font_and_picker_tiers`) because its overlay set differs; the relative priority is identical. + +`apply_cursor_move` (native `widget_host/input.rs`) runs its own 10-tier ladder: modals → floating panels → in-flight property/text/crop/node/pen drags (pointer capture) → menus + Git panel → property-panel popovers → StatusBar/align/model-picker → TopBar → the single-shot chat probe (deliberately non-consuming, so the late drags still run) → late pointer-capture drags → base hover. It keeps one owned `PropertyPanel` snapshot per event in `CursorMoveCtx::property_panel_probe` so the expensive snapshot/i18n work happens at most once. ### Coordinate invariant -Every input path that reasons about the canvas region MUST derive its rects from `canvas_region(viewport_w, viewport_h)`. Never reuse `LAYER_PANEL_WIDTH` for hit-test — paint follows `canvas_region`, which collapses to `canvas_left = 0` when `Document.ui.sidebar_open == false`. Sites that proved this rule by violating it: `over_canvas`, `apply_wheel` cursor offset, toolbar hit rect in `apply_press` / `apply_click`. Web `apply_wheel` zoom anchor + `toolbar_rect()` helper follow the same rule. +Every input path that reasons about the canvas region MUST derive its rects from `canvas_region(…)`. Never reuse `LAYER_PANEL_WIDTH` for hit-test — paint follows `canvas_region`, which collapses to `canvas_left = 0` when the sidebar is closed. Sites that proved this rule by violating it: `over_canvas`, `apply_wheel` cursor offset, toolbar hit rect in `apply_press` / `apply_click`. Web `apply_wheel` zoom anchor + `toolbar_rect()` follow the same rule. + +The invariant now has **one implementation**: `op-editor-ui/src/widgets/host_canvas_geometry.rs` owns `canvas_origin` / `canvas_region` / `canvas_rect` / `over_canvas` / `canvas_doc_point[_unclamped]` / `canvas_centre_doc_point` / `layer_panel_rect` / `property_panel_rect` / `toolbar_rect*` / `status_bar_rect` / `marquee_rect` / `path_anchor_hit`, and each host's `widget_host/geometry.rs` is a thin forwarder. Both hosts previously carried byte-identical copies of this math — exactly the drift the invariant exists to prevent. Sixteen inline screen-to-doc conversions were routed through the shared helper as part of the same pass; do not add a seventeenth by hand. ## Settings modal (`Cmd+,`) @@ -341,20 +398,66 @@ Layer-panel drag now supports **cross-parent reparenting**: a drag whose drop-ta - **Cursor-move coalescing** — `apply_cursor_move` cached as `pending_cursor_move`, drained on `RedrawRequested` AND right before `apply_press` / `apply_release` / `apply_right_press` (so the final drag-end frame isn't dropped). Without the press-time drain a fast drag-release could fire press before the queued cursor-move and the release saw stale hover state. - **Font cache prewarm** (`op-host-native/src/backend/skia.rs`) — `NativeBackend::new` walks `PREWARM_CJK_CODEPOINTS` (~50 chars covering every CJK glyph used in the chrome + settings modal) through `FontMgr::match_family_style_character` at startup. Without this the first cross-tab paint stutters because each unseen CJK char triggers a synchronous system font scan. -## Native widget_host layout (expanded) +## Shared host logic (the native↔web `widget_host` fork is single-sourced) -The shell-native `widget_host/` directory now houses 8 sibling submodules under the slim spine: +`op-host-native/src/widget_host/` and `op-host-web/src/widget_host/` used to be a copy-pasted twin pair, and the copies had silently drifted apart — web's Interactions actions were dead no-ops, native's shift-drag skipped the undo snapshot, native painted two variables menus at once, web's marquee release never synced the entered container, and so on. Every one of those was a *real user-visible bug* found only because the twins were diffed. The fork is now single-sourced. -| File | Purpose | -| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `widget_host/frame_backend.rs` | `NativeFrameBackend` (`RenderBackend` impl over `NativeBackend` + `&Canvas`) | -| `widget_host/helpers.rs` | `parse_hex_color` / `color_to_hex` / `rect_contains` / `resize_bounds` + the inset / gutter / width constants | -| `widget_host/geometry.rs` | Canvas region / panel-resize hover / cursor hint / picker rect math + `update_agent_settings_hover` (hover-card tracking for the agent cards) | -| `widget_host/input.rs` | `apply_*` (non-press input handlers) including the settings-focus keyboard early-return | -| `widget_host/press.rs` | `apply_press` + `create_node_for_active_tool` (largest single method; routes through 10+ hit-test layers including the settings modal + color picker + layer context) | -| `widget_host/paint.rs` | `WidgetHostNative::paint` — full editor-UI composition pass | -| `widget_host/property_dispatch.rs` | Property-panel action dispatch + `commit_property_focus_if_any` + `commit_settings_focus_if_any` | -| `widget_host/shortcuts.rs` | Keyboard shortcut helpers (Cmd-Shift-C color picker, Cmd-Shift-G ungroup, etc.) | +**Before adding logic to either host, check whether it belongs in one of these.** A host file should be a thin platform arm: hit-test, call the shared flow, run the platform tail. Genuinely platform-specific behaviour stays host-side **with a comment saying why**. + +Pure state transitions — no widget types, wasm32-clean — live in **`op-editor-core/src/`**: + +| Module | Owns | +| ---------------------------------- | ----------------------------------------------------------------------------------------- | +| `host_ui_transitions.rs` | Chrome-state transitions (the byte-identical twin blocks) | +| `host_press_transitions.rs` | Press-path chrome transitions | +| `host_keyboard_transitions.rs` | Keyboard-driven editor-state transitions | +| `host_escape_transitions.rs` | The Escape ladder — exactly one layer dismissed per press | +| `host_drag_state.rs` | The transient pointer-drag structs both spines used to declare separately | +| `host_drag_transitions.rs` | Canvas press/drag state transitions | +| `host_variables_transitions.rs` | VariablesPanel press arms | +| `host_variables_commit.rs` | VariablesPanel draft commits (header renames + per-row Name/Number/String/Color drafts) | +| `host_settings_commit.rs` | Settings-modal draft commit | +| `host_image_panel_transitions.rs` | Image-node panel transitions (Search / Generate popovers) | +| `host_preset_name_draft.rs` | The variables preset "save as name" input | +| `request_snapshot.rs` | `narrowed_snapshot` — see below | +| `agent_reveals.rs` | Before/after node-id diff + staggered reveal bookkeeping for freshly inserted subtrees | +| `auth_routes.rs` | Device-login HTTP route paths shared by the web shell (client) and serve-web daemon (server) | + +Widget-typed shared logic lives in **`op-editor-ui/src/widgets/`**: + +| Module | Owns | +| ---------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| `press_flow.rs` | Press dispatch — hosts map a hit-test result through it and run only the tail | +| `chat_click_flow.rs` | Chat-panel click dispatch (`apply_chat_hit`) | +| `property_panel_dispatch.rs` + `_support.rs` | PropertyPanel action dispatch (was two ~500-line near-verbatim copies) | +| `property_panel_commit.rs` | Both ends of the PropertyPanel draft lifecycle (`property_focus_initial` + commit) | +| `property_panel_layout_ops.rs` | Layout / sizing / typography writers behind the button groups | +| `drag_flow.rs` | Canvas node-drag flow | +| `marquee_flow.rs` | Marquee-release commit (screen→doc rect walk + additive/replace selection) | +| `image_crop_flow.rs` | Figma-style image-fill crop gesture | +| `image_popover_input_flow.rs` | Mouse selection in the image Search / Generate text inputs | +| `scroll_flow.rs` | Panel wheel / trackpad-pan scrolling | +| `cursor_hover_flow.rs` | Cursor-move hover resolution | +| `account_press_flow.rs` | Sign-in modal + signed-in account-dropdown press flow | +| `missing_fonts_flow.rs` | Missing-font prompt: scroll, press dispatch, detection/refresh bookkeeping | +| `variables_panel_geometry_flow.rs` | Floating VariablesPanel placement + resize clamping | +| `agent_settings_press_flow.rs` + `_entries.rs` + `_focus.rs` | Agent-settings modal press dispatch, list-entry arms, and input focus seeding | +| `host_canvas_geometry.rs` | The coordinate invariant (see "Hit-test order") | +| `host_overlay_geometry.rs` | On-screen rects for dropdowns, the three draggable floating panels, StatusBar actions | +| `host_frame_bookkeeping.rs` | Per-frame work both spines do around paint (cache owner rotation, layout transitions, animation deadlines) | +| `menu_paint.rs` | Shared dropdown row chrome (hover tint, hit rect, divider) for file + account menus | +| `settings_form.rs` | Shared card-form chrome for the Agent settings sections | +| `test_capture_backend.rs` | Test-only `RenderBackend` stub recording `fill_round_rect` calls | + +`op-editor-ui/src/accessibility_regions.rs` (crate root, not `widgets/`) single-sources the a11y region walk behind native's `widget_host/a11y.rs` and web's `a11y_bridge.rs`. + +Outside the widget hosts the same rule applies: settings payload serde lives in `op-editor-host-core/src/settings_payload.rs`, image generate / search in `op-host-services/src/web_image_{generate,search}.rs`, and the `--mcp` / `--mcp-http` / `--serve-web` argv dispatch in `op_host_services::cli_modes` (shared by `op-host-desktop` and `op-host-web-server`, which differ only in what an unknown mode means). Desktop no longer carries drifted copies of any of them. + +### `EditorUiState` split + narrowed snapshots + +`op-editor-core/src/editor_ui_state.rs` is a spine over seven siblings (`chrome`, `defaults`, `git_panel`, `groups`, `methods`, `pickers`, `tests`), with re-exports keeping every import path stable. Three measured field clusters became substructs in `editor_ui_state/groups.rs` — `PreviewState`, `SizeToggleState`, `DesignMdPanelState` (192 → 176 flat fields). Larger clusters were rejected on workspace touch-count, so don't group further without measuring. + +`request_snapshot::narrowed_snapshot(&mut EditorState)` is the sanctioned way to hand editor state to a worker thread. The MCP request path, the chat-launch path, and the desktop design pump's per-apply-ack all use it: it drops `chat` / `codegen` / `theme_presets`, which grow with session length and are read by no consumer on the worker side. A full `EditorState::clone()` on a request path is a bug. ## Canonical `.op` / `.pen` loader (v0.8.0+) @@ -409,16 +512,18 @@ Every host close path clears its respective hover state so reopening starts un-h `persistence.rs::show_error_dialog` pops a native `rfd::MessageDialog` on every failed load / save / export with a bilingual (EN / ZH per `Document.ui.locale`) title + path + detail body. `OpenRecent` failures additionally prune the stale entry from `Document.ui.recent_files`. -## MCP server (shell-core) +## MCP server (`op-mcp`) -`crates/openpencil-shell-core/src/mcp.rs` (+ siblings under `mcp/`) carries the MCP wire layer that mirrors the TS `packages/pen-mcp` server. Serde-free hand-rolled JSON-RPC parser keeps the wasm32 bundle small. +The **`op-mcp`** crate carries the MCP wire layer that mirrors the retired TS `packages/pen-mcp` server: a flat `op-mcp/src/*.rs` module set with `lib.rs` as the spine (types + `ToolRegistry` + `run_stdio*`). There is no `mcp/` subdirectory. A serde-free hand-rolled JSON-RPC parser keeps the wasm32 bundle small. ### Tool catalog -**113 static-schema tools** registered today (v0.8.0+). The -authoritative list is `openpencil-desktop/src/mcp_serve.rs` — -`TOOL_SCHEMAS` (JSON inputSchemas) plus the `rebuild_registry` -`r.register(...)` calls, guarded by an exact-count assertion test +**123 static-schema tools** registered today. The authoritative list +is `op-host-services/src/mcp_serve/schemas.rs` — `TOOL_SCHEMAS` +(JSON inputSchemas; `DEBUG_TOOL_SCHEMAS` is gated out of the +production catalog) — plus the `register_tool!` calls in +`op-host-services/src/mcp_serve.rs`, guarded by an exact-count +assertion test in `mcp_serve/tests.rs` (`tools_list_response_includes_all_registered_tools`). A further **37 kit-component tools** (`insert_`, one per built-in UIKit component — 6 starter-kit + 31 shadcn) are appended dynamically @@ -439,27 +544,27 @@ By category: `get_history_depth` / `get_viewport` / `get_selection_set`. Each snapshots `Document` state at registration. - **Node write (insert/update/delete/move/copy/replace)** — - `mcp/write_tools.rs`. + `op-mcp/src/write_tools.rs`. - **Per-node attribute writers** — `set_node_rotation` / `_text` / `_corner_radius` / `_font_size` / `_font_weight` / `_stroke_hex` / - `_stroke_width` / `_fill_hex` / `_name` (`mcp/node_attr_tools.rs`). + `_stroke_width` / `_fill_hex` / `_name` (`op-mcp/src/node_attr_tools.rs`). - **Selection / clipboard / canvas ops** — `set_selection` / `set_selection_set` / `toggle_node_selection` / `clear_selection` / `duplicate` / `delete` / `nudge` / `group` / `ungroup` / `reorder` / `align_selected` / `copy_selected` / `cut_selected` / `paste_clipboard` / `set_active_tool` / `set_viewport` / `set_node_hidden|locked|collapsed` / `undo` / `redo` - (`mcp/selected_ops_tools.rs` + `mcp/component_tools.rs`). + (`op-mcp/src/selected_ops_tools.rs` + `op-mcp/src/component_tools.rs`). - **Pages + components** — `add_page` / `rename_page` / `delete_page` / `duplicate_page` / `reorder_page` / `set_active_page` / `instantiate_component` / `create_component` - / `delete_component` / `rename_component` (`mcp/component_tools.rs`). + / `delete_component` / `rename_component` (`op-mcp/src/component_tools.rs`). - **Variables + themes** — `set_variable_color|number|string|boolean` / `create_variable` / `delete_variable` / `rename_variable` / `set_active_axis_value` / `cycle_active_axis_value` - (`mcp/scalar_vars.rs` + `mcp/component_tools.rs`). + (`op-mcp/src/scalar_vars.rs` + `op-mcp/src/component_tools.rs`). - **Batch design** — `batch_design` / `design_skeleton` / - `design_content` / `design_refine` (`mcp/batch_design.rs`). + `design_content` / `design_refine` (`op-mcp/src/batch_design.rs`). `batch_design` accepts exactly one of `nodes_json` (a flat descriptor array), `operations` (the insert-only `I(parent, node)` program DSL), or `script` — a JavaScript program sandboxed through @@ -479,7 +584,7 @@ Read tools snapshot `Document` state at registration time. Write tools stay `&se ### Wire-format hardening -`mcp/parser.rs::parse_tool_call` accepts both the real MCP `tools/call` envelope and the legacy direct-method shape. Multiple stop-gates baked in: +`op-mcp/src/parser.rs::parse_tool_call` accepts both the real MCP `tools/call` envelope and the legacy direct-method shape. Multiple stop-gates baked in: - **Structured args reject the parse.** `parse_flat_object_body` returns `None` on `{` or `[` for any value; `parse_tool_call` propagates that through a tri-state (`ParamsResult::Missing` / `Body` / `Malformed`). No scalar tool ever sees an object or array as a string-typed arg — earlier sentinel approaches were dropped because a literal `{...}` could collide with a real variable name. - **`arguments` field is a top-level walker.** `arguments_field` iterates top-level key/value pairs in the params body so a nested `meta.arguments` can't shadow the real top-level field, and `"name":"arguments"` doesn't false-positive. @@ -488,27 +593,41 @@ Read tools snapshot `Document` state at registration time. Write tools stay `&se ### File layout +All paths below are `op-mcp/src/` — flat, no subdirectory: + ``` -mcp.rs Spine: types + ToolRegistry + run_stdio* -mcp/json_serializer.rs JSON-RPC wire serializer (response_to_json + helpers) -mcp/parser.rs Wire parse — tri-state arguments_field, top-level walker -mcp/tools.rs Core read tools -mcp/extra_read_tools.rs get_node_children (carved off tools.rs at the 800-cap) -mcp/write_tools.rs Core node write tools (insert/update/delete/move/copy/replace) -mcp/node_attr_tools.rs Per-node attribute writers (rotation/text/font/stroke/fill/name) -mcp/selected_ops_tools.rs Selection ops (dup/delete/nudge/group/align/clipboard) + tests -mcp/component_tools.rs Components + pages + selection/viewport/flag/tool/undo tools -mcp/batch_design.rs BatchDesign tool + hand-rolled nodes_json parser -mcp/scalar_vars.rs Scalar + Color variables + create/delete/rename_variable -mcp/*_tests.rs Per-module sibling test files (800-line cap convention) -mcp_tests.rs (in crate root) Cross-cutting: stdio dispatch, parser invariants +lib.rs Spine: types + ToolRegistry + run_stdio* +json_serializer.rs JSON-RPC wire serializer (response_to_json + helpers) +parser.rs Wire parse — tri-state arguments_field, top-level walker +tools.rs Core read tools +read_tools.rs / read_nodes.rs / extra_read_tools.rs / read_tools_extra.rs + Read-tool families carved off tools.rs at the 800-cap +write_tools.rs Core node write tools (insert/update/delete/move/copy/replace) +write_tools_import_svg.rs SVG import arm of write_tools +write_tools_variables.rs Variable-write arm of write_tools +node_attr_tools.rs Per-node attribute writers (rotation/text/font/stroke/fill/name) +selected_ops_tools.rs Selection ops (dup/delete/nudge/group/align/clipboard) +component_tools.rs Components + selection/viewport/flag/tool/undo tools +page_tools.rs Page CRUD tools +batch_design.rs BatchDesign tool + hand-rolled nodes_json parser +batch_design_{dsl,normalize,fill_normalize,result,wire}.rs + The batch_design DSL, normalizers, and wire shapes +batch_program*.rs The `I(parent, node)` program DSL: parse / resolve / exec +batch_program_error.rs `ProgramError` (typed InvalidValue / ValueOutOfRange variants) +batch_layered.rs Layered design workflow +batch_layered_guidelines.rs Its guideline text (split off at the cap) +scalar_vars.rs Scalar + Color variables + create/delete/rename_variable +script_runner.rs Sandboxed JS runner behind `batch_design { script }` +element_tools.rs insert_ kit-component tools +*_tests.rs Per-module sibling test files (800-line cap convention) +mcp_tests.rs Cross-cutting: stdio dispatch, parser invariants ``` ### Host wiring -`openpencil-desktop --mcp ` (`crates/op-host-desktop/src/mcp_serve.rs`) runs a JSON-RPC stdio MCP server backed by the .op file at ``. External CLIs (Claude Code / Codex / OpenCode / Kiro / Copilot / Antigravity / Grok Build) spawn the binary in this mode to drive the Rust editor. +`openpencil-desktop --mcp ` (`crates/op-host-services/src/mcp_serve.rs`, shared by the desktop binary and `op-host-web-server` via `op_host_services::cli_modes`) runs a JSON-RPC stdio MCP server backed by the .op file at ``. External CLIs (Claude Code / Codex / OpenCode / Kiro / Copilot / Antigravity / Grok Build) spawn the binary in this mode to drive the Rust editor. The module is itself a spine over `mcp_serve/` siblings: `schemas.rs` (the `TOOL_SCHEMAS` catalog), `wire.rs`, `sniff.rs` (top-level method / id sniffing), `error.rs` (`McpServeError`, which separates protocol faults from socket failures), `file_path.rs`, `doc_sync.rs`, and the export / screenshot tool arms. -- Handshake: `initialize` returns protocol version + capabilities + serverInfo; `tools/list` enumerates all 21 tools with JSON inputSchemas; `notifications/initialized` + `ping` handled inline. +- Handshake: `initialize` returns protocol version + capabilities + serverInfo; `tools/list` enumerates the full catalog with JSON inputSchemas; `notifications/initialized` + `ping` handled inline. - Per-call lifecycle: re-build the `ToolRegistry` against the live document (so read-tool snapshots reflect prior writes) → dispatch through `run_stdio_with_applier` → applier closure mutates the doc + saves to disk on each successful write. - Top-level method / id sniffing uses the same key-walker discipline as the wire parser so nested keys can't shadow the real top-level fields.