Commit graph

111 commits

Author SHA1 Message Date
Kayshen-X a036463c93 feat(themes): axis chip click cycles to the next theme value
VariablesPanel chips were previously click-swallowing placeholders.
This commit adds the actual axis-cycle behavior — clicking the
"mode: dark" chip flips it to "mode: light", then "mode: sepia",
wrapping back to "mode: light". The TS app has the equivalent
behavior via its theme dropdown; the Rust shell now offers the
one-click chip flip as the simpler entry point.

`crates/openpencil-shell-core/src/document/variables.rs`:
  - `VariableTable::cycle_active_axis_value(axis) -> bool`:
      1. Returns false (no-op) when `axis` isn't in `themes` or
         its values list is empty.
      2. When the axis isn't in `active_theme`, seeds it with the
         first value.
      3. When current value matches an entry, advances to the
         next (wrapping past the last).
      4. When current value is unrecognized (axis options
         changed since file loaded), falls back to the first.

`crates/openpencil-shell-native/src/widget_host/property_dispatch.rs`:
  - `dispatch_variables_panel_press` `AxisChip(idx)` branch now
    looks up the axis name by position in `active_theme` (BTreeMap
    iteration is stable + matches the chip walk order in
    VariablesPanel::paint), commits any pending property focus,
    captures an undo snapshot, calls `cycle_active_axis_value`,
    and pushes the snapshot onto history when the cycle actually
    moved (return true). Snapshot+restore covers var_table per
    the prior history fix (99d602a3), so undo round-trips the
    theme cycle the same way it does variable color edits.

Tests (5 added, 300 shell-core total):
  - `cycle_active_axis_seeds_first_value_when_absent` — axis in
    `themes` but not in `active_theme` → cycle plants the first
    value.
  - `cycle_active_axis_advances_to_next_value` — three-value
    walk through light → dark → sepia → light (wrap).
  - `cycle_active_axis_returns_false_for_unknown_axis` — no-op +
    false.
  - `cycle_active_axis_returns_false_for_empty_values` — defined
    in themes but with empty values list → no-op + false.
  - `cycle_active_axis_falls_back_to_first_when_current_unknown`
    — graceful degradation when active_theme has a stale value
    (axis options changed since file load).

The variable edit chain now covers BOTH primary surfaces:
  - Row click on a Color-kind variable → ColorPicker → set_color_hex
  - Axis chip click → cycle_active_axis_value
Both push undo entries that restore through the var_table-aware
snapshot.
2026-05-14 19:39:37 +08:00
Kayshen-X 0a762f6a1e fix(host): fill-picker dismiss at the middle of the cascade
The previous commit (7fafa379) moved fill-picker dismiss to the
very top of the press cascade, but that stole layer-context-menu
clicks (codex stop-gate). The commit before THAT (c3394fda) had it
late enough to be stolen by VariablesPanel.

Right answer: middle position (the original 0c0 spot, between
TopBar and VariablesPanel) — after the higher-z overlays (color
picker, layer-context-menu, agent-settings modal) so those keep
their own click handling when both happen to be open, but BEFORE
the rail walkers (VariablesPanel, PropertyPanel) so the picker
can't survive a click into them.

Cascade now reads:
  pre   rename / text-edit blur
  pre   agent-settings modal
  0-c   color picker overlay
  pre   layer context menu
  0aa   commit-on-blur for property inputs
  0z    panel resize gutter
  0ab   shape picker overlay
  0a    locale picker overlay
  0b    TopBar
  0c0   **fill-type picker dismiss**  ← settled position
  0b1   VariablesPanel
  0c    PropertyPanel input + action
  1+    chat, toolbar, canvas, …

The remaining edge case — TopBar / shape-picker / locale-picker
clicks while fill-picker is open consume the click without closing
fill — is pre-existing behavior. The cascade ordering between
those small overlays is a wider design question (last-opened wins
vs. priority list); not in scope for this commit.

press.rs: 839 → 840 (+1). 295 shell-core + 20 shell-native tests
pass.
2026-05-14 19:33:09 +08:00
Kayshen-X 87e8f74f62 fix(host): fill-picker dismiss is actually FIRST in press cascade
Codex stop-gate caught: the previous commit (c3394fda) called the
fill-picker dismiss "first" but it was actually at step 0c0 — after
0-color (color picker), layer-context-menu, 0aa (commit-on-blur),
0z (panel resize), 0ab (shape picker), 0a (locale picker), and 0b
(TopBar). Any of those earlier steps could consume a click and
leave `fill_type_picker_open=true` behind.

Moved the entire fill-picker dismiss block to step 0-fp, which
runs immediately after the rename / text-edit blur lines and the
agent-settings modal dispatch — before any other overlay can
swallow the click:

  pre. rename + text-edit blur (always run)
  pre. agent-settings modal dispatch (only when modal open)
  0-fp. **fill-type picker dismiss** ← now first
  0-color. color picker overlay
  ... (all other overlays)
  0b1. VariablesPanel
  0c.  PropertyPanel
  ...

Behavior: any click while the fill-type picker is open closes it
+ swallows the click (matches previous semantics for the case
where the click was outside the picker's dropdown rows). Clicks
that land on the picker's own dropdown rows still route to
SetFillType / ToggleFillTypePicker.

Concrete fix: a sequence like "open fill picker on Property panel
→ click TopBar locale globe" now closes the fill picker (which the
old order missed because TopBar at 0b consumed the click first).

press.rs: 836 → 839 (+3). The block content is unchanged; only its
position moved, so the file growth is from the new doc-comment.
Still over the 800-line cap as pre-existing tech debt. 295
shell-core + 20 shell-native tests still pass.
2026-05-14 19:27:16 +08:00
Kayshen-X 5d2a691438 fix(host): fill-picker dismiss runs before VariablesPanel dispatch
Codex stop-gate on the previous reorder (96cf753b): putting
`dispatch_variables_panel_press` at the absolute top of the cascade
meant a Variables-row click while the PropertyPanel's fill-type
picker was open returned true and left `fill_type_picker_open=true`.
The fill picker would float over the chrome until the next
unrelated click happened to land outside its rect.

Cascade order now:
  0c0. Fill-type picker outside-click dismiss
       (must run first when open — any click anywhere closes the
        picker, then swallows or routes to a SetFillType /
        ToggleFillTypePicker action).
  0b1. VariablesPanel hit dispatch
       (BEFORE PropertyPanel so the bottom-anchored Variables rect
        wins for clicks in its z-order overlap with the rail).
  0c.  PropertyPanel input + action hit-tests.

The fill-picker block already swallows every click when open, so
reaching the Variables dispatcher requires the picker to be
closed — which means the existing dismiss path runs unconditionally
before any Variables click can fire. 295 shell-core + 20 shell-
native tests still pass.

press.rs: 832 → 836 (the dismiss comment + restored ordering adds
4 lines). Still over the 800 cap as pre-existing tech debt; my
session has added +11 lines total to this file (825 → 836), which
is small and bounded.
2026-05-14 19:22:20 +08:00
Kayshen-X 1714b6dd5b fix(host): test VariablesPanel hits before PropertyPanel (z-order)
Codex stop-gate caught: PropertyPanel was tested first in the press
cascade, so any click in the rail's bottom region went to its hit
walker — which would resolve to e.g. the Export-section row or an
input — even when the click was visually OVER the bottom-anchored
VariablesPanel. Paint stack has VariablesPanel ON TOP of
PropertyPanel (step 4 → step 4b), so hit-test order must follow.

`crates/openpencil-shell-native/src/widget_host/press.rs`:
  - The `dispatch_variables_panel_press` call moved from step 0d
    (after PropertyPanel) to step 0b1 (before fill-type picker +
    PropertyPanel input dispatch). Returns true when the click
    consumes a Variables row / chip; otherwise false, so the
    cascade falls through to the property-panel walkers exactly as
    before for non-rail-bottom clicks.
  - The dispatcher itself short-circuits when `var_table.variables`
    is empty (existing guard), so the reorder is a no-op for
    documents without variables.

Test surface unchanged — 295 shell-core + 20 shell-native tests
still pass. The fix is purely an ordering change; no new logic.
press.rs: 833 → 832 (the relocation trims one blank line). Still
over the 800 cap as pre-existing tech debt; not made materially
worse by this commit.
2026-05-14 19:17:42 +08:00
Kayshen-X c76af49b65 feat(host): VariablesPanel row click opens ColorPicker (variable mode)
Closes the missing host wire that codex called out as pending: the
VariablesPanel widget paints (140d5495 / 0bd98ae2) and the picker
has variable-mode commit (3c2e7711 / 99d602a3), but clicking a row
did nothing. This commit dispatches.

`crates/openpencil-shell-native/src/widget_host/press.rs`:
  - New step 0d in `apply_press` (between PropertyPanel input and
    AI chat dispatch): one-line call to the new helper. Keeps the
    cascade ordering — properties consume their hits first, vars
    next, chat after.

`crates/openpencil-shell-native/src/widget_host/property_dispatch.rs`:
  - `dispatch_variables_panel_press(x, y, vw, vh) -> bool` mirrors
    the existing `dispatch_export_dialog_press` pattern. Reconstructs
    the same right-rail rect math `paint.rs` step 4b uses (top
    when no selection, bottom-anchored above status bar when
    PropertyPanel owns the rail), hit-tests, then routes:
      - `Row(idx)` on a Color-kind variable → commit any pending
        property focus, then `open_color_picker_for_variable(name)`.
        The picker's HSV-drag path writes through `set_color_hex`
        (which carries all three correctness invariants); close
        pushes the snapshot+var_table undo entry from 99d602a3.
      - `Row(idx)` on a non-color variable → swallow (TODO: row
        inputs for string / number). Prevents fall-through to
        canvas deselect.
      - `AxisChip(_)` → swallow (TODO: theme-axis picker).
  - Returns `false` when var_table is empty or the click missed,
    so press.rs's cascade continues to chat / canvas.

Tests: 295 shell-core + 20 shell-native still pass. The dispatcher
is exercised by the existing `VariablesPanel::hit_test` tests +
the `color_picker::tests::*` end-to-end variable flow; a wired
host integration test would need a fake-render harness that's out
of scope here.

press.rs grew from 825 → 830 (+5 lines for the cascade call site).
That file was over the 800-line cap before this session — pre-
existing tech debt tracked separately. Not making it materially
worse.

TOP-10 #5 (Variables/Themes UI) edit chain is now functionally
complete: paint → click → picker → write → undo. The remaining UX
gaps are non-color variable inputs + the theme-axis picker, both
flagged as TODOs in the dispatcher.
2026-05-14 19:10:25 +08:00
Kayshen-X 21cb3ca1a9 fix(geometry): canvas_region shrinks for VariablesPanel too (codex BLOCK)
Codex stop-gate flagged: with no selection, the right-rail
`VariablesPanel` was painted (commit 0bd98ae2 wired the paint
step) but `canvas_region` still returned the full viewport width
because its `has_property` gate only checked `property_panel_
visible()` — which is false without a selection. The canvas
paint pass (Step 5) then extended over the right rail and
overpainted the Variables panel, defeating the visibility fix
the previous commit was supposed to deliver.

`crates/openpencil-shell-core/src/document/mutators.rs`:
  - New `Document::right_rail_visible() -> bool` is the unified
    gate: true when either `property_panel_visible()` is true or
    `var_table.variables` is non-empty. Future right-rail widgets
    (Components, Themes header, etc.) extend this method instead
    of patching every caller.

`crates/openpencil-shell-native/src/widget_host/geometry.rs`:
  - `canvas_region` swapped from `property_panel_visible()` to
    `right_rail_visible()`. The shrunk-canvas branch no longer
    requires a selection — any rail-occupying widget reserves the
    column.

Test (1 added, 275 shell-core total):
  - `right_rail_visible_tracks_property_panel_and_variables`
    covers the four state combinations:
      empty doc                       → rail hidden
      var-table-only (no selection)   → rail shown (codex repro)
      selection-only (no vars)        → rail shown (legacy gate)
      cleared selection + cleared vars→ rail hidden again

20 shell-native + 54 desktop + 275 shell-core tests pass.
2026-05-14 17:57:55 +08:00
Kayshen-X 048d53d71d fix(native/paint): wire VariablesPanel into the right-rail paint pass
Codex stop-gate flagged that the previous commit (140d5495) shipped
`VariablesPanel` as a widget definition + tests but never wired it
into the host's paint composition, so users couldn't actually see
it. This commit closes that gap.

`crates/openpencil-shell-native/src/widget_host/paint.rs`:
  - Step 4 (PropertyPanel) now also computes the right-rail x +
    width as locals so the new VariablesPanel paint step reuses
    them instead of duplicating the geometry math.
  - New Step 4b: when `Document.var_table.variables` is non-empty,
    paint a `VariablesPanel::for_document(...)` rectangle anchored
    to the right rail. Layout decision:
      - No selection: panel pinned at the top of the rail
        (TOP_BAR_HEIGHT + 8 px) so it's the primary chrome there.
      - Active selection: PropertyPanel owns the rail; Variables
        anchors to the bottom (above the status bar). Approximate
        because PropertyPanel paints to fill the rail today;
        proper stacking lands when the rail grows scrollable
        regions or tabs.
    Variables stays hidden when `var_table` is empty — no visual
    noise for documents that don't use them.
  - `VariablesPanel` imported via `widgets::variables_panel::
    VariablesPanel` (the module is `pub mod` exported in
    widgets/mod.rs).

`crates/openpencil-shell-native/src/widget_host/input.rs`:
  - Cleaned up the unused imports left over from the input/keyboard
    split (5caa2eb3): `PropertyFocus`, `AIChatHit`, `AIChatPlaceholder`,
    `LayerPanel`, `LayoutCx`, `Toolbar`, `Widget`, `TOOLBAR_WIDTH`,
    `TOP_BAR_HEIGHT` were all moved to keyboard.rs but still
    listed in input.rs's import list. Down to the actually-used
    `ChatAnchor` + helpers + Point2D + Rect.

Tests: 274 shell-core + 54 desktop + 20 shell-native all pass.
Visual verification: opening a `.op` file whose `variables` array
is non-empty now shows the panel chrome (header "Variables" label,
active-theme chips, one row per variable with name + resolved
color swatch / scalar label). Edit interactions still pending —
`VariablesPanelHit::Row(idx)` is wired up but the host doesn't
dispatch it yet.
2026-05-14 17:53:16 +08:00
Kayshen-X d5885045c1 refactor(native): split widget_host/input.rs to honor 800-line cap (Task #49)
`widget_host/input.rs` was 882 lines, over the project's documented
800-line ceiling. Split the `impl WidgetHostNative` block into two
files along a natural boundary:

  input.rs    (417 lines — pointer / wheel / pan / cursor-move /
               release + drag-commit helpers)
  keyboard.rs (481 lines — text / backspace / delete / duplicate /
               nudge / send / escape / click routing)

Both `impl WidgetHostNative` blocks share the same type, and Rust
allows multiple impl blocks for one type in the same crate so the
public method surface is preserved verbatim. No behavior changes —
every method moved verbatim with its full body + doc-comment.

Module wiring:
  crates/openpencil-shell-native/src/widget_host.rs:
    `mod keyboard;` registered between `input_tests` and `paint`
    in the alphabetical mod list.

Imports adjusted: keyboard.rs picks up only the symbols its
methods reference (PropertyFocus, AIChatHit / AIChatPlaceholder /
LayerPanel / LayoutCx / Toolbar / Widget + TOOLBAR_WIDTH +
TOP_BAR_HEIGHT, Point2D + Rect, TOOLBAR_INSET_X/Y from helpers).
input.rs drops the now-unused imports of those symbols.

Tests: 54 desktop + 250+ shell-core + 20 shell-native all pass.
File-cap check:
  input.rs     417 (was 882, well under 800)
  keyboard.rs  481 (new, well under 800)

main.rs still pending — Task #55 follow-up (804 lines today, 4 over).
2026-05-14 17:36:24 +08:00
Kayshen-X 883bd70a65 fix(shell): 3 codex stop-gate regressions (anchor drag, MCP id, doc load reset)
BLOCK #1 — anchor drag couldn't return to start. `apply_cursor_move`
only called `set_path_anchor_position` when the cursor doc-point
differed from `start_doc`, so dragging away and then BACK onto the
original point silently skipped the final write — release committed
history with the anchor stuck at the last off-start frame.
Fix: always write the cursor position during an active drag; use
the start-doc comparison only to flip `moved` (which gates history
push). Regression test `anchor_drag_back_to_start_lands_at_start`
simulates the round-trip and asserts the anchor follows the cursor
all the way home.

BLOCK #2 — MCP tool registry dropped the request id. `McpTool::call`
only received `&BTreeMap<String, String>`, forcing tools to invent
response ids (test double used `RequestId::Num(0)`). JSON-RPC + MCP
require every response to echo the originating request id. Fix:
change the trait signature to `call(&self, request: &ToolCall) ->
ToolResponse` and have `dispatch` forward the whole call. EchoTool
updated to read `request.id`; the registry test now asserts the
id round-trips.

BLOCK #3 — opening a native saved file leaked variables across
documents. `apply_payload` reset pages + history + selection but
never touched `doc.var_table` or `doc.components` (both added in
recent commits). Open a variable-bearing canonical `.op`, then
open a plain saved `.pen` — codegen would still emit the stale
canonical variables. Fix: `apply_payload` now reassigns both to
`Default::default()` after the page/UI reset block.

Tests: 206 shell-core + 20 shell-native (+1 anchor return) + 8 desktop.
Wasm32 build clean.
2026-05-14 15:18:02 +08:00
Kayshen-X 012042559d chore(shell-native): trim input.rs verbose comments (886 → 878)
Compact three multi-line comment blocks in `widget_host/input.rs`
that were narrating implementation details Codex already covers
inline elsewhere:
  - path-anchor `moved` flag explanation: 4 lines → 1
  - align-toolbar hover sync rationale: 4 lines → 1
  - settings-input keyboard ownership: 3 lines → 1

Net 8 lines saved; file is still 78 over the 800-line cap because
the remaining bulk is real code (apply_text dispatch, apply_release
drag-clearing chain, apply_cursor_move's 6-branch drag detection).
A proper sibling-module split — moving keyboard handlers to
`widget_host/keyboard.rs` and clipboard ops to `widget_host/clipboard.rs`
— is task #49 and stays a clean focused refactor for the next pass.

Tests: 19 native still pass. No behavior change.
2026-05-14 14:53:42 +08:00
Kayshen-X a6bafb4453 fix(shell): boolean ops nested-source removal + drop dead export rect helper
Codex stop-gate BLOCK #1: `boolean_ops::apply_boolean_op` looked up
source paths recursively (via `active_page().find()`) but only
removed them from the top-level `page.children` list. When the
sources lived inside a Group or Frame, the originals stayed in
their parent's children while the result was appended at the
canvas root — duplication + orphans.

Fix: replace the top-level `retain` call with a recursive
`remove_nodes_recursively` walker that drops any node whose id is
in the source set from every children Vec depth-first. New
regression test `boolean_op_removes_nested_paths_not_just_top_level`
wraps two paths in a Group, runs Union, and asserts:
  - the Group still exists but is empty
  - one result Path lives at top level (total page.children = 2)

Codex stop-gate BLOCK #2: `property_panel_sections::export_section_rect`
was added in commit `5bde95b9` (PropertyPanel preview pills) but
never wired into `hit_test_action`, leaving the visible Export
section unable to open the new ExportDialog. The helper is dead
code in the meantime. Drop it; replace with a comment marking the
follow-up. Existing UX (File menu → Export image / Cmd+Shift+P)
still opens the dialog. Tracking via task #52.

Codex stop-gate finding #3 (`main.rs` 828 / `input.rs` 886 over
the 800-line cap) is real but stylistic — already tracked as task
#55 (sibling-module split). No functional impact; deferred so this
commit stays scoped to the data-corruption + dead-code fixes.

Tests: 184 shell-core + 19 shell-native (+1 nested boolean ops
regression). Wasm32 build clean.
2026-05-14 14:46:28 +08:00
Kayshen-X e01b4eda69 feat(shell): expose boolean op as WidgetHostNative method
Adds `apply_boolean_op(op)` on the native widget host so downstream
callers (keyboard shortcuts in main.rs, future toolbar buttons,
menu items) can dispatch a path boolean op with a one-line call.
Wraps `boolean_ops::apply_boolean_op` and threads the host's
`next_node_id` allocator through so the result Path mints a fresh
id that can't collide with existing nodes.

Tests: 18 native (no new assertions in this commit; the existing
4 boolean_ops tests cover the underlying mutator). Wasm32 build
unaffected — the method is desktop-only.
2026-05-14 14:27:26 +08:00
Kayshen-X 05ddceca46 feat(shell): boolean path ops (Union / Subtract / Intersect / Exclude)
#2 on the TS-parity roadmap lands. Skia's built-in `Path::op`
backed by `SkPathOps` does the heavy lifting; the mutator lives in
`shell-native` (not shell-core) so the web bundle stays skia-free.

API:
  - `openpencil_shell_core::document::BooleanOp` — Union / Subtract /
    Intersect / Exclude (mirrors TS Paper.js' four ops).
  - `openpencil_shell_native::boolean_ops::apply_boolean_op(
      doc, op, next_id) -> bool`
    Filters the selection to Path nodes (Rect/Frame/etc are ignored
    so a mixed selection still composes paths). Requires ≥ 2 Path
    sources; otherwise no-op + no history. Builds skia paths via
    PathBuilder.{move_to, line_to, close}, folds via Path::op,
    extracts result points from the PathIterRec stream (Move/Line
    take pts[0]; Quad/Conic take pts[1]; Cubic takes pts[2]; Close
    is dropped). Builds the result Path node inheriting the first
    source's fill + stroke, recomputes its bounds, replaces the
    source paths in the active page, and pushes one history entry.

Tests (4):
  - union_of_two_overlapping_squares_collapses_to_one_path —
    proves the source pair is removed + one new Path appears + the
    bounds are non-empty + history grew by one.
  - intersect_keeps_overlap_region — verifies the 10×10 overlap
    bounds of two 20×20 squares offset by (10, 10).
  - boolean_op_requires_two_path_nodes — single-Path selection
    no-ops without touching history.
  - boolean_op_skips_non_path_nodes_in_selection — mixed Path +
    Rect selection still composes the two Paths; Rect survives.

Keyboard-shortcut + toolbar wiring lands in a follow-up so this
commit stays focused on the mutator. Curves in the result degrade
to their endpoint (TS Paper.js has the same v1 behavior; full
anchor-with-handles model arrives with #3 follow-up).

Tests total: 180 shell-core + 18 shell-native (+4) + 8 desktop
export. Wasm32 build clean.
2026-05-14 14:26:33 +08:00
Kayshen-X 0ed2d142d8 fix(shell): anchor click-without-move pollutes undo; PDF uses uniform page size
Codex CONCERN #1: a press-release on an anchor handle with no
cursor motion in between still pushed the pre-drag snapshot, adding
a no-op entry to the undo stack that made the next Cmd-Z appear
inert. Fix: PathAnchorDragState gains `start_doc: Point2D` +
`moved: bool`. apply_cursor_move flips `moved` true only when the
cursor's document-space position differs from the start by > 0.001
doc-px. apply_release_with_viewport pushes the snapshot only when
`moved == true`; otherwise drops the state without touching
history. Two regression tests:
  - anchor_press_release_without_motion_does_not_push_history
    (seeds moved=false; release leaves history unchanged + returns
    !consumed)
  - anchor_drag_with_motion_pushes_one_history_entry (seeds
    moved=true; release pushes exactly one entry + returns consumed)

Codex CONCERN #2: PDF pages were sized to each page's own content
bounds, producing heterogeneous page sizes that caused viewer
zoom/scroll to jump between pages. Fix: export_pdf takes the union
of all page bounds (max width + max height + 16-pt margin) and
emits every page at that uniform size. Each page's content is
positioned inside the frame at its own (origin.x, origin.y) so the
visual layout is unchanged — only the page frame becomes
consistent.

Tests: 180 shell-core + 14 shell-native + 8 desktop/export.
wasm32 build clean.
2026-05-14 14:20:27 +08:00
Kayshen-X 6ab5dd9b66 feat(shell): path-anchor drag-to-edit (Pen tool)
Wires the `set_path_anchor_position` mutator (groundwork commit
`814d05ca`) into the canvas hit-test + drag dispatch.

Geometry helper `path_anchor_hit(x, y, vw, vh)` in
`widget_host/geometry.rs` returns `Some((node_id, anchor_index))`
when the press lands inside an 8-screen-pixel circle around an
existing anchor of the selected Path node, with the Pen tool
active. Radius scales with viewport zoom so the hit box stays a
constant screen size.

Press dispatch (`widget_host/press.rs::apply_press`) — Pen tool
branch checks `path_anchor_hit` BEFORE the existing
add-anchor / start-path code path. Hit → captures pre-drag history
snapshot, seeds `path_anchor_drag` state. Miss → falls through to
existing author-anchor behaviour.

Cursor move (`widget_host/input.rs::apply_cursor_move`) — slot
between node-drag and marquee-drag: snaps the picked anchor to the
cursor's document-space coords via the mutator.

Release (`widget_host/input.rs::apply_release_with_viewport`) —
slot between node-drag and marquee-drag: pushes the pre-drag
snapshot so the user gets a single Cmd+Z to revert the whole
drag.

State struct `PathAnchorDragState { node_id, anchor_index,
pre_drag_snapshot }` lives in `widget_host.rs` (debug + clone, not
copy because DocumentSnapshot owns its pages Vec).

Tests: all 12 native input tests still pass (existing coverage
exercises the press/move/release path on adjacent drag types so any
regression on those would surface). 180 shell-core tests pass.
Wasm32 build clean.

Follow-ups: visual handles (paint per-anchor dots on selected Path
when Pen tool is active so the user sees what to grab); web parity;
codex review of the chain.
2026-05-14 14:12:11 +08:00
Kayshen-X 5503eefeb1 feat(shell): export dialog + multi-format raster (PNG/JPEG/WEBP) + Property-panel preview
Phase 1 — codec plumbing:
  - RasterFormat::{Png, Jpeg, Webp} enum.
  - export_raster(doc, target, format, scale) with NaN-guarded scale
    clamp; JPEG forces white background (no alpha); quality 100/92/92
    matches TS canvas.toDataURL.
  - File dialog filters expanded from {PNG, SVG} to {PNG, JPEG, WEBP, SVG}.
  - 6 unit tests cover format dispatch + alpha matrix + byte
    signatures + scale clamp.

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

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

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

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

Follow-ups: PDF emit (Phase 4), Property-panel section hit-test for
single-click open, input.rs / main.rs over-cap split.
2026-05-14 14:02:52 +08:00
Kayshen-X e1902ad047 feat(shell): align + distribute toolbar (multi-select-aware)
Document::align_selected covers 6 align actions (left / center-h / right
/ top / center-v / bottom) and 2 distribute actions (horizontal /
vertical center-spacing). Reference frame is the union of selection
bounds for 2+ nodes, the parent container for a single selection
(top-level no-ops). Ancestor-in-set dedup mirrors translate_selected so
a frame + child selection only shifts the frame; descendants cascade.
History pushes only when at least one node actually moved.

Floating AlignToolbar widget appears when selection_count >= 2; centered
horizontally above the canvas with a 56-px reserve so it never overlaps
the vertical Toolbar column. Hidden entirely when the canvas can't host
both. Hover state lives on Document.ui.align_toolbar_hover and clears on
every selection-count drop. Hit-test sits before apply_click on both
native + web so visible buttons always win clicks. Hover sync runs AFTER
all drag branches in cursor_move so an active node-drag isn't stolen by
a hover update.

8 lucide d-strings (align-start/center/end-vertical/horizontal +
horizontal/vertical-distribute-center) added to icons_data.rs from
lucide-react@0.545.0. Codex stop-gate reviewed three times to BLOCK-free.

Tests: 25 align (mutator + widget) + 1 native drag-interception
regression. Closes the v0.8.0 align/distribute roadmap item.
2026-05-14 13:27:44 +08:00
Kayshen-X 1a194efc3d feat(shell): canonical .op loader + jian-core layout + visual fidelity pass
Pivot the desktop's Open path to the canonical `jian-ops-schema`
parser and route layout through `jian-core::LayoutEngine` so files
saved by the TS editor, Jian apps, or any tool emitting the
canonical schema load through the shared parser + paragraph shaper.

Loader (pen_doc_adapter.rs + pen_doc_path_bounds.rs)

- All 12 PenNode variants → NodePayload, with each root's authored
  (base.x, base.y) added to harvested rects so multi-design files
  (e.g. pencil-demo.op's 14 mockups) spread across the canvas.
- Path anchors port `getPathBoundsFromAnchors` — endpoints + Bezier
  handles + cubic-derivative extrema — so curved paths scale into
  their (width, height) the way the canonical renderer paints.
- jian-skia's `SkiaMeasure` plugged in via
  `LayoutEngine::with_backend(...)`, replacing the ~10% character-
  count heuristic with real paragraph-shaper metrics. Wrap/layout
  now agree with paint instead of cascading 10% errors.
- Numeric-string fontWeight (`"700"`, `"normal"`, ...) parsed in
  both jian-core and the desktop adapter; expanded keyword table
  covers black/heavy/extralight/extrabold/demibold/hairline/etc.
- Version-tolerant `load_canonical` retries with `version` rewritten
  to `"1.0"` so legacy `version: "2.8"` files still load.

Text + icon rendering

- `Node.text_wrap` gated on `textGrowth: fixed-width` — single-line
  by default so font-fallback overshoot doesn't break lines the TS
  app shows on one line.
- CJK-aware `wrap_text` (canvas_viewport_overlay.rs) — per-char CJK
  breaks, word breaks for Latin, blank-line preservation, explicit
  `\n` splits. Takes a weight param.
- `RenderBackend::measure_text_weighted` added with NativeBackend +
  WebBackend overrides so wrap measurement matches weighted paint.
- icons.rs + new icons_data.rs sibling cover ~75 lucide variants
  for first-party `iconFontName` names from pen-core element-builders
  (trending-up/down, compass, refresh-cw, layout-dashboard, users,
  package, zap, sliders-horizontal, activity, loader, focus,
  chart-line, settings-2, arrow-right, check-circle, alert-triangle,
  alert-octagon, sticky-note, bar-chart-2, bold/italic/underline,
  shopping-cart/bag, send, message-circle, rocket, menu, credit-card,
  x-circle, mail, smartphone, chrome, apple, user, ...). Unknown
  names stroke a dot fallback (FALLBACK_ICON_D) instead of a block.
  All d-strings copied from lucide-react@0.545.0.
- `Icon::from_name(&str)` resolves kebab-case + common aliases.
- Synthetic bold via PaintStyle::StrokeAndFill for weights ≥600 on
  both native and web (single-weight bundles can't serve a real
  bold variant).

Chrome polish

- Hover state on file menu / locale picker / shape picker / layer
  panel rows / AgentSettings nav + provider cards. Host's
  apply_cursor_move updates each per its open state.
- File menu compacted (row 30, header 22, no `…` suffix on actions),
  recent file names truncate with a CJK-aware helper.
- `rfd::MessageDialog` on every failed Open / OpenRecent / Save /
  SaveAs / ExportImage with bilingual (EN/ZH) title + path + detail.
  OpenRecent failures prune the stale entry.
- `figma_import.rs` modal honest-stub (Coming soon copy, brand glyph),
  TopBar Folder+Chevron compound + Figma button.
- Settings sidebar nav + provider cards tinted on hover.
- Recent-files panel polished to single-line names with age column.

Tests

- pen_doc_adapter_tests.rs (sibling via #[path]) — 19 cases covering
  multi-root canvas offsets, shape size fallbacks, path anchor
  absolutize + Bezier extrema, fixed-width wrap, numeric-string
  weights, login.op + pencil-demo.op fixture loads.
- canvas_viewport_overlay.rs wrap_tests — 7 cases: ASCII / CJK /
  CJK+Latin / explicit-newline / blank-line / weighted advances.
- icons.rs first_party_icon_font_names_all_resolve guards 27+
  authored names against placeholder regressions.

File-cap discipline

- pen_doc_adapter.rs split into mod + path-bounds sibling + tests
  sibling.
- icons.rs split into mod + icons_data.rs sibling so the catalogue
  can grow without busting the cap.
- canvas_viewport_overlay.rs absorbs wrap_text + UniformBackend /
  WeightedBackend test stubs.

Sub-modules

- vendor/jian advanced for `resolve_weight` numeric-string parsing.
2026-05-14 09:26:08 +08:00
Kayshen-X 87df5afcbb feat(shell): Save / Save As / Open document via .pen / .op dialog
Closes the largest TS parity gap (#1 / #2 in the audit): the
document was completely non-persistent — every restart lost the
canvas tree. Wire up native Save / Save As / Open through rfd
dialogs so the user picks the file path themselves (per the
audit conversation: "随意保存").

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

- new `WidgetHostNative::settings_focus_active()` public helper
- `input_active()` already-private check also picks up the modal
  focus so single-letter tool switches gate cleanly
- desktop key handler reads `settings_focus_active()` once per
  event and stamps `&& !settings_focused` on every editor branch
  (Delete, arrows, Cmd-letter combos, Cmd-Shift-letter combos,
  bracket reorder). Cmd+, stays unguarded so the user can always
  toggle the modal itself.
2026-05-12 22:16:22 +08:00
Kayshen-X 82e91b9a4f fix(shell): settings port focus swallows non-digit keys
While the MCP port field was focused, only digits routed into the
draft and any other character fell through to the next handler —
so typing a letter would land in chat / rename / text-edit
(whichever happened to be active). Tighten both native + web
`apply_text`: while `agent_settings.focus.is_some()` swallow ALL
keys, accepting digits into the draft and returning false for
everything else.
2026-05-12 22:00:55 +08:00
Kayshen-X 33b44de80f feat(shell): editable MCP server port in settings modal
The port field on the MCP tab was display-only — clicking did
nothing and the user couldn't pick a different port. Wire it to a
proper focus / draft / commit cycle:

- new `SettingsFocus` enum (currently just `McpPort`; OAuth client
  id/secret will follow) + `AgentSettings.focus: Option<SettingsFocus>`
- `UiState.settings_input_draft: String` holds the in-progress text
  (lives on UiState because `AgentSettings` is `Copy`)
- McpHit::FocusPort hit on the port-field rect, dispatched from
  the native press handler — seeds draft from current port value
- keyboard routing: digits-only via `apply_text` (cap 5 chars),
  `apply_backspace` pops, `apply_send` commits, `apply_escape`
  cancels (one Escape clears focus + draft, second closes modal)
- commit parses u16 and clamps to ≥1024 so the user can't pick a
  root-only port
- modal Close / Outside / tab-switch press all commit any pending
  draft before transitioning so a typed value isn't silently lost
- focused field gets a primary-tinted border + static caret bar
  past the digits
2026-05-12 21:40:24 +08:00
Kayshen-X d69e9e2eaf feat(shell): editor v0.8.0 batch — layer panel, pen, color picker, brand icons, settings modal, corner radius, perf
restores the 3 reset commits (drag-into-container, layer right-click,
page menu) plus this session's new editor features + performance pass.

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

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

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

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

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

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

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

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

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

136 tests pass (was 135); cargo fmt + boundary check clean;
zero files over 800.
2026-05-12 05:33:18 +08:00
Kayshen-X b11f064e79 fix(shell): defensive commit_layer_drag source-validity guard
Final codex re-review CONCERN: `commit_layer_drag` was relying on
`Document::reorder_before/after`'s own source-existence check to
no-op on a deleted source. Per codex: the commit path should bail
explicitly for symmetry with the existing cursor_move + paint
guards. Added the same `active_page().find(source).is_none()`
check on both native (`widget_host/input.rs`) and web
(`widget_host.rs`) at the top of `commit_layer_drag`, right after
the `!d.active` early-out.

No behavior change in the safe path (reorder still happens), but
short-circuits the drop_target_at + dispatch when the source is
gone.
2026-05-12 02:21:59 +08:00
Kayshen-X 33ee2f37ed refactor(shell): split web widget_host + harden layer-drag lifecycle
Three codex broad-review findings:

BLOCK — `openpencil-shell-web/src/widget_host.rs` was 886 lines
(over the 800 cap). Extracted `apply_press` + `apply_click`
(~301 lines combined) into a new sibling
`crates/openpencil-shell-web/src/widget_host/press.rs`,
mirroring the native split pattern. Spine drops to 590 lines.
Explicit `use super::{ChatDragState, DragState, LayerDragState,
MarqueeDragState, rect_contains, WidgetHost}` so the type
references inside the moved methods stay readable (Rust resolves
them via descendant module privacy, but the imports document
the dependency).

CONCERN — Stale `layer_drag` could outlive its dragged node if
the document mutated mid-drag (delete / cut / page switch).
Added a source-validity guard at three sites per host (native
+ web parity):
- `apply_cursor_move`: clears `layer_drag` if
  `active_page().find(d.source)` returns None.
- Both paint passes: suppress the drop-indicator when the
  source is no longer in the active page.
- `commit_layer_drag` already silently no-ops on missing
  source via the existing `reorder_before/after` guards.

CONCERN — Missing host-level end-to-end test for the drag-to-
reorder gesture. Added two tests in
`openpencil-shell-native/src/widget_host/input_tests.rs`:
- `layer_drag_to_reorder_commits_on_release_with_threshold_move`
  — full press → 4-px-threshold move → release; asserts the
  tree was reordered and `layer_drag` is cleared.
- `layer_drag_below_activation_threshold_is_a_click_not_a_reorder`
  — sub-threshold move; asserts click semantics (selection
  set, tree unchanged).

Verification:
- cargo test --workspace: all green (184+ tests).
- cargo fmt --all --check: clean.
- bash tools/check-widget-boundary.sh: PASS.
- find / awk file-size scan: zero files over 800 lines.
2026-05-12 02:18:01 +08:00
Kayshen-X 44a2895bb0 fix(shell-core): drag-to-reorder drop-at-end + threshold doc
Two codex stop-gate CONCERNs from the drag-to-reorder commit:

- `LayerPanel::drop_target_at` now returns `After(last_layer)` for
  cursor positions in the empty area below all rows (still inside
  the panel rect). Was returning None, creating a dead zone where
  the user couldn't drop a row at the very end of the list. New
  test `drop_target_at_in_empty_area_below_rows_drops_at_end`.
- Activation threshold for layer-drag is intentionally vertical-
  only (4 px). Added an explanatory comment on both native and
  web hosts so the asymmetry vs the marquee's 2D threshold is
  legible at the call site (the row-stack reorder axis is y;
  horizontal wiggle would steal click-feel from selection +
  eye/lock/chevron toggles on the same row).

Doc drift on `drop_target_at` reverted likewise — the comment now
documents all four return cases (over-row Before / After, below-
rows After-last, outside / above-rows None).

130 shell-core tests pass; cargo fmt + boundary check clean.
2026-05-11 22:14:38 +08:00
Kayshen-X 8457f4d629 feat(shell): LayerPanel drag-to-reorder — cross-parent reparenting
Drag a layer row in the LayerPanel; a 2 px primary-tint drop
indicator paints between rows; release commits the move. Supports
both same-parent reordering and cross-parent reparenting.

Core (`document/`):
- `Document::reorder_before(source, anchor)` / `reorder_after`
  with editability + cycle + existence pre-checks. The mutation
  phase calls `extract_node` + `insert_before/after_in_children`
  via shared walkers — guarded by the pre-check pass so the
  Err(node) arm of the insert helpers stays unreachable.
- New walkers: `extract_node`, `insert_before/after_in_children`
  (`Result<(), Node>` so the node bounces back on miss),
  `children_contain_descendant`. All `pub(in crate::document)`
  so sibling submodules (`mutators`) can call them via
  `super::walkers::*`.

Widget (`widgets/layer_panel.rs`):
- `DropPosition::{Before, After}` + `DropTarget { anchor,
  position, indicator_y }` re-exported from `widgets::`.
- `LayerPanel::drop_target_at(rect, point) -> Option<DropTarget>`
  walks the layer rows; upper half → Before, lower half → After.
- `LayerPanel.drop_target` field + `from_document_with_drop`
  ctor so the host can thread the active drop target through
  to paint without touching the existing API. Drop-indicator
  paints last so it sits above row chrome.
- Layout constants promoted to `pub(crate)` and tests moved to
  a sibling `widgets/layer_panel_tests.rs` to honor the 800-line
  cap (mirrors the `document/` split pattern).

Host wiring (native + web parity):
- `LayerDragState { source, start_y, current_x/y, active }` on
  both `WidgetHostNative` and web `WidgetHost`.
- `apply_press` peeks for a Layer row hit and seeds the drag
  candidate (selection still happens immediately on press).
- `apply_cursor_move` promotes candidate → active once the
  cursor moves past a 4 px screen-space threshold (mirrors the
  marquee activation heuristic).
- `apply_release_with_viewport` calls `commit_layer_drag`,
  which runs `drop_target_at` against the live cursor pos and
  dispatches to `reorder_before` / `reorder_after`. Viewport-
  less `apply_release` drops the candidate silently (no rect
  to compute against).
- Paint paths build the panel via `from_document_with_drop`
  when `layer_drag.active` is true.

Tests (8 new, 137 total in shell-core):
- 6 mutator tests in `document/tests_mutators.rs`:
  same-parent before/after move, cross-parent reparenting,
  cycle rejection, locked/hidden source rejection,
  same-id/missing-id rejection.
- 1 drop-target hit-test in `widgets/layer_panel_tests.rs`
  covering both halves of a row (Before / After) and the
  `indicator_y` contract.

All checks clean: cargo fmt + cargo test + cargo check
--workspace + tools/check-widget-boundary.sh. Every file
under the 800-line ceiling.
2026-05-11 22:06:02 +08:00
Kayshen-X 2cda18318d feat(shell): selection handles + drag-create + per-node flags + LayerPanel polish
Re-apply 4 reset commits (1854dfa6 → b94274c6) bundled with session
follow-ons. Native + web hosts share the new behavior end-to-end.

Selection + canvas interaction:
- bounded Frame drag now translates descendants too
- 8 selection handles with hover-cursor feedback
- thinner selection outline + smaller AA handles
- handle-drag resize for rect/ellipse/polygon/line/frame/text
- drag-to-create shapes / frames / text from the active tool
- per-NodeKind hit-test (oval / triangle / line slack / point-in-poly)
- rotation pivot is kind-aware (handles negative-size Lines)

Per-node flags (TS parity):
- Node.hidden / locked / collapsed / fill_type (moved off Document.ui)
- mutators gated by is_editable / is_subtree_editable so locked /
  hidden subtrees can't be translated, resized, rotated, recolored,
  or deleted as collateral

Multi-select + marquee + clipboard + keyboard shortcuts:
- selected_set + anchor; shift+click toggles set membership
- marquee rect-select with screen-px threshold + ADD-only shift
- copy / cut / paste / duplicate / nudge / reorder / select-all
- escape one-layer-per-press priority cascade (property-focus →
  locale picker → shape picker → fill-type picker → chat → selection)
- Cmd-letter chord guards (!shift) so Cmd-Shift-letter doesn't fall
  through to text input; !modifier guards on named keys

LayerPanel polish:
- hover-reveal eye/lock affordances (TS parity)
- Eye → EyeOff icon when hidden; Lock → LockOpen when unlocked
- locked Lock renders in warm orange
- chevron expand/collapse for container rows; collapsed subtree
  hides from tree (paint/hit-test unaffected)
- `+` add-page button wired end-to-end (mints fresh id past
  max_node_id + 1, names "Page N", overflow-safe)
- smaller, refined trailing icons (12 px @ 1.2 stroke)
- 18 px chevron-to-kind-icon gap

RenderBackend trait grew fill_oval / stroke_oval / fill_polygon /
stroke_polygon / rotate so both native and web backends can paint
the new node shapes.

Refactor:
- split native widget_host.rs (1799 lines) into spine + 7 sibling
  submodules under widget_host/ to stay under the 800-line ceiling
- split web widget_host.rs into spine + paint + keyboard siblings
- amend tools/check-widget-boundary.sh + spec § 1.4 to allow
  widget_host/* sibling files; tighten `// glue:` marker rule to
  the immediately-preceding line (rustfmt-stable)

Stop-hook iterations addressed:
- allocator overflow guards (checked_add) on duplicate / paste /
  add_page paths
- subtree-size precheck before any id mint in deep_clone
- hidden subtree skipped in paint AND selection overlay
- nested protected delete leak closed via is_subtree_editable
- per-FocusKind hex/numeric input gating; sticky `#` prefix on hex
- ScaleFactorChanged refreshes viewport from window.inner_size()

122 shell-core tests pass; cargo fmt --all --check clean;
cargo check --workspace clean; widget boundary check clean.
2026-05-11 21:30:06 +08:00
Kayshen-X e2aff6c542 feat(shell): canvas click-to-select + drag-to-move
The canvas was pan-only; nodes could only be selected from the
LayerPanel and never moved without editing X/Y in the property
panel. Now:

* Document::node_at_doc_point walks the active page top-most-first
  and returns the topmost node whose aggregate bounds contain the
  document-space point. Children are tested before parents so a
  click on a button-rect inside a Frame selects the rect, not the
  Frame.

* Document::translate_selected moves the selected node by (dx, dy)
  document px. Leaf nodes update bounds.origin directly; container
  nodes (Group / unbounded Frame) translate every descendant that
  carries bounds, so dragging a Group moves the whole subtree.

* WidgetHostNative tracks a NodeDragState. Press over a node ⇒
  select + start node-drag. Cursor-move converts the screen-space
  delta to document space via the live zoom (no canvas_region
  offset needed because deltas are translation-invariant) and
  calls translate_selected. Release clears the drag.

* The Hand tool keeps its pure-pan behaviour. Empty-canvas press
  with any other tool clears the selection + starts a pan-drag,
  same as before.
2026-05-10 23:23:28 +08:00
Kayshen-X 4bb91af968 style(shell-core): drop dropdown shadow + add toolbar→picker gap
- LocalePicker / ShapePicker no longer paint a soft black offset rect
  underneath; popover background + border hairline are enough to
  read as floating, and the shadow was bleeding into the canvas.

- ShapePicker anchors 8 px to the right of the toolbar PANEL edge
  (not just the slot button), so the dropdown reads as a separate
  surface instead of butting flush against the toolbar's right border.
2026-05-10 23:18:39 +08:00
Kayshen-X 40f387a774 feat(shell-core): Toolbar shape-tool dropdown — Rect/Ellipse/Polygon/Line/Pen + Icon/Image
The vertical toolbar's shape button is now a compound slot driven by
`Document.ui.shape_tool` (defaults to Rect). Click it to open a
`ShapePicker` dropdown anchored immediately to the right of the
slot — seven rows mirror the TS app's shape-tool-dropdown:

  · Rectangle (Square icon)
  · Ellipse   (Circle)
  · Polygon   (Triangle)
  · Line      (Minus)
  · Icon      (Sparkles, opens icon picker — host follow-up)
  · Import Image or SVG…  (ImagePlus, opens file dialog — host follow-up)
  · Pen       (PenTool)

Picking a shape updates ui.shape_tool (so the toolbar slot's icon
flips), sets doc.tool to that variant, and closes the panel. Click
anywhere else closes silently.

* New Tool variants: Ellipse / Polygon / Line / Pen. Tool::is_shape()
  reports membership in the slot's group so the slot highlights when
  any of them is active.
* New icons: Circle, Triangle, PenTool, ImagePlus (lucide d-strings).
* New widget shape_picker.rs (≤ 280 lines) with hit-test + Widget
  impl + 3 unit tests; ShapeChoice variant for the host to dispatch
  on (Tool / OpenIconPicker / ImportImageOrSvg).
* PropertyLabels-style locale lookup falls back to English literals
  for the row labels (shapes.rectangle / ellipse / polygon / line /
  icon / importImageSvg / pen) — already present in zh.ts.
* Native host wires the open/close/dispatch loop alongside the
  existing locale picker; paint slot z-priority sits below the
  locale picker so a stack of overlays still does the right thing.
2026-05-10 23:12:35 +08:00
Kayshen-X 16e3c9ccf5 feat(shell-core): PropertyPanel i18n + X/Y/W/H input editing
The right-rail inspector picks up locale-aware labels and accepts
keyboard edits on the four most-used number inputs.

* New `PropertyLabels` struct in property_panel_sections; resolved
  once per panel build via `Document::t`. All hardcoded chinese
  section titles (位置/弹性布局/尺寸/图层/填充/描边/效果/导出),
  the 设计/代码 tab strip, the 创建组件 button label, and the five
  尺寸 checkboxes (填充宽/高 / 适应宽/高 / 裁剪内容) now flip with
  the TopBar Globe locale picker. Falls back to English when the TS
  locale tables don't carry a key.

* PropertyPanel now carries `focus / draft / caret_anchor_ms /
  now_ms` so the focused input renders the live edit buffer with a
  primary-color border + blinking caret. `for_selection_at(doc,
  now_ms)` is the new entry point; `for_selection` keeps a
  zero-clock variant for static contexts (tests, etc.).

* New `editable_input_rects` in sections — single source of truth
  for the X / Y / W / H rect layout, shared by paint and
  `PropertyPanel::hit_test`.

* WidgetHostNative wires the full edit cycle: clicking a row
  focuses + seeds the draft from the snapshot, `apply_text`
  filters digits/decimal/leading-minus into the draft, `apply_send`
  parses + commits via `Document::commit_property_edit`, and
  `apply_escape` discards. Click-outside-the-panel auto-commits.
  `next_animation_deadline_ms` now wakes for property focus too so
  the caret blinks at the same 500 ms cadence as the chat input.

* `PropertyFocus` already existed; `Document::commit_property_edit`
  + helper walk now mutate Node.bounds for the X/Y/W/H cases.
  Rotation/opacity/hex inputs accept focus + clear cleanly but are
  no-ops at the node level until the schema grows those fields.
2026-05-10 23:02:58 +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 967201162a feat(shell): AA round-rects + Layer/Property dividers + resizable rails + smaller chrome
* Native fill_round_rect now sets anti_alias(true) — was the source of the
  stair-stepped tool-button corners. Mirrors the AA flag we already had on
  stroke_round_rect / stroke_line / stroke_svg_path.

* LayerPanel paints a right-edge hairline (so the rail reads as a distinct
  surface from the canvas) plus an inset hairline between the Pages and
  Layers sections (matches the TS LayerPanel border-t).

* Layer + Property panel widths are now first-class Document.ui state
  (`layer_panel_width` / `property_panel_width`, defaults 240/280).
  Native host detects ±4 px gutter clicks on the panel edges, drags the
  width inside [180, 480], and the inspector_window runner flips the
  cursor to EwResize while hovering or actively resizing.

* Web host expressions threaded onto the same UiState fields for parity;
  drag wiring on web is a follow-up.

* TopBar trimmed: 48 → 40 px height, 32 → 28 icon button, 18 → 16 icon —
  the chrome reads less heavy at default zoom.

* Drops the now-unused PropertyPanel `Copy` derive (UiState carries a
  String draft) and lowers the toolbar (44×32) and topbar (40 px) so the
  rails feel tighter overall.
2026-05-10 19:42:46 +08:00
Kayshen-X 7b800f67bd feat(shell): chevron + close-on-globe + multi-script font fallback
TopBar Globe button is now a wider compound (44 px) carrying both
the globe glyph AND a small chevron-down — visually signals the
dropdown affordance the way the TS i18n switcher does.

Click-while-open behaviour fixed: any click outside the dropdown
(including a second click on the Globe itself) closes the picker
and swallows the press, instead of close→re-toggle-open which left
the picker stuck open.

Native font path now resolves a typeface PER CODEPOINT and renders
each contiguous-typeface segment with its own `Font`. Korean
한국어 / Devanagari हिन्दी / Thai ไทย / Vietnamese precomposed
`Tiếng Việt` now render against the right system font instead
of dropping through the Han-only fallback. Per-codepoint cache
keyed on `char as i32` keeps repeat lookups free.
2026-05-10 19:26:48 +08:00
Kayshen-X 4f95c0860b feat(shell-core): TopBar Globe → locale picker dropdown
Adds a LocalePicker widget that paints a vertical list of all 15
native-script locale names (English / 简体中文 / 繁體中文 / 日本語 /
한국어 / Français / Español / Deutsch / Português / Русский / हिन्दी
/ Türkçe / ไทย / Tiếng Việt / Bahasa Indonesia) with a Check icon
and primary tint on the active row.

Globe click toggles `Document.ui.locale_picker_open` instead of
silently cycling. Row click sets the locale + closes; clicking
outside the panel closes silently. Picker paints on top of every
other layer (chat / status / canvas) so it never gets covered.

Native + web hosts share the implementation via
shell-core::widgets::LocalePicker; `TopBar::globe_rect` exposes
the icon-button anchor so the panel stays glued under the icon
even after a viewport resize.
2026-05-10 19:21:36 +08:00
Kayshen-X c5d408d6be style(shell): cargo fmt --all (rustfmt-clean)
Stop-hook fix: codex flagged Rust files as not rustfmt-clean.
Run cargo fmt --all across openpencil-shell-{core,native,web}
+ wasm-libc-shim. 67 lib tests still pass, native + web cargo
check clean.
2026-05-10 18:47:33 +08:00
Kayshen-X 91d9e99a94 feat(shell): theme + locale toggle wired to TopBar Sun + Globe icons
Sun click flips dark↔light; Globe cycles ZhCn↔EnUs. Both pipe
through Document.ui (theme_mode + locale) so any widget builder
that reads doc.theme() / doc.t(key) reflows immediately.

- Document.ui.theme_mode: ThemeMode { Dark, Light } with
  ThemeMode::flipped()
- Document.ui.locale: Locale { ZhCn, EnUs } with Locale::next()
- Document::theme() returns dark/light from ui.theme_mode
- Document::t(key) calls i18n::translate with ui.locale
- New i18n module — flat per-locale match tables, ~25 keys for
  chrome strings (TopBar / LayerPanel / PropertyPanel / chat).
  Unknown keys fall through to the key itself for debug visibility.
- TopBar.hit_test resolves Sun → ToggleTheme + Globe → ToggleLocale
- WidgetHost (native + web) routes both new TopBarHit variants
- LayerPanel / PropertyPanel / CanvasViewport / Toolbar /
  AIChatPlaceholder constructors swapped Theme::dark() →
  doc.theme() so the chrome flips together
- TopBar / StatusBar gained for_document(doc) builders
- StatusBar.zoom_percent now reads from Document.viewport.zoom

67 lib tests pass (+3 i18n unit tests).
2026-05-10 18:37:41 +08:00
Kayshen-X 71a3b9010b fix(shell-native): refresh host clock at top of every WindowEvent
Stop-hook fix: 'caret reset can use a stale clock'. set_now_ms was
only called inside RedrawRequested, so apply_text / apply_backspace /
apply_press routed mid-frame stamped caret_anchor_ms with the
previous frame's now_ms. The result: caret reset visually appeared
delayed by up to one redraw interval (rare but inconsistent).

Refresh self.clock_start.elapsed() at the top of every WindowEvent
so any apply_* called inside the match arm sees the current
timestamp. Drop the redundant inside-RedrawRequested refresh.
2026-05-10 18:24:58 +08:00
Kayshen-X 0c84202798 feat(shell): caret blink driven by jian-core::anim primitives
Sinks the blink phase logic into vendor/jian (jian-core::anim) so any
host can wire the same square-wave timing instead of reimplementing
per-product. Both OpenPencil chrome and Zode TUI consume the same
helpers.

- vendor/jian bumped to head with new `jian_core::anim` module
  (blink_visible / next_blink_flip_ms, 9 unit tests)
- ChatState: `caret_anchor_ms` resets on focus / keystroke /
  example fill so the caret reappears solid right after the user
  acts, not mid-fade
- AIChatPlaceholder.now_ms threaded from host; paint computes
  caret visibility = focused && jian_core::anim::blink_visible
- AIChatPlaceholder caret X uses RenderBackend::measure_text for
  pixel-accurate trailing edge (replaces the 7px / 13px guess
  per char that drifted on Roboto + Noto-CJK)
- WidgetHostNative.set_now_ms / chat_focused / next_animation_
  deadline_ms surface; runner refreshes from a single Instant
  anchor + sets ControlFlow::WaitUntil at the next blink flip
- inspector_window: new_events handles ResumeTimeReached → request
  redraw so winit actually wakes for the next frame
2026-05-10 18:19:46 +08:00
Kayshen-X a7f9eb120f style(shell-core): selected layer row uses primary-tinted bg + primary text/icon
TS LayerPanel renders the selected row with bg-blue-500/15 + primary
text color + primary icon color (apps/web/src/components/panels/
layer-item.tsx). My panel was using theme.row_selected (gray #262626)
+ foreground text, which read as 'darker gray on dark gray' — not
the clear 'this is selected' affordance the TS app gives.

- Add Theme.row_selected_primary (rgba(0x3B82F6, 0.18) — blue 15%)
- LayerPanel: selected layer row uses row_selected_primary bg,
  primary text + primary icon
- Page rows still use the neutral row_selected (matches TS where
  the active page tab is also subdued gray)
2026-05-10 18:08:47 +08:00
Kayshen-X 1d2dd789a9 fix(shell-native): collapsed-sidebar canvas input uses canvas_region
Stop-hook fix: native over_canvas + apply_wheel + apply_click
LayerPanel hit-test all hardcoded LAYER_PANEL_WIDTH for the canvas
left edge. When the sidebar was collapsed, paint moved the canvas
left to x=0 but input still treated x∈[0,240) as 'over the LayerPanel'
— so clicks in that strip resolved to LayerPanel hits (against
nothing), wheel zoom anchored off-screen to the left of the cursor,
and pan-drag refused to start in that strip.

over_canvas now derives both x and y bounds from canvas_region;
apply_wheel uses canvas_region for the cursor offset; apply_click
short-circuits when sidebar is closed (LayerPanel isn't painted)
and lets the empty-canvas branch clear selection + start pan-drag.
2026-05-10 17:26:20 +08:00
Kayshen-X 0954629626 fix(shell): collapsed-sidebar toolbar hit-test follows canvas_region
Stop-hook fix: toolbar hit-test rects in apply_press / apply_click /
toolbar_rect were hardcoded to LAYER_PANEL_WIDTH + TOOLBAR_INSET_X,
but paint uses canvas_region's dynamic canvas_left (which is 0 when
sidebar is collapsed). When the user collapsed the sidebar, the
toolbar visibly slid left to x=12 but clicks still tried to hit it
at x=252, leaving the toolbar effectively unclickable.

Now both apply_press / apply_click in native + the toolbar_rect helper
in web compute the anchor from canvas_region, so hit-test always
matches paint. Wheel zoom in web also uses canvas_region's cx0/cy0
instead of the hardcoded LAYER_PANEL_WIDTH so cursor-centered zoom
keeps the right document point fixed when the sidebar is closed.
2026-05-10 17:21:18 +08:00
Kayshen-X 484c6032b8 feat(shell): step 4-6 chrome — TS-equivalent editor UI + interactions
Step 4 (visual lift):
- Theme tokens (shadcn-dark palette) in shell-core
- Lucide-style icons via stroke_svg_path (skia parse_path::from_svg)
- Vertical Toolbar / sectioned LayerPanel (Pages + Layers) /
  TopBar / floating StatusBar / floating AIChatPanel widgets
- Native + web backends: stroke_line / fill_round_rect /
  stroke_round_rect / stroke_svg_path primitives
- CJK fallback typeface: cached PingFang/Noto-CJK on native via
  match_family_style_character; embedded NotoSansCJK-Subset
  (8.7 KB) on web alongside Roboto

Step 5 (infinite canvas + AI chat input):
- Document.viewport (pan + zoom 10–800%) with cursor-centered
  zoom_at + Hand-tool drag pan + dotted background grid
- Trackpad PixelDelta → pan, LineDelta / pinch / Cmd+swipe →
  zoom (winit MouseScrollDelta + PinchGesture + Modifiers)
- Document.chat (input / messages / focused / collapsed /
  4-corner anchor) — WidgetHost wires apply_text /
  apply_backspace / apply_send + DOM keydown listener
- AI chat panel drag → 4-corner snap via ChatAnchor::nearest
- Collapsed mode: compact pill (MessageSquare + "New Chat" +
  ChevronUp), entire pill click expands

Step 6 (RightPanel + chrome polish):
- PropertyPanel rewrite: 设计/代码 tabs, 创建组件, 位置, 弹性布局,
  尺寸, 图层, 填充, 描边, 效果, 导出 — file split into
  property_panel.rs + property_panel_sections.rs (under 800 ea.)
- Node::aggregate_bounds for Group / unbounded containers so
  the panel reports child-union W/H instead of 0×0
- TopBar PanelLeft button toggles Document.ui.sidebar_open
- Click empty canvas clears selection (collapses RightPanel)
- Native font cache (Roboto + system CJK typeface) bypasses
  jian-skia textlayout: chrome paint 605 ms → sub-ms

Hit-test order = paint order reversed (chat → toolbar → layer
panel → canvas) so the topmost overlay always wins, plus
toolbar bounding-rect consumes gap clicks so they don't fall
through.

64 lib tests + 21 widgets_static green; native + web
cargo check clean. Web wasm rebuild gated on EMSDK
(tools/check-wasm-bundle.sh runs the bundle ceiling guard).
2026-05-10 17:07:59 +08:00
Kayshen-X 007e97ba03 fix(shell): Step 3 stop-hook R2 — plumb viewport_height through paint
Codex Step 3 R1 BLOCK: the prior fix `10cae1e5` exposed
canvas_height() on WebBackend but only used it for the white-
background clear, NOT for `WidgetHost::paint`. The host's
canvas viewport rect still derived its height from a hardcoded
`640.0` (web) / `600.0` (native), so any window/canvas at a
non-default height got the wrong bottom edge.

Fix: extend both `paint` signatures to accept
`viewport_height: f32` and replace the hardcoded
`640.0 - rail_top_y` / `600.0 - rail_top_y` expressions with
`(viewport_height - rail_top_y).max(0.0)`.

Web side:
- `widget_host.rs::WidgetHost::paint(backend, viewport_width,
  viewport_height)` — `// glue:` marker preserved on the
  signature line.
- `lib.rs::paint_inspector` reads BOTH `viewport_w` and
  `viewport_h` from the backend and forwards them to
  `host.paint`.

Native side:
- `widget_host.rs::WidgetHostNative::paint(frame,
  viewport_width, viewport_height)` — `// glue:` marker
  preserved.
- `examples/inspector_window.rs::paint_inspector(...,
  viewport_width, viewport_height)` — both axes plumbed
  through.
- `InspectorApp` gains `viewport_height: f32` cached field
  refreshed in the `Resized` arm so window-drag responsively
  updates the canvas viewport rect.

Stale comment that said "Window height isn't passed through
this signature; assume 600 px" updated to cite the codex
finding.

Verification:
- `cargo build -p openpencil-shell-native --example
  inspector_window` — green
- `cargo build -p openpencil-shell-web --target
  wasm32-unknown-unknown --features skia --release` — green
- `wasm-bindgen --target web` — produces ../pkg/*
- `bash tools/check-wasm-bundle.sh` — PASS, 0 env.*, 907 092
  bytes gzip = 86% of 1 MiB ceiling
- `grep "640.0\|600.0" crates/openpencil-shell-web/src/widget_
  host.rs crates/openpencil-shell-native/src/widget_host.rs`
  — only one match, inside a comment citing the prior bug
2026-05-10 13:20:17 +08:00
Kayshen-X 8523f7fbcf refactor(shell): single canonical MIN_RAIL_WIDTH in shell-core
Codex Step 3 R1 BLOCK: `MIN_RAIL_WIDTH: f32 = 80.0` was defined
twice — once in `crates/openpencil-shell-web/src/widget_host.rs`
and once in `crates/openpencil-shell-native/src/widget_host.rs`.
Each had a comment claiming "mirrors the other"; nothing
enforced agreement. A future drift on one side would silently
break cross-platform layout parity.

Move to a single canonical `pub const MIN_RAIL_WIDTH: f32 = 80.0`
in `crates/openpencil-shell-core/src/widgets/mod.rs`. Both hosts
import it via the existing `widgets::*` use list.

Verification:
- `cargo build -p openpencil-shell-native --example
  inspector_window` — green
- `cargo build -p openpencil-shell-web --target
  wasm32-unknown-unknown --features skia --release` — green
- `cargo test -p openpencil-shell-core --lib` — 39 tests passing
- grep confirms one definition + two imports + 4 use sites
2026-05-10 12:20:44 +08:00
Kayshen-X b161299e88 feat(shell): Step 3 — Node geometry + CanvasViewport center widget
Node grows bounds + fill + stroke + text fields; new
`widgets::CanvasViewport` recursively renders document nodes as
visual primitives; both hosts (web + native) now lay out
Toolbar-top + LayerPanel-left + CanvasViewport-center +
PropertyPanel-right. The `inspector_window` example launches a
1100×700 window showing a real document mock instead of just an
inspector slice. Direct run command:

    cargo run -p openpencil-shell-native --example inspector_window

What's added:

shell-core:
- `Rect::ZERO` const + `Rect::xywh(x,y,w,h)` builder — used
  pervasively by Step 3 fixtures.
- `Color` derives `PartialEq` so `Option<Color>` field comparisons
  work in tests.
- `document::Stroke { color, width }` for outlines.
- `document::Node` gains: `bounds: Rect` (origin + size), `fill:
  Option<Color>`, `stroke: Option<Stroke>`, `text: Option<String>`.
  Existing `Node::leaf` / `Node::with_children` keep working with
  defaults (Rect::ZERO, all None). Builder mutators
  `with_bounds` / `with_fill` / `with_stroke(color, width)` /
  `with_text(s)` chain off them.
- `Document::sample()` now configures concrete geometry for the
  demo: a 360×240 white-with-black-stroke Frame containing a
  "Hello OpenPencil" Title and a blue Button (rect + "Click me"
  text).

shell-core/widgets/canvas_viewport.rs (new, 5 unit tests):
- `CanvasViewport<'a>` borrows a `&Document` and impls `Widget`.
- `paint()` clears canvas to light-grey background, then walks
  the active page's nodes recursively:
  * Frame: fill + stroke + recurse
  * Group / Other(_): no own paint, just recurse
  * Rect: fill + stroke
  * Text: draw `text` string at bounds.origin via TextLayout
- Selected node gets a 2px blue stroke OVER its normal paint so
  the user can see the picked node across kinds.
- `accesskit::Role::Canvas` + label "Canvas".
- `from_document(&doc)` reserves WidgetId 4000 (matches the
  per-component id range convention: 1000s = LayerPanel, 2000s
  = PropertyPanel, 3000s = Toolbar, 4000s = canvas).

shell-web (`widget_host.rs`):
- Aux Dropdown + TextInput retired. Layout: rails take ~1/4
  width each; canvas takes the middle ~1/2 (640px tall band
  below the toolbar). Below MIN_RAIL_WIDTH the host paints the
  toolbar only and skips rails+canvas.
- `apply_ime` / `apply_key` are now no-op stubs (Step 4+ wires
  per-widget focus before they can route back to the document).

shell-native (`widget_host.rs`):
- Mirror of shell-web's layout. Canvas band 600px tall (matches
  default `inspector_window` window height).

shell-native (`examples/inspector_window.rs`):
- Window upgraded to 1100×700 (was 800×600) so all three rails
  + center canvas have room.
- `viewport_width` cached on `InspectorApp`, refreshed on
  `Resized` so dragging the window resizes the layout live.
- `paint_inspector` takes the current viewport_width.

Verification:
- `cargo test -p openpencil-shell-core --lib` — 39 tests passing
  (was 34; +5 canvas_viewport unit tests)
- `cargo build -p openpencil-shell-native --example
  inspector_window` — green (desktop launch ready)
- `cargo build -p openpencil-shell-web --target
  wasm32-unknown-unknown --features skia --release` — green
- `cargo check -p openpencil-shell-native --target
  aarch64-apple-ios` — green (mobile widget stack inherits
  CanvasViewport unchanged)
- `cargo check -p openpencil-shell-native --target
  aarch64-linux-android` — green
- `cargo check -p openpencil-shell-core --target
  wasm32-unknown-unknown` — green (shell-core stays
  wasm32-clean per spec §1.2)
- `bash tools/check-wasm-bundle.sh` — PASS:
  - 0 env.* imports
  - 624 474 bytes gzip = 59% of 1 MiB ceiling (negligible
    growth — canvas_viewport adds ~50 LOC of paint logic)
2026-05-10 12:08:24 +08:00
Kayshen-X aa966d0937 fix(shell): Step 2 codex R1 — sentinel + page-scope + clamp + overflow doc
Codex Step 2 R1 returned NO-GO with 1 BLOCK + 4 CONCERNs. All
addressed:

# BLOCK — NodeId(0) constructible in release builds

`NodeId` had a `pub u64` tuple field, so any caller could write
`NodeId(0)` directly and shadow `NodeId::NONE`. The `NodeId::new`
constructor only `debug_assert`ed against 0; release builds
silently let `Node::leaf(0, ...)` produce a zero-id Node that
collided with the NONE sentinel and confused
`Document::selected_node`.

Fix:
- Inner `u64` is now private (`pub struct NodeId(u64)`).
- `NodeId::new` hard-panics in BOTH debug and release if
  id == 0 (was `debug_assert`).
- New `NodeId::raw(self) -> u64` accessor for read paths
  (to_widget_id, serde Step 4+, tests).
- New `#[should_panic]` test runs in both build modes.

# CONCERN-1 — selection / LayerPanel page mismatch

`Document::selected_node` walked all pages while
`LayerPanel::from_document` rendered only `pages[0]`. A
selection on page 2 drove PropertyPanel while the LayerPanel
showed page 1 with no highlight.

Fix:
- New `Document::active_page_index: usize` field (defaults to 0).
- New `Document::active_page() -> Option<&Page>` accessor.
- `Document::selected_node` now ONLY searches the active page.
  A selection on a non-active page returns `None`.
- `LayerPanel::from_document` now walks `active_page()`.
- New tests:
  - `from_document_scopes_to_active_page_only`
  - `document_selected_node_scopes_to_active_page`
  - `document_active_page_returns_indexed_page`
  - `document_active_page_returns_none_when_index_out_of_range`

# CONCERN-2 — duplicate node ids unenforced

`Node::leaf` / `Node::with_children` / `Page::new` accepted
arbitrary id assignment with no uniqueness check; dup ids would
make `selected_node` return the first hit while LayerPanel might
mark several rows selected.

Fix:
- New `Document::find_duplicate_id() -> Option<NodeId>` walker
  (HashSet over page ids + recursive node ids; first dup wins).
- New `Document::validate() -> Result<(), String>` runs the
  duplicate scan + `active_page_index` range check.
- `Document::sample()` now `debug_assert`s self-validation so
  any fixture-time regression is caught in tests.
- New tests:
  - `document_sample_passes_validate`
  - `document_validate_catches_duplicate_node_id`
  - `document_validate_catches_active_page_index_out_of_range`

# CONCERN-3 — rail_w can go negative on tiny viewports

WidgetHost (web) + WidgetHostNative (native) computed
`rail_w = 240.0_f32.min(viewport_width / 2.0 - 8.0)`. When
viewport_width < 16 the expression went negative, producing
negative-size Rects.

Fix:
- New `MIN_RAIL_WIDTH: f32 = 80.0` const in both hosts.
- `rail_w_raw = (viewport_width / 2.0 - 8.0).min(240.0)` then
  `rail_w = rail_w_raw.max(0.0)` clamps to non-negative.
- If `rail_w < MIN_RAIL_WIDTH` the host paints the Toolbar only
  and skips both rails — there's no usable space for a
  meaningful LayerPanel + PropertyPanel split.

# CONCERN-4 — toolbar overflow silently drops buttons

`Toolbar::paint` early-returns from the per-button loop when a
button would overflow the rect, leaving later tools unreachable
on narrow viewports.

Fix (Step 2 scope = doc only):
- Inline comment in `Toolbar::paint` documents the limitation +
  enumerates the Step 3+ resolutions (horizontal scroll inside
  the toolbar rect, "More tools" overflow dropdown, icon-only
  mode at narrow widths). Phase D pointer/wheel routing has to
  land before any of those is wirable.

Test count: 24 → 33 lib tests (+9 new). All 64 shell-core tests
green; web + native + iOS + Android all compile; bundle gate
PASS at 624 466 bytes gzip (59% of 1 MiB ceiling).
2026-05-10 10:53:59 +08:00