From 1b4f362d59de6bb03abdca329b88a0e28c33bc4c Mon Sep 17 00:00:00 2001 From: Fini Date: Wed, 22 Apr 2026 09:40:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai):=20add=5Faction=5Fmenu=5Fv0=20?= =?UTF-8?q?=E2=80=94=20context/kebab=20dropdown=20panel=20(61st=20tool)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an N-tool for the floating card that drops from a "⋯ more" button or appears on right-click. Emits the OPEN state: vertical stack of padded icon+label rows in a white card with subtle stroke and shadow. Positioning and show/hide are caller concerns (same philosophy as add_modal_shell_v0 / add_toast_v0). Destructive items (destructive=true) render in red with role `action-menu-item-destructive` so renderers can style the hover state separately. divider_before=true on any item (except first, where it's ignored) inserts a 1px hairline above — useful for "Edit / Share / Report / Delete" grouping patterns. Wired through all 3 paths + parity/contract/design-prompt tests. Handler test covers 7 cases: simple list, destructive red fill, divider between groups, leading-divider ignored, label-only no icon, width clamp, bogus parent_id rejection. --- apps/web/server/api/mcp/exec-tool.post.ts | 2 + .../ai/__tests__/shim-server-parity.test.ts | 12 ++ .../services/ai/element-tool-shims/index.ts | 3 + .../skills/phases/generation/elements.md | 16 ++- .../src/element-builders/action-menu.ts | 118 +++++++++++++++ .../pen-core/src/element-builders/index.ts | 1 + packages/pen-core/src/index.ts | 3 + .../src/__tests__/add-action-menu-v0.test.ts | 136 ++++++++++++++++++ .../__tests__/element-tools-contract.test.ts | 1 + .../src/routes/element-tool-defs-ext.ts | 43 ++++++ .../pen-mcp/src/routes/element-tool-defs.ts | 3 + .../pen-mcp/src/tools/add-action-menu-v0.ts | 21 +++ 12 files changed, 358 insertions(+), 1 deletion(-) create mode 100644 packages/pen-core/src/element-builders/action-menu.ts create mode 100644 packages/pen-mcp/src/__tests__/add-action-menu-v0.test.ts create mode 100644 packages/pen-mcp/src/tools/add-action-menu-v0.ts diff --git a/apps/web/server/api/mcp/exec-tool.post.ts b/apps/web/server/api/mcp/exec-tool.post.ts index 2cf013276..4e7d51131 100644 --- a/apps/web/server/api/mcp/exec-tool.post.ts +++ b/apps/web/server/api/mcp/exec-tool.post.ts @@ -1,6 +1,7 @@ import { defineEventHandler, readBody, setResponseStatus } from 'h3'; import { assignIdsRecursively, + buildActionMenu, buildActivityRing, buildAlert, buildAvatar, @@ -182,6 +183,7 @@ const SERVER_BUILDERS: Record = { add_faq_item_v0: (a) => buildFaqItem(a as Parameters[0]), add_chip_input_v0: (a) => buildChipInput(a as Parameters[0]), add_empty_chart_v0: (a) => buildEmptyChart(a as Parameters[0]), + add_action_menu_v0: (a) => buildActionMenu(a as Parameters[0]), }; interface ExecToolBody { diff --git a/apps/web/src/services/ai/__tests__/shim-server-parity.test.ts b/apps/web/src/services/ai/__tests__/shim-server-parity.test.ts index 89de7c28c..1cc6e4b2e 100644 --- a/apps/web/src/services/ai/__tests__/shim-server-parity.test.ts +++ b/apps/web/src/services/ai/__tests__/shim-server-parity.test.ts @@ -8,6 +8,7 @@ vi.mock('@/canvas/canvas-text-measure', () => ({ })); import { + buildActionMenu, buildActivityRing, buildAlert, buildAvatar, @@ -417,6 +418,17 @@ const CASES: BuilderCase[] = [ args: { width: 320, height: 200, icon: 'line-chart', title: 'No data yet' }, build: (a) => buildEmptyChart(a as unknown as Parameters[0]), }, + { + toolName: 'add_action_menu_v0', + args: { + items: [ + { label: 'Edit', icon: 'pencil' }, + { label: 'Share', icon: 'share' }, + { label: 'Delete', icon: 'trash', destructive: true, divider_before: true }, + ], + }, + build: (a) => buildActionMenu(a as unknown as Parameters[0]), + }, ]; /** diff --git a/apps/web/src/services/ai/element-tool-shims/index.ts b/apps/web/src/services/ai/element-tool-shims/index.ts index 5339f36fd..50224bb5c 100644 --- a/apps/web/src/services/ai/element-tool-shims/index.ts +++ b/apps/web/src/services/ai/element-tool-shims/index.ts @@ -19,6 +19,7 @@ import { assignIdsRecursively, + buildActionMenu, buildActivityRing, buildAlert, buildAvatar, @@ -79,6 +80,7 @@ import { buildToast, buildTooltip, buildTopNavBar, + type ActionMenuParams, type ActivityRingParams, type AlertParams, type AvatarParams, @@ -290,6 +292,7 @@ export const ELEMENT_SHIMS: Record = { add_faq_item_v0: wrap(buildFaqItem), add_chip_input_v0: wrap(buildChipInput), add_empty_chart_v0: wrap(buildEmptyChart), + add_action_menu_v0: wrap(buildActionMenu), }; export function getElementShim(name: string): ElementShim | undefined { diff --git a/packages/pen-ai-skills/skills/phases/generation/elements.md b/packages/pen-ai-skills/skills/phases/generation/elements.md index 3ca73c48d..ddeb66110 100644 --- a/packages/pen-ai-skills/skills/phases/generation/elements.md +++ b/packages/pen-ai-skills/skills/phases/generation/elements.md @@ -175,7 +175,11 @@ Chart empty state: 55. Empty chart placeholder (dashed tile in chart footprint; "No data yet" message) → `add_empty_chart_v0` -56. None match → fall through to `batch_design` +Menus / floating panels: + +56. Action / context menu panel (dropdown list of icon+label rows, destructive variant supported) → `add_action_menu_v0` + +57. None match → fall through to `batch_design` **Disambiguation**: if you need a ROW of 3 metrics that should NOT scroll (e.g. a stats strip inside a card), use `add_stat_grid_v0`, NOT `add_metric_row_v0`. The grid uses `fill_container` per cell so it never overflows; the metric row uses fixed-px cells + scroll wrapper. @@ -241,6 +245,7 @@ PREFER an element tool when the spec says any of: - "FAQ", "accordion", "collapsible item", "Q&A", "expandable row", "常见问题", "折叠面板" → `add_faq_item_v0` - "chip input", "tag input", "multi-select field", "recipient list", "email chips", "标签输入", "多选标签" → `add_chip_input_v0` - "empty chart", "no data chart", "chart placeholder", "empty analytics tile", "暂无数据", "空图表" → `add_empty_chart_v0` +- "action menu", "context menu", "dropdown menu", "more menu", "kebab menu", "action sheet", "下拉菜单", "操作菜单" → `add_action_menu_v0` STILL use batch_design when: @@ -446,6 +451,15 @@ add_chip_input_v0({ label: "Send to", chips: [], placeholder: "Enter emails" }) add_empty_chart_v0({}) // default 320×200 bar-chart-2 icon add_empty_chart_v0({ icon: "line-chart", title: "No trends yet", subtitle: "Come back after 7 days" }) + +add_action_menu_v0({ + items: [ + { label: "Edit", icon: "pencil" }, + { label: "Share", icon: "share" }, + { label: "Report", icon: "flag", divider_before: true }, + { label: "Delete", icon: "trash", destructive: true }, + ], +}) ``` ## Composition pattern diff --git a/packages/pen-core/src/element-builders/action-menu.ts b/packages/pen-core/src/element-builders/action-menu.ts new file mode 100644 index 000000000..a47710aa8 --- /dev/null +++ b/packages/pen-core/src/element-builders/action-menu.ts @@ -0,0 +1,118 @@ +import type { ElementTree } from './helpers.js'; + +export interface ActionMenuItem { + /** Label shown inline. Required. */ + label: string; + /** Optional leading lucide icon. */ + icon?: string; + /** When true, renders the item in red (destructive variant). */ + destructive?: boolean; + /** When true, a 1px divider is drawn ABOVE this item. Use to group sections. */ + divider_before?: boolean; +} + +export interface ActionMenuParams { + /** Menu items (at least one). */ + items: ActionMenuItem[]; + /** Panel width in px. Default 200. Min 140. */ + width?: number; +} + +/** + * Action / context menu panel — the floating card that drops down + * from a "⋯ more" button or right-click. Rendered as an open / fully- + * expanded state; positioning and show/hide animation are caller / + * runtime concerns. Each item is a padded row (icon + label), with + * destructive items rendered red and optional dividers between + * groups. + * + * Structure: + * frame(w, fit_content, cornerRadius=10, bg=#FFFFFF, stroke=#E2E8F0, + * shadow, vertical, padding=[8,0], role='action-menu') + * ├ rectangle(h=1, fill_container, #E2E8F0, role='action-menu-divider') ← optional + * ├ frame(fill_container, horizontal, padding=[8,12], gap=10, align=center, role='action-menu-item') + * │ ├ icon_font(icon, 18×18) ← optional + * │ └ text(label, 14/500, destructive? #EF4444 : #0F172A) + * └ ... more items + * + * Destructive items use `role: 'action-menu-item-destructive'` so + * callers can restyle (e.g. hover:bg-red-50 in the renderer) without + * a structural change. + */ +export function buildActionMenu(params: ActionMenuParams): ElementTree { + const width = Math.max(140, Math.floor(params.width ?? 200)); + const items = params.items ?? []; + + const children: ElementTree[] = []; + items.forEach((item, i) => { + if (item.divider_before && i > 0) { + children.push({ + type: 'rectangle', + name: 'Divider', + role: 'action-menu-divider', + width: 'fill_container', + height: 1, + fill: [{ type: 'solid', color: '#E2E8F0' }], + }); + } + const itemChildren: ElementTree[] = []; + if (item.icon) { + itemChildren.push({ + type: 'icon_font', + name: 'Icon', + iconFontName: item.icon, + iconFontFamily: 'lucide', + width: 18, + height: 18, + fill: [{ type: 'solid', color: item.destructive ? '#EF4444' : '#334155' }], + }); + } + itemChildren.push({ + type: 'text', + name: 'Label', + content: item.label, + fontSize: 14, + fontWeight: 500, + fill: [{ type: 'solid', color: item.destructive ? '#EF4444' : '#0F172A' }], + }); + children.push({ + type: 'frame', + name: `Item (${item.label})`, + role: item.destructive ? 'action-menu-item-destructive' : 'action-menu-item', + width: 'fill_container', + height: 'fit_content', + layout: 'horizontal', + alignItems: 'center', + gap: 10, + paddingTop: 8, + paddingBottom: 8, + paddingLeft: 12, + paddingRight: 12, + children: itemChildren, + }); + }); + + return { + type: 'frame', + name: 'Action Menu', + role: 'action-menu', + width, + height: 'fit_content', + layout: 'vertical', + paddingTop: 8, + paddingBottom: 8, + cornerRadius: 10, + fill: [{ type: 'solid', color: '#FFFFFF' }], + stroke: { thickness: 1, fill: [{ type: 'solid', color: '#E2E8F0' }] }, + effects: [ + { + type: 'shadow', + color: 'rgba(15, 23, 42, 0.08)', + offsetX: 0, + offsetY: 8, + blur: 24, + }, + ], + children, + }; +} diff --git a/packages/pen-core/src/element-builders/index.ts b/packages/pen-core/src/element-builders/index.ts index a613e553b..5ba88743b 100644 --- a/packages/pen-core/src/element-builders/index.ts +++ b/packages/pen-core/src/element-builders/index.ts @@ -72,3 +72,4 @@ export { buildPagination, type PaginationParams } from './pagination.js'; export { buildFaqItem, type FaqItemParams } from './faq-item.js'; export { buildChipInput, type ChipInputParams } from './chip-input.js'; export { buildEmptyChart, type EmptyChartParams } from './empty-chart.js'; +export { buildActionMenu, type ActionMenuItem, type ActionMenuParams } from './action-menu.js'; diff --git a/packages/pen-core/src/index.ts b/packages/pen-core/src/index.ts index ad5be6fbc..eb904b72f 100644 --- a/packages/pen-core/src/index.ts +++ b/packages/pen-core/src/index.ts @@ -234,6 +234,7 @@ export { buildFaqItem, buildChipInput, buildEmptyChart, + buildActionMenu, cjkFontFamily, detectCjkScript, type ElementTree, @@ -312,4 +313,6 @@ export { type FaqItemParams, type ChipInputParams, type EmptyChartParams, + type ActionMenuItem, + type ActionMenuParams, } from './element-builders/index.js'; diff --git a/packages/pen-mcp/src/__tests__/add-action-menu-v0.test.ts b/packages/pen-mcp/src/__tests__/add-action-menu-v0.test.ts new file mode 100644 index 000000000..ce07171cd --- /dev/null +++ b/packages/pen-mcp/src/__tests__/add-action-menu-v0.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { writeFile, unlink, readFile, mkdir } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DESIGN_TOOL_DEFINITIONS, DESIGN_TOOL_NAMES } from '../routes/design-routes'; +import { handleAddActionMenuV0 } from '../tools/add-action-menu-v0'; +import { invalidateCache } from '../document-manager'; + +const TMP = join(tmpdir(), 'openpencil-add-action-menu-v0'); +const EMPTY = JSON.stringify({ version: '1.0.0', children: [] }); + +async function fresh(name: string): Promise { + const fp = join(TMP, name); + await writeFile(fp, EMPTY, 'utf-8'); + return fp; +} +async function readDoc(fp: string): Promise> { + return JSON.parse(await readFile(fp, 'utf-8')); +} +function getRoot(doc: Record): Record { + const pages = doc['pages'] as Array<{ children?: Record[] }> | undefined; + const top = doc['children'] as Record[] | undefined; + const root = (top ?? pages?.[0]?.children)?.[0]; + if (!root) throw new Error('no root'); + return root; +} + +beforeEach(async () => { + await mkdir(TMP, { recursive: true }); +}); +afterEach(async () => { + for (const f of ['a.op']) { + try { + const fp = join(TMP, f); + invalidateCache(fp); + await unlink(fp); + } catch {} + } +}); + +describe('add_action_menu_v0', () => { + it('registered; required=[items]', () => { + expect(DESIGN_TOOL_NAMES.has('add_action_menu_v0')).toBe(true); + const def = DESIGN_TOOL_DEFINITIONS.find((t) => t.name === 'add_action_menu_v0'); + expect(def?.inputSchema.required).toEqual(['items']); + }); + + it('simple list: icon + label per item', async () => { + const fp = await fresh('a.op'); + await handleAddActionMenuV0({ + filePath: fp, + items: [ + { label: 'Edit', icon: 'pencil' }, + { label: 'Share', icon: 'share' }, + ], + }); + const root = getRoot(await readDoc(fp)); + expect(root.role).toBe('action-menu'); + const items = root.children as Record[]; + expect(items.length).toBe(2); + expect(items[0].role).toBe('action-menu-item'); + const firstKids = items[0].children as Record[]; + expect(firstKids[0].iconFontName).toBe('pencil'); + expect(firstKids[1].content).toBe('Edit'); + }); + + it('destructive: red fill, role=action-menu-item-destructive', async () => { + const fp = await fresh('a.op'); + await handleAddActionMenuV0({ + filePath: fp, + items: [{ label: 'Delete', icon: 'trash', destructive: true }], + }); + const root = getRoot(await readDoc(fp)); + const item = (root.children as Record[])[0]; + expect(item.role).toBe('action-menu-item-destructive'); + const kids = item.children as Record[]; + const labelFill = kids[1].fill as Array<{ color: string }>; + expect(labelFill[0].color).toBe('#EF4444'); + }); + + it('divider_before inserts a 1px hairline ABOVE the item', async () => { + const fp = await fresh('a.op'); + await handleAddActionMenuV0({ + filePath: fp, + items: [{ label: 'Edit' }, { label: 'Delete', destructive: true, divider_before: true }], + }); + const root = getRoot(await readDoc(fp)); + const kids = root.children as Record[]; + // Edit, divider, Delete = 3 + expect(kids.length).toBe(3); + expect(kids[1].role).toBe('action-menu-divider'); + expect(kids[1].type).toBe('rectangle'); + }); + + it('divider_before on FIRST item is ignored (no leading divider)', async () => { + const fp = await fresh('a.op'); + await handleAddActionMenuV0({ + filePath: fp, + items: [{ label: 'Edit', divider_before: true }, { label: 'Delete' }], + }); + const root = getRoot(await readDoc(fp)); + const kids = root.children as Record[]; + // No leading divider — just 2 items + expect(kids.length).toBe(2); + expect(kids[0].role).toBe('action-menu-item'); + }); + + it('label-only item (no icon) omits the icon_font child', async () => { + const fp = await fresh('a.op'); + await handleAddActionMenuV0({ + filePath: fp, + items: [{ label: 'Just text' }], + }); + const root = getRoot(await readDoc(fp)); + const item = (root.children as Record[])[0]; + const kids = item.children as Record[]; + expect(kids.length).toBe(1); + expect(kids[0].type).toBe('text'); + }); + + it('width clamps (below 140 → 140)', async () => { + const fp = await fresh('a.op'); + await handleAddActionMenuV0({ filePath: fp, items: [{ label: 'x' }], width: 50 }); + const root = getRoot(await readDoc(fp)); + expect(root.width).toBe(140); + }); + + it('throws on bogus parent_id AND leaves file untouched', async () => { + const fp = await fresh('a.op'); + const before = await readFile(fp, 'utf-8'); + await expect( + handleAddActionMenuV0({ filePath: fp, items: [{ label: 'x' }], parent_id: 'nope' }), + ).rejects.toThrow(/parent_id.*not found/); + expect(await readFile(fp, 'utf-8')).toBe(before); + }); +}); diff --git a/packages/pen-mcp/src/__tests__/element-tools-contract.test.ts b/packages/pen-mcp/src/__tests__/element-tools-contract.test.ts index c2782e4aa..acfe55bd8 100644 --- a/packages/pen-mcp/src/__tests__/element-tools-contract.test.ts +++ b/packages/pen-mcp/src/__tests__/element-tools-contract.test.ts @@ -69,6 +69,7 @@ const ELEMENT_TOOL_NAMES = [ 'add_faq_item_v0', 'add_chip_input_v0', 'add_empty_chart_v0', + 'add_action_menu_v0', ]; describe('element tools — v0-MUST contract', () => { diff --git a/packages/pen-mcp/src/routes/element-tool-defs-ext.ts b/packages/pen-mcp/src/routes/element-tool-defs-ext.ts index 5d5a75537..cf0bc57c9 100644 --- a/packages/pen-mcp/src/routes/element-tool-defs-ext.ts +++ b/packages/pen-mcp/src/routes/element-tool-defs-ext.ts @@ -1063,6 +1063,49 @@ export const ELEMENT_TOOL_DEFINITIONS_EXT = [ required: ['label'], }, }, + { + name: 'add_action_menu_v0', + description: + 'Action / context menu panel — the floating card that drops from a "⋯ more" button or appears on ' + + 'right-click. Emits the OPEN state: vertical stack of padded rows (optional leading icon + ' + + 'label), white card with subtle stroke + shadow. Mark items with destructive=true to render ' + + 'red (e.g. "Delete"); use divider_before=true on an item to separate groups (like the "Share / ' + + 'Report / Delete" grouping). Positioning and show/hide animation are caller concerns. Use for ' + + '"context menu", "dropdown menu", "more menu", "kebab menu", "action sheet", "下拉菜单", "操作菜单". ' + + 'schemaVersion 1.0', + inputSchema: { + type: 'object' as const, + properties: { + schemaVersion: schemaVersionProp, + filePath: filePathProp, + items: { + type: 'array', + description: + 'Menu items. Each needs `label`; `icon` + `destructive` + `divider_before` optional.', + items: { + type: 'object', + properties: { + label: { type: 'string' }, + icon: { type: 'string', description: 'Optional lucide icon name' }, + destructive: { + type: 'boolean', + description: 'When true, label and icon render red (for Delete / Remove)', + }, + divider_before: { + type: 'boolean', + description: 'When true, draws a 1px divider ABOVE this item (group boundary)', + }, + }, + required: ['label'], + }, + }, + width: { type: 'number', description: 'Panel width in px (default 200, min 140)' }, + parent_id: parentIdProp, + pageId: pageIdProp, + }, + required: ['items'], + }, + }, { name: 'add_empty_chart_v0', description: diff --git a/packages/pen-mcp/src/routes/element-tool-defs.ts b/packages/pen-mcp/src/routes/element-tool-defs.ts index 48df2eb79..304db5419 100644 --- a/packages/pen-mcp/src/routes/element-tool-defs.ts +++ b/packages/pen-mcp/src/routes/element-tool-defs.ts @@ -75,6 +75,7 @@ import { handleAddPaginationV0 } from '../tools/add-pagination-v0'; import { handleAddFaqItemV0 } from '../tools/add-faq-item-v0'; import { handleAddChipInputV0 } from '../tools/add-chip-input-v0'; import { handleAddEmptyChartV0 } from '../tools/add-empty-chart-v0'; +import { handleAddActionMenuV0 } from '../tools/add-action-menu-v0'; import { ELEMENT_TOOL_DEFINITIONS_BASE } from './element-tool-defs-base'; import { ELEMENT_TOOL_DEFINITIONS_EXT } from './element-tool-defs-ext'; @@ -210,6 +211,8 @@ export async function handleElementToolCall(name: string, a: any): Promise>> { + await ensureParentExists(params); + const m = buildActionMenu(params); + assignIdsRecursively(m); + return insertElementTree({ binding: 'actionMenu', tree: m, ...params }); +}