feat(ai): ship 5 element tools to reach 97 (filter_group / invite_row / activity_log / event_card / step_card)

Closes the obvious gaps remaining in the family:
- add_filter_group_v0 — sidebar facet (heading + checkbox-style options
  with optional counts). Distinct from nav_chip_row (horizontal scrolling
  chips), tag (single applied chip), segmented_control (mutex tabs).
- add_invite_row_v0 — pending invite row (avatar + email/role + status
  pill + trailing action). Distinct from member_row (a JOINED member,
  no status pill or action) and list_row (no avatar / status / action).
- add_activity_log_v0 — single-line audit feed entry (optional tinted
  icon dot + actor in bold + action + right-aligned timestamp). Uses
  StyledTextSegment[] content for the bold/regular split. Distinct from
  timeline (multi-event vertical with connectors) and notification_row
  (title + body, no actor focus).
- add_event_card_v0 — single calendar event tile (date column with
  month band + day number, then title + time + location). Distinct from
  calendar_grid (the full month grid) and card_row (no date column).
- add_step_card_v0 — onboarding step card (numbered circle / check +
  title + description). Distinct from stepper (horizontal progress nav
  with connectors) and faq_item (collapsible Q&A header).

9 touchpoints per tool: pen-core builder + index + barrel + types,
pen-mcp handler + dispatcher + ext-4 schema, apps/web shim +
SERVER_BUILDERS, parity test (+5 cases), elements.md decision tree
items 86-89 + 6 PREFER mappings with cross-links to existing tools,
elements-cookbook.md arg-shape examples (8 entries across 5 tools).
This commit is contained in:
Fini 2026-04-28 08:50:00 +08:00
parent a5b594cf69
commit 1fee613111
19 changed files with 1173 additions and 1 deletions

View file

@ -63,6 +63,11 @@ import {
buildProfileHeader,
buildSettingRow,
buildMemberRow,
buildFilterGroup,
buildInviteRow,
buildActivityLog,
buildEventCard,
buildStepCard,
buildKbd,
buildLink,
buildListRow,
@ -249,6 +254,11 @@ const SERVER_BUILDERS: Record<string, BuilderFn> = {
add_profile_header_v0: (a) => buildProfileHeader(a as Parameters<typeof buildProfileHeader>[0]),
add_setting_row_v0: (a) => buildSettingRow(a as Parameters<typeof buildSettingRow>[0]),
add_member_row_v0: (a) => buildMemberRow(a as Parameters<typeof buildMemberRow>[0]),
add_filter_group_v0: (a) => buildFilterGroup(a as Parameters<typeof buildFilterGroup>[0]),
add_invite_row_v0: (a) => buildInviteRow(a as Parameters<typeof buildInviteRow>[0]),
add_activity_log_v0: (a) => buildActivityLog(a as Parameters<typeof buildActivityLog>[0]),
add_event_card_v0: (a) => buildEventCard(a as Parameters<typeof buildEventCard>[0]),
add_step_card_v0: (a) => buildStepCard(a as Parameters<typeof buildStepCard>[0]),
};
interface ExecToolBody {

View file

@ -44,6 +44,11 @@ import {
buildProfileHeader,
buildSettingRow,
buildMemberRow,
buildFilterGroup,
buildInviteRow,
buildActivityLog,
buildEventCard,
buildStepCard,
buildDatePicker,
buildDivider,
buildEmptyChart,
@ -681,6 +686,59 @@ const CASES: BuilderCase[] = [
},
build: (a) => buildMemberRow(a as unknown as Parameters<typeof buildMemberRow>[0]),
},
{
toolName: 'add_filter_group_v0',
args: {
title: 'Category',
options: [
{ label: 'Books', count: 42, selected: true },
{ label: 'Music', count: 17 },
],
},
build: (a) => buildFilterGroup(a as unknown as Parameters<typeof buildFilterGroup>[0]),
},
{
toolName: 'add_invite_row_v0',
args: {
email: 'sarah@acme.com',
role: 'Editor',
status: 'pending',
action_label: 'Resend',
},
build: (a) => buildInviteRow(a as unknown as Parameters<typeof buildInviteRow>[0]),
},
{
toolName: 'add_activity_log_v0',
args: {
actor: 'Sarah Lee',
action: 'merged pull request #142',
timestamp: '2h ago',
icon: 'git-merge',
tone: 'success',
},
build: (a) => buildActivityLog(a as unknown as Parameters<typeof buildActivityLog>[0]),
},
{
toolName: 'add_event_card_v0',
args: {
month: 'OCT',
day: 15,
title: 'Design review',
time: '2:00 PM – 3:00 PM',
location: 'Conference Room B',
},
build: (a) => buildEventCard(a as unknown as Parameters<typeof buildEventCard>[0]),
},
{
toolName: 'add_step_card_v0',
args: {
number: 1,
title: 'Connect your repo',
description: 'Authorize GitHub so we can read your commits.',
completed: true,
},
build: (a) => buildStepCard(a as unknown as Parameters<typeof buildStepCard>[0]),
},
];
/**

View file

@ -81,6 +81,11 @@ import {
buildProfileHeader,
buildSettingRow,
buildMemberRow,
buildFilterGroup,
buildInviteRow,
buildActivityLog,
buildEventCard,
buildStepCard,
buildKbd,
buildLink,
buildListRow,
@ -174,6 +179,11 @@ import {
type ProfileHeaderParams,
type SettingRowParams,
type MemberRowParams,
type FilterGroupParams,
type InviteRowParams,
type ActivityLogParams,
type EventCardParams,
type StepCardParams,
type KbdParams,
type LinkParams,
type ListRowParams,
@ -386,6 +396,11 @@ export const ELEMENT_SHIMS: Record<string, ElementShim> = {
add_profile_header_v0: wrap<ProfileHeaderParams>(buildProfileHeader),
add_setting_row_v0: wrap<SettingRowParams>(buildSettingRow),
add_member_row_v0: wrap<MemberRowParams>(buildMemberRow),
add_filter_group_v0: wrap<FilterGroupParams>(buildFilterGroup),
add_invite_row_v0: wrap<InviteRowParams>(buildInviteRow),
add_activity_log_v0: wrap<ActivityLogParams>(buildActivityLog),
add_event_card_v0: wrap<EventCardParams>(buildEventCard),
add_step_card_v0: wrap<StepCardParams>(buildStepCard),
};
export function getElementShim(name: string): ElementShim | undefined {

View file

@ -430,4 +430,64 @@ add_member_row_v0({
initial: "PP",
trailing: { kind: "menu" },
})
add_filter_group_v0({
title: "Category",
options: [
{ label: "Books", count: 42, selected: true },
{ label: "Music", count: 17 },
{ label: "Movies", count: 9 },
],
})
add_invite_row_v0({
email: "sarah@acme.com",
role: "Editor",
status: "pending",
action_label: "Resend",
})
add_invite_row_v0({
email: "alex@acme.com",
role: "Viewer",
status: "expired",
action_label: "Revoke",
})
add_activity_log_v0({
actor: "Sarah Lee",
action: "merged pull request #142",
timestamp: "2h ago",
icon: "git-merge",
tone: "success",
})
add_activity_log_v0({
actor: "Marcus Chen",
action: "deleted the staging environment",
timestamp: "Yesterday",
icon: "trash-2",
tone: "danger",
})
add_event_card_v0({
month: "OCT",
day: 15,
title: "Design review",
time: "2:00 PM – 3:00 PM",
location: "Conference Room B",
})
add_step_card_v0({
number: 1,
title: "Connect your repo",
description: "Authorize GitHub so we can read your commits and surface them in your dashboard.",
completed: true,
})
add_step_card_v0({
number: 2,
title: "Invite teammates",
description: "Add your team so they can collaborate on changes.",
})
```

View file

@ -275,8 +275,25 @@ Settings / preferences:
People / lists:
84. Members / team list row (avatar + name + subtitle + role badge / kebab menu / status dot) → `add_member_row_v0`
85. Pending invite list row (initial avatar + email/role + status pill + Resend action) → `add_invite_row_v0`
85. None match → fall through to `batch_design`
Faceted search / filter sidebar:
86. Sidebar filter group (heading + checkbox-style option list with counts) → `add_filter_group_v0`
Audit / activity feed:
87. Activity log entry (actor in bold + action + timestamp + tinted icon dot) → `add_activity_log_v0`
Calendar:
88. Single event card (month/day column + title + time + location) → `add_event_card_v0`
Onboarding:
89. Numbered step card (circle/check + title + description) → `add_step_card_v0`
90. 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.
@ -374,6 +391,11 @@ PREFER an element tool when the spec says any of:
- "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).
- "facet panel", "filter sidebar", "category checklist", "brand filter", "applied filters group", "搜索筛选侧栏", "分面筛选", "类目筛选" → `add_filter_group_v0` (heading + vertical checkbox-style option list with optional counts). Different from `add_nav_chip_row_v0` (horizontal scrolling chips), `add_tag_v0` (single applied chip with × close), and `add_segmented_control_v0` (mutex pill tabs).
- "pending invite", "invitation row", "team invite", "邀请列表行", "待接受邀请", "invitations table row" → `add_invite_row_v0` (initial avatar + email/role + status pill + trailing action). Different from `add_member_row_v0` (a JOINED member, no status pill or action slot) and `add_list_row_v0` (no avatar / status / action).
- "audit log row", "activity feed entry", "recent activity item", "audit entry", "审计日志条目", "活动记录", "操作日志行" → `add_activity_log_v0` (single line: optional tinted icon dot + "<actor> <action>" + right-aligned timestamp). Different from `add_timeline_v0` (multi-event vertical with connecting line + per-event title + body) and `add_notification_row_v0` (title + body, no actor focus).
- "event card", "agenda item", "meeting tile", "upcoming event", "calendar event row", "日程卡片", "会议条目", "活动卡片" → `add_event_card_v0` (date column with month band + day number, then title + time + location). Different from `add_calendar_grid_v0` (the full month grid) and `add_card_row_v0` (horizontally scrolling cards with title + subtitle + image, no date column).
- "onboarding step", "how-it-works step", "tutorial step card", "setup checklist item", "操作步骤卡片", "教程步骤", "新手引导步骤" → `add_step_card_v0` (numbered circle / check + title + description, stacks vertically). Different from `add_stepper_v0` (horizontal progress nav with connectors) and `add_faq_item_v0` (collapsible Q&A header).
STILL use batch_design when:

View file

@ -0,0 +1,127 @@
import type { ElementTree } from './helpers.js';
export interface ActivityLogParams {
/** Actor name (rendered bold). E.g. "Sarah Lee". */
actor: string;
/** Action verb-phrase (rendered as plain text after actor). E.g. "merged pull request #142". */
action: string;
/** Right-aligned relative timestamp (e.g. "2h ago", "Just now"). */
timestamp: string;
/** Optional leading lucide icon slug (16×16, in a small tinted dot). */
icon?: string;
/** Icon dot tone. Default "info". */
tone?: 'info' | 'success' | 'warning' | 'danger' | 'neutral';
}
const ACTOR_FG = '#0F172A';
const ACTION_FG = '#475569';
const TS_FG = '#94A3B8';
const TONE_BG: Record<NonNullable<ActivityLogParams['tone']>, string> = {
info: '#DBEAFE',
success: '#DCFCE7',
warning: '#FEF3C7',
danger: '#FEE2E2',
neutral: '#E2E8F0',
};
const TONE_FG: Record<NonNullable<ActivityLogParams['tone']>, string> = {
info: '#1D4ED8',
success: '#166534',
warning: '#92400E',
danger: '#991B1B',
neutral: '#475569',
};
/**
* Audit / activity log entry — single line with optional small tinted
* icon dot, then "<actor> <action>" in mixed weights, then a right-
* aligned relative timestamp. Designed for stacking under a heading
* to form a feed (e.g. "Recent activity", "Audit log").
*
* Distinct from `add_timeline_v0` (vertical multi-event with
* connecting line + per-event title + body) and
* `add_notification_row_v0` (title + body, no actor focus).
*
* Use for "audit log row", "activity feed entry", "recent activity",
* "审计日志条目", "活动记录".
*/
export function buildActivityLog(params: ActivityLogParams): ElementTree {
const tone = params.tone ?? 'info';
const rowChildren: ElementTree[] = [];
if (params.icon) {
rowChildren.push({
type: 'frame',
name: 'Icon Dot',
role: 'activity-log-icon-dot',
width: 28,
height: 28,
cornerRadius: 14,
fill: [{ type: 'solid', color: TONE_BG[tone] }],
layout: 'horizontal',
alignItems: 'center',
justifyContent: 'center',
children: [
{
type: 'icon_font',
name: 'Icon',
role: 'activity-log-icon',
iconFontName: params.icon,
iconFontFamily: 'lucide',
width: 14,
height: 14,
fill: [{ type: 'solid', color: TONE_FG[tone] }],
},
],
});
}
rowChildren.push({
type: 'frame',
name: 'Body',
role: 'activity-log-body',
width: 'fill_container',
height: 'fit_content',
layout: 'vertical',
gap: 0,
children: [
{
type: 'text',
name: 'Line',
role: 'activity-log-line',
content: [
{ text: params.actor, fontWeight: 600, fill: ACTOR_FG },
{ text: ` ${params.action}`, fontWeight: 400, fill: ACTION_FG },
],
fontSize: 14,
fontWeight: 400,
width: 'fill_container',
textGrowth: 'fixed-width',
fill: [{ type: 'solid', color: ACTION_FG }],
},
],
});
rowChildren.push({
type: 'text',
name: 'Timestamp',
role: 'activity-log-timestamp',
content: params.timestamp,
fontSize: 13,
fontWeight: 400,
fill: [{ type: 'solid', color: TS_FG }],
});
return {
type: 'frame',
name: 'Activity Log Entry',
role: 'activity-log',
width: 'fill_container',
height: 'fit_content',
layout: 'horizontal',
alignItems: 'center',
gap: 12,
padding: [10, 0],
children: rowChildren,
};
}

View file

@ -0,0 +1,202 @@
import type { ElementTree } from './helpers.js';
export interface EventCardParams {
/** Short month code shown in the date column (e.g. "OCT", "10月"). */
month: string;
/** Day-of-month (e.g. "15"). */
day: string | number;
/** Event title (15/600). */
title: string;
/** Optional time string (e.g. "2:00 PM – 3:30 PM", "14:00"). */
time?: string;
/** Optional location string (e.g. "Conference Room B"). */
location?: string;
/** Accent color for the date column header. Default "#2563EB". */
accent?: string;
}
const TITLE_FG = '#0F172A';
const META_FG = '#64748B';
const DATE_BG = '#F1F5F9';
/**
* Calendar event tile — left date column (month + day) + right text
* stack (title + optional time + optional location). Distinct from
* `add_calendar_grid_v0` (the full month grid) and `add_card_row_v0`
* (a horizontally scrolling card row with title + subtitle + image).
*
* Use for "upcoming event card", "meeting tile", "agenda item",
* "calendar event row", "日程卡片", "会议条目".
*/
export function buildEventCard(params: EventCardParams): ElementTree {
const accent = params.accent ?? '#2563EB';
const dateColumn: ElementTree = {
type: 'frame',
name: 'Date Column',
role: 'event-card-date',
width: 64,
height: 'fit_content',
cornerRadius: 8,
fill: [{ type: 'solid', color: DATE_BG }],
layout: 'vertical',
alignItems: 'center',
children: [
{
type: 'frame',
name: 'Month Strip',
role: 'event-card-month-strip',
width: 'fill_container',
height: 'fit_content',
fill: [{ type: 'solid', color: accent }],
layout: 'horizontal',
alignItems: 'center',
justifyContent: 'center',
padding: [4, 0],
children: [
{
type: 'text',
name: 'Month',
role: 'event-card-month',
content: params.month,
fontSize: 11,
fontWeight: 700,
letterSpacing: 1,
fill: [{ type: 'solid', color: '#FFFFFF' }],
},
],
},
{
type: 'frame',
name: 'Day Strip',
role: 'event-card-day-strip',
width: 'fill_container',
height: 'fit_content',
layout: 'horizontal',
alignItems: 'center',
justifyContent: 'center',
padding: [8, 0],
children: [
{
type: 'text',
name: 'Day',
role: 'event-card-day',
content: String(params.day),
fontSize: 22,
fontWeight: 700,
fill: [{ type: 'solid', color: TITLE_FG }],
},
],
},
],
};
const stack: ElementTree[] = [
{
type: 'text',
name: 'Title',
role: 'event-card-title',
content: params.title,
fontSize: 15,
fontWeight: 600,
width: 'fill_container',
textGrowth: 'fixed-width',
fill: [{ type: 'solid', color: TITLE_FG }],
},
];
if (params.time) {
stack.push({
type: 'frame',
name: 'Time Row',
role: 'event-card-meta-row',
width: 'fit_content',
height: 'fit_content',
layout: 'horizontal',
alignItems: 'center',
gap: 6,
children: [
{
type: 'icon_font',
name: 'Time Icon',
role: 'event-card-time-icon',
iconFontName: 'clock',
iconFontFamily: 'lucide',
width: 14,
height: 14,
fill: [{ type: 'solid', color: META_FG }],
},
{
type: 'text',
name: 'Time',
role: 'event-card-time',
content: params.time,
fontSize: 13,
fontWeight: 400,
fill: [{ type: 'solid', color: META_FG }],
},
],
});
}
if (params.location) {
stack.push({
type: 'frame',
name: 'Location Row',
role: 'event-card-meta-row',
width: 'fit_content',
height: 'fit_content',
layout: 'horizontal',
alignItems: 'center',
gap: 6,
children: [
{
type: 'icon_font',
name: 'Location Icon',
role: 'event-card-location-icon',
iconFontName: 'map-pin',
iconFontFamily: 'lucide',
width: 14,
height: 14,
fill: [{ type: 'solid', color: META_FG }],
},
{
type: 'text',
name: 'Location',
role: 'event-card-location',
content: params.location,
fontSize: 13,
fontWeight: 400,
fill: [{ type: 'solid', color: META_FG }],
},
],
});
}
return {
type: 'frame',
name: 'Event Card',
role: 'event-card',
width: 'fill_container',
height: 'fit_content',
layout: 'horizontal',
alignItems: 'flex-start',
gap: 14,
padding: [12, 12],
cornerRadius: 12,
fill: [{ type: 'solid', color: '#FFFFFF' }],
stroke: { thickness: 1, fill: [{ type: 'solid', color: '#E2E8F0' }] },
children: [
dateColumn,
{
type: 'frame',
name: 'Text Stack',
role: 'event-card-text',
width: 'fill_container',
height: 'fit_content',
layout: 'vertical',
gap: 6,
padding: [4, 0],
children: stack,
},
],
};
}

View file

@ -0,0 +1,132 @@
import type { ElementTree } from './helpers.js';
export interface FilterGroupOption {
label: string;
/** Optional count badge (e.g. "(42)"). */
count?: number;
/** Whether this facet is currently selected. Default false. */
selected?: boolean;
}
export interface FilterGroupParams {
/** Heading text above the option list (e.g. "Category", "Brand"). */
title: string;
/** Options. Each renders a checkbox-style row + optional count. */
options: FilterGroupOption[];
}
const TITLE_FG = '#0F172A';
const LABEL_FG = '#334155';
const COUNT_FG = '#94A3B8';
const BOX_BORDER = '#CBD5E1';
const BOX_BG_SELECTED = '#2563EB';
const BOX_FG_SELECTED = '#FFFFFF';
function buildOptionRow(opt: FilterGroupOption): ElementTree {
const selected = opt.selected === true;
const box: ElementTree = {
type: 'frame',
name: 'Box',
role: 'filter-group-checkbox',
width: 16,
height: 16,
cornerRadius: 4,
fill: [{ type: 'solid', color: selected ? BOX_BG_SELECTED : '#FFFFFF' }],
stroke: {
thickness: 1.5,
fill: [{ type: 'solid', color: selected ? BOX_BG_SELECTED : BOX_BORDER }],
},
layout: 'horizontal',
alignItems: 'center',
justifyContent: 'center',
children: selected
? [
{
type: 'icon_font',
name: 'Check',
role: 'filter-group-check',
iconFontName: 'check',
iconFontFamily: 'lucide',
width: 12,
height: 12,
fill: [{ type: 'solid', color: BOX_FG_SELECTED }],
},
]
: [],
};
const rowChildren: ElementTree[] = [
box,
{
type: 'text',
name: 'Label',
role: 'filter-group-label',
content: opt.label,
fontSize: 14,
fontWeight: 400,
width: 'fill_container',
textGrowth: 'fixed-width',
fill: [{ type: 'solid', color: LABEL_FG }],
},
];
if (opt.count !== undefined) {
rowChildren.push({
type: 'text',
name: 'Count',
role: 'filter-group-count',
content: String(opt.count),
fontSize: 13,
fontWeight: 400,
fill: [{ type: 'solid', color: COUNT_FG }],
});
}
return {
type: 'frame',
name: `Option: ${opt.label}`,
role: 'filter-group-option',
width: 'fill_container',
height: 'fit_content',
layout: 'horizontal',
alignItems: 'center',
gap: 10,
padding: [6, 0],
children: rowChildren,
};
}
/**
* Sidebar filter group / facet — heading text over a vertical list of
* checkbox-style option rows (label + optional count). Distinct from
* `add_nav_chip_row_v0` (horizontal scrolling chips, single-select),
* `add_tag_v0` (single applied chip with × close), and
* `add_segmented_control_v0` (mutex pill tabs).
*
* Use for "facet panel", "filter sidebar", "category checklist",
* "brand filter", "applied filters group", "搜索筛选侧栏".
*/
export function buildFilterGroup(params: FilterGroupParams): ElementTree {
const optionRows = params.options.map(buildOptionRow);
return {
type: 'frame',
name: 'Filter Group',
role: 'filter-group',
width: 'fill_container',
height: 'fit_content',
layout: 'vertical',
gap: 8,
children: [
{
type: 'text',
name: 'Title',
role: 'filter-group-title',
content: params.title,
fontSize: 13,
fontWeight: 600,
letterSpacing: 1,
fill: [{ type: 'solid', color: TITLE_FG }],
},
...optionRows,
],
};
}

View file

@ -124,3 +124,12 @@ export {
export { buildCookieBanner, type CookieBannerParams } from './cookie-banner.js';
export { buildSettingRow, type SettingRowParams, type SettingRowTrailing } from './setting-row.js';
export { buildMemberRow, type MemberRowParams, type MemberRowTrailing } from './member-row.js';
export {
buildFilterGroup,
type FilterGroupOption,
type FilterGroupParams,
} from './filter-group.js';
export { buildInviteRow, type InviteRowParams, type InviteStatus } from './invite-row.js';
export { buildActivityLog, type ActivityLogParams } from './activity-log.js';
export { buildEventCard, type EventCardParams } from './event-card.js';
export { buildStepCard, type StepCardParams } from './step-card.js';

View file

@ -0,0 +1,150 @@
import type { ElementTree } from './helpers.js';
export type InviteStatus = 'pending' | 'expired' | 'accepted';
export interface InviteRowParams {
/** Invitee email (e.g. "sarah@acme.com"). */
email: string;
/** Optional invited role (e.g. "Editor", "Viewer"). */
role?: string;
/** Status. Default "pending". */
status?: InviteStatus;
/** Trailing primary action label (e.g. "Resend", "Revoke"). Default "Resend". */
action_label?: string;
}
const NAME_FG = '#0F172A';
const SUB_FG = '#64748B';
const STATUS_TONE: Record<InviteStatus, { bg: string; fg: string; text: string }> = {
pending: { bg: '#FEF3C7', fg: '#92400E', text: 'Pending' },
expired: { bg: '#FEE2E2', fg: '#991B1B', text: 'Expired' },
accepted: { bg: '#DCFCE7', fg: '#166534', text: 'Accepted' },
};
const ACTION_FG = '#2563EB';
/**
* Pending invite list row — avatar (initial from email) + (email over
* role) + status pill + trailing action label. Distinct from
* `add_member_row_v0` (an actual joined member with full name + role
* badge) and `add_list_row_v0` (no avatar, no status pill, no action
* label slot).
*
* Use for "pending invites list", "invitations table row", "team
* invite row", "邀请列表行", "待接受邀请".
*/
export function buildInviteRow(params: InviteRowParams): ElementTree {
const status = params.status ?? 'pending';
const tone = STATUS_TONE[status];
const actionLabel = params.action_label ?? 'Resend';
const initial = (params.email.charAt(0) ?? '?').toUpperCase();
const avatar: ElementTree = {
type: 'frame',
name: 'Avatar',
role: 'invite-row-avatar',
width: 36,
height: 36,
cornerRadius: 18,
fill: [{ type: 'solid', color: '#E2E8F0' }],
layout: 'horizontal',
alignItems: 'center',
justifyContent: 'center',
children: [
{
type: 'text',
name: 'Initial',
role: 'invite-row-avatar-initial',
content: initial,
fontSize: 14,
fontWeight: 600,
fill: [{ type: 'solid', color: SUB_FG }],
},
],
};
const textStackChildren: ElementTree[] = [
{
type: 'text',
name: 'Email',
role: 'invite-row-email',
content: params.email,
fontSize: 14,
fontWeight: 500,
width: 'fill_container',
textGrowth: 'fixed-width',
fill: [{ type: 'solid', color: NAME_FG }],
},
];
if (params.role) {
textStackChildren.push({
type: 'text',
name: 'Role',
role: 'invite-row-role',
content: params.role,
fontSize: 12,
fontWeight: 400,
width: 'fill_container',
textGrowth: 'fixed-width',
fill: [{ type: 'solid', color: SUB_FG }],
});
}
const statusPill: ElementTree = {
type: 'frame',
name: 'Status Pill',
role: 'invite-row-status',
width: 'fit_content',
height: 'fit_content',
cornerRadius: 12,
fill: [{ type: 'solid', color: tone.bg }],
padding: [3, 10],
children: [
{
type: 'text',
name: 'Status Text',
role: 'invite-row-status-text',
content: tone.text,
fontSize: 12,
fontWeight: 600,
fill: [{ type: 'solid', color: tone.fg }],
},
],
};
const action: ElementTree = {
type: 'text',
name: 'Action',
role: 'invite-row-action',
content: actionLabel,
fontSize: 14,
fontWeight: 600,
fill: [{ type: 'solid', color: ACTION_FG }],
};
return {
type: 'frame',
name: 'Invite Row',
role: 'invite-row',
width: 'fill_container',
height: 'fit_content',
layout: 'horizontal',
alignItems: 'center',
gap: 12,
padding: [12, 16],
children: [
avatar,
{
type: 'frame',
name: 'Text Stack',
role: 'invite-row-text',
width: 'fill_container',
height: 'fit_content',
layout: 'vertical',
gap: 2,
children: textStackChildren,
},
statusPill,
action,
],
};
}

View file

@ -0,0 +1,121 @@
import type { ElementTree } from './helpers.js';
export interface StepCardParams {
/** Step number / index (e.g. 1, "01", "Step 1"). */
number: string | number;
/** Step title (16/600). */
title: string;
/** Description body (14/400, muted). */
description: string;
/** Whether the step is completed. Default false. Filled circle vs ring. */
completed?: boolean;
}
const TITLE_FG = '#0F172A';
const DESC_FG = '#475569';
const ACCENT = '#2563EB';
const RING_BG = '#FFFFFF';
/**
* Onboarding / how-it-works step card — numbered circle (or check
* when completed) on the left, title over description on the right.
* Designed to stack vertically under a section header to form an
* onboarding column. Distinct from `add_stepper_v0` (horizontal
* progress nav with connectors), `add_list_row_v0` (no number /
* description split), and `add_faq_item_v0` (collapsible Q&A).
*
* Use for "onboarding step", "how-it-works step", "tutorial step
* card", "setup checklist item", "操作步骤卡片", "教程步骤".
*/
export function buildStepCard(params: StepCardParams): ElementTree {
const completed = params.completed === true;
const numberText = String(params.number);
const circle: ElementTree = {
type: 'frame',
name: 'Number Circle',
role: 'step-card-circle',
width: 36,
height: 36,
cornerRadius: 18,
fill: [{ type: 'solid', color: completed ? ACCENT : RING_BG }],
stroke: completed ? undefined : { thickness: 2, fill: [{ type: 'solid', color: ACCENT }] },
layout: 'horizontal',
alignItems: 'center',
justifyContent: 'center',
children: completed
? [
{
type: 'icon_font',
name: 'Check',
role: 'step-card-check',
iconFontName: 'check',
iconFontFamily: 'lucide',
width: 18,
height: 18,
fill: [{ type: 'solid', color: '#FFFFFF' }],
},
]
: [
{
type: 'text',
name: 'Number',
role: 'step-card-number',
content: numberText,
fontSize: 15,
fontWeight: 700,
fill: [{ type: 'solid', color: ACCENT }],
},
],
};
return {
type: 'frame',
name: 'Step Card',
role: 'step-card',
width: 'fill_container',
height: 'fit_content',
layout: 'horizontal',
alignItems: 'flex-start',
gap: 14,
padding: [4, 0],
children: [
circle,
{
type: 'frame',
name: 'Body',
role: 'step-card-body',
width: 'fill_container',
height: 'fit_content',
layout: 'vertical',
gap: 4,
padding: [4, 0, 0, 0],
children: [
{
type: 'text',
name: 'Title',
role: 'step-card-title',
content: params.title,
fontSize: 16,
fontWeight: 600,
width: 'fill_container',
textGrowth: 'fixed-width',
fill: [{ type: 'solid', color: TITLE_FG }],
},
{
type: 'text',
name: 'Description',
role: 'step-card-description',
content: params.description,
fontSize: 14,
fontWeight: 400,
lineHeight: 1.5,
width: 'fill_container',
textGrowth: 'fixed-width',
fill: [{ type: 'solid', color: DESC_FG }],
},
],
},
],
};
}

View file

@ -279,6 +279,11 @@ export {
buildProfileHeader,
buildSettingRow,
buildMemberRow,
buildFilterGroup,
buildInviteRow,
buildActivityLog,
buildEventCard,
buildStepCard,
cjkFontFamily,
detectCjkScript,
type ElementTree,
@ -408,4 +413,11 @@ export {
type SettingRowTrailing,
type MemberRowParams,
type MemberRowTrailing,
type FilterGroupOption,
type FilterGroupParams,
type InviteRowParams,
type InviteStatus,
type ActivityLogParams,
type EventCardParams,
type StepCardParams,
} from './element-builders/index.js';

View file

@ -398,4 +398,153 @@ export const ELEMENT_TOOL_DEFINITIONS_EXT_4 = [
required: ['name'],
},
},
{
name: 'add_filter_group_v0',
description:
'Sidebar filter group / facet — heading + vertical list of checkbox-style options ' +
'(label + optional count). Distinct from add_nav_chip_row_v0 (horizontal scrolling chips), ' +
'add_tag_v0 (single applied chip with × close), and add_segmented_control_v0 (mutex pill ' +
'tabs). Use for "facet panel", "filter sidebar", "category checklist", "brand filter", ' +
'"搜索筛选侧栏". schemaVersion 1.0',
inputSchema: {
type: 'object' as const,
properties: {
schemaVersion: schemaVersionProp,
filePath: filePathProp,
title: { type: 'string', description: 'Heading text (e.g. "Category", "Brand").' },
options: {
type: 'array',
description: 'Option rows. Each: { label: string, count?: number, selected?: boolean }.',
items: {
type: 'object',
properties: {
label: { type: 'string' },
count: { type: 'number' },
selected: { type: 'boolean' },
},
required: ['label'],
},
},
parent_id: parentIdProp,
pageId: pageIdProp,
},
required: ['title', 'options'],
},
},
{
name: 'add_invite_row_v0',
description:
'Pending invite list row — initial avatar + (email over optional role) + status pill ' +
'(pending / expired / accepted) + trailing action label (e.g. "Resend"). Distinct from ' +
'add_member_row_v0 (a JOINED member with full name + role badge) and add_list_row_v0 (no ' +
'avatar / status / action slots). Use for "pending invites list", "invitations table row", ' +
'"team invite row", "邀请列表行", "待接受邀请". schemaVersion 1.0',
inputSchema: {
type: 'object' as const,
properties: {
schemaVersion: schemaVersionProp,
filePath: filePathProp,
email: { type: 'string', description: 'Invitee email.' },
role: { type: 'string', description: 'Optional invited role (e.g. "Editor").' },
status: {
type: 'string',
enum: ['pending', 'expired', 'accepted'],
description: 'Status pill. Default "pending".',
},
action_label: {
type: 'string',
description: 'Trailing action label (e.g. "Resend", "Revoke"). Default "Resend".',
},
parent_id: parentIdProp,
pageId: pageIdProp,
},
required: ['email'],
},
},
{
name: 'add_activity_log_v0',
description:
'Audit / activity feed entry — optional small tinted icon dot + "<actor> <action>" line ' +
'(actor bold) + right-aligned relative timestamp. Stack vertically under a heading to form ' +
'a feed. Distinct from add_timeline_v0 (multi-event vertical with connecting line + per-' +
'event title + body) and add_notification_row_v0 (title + body, no actor focus). Use for ' +
'"audit log row", "activity feed entry", "recent activity item", "审计日志条目", "活动记录". ' +
'schemaVersion 1.0',
inputSchema: {
type: 'object' as const,
properties: {
schemaVersion: schemaVersionProp,
filePath: filePathProp,
actor: { type: 'string', description: 'Actor name (rendered bold).' },
action: { type: 'string', description: 'Action verb-phrase (plain).' },
timestamp: { type: 'string', description: 'Relative time (e.g. "2h ago").' },
icon: { type: 'string', description: 'Optional lucide icon slug for the leading dot.' },
tone: {
type: 'string',
enum: ['info', 'success', 'warning', 'danger', 'neutral'],
description: 'Icon dot tone. Default "info".',
},
parent_id: parentIdProp,
pageId: pageIdProp,
},
required: ['actor', 'action', 'timestamp'],
},
},
{
name: 'add_event_card_v0',
description:
'Calendar event tile — left date column (month band + day number) + right text stack ' +
'(title + optional time + optional location). Distinct from add_calendar_grid_v0 (full ' +
'month grid) and add_card_row_v0 (horizontally scrolling cards with title + subtitle + ' +
'image, no date column). Use for "upcoming event", "agenda item", "meeting tile", ' +
'"日程卡片", "会议条目". schemaVersion 1.0',
inputSchema: {
type: 'object' as const,
properties: {
schemaVersion: schemaVersionProp,
filePath: filePathProp,
month: { type: 'string', description: 'Short month code (e.g. "OCT", "10月").' },
day: { type: ['string', 'number'], description: 'Day-of-month (e.g. "15" or 15).' },
title: { type: 'string', description: 'Event title.' },
time: { type: 'string', description: 'Optional time string (e.g. "2:00 PM").' },
location: { type: 'string', description: 'Optional location string.' },
accent: {
type: 'string',
description: 'Accent color hex for the month band. Default "#2563EB".',
},
parent_id: parentIdProp,
pageId: pageIdProp,
},
required: ['month', 'day', 'title'],
},
},
{
name: 'add_step_card_v0',
description:
'Onboarding / how-it-works step card — numbered circle (or check when completed) + ' +
'(title over description) body. Stack vertically to form an onboarding column. Distinct ' +
'from add_stepper_v0 (horizontal progress nav with connectors) and add_faq_item_v0 ' +
'(collapsible Q&A). Use for "onboarding step", "how-it-works step", "tutorial step card", ' +
'"setup checklist item", "操作步骤卡片", "教程步骤". schemaVersion 1.0',
inputSchema: {
type: 'object' as const,
properties: {
schemaVersion: schemaVersionProp,
filePath: filePathProp,
number: {
type: ['string', 'number'],
description: 'Step number / index (e.g. 1, "01", "Step 1").',
},
title: { type: 'string', description: 'Step title (16/600).' },
description: { type: 'string', description: 'Description body (14/400, muted).' },
completed: {
type: 'boolean',
description: 'Filled circle + check icon when true. Default false (numbered ring).',
},
parent_id: parentIdProp,
pageId: pageIdProp,
},
required: ['number', 'title', 'description'],
},
},
];

View file

@ -107,6 +107,11 @@ 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 { handleAddMemberRowV0 } from '../tools/add-member-row-v0';
import { handleAddFilterGroupV0 } from '../tools/add-filter-group-v0';
import { handleAddInviteRowV0 } from '../tools/add-invite-row-v0';
import { handleAddActivityLogV0 } from '../tools/add-activity-log-v0';
import { handleAddEventCardV0 } from '../tools/add-event-card-v0';
import { handleAddStepCardV0 } from '../tools/add-step-card-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';
@ -325,6 +330,16 @@ async function dispatchElementToolCall(name: string, a: any): Promise<string> {
return JSON.stringify(await handleAddSettingRowV0(a), null, 2);
case 'add_member_row_v0':
return JSON.stringify(await handleAddMemberRowV0(a), null, 2);
case 'add_filter_group_v0':
return JSON.stringify(await handleAddFilterGroupV0(a), null, 2);
case 'add_invite_row_v0':
return JSON.stringify(await handleAddInviteRowV0(a), null, 2);
case 'add_activity_log_v0':
return JSON.stringify(await handleAddActivityLogV0(a), null, 2);
case 'add_event_card_v0':
return JSON.stringify(await handleAddEventCardV0(a), null, 2);
case 'add_step_card_v0':
return JSON.stringify(await handleAddStepCardV0(a), null, 2);
default:
return '';
}

View file

@ -0,0 +1,18 @@
import { assignIdsRecursively, buildActivityLog, type ActivityLogParams } from '@zseven-w/pen-core';
import type { handleBatchDesign } from './batch-design';
import { ensureParentExists, insertElementTree } from './element-tool-helpers';
export interface AddActivityLogV0Params extends ActivityLogParams {
parent_id?: string;
filePath?: string;
pageId?: string;
}
export async function handleAddActivityLogV0(
params: AddActivityLogV0Params,
): Promise<Awaited<ReturnType<typeof handleBatchDesign>>> {
await ensureParentExists(params);
const tree = buildActivityLog(params);
assignIdsRecursively(tree);
return insertElementTree({ binding: 'activityLog', tree, ...params });
}

View file

@ -0,0 +1,18 @@
import { assignIdsRecursively, buildEventCard, type EventCardParams } from '@zseven-w/pen-core';
import type { handleBatchDesign } from './batch-design';
import { ensureParentExists, insertElementTree } from './element-tool-helpers';
export interface AddEventCardV0Params extends EventCardParams {
parent_id?: string;
filePath?: string;
pageId?: string;
}
export async function handleAddEventCardV0(
params: AddEventCardV0Params,
): Promise<Awaited<ReturnType<typeof handleBatchDesign>>> {
await ensureParentExists(params);
const tree = buildEventCard(params);
assignIdsRecursively(tree);
return insertElementTree({ binding: 'eventCard', tree, ...params });
}

View file

@ -0,0 +1,18 @@
import { assignIdsRecursively, buildFilterGroup, type FilterGroupParams } from '@zseven-w/pen-core';
import type { handleBatchDesign } from './batch-design';
import { ensureParentExists, insertElementTree } from './element-tool-helpers';
export interface AddFilterGroupV0Params extends FilterGroupParams {
parent_id?: string;
filePath?: string;
pageId?: string;
}
export async function handleAddFilterGroupV0(
params: AddFilterGroupV0Params,
): Promise<Awaited<ReturnType<typeof handleBatchDesign>>> {
await ensureParentExists(params);
const tree = buildFilterGroup(params);
assignIdsRecursively(tree);
return insertElementTree({ binding: 'filterGroup', tree, ...params });
}

View file

@ -0,0 +1,18 @@
import { assignIdsRecursively, buildInviteRow, type InviteRowParams } from '@zseven-w/pen-core';
import type { handleBatchDesign } from './batch-design';
import { ensureParentExists, insertElementTree } from './element-tool-helpers';
export interface AddInviteRowV0Params extends InviteRowParams {
parent_id?: string;
filePath?: string;
pageId?: string;
}
export async function handleAddInviteRowV0(
params: AddInviteRowV0Params,
): Promise<Awaited<ReturnType<typeof handleBatchDesign>>> {
await ensureParentExists(params);
const tree = buildInviteRow(params);
assignIdsRecursively(tree);
return insertElementTree({ binding: 'inviteRow', tree, ...params });
}

View file

@ -0,0 +1,18 @@
import { assignIdsRecursively, buildStepCard, type StepCardParams } from '@zseven-w/pen-core';
import type { handleBatchDesign } from './batch-design';
import { ensureParentExists, insertElementTree } from './element-tool-helpers';
export interface AddStepCardV0Params extends StepCardParams {
parent_id?: string;
filePath?: string;
pageId?: string;
}
export async function handleAddStepCardV0(
params: AddStepCardV0Params,
): Promise<Awaited<ReturnType<typeof handleBatchDesign>>> {
await ensureParentExists(params);
const tree = buildStepCard(params);
assignIdsRecursively(tree);
return insertElementTree({ binding: 'stepCard', tree, ...params });
}