fix(ai): stop icon resolver and role inference from hijacking data-viz paths

The M2.7 health-tracker retry exposed two related regressions where the
post-processing passes were overreaching on descriptive path and frame
names:

1. applyIconPathResolution was running on every `path` node and trying
   to match its name against ICON_PATH_MAP. Descriptive geometry names
   share substrings with icon keys and got hijacked:
     - "Heart Rate Chart"   → lucide:circle    (isIconLikeName fallback)
     - "Chart Fill"          → lucide:bar-chart-2 (prefix "chart" 55%)
     - "Steps Progress"      → lucide:circle    (fallback)
     - "Calories Progress"   → lucide:circle    (fallback)
     - "Distance Progress"   → lucide:circle    (fallback)
   The resolver also injected a fake `d` and left stroke.color="none",
   so the progress arcs disappeared entirely and the rings visual
   collapsed into a single opaque black disc (three concentric dark
   tracks showing through).

   Fix: the `path` type is for custom geometry; `icon_font` is the
   canonical icon type. Only run icon resolution on a path whose name
   carries an explicit icon/logo/symbol/glyph marker. Uses camelCase-
   aware word splitting so "SearchIcon" / "MenuIcon" / "search-icon" /
   "BrandLogo" still qualify, while "Heart Rate Chart" and friends do
   not.

2. role-resolver name pattern /\bcard\b/i fired on "Card Header" and
   applied the card role defaults (white fill, shadow, cornerRadius).
   The "Card Header" node then rendered as a solid white strip across
   the top of the activity rings card.

   Fix: add a ROLE_PART_WORDS guard (header/body/footer/title/content/
   image/action/etc.) alongside the existing CONTAINER_SUFFIXES guard.
   A "Card Header" is a PIECE of a card, not a card — it must not
   inherit role=card defaults. Also tighten stat-card, pricing-card,
   feature-card, and card to honour the same skip-containers flag.

Verified with an inline script covering 41 cases (20 icon guard, 21
role inference), including camelCase, kebab, snake, and space variants
for legitimate icons plus all the failing descriptive names from the
retry dump.
This commit is contained in:
Fini 2026-04-06 19:07:12 +08:00
parent 7cc165b950
commit 5e2e6f9dae
2 changed files with 82 additions and 14 deletions

View file

@ -31,6 +31,38 @@ export { applyNoEmojiIconHeuristic } from './icon-emoji-heuristics';
// Icon path resolution — main entry point + node property mutation // Icon path resolution — main entry point + node property mutation
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/**
* Reserved words that mark a path node as explicitly an icon/logo/symbol.
* The `path` type is also used for legitimate custom geometry (chart
* lines, progress arcs, waveforms, sparklines, illustrations), so we
* MUST NOT blindly run icon resolution on every path node that would
* clobber the real geometry with a circle/bar-chart/arrow icon path.
*
* Only names that clearly signal "this is an icon" are candidates
* tested by splitting the name into words on camelCase, spaces, dashes
* and underscores, then checking for an exact word hit.
*/
const ICON_MARKER_WORDS = new Set(['icon', 'logo', 'symbol', 'glyph']);
/**
* Check whether a path node's name carries an explicit icon marker.
* Handles "SearchIcon" (camelCase), "Search Icon" (spaced), "search_icon"
* (snake), "search-icon" (kebab), and "BrandLogo" / "AppGlyph".
* Rejects descriptive geometry names like "Heart Rate Chart",
* "Steps Progress", "Chart Fill", "Heart Rate Waveform".
*/
function hasExplicitIconMarker(name: string): boolean {
// Split on camelCase boundaries, then on whitespace/underscore/hyphen.
const words = name
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.toLowerCase()
.split(/[\s_-]+/);
for (const word of words) {
if (ICON_MARKER_WORDS.has(word)) return true;
}
return false;
}
/** /**
* Resolve icon path nodes by their name. When the AI generates a path node * Resolve icon path nodes by their name. When the AI generates a path node
* with a name like "SearchIcon" or "MenuIcon", look up the verified SVG path * with a name like "SearchIcon" or "MenuIcon", look up the verified SVG path
@ -38,13 +70,29 @@ export { applyNoEmojiIconHeuristic } from './icon-emoji-heuristics';
* *
* On local map miss for icon-like names, sets a generic placeholder and * On local map miss for icon-like names, sets a generic placeholder and
* records the node for async resolution via the Iconify API. * records the node for async resolution via the Iconify API.
*
* IMPORTANT: Only path nodes whose name explicitly says "icon"/"logo"/
* "symbol"/"glyph" are considered. Everything else is treated as real
* custom geometry and left alone AI-generated data-viz paths like
* "Heart Rate Chart", "Steps Progress", "Chart Fill" must never be
* hijacked into a circle or bar-chart icon. The `icon_font` node type
* is the canonical way for AI to emit icons; icon_resolver only exists
* to salvage the rare case where AI picks `path` but still means an icon.
*/ */
export function applyIconPathResolution(node: PenNode): void { export function applyIconPathResolution(node: PenNode): void {
if (node.type !== 'path') return; if (node.type !== 'path') return;
const rawName = (node.name ?? node.id ?? '')
const originalName = node.name ?? node.id ?? '';
// Hard gate: require an explicit icon/logo marker in the name.
// Without this guard, descriptive path names share substrings with icon
// dictionary keys (e.g. "Chart Fill" → prefix "chart") and get
// overwritten with the matched icon path.
if (!hasExplicitIconMarker(originalName)) return;
const rawName = originalName
.toLowerCase() .toLowerCase()
.replace(/[-_\s]+/g, '') // normalize separators .replace(/[-_\s]+/g, '') // normalize separators
.replace(/(icon|logo)$/, ''); // strip trailing "icon" or "logo" .replace(/(icon|logo|symbol|glyph)$/, ''); // strip trailing marker
let match = ICON_PATH_MAP[rawName]; let match = ICON_PATH_MAP[rawName];
@ -88,13 +136,17 @@ export function applyIconPathResolution(node: PenNode): void {
// Internal helpers // Internal helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** Check if a name looks like an icon reference (not just any path node). */ /**
function isIconLikeName(originalName: string, normalized: string): boolean { * Check if a name looks like an icon reference (not just any path node).
// Explicit icon/logo suffix in original name *
if (/icon|logo/i.test(originalName)) return true; * The top-level guard in applyIconPathResolution already requires an
// Short normalized name (likely an icon name, not a complex path description) * explicit icon/logo/symbol/glyph marker, so by the time we get here we
if (normalized.length > 0 && normalized.length <= 30) return true; * know the caller believes this is an icon. We still want a short
return false; * non-empty normalized form so we can queue it for async Iconify
* resolution (empty after normalization means there is nothing to look up).
*/
function isIconLikeName(_originalName: string, normalized: string): boolean {
return normalized.length > 0 && normalized.length <= 30;
} }
/** Apply stroke/fill styling to a resolved icon node (caller must ensure path type). */ /** Apply stroke/fill styling to a resolved icon node (caller must ensure path type). */

View file

@ -103,19 +103,29 @@ const NAME_EXACT_MAP: Record<string, string> = {
/** Names that indicate a container rather than an individual component. */ /** Names that indicate a container rather than an individual component. */
const CONTAINER_SUFFIXES = /\b(group|row|container|wrapper|section|list|area|stack|grid|bar)s?\b/i; const CONTAINER_SUFFIXES = /\b(group|row|container|wrapper|section|list|area|stack|grid|bar)s?\b/i;
/**
* Words that, when combined with a role-like word, turn the node into a
* PART of that role rather than an instance of it. "Card Header",
* "Card Body", "Card Footer", "Button Group", "Nav Link Wrapper" are all
* structural pieces inside a parent component they must NOT inherit the
* parent component's role defaults (white fill, shadow, rounded corners).
*/
const ROLE_PART_WORDS =
/\b(header|body|footer|title|subtitle|content|wrapper|container|area|label|value|caption|description|image|media|icon|action|actions|meta|row|column|stack|grid)\b/i;
/** Substring patterns → role (checked in order, first match wins). */ /** Substring patterns → role (checked in order, first match wins). */
const NAME_PATTERN_MAP: [RegExp, string, boolean?][] = [ const NAME_PATTERN_MAP: [RegExp, string, boolean?][] = [
[/\bbtn\b|\bbutton\b/i, 'button', true], [/\bbtn\b|\bbutton\b/i, 'button', true],
[/\bcard\b/i, 'card'], [/\bcard\b/i, 'card', true],
[/\binput\b|text\s*field|text\s*box/i, 'input'], [/\binput\b|text\s*field|text\s*box/i, 'input'],
[/\bform\b/i, 'form-group'], [/\bform\b/i, 'form-group'],
[/\bsearch/i, 'search-bar'], [/\bsearch/i, 'search-bar'],
[/\bnav\s*link/i, 'nav-link'], [/\bnav\s*link/i, 'nav-link'],
[/\bstat/i, 'stat-card'], [/\bstat/i, 'stat-card', true],
[/\bpricing/i, 'pricing-card'], [/\bpricing/i, 'pricing-card', true],
[/\btestimonial\b|\breview\b|\bquote\b/i, 'testimonial'], [/\btestimonial\b|\breview\b|\bquote\b/i, 'testimonial'],
[/\bcta\b|call\s*to\s*action/i, 'cta-section'], [/\bcta\b|call\s*to\s*action/i, 'cta-section'],
[/\bfeature/i, 'feature-card'], [/\bfeature/i, 'feature-card', true],
[/\bicon\b/i, 'icon'], [/\bicon\b/i, 'icon'],
]; ];
@ -138,7 +148,13 @@ function inferRoleFromName(node: PenNode): string | undefined {
for (const [pattern, role, skipContainers] of NAME_PATTERN_MAP) { for (const [pattern, role, skipContainers] of NAME_PATTERN_MAP) {
if (pattern.test(lower)) { if (pattern.test(lower)) {
// Skip container-like names (e.g. "Button Group", "Buttons Row") // Skip container-like names (e.g. "Button Group", "Buttons Row")
if (skipContainers && CONTAINER_SUFFIXES.test(lower)) continue; if (skipContainers) {
if (CONTAINER_SUFFIXES.test(lower)) continue;
// Also skip "Card Header", "Card Body", "Button Label", etc. — the
// role word is modified by a structural part word, meaning the
// node is a PIECE of the role, not the role itself.
if (ROLE_PART_WORDS.test(lower)) continue;
}
return role; return role;
} }
} }