Codex stop-hook on the prior strip-nested-card-decoration commit
caught a regression: cornerRadius on a media-clipping frame
(`clipContent: true` wrapping an image / video, or roles like
`image-placeholder` / `thumbnail` / `cover-image`) is doing the
rounding work for the photo, not stacking card decoration. Blanket
stripping un-rounded the media against the user's clear intent —
typical pattern is
card { cornerRadius: 16, clipContent: true }
└─ image-placeholder { cornerRadius: 12, clipContent: true }
└─ image
where the inner cornerRadius rounds the photo and the outer rounds
the card frame around it. After the prior pass the inner radius got
stripped (ancestor had cornerRadius too) → square corners on the
photo.
New `MEDIA_CLIP_ROLES` set + `isMediaClipper(node)` helper:
- role match: image, image-card, image-placeholder, video,
video-placeholder, media, media-thumbnail, thumbnail, cover,
cover-image, gallery-item
- shape match: clipContent: true AND has a direct image / video /
media-roled child
Either signal preserves cornerRadius. Other decorations (stroke,
shadow) still get stripped — those ARE redundant card decoration
even on a media wrapper, since the photo's own outline + the
ancestor card already provide the visual frame.
Tests: 2 new cases — clipContent + image, and the role-only path
covering image-placeholder / thumbnail / cover-image / gallery-item.
User-reported 2026-05-11 "Popular Restaurants" — inspecting the live
canvas via batch_get showed the LLM built each row as
`role:card` (outer) carrying stroke + cornerRadius:16 + 2-shadow
elevation, then nested an inner `Card Info` frame ALSO with
`role:card`, cornerRadius:12, and the SAME 2-shadow stack for the
right-hand text column. The doubled decoration rendered as a
visible "border" / box-in-box that the user called out as the
N-tools being "死板" — element-builders deterministically emit
their own card decoration without knowing they're being nested.
New post-pass `stripNestedCardDecoration` walks the page tree and,
for each non-protected frame:
- if the frame has stroke AND any frame ancestor has stroke → strip stroke
- if the frame has cornerRadius > 0 AND any frame ancestor
has cornerRadius > 0 → strip cornerRadius
- if the frame has shadow AND any frame ancestor has shadow → strip effects
Each decoration type is checked independently so e.g. a card inside
a shadow-only ancestor still keeps its cornerRadius. Fills are NOT
touched — stripRedundantSectionFills already handles fill heuristics
and a child fill may be an intentional surface change (dark accent
strip inside a white card).
KEEP_DECORATION_ROLES exempts elements that legitimately carry their
own affordance even when nested in a card: button, chip, search-bar,
input, badge, avatar, switch, etc. Those keep their click-target
visual whether or not the parent is decorated.
Wired in apps/web design-canvas-ops.ts at both finalize sites,
running AFTER stripRedundantSectionFills so the fill pass gets first
crack and this pass cleans up the leftover stroke/cornerRadius/
shadow stack.
Tests: 8 cases — basic strip, partial strip (only matched types),
top-level decoration preserved, protected-role exemption, fills
untouched, deep nesting, asymmetric cornerRadius arrays, no-op
return value.
User-reported 2026-05-11 mobile food design — the page had Header
(search bar + cart), Categories (icon row), and Bottom Nav each
carrying their own horizontal padding by design, but Hero section
left its frame edge-to-edge intentionally. Previous version saw
Hero's missing padding + ≥1 offending child and flagged → root got
+16px gutter on top of every per-section-padded sibling, producing
a visible double-inset / "边距过大" complaint.
Treat any non-fullbleed content child carrying its own h-padding as
a signal that the design has chosen the per-section gutter mode.
Once that signal is observed, skip the root-level recommendation
entirely so we don't double up. Hero / banner / image-bleed roles
remain filtered out of the signal pass via FULL_BLEED_ROLES so a
hero with no padding still doesn't activate the detector.
Test: covers the user's exact pattern (categories + content with
per-section padding + hero without) — previous expectation flipped
from "fire" to "do not fire".
The canvas was pan-only; nodes could only be selected from the
LayerPanel and never moved without editing X/Y in the property
panel. Now:
* Document::node_at_doc_point walks the active page top-most-first
and returns the topmost node whose aggregate bounds contain the
document-space point. Children are tested before parents so a
click on a button-rect inside a Frame selects the rect, not the
Frame.
* Document::translate_selected moves the selected node by (dx, dy)
document px. Leaf nodes update bounds.origin directly; container
nodes (Group / unbounded Frame) translate every descendant that
carries bounds, so dragging a Group moves the whole subtree.
* WidgetHostNative tracks a NodeDragState. Press over a node ⇒
select + start node-drag. Cursor-move converts the screen-space
delta to document space via the live zoom (no canvas_region
offset needed because deltas are translation-invariant) and
calls translate_selected. Release clears the drag.
* The Hand tool keeps its pure-pan behaviour. Empty-canvas press
with any other tool clears the selection + starts a pan-drag,
same as before.
- LocalePicker / ShapePicker no longer paint a soft black offset rect
underneath; popover background + border hairline are enough to
read as floating, and the shadow was bleeding into the canvas.
- ShapePicker anchors 8 px to the right of the toolbar PANEL edge
(not just the slot button), so the dropdown reads as a separate
surface instead of butting flush against the toolbar's right border.
Was overlapping the icon at the lower-right of the button. Moved to
the gutter directly below the button, horizontally centered, sized
10 px in muted-foreground. Toolbar now reserves a 10 px extra
bottom slot after ShapeSlot so the chevron has room without bumping
the next button. Hit area extended to include the chevron gutter
so clicking on the caret also opens the picker.
Lower-right corner of the shape button now carries a small
ChevronDown so the dropdown affordance is visible at rest, matching
the TS shape-tool-dropdown's caret. Color follows the active /
muted-foreground split the rest of the slot uses.
The vertical toolbar's shape button is now a compound slot driven by
`Document.ui.shape_tool` (defaults to Rect). Click it to open a
`ShapePicker` dropdown anchored immediately to the right of the
slot — seven rows mirror the TS app's shape-tool-dropdown:
· Rectangle (Square icon)
· Ellipse (Circle)
· Polygon (Triangle)
· Line (Minus)
· Icon (Sparkles, opens icon picker — host follow-up)
· Import Image or SVG… (ImagePlus, opens file dialog — host follow-up)
· Pen (PenTool)
Picking a shape updates ui.shape_tool (so the toolbar slot's icon
flips), sets doc.tool to that variant, and closes the panel. Click
anywhere else closes silently.
* New Tool variants: Ellipse / Polygon / Line / Pen. Tool::is_shape()
reports membership in the slot's group so the slot highlights when
any of them is active.
* New icons: Circle, Triangle, PenTool, ImagePlus (lucide d-strings).
* New widget shape_picker.rs (≤ 280 lines) with hit-test + Widget
impl + 3 unit tests; ShapeChoice variant for the host to dispatch
on (Tool / OpenIconPicker / ImportImageOrSvg).
* PropertyLabels-style locale lookup falls back to English literals
for the row labels (shapes.rectangle / ellipse / polygon / line /
icon / importImageSvg / pen) — already present in zh.ts.
* Native host wires the open/close/dispatch loop alongside the
existing locale picker; paint slot z-priority sits below the
locale picker so a stack of overlays still does the right thing.
The right-rail inspector picks up locale-aware labels and accepts
keyboard edits on the four most-used number inputs.
* New `PropertyLabels` struct in property_panel_sections; resolved
once per panel build via `Document::t`. All hardcoded chinese
section titles (位置/弹性布局/尺寸/图层/填充/描边/效果/导出),
the 设计/代码 tab strip, the 创建组件 button label, and the five
尺寸 checkboxes (填充宽/高 / 适应宽/高 / 裁剪内容) now flip with
the TopBar Globe locale picker. Falls back to English when the TS
locale tables don't carry a key.
* PropertyPanel now carries `focus / draft / caret_anchor_ms /
now_ms` so the focused input renders the live edit buffer with a
primary-color border + blinking caret. `for_selection_at(doc,
now_ms)` is the new entry point; `for_selection` keeps a
zero-clock variant for static contexts (tests, etc.).
* New `editable_input_rects` in sections — single source of truth
for the X / Y / W / H rect layout, shared by paint and
`PropertyPanel::hit_test`.
* WidgetHostNative wires the full edit cycle: clicking a row
focuses + seeds the draft from the snapshot, `apply_text`
filters digits/decimal/leading-minus into the draft, `apply_send`
parses + commits via `Document::commit_property_edit`, and
`apply_escape` discards. Click-outside-the-panel auto-commits.
`next_animation_deadline_ms` now wakes for property focus too so
the caret blinks at the same 500 ms cadence as the chat input.
* `PropertyFocus` already existed; `Document::commit_property_edit`
+ helper walk now mutate Node.bounds for the X/Y/W/H cases.
Rotation/opacity/hex inputs accept focus + clear cleanly but are
no-ops at the node level until the schema grows those fields.
Codex stop-hook on the prior per-node try/catch caught a leak: the
catch logged but didn't roll back canvas state. drawNode pushes
canvas.save() once per ancestor clipStack entry (node-renderer.ts:548)
plus more for rotation / flip (574, 583) and per-shape sub-paths
(701, 1094, 1102). If drawNode throws mid-loop, every save() between
its entry and the throw stays on the stack — the next node's draw
operates inside a leaked clip / leaked transform, and the canvas
either renders nothing or renders to the wrong region.
Snapshot canvas.getSaveCount() before each drawNode call; on catch,
canvas.restoreToCount(saveCount) pops everything back to the
baseline. Wrap the restoreToCount itself in a no-op catch since it
can throw if the snapshot count is somehow above the current depth
(shouldn't happen but guarded so the error reporter still runs).
Net effect: per-node failures are now genuinely isolated. The
canvas state at the start of each iteration is identical to where
the previous iteration left it; one bad node can't smear its leaked
state across the rest of the frame.
User reported "为什么画布是空的" — Bistro DeepSeek generation, layer
panel populated with the root frame but canvas fully blank mid-stream.
The MCP-side document showed children, the UI-side layer tree showed
the root, but no pixels rendered. Two structural issues converged:
1. ShadowEffect TS type marks offsetX / offsetY / blur / spread as
required, but LLM-emitted shadows routinely omit them
(`{type:'shadow', blur:3, color:'#0000001A'}` with no spread). The
prior (and the new shadow-cornerRadius) code multiplied the missing
field through cornerRadius / RRectXY math, producing NaN. CanvasKit's
RRectXY throws on NaN inside the WASM module, the throw escapes
drawNode (renderer.ts:326 had no try/catch), and the entire render
loop aborts past the bad node — so even unrelated siblings stop
drawing. User sees a fully empty canvas despite document state
being intact.
2. The drawNode loop had zero error isolation — a single malformed
node could blank the whole frame. Structural fragility independent
of the NaN bug; any future renderer regression would have the same
symptom.
Two fixes:
- applyShadowDirect coerces missing / non-finite shadow numeric
fields to 0 before any math (offsetX / offsetY / spread defaults
to 0; blur defaults to 0 and clamps non-negative). NaN can't reach
CanvasKit. The pre-existing drawRect path also benefits — the old
code happily fed NaN to drawRect via `x + shadow.offsetX -
shadow.spread`, just relied on Skia's tolerance for some NaN cases.
- renderer.ts wraps drawNode in per-node try/catch with a console.error
on failure. A bad node now logs and skips; siblings render normally.
Defense-in-depth so the next renderer regression doesn't blank the
canvas.
Codex stop-hook on the previous shadow commit caught: the in-function
clamp `Math.min(maxShadowRX, cornerRadiusX + spread)` looked correct
in isolation but diverged from the body's actually-rendered curve
when cornerRadius exceeded the body's half-extent.
Concrete: 60×60 frame with cornerRadius=100, spread=4.
- Body's drawRRect at L664 clamps to min(100, 30) = 30. Body curve = 30.
- Old shadow path: shadowRX = min(34, 100+4) = 34. Shadow curve = 34.
- Result: shadow corner sticks out past body corner by 4px on all sides
(visible on canvas — same "尖角" complaint, just at clamp boundary).
Architecture: push the body-clamp out of `applyShadowDirect` and into
the call site, so the function's contract is "input radii are already
the body's rendered radii — I just add spread + clamp to my own
half-extent". Shadow stays in lockstep with whatever the body
actually drew, by construction.
- Frame / rectangle / image: caller passes
Math.min(cornerRadius, Math.min(w/2, h/2)) for both rx and ry.
- Ellipse: caller passes (w/2, h/2) — matches drawOval outline.
- Path / line / polygon: caller passes 0/0 → plain drawRect.
Tests: pen-renderer 5 / 46 still passes (unchanged functional surface
area; the change is internal to the radii contract).
Codex review on the prior shadow-cornerRadius commit caught two
edges:
Q4 — shadow radius needs upper-clamp like the body's drawRRect.
node-renderer.ts:643 / :1108 already guard `Math.min(cr, maxR)` so
a too-large cornerRadius doesn't degenerate the rrect; the shadow
path was missing the same clamp. A 60×60 ellipse with
spread=4 + cr=34 would emit raw rx=38 which exceeds the
spread-expanded rect's half-extent and visibly distorts. Now clamps
to half-extent of the spread-adjusted rect on each axis.
Q5 — ellipse shadow rx/ry should be independent. The previous fix
mapped ellipse → cornerRadius = min(w,h)/2, which produces a stadium
(pill) shadow when w ≠ h. Splitting the param into independent rx /
ry lets the call site pass (w/2, h/2) for ellipse, matching the
body's drawOval outline for the asymmetric case while staying
identical for symmetric circles.
Frame / rectangle / image stay at rx === ry === cornerRadius. Path /
line / polygon stay at 0/0 → plain drawRect.
Tests: pen-renderer 5 / 46 still passes (no rendering-result coverage
to extend; the change is exercised at the next renderer reload).
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.
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.
* 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.
* 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.
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.
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.
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.