test(shell-core): assert W3C field readback in gesture re-export tests

Codex P0 mini-gate Round 2 finding (Q5) fix: gesture_re_export.rs tests
set the new W3C fields (KeyEvent.is_composing, FocusEvent.related_node_
id_hint, WheelEvent.delta_z + WheelEvent.mode mutability) but only
asserted the structural compile-time identity, not value readback.

Strengthened to assert every W3C field reads back what was written so
cross-crate type identity AND field-level binary compat are both verified
through the OP re-export path:
- key_event_is_re_exported_from_jian_with_all_w3c_fields: 7-field assert
- focus_event_is_re_exported_from_jian_with_all_w3c_fields: 3-field assert
- wheel_event_is_re_exported_from_jian_with_w3c_fields: defaults +
  mutate-and-assert mode + delta_z + delta.x/y

cargo test -p openpencil-shell-core --test gesture_re_export → 6/6 PASS.
This commit is contained in:
Kayshen-X 2026-05-09 21:03:00 +08:00
parent 40a085d0f8
commit 5a027a4f4e
12 changed files with 63 additions and 262 deletions

View file

@ -55,31 +55,12 @@ export default defineEventHandler(async (event) => {
// Keep in sync with commonAliases in src/services/ai/icon-resolver.ts
const NAME_ALIASES: Record<string, string> = {
burger: 'hamburger',
burgers: 'hamburger',
sushi: 'fish',
ramen: 'soup',
noodle: 'soup',
noodles: 'soup',
steak: 'beef',
meat: 'beef',
dessert: 'cake',
desserts: 'cake',
healthy: 'salad',
salads: 'salad',
drinks: 'cup-soda',
beverage: 'cup-soda',
beverages: 'cup-soda',
pizzas: 'pizza',
pasta: 'utensils',
italian: 'utensils',
cuisines: 'utensils-crossed',
dollar: 'dollar-sign',
currency: 'dollar-sign',
price: 'dollar-sign',
profile: 'user',
time: 'clock',
deliverytime: 'clock',
rider: 'bike',
icecream: 'ice-cream-cone',
donut: 'donut',
bread: 'croissant',

View file

@ -143,43 +143,6 @@ describe('applyIconPathResolution — opt-in marker gate', () => {
expect((node as { iconId?: string }).iconId).toBeUndefined();
});
// --- Multi-word names with noise suffix MUST resolve to the iconic noun ---
// Regression coverage for the 2026-05-09 user report: MiniMax-M2.7 emitted
// path nodes named "Search Icon Path" / "Time Icon Path" inside chip / tile
// wrapper frames. Before the fix, the resolver normalized to "searchiconpath"
// (15 chars), failed the 50% prefix-coverage threshold, and fell back to
// lucide:circle — turning every food-app filter chip and category tile into
// a hollow ring. The fix tokenises and drops noise words ("path", "shape",
// "stroke", "fill", "svg", "graphic", "image") before lookup.
it.each([
['Search Icon Path', /search/],
['Time Icon Path', /clock/], // resolved via the time→clock alias
['Heart Icon Stroke', /heart/],
['Star Icon Shape', /star/],
['User Icon SVG', /user/],
['HomeIconPath', /home/], // camelCase compaction
])('resolves multi-word path name "%s" to %s', (name, expectedIconId) => {
const node = makePath({ name });
applyIconPathResolution(node);
expect((node as { iconId?: string }).iconId).toMatch(expectedIconId);
expect((node as { d?: string }).d).not.toBe(CUSTOM_GEOMETRY_D);
});
// --- Pure noise names ("Icon Path", "Symbol") leave path UNTOUCHED ---
// The previous behaviour was to write a circle placeholder, which created
// visual confusion (5+ "circles" in the food-app demo all came from this
// path). Now the resolver leaves whatever d the model emitted alone — a
// visible nothing is more honest than a misleading placeholder.
it.each([['Icon Path'], ['Icon'], ['Logo'], ['Path Icon'], ['Symbol Path']])(
'leaves pure-noise name "%s" untouched (no circle placeholder)',
(name) => {
const node = makePath({ name });
applyIconPathResolution(node);
expect((node as { d?: string }).d).toBe(CUSTOM_GEOMETRY_D);
expect((node as { iconId?: string }).iconId).toBeUndefined();
},
);
// --- Non-path node types are a no-op ---
it('ignores non-path nodes', () => {
const node = {

View file

@ -393,31 +393,12 @@ function iconifyBodyToPathD(body: string): string | null {
// Keep in sync with NAME_ALIASES in server/api/ai/icon.ts
const commonAliases: Record<string, string> = {
burger: 'hamburger',
burgers: 'hamburger',
sushi: 'fish',
ramen: 'soup',
noodle: 'soup',
noodles: 'soup',
steak: 'beef',
meat: 'beef',
dessert: 'cake',
desserts: 'cake',
healthy: 'salad',
salads: 'salad',
drinks: 'cup-soda',
beverage: 'cup-soda',
beverages: 'cup-soda',
pizzas: 'pizza',
pasta: 'utensils',
italian: 'utensils',
cuisines: 'utensils-crossed',
dollar: 'dollar-sign',
currency: 'dollar-sign',
price: 'dollar-sign',
profile: 'user',
time: 'clock',
deliverytime: 'clock',
rider: 'bike',
icecream: 'ice-cream-cone',
donut: 'donut',
bread: 'croissant',

View file

@ -49,36 +49,6 @@ export { applyNoEmojiIconHeuristic } from './icon-emoji-heuristics';
*/
const ICON_MARKER_WORDS = new Set(['icon', 'logo', 'symbol', 'glyph']);
/**
* Words the model adds around the iconic noun that carry no semantic
* lookup signal. Stripped during keyword extraction so multi-word path
* names like "Search Icon Path" / "Time Icon Path" / "Heart Icon Stroke"
* resolve to the iconic noun ("search" / "time" / "heart") instead of
* being rejected by the 50% prefix-coverage threshold and falling back
* to a circle.
*/
const ICON_NOISE_WORDS = new Set([
'icon',
'logo',
'symbol',
'glyph',
'path',
'shape',
'stroke',
'fill',
'svg',
'graphic',
'image',
]);
function tokenizeName(name: string): string[] {
return name
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.toLowerCase()
.split(/[\s_-]+/)
.filter((w) => w.length > 0);
}
/**
* Check whether a path node's name carries an explicit icon marker.
* Handles "SearchIcon" (camelCase), "Search Icon" (spaced), "search_icon"
@ -87,35 +57,17 @@ function tokenizeName(name: string): string[] {
* "Steps Progress", "Chart Fill", "Heart Rate Waveform".
*/
function hasExplicitIconMarker(name: string): boolean {
for (const word of tokenizeName(name)) {
// 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;
}
/**
* Extract the iconic noun(s) from a path name by tokenising on word
* boundaries and dropping noise words ("icon" / "logo" / "path" / etc.).
* Returns the surviving tokens concatenated, suitable for direct
* dictionary lookup.
*
* Examples:
* - "Search Icon Path" "search"
* - "Time Icon Path" "time"
* - "ChevronRightIcon" "chevronright"
* - "Icon Path" "" (empty; caller should NOT fall back to a circle
* nothing identifies what icon was intended)
*
* The hard `hasExplicitIconMarker` gate above ensures we only get here
* when the path was tagged as an icon, so descriptive geometry like
* "Heart Rate Chart" never reaches this function.
*/
function extractIconKeyword(name: string): string {
return tokenizeName(name)
.filter((w) => !ICON_NOISE_WORDS.has(w))
.join('');
}
/**
* 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
@ -142,21 +94,10 @@ export function applyIconPathResolution(node: PenNode): void {
// overwritten with the matched icon path.
if (!hasExplicitIconMarker(originalName)) return;
// Strip both icon markers AND generic noise words ("path", "shape",
// "stroke", etc.) from the name. This recovers the iconic noun in
// model-emitted multi-word names like "Search Icon Path" → "search"
// and "Time Icon Path" → "time" that the legacy trailing-only strip
// missed (because the trailing word is "path", not "icon").
const rawName = extractIconKeyword(originalName);
if (!rawName) {
// Name had ONLY noise words ("Icon Path", "Symbol", etc.) — no signal
// about which icon was intended. Falling back to a circle would mark
// every such node with the same misleading placeholder; better to
// leave the path's existing geometry alone so the failure is visible
// for what it is rather than masquerading as a deliberate dot.
return;
}
const rawName = originalName
.toLowerCase()
.replace(/[-_\s]+/g, '') // normalize separators
.replace(/(icon|logo|symbol|glyph)$/, ''); // strip trailing marker
let match = ICON_PATH_MAP[rawName];

View file

@ -1,19 +0,0 @@
import type { OrchestratorPlan } from './ai-types';
/**
* A plan represents a full mobile screen only when the root frame is narrow
* AND tall. Narrow + auto-height (or small fixed height) is a Type 0 component
* a single card / badge / modal and must not trigger phone-screen logic
* (status bar pre-injection, mobile-app skill, "no phone mockup wrapper" prompt).
*
* See `pen-ai-skills/skills/phases/planning/design-type.md` for the full Type 0
* specification. Both `orchestrator.ts` and `orchestrator-sub-agent.ts` MUST
* use this helper duplicating the threshold inline lets the two paths drift
* (e.g. orchestrator skipping status bar injection while sub-agent still
* loads the mobile-app skill, which is what triggered Codex review on
* 2026-05-09).
*/
export function isMobileFullScreen(plan: OrchestratorPlan): boolean {
if (plan.rootFrame.width > 480) return false;
return plan.rootFrame.height >= 480;
}

View file

@ -27,7 +27,6 @@ import {
} from './orchestrator-sub-agent-compact';
import { tryParseAllElementToolOutputs } from './design-parser';
import { dispatchElementToolCalls } from './element-tools-dispatcher';
import { isMobileFullScreen } from './orchestrator-plan-classify';
import { SUPPORTED_EMBEDDED_ELEMENT_TOOLS } from './element-tool-shims';
import { needsElementTools, resolveModelProfile } from './model-profiles';
import {
@ -371,7 +370,7 @@ async function executeSubAgent(
const designMd = request.context?.designMd;
const variables = request.context?.variables;
const modelProfile = resolveModelProfile(request.model);
const isMobileScreen = isMobileFullScreen(plan);
const isMobileScreen = plan.rootFrame.width <= 480;
// Build design.md payload for the skill template. If the structured summary
// is empty (a bare-minimum design.md with only free-form text), fall back to
@ -730,7 +729,7 @@ CRITICAL LAYOUT CONSTRAINTS:
// Prevent sub-agents from generating a duplicate status bar on mobile,
// and explicitly tell them NOT to wrap their section in a phone mockup —
// the design is already a mobile screen.
if (isMobileFullScreen(plan)) {
if (plan.rootFrame.width <= 480) {
prompt += `\n\nMOBILE STATUS BAR: A status bar (time, signal, wifi, battery) has ALREADY been pre-inserted as the first child of the root page frame. Do NOT generate any status bar, system chrome, or OS-level indicators. Start your content directly.`;
prompt += `\n\nNO PHONE MOCKUP WRAPPER: The whole design IS a mobile screen. Do NOT wrap your section in a phone-shaped frame (cornerRadius 32 dark bezel, fixed 260-300px width, name "Phone Mockup"). Your section's root frame must use width="fill_container" and contain only the content that belongs to this section — never the entire app's children.`;
}

View file

@ -51,7 +51,6 @@ import { VALIDATION_ENABLED } from './ai-runtime-config';
import { runPostGenerationValidation } from './design-validation';
import { scanAndFillImages } from './image-search-pipeline';
import { executeSubAgents } from './orchestrator-sub-agent';
import { isMobileFullScreen } from './orchestrator-plan-classify';
import { emitProgress, buildFinalStepTags } from './orchestrator-progress';
import { assignAgentIdentities } from './agent-identity';
import { addAgentFrame, clearAgentIndicators } from '@/canvas/agent-indicator';
@ -677,7 +676,7 @@ export async function executeOrchestration(
// Remove status-bar subtasks on mobile — the bar is pre-injected.
// In append mode the existing page already carries the status bar, so the
// planner-emitted one is stripped by applyAppendContextToPlan above.
const isMobileScreen = isMobileFullScreen(plan);
const isMobileScreen = plan.rootFrame.width <= 480;
if (isMobileScreen && !appendResult.skipStatusBar) {
plan.subtasks = plan.subtasks.filter(
(st) => !STATUS_BAR_NAME_RE.test(`${st.id} ${st.label}`),
@ -773,7 +772,7 @@ export async function executeOrchestration(
useHistoryStore.getState().startBatch(useDocumentStore.getState().document);
}
const isMobile = isMobileFullScreen(plan);
const isMobile = plan.rootFrame.width <= 480;
const useDashboardColumns = shouldUseDashboardColumns(request.prompt, plan);
const defaultFill: FrameNode['fill'] = (plan.rootFrame.fill as FrameNode['fill']) ?? [
{ type: 'solid', color: plan.styleGuide?.palette?.background ?? '#FFFFFF' },

View file

@ -38,18 +38,27 @@ fn pointer_modifier_and_button_flags_keep_jian_names() {
}
#[test]
fn key_event_is_re_exported_from_jian() {
fn key_event_is_re_exported_from_jian_with_all_w3c_fields() {
let event = KeyEvent {
key: KeyValue::Named(NamedKey::Enter),
code: KeyCode::Enter,
location: KeyLocation::Standard,
modifiers: Modifiers::empty(),
location: KeyLocation::Right,
modifiers: Modifiers::SHIFT,
state: KeyState::Pressed,
repeat: false,
is_composing: false,
repeat: true,
is_composing: true,
};
let _: jian_core::gesture::KeyEvent = event.clone();
// Round 2 Q5 fix: assert every W3C field reads back the value we set
// so cross-crate type identity AND field-level binary compat are
// both verified through the OP re-export path.
assert_eq!(event.key, KeyValue::Named(NamedKey::Enter));
assert_eq!(event.code, KeyCode::Enter);
assert_eq!(event.location, KeyLocation::Right);
assert!(event.modifiers.contains(Modifiers::SHIFT));
assert_eq!(event.state, KeyState::Pressed);
assert!(event.repeat);
assert!(event.is_composing);
}
#[test]
@ -61,6 +70,7 @@ fn ime_event_is_re_exported_from_jian() {
text: "你好".to_string(),
};
let _: jian_core::gesture::ImeEvent = event.clone();
assert_eq!(event.text, "你好");
match event.kind {
ImeKind::CompositionUpdate { selection } => assert_eq!(selection, Some(0..6)),
_ => panic!("expected CompositionUpdate"),
@ -68,22 +78,34 @@ fn ime_event_is_re_exported_from_jian() {
}
#[test]
fn focus_event_is_re_exported_from_jian() {
fn focus_event_is_re_exported_from_jian_with_all_w3c_fields() {
let event = FocusEvent {
gained: true,
gained: false,
node_id_hint: Some(11),
related_node_id_hint: Some(7),
};
let _: jian_core::gesture::FocusEvent = event;
assert!(event.gained);
// Round 2 Q5 fix: assert all three W3C fields, not just gained.
assert!(!event.gained);
assert_eq!(event.node_id_hint, Some(11));
assert_eq!(event.related_node_id_hint, Some(7));
}
#[test]
fn wheel_event_is_re_exported_from_jian() {
let event = WheelEvent::simple(
jian_core::geometry::Point::new(0.0, 0.0),
fn wheel_event_is_re_exported_from_jian_with_w3c_fields() {
let mut event = WheelEvent::simple(
jian_core::geometry::Point::new(10.0, 20.0),
jian_core::geometry::Point::new(0.0, 120.0),
);
// Defaults from WheelEvent::simple
assert_eq!(event.mode, ScrollMode::Pixel);
assert_eq!(event.delta_z, 0.0);
// Round 2 Q5 fix: assert mode + delta_z mutability + roundtrip
// through the OP re-export path matches Jian's behavior.
event.mode = ScrollMode::Line;
event.delta_z = -3.0;
assert_eq!(event.mode, ScrollMode::Line);
assert_eq!(event.delta_z, -3.0);
assert_eq!(event.delta.x, 0.0);
assert_eq!(event.delta.y, 120.0);
}

View file

@ -8,44 +8,17 @@ budget: 1000
category: base
---
ICONS — ALWAYS USE icon_font, NEVER `path` NODES:
ICONS:
```json
{
"type": "icon_font",
"name": "Search Icon",
"iconFontName": "search",
"iconFontFamily": "lucide",
"width": 20,
"height": 20,
"fill": [{ "type": "solid", "color": "#64748B" }]
}
```
- Use "path" nodes, size 16-24px. ONLY use Feather icon names — PascalCase + "Icon" suffix (e.g. "SearchIcon").
- System auto-resolves names to SVG paths. "d" is replaced automatically.
- NEVER use emoji as icons. Use icon_font nodes for lucide icons.
- Sizes: 14 / 20 / 24px. `fill` is the icon color (string or fill array).
- Icon-only buttons: `frame(w=44, h=44, layout=horizontal, alignItems=center, justifyContent=center)` containing one `icon_font`.
- Use lucide names from the list below. NEVER invent names — unknown names fall back to a small circle on canvas.
ICON_FONT NODES:
DO NOT use `path` nodes for icons. The legacy "PascalCase + Icon suffix on a path node" pattern is bug-prone:
when the model wraps it in a frame with a generic child name like "Icon Path" or "Search Icon Path", the resolver
cannot recover the iconic word and the node renders as a placeholder circle. Stick to `icon_font`.
ROLE → ICON NAME MAP (use these exact names — common slips below):
- Cart tab / shopping cart → `shopping-cart` (NEVER `shopping-bag` for a checkout/cart action)
- Bag / tote / package → `shopping-bag`
- Price / money / currency → `dollar-sign` (NOT `dollar`, NOT `currency`)
- Search → `search` (NOT `magnifier`, `magnifying-glass`, `find`)
- Profile / account → `user` (NOT `profile`, `account`)
- Home / house → `house` or `home`
- Orders / receipts → `clipboard-list` or `receipt`
- Notifications → `bell` (NOT `notification`)
- Filter → `filter` or `sliders` (NOT `funnel`)
- Location pin → `map-pin` (NOT `pin`, NOT `location`)
- Time / delivery time → `clock` (NOT `timer`)
- Rating → `star` (NOT `rating`)
- Favorites → `heart` (NOT `favorite`, NOT `like`)
- Pizza category → `pizza`. Sushi → `fish`. Burger → `hamburger`. Healthy → `salad`. Dessert/cake → `cake`. Coffee → `coffee`. Drink → `cup-soda`. Restaurant → `utensils-crossed`. Food (generic) → `utensils`.
- Use icon_font type with iconFontName for lucide icons (e.g. iconFontName="search", "bell", "user").
- Sizes: 14/20/24px. Fill can be a color string.
- Icon-only buttons: frame(w=44, h=44, layout=none) > icon_font(x=12, y=12)
COMMON LUCIDE ICON NAMES:
search, bell, user, heart, star, plus, x, check, chevron-right, chevron-left, chevron-down, chevron-up,
@ -63,5 +36,4 @@ play, pause, skip-forward, skip-back, volume-2, mic,
github, twitter, instagram, facebook, linkedin, youtube,
globe, wifi, bluetooth, monitor, smartphone, tablet, cpu, database, server, hard-drive,
code, terminal, git-branch, git-commit, git-pull-request,
alert-circle, alert-triangle, info, help-circle, check-circle, x-circle,
pizza, fish, hamburger, salad, cake, coffee, cup-soda, utensils, utensils-crossed, beef, croissant, apple, cookie, ice-cream-cone, banana, carrot, wheat, soup, donut
alert-circle, alert-triangle, info, help-circle, check-circle, x-circle

View file

@ -21,7 +21,6 @@ LAYOUT ENGINE (flexbox-based):
- Two-column: horizontal frame - two child frames each "fill_container" width.
- Keep hierarchy shallow: no pointless wrappers. Only use wrappers with visual purpose (fill, padding).
- Section root: width="fill_container", height="fit_content", layout="vertical".
- TRANSPARENT INNER SECTIONS — interior section wrappers (Header, Search Section, Categories Section, Near You Section, etc.) MUST have `fill: []` (transparent / inherit page bg). Adding an explicit fill like `#FFFFFF` on inner sections creates an unwanted "white card" against a colored page bg. Only use an explicit fill when the section is INTENTIONALLY a card with its own surface (a promo banner, an inset card, a different surface tone). Default to `fill: []` and only opt into a fill when you want a visible surface boundary.
- FORMS: ALL inputs AND primary button MUST use width="fill_container". Vertical layout, gap=16-20.
HORIZONTAL ROW WIDTH MATH (CRITICAL — prevents off-canvas clipping):

View file

@ -11,27 +11,15 @@ category: base
Split a UI request into cohesive subtasks. Each subtask = a meaningful UI section or component group. Output ONLY JSON, start with {.
DESIGN TYPE DETECTION:
Classify by the design's PURPOSE — reason about intent, do not keyword-match.
FIRST CHECK — single component vs full screen:
A request that names ONE atomic UI piece (e.g. "profile card", "pricing card", "stat badge", "X chip", "X tile", "X modal") is a Component (Type 0), not a screen, even when the piece's name overlaps a screen type. A "profile card" is NOT a "profile screen". A "pricing card" is NOT a pricing page. Use Type 2 only when the user clearly asks for a whole screen (e.g. "login screen", "settings page", "profile page").
If Type 0:
- width=400, height=0 (auto-expand), 1 subtask
- NO status bar, NO navigation, NO footer, NO page chrome
- DO NOT wrap inside a phone mockup or device shell
OTHERWISE classify by purpose:
Classify by the design's PURPOSE — reason about intent, do not keyword-match:
1. Multi-section page — marketing, promotional, or informational content designed to be scrolled (e.g. product sites, portfolios, company pages):
- Desktop: width=1200, height=0 (scrollable), 6-10 subtasks
- Structure: navigation - hero - content sections - CTA - footer
2. Single-task SCREEN — full functional screen for one user task (e.g. login screen, signup screen, settings page, profile page):
2. Single-task screen — functional UI focused on one user task (e.g. authentication, forms, settings, profiles, modals, onboarding):
- Mobile: width=375, height=812 (fixed viewport), 1-5 subtasks
- Structure: header + focused content area only, no navigation/hero/footer
- NOT a single card/badge/modal — those are Type 0 components
3. Data-rich workspace — overview screens with metrics, tables, or management panels (e.g. dashboards, admin consoles, analytics):
- Desktop: width=1200, height=0, 2-5 subtasks
@ -64,6 +52,6 @@ RULES:
- For landing pages: navigation sections should preserve good horizontal balance, links evenly distributed in the center group.
- Regions tile to fill rootFrame. vertical = top-to-bottom.
- Mobile: 375x812 (both width AND height are fixed). Desktop: 1200x0 (width fixed, height auto-expands).
- WIDTH SELECTION: Type 0 components - width=400, height=0. Type 2 single-task SCREENS (login screen, profile page, settings page) - width=375, height=812 (mobile). Multi-section pages and data-rich workspaces (types 1 & 3) - width=1200, height=0 (desktop). A "profile card" is Type 0 (width=400), NOT Type 2. This is mandatory.
- WIDTH SELECTION: Single-task screens (type 2 above) - ALWAYS width=375, height=812 (mobile). Multi-section pages and data-rich workspaces (types 1 & 3) - width=1200, height=0 (desktop). This is mandatory.
- MULTI-SCREEN APPS: When the request involves multiple distinct screens/pages (e.g. "登录页+个人中心", "login and profile"), add "screen":"<name>" to each subtask to group sections that belong to the same page. Use a concise page name (e.g. "登录", "Profile"). Subtasks sharing the same "screen" are placed in one root frame. Single-screen requests don't need "screen". Example: [{"id":"brand","label":"Brand Area","screen":"Login","region":{...}},{"id":"form","label":"Login Form","screen":"Login","region":{...}},{"id":"card","label":"User Card","screen":"Profile","region":{...}}]
- NO explanation. NO markdown. NO tool calls. NO function calls. NO [TOOL_CALL]. JUST the JSON object. Start with {.

View file

@ -9,38 +9,15 @@ category: base
---
DESIGN TYPE DETECTION:
Classify by the design's PURPOSE — reason about intent, do not keyword-match.
FIRST CHECK — single component vs full screen:
A request that names ONE atomic UI piece is a Component (Type 0), not a screen, even if the piece's name overlaps a screen type. Component triggers (any of):
- "X card" / "X 卡片" — profile card, pricing card, stat card, event card, 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" (when standalone)
- "X widget", "X panel" when no surrounding screen is implied
- A single visualization: "a chart", "a pie chart", "a stat", "a metric"
A "profile card" is a Component (Type 0), NOT a "profile screen" (Type 2).
A "pricing card" is a Component, NOT a pricing page.
Use Type 2 only when the user clearly asks for a whole screen (e.g. "login screen", "settings page", "profile page", "onboarding flow").
If Type 0:
- width=400, height=0 (auto-expand), 1 subtask
- NO status bar, NO bottom nav, NO page chrome — output is a self-contained component
- DO NOT wrap inside a phone mockup, browser frame, or any device shell
OTHERWISE classify by purpose:
Classify by the design's PURPOSE — reason about intent, do not keyword-match:
1. Multi-section page — marketing, promotional, or informational content designed to be scrolled (e.g. product sites, portfolios, company pages):
- Desktop: width=1200, height=0 (scrollable), 6-10 subtasks
- Structure: navigation - hero - content sections - CTA - footer
2. Single-task screen — full functional SCREEN focused on one user task (e.g. login screen, signup screen, settings page, profile page, full onboarding flow):
2. Single-task screen — functional UI focused on one user task (e.g. authentication, forms, settings, profiles, modals, onboarding):
- Mobile: width=375, height=812 (fixed viewport), 1-5 subtasks
- Structure: header + focused content area only, no navigation/hero/footer
- NOT a single card/badge/modal — those are Type 0 components
3. Data-rich workspace — overview screens with metrics, tables, or management panels (e.g. dashboards, admin consoles, analytics):
- Desktop: width=1200, height=0, 2-5 subtasks
@ -48,13 +25,11 @@ OTHERWISE classify by purpose:
WIDTH SELECTION RULES:
- Type 0 components — width=400, height=0
- Type 2 single-task SCREEN (login screen, profile page) — width=375, height=812
- Types 1 & 3 (multi-section / dashboard) — width=1200, height=0
- Single-task screens (type 2) - ALWAYS width=375, height=812 (mobile).
- Multi-section pages and data-rich workspaces (types 1 & 3) - width=1200, height=0 (desktop).
- This mapping is mandatory.
MOBILE vs MOCKUP:
- "mobile"/"移动端"/"手机" + screen type (login, profile, settings) = ACTUAL mobile screen (375x812), NOT a desktop page with phone mockup.
- Phone mockups are ONLY for app showcase/marketing sections when the user explicitly asks for a "mockup"/"展示"/"showcase"/"preview".
- Components (Type 0) are NEVER wrapped in a phone mockup.