mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-05-31 19:04:29 +07:00
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:
parent
7cc165b950
commit
5e2e6f9dae
2 changed files with 82 additions and 14 deletions
|
|
@ -31,6 +31,38 @@ export { applyNoEmojiIconHeuristic } from './icon-emoji-heuristics';
|
|||
// 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
|
||||
* 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
|
||||
* 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 {
|
||||
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()
|
||||
.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];
|
||||
|
||||
|
|
@ -88,13 +136,17 @@ export function applyIconPathResolution(node: PenNode): void {
|
|||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Check if a name looks like an icon reference (not just any path node). */
|
||||
function isIconLikeName(originalName: string, normalized: string): boolean {
|
||||
// Explicit icon/logo suffix in original name
|
||||
if (/icon|logo/i.test(originalName)) return true;
|
||||
// Short normalized name (likely an icon name, not a complex path description)
|
||||
if (normalized.length > 0 && normalized.length <= 30) return true;
|
||||
return false;
|
||||
/**
|
||||
* Check if a name looks like an icon reference (not just any path node).
|
||||
*
|
||||
* The top-level guard in applyIconPathResolution already requires an
|
||||
* explicit icon/logo/symbol/glyph marker, so by the time we get here we
|
||||
* know the caller believes this is an icon. We still want a short
|
||||
* 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). */
|
||||
|
|
|
|||
|
|
@ -103,19 +103,29 @@ const NAME_EXACT_MAP: Record<string, string> = {
|
|||
/** 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;
|
||||
|
||||
/**
|
||||
* 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). */
|
||||
const NAME_PATTERN_MAP: [RegExp, string, boolean?][] = [
|
||||
[/\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'],
|
||||
[/\bform\b/i, 'form-group'],
|
||||
[/\bsearch/i, 'search-bar'],
|
||||
[/\bnav\s*link/i, 'nav-link'],
|
||||
[/\bstat/i, 'stat-card'],
|
||||
[/\bpricing/i, 'pricing-card'],
|
||||
[/\bstat/i, 'stat-card', true],
|
||||
[/\bpricing/i, 'pricing-card', true],
|
||||
[/\btestimonial\b|\breview\b|\bquote\b/i, 'testimonial'],
|
||||
[/\bcta\b|call\s*to\s*action/i, 'cta-section'],
|
||||
[/\bfeature/i, 'feature-card'],
|
||||
[/\bfeature/i, 'feature-card', true],
|
||||
[/\bicon\b/i, 'icon'],
|
||||
];
|
||||
|
||||
|
|
@ -138,7 +148,13 @@ function inferRoleFromName(node: PenNode): string | undefined {
|
|||
for (const [pattern, role, skipContainers] of NAME_PATTERN_MAP) {
|
||||
if (pattern.test(lower)) {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue