diff --git a/packages/pen-ai-skills/src/__tests__/diagnostics.test.ts b/packages/pen-ai-skills/src/__tests__/diagnostics.test.ts index 0f2d61e04..0b09b37a6 100644 --- a/packages/pen-ai-skills/src/__tests__/diagnostics.test.ts +++ b/packages/pen-ai-skills/src/__tests__/diagnostics.test.ts @@ -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); + }); +}); diff --git a/packages/pen-ai-skills/src/diagnostics/detectors.ts b/packages/pen-ai-skills/src/diagnostics/detectors.ts index aff928388..49be36fac 100644 --- a/packages/pen-ai-skills/src/diagnostics/detectors.ts +++ b/packages/pen-ai-skills/src/diagnostics/detectors.ts @@ -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(); const unique: Issue[] = []; diff --git a/packages/pen-ai-skills/src/diagnostics/index.ts b/packages/pen-ai-skills/src/diagnostics/index.ts index b26d40831..12b30032d 100644 --- a/packages/pen-ai-skills/src/diagnostics/index.ts +++ b/packages/pen-ai-skills/src/diagnostics/index.ts @@ -11,5 +11,6 @@ export { detectTextEffect, detectTextStroke, detectMixedSiblingPadding, + detectExcessiveFrameEffects, detectAllIssues, } from './detectors'; diff --git a/packages/pen-ai-skills/src/diagnostics/types.ts b/packages/pen-ai-skills/src/diagnostics/types.ts index a9d81940e..6057a4a6e 100644 --- a/packages/pen-ai-skills/src/diagnostics/types.ts +++ b/packages/pen-ai-skills/src/diagnostics/types.ts @@ -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 */ diff --git a/packages/pen-mcp/src/routes/debug-routes.ts b/packages/pen-mcp/src/routes/debug-routes.ts index 3efe13746..d97ad82b7 100644 --- a/packages/pen-mcp/src/routes/debug-routes.ts +++ b/packages/pen-mcp/src/routes/debug-routes.ts @@ -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.',