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.
This commit is contained in:
Fini 2026-05-09 20:59:54 +08:00
parent fef986098e
commit 828f09abda
2 changed files with 158 additions and 1 deletions

View file

@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import type { PenNode } from '@zseven-w/pen-types';
import type { PenNode, PenFill } from '@zseven-w/pen-types';
import { stripRedundantSectionFills } from '../layout/strip-redundant-section-fills';
const frame = (props: Partial<PenNode> & { children?: PenNode[] }): PenNode =>
@ -535,4 +535,111 @@ describe('stripRedundantSectionFills', () => {
expect((section as PenNode & { fill?: unknown }).fill).toBeUndefined();
}
});
// Container-role wrapper detection (2026-05-10 user report).
// The food-app "Featured" section landed with a black bg on a cream
// page because the model marked the wrapper role='card' AND the wrapper
// held 3 child frames each with role='card'. The original PROTECTED_ROLES
// gate kept the outer black bg untouched (cards legitimately have fills).
// The new hasMultipleSameRoleChildren branch identifies the misroll and
// strips the hedge fill — the 3 inner restaurant cards keep their own
// fills.
it("strips a 'card' wrapper that holds 2+ same-role children (Featured-block misroll)", () => {
const innerCard1 = frame({
id: 'r1',
name: 'Bella Napoli',
role: 'card',
fill: solidFill('#FFFFFF'),
});
const innerCard2 = frame({
id: 'r2',
name: 'Burger House',
role: 'card',
fill: solidFill('#FFFFFF'),
});
const innerCard3 = frame({
id: 'r3',
name: 'Sakura Sushi',
role: 'card',
fill: solidFill('#FFFFFF'),
});
const featured = frame({
id: 'featured',
name: 'Featured',
role: 'card', // misroll — actually a section wrapper
fill: solidFill('#000000'),
children: [innerCard1, innerCard2, innerCard3],
});
const root = frame({
id: 'root',
fill: solidFill('#FFF8F0'), // cream page bg
children: [featured],
});
const changed = stripRedundantSectionFills(root);
expect(changed).toBe(true);
// Wrapper's fill stripped — section now inherits page bg
expect((featured as PenNode & { fill?: unknown }).fill).toBeUndefined();
// Inner cards preserved — their own surface fills are intentional
expect((innerCard1 as PenNode & { fill?: PenFill[] }).fill).toEqual(solidFill('#FFFFFF'));
expect((innerCard2 as PenNode & { fill?: PenFill[] }).fill).toEqual(solidFill('#FFFFFF'));
expect((innerCard3 as PenNode & { fill?: PenFill[] }).fill).toEqual(solidFill('#FFFFFF'));
});
it('does NOT strip a real card holding ONE same-role child (e.g. card with badge inside)', () => {
// 1 child of the same role doesn't trigger — needs ≥ 2.
const innerBadge = frame({
id: 'b',
role: 'badge',
fill: solidFill('#DBEAFE'),
});
const card = frame({
id: 'c',
role: 'card',
fill: solidFill('#000000'),
children: [innerBadge],
});
const root = frame({
id: 'root',
fill: solidFill('#FFF8F0'),
children: [card],
});
const changed = stripRedundantSectionFills(root);
// Card has no nested SAME-role child, so the wrapper detection is silent;
// the strip-by-safe-dark-hex still does NOT fire because role='card' is
// PROTECTED. The card surface stays.
expect(changed).toBe(false);
expect((card as PenNode & { fill?: PenFill[] }).fill).toEqual(solidFill('#000000'));
});
it('strips a banner wrapper holding 2+ same-role banners', () => {
const banner1 = frame({
id: 'b1',
name: 'Promo 1',
role: 'banner',
fill: solidFill('#FF6B35'),
});
const banner2 = frame({
id: 'b2',
name: 'Promo 2',
role: 'banner',
fill: solidFill('#10B981'),
});
const wrapper = frame({
id: 'w',
role: 'banner',
fill: solidFill('#0A0A0A'),
children: [banner1, banner2],
});
const root = frame({
id: 'root',
fill: solidFill('#FFF8F0'),
children: [wrapper],
});
const changed = stripRedundantSectionFills(root);
expect(changed).toBe(true);
expect((wrapper as PenNode & { fill?: unknown }).fill).toBeUndefined();
// Inner banners keep their own brand fills
expect((banner1 as PenNode & { fill?: PenFill[] }).fill).toEqual(solidFill('#FF6B35'));
expect((banner2 as PenNode & { fill?: PenFill[] }).fill).toEqual(solidFill('#10B981'));
});
});

View file

@ -156,6 +156,15 @@ function isSectionLevelFrame(node: PenNode): boolean {
// Container(role=input). The outer "search-bar" is just a section
// wrapper. Treat as section-level so its hedge fill can be stripped.
if (hasNestedFilledComponent(node, role)) return true;
// Container-component wrapper detection: a 'card' / 'banner' /
// 'pricing-card' / etc. that contains 2+ children with the SAME
// container role is the "Featured wrapper holding 3 restaurant
// cards" pattern from the 2026-05-10 user report. The outer is a
// section wrapper that the model misrolled; its safe-dark hedge
// fill (black Featured bg on a cream page) needs stripping just
// like any structural section wrapper. Container-roles only —
// atomics already handled by hasNestedFilledComponent above.
if (hasMultipleSameRoleChildren(node, role)) return true;
return false;
}
if (STRUCTURAL_ROLES.has(role)) return true;
@ -164,6 +173,47 @@ function isSectionLevelFrame(node: PenNode): boolean {
return false;
}
/**
* Container-role wrapper detector: a frame with role=X is treated as a
* section wrapper when ≥ 2 of its children carry the SAME role X. The
* food-app "Featured" black-bg block matched this — it had role='card'
* and held 3 restaurant cards each with role='card'. A real card very
* rarely contains other cards as siblings (a card-of-cards is a list
* pattern, which models render as a section, not a nested card).
*
* Limited to CONTAINER roles in PROTECTED_ROLES (card / banner /
* stat-card / pricing-card / feature-card / image-card / testimonial /
* metric-card / gallery-item / phone-mockup) — atomics are handled by
* hasNestedFilledComponent above and have different "what counts as a
* misroll" semantics.
*/
const CONTAINER_PROTECTED_ROLES = new Set([
'card',
'stat-card',
'pricing-card',
'feature-card',
'image-card',
'testimonial',
'banner',
'metric-card',
'gallery-item',
'phone-mockup',
]);
function hasMultipleSameRoleChildren(node: PenNode, parentRole: string): boolean {
if (!CONTAINER_PROTECTED_ROLES.has(parentRole)) return false;
if (!('children' in node) || !Array.isArray(node.children)) return false;
let sameRoleCount = 0;
for (const child of node.children) {
const childRole = (child as PenNode & { role?: string }).role;
if (childRole === parentRole) {
sameRoleCount++;
if (sameRoleCount >= 2) return true;
}
}
return false;
}
/**
* True when a frame is the outer wrapper of a misrolled component pattern:
*