Default Document has no connected agent, so for_document now sets
agent_count = 0 and the chip switches to the empty-state look:
LayoutGrid icon + 'Agents 与 MCP' label (TS topbar.agentsAndMcp /
en topbar.agentsAndMcp). Active state (agent_count >= 1) keeps the
Sparkles + green dot + 'N agent' look.
Chip width is now driven by RenderBackend::measure_text so the
border ring tracks the actual rendered string instead of a
per-char estimate.
crates/CLAUDE.md gains an 'i18n' row + 'Theme + i18n' section that
covers Document::theme()/t() + the 15 locale tables generated
from TS. Includes the convert-locales.py re-run command for
contributors who change TS strings.
Earlier convert-locales.py was line-based + single-quote-only, missing
~16 keys per locale where:
- the value spans onto the next line ('long.key.name':\n 'value')
- the value uses double quotes for English contractions ('topbar.dontSave': "Don't Save")
Switch to a regex.finditer over the whole file with multi-line +
double-quote alternation. All 15 locales now report 706 keys each,
matching the TS source (apps/web/src/i18n/locales/*.ts).
Stop-hook: 'locale import is incomplete'.
Replaces the hand-rolled 25-key i18n.rs with 15 generated locale
modules (~700 keys each) mirrored from
apps/web/src/i18n/locales/*.ts via tools/convert-locales.py.
Locale enum expanded to match the TS dropdown:
EnUs / ZhCn / ZhTw / Ja / Ko / Fr / Es / De / Pt / Ru / Hi / Tr /
Th / Vi / Id (15 total). Each carries its native-script
display_name() (English / 简体中文 / 繁體中文 / 日本語 / 한국어 /
Français / Español / Deutsch / Português / Русский / हिन्दी /
Türkçe / ไทย / Tiếng Việt / Bahasa Indonesia).
Globe icon click cycles all 15 via Locale::next() (round-robin
through Locale::ALL).
Chrome key references updated to TS dot.case naming so the same
key resolves on both sides:
- topbar.untitled → common.untitled
- layer_panel.pages → pages.title
- layer_panel.layers → layers.title
- chat.new_chat → ai.newChat
- chat.start_with_ai → ai.tryExample
- chat.input_placeholder → ai.designWithAgent
Generator script lives at tools/convert-locales.py (re-run when
TS strings update). Each locale .rs file is ≤ 710 lines (under
the 800-line ceiling). Cross-locale fallback: missing keys try
EN before falling through to the key itself.
68 lib tests pass (+1 i18n fallback test).
Stop-hook fix: codex flagged Rust files as not rustfmt-clean.
Run cargo fmt --all across openpencil-shell-{core,native,web}
+ wasm-libc-shim. 67 lib tests still pass, native + web cargo
check clean.
Theme + locale toggle infrastructure landed in ed36df56, but the
visible chrome strings were still hardcoded so flipping the Globe
icon didn't actually change anything. Now:
- LayerPanel resolves '页面' / '图层' from doc.t() at construction
and stores as String fields; paint reads those instead of
hardcoded literals.
- AIChatPlaceholder resolves 'New Chat' / '用 AI 开始设计' /
'用 Agent 设计…' the same way; paint_examples takes the hint
label as a parameter.
TopBar 'untitled' label was already wired (for_document uses
doc.t). 67 lib tests still pass.
Stop-hook fix: 'caret reset can use a stale clock'. set_now_ms was
only called inside RedrawRequested, so apply_text / apply_backspace /
apply_press routed mid-frame stamped caret_anchor_ms with the
previous frame's now_ms. The result: caret reset visually appeared
delayed by up to one redraw interval (rare but inconsistent).
Refresh self.clock_start.elapsed() at the top of every WindowEvent
so any apply_* called inside the match arm sees the current
timestamp. Drop the redundant inside-RedrawRequested refresh.
Sinks the blink phase logic into vendor/jian (jian-core::anim) so any
host can wire the same square-wave timing instead of reimplementing
per-product. Both OpenPencil chrome and Zode TUI consume the same
helpers.
- vendor/jian bumped to head with new `jian_core::anim` module
(blink_visible / next_blink_flip_ms, 9 unit tests)
- ChatState: `caret_anchor_ms` resets on focus / keystroke /
example fill so the caret reappears solid right after the user
acts, not mid-fade
- AIChatPlaceholder.now_ms threaded from host; paint computes
caret visibility = focused && jian_core::anim::blink_visible
- AIChatPlaceholder caret X uses RenderBackend::measure_text for
pixel-accurate trailing edge (replaces the 7px / 13px guess
per char that drifted on Roboto + Noto-CJK)
- WidgetHostNative.set_now_ms / chat_focused / next_animation_
deadline_ms surface; runner refreshes from a single Instant
anchor + sets ControlFlow::WaitUntil at the next blink flip
- inspector_window: new_events handles ResumeTimeReached → request
redraw so winit actually wakes for the next frame
TS LayerPanel renders the selected row with bg-blue-500/15 + primary
text color + primary icon color (apps/web/src/components/panels/
layer-item.tsx). My panel was using theme.row_selected (gray #262626)
+ foreground text, which read as 'darker gray on dark gray' — not
the clear 'this is selected' affordance the TS app gives.
- Add Theme.row_selected_primary (rgba(0x3B82F6, 0.18) — blue 15%)
- LayerPanel: selected layer row uses row_selected_primary bg,
primary text + primary icon
- Page rows still use the neutral row_selected (matches TS where
the active page tab is also subdued gray)
TS imports both Diamond (instance indicator) and Component (cluster
of 4 small diamonds, used for the 创建组件 button). I picked the
single-diamond Diamond by mistake; the button uses Component.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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".
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
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
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).
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)
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.
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
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.
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)
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.
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).
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.
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
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.
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.