Commit graph

1096 commits

Author SHA1 Message Date
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 e92ed98203 docs(shell): note canvas_region invariant for collapsed sidebar
Recent codex stop-hook iterations exposed three sites where input
hit-test hardcoded LAYER_PANEL_WIDTH while paint followed
canvas_region (which collapses to 0 when sidebar is closed):
over_canvas, apply_wheel cursor offset, toolbar hit rect. Document
the invariant so future widgets don't re-introduce the drift.
2026-05-10 17:57:07 +08:00
Kayshen-X 1d2dd789a9 fix(shell-native): collapsed-sidebar canvas input uses canvas_region
Stop-hook fix: native over_canvas + apply_wheel + apply_click
LayerPanel hit-test all hardcoded LAYER_PANEL_WIDTH for the canvas
left edge. When the sidebar was collapsed, paint moved the canvas
left to x=0 but input still treated x∈[0,240) as 'over the LayerPanel'
— so clicks in that strip resolved to LayerPanel hits (against
nothing), wheel zoom anchored off-screen to the left of the cursor,
and pan-drag refused to start in that strip.

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

Now both apply_press / apply_click in native + the toolbar_rect helper
in web compute the anchor from canvas_region, so hit-test always
matches paint. Wheel zoom in web also uses canvas_region's cx0/cy0
instead of the hardcoded LAYER_PANEL_WIDTH so cursor-centered zoom
keeps the right document point fixed when the sidebar is closed.
2026-05-10 17:21:18 +08:00
Kayshen-X a578178842 docs(shell): add crates/CLAUDE.md for the Rust shell
Documents the crate layout, key invariants (wasm32-clean shell-core,
800-line file ceiling, web bundle 1 MiB / 0 env.* imports), Document
model, widget catalog, RenderBackend surface, native + web runner
specifics, hit-test order, and the perf gotchas (native chrome paint
605ms→sub-ms via cached typeface path; skia matrix reset; jian DrawOp
bypass for line + round-rect + svg-path).
2026-05-10 17:16:29 +08:00
Kayshen-X 8a95770c40 fix(shell-web): wire TopBar sidebar toggle + selection-clear parity with native
Stop-hook fix: web apply_press never wired the TopBar PanelLeft hit
or the empty-canvas selection-clear, so the sidebar collapse + click-
blank-to-deselect interactions only worked in the native demo. This
brings web behaviour in line:

- apply_press top-of-function now hit-tests TopBar; PanelLeft toggles
  Document.ui.sidebar_open. Other top-bar gaps eat the click so they
  don't fall through to canvas pan.
- canvas_region + over_canvas branch on sidebar_open so the canvas
  region extends to viewport_left when the LayerPanel is hidden.
- apply_click skips the LayerPanel hit-test entirely when the sidebar
  is collapsed.
- paint conditionally skips LayerPanel and uses canvas_region's
  collapsed-aware canvas_left for the StatusBar anchor.
- Empty-canvas press clears Document.selected (collapses RightPanel),
  matching native.

Also: collapsed AI chat pill — entire pill click toggles back open
instead of requiring a precise hit on the chevron icon (40px hit zone
was too tight).
2026-05-10 17:14:13 +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
Fini 28ea5da103 fix(ai): memoize isMobileFullScreen per plan via WeakMap
Codex stop-hook caught: even after the orchestrator-level reuse fix
(a720aac1), `orchestrator-sub-agent.ts` still calls
`isMobileFullScreen(plan)` independently in 2 places (L374 in
executeSubAgent + L734 in buildSubAgentUserPrompt). Both run AFTER
the orchestrator stripped the status-bar subtask, so they see a
smaller subtask count than the orchestrator's pre-strip classify.
A 2-subtask [status-bar, content] plan would flip from "mobile" →
"not-mobile" across the strip, and sub-agent prompt builders would
then disagree with the orchestrator about chrome handling — sub-
agent emits its own status bar / wraps in a phone mockup.

Architectural fix: classify ONCE per plan and memoize the result on
a WeakMap keyed by the plan object. Subsequent calls (whether from
orchestrator, executeSubAgent, or buildSubAgentUserPrompt) return
the cached pre-mutation answer. WeakMap avoids polluting the public
OrchestratorPlan type and lets the cache vacate naturally when the
plan goes out of scope.

This subsumes the orchestrator.ts L838 local-reuse fix from a720aac1
— that path is now safe via memo too — but the explicit reuse is
retained as defense-in-depth + readability (clear that the same
classification value is used at two adjacent call sites).

Tests: 2 new cases — mutation-survives-classify + per-plan
isolation. Existing 7 cases continue to pass.
2026-05-10 15:25:00 +08:00
Fini 0f30526676 fix(ai): orchestrator reuses pre-strip mobile classification
Codex stop-hook on the 2026-05-10 mobile fallback fix caught a
strip-and-reclassify ordering bug. orchestrator.ts mutates
`plan.subtasks` in-place at L744 to remove the status-bar subtask
on mobile, then 96 lines later re-runs `isMobileFullScreen(plan)`
to gate status-bar injection. The new narrow + multi-subtask
fallback (`subtasks.length >= 2`) flips on the second call when
a plan that originally had [status-bar, content] (2 items, height=0
or non-numeric) drops to 1 item after the strip. Result: status bar
correctly classified as needed, then the strip removes it, then the
re-classify says "actually it's a Type 0 component" → injection
skipped. Round-trip the user back to the original missing-status-
bar bug.

Fix: reuse the `isMobileScreen` constant computed at L742 (BEFORE
the strip). The classification is stable for a given plan — there's
no reason to re-evaluate after our own mutation. Comment pins the
invariant for the next refactor.
2026-05-10 15:20:00 +08:00
Fini 20dbbf227a feat(ai): detect stacked horizontal padding (page-vs-section gutter)
14th pre-validation detector + a preventive skill rule.

User-reported 2026-05-10 "Bistro" mobile food app shipped with root
padding [0,16,0,16] AND a "Today's Specials" section padding [0,24].
Effective gutter = 40px on a 375px page → only 295px of usable
content width. Reads as "too much padding" / pinched.

Two pieces:

1. layout.md AESTHETIC HYGIENE block now teaches "page gutter goes
   on ONE layer, not both" — pick root horizontal padding OR
   per-section horizontal padding, not both. Default convention:
   root carries the gutter, sections set vertical-only padding.
   Hero / banner / image-bleed sections then sit edge-to-edge by
   simply NOT adding horizontal padding (root's gutter shows
   through). Preventive teaching at prompt time.

2. detectStackedHorizontalPadding (info-only, detect-only). Walks
   every mobile-shaped root (width 320–480 + tall + multi-child),
   compares root horizontal padding against each direct child's
   horizontal padding; flags the section as the offender when both
   are > 0. Page-shape filter mirrors detectEdgeSectionPadding so
   the legitimate component-internal padding stacking pattern
   (chip → badge → icon, etc.) doesn't trip it. Severity is INFO
   because a section may legitimately want a deeper inset for
   visual emphasis — let the user/agent decide via audit panel.

Side-quest: scripts/ab-corpus/check-stacked-padding.ts ships with
this commit so the next stacked-padding-style detector calibration
can survey corpus frequency without rebuilding the harness.
2026-05-10 15:15:00 +08:00
Fini 160ab4c86c fix(ai): isMobileFullScreen treats narrow + multi-subtask as mobile
User-reported 2026-05-10: DeepSeek "Bistro" mobile food app shipped
without the iOS status-bar chrome that the orchestrator is supposed
to inject for every mobile screen.

Forensic chain: status-bar injection at orchestrator.ts:916/977 is
gated by `isMobileFullScreen(plan)`, which required
`plan.rootFrame.height >= 480`. The LLM plan came back with width=375
but a non-numeric height ("fit_content" or similar). The plan parser's
`asNonNegativeNumber` rejected the string and fell back to the
landing-page preset's `rootHeight: 0`. So the runtime check saw
height=0 → returned false → no status bar.

Fix: when width is mobile-shaped (≤480) and declared height isn't
the canonical tall-page number, fall back to the subtask count. A
plan with 2+ subtasks is structurally a multi-section mobile page;
a Type 0 component (single card / badge / modal) is always 1 subtask.
The new branch keeps Type 0 components correctly classified as
non-mobile-screen (no chrome injection, no mobile-app skill) while
catching real mobile pages whose height got lost in plan coercion.

Tests: 7 cases covering the canonical mobile, desktop, Type 0, and
the new narrow + height-0 + multi-subtask path.
2026-05-10 15:10:00 +08:00
Fini e709745f0e fix(ai): align contrast walk prune with canonical isNodeVisible
Codex round 4 caught: my walk prune was checking opacity=0 alongside
visible/enabled, but the renderer treats opacity as a paint alpha
(paint.setAlphaf) — opacity=0 nodes still get walked + laid out, just
painted with alpha 0. The canonical render-time visibility helper in
pen-core (isNodeVisible) checks ONLY `visible !== false && enabled
!== false`. Detector walk pruning has to match or it diverges from
what the renderer actually does, producing surprising results when
users probe the same tree elsewhere (debug screenshot, batch_get,
diagnostics report).

Switch to the shared isNodeVisible from pen-core. Drop the inline
opacity=0 check from the walk; an opacity=0 wrapper now gets walked
and its text is checked, with ancestorBgColor still bypassing the
alpha-0 fill at the bg-resolution layer (a fill painted with alpha
0 contributes no visible color, so the real bg is whatever sits
behind). This produces user-correct flags for opacity=0 wrappers
that hide cream-on-cream cases.

Test rewrites:
  - opacity=0 wrapper:  flips from "no flag" → "flag with real bg"
  - visible=false:      stays "no flag" (canonical hidden)
  - enabled=false:      new test — same path, also pruned

Corpus replay 14 → 14, no regression.
2026-05-10 15:00:00 +08:00
Fini fcbd2230c4 fix(ai): prune hidden subtrees in contrast walk — kill double-fix FP
Third Codex stop-hook in this thread caught a bug introduced by the
previous fix. The "skip ancestor whose node-level opacity=0 / visible
=false" guard correctly stopped a hidden wrapper from being read as
the bg, BUT the walk still descended into the hidden subtree and
flagged its text against whatever bg sat above. Hidden text doesn't
render at all, so flagging its contrast is a textbook false positive.

Move the check up: if a node has `opacity === 0` or `visible === false`
the whole subtree gets pruned at walk time. Text inside is never
inspected. The earlier `ancestorBgColor` guard is left as defense-
in-depth (cheap and protects against direct callers).

The two tests added in 65e115e8 had the wrong expectation — they
asserted the detector flagged hidden-wrapper text. Both now flip to
"does NOT flag" matching the corrected semantic. Hidden = invisible
= no contrast pair to score.

Distinction the test set still pins:
  - fill.opacity=0 (rectangle invisible, node visible)  → walk continues, ancestor walk picks real bg further up, FLAG
  - node.opacity=0 (whole subtree invisible)            → walk prunes, NO flag

Corpus replay holds at 14 hits — no regression.
2026-05-10 14:59:00 +08:00
Fini 8ddefec481 fix(ai): contrast detector skips node-level opacity=0 / visible=false too
Second Codex stop-hook caught: the previous fix only guarded fill-level
opacity (`fill.opacity === 0` / 8-hex alpha 00). PenNodeBase has its
own `opacity?: number | string` and `visible?: boolean` fields that
hide the WHOLE wrapper including its fill. A wrapper with

    { fill: [{type:'solid', color:'#FFFFFF'}], opacity: 0, ... }

was still being treated as a white bg and masking the real bg further
up the chain.

ancestorBgColor() now skips ancestors whose node-level
`opacity === 0` or `visible === false`, complementing the
firstSolidColor fill-level guard. `opacity` can be a `$variable` ref
in PenDocument; resolving that to a literal 0 is not yet covered —
we only catch the literal-0 case for now (which is the AI-output
shape the corpus produces).

Two new test cases cover both paths.
2026-05-10 14:58:00 +08:00
Fini 5a841f011f fix(ai): contrast detector skips effectively-transparent wrapper fills
Codex stop-hook review caught: the detectTextBgContrast ancestor walk
treated any wrapper with a solid `fill` entry as the bg color, even
when the fill was effectively invisible. The classic miss case:

  page { fill: cream }
    └─ wrapper { fill: [{ type: 'solid', color: '#FFFFFF', opacity: 0 }] }
        └─ text { fill: cream }

Without the guard, the detector picked the wrapper's white fill as bg
and reported a healthy contrast ratio against the cream text — masking
the real cream-on-cream failure that lives one level up.

firstSolidColor() now skips fills with `opacity === 0` and 8-hex colors
whose alpha byte is `00` (e.g. `#FFFFFF00`). Both produce no visible
color, so the ancestor walk continues past them to the real bg.

Semi-transparent fills (opacity 0.5, 8-hex alpha 80, etc.) are out of
scope — the detector still treats them as opaque rather than trying to
math the layered composite. Tests pin both: opacity=0.5 + alpha=80
stay treated as bg.

4 new test cases cover the fix plus the boundary (opacity=0.5, alpha=80
should NOT be skipped). Full corpus replay shows 14 hits unchanged on
the 470-row corpus — no false-positive regression introduced.
2026-05-10 14:55:00 +08:00
Fini 31d45b79be chore(ab-corpus): add shape + per-category issue inspectors
Two complement scripts that ride alongside replay-detectors.ts:

- inspect-shape.ts: bucket every applied row's root by (width-bucket /
  height-bucket / aspect-ratio / child-count). Used 2026-05-10 to
  diagnose why detectEdgeSectionPadding scored 0 hits on a 220-row
  mobile subset — turned out 49% of mobile rows produce roots with a
  string-typed width ("fill_container" / "fit_content") because the
  element-tools path emits component fragments, not pages. The
  detector predicate `typeof width === 'number'` correctly skips them
  → 0 hits is the EVAL HARNESS coverage gap, not a detector bug.

- inspect-issue-category.ts: print every issue in a chosen category
  with row id + node id + reason. Generic version of inspect-contrast-
  hits.ts. 2026-05-10 used it to validate
  excessive-frame-effects (4/4 TP — blur 48 cards + OTP slot spread)
  and the two borderline mixed-sibling hits (header padding outlier,
  spacer cornerRadius outlier — known role-aware limitation, 0.2%
  noise rate, accepted).

Together with replay-detectors.ts these three give a fast empirical
loop for tuning a detector against real corpus output without burning
fresh API tokens.
2026-05-10 14:50:00 +08:00
Fini 761c5202e2 fix(ai): retune contrast thresholds to 2.5/2.0 — kill 35/41 false positives
Replayed the 2026-05-08-rank4-gpt55 corpus (104 GPT-5.5 dashboard
outputs, 95 applied) through the new detectTextBgContrast and got
41 hits — 43% of designs flagged. Sampling showed almost all of them
were industry-standard Tailwind palettes used as intentional tertiary
text:

  - #94A3B8 (slate-400) caption on #FFFFFF, ratio 2.56  ← Linear/Vercel/Notion
  - #2563EB (blue-600) chip on #DBEAFE, ratio 4.24      ← shadcn/ui tag pattern
  - #10B981 (emerald-500) delta on #FFFFFF, ratio 2.54  ← stat-positive pattern
  - #64748B (slate-500) row text on #F1F5F9, ratio 4.34 ← muted-row pattern

WCAG-AA 4.5:1 is a compliance threshold, not a design-diagnosis
threshold. The user-reported pain point is "white-on-cream" (1.10:1)
and "white-on-white" (1.0:1) — disasters that read as obviously broken
to anyone, not borderline-WCAG cases that production designers ship
on purpose.

Drop default normalThreshold to 2.5 and largeThreshold to 2.0. Open
both as opts so callers needing a stricter audit (e.g. compliance
report) can bring back WCAG-AA without re-implementing the walk.

Replay confirms the new thresholds:
  - 41 hits → 6 hits (signal-to-noise from 50% to 0% on the sample)
  - All 6 remaining are true positives:
    * 3 × slate-400 on slate-100 (caption color used on a non-white
      bg — designer mis-paired the palette)
    * 3 × white initial on amber-500 avatar (the readability gap the
      industry routinely ignores; legitimately worth flagging)

Codex review (a47ef892f72a2d315) confirmed the direction, the
specific numeric pair (2.5 not 3.0 — 3.0 still hits slate-400 at 2.56),
parameterization over a mode-flag, and keeping severity at info-only.

Side-quest: scripts/ab-corpus/replay-detectors.ts +
inspect-contrast-hits.ts ship with this commit so the next detector
calibration doesn't have to rebuild the harness from scratch.
2026-05-10 14:45:00 +08:00
Fini bf4273f328 feat(ai): detect text-bg contrast below WCAG AA (P0-2 from aesthetics roadmap)
13th pre-validation detector. Walks every text node, finds the closest
ancestor with a usable solid fill (or first gradient stop as a coarse
approximation), resolves both colors through doc.variables / theme,
and computes WCAG 2.x relative-luminance contrast ratio. Flags ratios
below 4.5:1 for normal text and 3.0:1 for large text (>=24px or
>=19px bold).

Detect-only severity (info). The 2026-05-09 review explicitly rejected
auto-replacing fills via "nearest brand-token" heuristics — the right
replacement depends on the design system + theme + intent, which only
the user/agent can decide. Issues surface in the audit panel and chat
status line so the misuse is visible without silently rewriting fills.

Side effects:
- Extracted parseHexColor / relativeLuminance / colorContrast from
  detectors.ts into diagnostics/color-utils.ts so the new detector
  doesn't duplicate ~30 lines of WCAG math.
- New detector lives in diagnostics/detectors-typography.ts (mirroring
  the per-category split started by detectors-spacing.ts).
- Adds @zseven-w/pen-core to pen-ai-skills deps so the detector can
  call resolveColorRef + getDefaultTheme — the canonical authority on
  the document's variable model.
2026-05-10 14:30:00 +08:00
Fini 02c137e539 chore(agent): bump agent-native — anthropic provider gets same fix
Bumps to 22f20e42 which mirrors the openai_compat cleanup-race fix to
the anthropic provider. Same two bombs (non-atomic cleaned guard +
stack→heap HttpClient bit-copy) had the same potential SIGABRT
trigger when consuming Claude / MiniMax-anthropic-compat streams.
2026-05-10 13:55:00 +08:00
Fini b8baaa9dd7 chore(agent): bump agent-native — kill SIGABRT cleanup race
Bumps to agent-native@5fc073ce which mutex-guards OpenAIStreamState
nextDelta and constructs HttpClient directly into the heap state struct
(no stack→heap bit-copy of std.http.Client). Fixes the 2026-05-10 dev-
server SIGABRT triggered when sub-agent #3's streaming response was
double-cleaned by two concurrent NAPI thread-pool workers.

Trigger from openpencil side is the fire-and-forget delegate fan-out
in apps/web/server/api/ai/agent.ts:1094 — multiple member iterators
race through the same nextDelta loop. Thread safety is now enforced
in the native module so the JS contract stays "delegate as you like".
2026-05-10 13:40:00 +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 007e97ba03 fix(shell): Step 3 stop-hook R2 — plumb viewport_height through paint
Codex Step 3 R1 BLOCK: the prior fix `10cae1e5` exposed
canvas_height() on WebBackend but only used it for the white-
background clear, NOT for `WidgetHost::paint`. The host's
canvas viewport rect still derived its height from a hardcoded
`640.0` (web) / `600.0` (native), so any window/canvas at a
non-default height got the wrong bottom edge.

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

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

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

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

Verification:
- `cargo build -p openpencil-shell-native --example
  inspector_window` — green
- `cargo build -p openpencil-shell-web --target
  wasm32-unknown-unknown --features skia --release` — green
- `wasm-bindgen --target web` — produces ../pkg/*
- `bash tools/check-wasm-bundle.sh` — PASS, 0 env.*, 907 092
  bytes gzip = 86% of 1 MiB ceiling
- `grep "640.0\|600.0" crates/openpencil-shell-web/src/widget_
  host.rs crates/openpencil-shell-native/src/widget_host.rs`
  — only one match, inside a comment citing the prior bug
2026-05-10 13:20:17 +08:00
Kayshen-X 7e1fbcabf0 fix(shell-web): Step 3 stop-hook — read canvas size from backend
Codex Step 3 stop-hook flagged: "web repaint ignores actual
canvas size". The prior fix `9cf0f865` hardcoded `960.0` to
match the smoke HTML's `<canvas id="op" width="960">`, but
that's brittle — any host that mounts onto a differently-sized
canvas (responsive HTML, programmatic mount, future smoke
fixture changes) gets the wrong layout viewport.

Fix: WebBackend gains `canvas_width(&self) -> u32` +
`canvas_height(&self) -> u32` accessors. The `width` /
`height` fields are already refreshed at construction
(`canvas.width()`) and on `RenderBackend::resize`, so reading
them per-paint reflects whatever the host's `<canvas>` width
attribute currently is.

`paint_inspector` now:
- Reads `viewport_w` + `viewport_h` from the backend at the
  start of each frame.
- Uses them for the white-background clear AND for the
  WidgetHost::paint viewport_width arg.

This also resolves the prior "web smoke paints only the
toolbar" issue since the smoke canvas is 960×640 — the first
frame still receives 960 as viewport_width, but now via the
backend instead of a hardcode.

Verification:
- `cargo build -p openpencil-shell-web --target
  wasm32-unknown-unknown --features skia --release` — green
- `wasm-bindgen --target web` — produces ../pkg/*
- `bash tools/check-wasm-bundle.sh` — PASS, 0 env.*, 907 031
  bytes gzip = 86% of 1 MiB ceiling (negligible delta — two
  small accessor methods).
2026-05-10 13:14:17 +08:00
Kayshen-X 01c5baf8f5 refactor(wasm-libc-shim): split imp.rs to honor 800-line ceiling
Codex Step 3 stop-hook flagged: `wasm-libc-shim` violates the
repo file-size gate. After the Step 3 additions
(`bd6f4836` + `12b9af92`) `imp.rs` grew to 845 lines, over
the 800-line ceiling documented in `openpencil/CLAUDE.md`
("Single files must not exceed 800 lines").

# Layout

```
crates/wasm-libc-shim/src/imp/
  mod.rs   (521 lines) — original C-hard.2 surface:
                          allocator + libm + libc string +
                          libc misc + C++ ABI + operator new/
                          delete + threads + libcxx panic stubs
  step3.rs (349 lines) — Skia font-path additions: extra
                          string ops (strncmp/strncpy/strstr/
                          strrchr/strcat/strtol/tolower/qsort),
                          file I/O sentinel returns, mmap,
                          setjmp/longjmp, _ZnwmRKSt9nothrow_t
```

Both files emit `#[no_mangle]` symbols at the same flat global
namespace — wasm-ld resolves them identically regardless of
the parent Rust module. Module structure is purely a source-
organisation concern, no ABI implications.

# Plumbing

- `git mv imp.rs imp/mod.rs` to preserve history.
- `imp/mod.rs` declares `mod step3;` at the top + makes
  `is_space` `pub(super)` so `step3::strtol` can reuse it
  without re-implementing the whitespace check.
- `c_long` import dropped from `mod.rs` (only `step3.rs` uses
  it now).

# Verification

- `cargo build -p openpencil-shell-web --target
  wasm32-unknown-unknown --features skia --release` — green
- `wasm-bindgen --target web` — produces ../pkg/*
- `bash tools/check-wasm-bundle.sh` — PASS, 0 env.*, 906 988
  bytes gzip = 86% of 1 MiB ceiling (no growth — the split is
  source-only)
- File sizes:
  - imp/mod.rs: 521 lines (under 800 ✓)
  - imp/step3.rs: 349 lines (under 800 ✓)
2026-05-10 13:02:47 +08:00
Kayshen-X 7b29943946 fix(wasm-libc-shim): Step 3 R1 — c_long for strtol/ftell/fseek
Codex Step 3 R1 CONCERN: wasm32-unknown-unknown sizes `long` as
32-bit, but the new libc shim returned `i64` from `strtol` /
`ftell` and accepted `i64 offset` in `fseek`. The wasm-ld
linker resolved the mismatch by inserting `signature_mismatch:
strtol` / `signature_mismatch:ftell` / `signature_mismatch:
fseek` trap stubs into the bundle — calling any of them at
runtime would have crashed even though the shim crate
"compiled".

Fix: use `core::ffi::c_long` (= i32 on wasm32, i64 on
desktop) for return + offset types. Verified with
`wasm-objdump -x | grep signature_mismatch:` — no entries
remain for strtol / ftell / fseek / setjmp / longjmp / fopen /
fread / fclose / fprintf. (The remaining `signature_mismatch:
_ZNSt3__2…` entries are our libcxx_stub! macros, which return
`!` and are correct to trap-on-call.)

Two NIT fixes folded in:

- `strtol` now accepts an explicit `0x` prefix when the
  caller passes `base=16` (codex Step 3 R1 NIT-2 — strtoull
  already had the same handling; mirrored for parity).
- `qsort` no longer silently no-ops on element size > 256;
  panics loudly so a real call site gets a usable diagnostic
  (codex Step 3 R1 NIT-3). Tiny font-feature / glyph-run
  arrays stay under the threshold.

WebBackend typeface caching tightened (codex Step 3 R1 NIT-1):
- New sticky `typeface_tried: bool` flag flips on first
  attempt regardless of outcome. Subsequent draw_text calls
  skip the FontMgr / from_data round-trip if `typeface` is
  still None — a one-time failure no longer re-parses the
  TTF on every frame.

Verification:
- `cargo build -p openpencil-shell-web --target
  wasm32-unknown-unknown --features skia --release` — green
- `wasm-bindgen --target web` — produces ../pkg/*
- `wasm-objdump -x | grep "signature_mismatch:" | grep -E
  "strtol|ftell|fseek|..."` — empty (all shim signatures
  resolve cleanly)
- `bash tools/check-wasm-bundle.sh` — PASS:
  - 0 env.* imports
  - 906 964 bytes gzip = 86% of 1 MiB ceiling (unchanged)
2026-05-10 12:51:17 +08:00
Kayshen-X fe5249567f fix(shell-web): Step 3 stop-hook — actually render canvas text
Codex stop-hook flagged: "web Step 3 cannot render the claimed
canvas text". Root cause: WebBackend::draw_text was a Phase A
no-op stub. CanvasViewport calls draw_text for "Hello
OpenPencil" / "Click me" / Layer panel labels / etc — none of
those text strings actually rendered in the browser.

# Fix

`crates/openpencil-shell-web/src/backend/mod.rs::WebBackend`:
- Embeds `assets/Roboto-Regular.ttf` (Apache 2.0, 35 KB, copied
  from rust-skia test resources) via `include_bytes!`. The
  C-hard wasm32-unknown-unknown skia build uses
  `skia_enable_fontmgr_custom_empty=yes` (see
  `vendor/skia-safe-op/skia-bindings/build_support/platform/
  wasm_unknown.rs`), so there are no system fonts and we have
  to bake the bytes in.
- New `typeface: Option<Typeface>` field, lazy-init on first
  draw_text via `FontMgr::custom_empty().and_then(|m|
  m.new_from_data(ROBOTO_TTF, None))`. Build failure → None
  silently no-ops subsequent draws (no panic — text just
  doesn't render).
- `draw_text` now iterates `layout.runs()` and calls
  `Canvas::draw_str` per run with a `Font::new(typeface,
  font_size)` + `Paint` from the run color.

`crates/openpencil-shell-web/Cargo.toml`:
- Drops `textlayout` skia-safe feature. We use raw `draw_str`
  not paragraph builder; textlayout pulled ICU + Harfbuzz +
  ~400 KB gzip + a swarm of font-lookup imports we don't
  exercise.

# 24 new env.* imports — wasm-libc-shim expansion

Even without textlayout, Skia's font path imports 24 libc
symbols our prior C-hard.2 shim didn't cover. All resolved:

`crates/wasm-libc-shim/src/imp.rs` — Rust extern "C" shims:
- string ops (real impls): strncmp, strncpy, strstr, strrchr,
  strcat, strtol, tolower, qsort (insertion sort, fits Skia's
  small-array call sites; debug_assert on element size > 256)
- file I/O (sentinel error returns, no filesystem on wasm):
  fopen → null, fread → 0, fclose → 0, fputc → c, fileno → -1,
  fstat → -1, pread → -1, ftell → -1, fseek → -1
- env: getenv → null
- mmap: returns MAP_FAILED ((void*)-1); munmap → -1
- setjmp/longjmp: setjmp returns 0 (treat as initial call);
  longjmp panics — happy text path through in-memory TTF parse
  should never trigger it
- C++ nothrow new: `_ZnwmRKSt9nothrow_t` forwards to malloc,
  returns nullptr on OOM (the nothrow contract)

`crates/wasm-libc-shim/src/stdio_stub.c`:
- fprintf C-side variadic stub (Skia diagnostic path) that
  routes into the same panic helper as snprintf / vsnprintf /
  vfprintf — same fail-fast policy.

# Verification

- `cargo build -p openpencil-shell-web --target
  wasm32-unknown-unknown --features skia --release` — green
- `wasm-bindgen --target web` produces ../pkg/openpencil_shell
  _web.{js,_bg.wasm}
- `bash tools/check-wasm-bundle.sh` — PASS:
  - 0 env.* imports preserved (24 new ones absorbed by shim)
  - 906 935 bytes gzip = 86% of 1 MiB ceiling (+286 KB vs
    pre-text — Skia font/freetype subsystem is substantial.
    Headroom: 14% of ceiling)
- `cargo test -p openpencil-shell-core --lib` — 39 tests
- `cargo build -p openpencil-shell-native --example
  inspector_window` — green (desktop demo unchanged — uses
  jian-skia textlayout via NativeBackend, not the web font
  path)
- `cargo check -p openpencil-shell-native --target
  aarch64-apple-ios` — green
- `cargo check -p openpencil-shell-native --target
  aarch64-linux-android` — green

# Re-run the demo

```
EMSDK="$HOME/.emsdk" cargo build -p openpencil-shell-web \
    --target wasm32-unknown-unknown --features skia --release
wasm-bindgen --target web --out-dir crates/openpencil-shell-web/pkg \
    target/wasm32-unknown-unknown/release/openpencil_shell_web.wasm
cd crates/openpencil-shell-web/smoke
python3 -m http.server 8000
# Browser: http://localhost:8000/step-1b.html
```

Now the canvas viewport renders "Hello OpenPencil" + "Click me"
text in addition to the rect/stroke geometry.
2026-05-10 12:38:33 +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 3192d237f4 fix(shell-web): Step 3 stop-hook — pass full canvas width to host
Codex stop-hook flagged: "web smoke paints only the toolbar".

Root cause: shell-web's `paint_inspector` still passed the Step
1b leftover `280.0` to `host.paint`, but Step 3's WidgetHost
layout takes ~1/4 width per rail. With viewport_width=280 the
rail_w computation:

    rail_w = ((280.0 / 4.0) - 8.0).min(240.0).max(0.0) = 62.0

falls below MIN_RAIL_WIDTH (80), so the host's early-return
silently fired and only the toolbar painted. The smoke HTML
canvas is 960×640 — the host was getting a synthetic
viewport that didn't reflect reality.

Fix: pass `960.0` to `host.paint`, matching the smoke HTML's
`<canvas id="op" width="960">`. Now LayerPanel + CanvasViewport
+ PropertyPanel all paint into the canvas.

Inline comment cites the codex finding so a future hardcoded
viewport width regression is obvious.

Bundle untouched at 624 474 bytes gzip / 0 env.* imports.
2026-05-10 12:15:39 +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
Fini 33493bf7bd Merge remote-tracking branch 'origin/v0.8.0' into v0.8.0
# Conflicts:
#	Cargo.lock
#	crates/openpencil-shell-web/Cargo.toml
#	crates/openpencil-shell-web/src/lib.rs
#	packages/pen-ai-skills/src/diagnostics/detectors.ts
#	packages/pen-ai-skills/src/diagnostics/types.ts
#	packages/pen-mcp/src/routes/debug-routes.ts
2026-05-10 10:18:00 +08:00
Kayshen-X c109b54f70 docs(shell-native): correct stale Cargo.toml comment about Invariant 2
The previous commit's inline rationale at the top of the cross-
platform widget stack `[target...]` block claimed "Spec §12.3 jian
boundary invariants 2 & 3 are unchanged", which is incorrect — the
same commit also revised Invariant 2 in
`tools/check-jian-boundaries.sh` to allow `jian-skia` on iOS /
Android (only `jian-host-desktop` stays forbidden on mobile).

Replaced the stale paragraph with an accurate one that:
- names the 2026-05-10 revision date
- describes what Invariant 2 now permits (jian-skia) and forbids
  (jian-host-desktop)
- notes Invariant 3 (wasm32 forbids both) is unchanged
- points at the boundary script header for the full rationale
2026-05-10 10:17:10 +08:00
Kayshen-X 97ef49d76f feat(shell-native): extend widget stack to iOS + Android cargo check
Per 2026-05-10 user directive ("extend, jian 最后也会需要 ios 和
android"): lift the desktop-only cfg gate so the widget render
stack (skia-safe + jian-skia + NativeBackend + widget_host)
compiles for iOS (`aarch64-apple-ios`) AND Android
(`aarch64-linux-android`) cargo check too. Mobile shells now have
a real widget-rendering surface to target in Step 1f, and the
"shell-core widgets are platform-agnostic" claim from spec §1.4
is now compile-verified across desktop trio + mobile pair + wasm.

Cargo.toml restructure (`crates/openpencil-shell-native/Cargo.toml`):
- New `[target.'cfg(any(macos, linux, windows, ios, android))']`
  block for the cross-platform widget stack: `skia-safe = "0.97"`
  (default-features = false; binary-cache + textlayout) and
  `jian-skia` (textlayout). Both pull on every desktop trio +
  mobile pair target.
- Existing desktop-only block kept for the GUI host stack: adds
  `gl` to skia-safe's features (iOS deprecated GL — Metal goes
  in Step 1f; Android GL/Vulkan via the platform provider not
  via skia-safe's bundled bindings here), plus glutin / glutin-
  winit / winit / scopeguard / jian-host-desktop. Cargo
  deduplicates: skia-safe resolves to one crate-version with
  feature-union (binary-cache + textlayout from the wider block
  + gl from the desktop block on desktop-only).

src/lib.rs gate lift:
- `pub mod backend;` and `pub mod widget_host;` cfg now includes
  `target_os = "ios"` and `target_os = "android"`. `pub use`
  re-exports follow.
- `canvas_view_stub` stays desktop-only (uses glow GL-isolation
  probe with no mobile equivalent).
- Comment block at the cfg site cites the user directive +
  Step 1f handoff (real EaglProvider / AndroidEglProvider impls
  + Metal / Vulkan / event integration).

Boundary script revision (`tools/check-jian-boundaries.sh`):
- Invariant 2 was: mobile targets must NOT pull jian-host-desktop
  OR jian-skia. Per the user directive, jian-skia is now ALLOWED
  on mobile (the widget render stack uses it). jian-host-desktop
  remains forbidden — it carries winit / glutin / desktop
  GLContextProvider impls that have no mobile equivalent.
- Header comment block + active grep narrowed accordingly. The
  Step 1f path through EaglProvider / AndroidEglProvider is the
  spec-blessed mobile host plugin point (no IPC / CLI needed).

Verification:
- `cargo check -p openpencil-shell-native --target
  aarch64-apple-ios` — green (skia-bindings + jian-skia +
  shell-native all compile)
- `cargo check -p openpencil-shell-native --target
  aarch64-linux-android` — green (same)
- `cargo check -p openpencil-shell-native` — green (no desktop
  regression)
- `cargo check -p openpencil-shell-core --target
  wasm32-unknown-unknown` — green (shell-core stays wasm32-clean)
- `cargo test -p openpencil-shell-core --test widgets_static` —
  21/21 passing (widget logic untouched)
- `bash tools/check-wasm-bundle.sh` — PASS:
  - 0 env.* imports
  - 622 156 bytes gzip = 59% of 1 MiB ceiling (no web regression)
- `bash tools/check-widget-boundary.sh` — PASS
- `bash tools/check-jian-boundaries.sh` — 4/4 invariants pass
  (Invariant 2 revised to allow jian-skia on mobile)

What's still mobile-pending (Step 1f scope):
- `EaglProvider` (iOS) — Metal-backed `GlContextProvider` impl
  (skia-safe `metal` feature when iOS host actually runs)
- `AndroidEglProvider` (Android) — GL/Vulkan-backed impl
- Mobile host runners (UIKit AppDelegate / Activity wrappers)
- Mobile event translation (jian-host-ios / jian-host-android —
  siblings of jian-host-desktop)
- `inspector_window` example is desktop-only by design (winit +
  SharedSkiaContext::new_desktop); mobile shells will land their
  own UIKit / Activity runners that consume the SAME
  `WidgetHostNative::paint(&mut frame, width)` surface

The widget glue itself (NativeFrameBackend + WidgetHostNative)
is platform-agnostic in shape — no winit / glutin / EGL types
leak in. Step 1f mobile work plugs in providers, not widgets.
2026-05-10 10:16:36 +08:00
Kayshen-X 987ce82a24 feat(shell-native): WidgetHostNative + inspector_window — cross-platform proof
Lands the shell-native consumer of shell-core's Step 1b widget
module so spec §1.4 is concrete: same widget code, same paint
output on macOS / Linux / Windows desktop AND
wasm32-unknown-unknown browsers. User priority for this commit
("主要是native 端") + the parallel Phase D web work.

What's added:
- `crates/openpencil-shell-native/src/widget_host.rs` (~155 LOC):
  * `NativeFrameBackend<'a>` — frame-scoped wrapper holding
    `(&mut NativeBackend, &skia_safe::Canvas)`, impls
    shell-core's `RenderBackend` by forwarding to the existing
    `NativeBackend::{fill_rect, stroke_rect, draw_text,
    clip_rect, save, restore, translate}` methods (each takes
    the canvas as a separate arg in the existing API).
    `begin_frame`/`end_frame` no-op because `SharedSkiaContext::
    with_frame` owns those bracket points; `resize` no-op because
    surface resize lives on `SharedSkiaContext::resize`. Spec
    §5.2.1 explicitly deferred this RenderBackend impl to Step
    1c+ widget tree work — this is that landing site.
  * `WidgetHostNative` — owns one of each B1/B2 widget
    (TreeWidget::sample, PropertyRow::new(200, "Width", "960"),
    Dropdown::sample, TextInput::sample). `paint(&self, frame,
    available_width)` mirrors shell-web's `WidgetHost::paint`
    exactly (16/12 px gaps, 280 px column) so the visual layout
    is identical between platforms — Phase E manual smoke
    acceptance criterion.
  * `// glue:` markers for the (future) cross-crate widget-
    boundary gate.

- `crates/openpencil-shell-native/examples/inspector_window.rs`
  (~150 LOC) — winit + SharedSkiaContext + NativeBackend +
  WidgetHostNative end-to-end. Same shape as `basic_window.rs`
  but the per-frame paint dispatches to `WidgetHostNative`
  instead of hard-coded chrome. cfg-gated to desktop OS; CI
  verifies `cargo build --examples` only.

- `crates/openpencil-shell-native/src/lib.rs` — adds `pub mod
  widget_host;` cfg-gated to desktop OS (matches the existing
  `backend` / `canvas_view_stub` gating per spec §11). Re-exports
  `NativeFrameBackend` + `WidgetHostNative` at the crate root.

Mobile (iOS / Android) considered (per 2026-05-10 user directive
"安卓和ios 不需要 ipc / 本地 cli — 只需要 custom provider"):
- The widget glue is platform-agnostic in shape — no winit /
  glutin / EGL / desktop-only types leak in. `NativeFrameBackend`
  only borrows `NativeBackend` + `&skia_safe::Canvas`;
  `WidgetHostNative` only consumes shell-core widgets + the
  `RenderBackend` trait. Both compile on any target where
  `NativeBackend` compiles.
- Today the desktop-only cfg on `widget_host` mirrors the
  desktop-only cfg on `backend` (per spec §11 invariants 1 & 3:
  mobile widget rendering lands in Step 1f). When Step 1f ships
  real `EaglProvider` (iOS) / `AndroidEglProvider` (Android)
  impls and lifts the desktop cfg, `WidgetHostNative` follows
  automatically — no rewrite, no IPC / CLI infrastructure.
- Doc comment in `widget_host.rs` + `inspector_window.rs`
  explicitly documents this Step 1f path.
- Verified both iOS (`aarch64-apple-ios`) and Android
  (`aarch64-linux-android`) cargo check still green with
  shell-native's mobile compile guard in place.

Verification:
- `cargo build -p openpencil-shell-native --example
  inspector_window` — green (desktop)
- `cargo check -p openpencil-shell-native` — green (no
  regression on Step 1a basic_window)
- `cargo check -p openpencil-shell-native --target
  aarch64-apple-ios` — green (mobile compile guard intact)
- `cargo check -p openpencil-shell-native --target
  aarch64-linux-android` — green (mobile compile guard intact)
- `cargo check -p openpencil-shell-core --target
  wasm32-unknown-unknown` — green (shell-core stays
  wasm32-clean per spec §1.2)
- `cargo test -p openpencil-shell-core --test widgets_static` —
  21/21 (no widget changes)
- `bash tools/check-wasm-bundle.sh` — PASS (web bundle still 0
  env.* / 622 KiB gzip / 59% ceiling — no regression)
- `bash tools/check-widget-boundary.sh` — PASS
- `bash tools/check-jian-boundaries.sh` — 4/4 invariants PASS

Phase D (web DOM mirror + native accesskit_winit integration)
follows.
2026-05-10 10:11:01 +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 32d8a190a3 fix(shell-web): Phase C stop-hook — owned hidden IME target
Codex stop-hook surfaced after the Phase C gate GO: window-level
composition listeners were processing IME activity from ANYWHERE on
the page (URL bar, devtools search, any other editable element)
as if it were directed at the inspector's TextInput state. Without
an owned editable target, the IME wiring was technically reachable
end-to-end but semantically incorrect.

Fix: create a hidden `<textarea>` in `mount()`, append to
`document.body`, programmatically focus it, and register the 3
composition listeners on it instead of `window`. The textarea is:
- styled `position:fixed; left:-9999px; top:0; width:1px;
  height:1px; opacity:0; pointer-events:none;` so it does not
  visually intrude
- `aria-hidden="true"` so screen readers ignore it
- `tabindex="-1"` so Tab-traversal skips it

`focus()` is best-effort (returns Err if the document is not yet
visible — e.g. background tab); the page user gives focus on the
first interaction. Once focused the textarea owns IME composition
contexts; only compositions targeted at it reach the inspector.

Keyboard listeners stay on `window` — Cmd+S / Tab / arrow shortcuts
should fire regardless of which element has focus, and the
stop-hook concern was specifically about IME, not keyboard.

Plumbing changes:
- `WebShell` gains `ime_target: web_sys::HtmlElement` field. Drop
  calls `self.ime_target.remove()` after unregistering listeners
  so leaving the page does not leave an orphan node + the browser
  does not ship dead composition state into the next document.
- `add_listener` is now generic over the target type
  `T: Clone + Into<EventTarget>`; the helper clones into an owned
  EventTarget once for the registration call + Listener cleanup
  storage. Call sites pass `&win_target` (T = EventTarget) for
  keyboard listeners and `&ime_textarea` (T = HtmlElement) for IME
  listeners without an explicit cast.
- The partial-registration unwind path also calls
  `ime_textarea.remove()` so a failed mount does not leave an
  orphan node behind (Phase C gate Round 1 BLOCK fix from earlier
  is preserved + extended).

Verification:
- `cargo build -p openpencil-shell-web --target wasm32-unknown-
  unknown --features skia --release` — green
- `cargo check -p openpencil-shell-web --target wasm32-unknown-
  unknown --no-default-features --features web` — green
  (compile guard)
- `bash tools/check-wasm-bundle.sh` — PASS:
  - 0 env.* imports
  - 622 149 bytes gzip = 59% of 1 MiB ceiling (+1 KiB vs C-gate
    R5 — hidden-textarea creation + remove paths cost ~1 KiB of
    web-sys glue)
- `cargo test -p openpencil-shell-web --test dom_event_mapping`
  — 25/25 (mappers are pure; this fix is mount-side glue and
  doesn't touch the mappers)
- `bash tools/check-widget-boundary.sh` — PASS

Phase D will land focus management for arbitrary widget chrome
focus + the Reflect-based getTargetRanges() lookup that the IME
selection pipeline still owes; for Phase C / Step 1b the hidden
textarea is the correct simplest scope.
2026-05-09 21:53:00 +08:00
Kayshen-X 40dd271816 fix(shell-web): Phase C gate — exception-safe listener reg + key coverage
Two fixes surfaced by the Phase C gate review iterations:

# C-gate R1 BLOCK: partial listener registration not exception-safe

Five `add_listener?` calls in `mount()` were sequenced via the
question-mark operator. If registration #K returned `Err`, the
K-1 already-landed listeners would never be unregistered:
WebShell never reaches `Ok(WebShell { ... })` so its `Drop`
never runs, and the partial `listeners` Vec drops without
calling `remove_event_listener_with_callback` first — the
browser-held DOM callbacks then outlive their Closures, which
is dangling-listener UB at the wasm-bindgen boundary.

Fix: wrap all 5 registrations in an inner closure that consumes
`&mut Vec<Listener>` and returns `Result<(), JsValue>`. On Err
we drain the partial vec and unregister each listener with the
same body Drop uses, then return the error. Comment cites the
codex finding inline.

# C-gate R1 CONCERN + R3 BLOCK: incomplete named-key coverage

`map_key_value` covered Enter/Escape/Tab/Space/Backspace/Delete/
Arrow{Up,Down,Left,Right} only. jian's `NamedKey` enum has 31
variants total — Home, End, PageUp, PageDown, F1..F12, Shift,
Control, Alt, Meta, and CapsLock were all falling through to
`Unidentified(<key>)`.

`map_key_code` similarly missed Home, End, PageUp, PageDown, and
the 8 modifier physical codes (ShiftLeft / ShiftRight / etc).

Fix: extended both tables. After the fix, every NamedKey jian
exposes round-trips through map_key_value, and every
non-Unknown KeyCode variant round-trips through map_key_code
(F1-F12 stay Unknown because jian's KeyCode does NOT have F-key
variants — NamedKey covers them; comment in keyboard.rs
explains).

# Tests added (3 new round-trip tests, 22 → 25)

- `keyboard_navigation_keys_mapped` — Home / End / PageUp /
  PageDown / CapsLock all map to Named(<variant>), not
  Unidentified.
- `keyboard_function_keys_mapped` — F1..F12 round-trip via a
  for-loop.
- `keyboard_modifier_keys_mapped_to_named_values` — Shift /
  Control / Alt / Meta with both Left and Right `code` variants
  assert both Named(<modifier>) on `key` and the matching
  KeyCode::{Mod}{Left,Right} on `code`.

CapsLock specifically was the Round 3 NO-GO discovery — the
only NamedKey variant the Round 1 fix missed; Round 4 added it
+ the round-trip tests above.

Verification:
- `cargo test -p openpencil-shell-web --test dom_event_mapping` —
  25/25 passing
- `cargo build -p openpencil-shell-web --target
  wasm32-unknown-unknown --features skia --release` — green
- `bash tools/check-wasm-bundle.sh` — PASS:
  - 0 env.* imports
  - 621 151 bytes gzip = 59% of 1 MiB ceiling (+0.6 KiB vs
    pre-fix; new key table entries are tiny)
- `bash tools/check-widget-boundary.sh` — PASS

Codex Phase C gate review: 5 rounds (gate-level, not commit-level).
Round 1 BLOCK + CONCERN, Round 2 sandbox-only BLOCK, Round 3
NO-GO (CapsLock + missing test coverage), Round 4 sandbox-only
BLOCK (source verdict clean), Round 5 GO with Phase B5 R2
precedent applied for sandbox-only build evidence.
2026-05-09 21:52:00 +08:00
Kayshen-X a84d1f30e3 feat(shell-web): Phase C2.2 — browser closures + Drop cleanup
Lands the DOM-side glue that drives Phase C2.1's apply_ime /
apply_key paths from real browser events. Inspector text input now
accepts CJK IME composition (compositionstart/update/end) and the
dropdown responds to keyboard navigation (Arrow/Enter/Escape).

Architecture:
- WebShell restructured: was `{ backend, host }`, now
  `{ inner: Rc<RefCell<Inner>>, listeners: Vec<Listener> }` where
  `Inner { backend, host }`. Each browser closure needs `'static`
  ownership (wasm-bindgen Closure::new requirement); cloning the
  Rc per closure and borrow_mut'ing on dispatch is the canonical
  pattern. The `inner` field on WebShell anchors the original
  ownership so the Rc isn't dropped before Drop removes listeners
  (`#[allow(dead_code)]` on the field — all reads go through the
  closure-captured clones).
- `Listener` struct stores
  `Closure<dyn FnMut(JsValue)>` uniformly across event types; each
  handler body uses runtime-checked `dyn_into::<SpecificEvent>()`
  (not `unchecked_into`) so a mismatched synthetic event from
  same-page JS silently skips the handler instead of producing a
  wrong-type reference (codex C2.2 R1 CONCERN-3 fix).
- `add_listener<E, F>` helper registers a listener via
  `EventTarget::add_event_listener_with_callback` and pushes the
  Closure into the listener vec for lifetime anchoring.
- `Inner::repaint()` extracted from the old
  `WebShell::paint_inspector` — every closure body calls
  `inner.borrow_mut(); inner.host.apply_*(...); inner.repaint()`.
  Returns Result for present errors but closure bodies use
  `let _ = inner.repaint()` since closure must be infallible;
  errors still surface through console_error_panic_hook on panic
  paths.
- `modifiers_from_keyboard` builds Jian Modifiers from W3C
  KeyboardEvent.{shiftKey, ctrlKey, altKey, metaKey}; metaKey →
  CMD per spec §2.4 ("Cmd on macOS / Win key on Windows / Super
  on Linux").
- Drop impl: drains listener vec, calls
  `remove_event_listener_with_callback` BEFORE Closure drops so
  wasm-bindgen sees a valid registration to unregister. Best-
  effort — if target detached from DOM the call is a no-op.

5 listeners registered on `window` (rationale comment at the
decision point: keyboard/composition events on canvas only fire
with focus + tabindex; smoke HTML doesn't set tabindex; window-
level always fires for the static inspector demo. Phase D+ widget
chrome may parameterize the target — codex C2.2 R1 NIT-8 fix):
- `keydown` → `KeyEvent { state: Pressed }` →
  `host.apply_key(...)`
- `keyup` → `KeyEvent { state: Released }` (no-op in apply_key
  per C2.1)
- `compositionstart` → `host.apply_ime(&composition_start())`
- `compositionupdate` → `host.apply_ime(&composition_update(
  evt.data().unwrap_or_default(), None))`. Selection currently
  passes `None`; W3C `getTargetRanges()` requires `Reflect::get`
  + a manual Function call (web-sys 0.3.94 doesn't expose the
  getter). Phase D's DOM mirror already needs Reflect for
  accesskit::TextSelection mapping; folding the IME selection
  path into that work is cleaner than adding Reflect just here.
- `compositionend` → `host.apply_ime(&composition_end(...))`

Plan-vs-implementation deviations (deliberate, all kept narrow):
- Listeners on `window` instead of canvas (focus+tabindex
  avoidance — see decision-point comment).
- `compositionupdate` selection skipped pending Phase D
  Reflect adapter.
- Pointer / wheel / focus listeners NOT registered yet — those
  mappers exist in event/*.rs but no widget consumes them today
  (Tree click→selected wiring is Phase D). Listener helper +
  Drop pattern apply unchanged when they land.
- `dyn_into` (runtime-checked) instead of plan-body's
  `unchecked_into` for type safety.

Verification:
- `cargo build -p openpencil-shell-web --target
  wasm32-unknown-unknown --features skia --release` — green
- `cargo check -p openpencil-shell-web --target
  wasm32-unknown-unknown --no-default-features --features web` —
  green (compile guard)
- `bash tools/check-wasm-bundle.sh` — PASS:
  - 0 env.* imports
  - 620 548 bytes gzip = 59% of 1 MiB ceiling (+5 KiB vs C2.1 —
    wasm-bindgen Closure infrastructure for 5 listeners; ~1 KiB
    each gzipped)
- `bash tools/check-widget-boundary.sh` — PASS

Codex iterate review: 2 rounds → GO. Round 1 1 CONCERN
(unchecked_into trust) + 2 NITs (redundant guard, missing
rationale comment); Round 2 GO with 1 doc-comment NIT (stale
unchecked_into prose); both fixed in this commit.
2026-05-09 21:51: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 2bb49d96bd feat(shell-web): Phase C1 — pure DOM event mapping modules
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.
2026-05-09 21:49:00 +08:00
Kayshen-X 8ad5fa81b0 build(step-1b): tools/check-widget-boundary.sh — Phase B4 spec §1.4 guard
Enforces the Step 1b §1.4 widget boundary invariant: widget logic
(Widget impls + layout/paint/access_node methods) lives in
crates/openpencil-shell-core/src/widgets/; shell-web's only
widget-touching file is `widget_host.rs` and even there the only
widget-method signature allowed is the `// glue:` marked paint
dispatcher.

Forward checks (no widget logic in shell-web/src/):
- F1: `impl(<...>)?[[:space:]]+(ns::)*Widget[[:space:]]+for[[:space:]]`
  anywhere under shell-web/src/. Allows generic params + arbitrary
  namespace depth so `impl<T> shell_core::widgets::Widget for X` is
  caught. No `// glue:` exemption — Widget impls have no place in
  shell-web period.
- F2: `fn[[:space:]]+(layout|access_node)\(` anywhere under
  shell-web/src/. No exemption.
- F3: `fn[[:space:]]+paint\(` under shell-web/src/, EXCEPT lines in
  widget_host.rs that ALSO carry `// glue:`. Tight exemption — the
  marker only blesses one specific signature, not arbitrary tagged
  lines.
- F4: any line under shell-web/src/ mentioning both
  `openpencil_shell_core` AND `widgets`, except widget_host.rs.
  Catches direct + grouped `use` forms (e.g. `use
  openpencil_shell_core::{widgets::TreeWidget};`) plus path
  expressions. Multi-line braced `use` is out of scope (single-line
  policy in this crate).

Reverse check (shell-core/src/widgets/ has all four impls):
- R1: For each of {tree, prop_row, dropdown, text_input}, the file
  must exist AND, after stripping `//` line comments, must contain
  a live `impl Widget for X`. Block comments out of scope (line
  comments only in this directory).

CI integration:
- New "Verify Step 1b widget boundary (spec §1.4)" step in
  .github/workflows/rust-check.yml right after the existing
  "Verify Jian boundary invariants" step, gated to Linux runner
  (matches the jian-boundaries pattern).
- Added `tools/check-jian-boundaries.sh` and
  `tools/check-widget-boundary.sh` to the rust-check.yml push +
  pull_request path filters so PRs editing only the checker still
  trigger CI.

7-test regression matrix (positive + 6 negative cases):
- positive (real codebase) → PASS
- generic `impl<T> Widget for X` injected → FAIL F1
- direct `use openpencil_shell_core::widgets` outside host → FAIL F4
- `// glue:` tag on `impl Widget for X` line → FAIL F1 (exemption
  doesn't save it; only `fn paint` lines are exempted)
- shell-core file replaced with `// stub` → FAIL R1
- grouped `use openpencil_shell_core::{widgets::TreeWidget};` → FAIL F4
- grouped `use openpencil_shell_core::{widgets};` → FAIL F4
- shell-core file body replaced with `// impl Widget for X { ... }` → FAIL R1

Codex iterate review: 5 rounds → GO. Round 1 BLOCK (greedy
WidgetHost match), R2 BLOCK + 3 CONCERN (calls/imports unchecked,
generic impls, broad exemption, filename-only count), R3 BLOCK +
CONCERN (grouped imports, commented-out impls), R4 2 NITs
(documentation parity), R5 GO clean.
2026-05-09 21:48:00 +08:00
Kayshen-X d8d6e127cd feat(shell-web): Phase B3 — wire WidgetHost into mount path
Replaces the Phase A red-rect demo with the Step 1b inspector
composition: WebShell now owns a `WidgetHost` and `mount()` paints
the four shell-core widgets (Tree / PropertyRow / Dropdown /
TextInput) into a 280-px column on a white-cleared canvas.

What's added:
- `crates/openpencil-shell-web/src/widget_host.rs` — the only file
  in shell-web that calls into `openpencil_shell_core::widgets::*`,
  per spec §1.4 boundary. Module doc anchors the invariant; the
  paint signature carries the `// glue:` marker that the Phase B4
  boundary check script (`tools/check-widget-boundary.sh`) will
  grep for.
- `WidgetHost { tree, width, dropdown, text_input }` owns one of
  each kind; `WidgetHost::new()` populates them from the B2 sample
  / new constructors. `Default` forwards to `new()`.
- `WidgetHost::paint(&self, backend, available_width)` builds a
  `LayoutCx`, iterates a `[&dyn Widget; 4]` array, places each at
  x=16 with 12-px vertical gaps. Reborrows backend each iteration
  (`&mut *backend`) so subsequent iterations don't fail
  borrow-check on the moved `&mut WebBackend`.

shell-web/src/lib.rs:
- Adds `mod widget_host;` cfg-gated to the `skia` feature.
- WebShell gains `host: WidgetHost`.
- Renames `paint_phase_a` → `paint_inspector`. Body clears the
  canvas to white, dispatches via `self.host.paint(...)`, then
  surfaces `take_present_error()` as JsValue exception. Same
  panic-safe + canvas-type-check + present-error-propagation
  pattern as Phase A C-hard.2 (codex Phase A gate review approved).
- The stub mount entry (no skia feature) is unchanged — the
  kickoff §1.2 wasm32-clean compile guard CI still uses it.

Plan-vs-implementation deviations (deliberate):
- Plan B3 step 2 simplifies `mount()` in a way that drops the
  panic hook + canvas-type-check + present-error propagation.
  Preserved all three because the codex Phase A gate review
  explicitly approved them as "panic-safe mount". Plan body's
  `mount` block is treated as historical sketch.
- Plan body's `let mut cx = PaintCx { backend };` would move the
  reference and fail borrow-check on the second iteration.
  Changed to explicit reborrow `&mut *backend`. This is the
  plan's intent, just with the borrow-checker subtlety made
  explicit.

Verification:
- `cargo build -p openpencil-shell-web --target
  wasm32-unknown-unknown --features skia --release` — green
- `cargo check -p openpencil-shell-web --target
  wasm32-unknown-unknown --no-default-features --features web` —
  green (compile guard)
- `bash tools/check-wasm-bundle.sh` — PASS
  - 0 env.* imports (bundle still LinkError-free)
  - 615 764 bytes gzip = 58% of 1 MiB ceiling
  - +2 KiB vs Phase A C-hard.2 (~613 KiB) — widget code is small
- `cargo check -p openpencil-shell-native` — green (no regression)

Codex iterate review: 1 round → GO with 2 NITs (script name
singular vs plural, stale "Phase A red-rect" phrase) — both
fixed in this commit.
2026-05-09 21:47: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
Kayshen-X d20e1acf0f build(step-1b): tools/check-wasm-bundle.sh — local A3 bundle gate
Implements the Phase A A4 NIT-1 fix from codex Phase A gate review
(verdict GO, 2 NITs flagged): the local equivalent of the Step 1b §6
+ §7.1 CI gate, runnable by developers before submitting Phase A-E
PRs. The full CI workflow remains DEFERRED in
`.github/workflows/rust-release.yml` until brew emscripten install +
EMSDK + .wasm.a → .a symlink + wasm-bindgen + wasm-opt are
automated; until then this script is the authoritative gate.

What it enforces (matches spec §6 + §7.1):
  - EMSDK env var present (build-time-only emsdk libcxx headers +
    wasm-aware clang)
  - cargo build → wasm-bindgen → wasm-opt -Oz pipeline succeeds
  - 0 env.* imports in the post-bindgen bundle (any leak = LinkError
    at load time = regression)
  - gzip size ≤ STEP1B_SHELL_WASM_GZIP_LIMIT_BYTES (default 1 MiB)

Verified on the C-hard.2 bundle: 613 006 bytes gzip (58% of 1 MiB
ceiling), 0 env.* imports, exit 0.

Step 1b Phase A — completes A3 (local) + A4 (codex verdict GO).
2026-05-09 21:44:00 +08:00
Fini d1734f5480 fix(ai): run clipCardImageCorners AFTER role-resolver fills in card defaults
Why: Codex stop-time review 2026-05-10 — clipCardImageCorners was
slotted between unwrapFakePhoneMockups and resolveTreeRoles in the
post-streaming pipeline. Cards that don't carry an explicit
cornerRadius from the sub-agent get one filled in by role-resolver
(e.g. role='card' → default cornerRadius=12). Running clip before role
defaults silently skipped every default-radius card — match policy
required scalar cornerRadius > 0 and saw cornerRadius=undefined at
that point. Net effect: the bug stayed for the most common case where
the model wrote role='card' without a numeric radius.

What: move the clipCardImageCorners call to the freshRoot block right
after resolveTreeRoles + resolveTreePostPass. By that point the
role-resolver has populated defaults, so a card without an explicit
radius now correctly hits the predicate. Reuse the existing
\`freshRoot\` reference (re-fetched after updateNode mutations earlier
in the pipeline) so we don't double-fetch from the store.

No new tests — the existing 9 unit tests already pin the predicate;
the bug was placement-only. 1448 / 1448 pen-core tests, 1110 / 1110
AI service tests still pass.
2026-05-09 21:43:00 +08:00
Fini 5112bea716 fix(ai): clean card-image corners + strip wrapper stroke/cornerRadius with fill
Why: 2026-05-10 user report — "你看不到任何真正的生产级设计工具的希望"
called out three persistent visible issues with the food-app design:

  1. Search bar shows a "weird inner rounded border" (image 7) — the
     wrapper around address+search section sets fill + stroke +
     cornerRadius together. strip-redundant-section-fills only cleared
     the fill; the leftover stroke + cornerRadius kept drawing the
     visible inner pill, which has been there for a long time.

  2. Card image has all 4 corners rounded (image 8) — Taco Fiesta /
     Bella Italia cards show the food image with bottom corners
     rounded too, leaving them visually "ragged" against the title
     text below that's flush with the card surface.

(The third — Categories padding — is a layout/intent question; left
for later since it can't be auto-fixed safely without knowing whether
the design wants edge-to-edge content.)

What — two coordinated fixes:

  Fix #1: strip-redundant-section-fills now removes stroke and
  cornerRadius alongside the fill. A misroll wrapper sets all three
  together to "look like a card"; the fill gets stripped on detection
  but the leftover chrome kept drawing a phantom card outline. The
  three travel together so they should be cleared together. New test
  pins the search-bar wrapper case.

  Fix #2: new clipCardImageCorners pass in pen-core. When a card-shape
  parent (scalar cornerRadius > 0, 2+ children, first child is an
  image or canonical image-placeholder, image has its own scalar
  cornerRadius) is detected, set parent.clipContent = true and remove
  the image's scalar cornerRadius. The card's own corner clip then
  cleanly handles the image — top corners round with the card, bottom
  corners flush against the title below. 9 unit tests pin the
  conservative match policy (silent on standalone image, title-first
  card, array-form cornerRadius, cornerRadius:0, no image
  cornerRadius, nested cards, existing clipContent).

Wired into applyPostStreamingTreeHeuristics right after
unwrapFakePhoneMockups. 1448 / 1448 pen-core tests pass (was 1438; +10);
1110 / 1110 AI service tests still pass.
2026-05-09 21:42:00 +08:00
Fini a7c05fb054 feat(ai): aesthetic detector — excessive-frame-effects (spread > 0 / blur > 40 / 3+ stacked)
Why: 2026-05-10 user report — "Mexican" badge in the food-app screenshot
landed with a "带尖的背景阴影" (pointy / spiked background shadow). The
underlying cause is the model emitting effects with positive spread,
which "bleeds" the shadow color outward and creates a visible bloom /
halo around the badge that doesn't match real product UI shadows.
Real UI shadows are tight: blur 4-16, spread 0, near-black low-alpha.
Existing detectors only handle text effects (text-effect from 7aef1b14);
frame-level effects had no aesthetic gate.

What: detectExcessiveFrameEffects flags a frame node iff ANY of:
  - blur > 40 (glow / halo signature; modal-shell scrim uses exactly
    40 so the threshold is strict-greater to keep that legitimate use
    untouched — verified by detectors-builder-clean.test.ts)
  - any effect carries spread > 0 (bleeding outward = the "spiked
    shadow" the user called out)
  - 3+ stacked effects on one frame (typical UI uses 0-2)

Suggested fix is to remove the effects array; the user / agent can
re-add a proper subtle shadow afterwards if intentional.

Wired through detectAllIssues + index.ts public exports + the
debug_validation_report MCP categories enum. Skips text nodes (those
go through detectTextEffect with a stricter zero-tolerance rule).

7 new tests cover: positive on spread > 0 / blur > 40 / 3+ stacked,
negative on typical subtle shadow / no effects / blur exactly 40
(modal-shell legit) / text node (different detector). 241 / 241
pen-ai-skills tests pass (was 234; +7). 1110 / 1110 AI service tests
pass (unchanged — production builders pre-clean).
2026-05-09 21:41:00 +08:00