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).
This commit is contained in:
Fini 2026-05-09 20:59:55 +08:00
parent 828f09abda
commit 40dfc55c95
5 changed files with 202 additions and 2 deletions

View file

@ -10,6 +10,7 @@ import {
detectTextEffect,
detectTextStroke,
detectMixedSiblingPadding,
detectExcessiveFrameEffects,
detectAllIssues,
} from '../diagnostics/detectors';
import type { PenNode, PenDocument } from '@zseven-w/pen-types';
@ -1059,3 +1060,132 @@ describe('detectMixedSiblingPadding', () => {
expect(detectMixedSiblingPadding(root)).toHaveLength(0);
});
});
describe('detectExcessiveFrameEffects', () => {
it('flags spread > 0 (the "spiked / bleeding" shadow on a badge)', () => {
const root = {
id: 'r',
type: 'frame',
children: [
{
id: 'badge',
type: 'frame',
name: 'Mexican',
effects: [{ type: 'shadow', offsetX: 0, offsetY: 0, blur: 8, spread: 4 }],
children: [],
} as unknown as PenNode,
],
} as unknown as PenNode;
const issues = detectExcessiveFrameEffects(root);
expect(issues).toHaveLength(1);
expect(issues[0].nodeId).toBe('badge');
expect(issues[0].suggestedValue).toBeUndefined();
expect(issues[0].reason).toMatch(/spread/);
});
it('flags blur > 40 (atypical halo)', () => {
const root = {
id: 'r',
type: 'frame',
children: [
{
id: 'card',
type: 'frame',
effects: [{ type: 'shadow', blur: 64, spread: 0 }],
children: [],
} as unknown as PenNode,
],
} as unknown as PenNode;
const issues = detectExcessiveFrameEffects(root);
expect(issues).toHaveLength(1);
expect(issues[0].reason).toMatch(/blur/);
});
it('does NOT flag blur exactly 40 (modal-shell scrim is legitimate)', () => {
const root = {
id: 'r',
type: 'frame',
children: [
{
id: 'modal-bg',
type: 'frame',
effects: [{ type: 'shadow', blur: 40, spread: 0 }],
children: [],
} as unknown as PenNode,
],
} as unknown as PenNode;
expect(detectExcessiveFrameEffects(root)).toHaveLength(0);
});
it('flags 3+ stacked effects on one frame', () => {
const root = {
id: 'r',
type: 'frame',
children: [
{
id: 'card',
type: 'frame',
effects: [
{ type: 'shadow', blur: 8 },
{ type: 'shadow', blur: 4 },
{ type: 'blur', radius: 2 },
],
children: [],
} as unknown as PenNode,
],
} as unknown as PenNode;
const issues = detectExcessiveFrameEffects(root);
expect(issues).toHaveLength(1);
expect(issues[0].reason).toMatch(/3 stacked|stacked effects/);
});
it('does NOT flag a typical subtle shadow (blur=8, spread=0, single)', () => {
const root = {
id: 'r',
type: 'frame',
children: [
{
id: 'card',
type: 'frame',
effects: [
{
type: 'shadow',
offsetX: 0,
offsetY: 4,
blur: 8,
spread: 0,
color: 'rgba(0,0,0,0.1)',
},
],
children: [],
} as unknown as PenNode,
],
} as unknown as PenNode;
expect(detectExcessiveFrameEffects(root)).toHaveLength(0);
});
it('does NOT flag a frame without effects', () => {
const root = {
id: 'r',
type: 'frame',
children: [{ id: 'plain', type: 'frame', children: [] } as unknown as PenNode],
} as unknown as PenNode;
expect(detectExcessiveFrameEffects(root)).toHaveLength(0);
});
it('does NOT flag text nodes (text-effect detector handles those)', () => {
const root = {
id: 'r',
type: 'frame',
children: [
{
id: 't',
type: 'text',
content: 'hi',
effects: [{ type: 'shadow', spread: 8 }],
} as unknown as PenNode,
],
} as unknown as PenNode;
expect(detectExcessiveFrameEffects(root)).toHaveLength(0);
});
});

View file

@ -684,7 +684,73 @@ export function detectMixedSiblingPadding(root: PenNode): Issue[] {
}
/**
* Run all 10 detectors and return the deduplicated combined issue list.
* Aesthetic detector: frame node with shadow/blur effects whose parameters
* are outside typical UI ranges. Real product UI shadows are tight and
* subtle — blur 4-16, no positive spread, near-black low-alpha color. The
* AI hallucination signature is one of:
* - blur >= 40 (too soft, looks like a glow / halo)
* - spread > 0 (a positive spread "bleeds" the shadow outward and reads
* as a colored bloom around the element — the "带尖的背景阴影"
* ("pointy / spiked background shadow") the 2026-05-10 user report
* called out on the Mexican badge)
* - 3+ effects on one frame (badges / cards rarely stack 3+ shadows)
*
* Suggested fix is to remove the effects array; the user / agent can
* re-add a proper shadow afterwards if intentional.
*/
export function detectExcessiveFrameEffects(root: PenNode): Issue[] {
const issues: Issue[] = [];
// Threshold is "strictly greater than" — modal-shell + similar
// production builders legitimately use blur=40 for the modal scrim.
// A blur of 41+ is the AI-hallucination signature.
const TYPICAL_BLUR_MAX = 40;
function walk(node: PenNode): void {
if (node.type === 'frame') {
const eff = (node as unknown as { effects?: unknown }).effects;
if (Array.isArray(eff) && eff.length > 0) {
let suspect = false;
let reason = '';
if (eff.length >= 3) {
suspect = true;
reason = `${eff.length} stacked effects on one frame (typical UI uses 0-2)`;
} else {
for (const e of eff) {
const r = e as { blur?: unknown; spread?: unknown; type?: unknown };
if (typeof r.blur === 'number' && r.blur > TYPICAL_BLUR_MAX) {
suspect = true;
reason = `effect blur ${r.blur} > ${TYPICAL_BLUR_MAX} (glow / halo, atypical for UI)`;
break;
}
if (typeof r.spread === 'number' && r.spread > 0) {
suspect = true;
reason = `effect spread ${r.spread} > 0 (bleeds outward, "spiked" appearance)`;
break;
}
}
}
if (suspect) {
issues.push({
nodeId: node.id,
category: 'excessive-frame-effects',
severity: 'warning',
property: 'effects',
currentValue: eff,
suggestedValue: undefined,
reason,
});
}
}
}
if ('children' in node && Array.isArray(node.children)) {
for (const c of node.children) walk(c);
}
}
walk(root);
return issues;
}
/**
* Run all 11 detectors and return the deduplicated combined issue list.
* Dedup key: `${nodeId}:${property}` (matches runPreValidationFixes).
* On collision, the first issue wins (detector execution order below).
*/
@ -700,6 +766,7 @@ export function detectAllIssues(root: PenNode, doc: PenDocument): Issue[] {
...detectTextEffect(root),
...detectTextStroke(root),
...detectMixedSiblingPadding(root),
...detectExcessiveFrameEffects(root),
];
const seen = new Set<string>();
const unique: Issue[] = [];

View file

@ -11,5 +11,6 @@ export {
detectTextEffect,
detectTextStroke,
detectMixedSiblingPadding,
detectExcessiveFrameEffects,
detectAllIssues,
} from './detectors';

View file

@ -10,7 +10,8 @@ export type IssueCategory =
| 'mixed-sibling-corner-radius'
| 'text-effect'
| 'text-stroke'
| 'mixed-sibling-padding';
| 'mixed-sibling-padding'
| 'excessive-frame-effects';
export interface Issue {
/** Node id where the issue was detected */

View file

@ -45,6 +45,7 @@ export const DEBUG_TOOL_DEFINITIONS = [
'text-effect',
'text-stroke',
'mixed-sibling-padding',
'excessive-frame-effects',
],
},
description: 'Filter to specific detector categories.',