feat(ai): add 'elements' section to get_design_prompt + new skill

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.
This commit is contained in:
Fini 2026-04-19 18:52:00 +08:00
parent b62e1e9bd9
commit 43a6b7b86f
5 changed files with 200 additions and 4 deletions

View file

@ -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: "<section-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`

View file

@ -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",
},

View file

@ -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<string, unknown> | 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);
});
});

View file

@ -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: [],

View file

@ -18,6 +18,7 @@ const SECTION_NAME_MAP: Record<string, string> = {
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<PromptSection, () => 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'),