From af1ddd1ad2cdf5674c005439fb5be4efc845d1eb Mon Sep 17 00:00:00 2001 From: Fini Date: Tue, 28 Apr 2026 06:30:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai):=20add=5Fsetting=5Frow=5Fv0=20?= =?UTF-8?q?=E2=80=94=20settings=20menu=20row=20(91st=20tool)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Leading icon + (title over optional subtitle) + trailing control with 4 variants: chevron / value text / switch / badge. Distinct from add_list_row_v0 (trailing is always icon, no switch/value/badge) and add_form_field_v0 (label-above-input for forms). Wires all 9 touchpoints: pen-core builder + index + barrel re-export, pen-mcp handler + dispatcher case + ext-4 schema, apps/web shim + Nitro SERVER_BUILDERS, elements.md decision tree #83 + PREFER mapping, elements-cookbook arg-shape examples, plus shim-server parity case. --- apps/web/server/api/mcp/exec-tool.post.ts | 2 + .../ai/__tests__/shim-server-parity.test.ts | 11 + .../services/ai/element-tool-shims/index.ts | 3 + .../phases/generation/elements-cookbook.md | 18 ++ .../skills/phases/generation/elements.md | 7 +- .../pen-core/src/element-builders/index.ts | 1 + .../src/element-builders/setting-row.ts | 195 ++++++++++++++++++ packages/pen-core/src/index.ts | 3 + .../src/routes/element-tool-defs-ext-4.ts | 45 ++++ .../pen-mcp/src/routes/element-tool-defs.ts | 3 + .../pen-mcp/src/tools/add-setting-row-v0.ts | 18 ++ 11 files changed, 305 insertions(+), 1 deletion(-) create mode 100644 packages/pen-core/src/element-builders/setting-row.ts create mode 100644 packages/pen-mcp/src/tools/add-setting-row-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 566b37e7c..be527d00b 100644 --- a/apps/web/server/api/mcp/exec-tool.post.ts +++ b/apps/web/server/api/mcp/exec-tool.post.ts @@ -61,6 +61,7 @@ import { buildLegendItem, buildInboxMessage, buildProfileHeader, + buildSettingRow, buildKbd, buildLink, buildListRow, @@ -245,6 +246,7 @@ const SERVER_BUILDERS: Record = { add_legend_item_v0: (a) => buildLegendItem(a as Parameters[0]), add_inbox_message_v0: (a) => buildInboxMessage(a as Parameters[0]), add_profile_header_v0: (a) => buildProfileHeader(a as Parameters[0]), + add_setting_row_v0: (a) => buildSettingRow(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 0f2cf4905..008941350 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 @@ -42,6 +42,7 @@ import { buildLegendItem, buildInboxMessage, buildProfileHeader, + buildSettingRow, buildDatePicker, buildDivider, buildEmptyChart, @@ -659,6 +660,16 @@ const CASES: BuilderCase[] = [ args: { name: 'Sarah Lee', handle: '@sarah', bio: 'Designer at Acme.', initial: 'SL' }, build: (a) => buildProfileHeader(a as unknown as Parameters[0]), }, + { + toolName: 'add_setting_row_v0', + args: { + title: 'Notifications', + subtitle: 'Push, email, in-app', + leading_icon: 'bell', + trailing: { kind: 'switch', on: true }, + }, + build: (a) => buildSettingRow(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 e3e8e37c5..69b541171 100644 --- a/apps/web/src/services/ai/element-tool-shims/index.ts +++ b/apps/web/src/services/ai/element-tool-shims/index.ts @@ -79,6 +79,7 @@ import { buildLegendItem, buildInboxMessage, buildProfileHeader, + buildSettingRow, buildKbd, buildLink, buildListRow, @@ -170,6 +171,7 @@ import { type LegendItemParams, type InboxMessageParams, type ProfileHeaderParams, + type SettingRowParams, type KbdParams, type LinkParams, type ListRowParams, @@ -380,6 +382,7 @@ export const ELEMENT_SHIMS: Record = { add_legend_item_v0: wrap(buildLegendItem), add_inbox_message_v0: wrap(buildInboxMessage), add_profile_header_v0: wrap(buildProfileHeader), + add_setting_row_v0: wrap(buildSettingRow), }; export function getElementShim(name: string): ElementShim | undefined { diff --git a/packages/pen-ai-skills/skills/phases/generation/elements-cookbook.md b/packages/pen-ai-skills/skills/phases/generation/elements-cookbook.md index 3fb11132f..7badc7a56 100644 --- a/packages/pen-ai-skills/skills/phases/generation/elements-cookbook.md +++ b/packages/pen-ai-skills/skills/phases/generation/elements-cookbook.md @@ -390,4 +390,22 @@ add_inbox_message_v0({ timestamp: "10:42 AM", unread: true, }) + +add_setting_row_v0({ + title: "Notifications", + subtitle: "Push, email, in-app", + leading_icon: "bell", + trailing: { kind: "switch", on: true }, +}) + +add_setting_row_v0({ + title: "Language", + leading_icon: "globe", + trailing: { kind: "value", value: "English" }, +}) + +add_setting_row_v0({ + title: "What's new", + trailing: { kind: "badge", value: "New" }, +}) ``` diff --git a/packages/pen-ai-skills/skills/phases/generation/elements.md b/packages/pen-ai-skills/skills/phases/generation/elements.md index e6ea3c222..3048e650d 100644 --- a/packages/pen-ai-skills/skills/phases/generation/elements.md +++ b/packages/pen-ai-skills/skills/phases/generation/elements.md @@ -268,7 +268,11 @@ Mail / inbox: 82. Inbox / email list row (sender + subject + preview + unread dot) → `add_inbox_message_v0` -83. None match → fall through to `batch_design` +Settings / preferences: + +83. Settings menu row (icon + title + subtitle + trailing chevron / value / switch / badge) → `add_setting_row_v0` + +84. 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. @@ -364,6 +368,7 @@ PREFER an element tool when the spec says any of: - "share row", "share to social", "share buttons", "post share", "send via", "分享按钮组" → `add_share_row_v0` (horizontal circular icon buttons each labeled below). Different from `add_social_login_row_v0` (sign-in CTAs). - "chart legend", "legend item", "legend entry", "数据图例", "图例条目" → `add_legend_item_v0` (marker + label + optional value). Different from `add_status_badge_v0` (semantic dot + text). - "inbox row", "email row", "message list cell", "mail item", "email preview", "邮件条目", "收件箱条目" → `add_inbox_message_v0` (sender + subject + preview + timestamp + unread dot). Different from `add_notification_row_v0` (single title + body) and `add_list_row_v0` (no timestamp). +- "settings row", "preference row", "menu item with toggle", "settings list item", "设置项", "偏好项", "开关行" → `add_setting_row_v0` (icon + title/subtitle + trailing chevron/value/switch/badge). Different from `add_list_row_v0` (trailing always icon, no switch/value/badge variants) and `add_form_field_v0` (label-above-input pattern for forms). STILL use batch_design when: diff --git a/packages/pen-core/src/element-builders/index.ts b/packages/pen-core/src/element-builders/index.ts index c38a465a9..0c002c716 100644 --- a/packages/pen-core/src/element-builders/index.ts +++ b/packages/pen-core/src/element-builders/index.ts @@ -122,3 +122,4 @@ export { type InputWithActionKind, } from './input-with-action.js'; export { buildCookieBanner, type CookieBannerParams } from './cookie-banner.js'; +export { buildSettingRow, type SettingRowParams, type SettingRowTrailing } from './setting-row.js'; diff --git a/packages/pen-core/src/element-builders/setting-row.ts b/packages/pen-core/src/element-builders/setting-row.ts new file mode 100644 index 000000000..6a537f124 --- /dev/null +++ b/packages/pen-core/src/element-builders/setting-row.ts @@ -0,0 +1,195 @@ +import type { ElementTree } from './helpers.js'; + +export type SettingRowTrailing = + | { kind: 'chevron' } + | { kind: 'value'; value: string } + | { kind: 'switch'; on: boolean } + | { kind: 'badge'; value: string }; + +export interface SettingRowParams { + /** Title text on the left (15/500). */ + title: string; + /** Optional second-line text below the title (13/400, muted). */ + subtitle?: string; + /** Optional leading lucide icon slug (24×24). */ + leading_icon?: string; + /** + * Trailing control. Default: chevron. + * - chevron: a `chevron-right` icon (link-style row) + * - value: muted text (e.g. "English") with no chevron + * - switch: 36×22 pill, on/off + * - badge: small colored capsule with text (e.g. "New") + */ + trailing?: SettingRowTrailing; +} + +const MUTED = '#64748B'; +const FG = '#0F172A'; +const PRIMARY = '#2563EB'; +const TRACK_OFF = '#CBD5E1'; +const KNOB = '#FFFFFF'; +const BADGE_BG = '#DBEAFE'; +const BADGE_FG = '#1D4ED8'; + +function buildTrailing(t: SettingRowTrailing): ElementTree { + if (t.kind === 'value') { + return { + type: 'text', + name: 'Trailing Value', + role: 'setting-row-value', + content: t.value, + fontSize: 14, + fontWeight: 400, + fill: [{ type: 'solid', color: MUTED }], + }; + } + if (t.kind === 'switch') { + return { + type: 'frame', + name: 'Switch', + role: 'setting-row-switch', + width: 36, + height: 22, + cornerRadius: 11, + fill: [{ type: 'solid', color: t.on ? PRIMARY : TRACK_OFF }], + layout: 'horizontal', + alignItems: 'center', + justifyContent: t.on ? 'flex-end' : 'flex-start', + padding: [0, 3], + children: [ + { + type: 'frame', + name: 'Knob', + role: 'setting-row-switch-knob', + width: 16, + height: 16, + cornerRadius: 8, + fill: [{ type: 'solid', color: KNOB }], + children: [], + }, + ], + }; + } + if (t.kind === 'badge') { + return { + type: 'frame', + name: 'Badge', + role: 'setting-row-badge', + width: 'fit_content', + height: 'fit_content', + cornerRadius: 4, + fill: [{ type: 'solid', color: BADGE_BG }], + padding: [2, 8], + children: [ + { + type: 'text', + name: 'Badge Text', + role: 'setting-row-badge-text', + content: t.value, + fontSize: 12, + fontWeight: 600, + fill: [{ type: 'solid', color: BADGE_FG }], + }, + ], + }; + } + return { + type: 'icon_font', + name: 'Chevron', + role: 'setting-row-chevron', + iconFontName: 'chevron-right', + iconFontFamily: 'lucide', + width: 20, + height: 20, + fill: [{ type: 'solid', color: MUTED }], + }; +} + +/** + * Settings menu row — leading icon + (title over optional subtitle) + + * trailing control. Distinct from `add_list_row_v0` (generic; trailing + * is always an icon, no switch/value/badge variants) and + * `add_form_field_v0` (label-above-input, used inside forms not menus). + * + * Structure: + * frame(horizontal, fill_container, fit_content, padding=[14,16], + * gap=12, alignItems=center, role='setting-row') + * ├ [icon_font 24×24] (if leading_icon) + * ├ frame(vertical, fill_container, gap=2, role='setting-row-text') + * │ ├ text(title, 15/500) + * │ └ text(subtitle, 13/400, muted) (if subtitle) + * └ (chevron | value text | switch | badge) + * + * The text-stack uses fill_container so the title can wrap and push + * row height naturally — same no-overlap invariant as `add_list_row_v0`. + */ +export function buildSettingRow(params: SettingRowParams): ElementTree { + const trailing = params.trailing ?? { kind: 'chevron' }; + const rowChildren: ElementTree[] = []; + + if (params.leading_icon) { + rowChildren.push({ + type: 'icon_font', + name: 'Leading Icon', + role: 'setting-row-icon', + iconFontName: params.leading_icon, + iconFontFamily: 'lucide', + width: 24, + height: 24, + fill: [{ type: 'solid', color: FG }], + }); + } + + const textStackChildren: ElementTree[] = [ + { + type: 'text', + name: 'Title', + role: 'setting-row-title', + content: params.title, + fontSize: 15, + fontWeight: 500, + width: 'fill_container', + textGrowth: 'fixed-width', + fill: [{ type: 'solid', color: FG }], + }, + ]; + if (params.subtitle) { + textStackChildren.push({ + type: 'text', + name: 'Subtitle', + role: 'setting-row-subtitle', + content: params.subtitle, + fontSize: 13, + fontWeight: 400, + width: 'fill_container', + textGrowth: 'fixed-width', + fill: [{ type: 'solid', color: MUTED }], + }); + } + + rowChildren.push({ + type: 'frame', + name: 'Text Stack', + role: 'setting-row-text', + width: 'fill_container', + height: 'fit_content', + layout: 'vertical', + gap: 2, + children: textStackChildren, + }); + + rowChildren.push(buildTrailing(trailing)); + + return { + type: 'frame', + name: 'Setting Row', + role: 'setting-row', + width: 'fill_container', + height: 'fit_content', + layout: 'horizontal', + alignItems: 'center', + gap: 12, + padding: [14, 16], + children: rowChildren, + }; +} diff --git a/packages/pen-core/src/index.ts b/packages/pen-core/src/index.ts index 6287c08f7..b5634de91 100644 --- a/packages/pen-core/src/index.ts +++ b/packages/pen-core/src/index.ts @@ -277,6 +277,7 @@ export { buildLegendItem, buildInboxMessage, buildProfileHeader, + buildSettingRow, cjkFontFamily, detectCjkScript, type ElementTree, @@ -402,4 +403,6 @@ export { type LegendItemParams, type InboxMessageParams, type ProfileHeaderParams, + type SettingRowParams, + type SettingRowTrailing, } from './element-builders/index.js'; diff --git a/packages/pen-mcp/src/routes/element-tool-defs-ext-4.ts b/packages/pen-mcp/src/routes/element-tool-defs-ext-4.ts index 694ea27c3..3fbd93975 100644 --- a/packages/pen-mcp/src/routes/element-tool-defs-ext-4.ts +++ b/packages/pen-mcp/src/routes/element-tool-defs-ext-4.ts @@ -299,4 +299,49 @@ export const ELEMENT_TOOL_DEFINITIONS_EXT_4 = [ required: ['name'], }, }, + { + name: 'add_setting_row_v0', + description: + 'Settings menu row — leading icon + (title over optional subtitle) + trailing control ' + + '(chevron / value text / switch / badge). Distinct from add_list_row_v0 (generic, trailing ' + + 'is always an icon — no switch/value/badge variants) and add_form_field_v0 (label-above-' + + 'input pattern for forms, not menus). Use for "settings row", "preferences row", "menu ' + + 'item with toggle", "设置项", "偏好项". schemaVersion 1.0', + inputSchema: { + type: 'object' as const, + properties: { + schemaVersion: schemaVersionProp, + filePath: filePathProp, + title: { type: 'string', description: 'Title (left, 15/500).' }, + subtitle: { type: 'string', description: 'Optional second-line text (13/400, muted).' }, + leading_icon: { type: 'string', description: 'Optional leading lucide icon slug (24×24).' }, + trailing: { + type: 'object', + description: + 'Trailing control. Default: { kind: "chevron" }. Variants: ' + + '{ kind: "chevron" } | { kind: "value", value: string } | ' + + '{ kind: "switch", on: boolean } | { kind: "badge", value: string }.', + properties: { + kind: { + type: 'string', + enum: ['chevron', 'value', 'switch', 'badge'], + description: 'Trailing variant.', + }, + value: { + type: 'string', + description: 'Required for kind="value" and kind="badge".', + }, + on: { + type: 'boolean', + description: 'Required for kind="switch".', + }, + }, + required: ['kind'], + }, + parent_id: parentIdProp, + pageId: pageIdProp, + }, + required: ['title'], + }, + }, ]; diff --git a/packages/pen-mcp/src/routes/element-tool-defs.ts b/packages/pen-mcp/src/routes/element-tool-defs.ts index 613c5a582..06356f571 100644 --- a/packages/pen-mcp/src/routes/element-tool-defs.ts +++ b/packages/pen-mcp/src/routes/element-tool-defs.ts @@ -105,6 +105,7 @@ import { handleAddInlineActionV0 } from '../tools/add-inline-action-v0'; import { handleAddLegendItemV0 } from '../tools/add-legend-item-v0'; import { handleAddInboxMessageV0 } from '../tools/add-inbox-message-v0'; import { handleAddProfileHeaderV0 } from '../tools/add-profile-header-v0'; +import { handleAddSettingRowV0 } from '../tools/add-setting-row-v0'; import { recordElementToolCall } from '../metrics/element-tool-metrics'; import { ELEMENT_TOOL_DEFINITIONS_BASE } from './element-tool-defs-base'; import { ELEMENT_TOOL_DEFINITIONS_EXT } from './element-tool-defs-ext'; @@ -319,6 +320,8 @@ async function dispatchElementToolCall(name: string, a: any): Promise { return JSON.stringify(await handleAddInboxMessageV0(a), null, 2); case 'add_profile_header_v0': return JSON.stringify(await handleAddProfileHeaderV0(a), null, 2); + case 'add_setting_row_v0': + return JSON.stringify(await handleAddSettingRowV0(a), null, 2); default: return ''; } diff --git a/packages/pen-mcp/src/tools/add-setting-row-v0.ts b/packages/pen-mcp/src/tools/add-setting-row-v0.ts new file mode 100644 index 000000000..2c834cd16 --- /dev/null +++ b/packages/pen-mcp/src/tools/add-setting-row-v0.ts @@ -0,0 +1,18 @@ +import { assignIdsRecursively, buildSettingRow, type SettingRowParams } from '@zseven-w/pen-core'; +import type { handleBatchDesign } from './batch-design'; +import { ensureParentExists, insertElementTree } from './element-tool-helpers'; + +export interface AddSettingRowV0Params extends SettingRowParams { + parent_id?: string; + filePath?: string; + pageId?: string; +} + +export async function handleAddSettingRowV0( + params: AddSettingRowV0Params, +): Promise>> { + await ensureParentExists(params); + const row = buildSettingRow(params); + assignIdsRecursively(row); + return insertElementTree({ binding: 'settingRow', tree: row, ...params }); +}