fix(ai): drop redundant section-level fills from sub-agent output

Sub-agents (MiniMax M2 especially) hedge by hardcoding a "safe dark" hex
(#0A0A0A, #111, etc.) on every section root they emit. That fill then
completely covers the page root's intended background color —
#1a1a2e in the health-tracker case — breaking theme switching and
creating visible seams between sections.

Two-layer fix:

1. Prompt guardrail in orchestrator-sub-agent.ts — tell the sub-agent NOT
   to set `fill` on its section root, and show the actual inherited
   background color so the model has no reason to hedge.
2. Post-pass cleanup stripRedundantSectionFills in pen-core — for each
   direct child of the page root frame, drop the fill if:
   - the child has no role or a structural role (section/row/column/
     stack/container/hero/footer/cta-section/etc.), AND
   - the fill matches the root fill exactly OR is one of the common
     "safe dark" hexes sub-agents reach for.
   Cards, buttons, chips, badges, inputs, phone mockups, status bars,
   banners and other protected roles are never touched. Unknown roles
   are also preserved (conservative default).

The pass is wired into both applyPostStreamingTreeHeuristics (streaming
path, runs on the outer parent frame via getParentOf so each sub-agent's
section root is visible at the strip scope) and sanitizeNodesForInsert/
Upsert (batch path). All mutations are covered by the existing
forcePageResync call so the canvas re-renders without a stale frame.
This commit is contained in:
Fini 2026-04-06 18:14:09 +08:00
parent 716cb074bf
commit b4d35bb185
6 changed files with 428 additions and 1 deletions

View file

@ -14,4 +14,5 @@ export {
estimateLineWidth,
normalizeTreeLayout,
unwrapFakePhoneMockups,
stripRedundantSectionFills,
} from '@zseven-w/pen-core';

View file

@ -14,7 +14,11 @@ import {
estimateNodeIntrinsicHeight,
} from './generation-utils';
import { defaultLineHeight } from '@/canvas/canvas-text-measure';
import { normalizeTreeLayout, unwrapFakePhoneMockups } from '@/canvas/canvas-layout-engine';
import {
normalizeTreeLayout,
unwrapFakePhoneMockups,
stripRedundantSectionFills,
} from '@/canvas/canvas-layout-engine';
import { forcePageResync } from '@/canvas/canvas-sync-utils';
import {
applyIconPathResolution,
@ -576,6 +580,26 @@ export function applyPostStreamingTreeHeuristics(rootNodeId: string): void {
// before role defaults can override them.
normalizeTreeLayout(freshRoot);
// Strip redundant section-level fills. Weaker sub-agents hedge by
// hardcoding a "safe dark" hex (e.g. #0A0A0A) on every section root they
// emit, which then completely covers the page root's intended background
// and breaks theme switching. This pass drops those redundant fills while
// preserving cards/buttons/badges. Must run AFTER role resolution so we
// can tell section containers apart from card/button/chip components by
// their resolved role.
//
// The sub-agent's root frame is itself a direct child of the OUTER page
// root frame (DEFAULT_FRAME_ID or the generation root), so we run the
// strip pass on that parent — that's the level where section fills live.
// We also run it on freshRoot itself to catch sub-agents that build an
// entire page inside a single sub-task (where the "sections" are freshRoot's
// own children).
const parentOfRoot = useDocumentStore.getState().getParentOf(rootNodeId);
if (parentOfRoot && parentOfRoot.type === 'frame') {
stripRedundantSectionFills(parentOfRoot);
}
stripRedundantSectionFills(freshRoot);
// Publish point. unwrap, resolveTreeRoles, and normalizeTreeLayout all
// mutate store-owned nodes in place; resolveTreePostPass mostly goes
// through updateNode but also has direct-mutation branches. Without an
@ -762,6 +786,9 @@ function sanitizeNodesForInsert(nodes: PenNode[], existingIds: Set<string>): Pen
resolveTreeRoles(node, generationCanvasWidth);
applyGenerationHeuristics(node);
normalizeTreeLayout(node);
// Drop redundant section-level fills after role resolution so cards
// and buttons (which must keep their fill) are correctly identified.
stripRedundantSectionFills(node);
sanitizeLayoutChildPositions(node, false);
sanitizeScreenFrameBounds(node);
}
@ -788,6 +815,9 @@ function sanitizeNodesForUpsert(nodes: PenNode[]): PenNode[] {
resolveTreeRoles(node, generationCanvasWidth);
applyGenerationHeuristics(node);
normalizeTreeLayout(node);
// Drop redundant section-level fills after role resolution so cards
// and buttons (which must keep their fill) are correctly identified.
stripRedundantSectionFills(node);
sanitizeLayoutChildPositions(node, false);
sanitizeScreenFrameBounds(node);
}

View file

@ -394,6 +394,11 @@ function buildSubAgentUserPrompt(
? `\nYOUR ELEMENTS: ${subtask.elements}\nDo NOT generate elements listed in other sections — they handle their own content.`
: '';
const rootBgColor = extractRootFrameFillColor(plan);
const rootBgHint = rootBgColor
? `The page root frame already has background color ${rootBgColor} — your section inherits it.`
: `The page root frame already carries the background color — your section inherits it.`;
let prompt = `Page sections:\n${sectionList}\n\nGenerate ONLY "${subtask.label}" (~${region.height}px of content).${myElements}\n${compactPrompt}
CRITICAL LAYOUT CONSTRAINTS:
@ -404,6 +409,7 @@ CRITICAL LAYOUT CONSTRAINTS:
- Use "fill_container" for children that stretch, "fit_content" for shrink-wrap sizing.
- Use justifyContent="space_between" to distribute items (e.g. navbar: logo | links | CTA). Use padding=[0,80] for horizontal page margins.
- For side-by-side layouts, nest a horizontal frame with child frames using "fill_container" width.
- SECTION BACKGROUND: do NOT set \`fill\` on your section root frame. ${rootBgHint} Hardcoding a "safe dark" fill (e.g. #000 / #0A0A0A / #111) will cover the intended background and break theme switching. Only set \`fill\` on cards, buttons, chips, badges, and other visually distinct components — never on the section container itself.
- IDs prefix="${subtask.idPrefix}-". No <step> tags. Output \`\`\`json immediately.`;
// Phone mockup guidance is only relevant when this subtask is actually
@ -542,6 +548,20 @@ function needsHeroPhoneTwoColumnInstruction(
return heroLike && phoneLike;
}
/**
* Pull the first solid fill color off the plan's root frame, if any.
* Used in the sub-agent prompt so the model sees the actual background
* color it is inheriting and has no excuse to hedge with a hardcoded
* "safe dark" fill on its own section root.
*/
function extractRootFrameFillColor(plan: OrchestratorPlan): string | null {
const fill = plan.rootFrame?.fill;
if (!fill || !Array.isArray(fill) || fill.length === 0) return null;
const first = fill[0] as { type?: string; color?: string };
if (first?.type === 'solid' && typeof first.color === 'string') return first.color;
return null;
}
/**
* Decide whether this sub-agent should see the phone mockup style guide.
*

View file

@ -0,0 +1,228 @@
import { describe, it, expect } from 'vitest';
import type { PenNode } from '@zseven-w/pen-types';
import { stripRedundantSectionFills } from '../layout/strip-redundant-section-fills';
const frame = (props: Partial<PenNode> & { children?: PenNode[] }): PenNode =>
({
id: 'f1',
type: 'frame',
...props,
}) as PenNode;
const solidFill = (color: string) => [{ type: 'solid' as const, color }];
describe('stripRedundantSectionFills', () => {
it('strips a section fill that exactly matches the root fill', () => {
const section = frame({
id: 'sec1',
name: 'Section',
fill: solidFill('#1a1a2e'),
children: [frame({ id: 'child' })],
});
const root = frame({
id: 'root',
fill: solidFill('#1a1a2e'),
children: [section],
});
const changed = stripRedundantSectionFills(root);
expect(changed).toBe(true);
expect((section as PenNode & { fill?: unknown }).fill).toBeUndefined();
});
it('strips a section fill that matches a common safe-dark tint', () => {
// Root has #1a1a2e (deep navy), section has #0A0A0A (near-black safe
// dark) — the classic M2.7 failure where the model picks a "safe"
// dark for every section root, hiding the intended root background.
const section = frame({
id: 'sec1',
name: 'Activity Rings Section',
fill: solidFill('#0A0A0A'),
children: [frame({ id: 'child' })],
});
const root = frame({
id: 'root',
fill: solidFill('#1a1a2e'),
children: [section],
});
stripRedundantSectionFills(root);
expect((section as PenNode & { fill?: unknown }).fill).toBeUndefined();
});
it('does not strip fill from a card (cards own their visual fill)', () => {
const card = frame({
id: 'card1',
name: 'Stat Card',
role: 'card',
fill: solidFill('#0A0A0A'),
cornerRadius: 12,
children: [frame({ id: 'child' })],
});
const root = frame({
id: 'root',
fill: solidFill('#1a1a2e'),
children: [card],
});
const changed = stripRedundantSectionFills(root);
expect(changed).toBe(false);
expect((card as PenNode & { fill?: unknown }).fill).toEqual(solidFill('#0A0A0A'));
});
it('does not strip fill from a button', () => {
const button = frame({
id: 'btn',
name: 'CTA Button',
role: 'button',
fill: solidFill('#0A0A0A'),
children: [frame({ id: 'label' })],
});
const root = frame({
id: 'root',
fill: solidFill('#1a1a2e'),
children: [button],
});
stripRedundantSectionFills(root);
expect((button as PenNode & { fill?: unknown }).fill).toEqual(solidFill('#0A0A0A'));
});
it('does not strip fill from a badge or chip', () => {
const badge = frame({
id: 'bd',
name: 'Badge',
role: 'badge',
fill: solidFill('#0A0A0A'),
});
const chip = frame({
id: 'ch',
name: 'Chip',
role: 'chip',
fill: solidFill('#0A0A0A'),
});
const root = frame({
id: 'root',
fill: solidFill('#1a1a2e'),
children: [badge, chip],
});
stripRedundantSectionFills(root);
expect((badge as PenNode & { fill?: unknown }).fill).toEqual(solidFill('#0A0A0A'));
expect((chip as PenNode & { fill?: unknown }).fill).toEqual(solidFill('#0A0A0A'));
});
it('does not strip a fill that is clearly distinct from root (intentional)', () => {
// #FF5733 is nothing like root's #1a1a2e and is not a safe-dark — it
// is probably a deliberate accent / hero section. Leave it.
const hero = frame({
id: 'hero',
name: 'Hero Section',
fill: solidFill('#FF5733'),
children: [frame({ id: 'headline' })],
});
const root = frame({
id: 'root',
fill: solidFill('#1a1a2e'),
children: [hero],
});
const changed = stripRedundantSectionFills(root);
expect(changed).toBe(false);
expect((hero as PenNode & { fill?: unknown }).fill).toEqual(solidFill('#FF5733'));
});
it('strips fills from multiple sections in one pass', () => {
const section1 = frame({ id: 's1', fill: solidFill('#0A0A0A') });
const section2 = frame({ id: 's2', fill: solidFill('#0A0A0A') });
const section3 = frame({ id: 's3', fill: solidFill('#0A0A0A') });
const root = frame({
id: 'root',
fill: solidFill('#1a1a2e'),
children: [section1, section2, section3],
});
stripRedundantSectionFills(root);
expect((section1 as PenNode & { fill?: unknown }).fill).toBeUndefined();
expect((section2 as PenNode & { fill?: unknown }).fill).toBeUndefined();
expect((section3 as PenNode & { fill?: unknown }).fill).toBeUndefined();
});
it('does not touch deeply nested frames inside a section', () => {
// Only direct children of the root are considered "section level". A
// card nested three levels deep with the same color should be left
// alone — it is not a top-level section.
const deepCard = frame({
id: 'deep-card',
role: 'card',
fill: solidFill('#0A0A0A'),
});
const middle = frame({ id: 'middle', children: [deepCard] });
const section = frame({
id: 'section',
fill: solidFill('#0A0A0A'),
children: [middle],
});
const root = frame({
id: 'root',
fill: solidFill('#1a1a2e'),
children: [section],
});
stripRedundantSectionFills(root);
// Section (direct child) is stripped
expect((section as PenNode & { fill?: unknown }).fill).toBeUndefined();
// Deep card is left alone
expect((deepCard as PenNode & { fill?: unknown }).fill).toEqual(solidFill('#0A0A0A'));
});
it('returns false when there is nothing to strip', () => {
const root = frame({
id: 'root',
fill: solidFill('#1a1a2e'),
children: [
frame({ id: 's1' }), // no fill
frame({
id: 'card1',
role: 'card',
fill: solidFill('#0A0A0A'), // card protected
}),
],
});
const changed = stripRedundantSectionFills(root);
expect(changed).toBe(false);
});
it('handles a root frame without a fill (treats only safe-dark sections)', () => {
// Root has no fill; we still strip sections that carry a safe-dark
// "default" fill, because those are almost certainly the sub-agent
// hedging against a missing background spec.
const section = frame({
id: 'sec',
fill: solidFill('#0A0A0A'),
});
const root = frame({
id: 'root',
children: [section],
});
stripRedundantSectionFills(root);
expect((section as PenNode & { fill?: unknown }).fill).toBeUndefined();
});
it('reproduces the M2.7 health-tracker case', () => {
// Direct repro of the actual failure: root #1a1a2e, six section roots
// all hardcoded #0A0A0A, including one real card. The six section
// fills get stripped, the card keeps its fill.
const root = frame({
id: 'root-frame',
name: 'Health Dashboard',
fill: solidFill('#1a1a2e'),
children: [
frame({ id: 'header-root', name: 'Greeting Header', fill: solidFill('#0A0A0A') }),
frame({ id: 'activityRings-root', name: 'Activity Rings Section', fill: solidFill('#0A0A0A') }),
frame({ id: 'heartRate-root', name: 'Heart Rate Card Section', fill: solidFill('#0A0A0A') }),
frame({ id: 'workoutChart-root', name: 'Weekly Workout Chart', fill: solidFill('#0A0A0A') }),
frame({ id: 'upcomingWorkouts-root', name: 'Upcoming Workouts', fill: solidFill('#0A0A0A') }),
frame({ id: 'bottomNav-root', name: 'Bottom Tab Bar', fill: solidFill('#0A0A0A') }),
],
});
const changed = stripRedundantSectionFills(root);
expect(changed).toBe(true);
const kids = (root as PenNode & { children: PenNode[] }).children;
for (const section of kids) {
expect((section as PenNode & { fill?: unknown }).fill).toBeUndefined();
}
});
});

View file

@ -58,6 +58,7 @@ export {
} from './layout/engine.js';
export { normalizeTreeLayout } from './layout/normalize-tree.js';
export { unwrapFakePhoneMockups } from './layout/unwrap-fake-phone-mockup.js';
export { stripRedundantSectionFills } from './layout/strip-redundant-section-fills.js';
// Text measurement
export {

View file

@ -0,0 +1,147 @@
import type { PenNode, PenFill, SolidFill } from '@zseven-w/pen-types';
/**
* Strip redundant "section-level" fills from direct children of the page
* root frame.
*
* Weaker sub-agents (MiniMax M2, GLM, Kimi) often hedge by writing a
* hardcoded dark hex (`#0A0A0A`, `#111`, etc.) on every section root they
* produce. That hex then completely covers the page root's intended
* background color, breaking theme switching and creating visible seams
* between sections. Cards, buttons, chips and other legitimately filled
* components are NOT affected only "section container" frames (direct
* children of the root that either have no role or have a structural
* role).
*
* Returns `true` when any fill was stripped, so the caller can publish a
* store update.
*/
export function stripRedundantSectionFills(rootFrame: PenNode): boolean {
if (!('children' in rootFrame) || !Array.isArray(rootFrame.children)) return false;
const rootFill = getFirstSolidColor(rootFrame);
let changed = false;
for (const child of rootFrame.children) {
if (child.type !== 'frame') continue;
if (!isSectionLevelFrame(child)) continue;
const childFill = getFirstSolidColor(child);
if (!childFill) continue;
if (shouldStripFill(childFill, rootFill)) {
delete (child as PenNode & { fill?: unknown }).fill;
changed = true;
}
}
return changed;
}
/**
* Roles that identify visually distinct components and must never have
* their fill stripped.
*/
const PROTECTED_ROLES = new Set([
'card',
'stat-card',
'pricing-card',
'feature-card',
'image-card',
'testimonial',
'button',
'icon-button',
'badge',
'chip',
'tag',
'pill',
'input',
'form-input',
'search-bar',
'phone-mockup',
'banner',
'metric-card',
'gallery-item',
'status-bar',
]);
/**
* Roles that are considered structural just a container grouping other
* nodes. These are candidates for fill stripping when they echo the root
* background or hedge with a safe-dark fill.
*/
const STRUCTURAL_ROLES = new Set([
'section',
'row',
'column',
'stack',
'container',
'content-area',
'section-header',
'wrapper',
'group',
'hero',
'footer',
'cta-section',
'stats-section',
]);
function isSectionLevelFrame(node: PenNode): boolean {
const role = (node as PenNode & { role?: string }).role;
if (!role) return true; // unrolled section root
if (PROTECTED_ROLES.has(role)) return false;
if (STRUCTURAL_ROLES.has(role)) return true;
// Unknown role: be conservative, treat as protected so we don't clobber
// future role additions.
return false;
}
/**
* Hex tints that sub-agents reach for when they want a "safe dark"
* background without knowing the real design background color. Any of
* these on a section container is almost certainly a hedge, not an
* intentional visual choice.
*/
const SAFE_DARK_HEXES = new Set([
'#000000',
'#000',
'#0a0a0a',
'#0f0f0f',
'#111',
'#111111',
'#121212',
'#141414',
'#1a1a1a',
'#181818',
'#1c1c1c',
'#1e1e1e',
'#202020',
]);
function shouldStripFill(childFill: string, rootFill: string | null): boolean {
const childKey = normalizeHex(childFill);
if (rootFill) {
const rootKey = normalizeHex(rootFill);
if (childKey === rootKey) return true;
}
return SAFE_DARK_HEXES.has(childKey);
}
function normalizeHex(color: string): string {
let c = color.trim().toLowerCase();
// Strip alpha if present (#rrggbbaa → #rrggbb)
if (c.length === 9 && c.startsWith('#')) c = c.slice(0, 7);
return c;
}
function getFirstSolidColor(node: PenNode): string | null {
const fill = (node as PenNode & { fill?: PenFill[] | string }).fill;
if (!fill) return null;
if (typeof fill === 'string') return fill;
if (!Array.isArray(fill) || fill.length === 0) return null;
const first = fill[0];
if (first && first.type === 'solid') {
return (first as SolidFill).color;
}
return null;
}