diff --git a/packages/pen-ai-skills/src/__tests__/diagnostics.test.ts b/packages/pen-ai-skills/src/__tests__/diagnostics.test.ts index 717f7f30b..15cf8018a 100644 --- a/packages/pen-ai-skills/src/__tests__/diagnostics.test.ts +++ b/packages/pen-ai-skills/src/__tests__/diagnostics.test.ts @@ -7,6 +7,7 @@ import { detectUnexpectedRotation, detectTextCornerRadius, detectMixedSiblingCornerRadius, + detectTextEffect, detectAllIssues, } from '../diagnostics/detectors'; import type { PenNode, PenDocument } from '@zseven-w/pen-types'; @@ -832,3 +833,81 @@ describe('detectMixedSiblingCornerRadius', () => { expect(issues[0].nodeId).toBe('c3'); }); }); + +describe('detectTextEffect', () => { + it('flags text node with shadow effects', () => { + const root = { + id: 'r', + type: 'frame', + children: [ + { + id: 't', + type: 'text', + content: 'hi', + effects: [{ type: 'shadow', color: '#000', offsetX: 0, offsetY: 4, radius: 8 }], + } as unknown as PenNode, + ], + } as unknown as PenNode; + const issues = detectTextEffect(root); + expect(issues).toHaveLength(1); + expect(issues[0].category).toBe('text-effect'); + expect(issues[0].nodeId).toBe('t'); + expect(issues[0].suggestedValue).toBeUndefined(); + }); + + it('does not flag text without effects', () => { + const root = { + id: 'r', + type: 'frame', + children: [{ id: 't', type: 'text', content: 'hi' } as unknown as PenNode], + } as unknown as PenNode; + expect(detectTextEffect(root)).toHaveLength(0); + }); + + it('does not flag text with empty effects array', () => { + const root = { + id: 'r', + type: 'frame', + children: [{ id: 't', type: 'text', content: 'hi', effects: [] } as unknown as PenNode], + } as unknown as PenNode; + expect(detectTextEffect(root)).toHaveLength(0); + }); + + it('does not flag frame nodes with effects (only text)', () => { + const root = { + id: 'r', + type: 'frame', + effects: [{ type: 'shadow', color: '#000', offsetX: 0, offsetY: 4, radius: 8 }], + children: [], + } as unknown as PenNode; + expect(detectTextEffect(root)).toHaveLength(0); + }); + + it('flags multiple text nodes with effects in a tree', () => { + const root = { + id: 'r', + type: 'frame', + children: [ + { + id: 't1', + type: 'text', + content: 'a', + effects: [{ type: 'shadow' }], + } as unknown as PenNode, + { + id: 'wrapper', + type: 'frame', + children: [ + { + id: 't2', + type: 'text', + content: 'b', + effects: [{ type: 'blur' }], + } as unknown as PenNode, + ], + } as unknown as PenNode, + ], + } as unknown as PenNode; + expect(detectTextEffect(root)).toHaveLength(2); + }); +}); diff --git a/packages/pen-ai-skills/src/diagnostics/detectors.ts b/packages/pen-ai-skills/src/diagnostics/detectors.ts index 8d67e91bb..30d1a5294 100644 --- a/packages/pen-ai-skills/src/diagnostics/detectors.ts +++ b/packages/pen-ai-skills/src/diagnostics/detectors.ts @@ -515,7 +515,43 @@ export function detectMixedSiblingCornerRadius(root: PenNode): Issue[] { } /** - * Run all 7 detectors and return the deduplicated combined issue list. + * Aesthetic detector: text node with effects (shadow / blur / etc). + * + * Rationale: drop-shadows and blurs on UI text almost always read as + * "designed by AI" — real product UIs use text shadows extremely + * sparingly (hero overlays on photos and a few brand elements). The + * model frequently sprinkles `effects: [{ type: 'shadow', … }]` onto + * body / label / caption text which makes the type look fuzzy and + * imprecise on canvas. Suggest removing the effects array. + */ +export function detectTextEffect(root: PenNode): Issue[] { + const issues: Issue[] = []; + function walk(node: PenNode): void { + if (node.type === 'text') { + const eff = (node as unknown as { effects?: unknown }).effects; + if (Array.isArray(eff) && eff.length > 0) { + issues.push({ + nodeId: node.id, + category: 'text-effect', + severity: 'warning', + property: 'effects', + currentValue: eff, + suggestedValue: undefined, + reason: + 'text node carries shadow / blur effects — almost always an AI hallucination on UI labels', + }); + } + } + if ('children' in node && Array.isArray(node.children)) { + for (const c of node.children) walk(c); + } + } + walk(root); + return issues; +} + +/** + * Run all 8 detectors and return the deduplicated combined issue list. * Dedup key: `${nodeId}:${property}` (matches runPreValidationFixes). * On collision, the first issue wins (detector execution order below). */ @@ -528,6 +564,7 @@ export function detectAllIssues(root: PenNode, doc: PenDocument): Issue[] { ...detectUnexpectedRotation(root), ...detectTextCornerRadius(root), ...detectMixedSiblingCornerRadius(root), + ...detectTextEffect(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 8b56824ef..8d8394ab7 100644 --- a/packages/pen-ai-skills/src/diagnostics/index.ts +++ b/packages/pen-ai-skills/src/diagnostics/index.ts @@ -8,5 +8,6 @@ export { detectUnexpectedRotation, detectTextCornerRadius, detectMixedSiblingCornerRadius, + detectTextEffect, detectAllIssues, } from './detectors'; diff --git a/packages/pen-ai-skills/src/diagnostics/types.ts b/packages/pen-ai-skills/src/diagnostics/types.ts index 61d19a9cc..4fb7a3df3 100644 --- a/packages/pen-ai-skills/src/diagnostics/types.ts +++ b/packages/pen-ai-skills/src/diagnostics/types.ts @@ -7,7 +7,8 @@ export type IssueCategory = | 'sibling-inconsistency' | 'unexpected-rotation' | 'text-corner-radius' - | 'mixed-sibling-corner-radius'; + | 'mixed-sibling-corner-radius' + | 'text-effect'; 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 0a031766e..16f3e8c1f 100644 --- a/packages/pen-mcp/src/routes/debug-routes.ts +++ b/packages/pen-mcp/src/routes/debug-routes.ts @@ -42,6 +42,7 @@ export const DEBUG_TOOL_DEFINITIONS = [ 'unexpected-rotation', 'text-corner-radius', 'mixed-sibling-corner-radius', + 'text-effect', ], }, description: 'Filter to specific detector categories.',