diff --git a/apps/web/server/api/mcp/exec-tool.post.ts b/apps/web/server/api/mcp/exec-tool.post.ts index 5961c8cb8..444f37ea1 100644 --- a/apps/web/server/api/mcp/exec-tool.post.ts +++ b/apps/web/server/api/mcp/exec-tool.post.ts @@ -63,6 +63,11 @@ import { buildProfileHeader, buildSettingRow, buildMemberRow, + buildFilterGroup, + buildInviteRow, + buildActivityLog, + buildEventCard, + buildStepCard, buildKbd, buildLink, buildListRow, @@ -249,6 +254,11 @@ const SERVER_BUILDERS: Record = { add_profile_header_v0: (a) => buildProfileHeader(a as Parameters[0]), add_setting_row_v0: (a) => buildSettingRow(a as Parameters[0]), add_member_row_v0: (a) => buildMemberRow(a as Parameters[0]), + add_filter_group_v0: (a) => buildFilterGroup(a as Parameters[0]), + add_invite_row_v0: (a) => buildInviteRow(a as Parameters[0]), + add_activity_log_v0: (a) => buildActivityLog(a as Parameters[0]), + add_event_card_v0: (a) => buildEventCard(a as Parameters[0]), + add_step_card_v0: (a) => buildStepCard(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 d543b2eaa..175445047 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 @@ -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[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[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[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[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[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[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 53d9965d7..fa143acc5 100644 --- a/apps/web/src/services/ai/element-tool-shims/index.ts +++ b/apps/web/src/services/ai/element-tool-shims/index.ts @@ -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 = { add_profile_header_v0: wrap(buildProfileHeader), add_setting_row_v0: wrap(buildSettingRow), add_member_row_v0: wrap(buildMemberRow), + add_filter_group_v0: wrap(buildFilterGroup), + add_invite_row_v0: wrap(buildInviteRow), + add_activity_log_v0: wrap(buildActivityLog), + add_event_card_v0: wrap(buildEventCard), + add_step_card_v0: wrap(buildStepCard), }; 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 5b56594ee..d5d1aed03 100644 --- a/packages/pen-ai-skills/skills/phases/generation/elements-cookbook.md +++ b/packages/pen-ai-skills/skills/phases/generation/elements-cookbook.md @@ -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.", +}) ``` diff --git a/packages/pen-ai-skills/skills/phases/generation/elements.md b/packages/pen-ai-skills/skills/phases/generation/elements.md index bedc8b94c..b664f2203 100644 --- a/packages/pen-ai-skills/skills/phases/generation/elements.md +++ b/packages/pen-ai-skills/skills/phases/generation/elements.md @@ -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 + " " + 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: diff --git a/packages/pen-core/src/element-builders/activity-log.ts b/packages/pen-core/src/element-builders/activity-log.ts new file mode 100644 index 000000000..77fd4b4e4 --- /dev/null +++ b/packages/pen-core/src/element-builders/activity-log.ts @@ -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, string> = { + info: '#DBEAFE', + success: '#DCFCE7', + warning: '#FEF3C7', + danger: '#FEE2E2', + neutral: '#E2E8F0', +}; +const TONE_FG: Record, string> = { + info: '#1D4ED8', + success: '#166534', + warning: '#92400E', + danger: '#991B1B', + neutral: '#475569', +}; + +/** + * Audit / activity log entry — single line with optional small tinted + * icon dot, then " " 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, + }; +} diff --git a/packages/pen-core/src/element-builders/event-card.ts b/packages/pen-core/src/element-builders/event-card.ts new file mode 100644 index 000000000..2d82b47a7 --- /dev/null +++ b/packages/pen-core/src/element-builders/event-card.ts @@ -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, + }, + ], + }; +} diff --git a/packages/pen-core/src/element-builders/filter-group.ts b/packages/pen-core/src/element-builders/filter-group.ts new file mode 100644 index 000000000..2c0f07920 --- /dev/null +++ b/packages/pen-core/src/element-builders/filter-group.ts @@ -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, + ], + }; +} diff --git a/packages/pen-core/src/element-builders/index.ts b/packages/pen-core/src/element-builders/index.ts index 0c53c313f..1b5da94d1 100644 --- a/packages/pen-core/src/element-builders/index.ts +++ b/packages/pen-core/src/element-builders/index.ts @@ -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'; diff --git a/packages/pen-core/src/element-builders/invite-row.ts b/packages/pen-core/src/element-builders/invite-row.ts new file mode 100644 index 000000000..35c77fc19 --- /dev/null +++ b/packages/pen-core/src/element-builders/invite-row.ts @@ -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 = { + 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, + ], + }; +} diff --git a/packages/pen-core/src/element-builders/step-card.ts b/packages/pen-core/src/element-builders/step-card.ts new file mode 100644 index 000000000..69ed7819c --- /dev/null +++ b/packages/pen-core/src/element-builders/step-card.ts @@ -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 }], + }, + ], + }, + ], + }; +} diff --git a/packages/pen-core/src/index.ts b/packages/pen-core/src/index.ts index 01feff274..2982de2af 100644 --- a/packages/pen-core/src/index.ts +++ b/packages/pen-core/src/index.ts @@ -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'; 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 390d53f04..ec7d44240 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 @@ -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 + " " 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'], + }, + }, ]; diff --git a/packages/pen-mcp/src/routes/element-tool-defs.ts b/packages/pen-mcp/src/routes/element-tool-defs.ts index 72fd76d62..afff2f41b 100644 --- a/packages/pen-mcp/src/routes/element-tool-defs.ts +++ b/packages/pen-mcp/src/routes/element-tool-defs.ts @@ -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 { 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 ''; } diff --git a/packages/pen-mcp/src/tools/add-activity-log-v0.ts b/packages/pen-mcp/src/tools/add-activity-log-v0.ts new file mode 100644 index 000000000..a3dc90d65 --- /dev/null +++ b/packages/pen-mcp/src/tools/add-activity-log-v0.ts @@ -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>> { + await ensureParentExists(params); + const tree = buildActivityLog(params); + assignIdsRecursively(tree); + return insertElementTree({ binding: 'activityLog', tree, ...params }); +} diff --git a/packages/pen-mcp/src/tools/add-event-card-v0.ts b/packages/pen-mcp/src/tools/add-event-card-v0.ts new file mode 100644 index 000000000..2a0c91a99 --- /dev/null +++ b/packages/pen-mcp/src/tools/add-event-card-v0.ts @@ -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>> { + await ensureParentExists(params); + const tree = buildEventCard(params); + assignIdsRecursively(tree); + return insertElementTree({ binding: 'eventCard', tree, ...params }); +} diff --git a/packages/pen-mcp/src/tools/add-filter-group-v0.ts b/packages/pen-mcp/src/tools/add-filter-group-v0.ts new file mode 100644 index 000000000..8e1d0b781 --- /dev/null +++ b/packages/pen-mcp/src/tools/add-filter-group-v0.ts @@ -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>> { + await ensureParentExists(params); + const tree = buildFilterGroup(params); + assignIdsRecursively(tree); + return insertElementTree({ binding: 'filterGroup', tree, ...params }); +} diff --git a/packages/pen-mcp/src/tools/add-invite-row-v0.ts b/packages/pen-mcp/src/tools/add-invite-row-v0.ts new file mode 100644 index 000000000..dbe778734 --- /dev/null +++ b/packages/pen-mcp/src/tools/add-invite-row-v0.ts @@ -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>> { + await ensureParentExists(params); + const tree = buildInviteRow(params); + assignIdsRecursively(tree); + return insertElementTree({ binding: 'inviteRow', tree, ...params }); +} diff --git a/packages/pen-mcp/src/tools/add-step-card-v0.ts b/packages/pen-mcp/src/tools/add-step-card-v0.ts new file mode 100644 index 000000000..ee8e5a398 --- /dev/null +++ b/packages/pen-mcp/src/tools/add-step-card-v0.ts @@ -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>> { + await ensureParentExists(params); + const tree = buildStepCard(params); + assignIdsRecursively(tree); + return insertElementTree({ binding: 'stepCard', tree, ...params }); +}