Commit graph

50 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 2a3a6e96d6 fix(shell-core): convert-locales handles multi-line + double-quoted values
Earlier convert-locales.py was line-based + single-quote-only, missing
~16 keys per locale where:
  - the value spans onto the next line ('long.key.name':\n    'value')
  - the value uses double quotes for English contractions ('topbar.dontSave': "Don't Save")

Switch to a regex.finditer over the whole file with multi-line +
double-quote alternation. All 15 locales now report 706 keys each,
matching the TS source (apps/web/src/i18n/locales/*.ts).

Stop-hook: 'locale import is incomplete'.
2026-05-10 19:04:38 +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 fb2d3b1a8d fix(shell-core): Step 2 codex R2+R3 — empty-pages validate gap
Two iterative tightenings on Document::validate after the R1
fixes landed in 3d291ec8.

# R2 CONCERN-1: empty-pages document silently passed validate

`Document::validate` previously gated the active_page_index
range check on `!pages.is_empty()`, so a `Document { pages:
vec![], active_page_index: 99, ... }` returned `Ok(())` —
inconsistent with the implicit "every Document has at least one
page" invariant that Document::empty() and Document::sample()
both establish.

Fix:
- `validate()` now treats `pages.is_empty()` as the FIRST
  violation it returns. Empty pages is itself an invariant
  violation — `Document::empty()` is the constructor for the
  default single-page shape.
- `active_page_index` range check now fires unconditionally.

New test `document_validate_catches_empty_pages` covers two
sub-cases:
- `pages: vec![], active_page_index: 0` → Err("pages is empty")
- `pages: vec![], active_page_index: 99` → Err (empty check
  fires first, range check short-circuited)

# R3 CONCERN: empty-vs-range ordering not asserted

The R2 second sub-case only asserted `.is_err()` without
proving WHICH violation fired first. Strengthened to:
- assert error contains "pages is empty"
- assert error does NOT contain "active_page_index"
Both asserts carry failure messages so a future regression
points at the cause.

Test count: 34 lib + 21 widgets_static + 6 jian + 4
render_backend = 65 shell-core tests passing.

R4 GO from codex.
2026-05-10 11:05:53 +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
Fini d582a715e4 fix(shell): drop orphan pub mod event refs after Step 1b §3.2 merge
The merge of origin/v0.8.0 brought in Kayshen's 504f1874 / ef4f9f67
which delete src/event.rs + src/event/mod.rs (re-affirming v19.4 drop).
Local 6af3a7d7 had wired `pub mod event` into both shell-core and
shell-native lib.rs to keep those files alive — now the source files
are gone and the `pub mod event` declarations point at nothing.

Drop the dangling declarations:
- crates/openpencil-shell-core/src/lib.rs: remove `pub mod event;`
- crates/openpencil-shell-native/src/lib.rs: remove `pub mod event;`
  + the `pub use event::JianPointerMapper;` re-export

Verify: cargo check --workspace --tests + cargo test -p shell-core
-p shell-native both green; format:check + tsc + vitest 4223/4223 pass.

This finalizes the same direction Kayshen's commit message describes
("shell-core exposes Jian gesture types directly without an OP-side
wrapper") — confirmed with user before merging.
2026-05-08 22:35:00 +08:00
Fini b2f6ae99ff Merge remote-tracking branch 'origin/v0.8.0' into v0.8.0 2026-05-08 22:33:12 +08:00
Kayshen-X ca2308a0d5 refactor(shell-core): drop stale ShellEvent + re-export Jian Step 1b events
Step 1b Phase 0 + §3.2 P0.5A landing on the OP side:

(1) Phase 0 cleanup (spec v19.4 / OP commit f9dd4b56 was reverted by
    merge cf61bcc1 ↔ origin/v0.8.0): delete src/event.rs (226-line OP
    ShellEvent enum) and tests/event_shape.rs (3 stale unit tests).
    Replace with tests/gesture_re_export.rs, which asserts the contract
    that shell-core exposes Jian gesture types directly without an
    OP-side wrapper.

(2) §3.2 P0.5A re-exports: lib.rs now also re-exports the new Jian
    keyboard / IME / focus / wheel event types so widget code can
    `use openpencil_shell_core::{KeyEvent, ImeEvent, FocusEvent,
    WheelEvent, ScrollMode, ...};` without naming jian_core directly.
    Full export list: FocusEvent, ImeEvent, ImeKind, KeyCode, KeyEvent,
    KeyLocation, KeyState, KeyValue, NamedKey, Modifiers, MouseButtons,
    PointerEvent, PointerId, PointerKind, PointerPhase, ScrollMode,
    WheelEvent.

Tests (gesture_re_export.rs, 6 tests):
- pointer_event_is_re_exported_from_jian
- pointer_modifier_and_button_flags_keep_jian_names
- key_event_is_re_exported_from_jian
- ime_event_is_re_exported_from_jian
- focus_event_is_re_exported_from_jian
- wheel_event_is_re_exported_from_jian

cargo test -p openpencil-shell-core --test gesture_re_export → 6/6 PASS.
2026-05-08 22:03:23 +08:00
Kayshen-X 136274a3ec chore(vendor): bump jian submodule to d5d358e (Step 1b §3.2 P0.5A)
Picks up the keyboard/IME/focus event additions + W3C wheel deltaMode
landed in jian commit d5d358e. shell-core re-exports of the new types
land in the next commit; this commit only moves the pointer + Cargo.lock.

cargo test -p openpencil-shell-core --test gesture_re_export → 6/6 PASS
against the pinned submodule.
2026-05-08 22:03:08 +08:00
Fini b5219a2c17 fix(shell): wire ShellEvent + JianPointerMapper through lib.rs after merge
The merge of origin/v0.8.0 (commit 19582fc) resurrected the 4 ShellEvent
files that b133ebc0 / f9dd4b56 declared dropped — the same modify-vs-delete
artifact 50c9c3de cleaned up after 6fbee16a. Per user direction keep the
files: declare the event modules in both lib.rs + re-export JianPointerMapper
at shell-native crate root so the existing tests resolve.

Tests: event_shape 3/3, event_mapping 15/15, workspace cargo check + test
green, format:check / tsc / vitest (4204) all pass.

Also ignore xhs-images/ + .baoyu-skills/ in .prettierignore (local tutorial
scratch dirs; oxfmt was scanning markdown inside).
2026-05-08 07:30:00 +08:00
Fini c81cfe82a4 Merge branch 'v0.8.0' of github.com:ZSeven-W/openpencil into v0.8.0
# Conflicts:
#	.github/workflows/rust-multiplatform.yml
#	README.md
#	crates/openpencil-shell-core/src/lib.rs
#	crates/openpencil-shell-native/examples/basic_window.rs
#	crates/openpencil-shell-native/src/lib.rs
2026-05-05 22:51:00 +08:00
Kayshen-X e706454235 refactor(shell): drop OP ShellEvent + JianPointerMapper — re-export Jian events directly
Per user 2026-05-05 directive: OP render engine + event types stay
consistent with Jian. The OP-specific ShellEvent enum + JianPointerMapper
translation layer (Phase B Task 3 commit f2169d00) was over-designed —
OP-side abstraction provides no value over directly consuming
jian_core::gesture::PointerEvent.

Deleted:
- crates/openpencil-shell-core/src/event.rs (ShellEvent enum + 9 subtypes)
- crates/openpencil-shell-core/tests/event_shape.rs (3 unit tests)
- crates/openpencil-shell-native/src/event/mod.rs (JianPointerMapper)
- crates/openpencil-shell-native/tests/event_mapping.rs (15 unit tests)

Added:
- shell-core lib.rs re-exports jian_core::gesture::{PointerEvent,
  PointerKind, PointerPhase, MouseButtons, Modifiers, PointerId} so
  consumer code can import Jian event types via the OP shell crate.

OP visual model differentiation (single-page + infinite canvas
recommended, multi-page also supported, no routing, cross-page event
linkage when multi-page) lives at canvas viewport layer (Step 1c+),
not at event type abstraction.

spec v19.3 → v19.4 mini-patch (separate commit in openpencil-docs)
documents the simplification.
2026-05-05 22:42:00 +08:00
Kayshen-X c3e935ccbb feat(shell-core,shell-native): map Jian PointerEvent to ShellEvent (Step 1a Task 3)
Phase B Task 3 implementation per spec v19 §5.1 + §5.1.1 (FROZEN
2026-05-04):

shell-core:
- New `event` module declaring `ShellEvent` (6 variants per spec §5.1)
  + sub-types `PointerId / TouchId / TouchPhase / TouchForce /
    MouseButton / ElementState / ScrollDelta / Modifiers / KeyCode /
    WindowEventKind`. Pure OP types — no winit / Jian / GL — so the
  enum is wasm32-clean and visible on iOS / Android (spec §11.3).
- TouchForce::Calibrated mirrors winit::Force 1:1 (spec §11.3
  invariant) so Step 1f mobile mapper compiles without API break.
- Newtype id fields are `pub` so shell-native can construct them across
  crates (spec round 3 BLOCK-R3-4 fix).

shell-native:
- New `event` module (cfg-gated desktop only) housing
  `JianPointerMapper` — stateful diff over the per-PointerId
  `MouseButtons` snapshot. Diff runs on Down / Up / Move (spec round 3
  CONCERN-R3-1 fix); Hover / Move emits a trailing `PointerMove`.
- Touch branch maps Down/Move/Up/Cancel → Started/Moved/Ended/Cancelled;
  Touch Hover returns `Vec::new()` (touches never hover).
- Mouse / Pen / Stylus / Trackpad share the same diff branch.
- Degraded inputs (no button transition + no Move emission) return
  `Vec::new()` instead of synthesising a `ShellEvent::Other` variant
  (spec round 4 CONCERN-R4-1 fix; the enum stays at exactly 6 variants).

Tests:
- 15 new unit tests in shell-native/tests/event_mapping.rs covering
  the 4 Touch phases, mouse Hover, LEFT Down/Up pair, multi-button
  press/release during Move, Pen/Stylus/Trackpad routing, two
  degraded-empty paths, and modifiers propagation (CMD → meta).
- 3 new shape tests in shell-core/tests/event_shape.rs proving the
  6-variant invariant + TouchForce::Calibrated field shape +
  `pub`-field newtype constructibility.

Verified:
- `cargo test -p openpencil-shell-core -p openpencil-shell-native`
  green (36 tests total across both crates).
- `cargo check --target wasm32-unknown-unknown -p openpencil-shell-core`
  green; shell-web on wasm32 still compiles with the new module pulled
  through.
- `cargo check --target aarch64-apple-ios -p openpencil-shell-native`
  + `--target aarch64-linux-android -p openpencil-shell-native` both
  green (mapper cfg-gated out of mobile).
- `cargo metadata --filter-platform aarch64-linux-android` confirms
  jian-host-desktop / jian-skia not in the Android dep tree.
- §11.1 grep: 0 actual `use winit/skia_safe/glutin/...` items in
  shell-core (only doc-comment references).
- `cargo clippy --all-targets` clean; `cargo fmt --check` clean.
2026-05-05 22:36:00 +08:00
Kayshen-X c55807e432 ci: remove TS/Electron workflows (build-electron / ci / docker / publish-cli)
Rust-ification 阶段,CI 只保留 Rust 相关:
- rust-check.yml: cargo fmt + build + test (with STEP1A_REQUIRE_GPU=1 on Linux) + clippy + cargo-deny
- wasm-bundle-check.yml: wasm32 target check

删除:
- build-electron.yml: Electron desktop build (Rust 化后用 openpencil-shell-native)
- ci.yml: TS type-check + Vitest + web build (Rust 化后已废)
- docker.yml: TS Docker image (Rust 化后重做)
- publish-cli.yml: npm packages (Rust 化后改 cargo publish)
2026-05-05 22:09:00 +08:00
Kayshen-X bb321f115c feat(shell-core,shell-native): map Jian PointerEvent to ShellEvent (Step 1a Task 3)
Phase B Task 3 implementation per spec v19 §5.1 + §5.1.1 (FROZEN
2026-05-04):

shell-core:
- New `event` module declaring `ShellEvent` (6 variants per spec §5.1)
  + sub-types `PointerId / TouchId / TouchPhase / TouchForce /
    MouseButton / ElementState / ScrollDelta / Modifiers / KeyCode /
    WindowEventKind`. Pure OP types — no winit / Jian / GL — so the
  enum is wasm32-clean and visible on iOS / Android (spec §11.3).
- TouchForce::Calibrated mirrors winit::Force 1:1 (spec §11.3
  invariant) so Step 1f mobile mapper compiles without API break.
- Newtype id fields are `pub` so shell-native can construct them across
  crates (spec round 3 BLOCK-R3-4 fix).

shell-native:
- New `event` module (cfg-gated desktop only) housing
  `JianPointerMapper` — stateful diff over the per-PointerId
  `MouseButtons` snapshot. Diff runs on Down / Up / Move (spec round 3
  CONCERN-R3-1 fix); Hover / Move emits a trailing `PointerMove`.
- Touch branch maps Down/Move/Up/Cancel → Started/Moved/Ended/Cancelled;
  Touch Hover returns `Vec::new()` (touches never hover).
- Mouse / Pen / Stylus / Trackpad share the same diff branch.
- Degraded inputs (no button transition + no Move emission) return
  `Vec::new()` instead of synthesising a `ShellEvent::Other` variant
  (spec round 4 CONCERN-R4-1 fix; the enum stays at exactly 6 variants).

Tests:
- 15 new unit tests in shell-native/tests/event_mapping.rs covering
  the 4 Touch phases, mouse Hover, LEFT Down/Up pair, multi-button
  press/release during Move, Pen/Stylus/Trackpad routing, two
  degraded-empty paths, and modifiers propagation (CMD → meta).
- 3 new shape tests in shell-core/tests/event_shape.rs proving the
  6-variant invariant + TouchForce::Calibrated field shape +
  `pub`-field newtype constructibility.

Verified:
- `cargo test -p openpencil-shell-core -p openpencil-shell-native`
  green (36 tests total across both crates).
- `cargo check --target wasm32-unknown-unknown -p openpencil-shell-core`
  green; shell-web on wasm32 still compiles with the new module pulled
  through.
- `cargo check --target aarch64-apple-ios -p openpencil-shell-native`
  + `--target aarch64-linux-android -p openpencil-shell-native` both
  green (mapper cfg-gated out of mobile).
- `cargo metadata --filter-platform aarch64-linux-android` confirms
  jian-host-desktop / jian-skia not in the Android dep tree.
- §11.1 grep: 0 actual `use winit/skia_safe/glutin/...` items in
  shell-core (only doc-comment references).
- `cargo clippy --all-targets` clean; `cargo fmt --check` clean.
2026-05-05 21:48:00 +08:00
Kayshen-X 656b57a024 style(shell): convert all comments to English
Open-source codebase convention: all source-code comments in English.
Translates Chinese comments across openpencil-shell-{core,native,web}
.rs and Cargo.toml files. Logic, identifiers, and string literals
unchanged; the literal CJK fixture "Hello 你好" in raster_text_smoke
stays since it exercises the textlayout CJK path.
2026-05-05 21:12:00 +08:00
Kayshen-X 2f60bd49f8 feat(workspace): pin Jian submodule and shell wrapper deps (Step 1a Task 1)
Anchor v19 pivot at the workspace level: vendor Jian as a git submodule
pinned to fork commit ad13ce6 (P0.5 mini-gate GO; skia-safe 0.78 → 0.97 +
new pub draw_on_canvas adapter), wire jian-core / jian-skia / jian-host-desktop
as path deps with explicit version per spec §12.2, and re-export the
Jian render/geometry/scene types from shell-core so shell-native can
translate the OP RenderBackend facade into jian DrawOp commands.

shell-core stays wasm32-clean: only jian-core (already wasm32-validated
in P0.5) plus glam / bitflags / thiserror / tracing land here.
shell-native picks up the full P0-pinned GL stack (skia-safe 0.97.0,
glutin 0.32.3, glutin-winit 0.5.0, glow 0.17.0, winit 0.30.13,
raw-window-handle 0.6.2, scopeguard 1.2) plus jian-skia (textlayout)
and target-gated jian-host-desktop (default-features = false, no `run`
feature so we skip Jian's softbuffer raster present path — OP owns its
own GPU swap_buffers per spec §3.6).

Adds OP RenderBackend trait + Rect / Color (with RED/GREEN/BLUE/BLACK/
WHITE/TRANSPARENT named constants per spec §5.2) + TextLayout facade
that wraps jian_core::render::TextRun explicitly (TextRun has no Default
impl, fields enumerated to honour spec §5.2 round-2 CONCERN-1 fix).

Boundary checks all pass:
- wasm32 shell-web metadata: no jian-host-desktop / jian-skia
- aarch64-linux-android shell-native metadata: no jian-host-desktop
- shell-core src: no glutin / skia_safe / winit / glow imports

Tasks 2-4 (SharedSkiaContext + NativeBackend + ShellEvent mapping +
acceptance) follow per plan v7.
2026-05-05 21:00:00 +08:00
Kayshen-X 22003f9b0c chore(shell-native): add transient P0 probe gate (Step 1a)
Drives the three-OS CI matrix verification of the skia-safe + glutin +
glow + winit dep stack per Step 1a spec §7.

- examples/p0_probe.rs: stencil_visibility + readback chain runner (must
  own a real OS main thread because winit on macOS rejects
  EventLoop::new() from cargo test worker threads).
- tests/p0_probe.rs: subprocess-invoke wrapper, gated
  #[ignore = "P0_PROBE_GATE"] so default cargo test stays untouched.
- Cargo.toml: add transient [target.'cfg(not(target_arch = "wasm32"))'.
  dev-dependencies] block (skia-safe 0.97 + glutin 0.32.3 + glutin-winit
  0.5.0 + glow 0.17.0 + raw-window-handle 0.6.2 + scopeguard 1.2.0 +
  winit defaults). Pinned to versions resolved in /tmp/skia-glow-probe.
- .github/workflows/rust-check.yml: install Linux GL prereqs (xvfb,
  mesa, libxkbcommon, libwayland) and add a P0-probe-gate step running
  cargo test --ignored on each OS (Linux through xvfb-run; Windows
  early-returns per spec §8.2 WINDOWS_GPU_DEFERRED_NO_RUNNER).

All three artefacts are TRANSIENT — reverted in a follow-up cleanup
commit after CI is green and the loader-compat notes commit lands.
Task 1 owns the permanent integration.
2026-05-05 12:23:49 +08:00
Fini f5ac6a494b fix(shell): remove orphan rust files merge missed (modify-vs-delete)
The merge in 6fbee16a took 'theirs' for 5 conflict files but git
silently kept 4 files from our side that upstream had DELETED in
b133ebc0 ("drop OP ShellEvent + JianPointerMapper"). Modify-vs-
delete merges don't always surface as unmerged paths — the file
still existed locally + had no conflict markers, so the merge
commit went out clean despite leaving these orphans:

  crates/openpencil-shell-core/src/event.rs           (ShellEvent enum)
  crates/openpencil-shell-core/tests/event_shape.rs   (3 tests)
  crates/openpencil-shell-native/src/event/mod.rs     (JianPointerMapper)
  crates/openpencil-shell-native/tests/event_mapping.rs (15 tests)

Codex flagged the consequence: tests in event_mapping.rs still
imported ShellEvent + JianPointerMapper, both of which the
upstream's lib.rs no longer exports — workspace test compile
broken.

Removed all four files. cargo check --workspace --tests now
finishes 0 errors / 0 warnings on Apple Silicon (1.85.1 toolchain),
no remaining ShellEvent / JianPointerMapper references in the
crates tree.
2026-05-05 12:23:48 +08:00
Fini 90cd86fc11 Merge branch 'v0.8.0' of github.com:ZSeven-W/openpencil into v0.8.0
# Conflicts:
#	.github/workflows/rust-multiplatform.yml
#	README.md
#	crates/openpencil-shell-core/src/lib.rs
#	crates/openpencil-shell-native/examples/basic_window.rs
#	crates/openpencil-shell-native/src/lib.rs
2026-05-05 12:23:47 +08:00
Kayshen-X 92cf0378e8 refactor(shell): drop OP ShellEvent + JianPointerMapper — re-export Jian events directly
Per user 2026-05-05 directive: OP render engine + event types stay
consistent with Jian. The OP-specific ShellEvent enum + JianPointerMapper
translation layer (Phase B Task 3 commit f2169d00) was over-designed —
OP-side abstraction provides no value over directly consuming
jian_core::gesture::PointerEvent.

Deleted:
- crates/openpencil-shell-core/src/event.rs (ShellEvent enum + 9 subtypes)
- crates/openpencil-shell-core/tests/event_shape.rs (3 unit tests)
- crates/openpencil-shell-native/src/event/mod.rs (JianPointerMapper)
- crates/openpencil-shell-native/tests/event_mapping.rs (15 unit tests)

Added:
- shell-core lib.rs re-exports jian_core::gesture::{PointerEvent,
  PointerKind, PointerPhase, MouseButtons, Modifiers, PointerId} so
  consumer code can import Jian event types via the OP shell crate.

OP visual model differentiation (single-page + infinite canvas
recommended, multi-page also supported, no routing, cross-page event
linkage when multi-page) lives at canvas viewport layer (Step 1c+),
not at event type abstraction.

spec v19.3 → v19.4 mini-patch (separate commit in openpencil-docs)
documents the simplification.
2026-05-05 12:23:44 +08:00
Kayshen-X 8d720dbc83 feat(shell-core,shell-native): map Jian PointerEvent to ShellEvent (Step 1a Task 3)
Phase B Task 3 implementation per spec v19 §5.1 + §5.1.1 (FROZEN
2026-05-04):

shell-core:
- New `event` module declaring `ShellEvent` (6 variants per spec §5.1)
  + sub-types `PointerId / TouchId / TouchPhase / TouchForce /
    MouseButton / ElementState / ScrollDelta / Modifiers / KeyCode /
    WindowEventKind`. Pure OP types — no winit / Jian / GL — so the
  enum is wasm32-clean and visible on iOS / Android (spec §11.3).
- TouchForce::Calibrated mirrors winit::Force 1:1 (spec §11.3
  invariant) so Step 1f mobile mapper compiles without API break.
- Newtype id fields are `pub` so shell-native can construct them across
  crates (spec round 3 BLOCK-R3-4 fix).

shell-native:
- New `event` module (cfg-gated desktop only) housing
  `JianPointerMapper` — stateful diff over the per-PointerId
  `MouseButtons` snapshot. Diff runs on Down / Up / Move (spec round 3
  CONCERN-R3-1 fix); Hover / Move emits a trailing `PointerMove`.
- Touch branch maps Down/Move/Up/Cancel → Started/Moved/Ended/Cancelled;
  Touch Hover returns `Vec::new()` (touches never hover).
- Mouse / Pen / Stylus / Trackpad share the same diff branch.
- Degraded inputs (no button transition + no Move emission) return
  `Vec::new()` instead of synthesising a `ShellEvent::Other` variant
  (spec round 4 CONCERN-R4-1 fix; the enum stays at exactly 6 variants).

Tests:
- 15 new unit tests in shell-native/tests/event_mapping.rs covering
  the 4 Touch phases, mouse Hover, LEFT Down/Up pair, multi-button
  press/release during Move, Pen/Stylus/Trackpad routing, two
  degraded-empty paths, and modifiers propagation (CMD → meta).
- 3 new shape tests in shell-core/tests/event_shape.rs proving the
  6-variant invariant + TouchForce::Calibrated field shape +
  `pub`-field newtype constructibility.

Verified:
- `cargo test -p openpencil-shell-core -p openpencil-shell-native`
  green (36 tests total across both crates).
- `cargo check --target wasm32-unknown-unknown -p openpencil-shell-core`
  green; shell-web on wasm32 still compiles with the new module pulled
  through.
- `cargo check --target aarch64-apple-ios -p openpencil-shell-native`
  + `--target aarch64-linux-android -p openpencil-shell-native` both
  green (mapper cfg-gated out of mobile).
- `cargo metadata --filter-platform aarch64-linux-android` confirms
  jian-host-desktop / jian-skia not in the Android dep tree.
- §11.1 grep: 0 actual `use winit/skia_safe/glutin/...` items in
  shell-core (only doc-comment references).
- `cargo clippy --all-targets` clean; `cargo fmt --check` clean.
2026-05-05 12:23:42 +08:00
Kayshen-X d5011547f1 ci: remove TS/Electron workflows (build-electron / ci / docker / publish-cli)
Rust-ification 阶段,CI 只保留 Rust 相关:
- rust-check.yml: cargo fmt + build + test (with STEP1A_REQUIRE_GPU=1 on Linux) + clippy + cargo-deny
- wasm-bundle-check.yml: wasm32 target check

删除:
- build-electron.yml: Electron desktop build (Rust 化后用 openpencil-shell-native)
- ci.yml: TS type-check + Vitest + web build (Rust 化后已废)
- docker.yml: TS Docker image (Rust 化后重做)
- publish-cli.yml: npm packages (Rust 化后改 cargo publish)
2026-05-05 12:23:33 +08:00
Kayshen-X 786d10a1c6 feat(shell-core,shell-native): map Jian PointerEvent to ShellEvent (Step 1a Task 3)
Phase B Task 3 implementation per spec v19 §5.1 + §5.1.1 (FROZEN
2026-05-04):

shell-core:
- New `event` module declaring `ShellEvent` (6 variants per spec §5.1)
  + sub-types `PointerId / TouchId / TouchPhase / TouchForce /
    MouseButton / ElementState / ScrollDelta / Modifiers / KeyCode /
    WindowEventKind`. Pure OP types — no winit / Jian / GL — so the
  enum is wasm32-clean and visible on iOS / Android (spec §11.3).
- TouchForce::Calibrated mirrors winit::Force 1:1 (spec §11.3
  invariant) so Step 1f mobile mapper compiles without API break.
- Newtype id fields are `pub` so shell-native can construct them across
  crates (spec round 3 BLOCK-R3-4 fix).

shell-native:
- New `event` module (cfg-gated desktop only) housing
  `JianPointerMapper` — stateful diff over the per-PointerId
  `MouseButtons` snapshot. Diff runs on Down / Up / Move (spec round 3
  CONCERN-R3-1 fix); Hover / Move emits a trailing `PointerMove`.
- Touch branch maps Down/Move/Up/Cancel → Started/Moved/Ended/Cancelled;
  Touch Hover returns `Vec::new()` (touches never hover).
- Mouse / Pen / Stylus / Trackpad share the same diff branch.
- Degraded inputs (no button transition + no Move emission) return
  `Vec::new()` instead of synthesising a `ShellEvent::Other` variant
  (spec round 4 CONCERN-R4-1 fix; the enum stays at exactly 6 variants).

Tests:
- 15 new unit tests in shell-native/tests/event_mapping.rs covering
  the 4 Touch phases, mouse Hover, LEFT Down/Up pair, multi-button
  press/release during Move, Pen/Stylus/Trackpad routing, two
  degraded-empty paths, and modifiers propagation (CMD → meta).
- 3 new shape tests in shell-core/tests/event_shape.rs proving the
  6-variant invariant + TouchForce::Calibrated field shape +
  `pub`-field newtype constructibility.

Verified:
- `cargo test -p openpencil-shell-core -p openpencil-shell-native`
  green (36 tests total across both crates).
- `cargo check --target wasm32-unknown-unknown -p openpencil-shell-core`
  green; shell-web on wasm32 still compiles with the new module pulled
  through.
- `cargo check --target aarch64-apple-ios -p openpencil-shell-native`
  + `--target aarch64-linux-android -p openpencil-shell-native` both
  green (mapper cfg-gated out of mobile).
- `cargo metadata --filter-platform aarch64-linux-android` confirms
  jian-host-desktop / jian-skia not in the Android dep tree.
- §11.1 grep: 0 actual `use winit/skia_safe/glutin/...` items in
  shell-core (only doc-comment references).
- `cargo clippy --all-targets` clean; `cargo fmt --check` clean.
2026-05-05 12:23:26 +08:00
Kayshen-X b649143667 style(shell): convert all comments to English
Open-source codebase convention: all source-code comments in English.
Translates Chinese comments across openpencil-shell-{core,native,web}
.rs and Cargo.toml files. Logic, identifiers, and string literals
unchanged; the literal CJK fixture "Hello 你好" in raster_text_smoke
stays since it exercises the textlayout CJK path.
2026-05-05 12:23:14 +08:00
Kayshen-X 2dcc8a96d3 feat(workspace): pin Jian submodule and shell wrapper deps (Step 1a Task 1)
Anchor v19 pivot at the workspace level: vendor Jian as a git submodule
pinned to fork commit ad13ce6 (P0.5 mini-gate GO; skia-safe 0.78 → 0.97 +
new pub draw_on_canvas adapter), wire jian-core / jian-skia / jian-host-desktop
as path deps with explicit version per spec §12.2, and re-export the
Jian render/geometry/scene types from shell-core so shell-native can
translate the OP RenderBackend facade into jian DrawOp commands.

shell-core stays wasm32-clean: only jian-core (already wasm32-validated
in P0.5) plus glam / bitflags / thiserror / tracing land here.
shell-native picks up the full P0-pinned GL stack (skia-safe 0.97.0,
glutin 0.32.3, glutin-winit 0.5.0, glow 0.17.0, winit 0.30.13,
raw-window-handle 0.6.2, scopeguard 1.2) plus jian-skia (textlayout)
and target-gated jian-host-desktop (default-features = false, no `run`
feature so we skip Jian's softbuffer raster present path — OP owns its
own GPU swap_buffers per spec §3.6).

Adds OP RenderBackend trait + Rect / Color (with RED/GREEN/BLUE/BLACK/
WHITE/TRANSPARENT named constants per spec §5.2) + TextLayout facade
that wraps jian_core::render::TextRun explicitly (TextRun has no Default
impl, fields enumerated to honour spec §5.2 round-2 CONCERN-1 fix).

Boundary checks all pass:
- wasm32 shell-web metadata: no jian-host-desktop / jian-skia
- aarch64-linux-android shell-native metadata: no jian-host-desktop
- shell-core src: no glutin / skia_safe / winit / glow imports

Tasks 2-4 (SharedSkiaContext + NativeBackend + ShellEvent mapping +
acceptance) follow per plan v7.
2026-05-05 12:23:10 +08:00
Kayshen-X 1a6b28698d feat(openpencil-shell-core): skeleton crate (kickoff §1.2 three-crate split) 2026-05-03 22:10:00 +08:00