Commit graph

26 commits

Author SHA1 Message Date
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 bf8f156a7b feat(shell-core): canvas surface, slimmer toolbar, TS-style chat input
* Theme: new `canvas_surface` token (#181818 dark / #fafafa light) —
  CanvasViewport now paints the surface with this distinct shade so
  the canvas reads as its own surface rather than blending into the
  chrome background.

* AI chat panel: rebuilt the bottom of the panel to mirror the TS
  reference. Single hairline separator between body and input,
  borderless 14 px textarea with the same caret blink driver,
  dedicated 40 px toolbar carrying ✦ Default ▾ on the left and
  attach + send (24 px primary square) on the right. ai.tipSelectElements
  string wired in (used by the empty-state body).

* Toolbar (vertical floating column): trimmed from 48×36 to 44×32 so
  the tool buttons feel less heavy at default zoom levels.
2026-05-10 19:33:06 +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 3b77cbb211 style(shell-core): drop border ring on Agents 与 MCP chip 2026-05-10 19:16:46 +08:00
Kayshen-X e40c3a6e1c feat(shell-core): TopBar empty-agent chip — 'Agents 与 MCP' affordance
Default Document has no connected agent, so for_document now sets
agent_count = 0 and the chip switches to the empty-state look:
LayoutGrid icon + 'Agents 与 MCP' label (TS topbar.agentsAndMcp /
en topbar.agentsAndMcp). Active state (agent_count >= 1) keeps the
Sparkles + green dot + 'N agent' look.

Chip width is now driven by RenderBackend::measure_text so the
border ring tracks the actual rendered string instead of a
per-char estimate.
2026-05-10 19:11:06 +08:00
Kayshen-X 544a307fd9 feat(shell-core): import all 15 TS locale tables verbatim
Replaces the hand-rolled 25-key i18n.rs with 15 generated locale
modules (~700 keys each) mirrored from
apps/web/src/i18n/locales/*.ts via tools/convert-locales.py.

Locale enum expanded to match the TS dropdown:
EnUs / ZhCn / ZhTw / Ja / Ko / Fr / Es / De / Pt / Ru / Hi / Tr /
Th / Vi / Id (15 total). Each carries its native-script
display_name() (English / 简体中文 / 繁體中文 / 日本語 / 한국어 /
Français / Español / Deutsch / Português / Русский / हिन्दी /
Türkçe / ไทย / Tiếng Việt / Bahasa Indonesia).

Globe icon click cycles all 15 via Locale::next() (round-robin
through Locale::ALL).

Chrome key references updated to TS dot.case naming so the same
key resolves on both sides:
- topbar.untitled → common.untitled
- layer_panel.pages → pages.title
- layer_panel.layers → layers.title
- chat.new_chat → ai.newChat
- chat.start_with_ai → ai.tryExample
- chat.input_placeholder → ai.designWithAgent

Generator script lives at tools/convert-locales.py (re-run when
TS strings update). Each locale .rs file is ≤ 710 lines (under
the 800-line ceiling). Cross-locale fallback: missing keys try
EN before falling through to the key itself.

68 lib tests pass (+1 i18n fallback test).
2026-05-10 18:57:20 +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 9506341da0 feat(shell-core): thread localised strings through LayerPanel + AIChatPanel paint
Theme + locale toggle infrastructure landed in ed36df56, but the
visible chrome strings were still hardcoded so flipping the Globe
icon didn't actually change anything. Now:

- LayerPanel resolves '页面' / '图层' from doc.t() at construction
  and stores as String fields; paint reads those instead of
  hardcoded literals.
- AIChatPlaceholder resolves 'New Chat' / '用 AI 开始设计' /
  '用 Agent 设计…' the same way; paint_examples takes the hint
  label as a parameter.

TopBar 'untitled' label was already wired (for_document uses
doc.t). 67 lib tests still pass.
2026-05-10 18:40:46 +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 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 ed48e1f139 fix(shell-core): 创建组件 icon Diamond → Component to match TS
TS imports both Diamond (instance indicator) and Component (cluster
of 4 small diamonds, used for the 创建组件 button). I picked the
single-diamond Diamond by mistake; the button uses Component.
2026-05-10 18:05:05 +08:00
Kayshen-X 0c0131653e style(shell-core): refine property panel to match TS — section labels foreground, full-width dividers, taller inputs
Previous panel had muted section labels + inset dividers + 26px inputs;
TS reference (apps/web/src/components/panels/right-panel.tsx) uses
foreground-tinted headers, edge-to-edge dividers, and 30px inputs.

- INPUT_HEIGHT 26 → 30 (matches TS Input render height)
- SECTION_HEADER_HEIGHT 28 → 24 (TS  tighter strip)
- SECTION_GAP 1.0 → 8.0 (proper breathing room between divider and
  next label)
- Section labels: muted_foreground → foreground (matches TS)
- Section dividers: PAD_X inset → full canvas-edge-to-edge

Visible polish difference vs the TS screenshot is now mostly the
input baseline / right-padding, which a future text-measure API
will fix once we have skia-side advances.
2026-05-10 18:04:09 +08:00
Kayshen-X dacd4e6994 fix(shell-core): align property panel icons with TS lucide imports
- 创建组件 button: Sparkles → Diamond (TS uses lucide Diamond)
- 弹性布局 buttons: Square/Frame/FolderOpen → LayoutGrid/Rows3/Columns3
  (TS layout-section.tsx imports Columns3/Rows3/LayoutGrid/Check)
- 位置 R input: Redo → RotateCw (TS uses RotateCw for rotation handle)
- Add 9 new lucide d-strings to icons.rs: LayoutGrid, Rows3, Columns3,
  RotateCw, Diamond, Component, Unlink, Check, ArrowUpRight
2026-05-10 18:02:08 +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 c299b3990c fix(shell-core): Step 3 stop-hook — clip-isolate canvas viewport paint
Codex stop-hook flagged: "canvas viewport is not paint-isolated".
Root cause: `CanvasViewport::paint` walked the document tree and
issued draw calls translated by `viewport_origin = rect.origin`,
but never clipped to the widget's `rect`. A document node whose
bounds extend past the canvas-widget rect (e.g. a frame at
document (40, 40)–(960, 640) painted into a 300-px-wide canvas
band) would spill onto the LayerPanel / PropertyPanel area
sitting in adjacent rects.

Fix: wrap the entire viewport paint in
save → clip_rect(rect) → bg fill → recursive node paints →
restore. The host-level clip stack catches whatever the
recursive paint emits, and the recursive code stays unaware of
the bounds (no per-node clipping needed).

Defensive guard up front: `if rect.size.x <= 0.0 || rect.size.y
<= 0.0 { return; }` so a zero-size canvas (host clamped below
MIN_RAIL_WIDTH) doesn't even open the save scope.

Test additions (41 lib tests, was 39):
- `paint_is_clip_isolated_save_clip_then_restore` extends the
  RecordingBackend to capture op order via a new `Op` enum;
  asserts the first three ops are Save → Clip → Fill (canvas
  bg), the last op is Restore, and save/restore counts balance.
- `paint_with_zero_size_rect_skips_entirely` confirms the
  defensive zero-size early return — backend.ops stays empty
  when rect has zero width or height.

Verification:
- `cargo test -p openpencil-shell-core --lib` — 41 tests passing
- `cargo build -p openpencil-shell-native --example
  inspector_window` — green
- `cargo build -p openpencil-shell-web --target
  wasm32-unknown-unknown --features skia --release` — green
- `bash tools/check-wasm-bundle.sh` — PASS, 0 env.*, 907 141
  bytes gzip = 86% of 1 MiB ceiling
2026-05-10 13:29:45 +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
Kayshen-X 472f1061b2 feat(shell): Step 2 — Document model + editor-UI widgets driving WidgetHost
Pivot toward "去除 TS, 打通 jian/op". Lands the spine the Rust
shell needs to replace `apps/web` (TS) — a Document model that
the Rust editor consumes, plus three composite widgets
(LayerPanel / PropertyPanel / Toolbar) that render the editor UI
from the document. Same surface on shell-web (browser via
WidgetHost) and shell-native (desktop via WidgetHostNative).

What's added:

shell-core:
- `crates/openpencil-shell-core/src/document.rs` — minimal
  Document model: NodeId(u64) (with NONE sentinel + ::new
  debug_assert mirroring WidgetId), NodeKind enum (Frame /
  Group / Rect / Text / Other(String)), Node (recursive tree
  with id + kind + name + children + find()), Page (id + name +
  children + find()), Document (pages + selected NodeId +
  selected_node()/first_page()/sample()/empty() helpers). 8 unit
  tests cover sentinel semantics, find walk, sample shape,
  selection state, kind label. Step 3+ extends with fills /
  strokes / transform / variables / components.
- `crates/openpencil-shell-core/src/widgets/layer_panel.rs` —
  LayerPanel rebuilt per frame from `Document::pages[0]` via a
  depth-first walk into a flat `LayerItem` list with depth +
  selection state. Paints depth-indented rows with selection
  highlight + kind-label column. accesskit::Role::Tree, label
  "Layers". 6 unit tests.
- `crates/openpencil-shell-core/src/widgets/property_panel.rs`
  — PropertyPanel rebuilt per frame from `Document::
  selected_node()`. Paints a header strip + 3 PropertyRow
  rows (Name / Type / Children count) when something is
  selected; "(no selection)" placeholder otherwise. accesskit::
  Role::Group with the selection's "Type — Name" label. 5 unit
  tests.
- `crates/openpencil-shell-core/src/widgets/toolbar.rs` —
  Toolbar with default 4-tool set (Select / Rect / Text / Pen),
  active-tool fill highlight, label-per-button. accesskit::
  Role::Toolbar. 4 unit tests.

The composite widgets live alongside the B2 primitives in
`widgets/` (one module, primitives + compositions all
`impl Widget`). They were briefly housed in a `chrome/` submodule
but the name collided with the higher-level "OP chrome =
openpencil-shell" architectural term — module renamed +
inline references updated to "editor UI".

shell-web (`widget_host.rs`):
- WidgetHost now owns `Document::sample()` + auxiliary widget
  state (Dropdown + TextInput Step 1b holdovers); per-frame
  builds LayerPanel + PropertyPanel from the document.
- paint() lays out Toolbar pinned top, LayerPanel left rail
  (240 px or viewport/2-8), PropertyPanel right rail (same
  width), aux Dropdown + TextInput stacked under property
  panel.
- apply_ime / apply_key still route to aux widgets (Step 3 will
  fold them into the document-driven property sections).

shell-native (`widget_host.rs`):
- Mirror of shell-web's structure: Document::sample() +
  Toolbar + aux widgets. Same Toolbar-top + LayerPanel-left +
  PropertyPanel-right layout for cross-platform visual parity
  (Phase E manual smoke acceptance).

Verification:
- `cargo test -p openpencil-shell-core` — lib 24 + jian 6 +
  render_backend 4 + widgets_static 21 = 55/55 passing
- `cargo build -p openpencil-shell-native --example
  inspector_window` — green (desktop)
- `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
- `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 699 bytes gzip = 59% of 1 MiB ceiling (+3 KiB vs
    Step 1b: editor-UI composition adds ~3 KiB of view-build
    code)
- `bash tools/check-widget-boundary.sh` — PASS
- `bash tools/check-jian-boundaries.sh` — 4/4 invariants pass

Step 2 scope (kill-spike pivot toward TS removal):
- Document model: minimal but extensible spine. Step 3+ adds
  fills / strokes / transform / variables / components / ...
- Editor UI: per-frame view rebuild from document — cheap
  enough at sample-doc scale, lets the host stay stateless for
  the document tree.
- Cross-platform parity: same WidgetHost shape on web +
  native + mobile (compile-checked).

What's still on the path to "去除 TS":
- Step 3+: fills / strokes / transform — render real document
  geometry, not just inspector text. Canvas viewport widget.
- Step 4+: real document I/O (load / save), backed by
  serde-roundtrip of the document model.
- Step 5+: replace `apps/web` (TS React + Zustand) with the
  wasm shell mount.
2026-05-10 10:34:25 +08:00
Kayshen-X af66f3d849 fix(shell-core): Phase C stop-hook #2 — skip dropdown keys during IME
Codex stop-hook #2: with the hidden IME textarea now focused (R1
fix in fe994c4b), every keystroke routes through it — including
arrow keys the user is pressing to navigate the IME's candidate
picker. The window-level keydown listener still fires on these
keystrokes, and `DropdownState::apply_key` was mutating selection
+ opening the menu behind the IME panel. Surfaced as: "focused
IME textarea lets composing keys mutate dropdown state."

Spec §2.4 says: "Widgets that consume keys directly should
usually skip dispatch when is_composing == true and let the
ImeEvent path handle the composition instead." The plumbing for
`is_composing` already runs through Phase C2.2 (W3C
`KeyboardEvent.isComposing` → C1 `map_keyboard_parts(...,
is_composing)` → `KeyEvent.is_composing` → C2.1
`DropdownState::apply_key`); we just weren't honoring the bit on
the consuming side.

Fix: add `event.is_composing` to the early-return condition in
`DropdownState::apply_key`. ArrowDown/Up/Enter/Escape during a
composition no-op now; the IME's candidate picker keeps the
keystroke and the dropdown stays put.

`TextInputState::apply_ime` is unaffected — it already only
processes ImeEvent, never KeyEvent, so composing-key bleed-
through was never a concern there.

Test: `dropdown_apply_key_ignores_keys_during_ime_composition`
asserts both ArrowDown (would advance + open) and Enter (would
close) are no-ops when `is_composing` is true. Test count
20 → 21.

Verification:
- `cargo test -p openpencil-shell-core --test widgets_static` —
  21/21 passing
- `cargo check -p openpencil-shell-core --target
  wasm32-unknown-unknown` — green
- `cargo build -p openpencil-shell-web --target
  wasm32-unknown-unknown --features skia --release` — green
- `bash tools/check-wasm-bundle.sh` — PASS:
  - 0 env.* imports
  - 622 156 bytes gzip = 59% of 1 MiB ceiling

Phase D may extend this guard pattern to other widgets that gain
key handling (Tree typeahead, etc.); the spec §2.4 is_composing
contract becomes a per-widget invariant.
2026-05-09 21:54:00 +08:00
Kayshen-X a936d22a6f feat(shell-core): Phase C2.1 — widget event handlers + WidgetHost forwarding
Lands the data-flow piece of plan C2: shell-core widgets gain pure
state-mutation methods (`TextInputState::apply_ime`,
`DropdownState::apply_key`) that the WidgetHost forwards to from the
two new `// glue:` marked methods. Phase C2.2 will land the browser
closure registration that drives these methods from real DOM
events.

shell-core (widgets stay platform-agnostic per spec §1.4):
- `TextInputState::apply_ime(&ImeEvent)` — CompositionStart clears
  preedit, CompositionUpdate replaces preedit with `event.text`
  (selection deferred to Phase D DOM mirror), CompositionEnd
  appends the commit text to value and clears preedit.
- `DropdownState::apply_key(&KeyEvent, option_count)` — ArrowDown
  advances + opens (saturates at last option, no wrap), ArrowUp
  retreats with `saturating_sub` + opens, Enter / Escape close
  without mutating selection. Skips on Released or empty options.

shell-web (glue file widget_host.rs, both methods carry `// glue:`
markers per spec §1.4 boundary check):
- `WidgetHost::apply_ime(&ImeEvent)` — forwards to text_input.state
- `WidgetHost::apply_key(&KeyEvent)` — forwards to dropdown.state
  with `dropdown.options.len()` for the option count

Tests (`tests/widgets_static.rs`, +10 → 20 total):
- text_input apply_ime: Start clears preedit (value untouched);
  Update replaces preedit (value untouched); End commits +
  clears; double-Start without End each clears (codex C2.1 R1
  CONCERN-1 — pathological host state machine)
- dropdown apply_key: ArrowDown advances + saturates; ArrowUp
  retreats + saturating_sub; Enter / Escape close + Escape
  preserves selection (codex C2.1 R1 CONCERN-2); Released = no-op;
  zero options = no-op; unrelated NamedKey (Tab) = no-op
- Helper `keydown(named)` / `keyup(named)` build minimal KeyEvents;
  apply_key reads only `key` + `state` so the harness's KeyCode
  field is intentionally Unknown(String::new())

Plan-vs-implementation deviations (deliberate):
- WidgetHost forwarding methods carry `// glue:` markers. F3 in
  the boundary script's exemption set covered `fn paint(...)`;
  the same exemption applies here because these methods import +
  invoke `openpencil_shell_core::{ImeEvent, KeyEvent}` at the
  signature line. Verified `bash tools/check-widget-boundary.sh`
  still PASS.
- Codex C2.1 R1 CONCERN-3 (WidgetHost forwarding has no direct
  test coverage) deferred to C2.2 — the browser closure
  registration there exercises the forwarding end-to-end via
  real DOM events, which is more meaningful than mocking
  WidgetHost in shell-web tests with read-only accessors that
  would only exist for testing.

Verification:
- `cargo test -p openpencil-shell-core --test widgets_static` —
  20/20 passing
- `cargo check -p openpencil-shell-core --target
  wasm32-unknown-unknown` — green (shell-core stays wasm32-clean
  per spec §1.2)
- `bash tools/check-widget-boundary.sh` — PASS

Codex iterate review: 2 rounds → GO.
2026-05-09 21:50:00 +08:00
Kayshen-X f214f4b4e3 feat(shell-core): Phase B2 — four static inspector widgets
Lands the four Step 1b inspector widgets in shell-core (per spec
§1.4 — widget logic lives here so shell-native + shell-web reuse
it; only the RenderBackend impl + DOM event mapping + accesskit
DOM mirror are platform-owned).

Widgets:
- `widgets::TreeWidget` (Role::Tree, label "Layers") — sample
  3-item tree (Frame / Title / Button) with selection-aware
  blue-row paint and depth-indented labels. WidgetIds 100-103.
- `widgets::PropertyRow` (Role::Group, label "{label} {value}")
  — single-row label/value pair. PropertyRow uses Role::Group
  rather than Role::GenericContainer so the row label survives
  ARIA filtering on the way to VoiceOver / NVDA (codex B2 R1
  CONCERN). WidgetIds 200-299.
- `widgets::Dropdown` (Role::ComboBox, label "Blend") — sample
  blend-mode picker with 3 options. Phase B static slice does
  NOT yet pop a menu when `state.open == true`; Phase C wires
  click + keyboard handling. WidgetIds 300-399.
- `widgets::TextInput` (Role::TextInput) — single-line input
  with CJK IME preview. Paints `state.preedit` (in-progress
  composition) when present, else `state.value`; non-empty
  preedit also draws an 80px underline. Phase C lands
  compositionstart / update / end → state mutation in shell-web.
  WidgetIds 400-499.

State separation:
- `DropdownState { selected, open }` and `TextInputState { value,
  preedit }` live as their own structs so Phase C event handlers
  can swap them without taking ownership of the surrounding
  widget. `TextInputState::default()` returns the empty state.

Tests (`tests/widgets_static.rs`):
- `four_inspector_widgets_paint_static_content` — paints all
  four widgets through one RecordingBackend, asserts
  ≥5 fills / ≥3 strokes / ≥7 text dispatches (each tightened
  vs the plan sketch to actually catch per-widget regressions).
- Per-widget role + label assertions (Tree / Group / ComboBox /
  TextInput).
- `text_input_paints_preedit_underline_when_composing` —
  drives `state.preedit = "你好"`, paints, asserts the IME
  branch emits 1 fill + 2 strokes (border + underline) + 1
  text run.
- `dropdown_state_independent_state_struct` +
  `text_input_state_default_is_empty` — verify state structs
  are independent + default-constructible.

Plan-vs-implementation deviations (deliberate):
- `WidgetId::new(N)` instead of the plan's `WidgetId(N)` tuple
  literal so the sample/new constructors exercise the B1
  debug_assert non-zero check. Tuple stays public for pattern
  matching + `const` contexts.
- WidgetId range conventions per widget kind (Tree=100s /
  PropertyRow=200s / Dropdown=300s / TextInput=400s) added as
  doc-only comments. Real Phase C host will allocate from a
  counter; the conventions just keep the B-phase fixtures
  predictable.
- Per-widget paint counts in `four_inspector_widgets_…` test
  tightened to ≥5/≥3/≥7 (plan sketch had ≥4/≥3/≥4 which
  wouldn't catch a regression in Tree's selection-row fill).

Verification:
- `cargo test -p openpencil-shell-core` — 10/10 passing
- `cargo check -p openpencil-shell-core --target
  wasm32-unknown-unknown` — green (shell-core stays
  wasm32-clean per spec §1.2 — no platform deps creeping in)
- `cargo check -p openpencil-shell-native` — green

Codex iterate review: 2 rounds → GO.
2026-05-09 21:46:00 +08:00
Kayshen-X b6efa324d1 feat(shell-core): Phase B1 — Widget trait + recording test harness
Adds the widget facade that B2 inspector widgets and Phase C event
handling will plug into. Logic-bearing widget code lives in
shell-core (per spec §1.4); shell-native + shell-web only own their
RenderBackend impls + DOM event mapping + accesskit DOM mirror.

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

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

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

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

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

Codex iterate review: 4 rounds → GO. Round 1 CONCERN (3 items),
Round 2 CONCERN (1 stale comment), Round 3 CONCERN (comment vs
test body mismatch), Round 4 GO clean. Q3 (accesskit_winit
alignment) carries to Phase C as informational.
2026-05-09 21:45:00 +08:00