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:
parent
828f09abda
commit
40dfc55c95
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -11,5 +11,6 @@ export {
|
|||
detectTextEffect,
|
||||
detectTextStroke,
|
||||
detectMixedSiblingPadding,
|
||||
detectExcessiveFrameEffects,
|
||||
detectAllIssues,
|
||||
} from './detectors';
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
Loading…
Reference in a new issue