feat(ai): add_setting_row_v0 — settings menu row (91st tool)

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.
This commit is contained in:
Fini 2026-04-28 06:30:00 +08:00
parent e656b52f95
commit af1ddd1ad2
11 changed files with 305 additions and 1 deletions

View file

@ -61,6 +61,7 @@ import {
buildLegendItem,
buildInboxMessage,
buildProfileHeader,
buildSettingRow,
buildKbd,
buildLink,
buildListRow,
@ -245,6 +246,7 @@ const SERVER_BUILDERS: Record<string, BuilderFn> = {
add_legend_item_v0: (a) => buildLegendItem(a as Parameters<typeof buildLegendItem>[0]),
add_inbox_message_v0: (a) => buildInboxMessage(a as Parameters<typeof buildInboxMessage>[0]),
add_profile_header_v0: (a) => buildProfileHeader(a as Parameters<typeof buildProfileHeader>[0]),
add_setting_row_v0: (a) => buildSettingRow(a as Parameters<typeof buildSettingRow>[0]),
};
interface ExecToolBody {

View file

@ -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<typeof buildProfileHeader>[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<typeof buildSettingRow>[0]),
},
];
/**

View file

@ -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<string, ElementShim> = {
add_legend_item_v0: wrap<LegendItemParams>(buildLegendItem),
add_inbox_message_v0: wrap<InboxMessageParams>(buildInboxMessage),
add_profile_header_v0: wrap<ProfileHeaderParams>(buildProfileHeader),
add_setting_row_v0: wrap<SettingRowParams>(buildSettingRow),
};
export function getElementShim(name: string): ElementShim | undefined {

View file

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

View file

@ -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:

View file

@ -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';

View file

@ -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)
* └ <trailing> (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,
};
}

View file

@ -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';

View file

@ -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'],
},
},
];

View file

@ -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<string> {
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 '';
}

View file

@ -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<Awaited<ReturnType<typeof handleBatchDesign>>> {
await ensureParentExists(params);
const row = buildSettingRow(params);
assignIdsRecursively(row);
return insertElementTree({ binding: 'settingRow', tree: row, ...params });
}