Commit graph

298 commits

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

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

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

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

This is a renderer-layer fix that detector-only paths can't reach;
ships in the same session as the typography / spacing detectors so
the user sees end-to-end aesthetic improvement on the next rebuild.
2026-05-10 22:53:58 +08:00
Fini d435fc53a7 Merge branch 'v0.8.0' of github.com:ZSeven-W/openpencil into v0.8.0 2026-05-10 21:37:11 +08:00
Kayshen-X 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 20dbbf227a feat(ai): detect stacked horizontal padding (page-vs-section gutter)
14th pre-validation detector + a preventive skill rule.

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

Two pieces:

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

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

Side-quest: scripts/ab-corpus/check-stacked-padding.ts ships with
this commit so the next stacked-padding-style detector calibration
can survey corpus frequency without rebuilding the harness.
2026-05-10 15:15:00 +08:00
Fini e709745f0e fix(ai): align contrast walk prune with canonical isNodeVisible
Codex round 4 caught: my walk prune was checking opacity=0 alongside
visible/enabled, but the renderer treats opacity as a paint alpha
(paint.setAlphaf) — opacity=0 nodes still get walked + laid out, just
painted with alpha 0. The canonical render-time visibility helper in
pen-core (isNodeVisible) checks ONLY `visible !== false && enabled
!== false`. Detector walk pruning has to match or it diverges from
what the renderer actually does, producing surprising results when
users probe the same tree elsewhere (debug screenshot, batch_get,
diagnostics report).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

4 new test cases cover the fix plus the boundary (opacity=0.5, alpha=80
should NOT be skipped). Full corpus replay shows 14 hits unchanged on
the 470-row corpus — no false-positive regression introduced.
2026-05-10 14:55:00 +08:00
Fini 761c5202e2 fix(ai): retune contrast thresholds to 2.5/2.0 — kill 35/41 false positives
Replayed the 2026-05-08-rank4-gpt55 corpus (104 GPT-5.5 dashboard
outputs, 95 applied) through the new detectTextBgContrast and got
41 hits — 43% of designs flagged. Sampling showed almost all of them
were industry-standard Tailwind palettes used as intentional tertiary
text:

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

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

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

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

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

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

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

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

Trigger from openpencil side is the fire-and-forget delegate fan-out
in apps/web/server/api/ai/agent.ts:1094 — multiple member iterators
race through the same nextDelta loop. Thread safety is now enforced
in the native module so the JS contract stays "delegate as you like".
2026-05-10 13:40:00 +08:00
Fini 33493bf7bd Merge remote-tracking branch 'origin/v0.8.0' into v0.8.0
# Conflicts:
#	Cargo.lock
#	crates/openpencil-shell-web/Cargo.toml
#	crates/openpencil-shell-web/src/lib.rs
#	packages/pen-ai-skills/src/diagnostics/detectors.ts
#	packages/pen-ai-skills/src/diagnostics/types.ts
#	packages/pen-mcp/src/routes/debug-routes.ts
2026-05-10 10:18:00 +08:00
Fini 5112bea716 fix(ai): clean card-image corners + strip wrapper stroke/cornerRadius with fill
Why: 2026-05-10 user report — "你看不到任何真正的生产级设计工具的希望"
called out three persistent visible issues with the food-app design:

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

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

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

What — two coordinated fixes:

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

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

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

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

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

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

7 new tests cover: positive on spread > 0 / blur > 40 / 3+ stacked,
negative on typical subtle shadow / no effects / blur exactly 40
(modal-shell legit) / text node (different detector). 241 / 241
pen-ai-skills tests pass (was 234; +7). 1110 / 1110 AI service tests
pass (unchanged — production builders pre-clean).
2026-05-09 21:41:00 +08:00
Fini 7c553586e9 fix(ai): strip dark hedge fill on container-role wrappers holding 2+ same-role children
Why: 2026-05-10 user report — the food-app "Featured" block landed
with a black background on a cream page. The wrapper had role='card'
AND held 3 restaurant cards each with role='card'. The existing
strip-redundant-section-fills pass treats role='card' as PROTECTED
(cards legitimately own their fills) so the black hedge fill survived.
Visible result: a giant black band between Categories and Popular Near
You that doesn't fit the cream page bg — exactly the "莫名其妙的背景颜色"
issue the user called out.

Root cause: the existing wrapper detection (hasNestedFilledComponent)
only fires for ATOMIC roles (search-bar / button / input / badge /
chip / tag / pill). Container-role wrappers around same-role children
were never matched, so a card-of-cards misroll kept its fill.

What: new hasMultipleSameRoleChildren predicate. Treats a frame as a
section-level wrapper (eligible for safe-dark / safe-light fill
stripping) when ALL of:
  - frame role is in CONTAINER_PROTECTED_ROLES (card / banner /
    pricing-card / feature-card / image-card / testimonial /
    metric-card / gallery-item / phone-mockup)
  - frame has ≥ 2 children with the SAME role
  - existing safe-hex / root-match check still applies

Net effect: the Featured wrapper's #000000 fill is now stripped, the
section inherits the cream page bg as intended, and the 3 inner
restaurant cards keep their own white surface fills (each was
role='card' but only 1 child of the same role per card, so the
predicate is silent on them).

3 new it() cases pin: Featured-block misroll (3 cards inside) gets
stripped, single-same-role-child stays untouched, banner-wrapping-
banners pattern also strips. 25 / 25 strip tests pass (was 22; +3).
1438 / 1438 pen-core tests pass overall.
2026-05-09 21:40:00 +08:00
Fini 4044a53ec9 fix(ai): sidebar-nav-v1 also canonicalises wrong-glyph icons via label
Why: f1923ff1 added coerceNavTabIcon to bottom-nav-v1 and noted that
sidebar-nav-v1 should adopt the same helper. Without it, a sidebar
nav with \`{ label: 'Profile', icon: 'profile' }\` would still render
a placeholder circle (resolver doesn't know "profile" is a known
wrong-glyph alias for "user") instead of the lucide:user glyph.

What: import coerceNavTabIcon and apply it in buildItemV1 before
stamping iconFontName onto the Icon child node. Same convention
single-sourced. Existing 1435 tests still pass — no test relied on
the prior pass-through behavior for known wrong-glyph names.
2026-05-09 21:34:00 +08:00
Fini 68bbfeed88 fix(ai): bottom-nav-v1 canonicalises wrong-glyph icons via title (Cart→shopping-cart)
Why: end-to-end test of "Design a bottom nav with Home / Search /
Orders / Cart / Profile" with MiniMax-M2.7 surfaced that the model
emits \`{ title: 'Cart', icon: 'shopping-bag' }\` for the Cart tab
~half the time. Both icons exist in lucide but they are different
glyphs — bag is for carrying, cart has wheels for checkout. The
icon-catalog skill update (19ca1c66) fixed it for the planning side
but not for the builder's runtime input — direct \`add_bottom_nav_v1\`
calls still pass through whatever icon the model picks.

What: new \`coerceNavTabIcon(title, icon, builder)\` helper in
coerce-params.ts. Maintains a small Title→canonical-lucide-name map
(Cart→shopping-cart, Profile→user, Home→house, etc., with Chinese
labels) AND a per-canonical KNOWN_WRONG_ALTS list so the swap only
fires when the emitted icon is one of the known wrong-glyph choices
for that title:

  Cart + shopping-bag         → shopping-cart  (warn)
  Cart + package              → shopping-cart  (warn)
  Cart + rocket               → rocket         (pass-through)
  Cart + shopping-cart        → shopping-cart  (silent)
  Custom + anything           → anything       (silent)

The pass-through rule keeps user / model intentional custom choices
intact. Warnings flow through the existing coerce-params sink so
orchestrators can surface them.

bottom-nav-v1.ts now calls coerceNavTabIcon before stamping
iconFontName onto the Tab frame. Sidebar-nav-v1 + similar nav
builders can adopt the same helper later without re-implementing the
map.

10 new unit + integration tests cover: positive swaps for cart /
profile / notifications / Chinese 购物车, pass-through for
custom titles, case-insensitivity, and the builder integration
that the emitted Tab tree carries the canonical iconFontName.
1098 / 1098 tests pass overall (1080 AI + 10 new + 8 elsewhere).
2026-05-09 21:33:00 +08:00
Fini 5a94c22c8e feat(ai-skills): aesthetic-hygiene rules in layout.md (preventive, not just curative)
Why: the 6 aesthetic detectors added in 53435bf7 / 7aef1b14 / cd1e4325 /
ad025c95 catch problems AFTER the model emits them. Telling the model
upfront — in the always-loaded layout skill — prevents the same patterns
in the first place. Cheaper than running a corrective post-pass on
every generation, and the model produces cleaner output that doesn't
trip the detectors at all.

What: AESTHETIC HYGIENE block appended to layout.md (priority 10, base,
loaded for every generation). 4 rules each backed by a corresponding
detector:

- Text never gets cornerRadius / stroke / effects / rotation. Mirrors
  detectTextCornerRadius / detectTextStroke / detectTextEffect.
- Rotation on UI frames is almost always wrong. Mirrors
  detectUnexpectedRotation (with the same 90/180/270 + path/line/polygon
  /image escape hatches).
- Same-role siblings must share cornerRadius AND padding. Mirrors
  detectMixedSiblingCornerRadius / detectMixedSiblingPadding.
- Inner layout frames (sections, wrappers) inherit from page/card —
  only opt into fill/stroke/shadow on the outer card/button/badge/chip.
  Mirrors the existing invisible-container detector.

Phrased as a "keep these silent" pre-condition since the post-pass
also strips them. 1080/1080 AI tests + 234/234 pen-ai-skills tests
still pass.
2026-05-09 21:28:00 +08:00
Fini e8a07e9b08 feat(ai): aesthetic detector — mixed-sibling-padding (mirror cornerRadius rule)
Why: continuation of the aesthetic detector series. Mirrors
detectMixedSiblingCornerRadius (53435bf7) for the padding axis. Three
cards with padding 16 / 16 / 20 looks ragged on canvas; the existing
sibling-inconsistency detector covers cards-vs-cards but dedupes
against cornerRadius and other props so the padding outlier
sometimes drops.

What: detectMixedSiblingPadding normalises padding values to a
4-tuple [top, right, bottom, left] before comparison, so
  padding: 16            → [16,16,16,16]
  padding: [12, 24]      → [12,24,12,24] (CSS 2-tuple shorthand)
  padding: [16,16,16,16] → [16,16,16,16]
all compare equal and don't trigger false positives. Modal value
collapses back to a scalar when all four sides are equal so the
suggested fix matches the model's preferred shorthand.

Same 60% modal-majority threshold as the cornerRadius detector —
1-1-1 three-way splits are skipped because there's no canonical value
to suggest. Same divider / spacer skip and same-type-and-role grouping.

Wired through detectAllIssues + index.ts exports + the
debug_validation_report MCP categories enum.

6 new tests cover: number shorthand outlier, number-vs-array
equivalence, 2-tuple-vs-4-tuple equivalence, 1-1-1 split skip,
mixed-role groups skipped, no-padding siblings excluded from modal.
57 / 57 diagnostics tests pass (was 51; +6).
2026-05-09 21:26:00 +08:00
Fini b24bc58a51 feat(ai): aesthetic detector — text-stroke (outlined UI labels)
Why: continuation of the aesthetic detector series. Outlined text on a
UI label is almost always an AI mistake — Lucide / SF / Material icons
get stroked, but body / heading / label text is filled. The model
occasionally copies a generic "give it a stroke" instruction onto text
nodes; on canvas the result reads as double-rendered glyphs. The
existing sibling-inconsistency detector doesn't catch this because
text stroke is rarely a sibling-by-sibling outlier — it's emitted
across the whole tree at once.

What:
- detectTextStroke added with the same shape as the other text-only
  aesthetic detectors (text node + property check + warning severity +
  suggestedValue undefined).
- Skips stroke.thickness === 0 (some model JSON keeps an empty stroke
  object as a placeholder; flagging that would be noise).
- Wired through detectAllIssues + index.ts public exports + the
  debug_validation_report MCP tool's categories enum.

Tests: 4 new positive + negative cases (text with stroke, text without
stroke, text with thickness=0 placeholder, frame with stroke). 51 / 51
diagnostics tests pass (was 47; +4); 228 / 228 pen-ai-skills overall.
2026-05-09 21:25:00 +08:00
Fini 96965161af feat(ai): aesthetic detector — text-effect (shadow / blur on text labels)
Why: continuation of the aesthetic detector family added in 53435bf7.
The model frequently sprinkles \`effects: [{type:'shadow', …}]\` onto
body / label / caption text. On canvas the type goes fuzzy and reads
"AI-designed". Real product UIs use text shadows extremely sparingly
(hero overlays on photos, a few brand elements). Detection is cheap
(walk + isArray check) and the suggested fix (remove effects array)
is safe — text shadow on UI labels is almost never intentional.

What:
- detectTextEffect added to packages/pen-ai-skills/diagnostics with the
  same shape as the prior 3 (warning severity, suggestedValue undefined,
  reason string for logs).
- Wired through detectAllIssues + index.ts public exports + the
  debug_validation_report MCP tool's categories enum.

Tests: 5 new it() cases covering positive (shadow / blur on text),
negative (text without effects, empty effects array, frame with
effects), and tree-walk (multiple text effects in nested frames).
47 / 47 diagnostics tests pass (was 42; +5).
2026-05-09 21:24:00 +08:00
Fini fcc3d8b4d3 feat(ai): aesthetic detectors — rotation / text-cornerRadius / mixed-sibling-cornerRadius
Why: user reports the validation pipeline lacks "aesthetic standards"
— it accepts misalignment, unwanted corner radius, and other visual
issues as "normal". Existing detectors are pure code-quality (invisible
container / empty path / text height / sibling inconsistency); they
don't catch design-system violations the user can see at a glance.
Vision validation does, but it only runs on Anthropic / Codex /
OpenCode / Gemini providers and only above 30 nodes — leaving a long
tail of small-design / builtin-provider runs with no aesthetic check
at all. Adding cheap pure-function detectors closes that gap with no
upstream provider dependency.

What: 3 new pure detectors in pen-ai-skills/diagnostics:

  - detectUnexpectedRotation — flags non-axis-aligned rotation on
    UI-bearing nodes (frame / text / shape). Skips path / line /
    polygon / image (legitimate decorative geometry frequently
    rotated), skips multiples of 90° (intentional vertical text /
    grid). Catches the "tilted card" hallucination cleanly.

  - detectTextCornerRadius — flags text nodes with cornerRadius > 0.
    Text isn't drawn into a clipped rectangle so the prop is silently
    dropped at render time, but it survives in the doc and burns
    LLM context on subsequent batch_get calls. Suggested fix: remove.

  - detectMixedSiblingCornerRadius — stricter than the existing
    sibling-inconsistency check on cornerRadius alone. Flags outliers
    when 2+ of 3 same-type-and-role siblings share a value and one
    differs (e.g. three cards with cornerRadius 8 / 8 / 12 reads as
    ragged on canvas). Skips 1-1-1 three-way splits (no canonical
    modal) and divider / spacer nodes (visual primitives).

All three are wired through detectAllIssues + the index.ts public
exports + the debug_validation_report MCP tool's `categories` enum so
the user / agent can opt-in or filter via `op debug_validation_report
--categories unexpected-rotation`.

35 new tests cover the load-bearing positive + negative cases for each
detector. 219/219 pen-ai-skills tests pass (was 184; +35). 1080/1080
AI service tests still pass.
2026-05-09 21:23: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 624cd75543 feat(ai): rewrite icon-catalog skill + add transparent-section rule
Why: MiniMax-M2.7 food-app run rendered Header with white fill on cream
page bg, and used iconFontName=shopping-bag for Cart tab. Two skill-side
issues: icon-catalog.md was self-contradictory ("use path nodes" vs
"use icon_font"), and layout.md had no rule for inner-section bg.

What:
- icon-catalog.md rewritten as "ALWAYS USE icon_font, NEVER path NODES"
  with role→name map (Cart→shopping-cart not shopping-bag, Pizza→pizza,
  Sushi→fish via alias, etc) and food-category icon list appended.
- layout.md adds: interior section wrappers (Header, Search Section,
  Categories Section) MUST have fill:[] (transparent / inherit page bg);
  only opt into a fill when the section is intentionally a card with
  its own surface tone.
2026-05-09 21:01: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 5aed1d9d24 fix(ai): edge-padding detector skips mobile-width components
The 12th detector landed in 55f1f5d7 fired on any frame 320–480 wide
regardless of height — so a 393×88 bottom-nav, a 393×200 tile, or a
393×500 settings panel being designed in isolation got page-level
gutters auto-applied as if it were the page root.

Tighten the page predicate to require height >= 568 (iPhone SE 1st gen,
the shortest production phone) AND aspect ratio h/w >= 1.5. Real phones
clear both bars; mobile-width components do not.

Surfaced by Codex stop-hook review on the previous commit.
2026-05-09 20:59:59 +08:00
Fini 2b534c7cf3 feat(ai): detect mobile section glued to screen edge
Add detectEdgeSectionPadding (12th pre-validation detector). Flags a
mobile-shaped page root (width 320–480) when its horizontal padding is
0 AND a child content section also has 0 left padding AND that section
contains visible text or icon descendants — the chain that produced the
"Categories" no-padding bug. Suggested fix sets root.padding to
[top, 16, bottom, 16] preserving vertical padding.

False-positive guards skip top-nav / bottom-nav / hero / banner roles
and image-only sections that are intentionally full-bleed.

The new detector lives in its own detectors-spacing.ts since detectors.ts
is already over the 800-line file limit.
2026-05-09 20:59:58 +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 40dfc55c95 feat(ai): aesthetic detector — excessive-frame-effects (spread > 0 / blur > 40 / 3+ stacked)
Why: 2026-05-10 user report — "Mexican" badge in the food-app screenshot
landed with a "带尖的背景阴影" (pointy / spiked background shadow). The
underlying cause is the model emitting effects with positive spread,
which "bleeds" the shadow color outward and creates a visible bloom /
halo around the badge that doesn't match real product UI shadows.
Real UI shadows are tight: blur 4-16, spread 0, near-black low-alpha.
Existing detectors only handle text effects (text-effect from 7aef1b14);
frame-level effects had no aesthetic gate.

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

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

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

7 new tests cover: positive on spread > 0 / blur > 40 / 3+ stacked,
negative on typical subtle shadow / no effects / blur exactly 40
(modal-shell legit) / text node (different detector). 241 / 241
pen-ai-skills tests pass (was 234; +7). 1110 / 1110 AI service tests
pass (unchanged — production builders pre-clean).
2026-05-09 20:59:55 +08:00
Fini 828f09abda fix(ai): strip dark hedge fill on container-role wrappers holding 2+ same-role children
Why: 2026-05-10 user report — the food-app "Featured" block landed
with a black background on a cream page. The wrapper had role='card'
AND held 3 restaurant cards each with role='card'. The existing
strip-redundant-section-fills pass treats role='card' as PROTECTED
(cards legitimately own their fills) so the black hedge fill survived.
Visible result: a giant black band between Categories and Popular Near
You that doesn't fit the cream page bg — exactly the "莫名其妙的背景颜色"
issue the user called out.

Root cause: the existing wrapper detection (hasNestedFilledComponent)
only fires for ATOMIC roles (search-bar / button / input / badge /
chip / tag / pill). Container-role wrappers around same-role children
were never matched, so a card-of-cards misroll kept its fill.

What: new hasMultipleSameRoleChildren predicate. Treats a frame as a
section-level wrapper (eligible for safe-dark / safe-light fill
stripping) when ALL of:
  - frame role is in CONTAINER_PROTECTED_ROLES (card / banner /
    pricing-card / feature-card / image-card / testimonial /
    metric-card / gallery-item / phone-mockup)
  - frame has ≥ 2 children with the SAME role
  - existing safe-hex / root-match check still applies

Net effect: the Featured wrapper's #000000 fill is now stripped, the
section inherits the cream page bg as intended, and the 3 inner
restaurant cards keep their own white surface fills (each was
role='card' but only 1 child of the same role per card, so the
predicate is silent on them).

3 new it() cases pin: Featured-block misroll (3 cards inside) gets
stripped, single-same-role-child stays untouched, banner-wrapping-
banners pattern also strips. 25 / 25 strip tests pass (was 22; +3).
1438 / 1438 pen-core tests pass overall.
2026-05-09 20:59:54 +08:00
Fini 9fd6abb8ea fix(ai): sidebar-nav-v1 also canonicalises wrong-glyph icons via label
Why: f1923ff1 added coerceNavTabIcon to bottom-nav-v1 and noted that
sidebar-nav-v1 should adopt the same helper. Without it, a sidebar
nav with \`{ label: 'Profile', icon: 'profile' }\` would still render
a placeholder circle (resolver doesn't know "profile" is a known
wrong-glyph alias for "user") instead of the lucide:user glyph.

What: import coerceNavTabIcon and apply it in buildItemV1 before
stamping iconFontName onto the Icon child node. Same convention
single-sourced. Existing 1435 tests still pass — no test relied on
the prior pass-through behavior for known wrong-glyph names.
2026-05-09 20:59:48 +08:00
Fini ac8dfcee1b fix(ai): bottom-nav-v1 canonicalises wrong-glyph icons via title (Cart→shopping-cart)
Why: end-to-end test of "Design a bottom nav with Home / Search /
Orders / Cart / Profile" with MiniMax-M2.7 surfaced that the model
emits \`{ title: 'Cart', icon: 'shopping-bag' }\` for the Cart tab
~half the time. Both icons exist in lucide but they are different
glyphs — bag is for carrying, cart has wheels for checkout. The
icon-catalog skill update (19ca1c66) fixed it for the planning side
but not for the builder's runtime input — direct \`add_bottom_nav_v1\`
calls still pass through whatever icon the model picks.

What: new \`coerceNavTabIcon(title, icon, builder)\` helper in
coerce-params.ts. Maintains a small Title→canonical-lucide-name map
(Cart→shopping-cart, Profile→user, Home→house, etc., with Chinese
labels) AND a per-canonical KNOWN_WRONG_ALTS list so the swap only
fires when the emitted icon is one of the known wrong-glyph choices
for that title:

  Cart + shopping-bag         → shopping-cart  (warn)
  Cart + package              → shopping-cart  (warn)
  Cart + rocket               → rocket         (pass-through)
  Cart + shopping-cart        → shopping-cart  (silent)
  Custom + anything           → anything       (silent)

The pass-through rule keeps user / model intentional custom choices
intact. Warnings flow through the existing coerce-params sink so
orchestrators can surface them.

bottom-nav-v1.ts now calls coerceNavTabIcon before stamping
iconFontName onto the Tab frame. Sidebar-nav-v1 + similar nav
builders can adopt the same helper later without re-implementing the
map.

10 new unit + integration tests cover: positive swaps for cart /
profile / notifications / Chinese 购物车, pass-through for
custom titles, case-insensitivity, and the builder integration
that the emitted Tab tree carries the canonical iconFontName.
1098 / 1098 tests pass overall (1080 AI + 10 new + 8 elsewhere).
2026-05-09 20:59:47 +08:00
Fini c26a037214 feat(ai-skills): aesthetic-hygiene rules in layout.md (preventive, not just curative)
Why: the 6 aesthetic detectors added in 53435bf7 / 7aef1b14 / cd1e4325 /
ad025c95 catch problems AFTER the model emits them. Telling the model
upfront — in the always-loaded layout skill — prevents the same patterns
in the first place. Cheaper than running a corrective post-pass on
every generation, and the model produces cleaner output that doesn't
trip the detectors at all.

What: AESTHETIC HYGIENE block appended to layout.md (priority 10, base,
loaded for every generation). 4 rules each backed by a corresponding
detector:

- Text never gets cornerRadius / stroke / effects / rotation. Mirrors
  detectTextCornerRadius / detectTextStroke / detectTextEffect.
- Rotation on UI frames is almost always wrong. Mirrors
  detectUnexpectedRotation (with the same 90/180/270 + path/line/polygon
  /image escape hatches).
- Same-role siblings must share cornerRadius AND padding. Mirrors
  detectMixedSiblingCornerRadius / detectMixedSiblingPadding.
- Inner layout frames (sections, wrappers) inherit from page/card —
  only opt into fill/stroke/shadow on the outer card/button/badge/chip.
  Mirrors the existing invisible-container detector.

Phrased as a "keep these silent" pre-condition since the post-pass
also strips them. 1080/1080 AI tests + 234/234 pen-ai-skills tests
still pass.
2026-05-09 20:59:42 +08:00
Fini bc9f8d1c62 feat(ai): aesthetic detector — mixed-sibling-padding (mirror cornerRadius rule)
Why: continuation of the aesthetic detector series. Mirrors
detectMixedSiblingCornerRadius (53435bf7) for the padding axis. Three
cards with padding 16 / 16 / 20 looks ragged on canvas; the existing
sibling-inconsistency detector covers cards-vs-cards but dedupes
against cornerRadius and other props so the padding outlier
sometimes drops.

What: detectMixedSiblingPadding normalises padding values to a
4-tuple [top, right, bottom, left] before comparison, so
  padding: 16            → [16,16,16,16]
  padding: [12, 24]      → [12,24,12,24] (CSS 2-tuple shorthand)
  padding: [16,16,16,16] → [16,16,16,16]
all compare equal and don't trigger false positives. Modal value
collapses back to a scalar when all four sides are equal so the
suggested fix matches the model's preferred shorthand.

Same 60% modal-majority threshold as the cornerRadius detector —
1-1-1 three-way splits are skipped because there's no canonical value
to suggest. Same divider / spacer skip and same-type-and-role grouping.

Wired through detectAllIssues + index.ts exports + the
debug_validation_report MCP categories enum.

6 new tests cover: number shorthand outlier, number-vs-array
equivalence, 2-tuple-vs-4-tuple equivalence, 1-1-1 split skip,
mixed-role groups skipped, no-padding siblings excluded from modal.
57 / 57 diagnostics tests pass (was 51; +6).
2026-05-09 20:59:40 +08:00
Fini 3eacc8c9ac feat(ai): aesthetic detector — text-stroke (outlined UI labels)
Why: continuation of the aesthetic detector series. Outlined text on a
UI label is almost always an AI mistake — Lucide / SF / Material icons
get stroked, but body / heading / label text is filled. The model
occasionally copies a generic "give it a stroke" instruction onto text
nodes; on canvas the result reads as double-rendered glyphs. The
existing sibling-inconsistency detector doesn't catch this because
text stroke is rarely a sibling-by-sibling outlier — it's emitted
across the whole tree at once.

What:
- detectTextStroke added with the same shape as the other text-only
  aesthetic detectors (text node + property check + warning severity +
  suggestedValue undefined).
- Skips stroke.thickness === 0 (some model JSON keeps an empty stroke
  object as a placeholder; flagging that would be noise).
- Wired through detectAllIssues + index.ts public exports + the
  debug_validation_report MCP tool's categories enum.

Tests: 4 new positive + negative cases (text with stroke, text without
stroke, text with thickness=0 placeholder, frame with stroke). 51 / 51
diagnostics tests pass (was 47; +4); 228 / 228 pen-ai-skills overall.
2026-05-09 20:59:39 +08:00
Fini 6225e09f08 feat(ai): aesthetic detector — text-effect (shadow / blur on text labels)
Why: continuation of the aesthetic detector family added in 53435bf7.
The model frequently sprinkles \`effects: [{type:'shadow', …}]\` onto
body / label / caption text. On canvas the type goes fuzzy and reads
"AI-designed". Real product UIs use text shadows extremely sparingly
(hero overlays on photos, a few brand elements). Detection is cheap
(walk + isArray check) and the suggested fix (remove effects array)
is safe — text shadow on UI labels is almost never intentional.

What:
- detectTextEffect added to packages/pen-ai-skills/diagnostics with the
  same shape as the prior 3 (warning severity, suggestedValue undefined,
  reason string for logs).
- Wired through detectAllIssues + index.ts public exports + the
  debug_validation_report MCP tool's categories enum.

Tests: 5 new it() cases covering positive (shadow / blur on text),
negative (text without effects, empty effects array, frame with
effects), and tree-walk (multiple text effects in nested frames).
47 / 47 diagnostics tests pass (was 42; +5).
2026-05-09 20:59:38 +08:00
Fini 2faf79b5d1 feat(ai): aesthetic detectors — rotation / text-cornerRadius / mixed-sibling-cornerRadius
Why: user reports the validation pipeline lacks "aesthetic standards"
— it accepts misalignment, unwanted corner radius, and other visual
issues as "normal". Existing detectors are pure code-quality (invisible
container / empty path / text height / sibling inconsistency); they
don't catch design-system violations the user can see at a glance.
Vision validation does, but it only runs on Anthropic / Codex /
OpenCode / Gemini providers and only above 30 nodes — leaving a long
tail of small-design / builtin-provider runs with no aesthetic check
at all. Adding cheap pure-function detectors closes that gap with no
upstream provider dependency.

What: 3 new pure detectors in pen-ai-skills/diagnostics:

  - detectUnexpectedRotation — flags non-axis-aligned rotation on
    UI-bearing nodes (frame / text / shape). Skips path / line /
    polygon / image (legitimate decorative geometry frequently
    rotated), skips multiples of 90° (intentional vertical text /
    grid). Catches the "tilted card" hallucination cleanly.

  - detectTextCornerRadius — flags text nodes with cornerRadius > 0.
    Text isn't drawn into a clipped rectangle so the prop is silently
    dropped at render time, but it survives in the doc and burns
    LLM context on subsequent batch_get calls. Suggested fix: remove.

  - detectMixedSiblingCornerRadius — stricter than the existing
    sibling-inconsistency check on cornerRadius alone. Flags outliers
    when 2+ of 3 same-type-and-role siblings share a value and one
    differs (e.g. three cards with cornerRadius 8 / 8 / 12 reads as
    ragged on canvas). Skips 1-1-1 three-way splits (no canonical
    modal) and divider / spacer nodes (visual primitives).

All three are wired through detectAllIssues + the index.ts public
exports + the debug_validation_report MCP tool's `categories` enum so
the user / agent can opt-in or filter via `op debug_validation_report
--categories unexpected-rotation`.

35 new tests cover the load-bearing positive + negative cases for each
detector. 219/219 pen-ai-skills tests pass (was 184; +35). 1080/1080
AI service tests still pass.
2026-05-09 20:59:37 +08:00
Fini e3ee765d90 Merge branch 'v0.8.0' of github.com:ZSeven-W/openpencil into v0.8.0 2026-05-09 20:59:25 +08:00
Kayshen-X 3389ab456d 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 09:29:52 +08:00
Fini bb52ca6b71 feat(ai): rewrite icon-catalog skill + add transparent-section rule
Why: MiniMax-M2.7 food-app run rendered Header with white fill on cream
page bg, and used iconFontName=shopping-bag for Cart tab. Two skill-side
issues: icon-catalog.md was self-contradictory ("use path nodes" vs
"use icon_font"), and layout.md had no rule for inner-section bg.

What:
- icon-catalog.md rewritten as "ALWAYS USE icon_font, NEVER path NODES"
  with role→name map (Cart→shopping-cart not shopping-bag, Pizza→pizza,
  Sushi→fish via alias, etc) and food-category icon list appended.
- layout.md adds: interior section wrappers (Header, Search Section,
  Categories Section) MUST have fill:[] (transparent / inherit page bg);
  only opt into a fill when the section is intentionally a card with
  its own surface tone.
2026-05-09 07:15:00 +08:00
Fini d8764875a6 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 06:30:00 +08:00
Fini b2f6ae99ff Merge remote-tracking branch 'origin/v0.8.0' into v0.8.0 2026-05-08 22:33:12 +08:00
Kayshen-X 136274a3ec chore(vendor): bump jian submodule to d5d358e (Step 1b §3.2 P0.5A)
Picks up the keyboard/IME/focus event additions + W3C wheel deltaMode
landed in jian commit d5d358e. shell-core re-exports of the new types
land in the next commit; this commit only moves the pointer + Cargo.lock.

cargo test -p openpencil-shell-core --test gesture_re_export → 6/6 PASS
against the pinned submodule.
2026-05-08 22:03:08 +08:00
Fini 6c5a2c21c6 fix(ai): rank 4 builder + multi-page vision-validation hardening
ab-corpus rerun (gpt-5.5, ab-v3, 52 prompts × 2 arms): obvious-T M3
59.6% -> 91.5% (+31.9pp); composite-T 0% -> 40% (+40pp). Lift on top
of d5d1a8cd (Rank 1 schema coerce), 9e90cffe (Rank 2 prompt fail watch),
e34d9238 (Rank 3 vision toggle).

Builder fallback minima:
- chart-pie/line/bars-v1: values [1] -> [30,25,20,15,10] / [10,15,12,20,18]
  so chart-pie-slice (>=4) and chart-line-dot (>=7) corpus minima are met
- toolbar-v1: fallback items include a divider_after entry so toolbar-divider
  role emits even when the model passes only icons
- avatar-group-v1: entry-coerce items with 5 placeholder initials so the
  builder always emits avatar-group-{item,initial,overflow,overflow-count}
- combobox/data-table-row/share-row-v1: fallback arrays grown to 3 items
  matching the corpus shape minimums

Optional-content discipline (codex stop-time round 2):
- user-card-v1: name field fuzzy-coerce (required field, real fix for the
  "Element tool insert failed: I(null,...)" handler bug); the optional role
  text stays conditional, never invented. content empty-string was tried
  but rejected (empty text nodes still consume flex gap).
- image-placeholder-v1: label stays conditional for the same reason.

Prompt:
- elements.md fail-watch table extended with 5 components (chart legend,
  skeleton, inline-action, share-row, combobox) so models routing to
  batch_design at least know the role names.

Multi-page vision validation (codex stop-time round 1):
- design-validation.ts: countNodesInActivePage + buildNodeTreeDump now
  read getActivePageChildren(activePageId) instead of DEFAULT_FRAME_ID,
  so the size-gate and the LLM's tree dump both reflect the page the user
  is actually editing rather than the default page. Was a latent bug
  surfaced when VALIDATION_ENABLED flipped to true in e34d9238.

Tests: 4223/4223 pass; format:check + tsc clean. 12 files changed.
2026-05-08 15:00:00 +08:00
Fini f7f9226599 feat(ai-skills): elements.md missing-role fail watch table (Rank 2)
ab-v8 obvious-T 40 fails matched "missing required role(s)" — model
went batch_design fallback rather than the matching add_*_v1 tool, and
forgot the role names the validator checks. Surface the top 12
fail-mode component-to-tool mappings + their explicit role names at
the top of elements.md (was previously buried 400 lines down in the
keyword section).

Components covered: modal-shell, avatar-group, metric-comparison,
image-placeholder, tag, toolbar, callout, profile-header, inbox-message,
drawer-shell, cookie-banner, user-card.

Even if the model still insists on batch_design (no v1 fits), the
explicit role list helps it emit the correct role strings on each
child node.

Tests: 84/84 pen-ai-skills pass; format:check + tsc clean; skill
budget under 2400 tokens unchanged.

Predicted KPI lift: M3 obvious-T +3-5pp on top of Rank 1's +6pp.
Recovers ~1/3 of the 40 missing-role fails on stronger models
(deepseek/gpt-5.5); weaker models (kimi/minimax) still need the
vision-feedback loop in Rank 3.
2026-05-08 08:15:00 +08:00
Fini 9513b4692d feat(ai): v1 builder fuzzy-coerce hallucinated params (Rank 1)
ab-v8 KPI showed ~14/95 obvious-T fail = v1 schema throw on hallucinated
enum values (tone='info') / missing required arrays (params.columns
undefined). Builder rejected the whole tool call instead of degrading.

Replace throw paths in 12 v1 builders + entry-coerce in 4 builders that
directly accessed params.X.map() / .forEach():

- chart-{pie,line,bars}-v1: coerceNumberArray fallback [1]
- tag-v1 / heading-v1 / callout-v1 / member-row-v1 / invite-row-v1 /
  activity-log-v1: coerceEnum fallback to schema default
- timeline-v1 / social-login-row-v1: coerceNonEmptyArray with placeholder
- kbd-v1: coerceStringArray fallback ['?']
- data-table-row-v1 / combobox-v1 / toolbar-v1 / share-row-v1: entry
  coerceNonEmptyArray (no prior throw, but params.X.map() crashed on
  undefined input)

New helper packages/pen-core/src/element-builders/coerce-params.ts with
five primitives (coerceEnum, coerceNonEmptyArray, coerceNumberArray,
coerceStringArray, coerceNonEmptyString) + process-global warning sink
for orchestrators to surface coercions to the LLM.

v0 builders unchanged (byte-parity contract still holds — verified by
existing parity tests).

3 pen-core tests + 1 pen-mcp test updated: previously asserted toThrow
on invalid input -> now assert coerce success + warning emission.

Tests: 4223/4223 pass; format:check + tsc clean.

Predicted KPI lift: M3 obvious-T 58% -> ~64% (~14/235 schema-throw fail
recovered; ~6pp). Composite-T unchanged (composite fail is routing/
parse, not schema — verified by ab-v8 raw analysis).
2026-05-08 08:00:00 +08:00
Fini c81cfe82a4 Merge branch 'v0.8.0' of github.com:ZSeven-W/openpencil into v0.8.0
# Conflicts:
#	.github/workflows/rust-multiplatform.yml
#	README.md
#	crates/openpencil-shell-core/src/lib.rs
#	crates/openpencil-shell-native/examples/basic_window.rs
#	crates/openpencil-shell-native/src/lib.rs
2026-05-05 22:51:00 +08:00
Kayshen-X c55807e432 ci: remove TS/Electron workflows (build-electron / ci / docker / publish-cli)
Rust-ification 阶段,CI 只保留 Rust 相关:
- rust-check.yml: cargo fmt + build + test (with STEP1A_REQUIRE_GPU=1 on Linux) + clippy + cargo-deny
- wasm-bundle-check.yml: wasm32 target check

删除:
- build-electron.yml: Electron desktop build (Rust 化后用 openpencil-shell-native)
- ci.yml: TS type-check + Vitest + web build (Rust 化后已废)
- docker.yml: TS Docker image (Rust 化后重做)
- publish-cli.yml: npm packages (Rust 化后改 cargo publish)
2026-05-05 22:09:00 +08:00