From 5a027a4f4ee4003f81f87be3e66bd05db5c43151 Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Sat, 9 May 2026 21:03:00 +0800 Subject: [PATCH] test(shell-core): assert W3C field readback in gesture re-export tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/web/server/api/ai/icon.ts | 19 ----- .../ai/__tests__/icon-resolver.test.ts | 37 --------- apps/web/src/services/ai/icon-dictionary.ts | 19 ----- apps/web/src/services/ai/icon-resolver.ts | 79 +++---------------- .../services/ai/orchestrator-plan-classify.ts | 19 ----- .../src/services/ai/orchestrator-sub-agent.ts | 5 +- apps/web/src/services/ai/orchestrator.ts | 5 +- .../tests/gesture_re_export.rs | 44 ++++++++--- .../skills/knowledge/icon-catalog.md | 46 +++-------- .../skills/phases/generation/layout.md | 1 - .../skills/phases/planning/decomposition.md | 18 +---- .../skills/phases/planning/design-type.md | 33 +------- 12 files changed, 63 insertions(+), 262 deletions(-) delete mode 100644 apps/web/src/services/ai/orchestrator-plan-classify.ts diff --git a/apps/web/server/api/ai/icon.ts b/apps/web/server/api/ai/icon.ts index c15c4f0f6..1cd4b327a 100644 --- a/apps/web/server/api/ai/icon.ts +++ b/apps/web/server/api/ai/icon.ts @@ -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 = { 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', diff --git a/apps/web/src/services/ai/__tests__/icon-resolver.test.ts b/apps/web/src/services/ai/__tests__/icon-resolver.test.ts index 5dfa8c611..836565365 100644 --- a/apps/web/src/services/ai/__tests__/icon-resolver.test.ts +++ b/apps/web/src/services/ai/__tests__/icon-resolver.test.ts @@ -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 = { diff --git a/apps/web/src/services/ai/icon-dictionary.ts b/apps/web/src/services/ai/icon-dictionary.ts index a169305ee..0a72e8c5d 100644 --- a/apps/web/src/services/ai/icon-dictionary.ts +++ b/apps/web/src/services/ai/icon-dictionary.ts @@ -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 = { 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', diff --git a/apps/web/src/services/ai/icon-resolver.ts b/apps/web/src/services/ai/icon-resolver.ts index 3b73af9dc..cc5c6144d 100644 --- a/apps/web/src/services/ai/icon-resolver.ts +++ b/apps/web/src/services/ai/icon-resolver.ts @@ -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]; diff --git a/apps/web/src/services/ai/orchestrator-plan-classify.ts b/apps/web/src/services/ai/orchestrator-plan-classify.ts deleted file mode 100644 index f478efc0c..000000000 --- a/apps/web/src/services/ai/orchestrator-plan-classify.ts +++ /dev/null @@ -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; -} diff --git a/apps/web/src/services/ai/orchestrator-sub-agent.ts b/apps/web/src/services/ai/orchestrator-sub-agent.ts index 5682b749a..896dba586 100644 --- a/apps/web/src/services/ai/orchestrator-sub-agent.ts +++ b/apps/web/src/services/ai/orchestrator-sub-agent.ts @@ -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.`; } diff --git a/apps/web/src/services/ai/orchestrator.ts b/apps/web/src/services/ai/orchestrator.ts index 924d7d6ac..547616f32 100644 --- a/apps/web/src/services/ai/orchestrator.ts +++ b/apps/web/src/services/ai/orchestrator.ts @@ -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' }, diff --git a/crates/openpencil-shell-core/tests/gesture_re_export.rs b/crates/openpencil-shell-core/tests/gesture_re_export.rs index fe61f751f..e5f141999 100644 --- a/crates/openpencil-shell-core/tests/gesture_re_export.rs +++ b/crates/openpencil-shell-core/tests/gesture_re_export.rs @@ -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); } diff --git a/packages/pen-ai-skills/skills/knowledge/icon-catalog.md b/packages/pen-ai-skills/skills/knowledge/icon-catalog.md index a8aec2862..6156b835f 100644 --- a/packages/pen-ai-skills/skills/knowledge/icon-catalog.md +++ b/packages/pen-ai-skills/skills/knowledge/icon-catalog.md @@ -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 diff --git a/packages/pen-ai-skills/skills/phases/generation/layout.md b/packages/pen-ai-skills/skills/phases/generation/layout.md index fef29301c..e7dd6fa36 100644 --- a/packages/pen-ai-skills/skills/phases/generation/layout.md +++ b/packages/pen-ai-skills/skills/phases/generation/layout.md @@ -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): diff --git a/packages/pen-ai-skills/skills/phases/planning/decomposition.md b/packages/pen-ai-skills/skills/phases/planning/decomposition.md index f142c698d..b64fc4b03 100644 --- a/packages/pen-ai-skills/skills/phases/planning/decomposition.md +++ b/packages/pen-ai-skills/skills/phases/planning/decomposition.md @@ -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":"" 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 {. diff --git a/packages/pen-ai-skills/skills/phases/planning/design-type.md b/packages/pen-ai-skills/skills/phases/planning/design-type.md index a67fd36cf..39ad5ccc6 100644 --- a/packages/pen-ai-skills/skills/phases/planning/design-type.md +++ b/packages/pen-ai-skills/skills/phases/planning/design-type.md @@ -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.