Commit graph

251 commits

Author SHA1 Message Date
Fini ec4fe44368 feat(pen-core): strip nested card-style decoration on inner frames
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.
2026-05-11 00:17:02 +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 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 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 d1734f5480 fix(ai): run clipCardImageCorners AFTER role-resolver fills in card defaults
Why: Codex stop-time review 2026-05-10 — clipCardImageCorners was
slotted between unwrapFakePhoneMockups and resolveTreeRoles in the
post-streaming pipeline. Cards that don't carry an explicit
cornerRadius from the sub-agent get one filled in by role-resolver
(e.g. role='card' → default cornerRadius=12). Running clip before role
defaults silently skipped every default-radius card — match policy
required scalar cornerRadius > 0 and saw cornerRadius=undefined at
that point. Net effect: the bug stayed for the most common case where
the model wrote role='card' without a numeric radius.

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

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

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

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

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

What — two coordinated fixes:

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

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

Wired into applyPostStreamingTreeHeuristics right after
unwrapFakePhoneMockups. 1448 / 1448 pen-core tests pass (was 1438; +10);
1110 / 1110 AI service tests still pass.
2026-05-09 21:42:00 +08:00
Fini d4663434de fix(ai): heuristic image-area requires 0 children or icon_font child
Why: Codex stop-time review 2026-05-10 caught a content-erasure bug.
The b9b4126b heuristic accepted "0 or 1 children" with no type check,
so a hero frame named "Hero" with a single CTA button child matched.
Then the queue's update path (line 388-391 of processQueue) clears
\`children: []\` when the photo lands — destroying the legitimate CTA
button under it. Same risk for banners with sale text, covers with
nested frames, etc.

What: tighten the children clause in isImageAreaFrameByHeuristic. A
heuristic frame is now accepted only when:
  - children is undefined / not an array, OR
  - children.length === 0, OR
  - children.length === 1 AND children[0].type === 'icon_font'
    (the typical "broken image" placeholder hint).

Anything else — single non-icon child OR multiple children — means the
frame holds real content and the children:[] erase step would damage
the design. Such frames are now rejected by the heuristic and stay
unchanged on canvas.

3 new it() cases pin the rejection: hero+button, banner+text, and
cover+nested-frame all return false. Existing single-icon-child case
still passes. 38 / 38 tests in image-search-pipeline.test.ts (was 37;
+1; the rejection case adds 1 new it). 1110 / 1110 AI tests pass.
2026-05-09 21:39:00 +08:00
Fini 310f28816f fix(ai): queue still-needs-fill check accepts heuristic image-area frames
Why: Codex stop-time review #N (2026-05-10) caught the real bug — my
b9b4126b / 810c2f7a chain detected heuristic image-area frames at
collectImageSearchTargets and enqueued them at enqueueImageForSearch,
but the queue processor's still-needs-fill re-check (line 350-358 of
processQueue) called isUnfilledImagePlaceholderFrame which strictly
requires role='image-placeholder'. Heuristic frames have no role, so
the re-check returned false and the queue silently dropped them
before issuing the fetch — net effect was zero photos for the food-app
card scenario the heuristic was supposed to fix.

What: extract a new pure-function predicate
\`isFramePlaceholderStillUnfilled(node)\` that accepts a node iff it is
EITHER a canonical unfilled placeholder (role-based) OR a heuristic
match (name-based). Queue processor calls this single helper instead
of the strict canonical-only check. The helper is also exported and
test-covered separately so a future regression on this code path
fails loudly.

6 new it() cases pin: positive on canonical + heuristic, negative on
already-filled (canonical AND heuristic), null/undefined, and
unrelated frame names. 31 → 37 tests in image-search-pipeline.test.ts;
1109 / 1109 AI service tests pass overall (was 1103; +6).
2026-05-09 21:38:00 +08:00
Fini 7fedfff954 test(ai): exercise extractQueryForNode + findParentSemanticName
Why: 810c2f7a added the parent-walk query mining for heuristic image-
area frames but landed without unit coverage of the two new helpers.
Without it, a future edit to GENERIC_PLACEHOLDER_NAMES or the layout-
word filter could silently regress query quality (e.g. start emitting
"Wrapper" or "Section" as queries) and the food-app card photos would
go back to looking generic.

What: export the two helpers and add 5 it() cases covering:
- explicit imageSearchQuery wins over name
- generic literal "Image" + parent "Bella Italia" → returns "Bella Italia"
- skip layout words ("Card Wrapper") in the parent walk; accept the
  next semantic ancestor ("Margherita Pizza")
- 4-hop layout-only chain returns null (maxHops bound)
- fall back to non-generic-but-image-themed name when parent walk
  yields nothing ("My Custom Photo")

31 / 31 tests in image-search-pipeline.test.ts pass (was 26; +5).
1103 / 1103 AI service tests pass (was 1098; +5).
2026-05-09 21:37:00 +08:00
Fini f072db8559 fix(ai): query mining + enqueue path for heuristic image-area frames
Why: b9b4126b's collectImageSearchTargets returns heuristic-matched
frames (named "Image" / "Photo" / "Cover" without the canonical
role) but two downstream code paths quietly dropped them:

1. enqueueImageForSearch only accepted type==='image' or
   isUnfilledImagePlaceholderFrame, so the heuristic frames got past
   collect but never reached the queue.

2. extractQueryForNode would have returned the literal name "Image" or
   "Photo" — useless to the photo search API. The user's "Bella Italia"
   restaurant card never gets a relevant photo because the placeholder
   frame's name says nothing about the restaurant.

What:
- enqueueImageForSearch grows a third branch: isImageAreaFrameByHeuristic
  → kind: 'placeholder-frame'. Same kind so the rest of the pipeline
  treats it identically to a canonical placeholder.
- extractQueryForNode learns to skip "generic" placeholder names
  (Image / Photo / Cover / Hero / Thumbnail / Banner / Poster + a few
  variants) and walk up to the nearest semantic parent frame name
  ("Bella Italia" / "Margherita Pizza" / "Sushi House" — whatever the
  enclosing card was named). Bounded to 3 hops. Filters layout words
  (Card / Wrapper / Container / Section / Frame / Root / Page / Stack /
  Row / Column / Content) so we don't end up searching for "Card".
- A new helper findParentSemanticName builds a parent map from the
  live document on demand. Cheap for typical designs (< few hundred
  nodes); avoids threading parent through every collect / enqueue call
  site.

Net effect: a model-emitted plain "Image" frame inside a "Bella Italia"
card now searches for "Bella Italia" instead of literally "Image". The
existing isImageAreaFrameByHeuristic test coverage protects the entry
condition; 1098 / 1098 AI service tests still pass.
2026-05-09 21:36:00 +08:00
Fini 6dcb436ca6 fix(ai): heuristic image-area detection for non-canonical frame placeholders
Why: 2026-05-09 user report — the food-app card design landed with
empty colored rectangles where restaurant photos should be (Bella
Italia / Green Bowl / Margherita Pizza). Root cause: the model emitted
plain frames named "Image" / "Photo" / "Cover" with a solid fill as
the card-top image area, instead of using add_image_placeholder_v1
(which sets role: 'image-placeholder'). The auto-search pipeline
strict-checks role and missed all of them, so scanAndFillImages found
nothing to search and the cards stayed solid-colored.

What: new isImageAreaFrameByHeuristic(node) supplements the strict
role check. Conservative match — only fires when ALL of:
  - frame node WITHOUT role='image-placeholder' (strict path handles
    that)
  - name matches /\\b(image|photo|cover|hero|thumbnail|thumb|picture|
    banner|poster)\\b/i
  - width >= 80 AND height >= 60 (filters tiny color swatches)
  - exactly one solid (non-image) fill (gradient = decorative, image
    = already filled, both skip)
  - 0 or 1 children (an icon child is OK, multi-child = real layout)

collectImageSearchTargets walks both the strict and heuristic paths
and produces the same kind: 'placeholder-frame' target either way, so
the existing query / aspect / search code-path runs unchanged.

10 new tests cover positive matches (Image / Photo / Cover / Hero /
Thumbnail / Banner / Poster), negative for canonical placeholder
(double-counting prevented), unrelated names (Card / Wrapper),
already-filled image fills, gradients, content-rich frames, single-
icon-child acceptance, undersize frames, and non-numeric dimensions.
26 / 26 tests in image-search-pipeline.test.ts pass (was 16; +10).
1098 / 1098 AI service tests pass (unchanged).
2026-05-09 21:35:00 +08:00
Fini 207cc1cd52 test(ai): integration coverage for text-effect + mixed-sibling-padding
Why: 3abdfcf9 added integration tests for 4 of the 6 new aesthetic
detectors (rotation / text-cornerRadius / text-stroke / mixed-sibling-
cornerRadius). text-effect (7aef1b14) and mixed-sibling-padding
(ad025c95) landed without integration coverage of the
detect → applyFixes → store mutation chain.

What: 2 more it() cases mirroring the existing pattern:
- text-effect: text node with shadow effects → effects cleared
- mixed-sibling-padding: 3 cards with padding 16/16/20 → outlier
  rewritten to scalar 16 (collapsed from the 4-tuple modal because
  all sides are equal — locks in the scalar-collapse code path)

13 / 13 tests in design-pre-validation.test.ts pass (was 11; +2).
1088 / 1088 AI tests overall (was 1086; +2).
2026-05-09 21:32:00 +08:00
Fini 3bd52767f2 feat(ai): per-subtask icon_font reminder in sub-agent prompt
Why: even after the icon-catalog skill rewrite (19ca1c66) said "ALWAYS
USE icon_font, NEVER path NODES", end-to-end testing with MiniMax-M2.7
shows the model still emits ~half its icons as \`path\` nodes (Bell
Icon / Cart Icon etc.) because the per-subtask CRITICAL LAYOUT
CONSTRAINTS prompt — which the model treats as the prompt of record
— never restated the icon convention. The skill prompt is upstream
context the model can drift away from; the per-subtask block is the
last thing the model reads before generating, so it carries weight.

What: append a one-line ICONS rule directly to CRITICAL LAYOUT
CONSTRAINTS in orchestrator-sub-agent.ts. Restates the icon_font shape
inline (\`{"type":"icon_font","iconFontName":"<lucide-name>",…}\`) and
calls out the failure mode by name (resolver guess + placeholder
circle) so the model sees both the right pattern and the consequence
of the wrong one.

The path-with-iconic-name fallback path keeps working — this is purely
preventive guidance, the resolver tokenize fix (ef6f7ed3) still
catches the leftover cases. 1086 / 1086 AI tests still pass.
2026-05-09 21:31:00 +08:00
Fini 1ff724bebb test(ai): integration tests for aesthetic detector → applyFixes pipeline
Why: the 4 new aesthetic detectors (rotation / text-cornerRadius /
text-stroke / mixed-sibling-cornerRadius) have unit tests against the
pure detect functions in pen-ai-skills, but the chain through the live
Zustand store + runPreValidationFixes had no end-to-end coverage.
Without it, a future refactor that subtly breaks the apply step (e.g.
suggestedValue:undefined not clearing the prop) would slip through.

What: 4 new integration tests in design-pre-validation.test.ts using
the existing makeDoc / loadDocument fixtures. Each builds a doc with
exactly one known aesthetic issue, runs runPreValidationFixes, and
asserts the store mutation took effect:

- rotation:12 on a frame → reset to 0
- cornerRadius:8 on a text node → cleared (undefined)
- stroke on a text node → cleared (undefined)
- mixed cornerRadius across 3 sibling cards (8 / 8 / 12) → outlier
  rewritten to modal 8 (note: handled by the older
  sibling-inconsistency detector; mixed-sibling-corner-radius is
  the dedupe-loser here since sibling-inconsistency runs first
  with the same {nodeId, property} key — both produce the right fix)

11 / 11 tests in design-pre-validation.test.ts pass (was 7; +4).
1086 / 1086 AI tests overall (was 1082; +4).
2026-05-09 21:30:00 +08:00
Fini 58b7b68ede test(ai): regression-cover photo / camera icon names after image-noise removal
Why: C9 (b1ffa1c5) removed `image` from the resolver noise list to make
"Image Icon" resolve to lucide:image. The doc comment claimed "Image
Placeholder Path" still resolves via prefix fallback, but the math is
wrong — `image` is 5 chars, `imageplaceholder` is 16 chars, 5/16 = 31%
which is below the 50% FALLBACK_MIN_RATIO. So "Image Placeholder Path"
genuinely no longer hits the resolver fallback path. That's actually
correct (no icon marker word, resolver returns early), and the original
food-app circles came from the model emitting circle path-data
directly, not from the resolver fallback. But the photo / camera
aliases were also implicit dependencies that deserve explicit coverage
to lock in the behavior.

What: 2 new tests verify photo / camera resolve correctly through the
already-existing alias chain (`photo: _IMAGE` in BUILTIN_ICONS, lucide
camera native). 1082 / 1082 AI tests pass (was 1080; +2).
2026-05-09 21:29:00 +08:00
Fini 8475797c7f feat(ai): show per-category breakdown in pre-check status line
Why: when the pre-validation pass auto-fixes issues, the chat panel
just says "Pre-checks: fixed 5 issues" — generic and uninformative.
The user can't tell whether 5 invisible-container fixes happened
(structural, mostly safe), 5 unexpected-rotation fixes (aesthetic,
worth reviewing), or 5 mixed-sibling-padding fixes (consistency, worth
reviewing). With 10 detector categories now (4 original + 6 aesthetic
added in 53435bf7 / 7aef1b14 / cd1e4325 / ad025c95), the per-category
visibility starts to matter.

What:
- runPreValidationFixesDetailed() returns { total, byCategory } where
  byCategory is a per-category count of APPLIED fixes (excludes the
  info-severity skips and the protected-status-bar skip).
- runPreValidationFixes() kept as a thin wrapper returning .total so
  no caller needs to change.
- design-validation.ts now uses the detailed result and formats the
  breakdown as e.g. "fixed 5 (3 text-effect, 2 unexpected-rotation)"
  in the chat panel — sorted by count descending so the dominant
  category surfaces first. Both the no-vision-validation path and the
  size-gated skip path show the breakdown when it exists.

Falls back to the legacy "fixed N issues" format when byCategory is
empty (defensive — should never happen if total > 0). 1080 / 1080 AI
tests still pass — the new return shape is additive and the wrapper
preserves the integer contract.
2026-05-09 21:27:00 +08:00
Fini 364a722c77 test(ai): extract Type 0 unwrap predicate as pure helper + 10 tests
Why: dd8eb0eb's unwrap pass (Type 0 single-component section root
hoist) was integration-tested via the live Playwright run but had no
unit coverage. The integration test won't catch regressions when
someone tightens the heuristics — and the load-bearing "do nothing"
guards (multi-section, mobile screen, desktop, 0/N children, non-frame
child) are exactly where a careless edit would silently flatten a
multi-page design.

What: split the helper into two — a pure predicate
shouldUnwrapSingleComponentSectionRoot(plan, root) returning bool, and
the existing unwrapSingleComponentSectionRoot(rootNodes, plan) which
calls the predicate then mutates the store. Predicate is exported.

10 new tests cover:
- 3 positive: wrapper id ends -root / wrapper id ends -section /
  wrapper name copies parent name
- 7 negative load-bearing guards: multi-section plan, mobile screen
  (height >= 480), desktop (width > 480), root with 0 children, root
  with multi children, wrapper with no children, wrapper with
  unrelated id+name, wrapper is non-frame (text / icon)

1080 / 1080 AI tests pass (was 1070; +10).
2026-05-09 21:22:00 +08:00
Fini 7cb28b52b6 fix(ai): propagate validate skipped reason into chat status line
Why: every time the vision validation loop returned skipped:true the
chat panel logged the same hardcoded "(timeout or provider error)"
string regardless of the actual cause — provider mismatch, HTTP error,
upstream config issue. Now that the server (validate.ts) returns
explicit skip reasons (e.g. "Vision validation is not supported for
builtin providers"), the UI should surface them so the user can fix
the right thing instead of guessing it's a timeout.

What: ValidationResult gains an optional `skippedReason` field.
validateDesignScreenshot fills it from response.json's `error` (or
the HTTP status text on a non-OK response) and propagates it through
the loop. The chat-panel status line now reads
"[error] Analysis skipped (<reason>)" with the server-provided
message clipped to 120 chars; falls back to the legacy string when no
reason is present.

1070 / 1070 AI tests still pass; no test depended on the literal
"timeout or provider error" string.
2026-05-09 21:21:00 +08:00
Fini 84fc0ffddb fix(ai): explicit skipped reason for builtin providers in vision validate
Why: builtin providers (MiniMax / DeepSeek / Bailian / Ark) currently
fall through to the generic "Missing or unsupported provider" error in
/api/ai/validate. The post-generation loop catches that as a hard
provider error and logs "[error] Analysis skipped (timeout or provider
error)" — which reads like a config bug to the user even though the
real reason is "this provider's models are text-only, vision validation
isn't useful here even if we did proxy it".

What: branch on body.provider === 'builtin' before the generic error
and return { skipped: true, error: '<explanatory message>' }. The
client design-validation.ts already short-circuits on `data.skipped`
so the loop now logs the clearer message instead. No behavior change
for the four supported providers; no new wire fields.
2026-05-09 21:20:00 +08:00
Fini 69db6a5dfc fix(ai): skip search-bar role styling inside nav-tab parents
Why: end-to-end test of "Design a bottom nav with Home / Search /
Orders / Cart / Profile" surfaced a stray coloured pill highlight
wrapping the Search tab. Root cause: the model labels the cell
\`role: 'search-bar'\` (intending "this tab whose icon is search"),
and the role-resolver dutifully stamps the input-shaped 44px-tall,
22-corner, filled-surface look onto the nav cell. Inside a 56px tall
tab row that pill swallows the icon + label, looks broken on canvas,
and competes for click area with the nav-item active state.

What: search-bar role now early-outs with `{}` (no overrides) when
ctx.parentRole is one of `bottom-tab-bar` / `tab-bar` / `tab-row` —
mirroring the same check the `button` role already uses to skip its
text-button defaults inside tab containers. Nav-cell layout / fill
remains the responsibility of nav-item / nav-item-active.

1070 / 1070 AI tests still pass; the input-shape default still applies
in every other context (forms, headers, hero search, etc.).
2026-05-09 21:19:00 +08:00
Fini ac689182cc fix(ai): unwrap redundant section-root when Type 0 plan has 1 subtask
Why: for Type 0 component plans (Notification Card / Profile Card / …)
the orchestrator pre-inserts a page rootFrame named after the component,
then the sub-agent emits its own section-root frame as the only child.
Result is a visible "Notification Card → Notification Card" double wrap
in the layers panel and a wasted layout depth that does nothing visual.
The double wrap was confirmed in the 2026-05-09 end-to-end test of the
notification-card prompt: depth-0 = orchestrator rootFrame (role=card),
depth-1 = sub-agent wrapper (also role=card), actual children at depth-2.

What: new unwrapSingleComponentSectionRoot pass added as Phase 4c right
after the mobile-status-bar dedup (mutually exclusive: that runs only on
mobile, this runs only on component-shaped plans). Conservative match —
only fires when:
  - plan.subtasks.length === 1, AND
  - plan.rootFrame is narrow (≤480) and auto-height (<480 or 0), AND
  - the orchestrator rootFrame has exactly 1 frame child, AND
  - that child's id has the sub-agent section-root suffix
    (`-root` / `-section`) OR the child copied the parent's name.

When the conditions hold, hoist the wrapper's children up via
store.moveNode (preserving order) and remove the wrapper. Multi-section
pages, dashboards, and mobile screens are untouched — early-out on the
plan.subtasks.length / width / height checks.

1070 / 1070 AI tests still pass; unit-testing this against the live
Zustand store is awkward, the integration verification will land via
the next end-to-end notification-card run.
2026-05-09 21:18:00 +08:00
Fini a0f2ecb7aa fix(ai): drop image from icon resolver noise list — it's a real lucide key
Why: my prior C3 resolver fix added `image` to ICON_NOISE_WORDS so
"Image Placeholder Path" (a non-icon container name) wouldn't collapse
to a circle. That was overcorrecting — `image` is also the canonical
Lucide icon key for the picture/photo glyph, and the model frequently
emits "Image Icon" meaning exactly that. With image stripped, "Image
Icon" tokenised to [] and the resolver returned without writing the
matched lucide:image path.

What: remove `image` from ICON_NOISE_WORDS, with an inline note that
the multi-word "Image Placeholder Path" pattern still resolves through
the prefix fallback (`image` covers >= 50% of `imageplaceholder` so
findPrefixFallback picks it up). Add a regression test for "Image Icon"
→ /image/.

1070 / 1070 AI tests pass (was 1069; +1).
2026-05-09 21:17:00 +08:00
Fini fea13441f1 fix(ai): add close-button aliases (dismiss/cancel/remove/closebutton/expand/collapse → x / maximize-2 / minimize-2)
Why: end-to-end test of "design a notification card with dismiss x
button" surfaced that MiniMax-M2.7 emits a path node named "Dismiss
Icon". Tokenisation gives "dismiss" but Lucide doesn't have a `dismiss`
key — the resolver fell through prefix/substring fallbacks and wrote
the placeholder lucide:circle, leaving the card with a hollow ring
where the X should be.

What: 5 new aliases added in lock-step to icon-dictionary.ts (client
commonAliases) + icon.ts (server NAME_ALIASES per existing comment):
  - dismiss   → x       (close button intent)
  - closebutton → x     (compacted from "Close Button Icon")
  - cancel    → x       (cancel-action close icon)
  - remove    → x       (remove-action close icon)
  - expand    → maximize-2
  - collapse  → minimize-2

NOT aliased: `cross`. Lucide already ships a `cross` icon (the
Christian-cross shape) and overriding it would lose that geometry.
"Cross" disambiguation is left to the model — if it really means a
close button, telling it to write "Dismiss Icon" / "Close Icon" via
the icon-catalog skill is enough.

Tests: 3 new it.each cases (Dismiss / Cancel / Remove Icon → /x/).
1069 / 1069 AI tests pass (was 1066; +3).
2026-05-09 21:16:00 +08:00
Fini 9c4815123e fix(ai): route workspace/console fallback prompts to desktop-screen, not landing-page
Why: Codex stop-time review #6 — C6 added workspace / console / 工作台 /
工作区 to the component DISQUALIFIER, but the dashboard detector regex
still only matched dashboard|admin|管理|后台|控制台. So "design a
workspace with side panel" skipped component (correct) AND skipped
dashboard (regex miss) and fell through to landing-page (1200×0,
4-section), which is the wrong shape for a workspace UI — the user
wants a 3-section desktop-screen with header/main/actions.

What: dashboard detector regex extended in lockstep with the
disqualifier — dashboard|admin|workspace|console|管理|后台|控制台|
工作台|工作区. Comment makes the "keep in sync" invariant explicit.

Tests: 4 new positive cases (Latin workspace + console, zh-Hans 工作台
+ 工作区 with 卡片) assert the plan returns 1200×800 with the 3-
section ['Header','Main Content','Actions'] layout, not the 4-section
landing-page default.

1066 / 1066 AI tests pass (was 1062; +4).
2026-05-09 21:15:00 +08:00
Fini db5fc9a4b0 fix(ai): expand Type 0 disqualifier so dashboard/admin/mobile prompts skip component
Why: Codex stop-time review #5 — broadening the component trigger list
from 17 to 25 nouns introduced false positives:
  "admin dashboard with metric tiles" → matched `tile` → Type 0 (400×0)
when the user clearly wants a desktop dashboard. Same for "design an
admin panel" / "workspace with charts" / Chinese 后台管理 + 卡片.

What: COMPONENT_DISQUALIFIER_RE gains three new keyword buckets in
addition to the existing screen / page / home / onboarding / flow:
  - mobile-screen markers — mobile, phone, ios, android, 手机, 移动端
  - workspace markers — dashboard, admin, workspace, console, 管理,
    后台, 控制台
  + zh-Hans 屏幕 (screen) was already added in C5.

These ensure component classification is reserved for "X card / X chip /
…" prompts that have no surrounding screen/dashboard/mobile context.
The dashboard / mobile prompts then continue down to their own explicit
detector branches and produce the right preset.

Tests: 7 new negative cases covering admin dashboards with tiles,
charts, panels, Chinese 后台 with 卡片, and mobile/phone prompts that
also mention card/badge. 1062 / 1062 AI tests pass (was 1055; +7).
2026-05-09 21:14:00 +08:00
Fini 42cd737dac fix(ai): expand Type 0 fallback regex to all documented component triggers
Why: Codex stop-time review #4 — the previous regex covered ~17 nouns
but design-type.md documents 25 (button / label / row / item / selector
/ panel / chart were missing) and the CJK 卡片 alias was also listed.
JS `\b` is ASCII-only and never fires between two CJK chars, so
`\b卡片\b` matched nothing in "design a 卡片".

What: split into COMPONENT_TRIGGER_LATIN_RE (full noun list with `\b`
boundaries) + COMPONENT_TRIGGER_CJK_RE (kana-free subset of the most
common Chinese aliases — 卡片 / 徽章 / 标签 / 按钮 / 开关 / 对话框 /
提示 / 气泡 / 图表). Either match is enough to classify Type 0.
Disqualifier regex also gains 屏幕 (screen in zh-Hans).

Tests: 23 it.each cases pin one Latin trigger each plus the CJK 卡片;
6 negative cases prove the disqualifier still wins for "X screen / page
/ app / onboarding / flow" prompts. 1055 / 1055 AI tests pass (was
1027; +28 new).
2026-05-09 21:13:00 +08:00
Fini 2d6b964240 fix(ai): close remaining Type 0 component leaks in fallback + agent paths
Why: Codex stop-time review #3 flagged "Type 0 component handling is
incomplete". The earlier C1 fix (orchestrator-plan-classify helper +
isMobileFullScreen heuristic) covered the orchestrator path, but four
more places still bucketed narrow widths (≤480 / ≤500) as mobile and
mishandled component-shaped plans.

What:
- agent-tool-executor.ts: replace `width<=500 ? 375 : 1200` bucket on
  setGenerationCanvasWidth with the inserted node's actual width — a
  400-wide profile card now estimates text against 400, not 375.
- design-type-presets.ts: add 'component' to DesignType union with
  width=400, height=0, and a single-section default. detectDesignType
  matches "X card / X badge / X chip / ..." prompts BEFORE the mobile
  / dashboard check, so the parse-failure fallback returns a 400px
  component instead of a 1200px landing-page for "design a profile
  card". Disqualified when prompt also names a screen / page.
- orchestrator-prompt-optimizer.ts: 3 spots — platform selection now
  uses preset.type==='mobile-screen' (component groups with webapp,
  not mobile, since it has no status bar / bottom nav); compact
  prompt rules and subtask hint get a component branch ("Use width=400
  height=0, exactly 1 subtask, no chrome"); fallback height map gives
  components a single 200px region instead of 800.
- orchestrator-planning.ts: buildFallbackHeights treats narrow +
  auto-height plans as component-shape and emits 200px sections,
  preventing the prior "812 / 1 = 812-tall card" output.

2 new tests pin: (a) "design a clean profile card" → 400×0 single
"Component" subtask with 200px region; (b) "design a card screen page"
must NOT shortcut to component (screen/page disqualifier holds).
2026-05-09 21:12:00 +08:00
Fini 381e86c412 Merge branch 'v0.8.0' of github.com:ZSeven-W/openpencil into v0.8.0 2026-05-09 21:11:00 +08:00
Kayshen-X 5a027a4f4e test(shell-core): assert W3C field readback in gesture re-export tests
Codex P0 mini-gate Round 2 finding (Q5) fix: gesture_re_export.rs tests
set the new W3C fields (KeyEvent.is_composing, FocusEvent.related_node_
id_hint, WheelEvent.delta_z + WheelEvent.mode mutability) but only
asserted the structural compile-time identity, not value readback.

Strengthened to assert every W3C field reads back what was written so
cross-crate type identity AND field-level binary compat are both verified
through the OP re-export path:
- key_event_is_re_exported_from_jian_with_all_w3c_fields: 7-field assert
- focus_event_is_re_exported_from_jian_with_all_w3c_fields: 3-field assert
- wheel_event_is_re_exported_from_jian_with_w3c_fields: defaults +
  mutate-and-assert mode + delta_z + delta.x/y

cargo test -p openpencil-shell-core --test gesture_re_export → 6/6 PASS.
2026-05-09 21:03:00 +08:00
Fini 40a085d0f8 fix(ai): tokenize path icon names so multi-word "Search Icon Path" resolves
Why: MiniMax-M2.7 keeps emitting path nodes named "Search Icon Path" /
"Time Icon Path" / "Heart Icon Stroke" (3 words ending in noise word).
The legacy resolver normalised to "searchiconpath" (15 chars), prefix
fallback found "search" (6/15 = 40% < 50% threshold) → rejected →
fallback to lucide:circle → user-visible "circle bug" across categories,
filter chips, and search bar leading icons. Skill update alone (telling
models to use icon_font) doesn't fix the trained-pattern leftover —
Codex flagged this as a still-unfixed failure mode.

What: extractIconKeyword() tokenises on camelCase / space / dash /
underscore boundaries and drops { icon, logo, symbol, glyph, path,
shape, stroke, fill, svg, graphic, image }. Surviving tokens are
concatenated for direct dictionary lookup. Pure-noise names ("Icon
Path", "Symbol") return early without writing the misleading circle
placeholder. time / deliverytime / rider aliases added (kept in sync
across icon-dictionary.ts and server icon.ts NAME_ALIASES per existing
comment). 11 new tests cover multi-word resolution and pure-noise
no-op; 21 prior regression cases (descriptive geometry untouched,
single-word camelCase / kebab / snake all resolve, "Brand Logo"
placeholder behaviour preserved) still green.
2026-05-09 21:02:00 +08:00
Fini af9292d8f5 fix(ai): classify Type 0 components as non-mobile to skip phone chrome
Why: "Design a profile card" through MiniMax-M2.7 produced a 375×803 mobile
screen with auto-injected status bar, because the planner skill listed
"profiles" as a Type 2 single-task screen and the orchestrator's
isMobileScreen heuristic ran on width≤480 alone.

What: design-type.md + decomposition.md add Type 0 (single component:
card / badge / chip / modal) with width=400 height=0 1 subtask no chrome.
isMobileFullScreen helper extracted to orchestrator-plan-classify.ts and
required by both orchestrator.ts and orchestrator-sub-agent.ts so the
two paths can't drift on what "mobile" means (Codex review caught this
when only orchestrator.ts had the new check).

Verified with same MiniMax + same prompt: 400×320 component, 8 nodes,
firstChildRole=card, no status-bar.
2026-05-09 21:00:00 +08:00
Fini e5ac53017d fix(ai): run clipCardImageCorners AFTER role-resolver fills in card defaults
Why: Codex stop-time review 2026-05-10 — clipCardImageCorners was
slotted between unwrapFakePhoneMockups and resolveTreeRoles in the
post-streaming pipeline. Cards that don't carry an explicit
cornerRadius from the sub-agent get one filled in by role-resolver
(e.g. role='card' → default cornerRadius=12). Running clip before role
defaults silently skipped every default-radius card — match policy
required scalar cornerRadius > 0 and saw cornerRadius=undefined at
that point. Net effect: the bug stayed for the most common case where
the model wrote role='card' without a numeric radius.

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

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

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

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

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

What — two coordinated fixes:

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

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

Wired into applyPostStreamingTreeHeuristics right after
unwrapFakePhoneMockups. 1448 / 1448 pen-core tests pass (was 1438; +10);
1110 / 1110 AI service tests still pass.
2026-05-09 20:59:56 +08:00
Fini fef986098e fix(ai): heuristic image-area requires 0 children or icon_font child
Why: Codex stop-time review 2026-05-10 caught a content-erasure bug.
The b9b4126b heuristic accepted "0 or 1 children" with no type check,
so a hero frame named "Hero" with a single CTA button child matched.
Then the queue's update path (line 388-391 of processQueue) clears
\`children: []\` when the photo lands — destroying the legitimate CTA
button under it. Same risk for banners with sale text, covers with
nested frames, etc.

What: tighten the children clause in isImageAreaFrameByHeuristic. A
heuristic frame is now accepted only when:
  - children is undefined / not an array, OR
  - children.length === 0, OR
  - children.length === 1 AND children[0].type === 'icon_font'
    (the typical "broken image" placeholder hint).

Anything else — single non-icon child OR multiple children — means the
frame holds real content and the children:[] erase step would damage
the design. Such frames are now rejected by the heuristic and stay
unchanged on canvas.

3 new it() cases pin the rejection: hero+button, banner+text, and
cover+nested-frame all return false. Existing single-icon-child case
still passes. 38 / 38 tests in image-search-pipeline.test.ts (was 37;
+1; the rejection case adds 1 new it). 1110 / 1110 AI tests pass.
2026-05-09 20:59:53 +08:00
Fini 8f35ded088 fix(ai): queue still-needs-fill check accepts heuristic image-area frames
Why: Codex stop-time review #N (2026-05-10) caught the real bug — my
b9b4126b / 810c2f7a chain detected heuristic image-area frames at
collectImageSearchTargets and enqueued them at enqueueImageForSearch,
but the queue processor's still-needs-fill re-check (line 350-358 of
processQueue) called isUnfilledImagePlaceholderFrame which strictly
requires role='image-placeholder'. Heuristic frames have no role, so
the re-check returned false and the queue silently dropped them
before issuing the fetch — net effect was zero photos for the food-app
card scenario the heuristic was supposed to fix.

What: extract a new pure-function predicate
\`isFramePlaceholderStillUnfilled(node)\` that accepts a node iff it is
EITHER a canonical unfilled placeholder (role-based) OR a heuristic
match (name-based). Queue processor calls this single helper instead
of the strict canonical-only check. The helper is also exported and
test-covered separately so a future regression on this code path
fails loudly.

6 new it() cases pin: positive on canonical + heuristic, negative on
already-filled (canonical AND heuristic), null/undefined, and
unrelated frame names. 31 → 37 tests in image-search-pipeline.test.ts;
1109 / 1109 AI service tests pass overall (was 1103; +6).
2026-05-09 20:59:52 +08:00
Fini 47646bc655 test(ai): exercise extractQueryForNode + findParentSemanticName
Why: 810c2f7a added the parent-walk query mining for heuristic image-
area frames but landed without unit coverage of the two new helpers.
Without it, a future edit to GENERIC_PLACEHOLDER_NAMES or the layout-
word filter could silently regress query quality (e.g. start emitting
"Wrapper" or "Section" as queries) and the food-app card photos would
go back to looking generic.

What: export the two helpers and add 5 it() cases covering:
- explicit imageSearchQuery wins over name
- generic literal "Image" + parent "Bella Italia" → returns "Bella Italia"
- skip layout words ("Card Wrapper") in the parent walk; accept the
  next semantic ancestor ("Margherita Pizza")
- 4-hop layout-only chain returns null (maxHops bound)
- fall back to non-generic-but-image-themed name when parent walk
  yields nothing ("My Custom Photo")

31 / 31 tests in image-search-pipeline.test.ts pass (was 26; +5).
1103 / 1103 AI service tests pass (was 1098; +5).
2026-05-09 20:59:51 +08:00
Fini 2da0f3625e fix(ai): query mining + enqueue path for heuristic image-area frames
Why: b9b4126b's collectImageSearchTargets returns heuristic-matched
frames (named "Image" / "Photo" / "Cover" without the canonical
role) but two downstream code paths quietly dropped them:

1. enqueueImageForSearch only accepted type==='image' or
   isUnfilledImagePlaceholderFrame, so the heuristic frames got past
   collect but never reached the queue.

2. extractQueryForNode would have returned the literal name "Image" or
   "Photo" — useless to the photo search API. The user's "Bella Italia"
   restaurant card never gets a relevant photo because the placeholder
   frame's name says nothing about the restaurant.

What:
- enqueueImageForSearch grows a third branch: isImageAreaFrameByHeuristic
  → kind: 'placeholder-frame'. Same kind so the rest of the pipeline
  treats it identically to a canonical placeholder.
- extractQueryForNode learns to skip "generic" placeholder names
  (Image / Photo / Cover / Hero / Thumbnail / Banner / Poster + a few
  variants) and walk up to the nearest semantic parent frame name
  ("Bella Italia" / "Margherita Pizza" / "Sushi House" — whatever the
  enclosing card was named). Bounded to 3 hops. Filters layout words
  (Card / Wrapper / Container / Section / Frame / Root / Page / Stack /
  Row / Column / Content) so we don't end up searching for "Card".
- A new helper findParentSemanticName builds a parent map from the
  live document on demand. Cheap for typical designs (< few hundred
  nodes); avoids threading parent through every collect / enqueue call
  site.

Net effect: a model-emitted plain "Image" frame inside a "Bella Italia"
card now searches for "Bella Italia" instead of literally "Image". The
existing isImageAreaFrameByHeuristic test coverage protects the entry
condition; 1098 / 1098 AI service tests still pass.
2026-05-09 20:59:50 +08:00
Fini 98211e6226 fix(ai): heuristic image-area detection for non-canonical frame placeholders
Why: 2026-05-09 user report — the food-app card design landed with
empty colored rectangles where restaurant photos should be (Bella
Italia / Green Bowl / Margherita Pizza). Root cause: the model emitted
plain frames named "Image" / "Photo" / "Cover" with a solid fill as
the card-top image area, instead of using add_image_placeholder_v1
(which sets role: 'image-placeholder'). The auto-search pipeline
strict-checks role and missed all of them, so scanAndFillImages found
nothing to search and the cards stayed solid-colored.

What: new isImageAreaFrameByHeuristic(node) supplements the strict
role check. Conservative match — only fires when ALL of:
  - frame node WITHOUT role='image-placeholder' (strict path handles
    that)
  - name matches /\\b(image|photo|cover|hero|thumbnail|thumb|picture|
    banner|poster)\\b/i
  - width >= 80 AND height >= 60 (filters tiny color swatches)
  - exactly one solid (non-image) fill (gradient = decorative, image
    = already filled, both skip)
  - 0 or 1 children (an icon child is OK, multi-child = real layout)

collectImageSearchTargets walks both the strict and heuristic paths
and produces the same kind: 'placeholder-frame' target either way, so
the existing query / aspect / search code-path runs unchanged.

10 new tests cover positive matches (Image / Photo / Cover / Hero /
Thumbnail / Banner / Poster), negative for canonical placeholder
(double-counting prevented), unrelated names (Card / Wrapper),
already-filled image fills, gradients, content-rich frames, single-
icon-child acceptance, undersize frames, and non-numeric dimensions.
26 / 26 tests in image-search-pipeline.test.ts pass (was 16; +10).
1098 / 1098 AI service tests pass (unchanged).
2026-05-09 20:59:49 +08:00
Fini b25805acb1 test(ai): integration coverage for text-effect + mixed-sibling-padding
Why: 3abdfcf9 added integration tests for 4 of the 6 new aesthetic
detectors (rotation / text-cornerRadius / text-stroke / mixed-sibling-
cornerRadius). text-effect (7aef1b14) and mixed-sibling-padding
(ad025c95) landed without integration coverage of the
detect → applyFixes → store mutation chain.

What: 2 more it() cases mirroring the existing pattern:
- text-effect: text node with shadow effects → effects cleared
- mixed-sibling-padding: 3 cards with padding 16/16/20 → outlier
  rewritten to scalar 16 (collapsed from the 4-tuple modal because
  all sides are equal — locks in the scalar-collapse code path)

13 / 13 tests in design-pre-validation.test.ts pass (was 11; +2).
1088 / 1088 AI tests overall (was 1086; +2).
2026-05-09 20:59:46 +08:00
Fini 8db9e97075 feat(ai): per-subtask icon_font reminder in sub-agent prompt
Why: even after the icon-catalog skill rewrite (19ca1c66) said "ALWAYS
USE icon_font, NEVER path NODES", end-to-end testing with MiniMax-M2.7
shows the model still emits ~half its icons as \`path\` nodes (Bell
Icon / Cart Icon etc.) because the per-subtask CRITICAL LAYOUT
CONSTRAINTS prompt — which the model treats as the prompt of record
— never restated the icon convention. The skill prompt is upstream
context the model can drift away from; the per-subtask block is the
last thing the model reads before generating, so it carries weight.

What: append a one-line ICONS rule directly to CRITICAL LAYOUT
CONSTRAINTS in orchestrator-sub-agent.ts. Restates the icon_font shape
inline (\`{"type":"icon_font","iconFontName":"<lucide-name>",…}\`) and
calls out the failure mode by name (resolver guess + placeholder
circle) so the model sees both the right pattern and the consequence
of the wrong one.

The path-with-iconic-name fallback path keeps working — this is purely
preventive guidance, the resolver tokenize fix (ef6f7ed3) still
catches the leftover cases. 1086 / 1086 AI tests still pass.
2026-05-09 20:59:45 +08:00
Fini 9743dedcdf test(ai): integration tests for aesthetic detector → applyFixes pipeline
Why: the 4 new aesthetic detectors (rotation / text-cornerRadius /
text-stroke / mixed-sibling-cornerRadius) have unit tests against the
pure detect functions in pen-ai-skills, but the chain through the live
Zustand store + runPreValidationFixes had no end-to-end coverage.
Without it, a future refactor that subtly breaks the apply step (e.g.
suggestedValue:undefined not clearing the prop) would slip through.

What: 4 new integration tests in design-pre-validation.test.ts using
the existing makeDoc / loadDocument fixtures. Each builds a doc with
exactly one known aesthetic issue, runs runPreValidationFixes, and
asserts the store mutation took effect:

- rotation:12 on a frame → reset to 0
- cornerRadius:8 on a text node → cleared (undefined)
- stroke on a text node → cleared (undefined)
- mixed cornerRadius across 3 sibling cards (8 / 8 / 12) → outlier
  rewritten to modal 8 (note: handled by the older
  sibling-inconsistency detector; mixed-sibling-corner-radius is
  the dedupe-loser here since sibling-inconsistency runs first
  with the same {nodeId, property} key — both produce the right fix)

11 / 11 tests in design-pre-validation.test.ts pass (was 7; +4).
1086 / 1086 AI tests overall (was 1082; +4).
2026-05-09 20:59:44 +08:00
Fini 209e195595 test(ai): regression-cover photo / camera icon names after image-noise removal
Why: C9 (b1ffa1c5) removed `image` from the resolver noise list to make
"Image Icon" resolve to lucide:image. The doc comment claimed "Image
Placeholder Path" still resolves via prefix fallback, but the math is
wrong — `image` is 5 chars, `imageplaceholder` is 16 chars, 5/16 = 31%
which is below the 50% FALLBACK_MIN_RATIO. So "Image Placeholder Path"
genuinely no longer hits the resolver fallback path. That's actually
correct (no icon marker word, resolver returns early), and the original
food-app circles came from the model emitting circle path-data
directly, not from the resolver fallback. But the photo / camera
aliases were also implicit dependencies that deserve explicit coverage
to lock in the behavior.

What: 2 new tests verify photo / camera resolve correctly through the
already-existing alias chain (`photo: _IMAGE` in BUILTIN_ICONS, lucide
camera native). 1082 / 1082 AI tests pass (was 1080; +2).
2026-05-09 20:59:43 +08:00
Fini 9689585009 feat(ai): show per-category breakdown in pre-check status line
Why: when the pre-validation pass auto-fixes issues, the chat panel
just says "Pre-checks: fixed 5 issues" — generic and uninformative.
The user can't tell whether 5 invisible-container fixes happened
(structural, mostly safe), 5 unexpected-rotation fixes (aesthetic,
worth reviewing), or 5 mixed-sibling-padding fixes (consistency, worth
reviewing). With 10 detector categories now (4 original + 6 aesthetic
added in 53435bf7 / 7aef1b14 / cd1e4325 / ad025c95), the per-category
visibility starts to matter.

What:
- runPreValidationFixesDetailed() returns { total, byCategory } where
  byCategory is a per-category count of APPLIED fixes (excludes the
  info-severity skips and the protected-status-bar skip).
- runPreValidationFixes() kept as a thin wrapper returning .total so
  no caller needs to change.
- design-validation.ts now uses the detailed result and formats the
  breakdown as e.g. "fixed 5 (3 text-effect, 2 unexpected-rotation)"
  in the chat panel — sorted by count descending so the dominant
  category surfaces first. Both the no-vision-validation path and the
  size-gated skip path show the breakdown when it exists.

Falls back to the legacy "fixed N issues" format when byCategory is
empty (defensive — should never happen if total > 0). 1080 / 1080 AI
tests still pass — the new return shape is additive and the wrapper
preserves the integer contract.
2026-05-09 20:59:41 +08:00
Fini 8f35361518 test(ai): extract Type 0 unwrap predicate as pure helper + 10 tests
Why: dd8eb0eb's unwrap pass (Type 0 single-component section root
hoist) was integration-tested via the live Playwright run but had no
unit coverage. The integration test won't catch regressions when
someone tightens the heuristics — and the load-bearing "do nothing"
guards (multi-section, mobile screen, desktop, 0/N children, non-frame
child) are exactly where a careless edit would silently flatten a
multi-page design.

What: split the helper into two — a pure predicate
shouldUnwrapSingleComponentSectionRoot(plan, root) returning bool, and
the existing unwrapSingleComponentSectionRoot(rootNodes, plan) which
calls the predicate then mutates the store. Predicate is exported.

10 new tests cover:
- 3 positive: wrapper id ends -root / wrapper id ends -section /
  wrapper name copies parent name
- 7 negative load-bearing guards: multi-section plan, mobile screen
  (height >= 480), desktop (width > 480), root with 0 children, root
  with multi children, wrapper with no children, wrapper with
  unrelated id+name, wrapper is non-frame (text / icon)

1080 / 1080 AI tests pass (was 1070; +10).
2026-05-09 20:59:36 +08:00
Fini 003e45edc0 fix(ai): propagate validate skipped reason into chat status line
Why: every time the vision validation loop returned skipped:true the
chat panel logged the same hardcoded "(timeout or provider error)"
string regardless of the actual cause — provider mismatch, HTTP error,
upstream config issue. Now that the server (validate.ts) returns
explicit skip reasons (e.g. "Vision validation is not supported for
builtin providers"), the UI should surface them so the user can fix
the right thing instead of guessing it's a timeout.

What: ValidationResult gains an optional `skippedReason` field.
validateDesignScreenshot fills it from response.json's `error` (or
the HTTP status text on a non-OK response) and propagates it through
the loop. The chat-panel status line now reads
"[error] Analysis skipped (<reason>)" with the server-provided
message clipped to 120 chars; falls back to the legacy string when no
reason is present.

1070 / 1070 AI tests still pass; no test depended on the literal
"timeout or provider error" string.
2026-05-09 20:59:35 +08:00
Fini 9f153981db fix(ai): explicit skipped reason for builtin providers in vision validate
Why: builtin providers (MiniMax / DeepSeek / Bailian / Ark) currently
fall through to the generic "Missing or unsupported provider" error in
/api/ai/validate. The post-generation loop catches that as a hard
provider error and logs "[error] Analysis skipped (timeout or provider
error)" — which reads like a config bug to the user even though the
real reason is "this provider's models are text-only, vision validation
isn't useful here even if we did proxy it".

What: branch on body.provider === 'builtin' before the generic error
and return { skipped: true, error: '<explanatory message>' }. The
client design-validation.ts already short-circuits on `data.skipped`
so the loop now logs the clearer message instead. No behavior change
for the four supported providers; no new wire fields.
2026-05-09 20:59:34 +08:00
Fini 5bdf50ef1a fix(ai): skip search-bar role styling inside nav-tab parents
Why: end-to-end test of "Design a bottom nav with Home / Search /
Orders / Cart / Profile" surfaced a stray coloured pill highlight
wrapping the Search tab. Root cause: the model labels the cell
\`role: 'search-bar'\` (intending "this tab whose icon is search"),
and the role-resolver dutifully stamps the input-shaped 44px-tall,
22-corner, filled-surface look onto the nav cell. Inside a 56px tall
tab row that pill swallows the icon + label, looks broken on canvas,
and competes for click area with the nav-item active state.

What: search-bar role now early-outs with `{}` (no overrides) when
ctx.parentRole is one of `bottom-tab-bar` / `tab-bar` / `tab-row` —
mirroring the same check the `button` role already uses to skip its
text-button defaults inside tab containers. Nav-cell layout / fill
remains the responsibility of nav-item / nav-item-active.

1070 / 1070 AI tests still pass; the input-shape default still applies
in every other context (forms, headers, hero search, etc.).
2026-05-09 20:59:33 +08:00
Fini 094cec7d8d fix(ai): unwrap redundant section-root when Type 0 plan has 1 subtask
Why: for Type 0 component plans (Notification Card / Profile Card / …)
the orchestrator pre-inserts a page rootFrame named after the component,
then the sub-agent emits its own section-root frame as the only child.
Result is a visible "Notification Card → Notification Card" double wrap
in the layers panel and a wasted layout depth that does nothing visual.
The double wrap was confirmed in the 2026-05-09 end-to-end test of the
notification-card prompt: depth-0 = orchestrator rootFrame (role=card),
depth-1 = sub-agent wrapper (also role=card), actual children at depth-2.

What: new unwrapSingleComponentSectionRoot pass added as Phase 4c right
after the mobile-status-bar dedup (mutually exclusive: that runs only on
mobile, this runs only on component-shaped plans). Conservative match —
only fires when:
  - plan.subtasks.length === 1, AND
  - plan.rootFrame is narrow (≤480) and auto-height (<480 or 0), AND
  - the orchestrator rootFrame has exactly 1 frame child, AND
  - that child's id has the sub-agent section-root suffix
    (`-root` / `-section`) OR the child copied the parent's name.

When the conditions hold, hoist the wrapper's children up via
store.moveNode (preserving order) and remove the wrapper. Multi-section
pages, dashboards, and mobile screens are untouched — early-out on the
plan.subtasks.length / width / height checks.

1070 / 1070 AI tests still pass; unit-testing this against the live
Zustand store is awkward, the integration verification will land via
the next end-to-end notification-card run.
2026-05-09 20:59:32 +08:00