Commit graph

768 commits

Author SHA1 Message Date
Fini b49060d59f fix(renderer): drop-shadow follows cornerRadius / ellipse outline
User-reported 2026-05-10 "圆角元素的尖角阴影" — rounded cards / hero
images had visibly square-cornered drop shadows poking out from
under the rounded shape. Forensic root cause: applyShadowDirect at
node-renderer.ts:447 was always drawing the shadow as a plain
`canvas.drawRect(...)`, completely ignoring the node's cornerRadius.
A frame with cornerRadius=24 + a subtle drop shadow would render the
rounded body cleanly but stamp a sharp-cornered shadow rectangle
just behind it, with the rectangle corners visible past the rounded
outline.

Fix: pass the node's cornerRadius into applyShadowDirect; when > 0
use `drawRRect` with `RRectXY(rect, cornerRadius+spread, ...)` so the
shadow's rounding stays parallel to the node's rounding (the +spread
correction keeps the visible curve aligned when spread expands /
contracts the bounds).

Ellipse / circle nodes (avatars, status dots) get cornerRadius =
min(w,h)/2 from the call site so their shadows render as stadium /
circle. Asymmetric-aspect ellipses get a stadium approximation
rather than a true ellipse — accepted simplification, the common
case is symmetric (avatar / dot).

Path / line / polygon nodes have no cornerRadius and fall through
with cr=0 — rectangular shadow stays correct for them.

This is a renderer-layer fix that detector-only paths can't reach;
ships in the same session as the typography / spacing detectors so
the user sees end-to-end aesthetic improvement on the next rebuild.
2026-05-10 22:53:58 +08:00
Fini d435fc53a7 Merge branch 'v0.8.0' of github.com:ZSeven-W/openpencil into v0.8.0 2026-05-10 21:37:11 +08:00
Kayshen-X db69fc5fc7 refactor(shell): promote inspector_window to openpencil-desktop binary crate
The native runner outgrew the `examples/` slot — it owns DPI tracking,
caret-blink animation timer, panel-resize cursor, the full Cmd+wheel /
PinchGesture / Pixel/LineDelta dispatch table, etc. None of that is a
sample, so it's been promoted to a real crate.

* New crate `crates/openpencil-desktop/` with a single `[[bin]]`
  target. Depends on `openpencil-shell-native` (lib) + winit +
  skia-safe (gl), gated to macOS / Linux / Windows.
* `examples/inspector_window.rs` removed; equivalent code lives at
  `crates/openpencil-desktop/src/main.rs` with the structs renamed
  (DesktopApp / paint) and the doc-block rewritten as a runner spec.
* Run command: `cargo run -p openpencil-desktop --release`. Old
  command (`--example inspector_window`) is gone.
* Workspace glob `crates/*` already picks up the new crate, no
  Cargo.toml workspace edit needed.
* Docs: crates/CLAUDE.md updated with the new crate row and runner
  section retitled "Desktop binary". Top-bar layout test renamed +
  uses the TOP_BAR_HEIGHT constant so future height tweaks stop
  breaking it.
2026-05-10 19:50:10 +08:00
Kayshen-X 967201162a feat(shell): AA round-rects + Layer/Property dividers + resizable rails + smaller chrome
* Native fill_round_rect now sets anti_alias(true) — was the source of the
  stair-stepped tool-button corners. Mirrors the AA flag we already had on
  stroke_round_rect / stroke_line / stroke_svg_path.

* LayerPanel paints a right-edge hairline (so the rail reads as a distinct
  surface from the canvas) plus an inset hairline between the Pages and
  Layers sections (matches the TS LayerPanel border-t).

* Layer + Property panel widths are now first-class Document.ui state
  (`layer_panel_width` / `property_panel_width`, defaults 240/280).
  Native host detects ±4 px gutter clicks on the panel edges, drags the
  width inside [180, 480], and the inspector_window runner flips the
  cursor to EwResize while hovering or actively resizing.

* Web host expressions threaded onto the same UiState fields for parity;
  drag wiring on web is a follow-up.

* TopBar trimmed: 48 → 40 px height, 32 → 28 icon button, 18 → 16 icon —
  the chrome reads less heavy at default zoom.

* Drops the now-unused PropertyPanel `Copy` derive (UiState carries a
  String draft) and lowers the toolbar (44×32) and topbar (40 px) so the
  rails feel tighter overall.
2026-05-10 19:42:46 +08:00
Kayshen-X 333403a0c7 docs(shell): note empty-agent chip, locale picker, multi-script, chat parity
* crates/CLAUDE.md — register LocalePicker widget, canvas_surface token,
  ai.tipSelectElements key, multi-script per-codepoint font cache.
* TopBar Globe section now describes the 44 px globe-plus-chevron
  compound and the click-anywhere-to-close behaviour.
2026-05-10 19:34:28 +08:00
Kayshen-X bf8f156a7b feat(shell-core): canvas surface, slimmer toolbar, TS-style chat input
* Theme: new `canvas_surface` token (#181818 dark / #fafafa light) —
  CanvasViewport now paints the surface with this distinct shade so
  the canvas reads as its own surface rather than blending into the
  chrome background.

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

* Toolbar (vertical floating column): trimmed from 48×36 to 44×32 so
  the tool buttons feel less heavy at default zoom levels.
2026-05-10 19:33:06 +08:00
Kayshen-X 7b800f67bd feat(shell): chevron + close-on-globe + multi-script font fallback
TopBar Globe button is now a wider compound (44 px) carrying both
the globe glyph AND a small chevron-down — visually signals the
dropdown affordance the way the TS i18n switcher does.

Click-while-open behaviour fixed: any click outside the dropdown
(including a second click on the Globe itself) closes the picker
and swallows the press, instead of close→re-toggle-open which left
the picker stuck open.

Native font path now resolves a typeface PER CODEPOINT and renders
each contiguous-typeface segment with its own `Font`. Korean
한국어 / Devanagari हिन्दी / Thai ไทย / Vietnamese precomposed
`Tiếng Việt` now render against the right system font instead
of dropping through the Han-only fallback. Per-codepoint cache
keyed on `char as i32` keeps repeat lookups free.
2026-05-10 19:26:48 +08:00
Kayshen-X 4f95c0860b feat(shell-core): TopBar Globe → locale picker dropdown
Adds a LocalePicker widget that paints a vertical list of all 15
native-script locale names (English / 简体中文 / 繁體中文 / 日本語 /
한국어 / Français / Español / Deutsch / Português / Русский / हिन्दी
/ Türkçe / ไทย / Tiếng Việt / Bahasa Indonesia) with a Check icon
and primary tint on the active row.

Globe click toggles `Document.ui.locale_picker_open` instead of
silently cycling. Row click sets the locale + closes; clicking
outside the panel closes silently. Picker paints on top of every
other layer (chat / status / canvas) so it never gets covered.

Native + web hosts share the implementation via
shell-core::widgets::LocalePicker; `TopBar::globe_rect` exposes
the icon-button anchor so the panel stays glued under the icon
even after a viewport resize.
2026-05-10 19:21:36 +08:00
Kayshen-X 3b77cbb211 style(shell-core): drop border ring on Agents 与 MCP chip 2026-05-10 19:16:46 +08:00
Kayshen-X e40c3a6e1c feat(shell-core): TopBar empty-agent chip — 'Agents 与 MCP' affordance
Default Document has no connected agent, so for_document now sets
agent_count = 0 and the chip switches to the empty-state look:
LayoutGrid icon + 'Agents 与 MCP' label (TS topbar.agentsAndMcp /
en topbar.agentsAndMcp). Active state (agent_count >= 1) keeps the
Sparkles + green dot + 'N agent' look.

Chip width is now driven by RenderBackend::measure_text so the
border ring tracks the actual rendered string instead of a
per-char estimate.
2026-05-10 19:11:06 +08:00
Kayshen-X c6020e3aeb docs(shell): document the i18n + theme toggle infrastructure
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.
2026-05-10 19:08:17 +08:00
Kayshen-X ba6f28f673 chore: gitignore tools/__pycache__/ + remove the .pyc that snuck in 2026-05-10 19:04:55 +08:00
Kayshen-X 2a3a6e96d6 fix(shell-core): convert-locales handles multi-line + double-quoted values
Earlier convert-locales.py was line-based + single-quote-only, missing
~16 keys per locale where:
  - the value spans onto the next line ('long.key.name':\n    'value')
  - the value uses double quotes for English contractions ('topbar.dontSave': "Don't Save")

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

Stop-hook: 'locale import is incomplete'.
2026-05-10 19:04:38 +08:00
Kayshen-X 544a307fd9 feat(shell-core): import all 15 TS locale tables verbatim
Replaces the hand-rolled 25-key i18n.rs with 15 generated locale
modules (~700 keys each) mirrored from
apps/web/src/i18n/locales/*.ts via tools/convert-locales.py.

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

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

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

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

68 lib tests pass (+1 i18n fallback test).
2026-05-10 18:57:20 +08:00
Kayshen-X c5d408d6be style(shell): cargo fmt --all (rustfmt-clean)
Stop-hook fix: codex flagged Rust files as not rustfmt-clean.
Run cargo fmt --all across openpencil-shell-{core,native,web}
+ wasm-libc-shim. 67 lib tests still pass, native + web cargo
check clean.
2026-05-10 18:47:33 +08:00
Kayshen-X 9506341da0 feat(shell-core): thread localised strings through LayerPanel + AIChatPanel paint
Theme + locale toggle infrastructure landed in ed36df56, but the
visible chrome strings were still hardcoded so flipping the Globe
icon didn't actually change anything. Now:

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

TopBar 'untitled' label was already wired (for_document uses
doc.t). 67 lib tests still pass.
2026-05-10 18:40:46 +08:00
Kayshen-X 91d9e99a94 feat(shell): theme + locale toggle wired to TopBar Sun + Globe icons
Sun click flips dark↔light; Globe cycles ZhCn↔EnUs. Both pipe
through Document.ui (theme_mode + locale) so any widget builder
that reads doc.theme() / doc.t(key) reflows immediately.

- Document.ui.theme_mode: ThemeMode { Dark, Light } with
  ThemeMode::flipped()
- Document.ui.locale: Locale { ZhCn, EnUs } with Locale::next()
- Document::theme() returns dark/light from ui.theme_mode
- Document::t(key) calls i18n::translate with ui.locale
- New i18n module — flat per-locale match tables, ~25 keys for
  chrome strings (TopBar / LayerPanel / PropertyPanel / chat).
  Unknown keys fall through to the key itself for debug visibility.
- TopBar.hit_test resolves Sun → ToggleTheme + Globe → ToggleLocale
- WidgetHost (native + web) routes both new TopBarHit variants
- LayerPanel / PropertyPanel / CanvasViewport / Toolbar /
  AIChatPlaceholder constructors swapped Theme::dark() →
  doc.theme() so the chrome flips together
- TopBar / StatusBar gained for_document(doc) builders
- StatusBar.zoom_percent now reads from Document.viewport.zoom

67 lib tests pass (+3 i18n unit tests).
2026-05-10 18:37:41 +08:00
Kayshen-X 71a3b9010b fix(shell-native): refresh host clock at top of every WindowEvent
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.
2026-05-10 18:24:58 +08:00
Kayshen-X 0c84202798 feat(shell): caret blink driven by jian-core::anim primitives
Sinks the blink phase logic into vendor/jian (jian-core::anim) so any
host can wire the same square-wave timing instead of reimplementing
per-product. Both OpenPencil chrome and Zode TUI consume the same
helpers.

- vendor/jian bumped to head with new `jian_core::anim` module
  (blink_visible / next_blink_flip_ms, 9 unit tests)
- ChatState: `caret_anchor_ms` resets on focus / keystroke /
  example fill so the caret reappears solid right after the user
  acts, not mid-fade
- AIChatPlaceholder.now_ms threaded from host; paint computes
  caret visibility = focused && jian_core::anim::blink_visible
- AIChatPlaceholder caret X uses RenderBackend::measure_text for
  pixel-accurate trailing edge (replaces the 7px / 13px guess
  per char that drifted on Roboto + Noto-CJK)
- WidgetHostNative.set_now_ms / chat_focused / next_animation_
  deadline_ms surface; runner refreshes from a single Instant
  anchor + sets ControlFlow::WaitUntil at the next blink flip
- inspector_window: new_events handles ResumeTimeReached → request
  redraw so winit actually wakes for the next frame
2026-05-10 18:19:46 +08:00
Kayshen-X a7f9eb120f style(shell-core): selected layer row uses primary-tinted bg + primary text/icon
TS LayerPanel renders the selected row with bg-blue-500/15 + primary
text color + primary icon color (apps/web/src/components/panels/
layer-item.tsx). My panel was using theme.row_selected (gray #262626)
+ foreground text, which read as 'darker gray on dark gray' — not
the clear 'this is selected' affordance the TS app gives.

- Add Theme.row_selected_primary (rgba(0x3B82F6, 0.18) — blue 15%)
- LayerPanel: selected layer row uses row_selected_primary bg,
  primary text + primary icon
- Page rows still use the neutral row_selected (matches TS where
  the active page tab is also subdued gray)
2026-05-10 18:08:47 +08:00
Kayshen-X ed48e1f139 fix(shell-core): 创建组件 icon Diamond → Component to match TS
TS imports both Diamond (instance indicator) and Component (cluster
of 4 small diamonds, used for the 创建组件 button). I picked the
single-diamond Diamond by mistake; the button uses Component.
2026-05-10 18:05:05 +08:00
Kayshen-X 0c0131653e style(shell-core): refine property panel to match TS — section labels foreground, full-width dividers, taller inputs
Previous panel had muted section labels + inset dividers + 26px inputs;
TS reference (apps/web/src/components/panels/right-panel.tsx) uses
foreground-tinted headers, edge-to-edge dividers, and 30px inputs.

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

Visible polish difference vs the TS screenshot is now mostly the
input baseline / right-padding, which a future text-measure API
will fix once we have skia-side advances.
2026-05-10 18:04:09 +08:00
Kayshen-X dacd4e6994 fix(shell-core): align property panel icons with TS lucide imports
- 创建组件 button: Sparkles → Diamond (TS uses lucide Diamond)
- 弹性布局 buttons: Square/Frame/FolderOpen → LayoutGrid/Rows3/Columns3
  (TS layout-section.tsx imports Columns3/Rows3/LayoutGrid/Check)
- 位置 R input: Redo → RotateCw (TS uses RotateCw for rotation handle)
- Add 9 new lucide d-strings to icons.rs: LayoutGrid, Rows3, Columns3,
  RotateCw, Diamond, Component, Unlink, Check, ArrowUpRight
2026-05-10 18:02:08 +08:00
Kayshen-X 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