feat(ai): aesthetic detector — text-effect (shadow / blur on text labels)

Why: continuation of the aesthetic detector family added in 53435bf7.
The model frequently sprinkles \`effects: [{type:'shadow', …}]\` onto
body / label / caption text. On canvas the type goes fuzzy and reads
"AI-designed". Real product UIs use text shadows extremely sparingly
(hero overlays on photos, a few brand elements). Detection is cheap
(walk + isArray check) and the suggested fix (remove effects array)
is safe — text shadow on UI labels is almost never intentional.

What:
- detectTextEffect added to packages/pen-ai-skills/diagnostics with the
  same shape as the prior 3 (warning severity, suggestedValue undefined,
  reason string for logs).
- Wired through detectAllIssues + index.ts public exports + the
  debug_validation_report MCP tool's categories enum.

Tests: 5 new it() cases covering positive (shadow / blur on text),
negative (text without effects, empty effects array, frame with
effects), and tree-walk (multiple text effects in nested frames).
47 / 47 diagnostics tests pass (was 42; +5).
This commit is contained in:
Fini 2026-05-09 21:24:00 +08:00
parent fcc3d8b4d3
commit 96965161af
5 changed files with 121 additions and 2 deletions

View file

@ -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);
});
});

View file

@ -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<string>();
const unique: Issue[] = [];

View file

@ -8,5 +8,6 @@ export {
detectUnexpectedRotation,
detectTextCornerRadius,
detectMixedSiblingCornerRadius,
detectTextEffect,
detectAllIssues,
} from './detectors';

View file

@ -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 */

View file

@ -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.',