fix(ai): expand Type 0 fallback regex to all documented component triggers

Why: Codex stop-time review #4 — the previous regex covered ~17 nouns
but design-type.md documents 25 (button / label / row / item / selector
/ panel / chart were missing) and the CJK 卡片 alias was also listed.
JS `\b` is ASCII-only and never fires between two CJK chars, so
`\b卡片\b` matched nothing in "design a 卡片".

What: split into COMPONENT_TRIGGER_LATIN_RE (full noun list with `\b`
boundaries) + COMPONENT_TRIGGER_CJK_RE (kana-free subset of the most
common Chinese aliases — 卡片 / 徽章 / 标签 / 按钮 / 开关 / 对话框 /
提示 / 气泡 / 图表). Either match is enough to classify Type 0.
Disqualifier regex also gains 屏幕 (screen in zh-Hans).

Tests: 23 it.each cases pin one Latin trigger each plus the CJK 卡片;
6 negative cases prove the disqualifier still wins for "X screen / page
/ app / onboarding / flow" prompts. 1055 / 1055 AI tests pass (was
1027; +28 new).
This commit is contained in:
Fini 2026-05-09 20:59:27 +08:00
parent e9dcfb5d91
commit 6c80bd1b3e
2 changed files with 66 additions and 11 deletions

View file

@ -151,9 +151,49 @@ describe('buildFallbackPlanFromPrompt', () => {
expect(plan.subtasks[0]?.region.height).toBe(200);
});
it('does NOT misclassify "X card screen" as a component (must stay landing-page or mobile)', () => {
const plan = buildFallbackPlanFromPrompt('design a card screen page');
// "screen" / "page" disqualifier prevents the component shortcut.
// Codex review #4: cover every documented Type 0 trigger from
// pen-ai-skills/skills/phases/planning/design-type.md so the fallback
// doesn't silently route "design a primary button" to a 1200px landing
// page when AI parsing fails.
it.each([
['design a profile card', 'card'],
['design a 卡片', '卡片'],
['design a primary button', 'button'],
['design a status badge', 'badge'],
['design a category chip', 'chip'],
['design a price tag', 'tag'],
['design a setting toggle', 'toggle'],
['design a confirm dialog', 'dialog'],
['design a tooltip with arrow', 'tooltip'],
['design a popover with menu', 'popover'],
['design a bottom sheet', 'sheet'],
['design a stat tile', 'tile'],
['design a notification row', 'row'],
['design an inbox item', 'item'],
['design a status label', 'label'],
['design a segmented selector', 'selector'],
['design a side panel', 'panel'],
['design a metric widget', 'widget'],
['design an avatar with initial', 'avatar'],
['design a step stepper', 'stepper'],
['design a revenue stat', 'stat'],
['design a metric for sales', 'metric'],
['design a pie chart', 'chart'],
])('classifies "%s" as Type 0 component (matches "%s")', (prompt) => {
const plan = buildFallbackPlanFromPrompt(prompt);
expect(plan.rootFrame.width).toBe(400);
expect(plan.rootFrame.height).toBe(0);
});
it.each([
['design a card screen page'],
['design a profile page'],
['design a settings screen'],
['design a mobile login screen'],
['design a dashboard home page'],
['design an onboarding flow'],
])('does NOT misclassify "%s" as a component', (prompt) => {
const plan = buildFallbackPlanFromPrompt(prompt);
expect(plan.rootFrame.width).not.toBe(400);
});

View file

@ -12,15 +12,28 @@ export interface DesignTypePreset {
/**
* Component triggers — when the prompt names one atomic UI piece without a
* surrounding screen ("X card", "X badge", "X chip", etc.). Mirrors the
* Type 0 list in `pen-ai-skills/skills/phases/planning/design-type.md`.
* surrounding screen. Mirrors the Type 0 list in
* `pen-ai-skills/skills/phases/planning/design-type.md`:
* - "X card" / "X 卡片" — profile / pricing / stat / event / user card
* - "X badge" / "X chip" / "X tag" / "X tile" / "X label" / "X row" / "X item"
* - "X button" / "X toggle" / "X switch" / "X selector"
* - "X modal" / "X dialog" / "X tooltip" / "X popover" / "X sheet"
* - "X widget" / "X panel" (when no surrounding screen)
* - Single visualisations: "a chart" / "a pie chart" / "a stat" / "a metric"
*
* Match policy: word boundary + the noun must lead OR follow the qualifier
* to keep noisy mid-sentence hits out (e.g. "shopping cart screen" should
* not match "cart" as a component).
* The disqualifier regex below blocks the trigger when the prompt also
* names a screen / page / app, so "shopping cart screen" or "navbar with
* cart button" stays a non-component.
*/
const COMPONENT_TRIGGER_RE =
/\b(?:[a-z一-鿿]+\s+)?(card|badge|chip|tile|tag|pill|toggle|switch|modal|dialog|tooltip|popover|sheet|widget|avatar|stepper|stat|metric)(?:\s+(?:design|component|widget))?\b/i;
// Split into Latin and CJK matchers because JS `\b` is ASCII-only and never
// fires between two CJK characters (so `\b卡片\b` would never match a prompt
// like "design a 卡片"). Each matcher runs independently and either is enough
// to classify as Type 0.
const COMPONENT_TRIGGER_LATIN_RE =
/\b(card|badge|chip|tag|tile|pill|label|row|item|button|toggle|switch|selector|modal|dialog|tooltip|popover|sheet|widget|panel|avatar|stepper|stat|metric|chart)\b/i;
const COMPONENT_TRIGGER_CJK_RE = /(卡片|徽章|标签|按钮|开关|对话框|提示|气泡|图表)/;
const COMPONENT_DISQUALIFIER_RE = /\b(screen|page|app|home|onboarding|flow)\b|网页|页面|屏幕/i;
/**
* Minimal fallback design type detection.
@ -36,7 +49,9 @@ export function detectDesignType(prompt: string): DesignTypePreset {
// mobile/dashboard so a "profile card" prompt doesn't fall through to
// landing-page (1200px) when AI parsing fails — the user gets a
// sensibly-sized 400px component instead.
if (COMPONENT_TRIGGER_RE.test(prompt) && !/screen|page|app|网页|页面/i.test(prompt)) {
const matchesComponent =
COMPONENT_TRIGGER_LATIN_RE.test(prompt) || COMPONENT_TRIGGER_CJK_RE.test(prompt);
if (matchesComponent && !COMPONENT_DISQUALIFIER_RE.test(prompt)) {
return {
type: 'component',
width: 400,