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.
Lands the four pure W3C → Jian gesture mappers that Phase C2's
browser listeners will consume:
- `event:⌨️:map_keyboard_parts(key, code, location, repeat,
pressed, modifiers, is_composing) -> KeyEvent` — W3C
KeyboardEvent.key/code/location string lookups produce KeyValue
(Char / Named / Unidentified) + KeyCode enum + KeyLocation enum.
Phase C1 covers KeyA-Z, Digit0-9, Enter / Escape / Tab / Space /
Backspace / Delete / arrows; Home/End/PageUp/PageDown/F-keys/
modifier physical codes (ShiftLeft etc) extend the table in C2
(codex C1 NIT-2 deferred).
- `event::ime::{composition_start, composition_update, composition_end}`
+ `utf16_selection_to_utf8` helper. The helper walks
`text.char_indices()` accumulating `len_utf16()` to remap UTF-16
code-unit offsets (what `CompositionEvent.getTargetRanges()`
hands us) to UTF-8 byte offsets (what jian-core's `ImeKind::
CompositionUpdate { selection: Range<usize> }` requires per spec
§2.4). Mis-ordered range returns None; out-of-range bounds clamp
to text.len(). CJK (你好), surrogate pairs (🙂), mixed-encoding
(aé), zero-length selections, and empty text are all covered.
- `event::pointer::map_wheel(position, dx, dy, dz, mode, mods,
timestamp: Instant) -> WheelEvent` — flips W3C deltaY sign so
widget code reads Jian-internal positive-up. Phase C1
intentionally takes `timestamp` as a parameter rather than
calling `Instant::now()` internally; `std::time::Instant::now()`
panics on wasm32-unknown-unknown ("time not implemented on this
platform") and the C2 listener will fill the timestamp from a
polyfill (web_time::Instant). Made the mapper pure to keep the
panic locus in the listener glue, where the polyfill lives.
- `event::focus::map_focus(gained, node_id_hint,
related_node_id_hint) -> FocusEvent` — pure pass-through; the
W3C target → WidgetId correlation work lives in C2 alongside
the DOM mirror id registry (Phase D groundwork).
Tests (`tests/dom_event_mapping.rs`, 22 tests, all native):
- keyboard: 5 tests + 1 empty-string-key fallback test (codex C1
NIT-3) — key/code/location preservation, named key, location
decoding, is_composing propagation, multi-codepoint &
empty-string Unidentified fallback
- ime: 8 tests — start/update/end shapes, UTF-16→UTF-8 remap for
CJK / surrogate pair / mixed encoding / zero-length / no-selection
/ mis-ordered range / out-of-range clamp / empty text
- wheel: 4 tests — Y sign flip, X no-flip, mode decoding, deltaZ
passthrough
- focus: 2 tests — gained=true with both hints, blur with no
related target
Plumbing:
- shell-web grows a direct path-with-version `jian-core` dep.
shell-core re-exports `gesture::*` but not `geometry::*`, and
the wheel mapper builds `WheelEvent.position` from
`jian_core::geometry::Point::new(...)`. Same path-with-version
pattern as shell-core's own jian-core dep.
- `pub mod event;` is NOT cfg-gated to skia — the mappers are pure
and useful on the wasm32-clean stub baseline too.
Plan-vs-implementation deviations (deliberate, all kept narrow):
- `map_keyboard_parts` adds `is_composing: bool` parameter (jian-core
KeyEvent struct REQUIRES the field per spec §2.4).
- `map_focus` adds `related_node_id_hint: Option<u64>` parameter
(jian-core FocusEvent struct field; plan body missed it).
- `map_wheel` takes `timestamp: Instant` parameter instead of
calling `Instant::now()` internally (avoids wasm32-unknown-unknown
runtime panic; pure mapper).
- `event/pointer.rs` covers ONLY wheel; full PointerEvent mapping
(kind / phase / buttons / pressure) lives in C2 alongside listener
registration since that's where the W3C PointerEvent surface meets
the runtime context.
Verification:
- `cargo test -p openpencil-shell-web --test dom_event_mapping` —
22/22 passing
- `cargo check -p openpencil-shell-web --target
wasm32-unknown-unknown --no-default-features --features web` —
green (compile guard)
- `EMSDK=$HOME/.emsdk bash tools/check-wasm-bundle.sh` — PASS
- 0 env.* imports
- 615 616 bytes gzip = 58% of 1 MiB ceiling (no growth — event
modules dead-code-eliminated when not called)
- `bash tools/check-widget-boundary.sh` — PASS (event/ doesn't
violate F1-F4)
Codex iterate review: 1 round → GO with 1 deferred CONCERN
(Instant source for C2 — already documented inline) + 3 NITs
(deltaX comment wording, additional KeyCode entries, extra
edge-case tests). NIT-1 and NIT-3 fixed in this commit; NIT-2
deferred to C2.
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.
Wires the workspace at vendor/skia-safe-op (committed in the previous
commit) via [patch.crates-io] so every consumer of skia-safe /
skia-bindings — both the wasm32-unknown-unknown shell-web bundle and
the macOS / Linux / Windows shell-native desktop binary — resolves
through the fork on every target.
[patch.crates-io] is workspace-global, NOT target-scoped; cargo does
not natively support per-target patches, so this is the accepted
blast radius. The fork is byte-identical to upstream rust-skia 0.97.0
except for the new `wasm_unknown` platform module + its single new
dispatch arm; native builds resolve to the same upstream platform
modules they did before. Verified `cargo check -p
openpencil-shell-native` builds through the fork unchanged.
Trade-off: upstream rust-skia patches no longer flow until we
re-vendor; Cargo.lock records `path` sources for skia-bindings /
skia-safe rather than `registry+...`. The full rationale block is
inline in Cargo.toml.
The Cargo.lock delta also pins js-sys 0.3.97 → 0.3.94 / web-sys
0.3.97 → 0.3.94 — this is the transitive consequence of pinning
wasm-bindgen = "=0.2.117" on shell-web (last 0.2.x release that
compiles on the workspace's Rust 1.85 toolchain; 0.2.120+ requires
1.86). Documented in shell-web/Cargo.toml.
Step 1b §3.2 P0.5B Run path, sub-phase C-hard.1.
Why: "Design a profile card" through MiniMax-M2.7 produced a 375×803 mobile
screen with auto-injected status bar, because the planner skill listed
"profiles" as a Type 2 single-task screen and the orchestrator's
isMobileScreen heuristic ran on width≤480 alone.
What: design-type.md + decomposition.md add Type 0 (single component:
card / badge / chip / modal) with width=400 height=0 1 subtask no chrome.
isMobileFullScreen helper extracted to orchestrator-plan-classify.ts and
required by both orchestrator.ts and orchestrator-sub-agent.ts so the
two paths can't drift on what "mobile" means (Codex review caught this
when only orchestrator.ts had the new check).
Verified with same MiniMax + same prompt: 400×320 component, 8 nodes,
firstChildRole=card, no status-bar.
Wires the workspace at vendor/skia-safe-op (committed in the previous
commit) via [patch.crates-io] so every consumer of skia-safe /
skia-bindings — both the wasm32-unknown-unknown shell-web bundle and
the macOS / Linux / Windows shell-native desktop binary — resolves
through the fork on every target.
[patch.crates-io] is workspace-global, NOT target-scoped; cargo does
not natively support per-target patches, so this is the accepted
blast radius. The fork is byte-identical to upstream rust-skia 0.97.0
except for the new `wasm_unknown` platform module + its single new
dispatch arm; native builds resolve to the same upstream platform
modules they did before. Verified `cargo check -p
openpencil-shell-native` builds through the fork unchanged.
Trade-off: upstream rust-skia patches no longer flow until we
re-vendor; Cargo.lock records `path` sources for skia-bindings /
skia-safe rather than `registry+...`. The full rationale block is
inline in Cargo.toml.
The Cargo.lock delta also pins js-sys 0.3.97 → 0.3.94 / web-sys
0.3.97 → 0.3.94 — this is the transitive consequence of pinning
wasm-bindgen = "=0.2.117" on shell-web (last 0.2.x release that
compiles on the workspace's Rust 1.85 toolchain; 0.2.120+ requires
1.86). Documented in shell-web/Cargo.toml.
Step 1b §3.2 P0.5B Run path, sub-phase C-hard.1.
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.
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.
P0 dep-stack probe (Step 1a) cleared all three OS targets in CI
run 25358457742:
- macOS aarch64: full window+GL probe (cross-API state + readback) PASS
- Linux x86_64 (hosted runner): link-time PASS, runtime DEFERRED
(LINUX_GPU_DEFERRED_NO_RUNNER) — Xvfb GLX limitation; same skip as
bevy / rust-skia / iced CI.
- Windows x86_64 (hosted runner): link-time PASS, runtime DEFERRED
(WINDOWS_GPU_DEFERRED_NO_RUNNER per spec §8.2).
Pin versions captured in
`openpencil-docs/superpowers/notes/2026-05-05-skia-glow-loader-compat-probe.md`.
Reverts:
- transient `[dev-dependencies]` block in shell-native Cargo.toml
(skia-safe / glutin / glutin-winit / glow / raw-window-handle /
scopeguard / dev-only winit override).
- transient `tests/p0_probe.rs` + `examples/p0_probe.rs`.
- transient workflow steps that gated `--ignored P0_PROBE_GATE` and the
Xvfb / freetype / mesa apt installs that only the probe needed.
Kept:
- prod winit dep features `["x11", "wayland", "wayland-csd-adwaita",
"rwh_06"]` — needed for Linux to satisfy winit's
`compile_error!("...not supported by winit")` guard. Stage F may
trim this when RenderBackend lands.
- workflow's libxkbcommon / libwayland apt install — winit's link-time
deps for the features above.
- `.gitattributes` — enforces `eol=lf` so future cross-OS rustfmt stays
green.
Task 1 will reintroduce skia-safe / glutin / glow / raw-window-handle
/ scopeguard as permanent prod deps when SharedSkiaContext +
RenderBackend land.
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.
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.
P0 dep-stack probe (Step 1a) cleared all three OS targets in CI
run 25358457742:
- macOS aarch64: full window+GL probe (cross-API state + readback) PASS
- Linux x86_64 (hosted runner): link-time PASS, runtime DEFERRED
(LINUX_GPU_DEFERRED_NO_RUNNER) — Xvfb GLX limitation; same skip as
bevy / rust-skia / iced CI.
- Windows x86_64 (hosted runner): link-time PASS, runtime DEFERRED
(WINDOWS_GPU_DEFERRED_NO_RUNNER per spec §8.2).
Pin versions captured in
`openpencil-docs/superpowers/notes/2026-05-05-skia-glow-loader-compat-probe.md`.
Reverts:
- transient `[dev-dependencies]` block in shell-native Cargo.toml
(skia-safe / glutin / glutin-winit / glow / raw-window-handle /
scopeguard / dev-only winit override).
- transient `tests/p0_probe.rs` + `examples/p0_probe.rs`.
- transient workflow steps that gated `--ignored P0_PROBE_GATE` and the
Xvfb / freetype / mesa apt installs that only the probe needed.
Kept:
- prod winit dep features `["x11", "wayland", "wayland-csd-adwaita",
"rwh_06"]` — needed for Linux to satisfy winit's
`compile_error!("...not supported by winit")` guard. Stage F may
trim this when RenderBackend lands.
- workflow's libxkbcommon / libwayland apt install — winit's link-time
deps for the features above.
- `.gitattributes` — enforces `eol=lf` so future cross-OS rustfmt stays
green.
Task 1 will reintroduce skia-safe / glutin / glow / raw-window-handle
/ scopeguard as permanent prod deps when SharedSkiaContext +
RenderBackend land.
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.