From 43a6b7b86f130ba37a466b2e38664a02dd31dfdf Mon Sep 17 00:00:00 2001 From: Fini Date: Sun, 19 Apr 2026 18:52:00 +0800 Subject: [PATCH] feat(ai): add 'elements' section to get_design_prompt + new skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 4 of N-tool element design. Teach external MCP clients (Claude Code / Codex / Gemini CLI / Cursor) when to reach for a narrow element tool vs fall through to batch_design. New skill: packages/pen-ai-skills/skills/phases/generation/elements.md - Decision tree mapping item shape → tool (card_row / metric_row / nav_chip_row / bottom_nav / activity_ring) - PREFER vs STILL-USE-batch_design conditions with concrete spec phrases ("horizontal scrolling cards", "KPI cards", "bottom nav") - Minimal usage examples for all 5 tools - Composition pattern: build section via batch_design → insert row with parent_id → post-hoc style via batch_design U-op - Invariants enumeration (wrapper / id assignment / role set) so AI knows what NOT to rebuild - Failure-mode guidance: if tool throws, inspect message and switch strategy rather than retry - Priority 14 / budget 1500 tokens design-prompt.ts: register 'elements' in SECTION_NAME_MAP, PromptSection type, SECTION_MAP dispatch. design-routes.ts: add 'elements' to get_design_prompt.section enum + updated description. _generated/skill-registry.ts regenerates at vite build time (43 → 44 skills; gitignored, not committed). Tests: - 10 new unit tests (design-prompt-elements.test.ts) cover registration, content invariants, decision-tree presence, fallback teaching, composition pattern, invariants naming, unknown-section fallback behavior - d0-parity-spike snapshot updated (get_design_prompt definition intentionally changed) MCP live e2e smoke via StdioClientTransport confirms ListTools enum shows 11 values including 'elements', CallTool returns 4122 char content, all 5 tools named, all structural assertions pass. 98/98 pen-mcp tests pass. format + tsc green. Bundle rebuilt. --- .../skills/phases/generation/elements.md | 105 ++++++++++++++++++ .../d0-parity-spike.test.ts.snap | 5 +- .../__tests__/design-prompt-elements.test.ts | 84 ++++++++++++++ packages/pen-mcp/src/routes/design-routes.ts | 7 +- packages/pen-mcp/src/tools/design-prompt.ts | 3 + 5 files changed, 200 insertions(+), 4 deletions(-) create mode 100644 packages/pen-ai-skills/skills/phases/generation/elements.md create mode 100644 packages/pen-mcp/src/__tests__/design-prompt-elements.test.ts diff --git a/packages/pen-ai-skills/skills/phases/generation/elements.md b/packages/pen-ai-skills/skills/phases/generation/elements.md new file mode 100644 index 000000000..355664b1c --- /dev/null +++ b/packages/pen-ai-skills/skills/phases/generation/elements.md @@ -0,0 +1,105 @@ +--- +name: elements +description: N-tool element family reference — when and how to call add_card_row_v0 / add_metric_row_v0 / add_nav_chip_row_v0 / add_bottom_nav_v0 / add_activity_ring_v0 instead of hand-building via batch_design +phase: [generation] +trigger: null +priority: 14 +budget: 1500 +category: base +--- + +ELEMENT TOOLS (schema-constrained alternatives to batch_design): + +These narrow MCP tools emit well-known structures that batch_design frequently gets wrong on non-Claude models (overflow, wrong role, anti-pattern layout). Each is shape-locked — you pick the tool by matching intent, then supply only content. Visual styling (color, font) stays orthogonal: override via a follow-up batch_design U-op if needed. + +## Decision tree (pick first match) + +1. Row of items with title + subtitle + optional icon → `add_card_row_v0` +2. Row of items with small label + big numeric value → `add_metric_row_v0` +3. Row of filter chips / category tabs (label + optional icon, active state) → `add_nav_chip_row_v0` +4. Bottom tab bar (inline flow, 3-5 nav items) → `add_bottom_nav_v0` +5. Apple-style progress ring with centered text → `add_activity_ring_v0` +6. None match → fall through to `batch_design` + +## When to use vs batch_design + +PREFER an element tool when the spec says any of: + +- "horizontal scrolling cards", "swipeable row", "chip row", "pills" +- "metric tiles", "KPI cards", "dashboard stats", "Steps / Kcal / Sleep" row +- "category filter chips", "quick-access shortcuts" +- "bottom nav", "tab bar", "tabbar", "底部导航" +- "activity ring", "progress ring", "circular progress", "Apple health ring" + +STILL use batch_design when: + +- The row's items are structurally heterogeneous (can't be uniformly described by a single items[] shape) +- You need to build a larger composite (e.g. a whole section containing a scroll row + other content — build the section via batch_design, then insert the row via element tool with parent_id) +- Post-hoc styling: once the element tool has laid the structure, use batch_design U-ops to apply fills, typography, or theme variables + +## Minimal usage + +``` +add_card_row_v0({ + items: [ + { title: "Hiit", subtitle: "30 min", icon: "flame" }, + { title: "Strength", subtitle: "45 min", icon: "dumbbell" }, + { title: "Yoga", subtitle: "25 min", icon: "leaf" }, + ], +}) + +add_metric_row_v0({ + items: [ + { label: "Steps", value: "8,432", icon: "activity" }, + { label: "Kcal", value: "512", icon: "flame" }, + { label: "Sleep", value: "7h 24m", icon: "moon" }, + ], +}) + +add_nav_chip_row_v0({ + items: [ + { label: "All", active: true }, // label-only chips OK + { label: "Videos", icon: "video" }, + { label: "Photos", icon: "image" }, + ], +}) + +add_bottom_nav_v0({ + items: [ + { title: "Home", icon: "home", active: true }, + { title: "Search", icon: "search" }, + { title: "Profile", icon: "user" }, + ], +}) + +add_activity_ring_v0({ + center_text: "8,432", + size: 80, + thickness: 8, +}) +``` + +## Composition pattern + +For a dashboard that needs a metric row inside a page: + +1. Build the page structure via `batch_design` (root frame + section container) — note the section's id +2. Call `add_metric_row_v0({ parent_id: "", items: [...] })` to insert the row under that section +3. Optional: a second `batch_design` U-op to style (fill, theme variables) + +## Invariants you don't need to think about + +The tool guarantees — you cannot break them from the input side: + +- Wrapper structure (`scroll-row-wrapper` + `scroll-row` + fixed-width children) for row tools — overflow-safe +- `bottom-tab-bar` is inline (no empty spacer sibling needed, do NOT add one) +- Activity ring is frame+cornerRadius=size/2+stroke+centered text — NEVER emit ellipse+sibling text for rings +- Every emitted node has a unique id (you can reference it later) +- Roles are set (`card` / `metric-tile` / `nav-chip` / `nav-chip-active` / `bottom-tab-bar` / `nav-item` / `nav-item-active` / `activity-ring`) + +## Failure mode + +If the tool throws, **do NOT retry with the same arguments** — the tool has already verified the failure is real (pre-check rejected the parent_id, or post-check detected a silent DSL no-op). Re-throwing from your side wastes tokens. Inspect the error message and either: + +- Fix `parent_id` (ensure the referenced node exists and has no `"` or `\` in its id) +- Switch to `batch_design` with the structure taught in `overflow.md` / `layout.md` diff --git a/packages/pen-mcp/src/__tests__/__snapshots__/d0-parity-spike.test.ts.snap b/packages/pen-mcp/src/__tests__/__snapshots__/d0-parity-spike.test.ts.snap index 2b7f158c0..a4906eaf5 100644 --- a/packages/pen-mcp/src/__tests__/__snapshots__/d0-parity-spike.test.ts.snap +++ b/packages/pen-mcp/src/__tests__/__snapshots__/d0-parity-spike.test.ts.snap @@ -3,11 +3,11 @@ exports[`D0 parity spike — additive & gated tool registration > pre-D0 production design tool DEFINITIONS are unchanged (snapshot) > pre-d0-design-tool-definitions 1`] = ` [ { - "description": "Get design knowledge prompt. Use "section" to retrieve a focused subset instead of the full prompt. Sections: schema (PenNode types), layout (flexbox rules), roles (semantic roles), text (typography/CJK/copywriting), style (visual style policy), icons (icon names), examples (design examples), guidelines (design tips), planning (layered workflow guide). Omit section for the full prompt.", + "description": "Get design knowledge prompt. Use "section" to retrieve a focused subset instead of the full prompt. Sections: schema (PenNode types), layout (flexbox rules), roles (semantic roles), text (typography/CJK/copywriting), style (visual style policy), icons (icon names), examples (design examples), guidelines (design tips), planning (layered workflow guide), elements (N-tool element reference — add_card_row_v0 / add_metric_row_v0 / add_nav_chip_row_v0 / add_bottom_nav_v0 / add_activity_ring_v0 usage + decision tree). Omit section for the full prompt.", "inputSchema": { "properties": { "section": { - "description": "Which section of design knowledge to retrieve. Default: all. Use "planning" for layered generation workflow.", + "description": "Which section of design knowledge to retrieve. Default: all. Use "planning" for layered generation workflow; "elements" for N-tool element tool reference.", "enum": [ "all", "schema", @@ -19,6 +19,7 @@ exports[`D0 parity spike — additive & gated tool registration > pre-D0 product "examples", "guidelines", "planning", + "elements", ], "type": "string", }, diff --git a/packages/pen-mcp/src/__tests__/design-prompt-elements.test.ts b/packages/pen-mcp/src/__tests__/design-prompt-elements.test.ts new file mode 100644 index 000000000..7e5d16933 --- /dev/null +++ b/packages/pen-mcp/src/__tests__/design-prompt-elements.test.ts @@ -0,0 +1,84 @@ +/** + * Tests for the `elements` section of get_design_prompt. + * + * The elements section is the AI-facing index of the N-tool element + * family (add_card_row_v0 / add_metric_row_v0 / add_nav_chip_row_v0 / + * add_bottom_nav_v0 / add_activity_ring_v0). It teaches the LLM WHEN + * to reach for a narrow tool vs fall through to batch_design. + * + * Spec: openpencil-docs/superpowers/specs/2026-04-19-element-tools-v0.md §5 step 4 + */ + +import { describe, it, expect } from 'vitest'; +import { buildDesignPrompt, listPromptSections } from '../tools/design-prompt'; +import { DESIGN_TOOL_DEFINITIONS } from '../routes/design-routes'; + +describe('get_design_prompt — elements section', () => { + it('"elements" appears in listPromptSections()', () => { + expect(listPromptSections()).toContain('elements'); + }); + + it('"elements" appears in get_design_prompt.inputSchema.section.enum', () => { + const def = DESIGN_TOOL_DEFINITIONS.find((t) => t.name === 'get_design_prompt'); + expect(def).toBeDefined(); + const sectionProp = (def?.inputSchema.properties as Record | undefined) + ?.section as { enum?: string[] } | undefined; + expect(sectionProp?.enum).toContain('elements'); + }); + + it('buildDesignPrompt("elements") returns non-empty content', () => { + const content = buildDesignPrompt('elements'); + expect(content.length).toBeGreaterThan(0); + expect(content).toMatch(/ELEMENT TOOLS/i); + }); + + it('elements section names all 5 production element tools', () => { + const content = buildDesignPrompt('elements'); + expect(content).toContain('add_card_row_v0'); + expect(content).toContain('add_metric_row_v0'); + expect(content).toContain('add_nav_chip_row_v0'); + expect(content).toContain('add_bottom_nav_v0'); + expect(content).toContain('add_activity_ring_v0'); + }); + + it('elements section teaches a decision tree (pick first match)', () => { + const content = buildDesignPrompt('elements'); + expect(content).toMatch(/decision tree/i); + // Decision tree should reference the "match items shape → tool" pattern + expect(content).toMatch(/title.*subtitle|label.*value|chip|tab bar|ring/); + }); + + it('elements section teaches when to fall back to batch_design', () => { + const content = buildDesignPrompt('elements'); + expect(content).toMatch(/batch_design/); + // Must name at least one fallback condition so the AI knows escape is OK + expect(content).toMatch(/heterogeneous|composite|post-hoc|styling/i); + }); + + it('elements section teaches the composition pattern (parent_id for row-in-section)', () => { + const content = buildDesignPrompt('elements'); + expect(content).toMatch(/parent_id/i); + }); + + it('elements section names the invariants tools guarantee', () => { + const content = buildDesignPrompt('elements'); + // The AI should know it doesn't need to reproduce wrapper structure + expect(content).toMatch(/wrapper|clipContent|overflow|invariant/i); + expect(content).toMatch(/unique id/i); + }); + + it('elements section NOT returned when section argument omitted, but IS part of full prompt', () => { + // Sanity: default / full prompt should not crash + const full = buildDesignPrompt(); + expect(full.length).toBeGreaterThan(0); + // Passing 'elements' explicitly returns just that section (smaller than full) + const elementsOnly = buildDesignPrompt('elements'); + expect(elementsOnly.length).toBeLessThan(full.length); + }); + + it('unknown section string falls back to full prompt (documented behavior)', () => { + const unknown = buildDesignPrompt('does-not-exist'); + const full = buildDesignPrompt(); + expect(unknown).toBe(full); + }); +}); diff --git a/packages/pen-mcp/src/routes/design-routes.ts b/packages/pen-mcp/src/routes/design-routes.ts index 6f7db03c6..c13129ecc 100644 --- a/packages/pen-mcp/src/routes/design-routes.ts +++ b/packages/pen-mcp/src/routes/design-routes.ts @@ -17,7 +17,9 @@ export const DESIGN_TOOL_DEFINITIONS = [ description: 'Get design knowledge prompt. Use "section" to retrieve a focused subset instead of the full prompt. ' + 'Sections: schema (PenNode types), layout (flexbox rules), roles (semantic roles), text (typography/CJK/copywriting), ' + - 'style (visual style policy), icons (icon names), examples (design examples), guidelines (design tips), planning (layered workflow guide). ' + + 'style (visual style policy), icons (icon names), examples (design examples), guidelines (design tips), ' + + 'planning (layered workflow guide), elements (N-tool element reference — add_card_row_v0 / add_metric_row_v0 / ' + + 'add_nav_chip_row_v0 / add_bottom_nav_v0 / add_activity_ring_v0 usage + decision tree). ' + 'Omit section for the full prompt.', inputSchema: { type: 'object' as const, @@ -35,9 +37,10 @@ export const DESIGN_TOOL_DEFINITIONS = [ 'examples', 'guidelines', 'planning', + 'elements', ], description: - 'Which section of design knowledge to retrieve. Default: all. Use "planning" for layered generation workflow.', + 'Which section of design knowledge to retrieve. Default: all. Use "planning" for layered generation workflow; "elements" for N-tool element tool reference.', }, }, required: [], diff --git a/packages/pen-mcp/src/tools/design-prompt.ts b/packages/pen-mcp/src/tools/design-prompt.ts index 2c7ef98e0..3801c7a5e 100644 --- a/packages/pen-mcp/src/tools/design-prompt.ts +++ b/packages/pen-mcp/src/tools/design-prompt.ts @@ -18,6 +18,7 @@ const SECTION_NAME_MAP: Record = { copywriting: 'copywriting', cjk: 'cjk-typography', examples: 'examples', + elements: 'elements', }; /** Look up a skill by legacy section key or skill name. */ @@ -235,6 +236,7 @@ type PromptSection = | 'examples' | 'guidelines' | 'planning' + | 'elements' | 'design-md' | 'copywriting' | 'overflow' @@ -264,6 +266,7 @@ const SECTION_MAP: Record string> = { examples: () => getSkillContent('examples'), guidelines: () => DESIGN_GUIDELINES, planning: () => PLANNING_GUIDE, + elements: () => getSkillContent('elements'), 'design-md': () => _designMdContent ?? 'No design.md loaded in the current document.', copywriting: () => getSkillContent('copywriting'), overflow: () => getSkillContent('overflow'),