From d547e6917bbb8bd63d3de052ad8a123b9308e498 Mon Sep 17 00:00:00 2001 From: Fini Date: Wed, 29 Apr 2026 09:50:28 +0800 Subject: [PATCH] feat(element-tools): add 10 v1 tools with theme parameter (P3 batch 8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Converts sidebar_nav, skeleton, social_login_row, spinner, stat_card, stat_grid, status_badge, step_card, stepper, switch to theme-aware v1. All 9 touchpoints wired; ext-8 shard extended for schema definitions. ListTools count: 167 → 177. All 4116 tests pass. Notable: spinner/stat_grid/status_badge/switch emit identical trees across all theme modes (caller-param colors, status semantics, or iOS HIG builder-private literals per spec §3.4) — theme param accepted for API consistency only. --- apps/web/server/api/mcp/exec-tool.post.ts | 21 ++ .../ai/__tests__/shim-server-parity-cases.ts | 73 ++++ .../services/ai/element-tool-shims/index.ts | 30 ++ .../skills/phases/generation/elements.md | 20 ++ .../pen-core/src/element-builders/index.ts | 22 ++ .../src/element-builders/sidebar-nav-v1.ts | 138 +++++++ .../src/element-builders/skeleton-v1.ts | 79 ++++ .../element-builders/social-login-row-v1.ts | 163 +++++++++ .../src/element-builders/spinner-v1.ts | 77 ++++ .../src/element-builders/stat-card-v1.ts | 146 ++++++++ .../src/element-builders/stat-grid-v1.ts | 88 +++++ .../src/element-builders/status-badge-v1.ts | 86 +++++ .../src/element-builders/step-card-v1.ts | 142 ++++++++ .../src/element-builders/stepper-v1.ts | 89 +++++ .../src/element-builders/switch-v1.ts | 56 +++ packages/pen-core/src/index.ts | 25 ++ .../src/routes/element-tool-defs-ext-8.ts | 339 +++++++++++++++++- .../pen-mcp/src/routes/element-tool-defs.ts | 30 ++ .../pen-mcp/src/tools/add-sidebar-nav-v1.ts | 28 ++ packages/pen-mcp/src/tools/add-skeleton-v1.ts | 22 ++ .../src/tools/add-social-login-row-v1.ts | 28 ++ packages/pen-mcp/src/tools/add-spinner-v1.ts | 23 ++ .../pen-mcp/src/tools/add-stat-card-v1.ts | 23 ++ .../pen-mcp/src/tools/add-stat-grid-v1.ts | 25 ++ .../pen-mcp/src/tools/add-status-badge-v1.ts | 27 ++ .../pen-mcp/src/tools/add-step-card-v1.ts | 23 ++ packages/pen-mcp/src/tools/add-stepper-v1.ts | 23 ++ packages/pen-mcp/src/tools/add-switch-v1.ts | 24 ++ 28 files changed, 1869 insertions(+), 1 deletion(-) create mode 100644 packages/pen-core/src/element-builders/sidebar-nav-v1.ts create mode 100644 packages/pen-core/src/element-builders/skeleton-v1.ts create mode 100644 packages/pen-core/src/element-builders/social-login-row-v1.ts create mode 100644 packages/pen-core/src/element-builders/spinner-v1.ts create mode 100644 packages/pen-core/src/element-builders/stat-card-v1.ts create mode 100644 packages/pen-core/src/element-builders/stat-grid-v1.ts create mode 100644 packages/pen-core/src/element-builders/status-badge-v1.ts create mode 100644 packages/pen-core/src/element-builders/step-card-v1.ts create mode 100644 packages/pen-core/src/element-builders/stepper-v1.ts create mode 100644 packages/pen-core/src/element-builders/switch-v1.ts create mode 100644 packages/pen-mcp/src/tools/add-sidebar-nav-v1.ts create mode 100644 packages/pen-mcp/src/tools/add-skeleton-v1.ts create mode 100644 packages/pen-mcp/src/tools/add-social-login-row-v1.ts create mode 100644 packages/pen-mcp/src/tools/add-spinner-v1.ts create mode 100644 packages/pen-mcp/src/tools/add-stat-card-v1.ts create mode 100644 packages/pen-mcp/src/tools/add-stat-grid-v1.ts create mode 100644 packages/pen-mcp/src/tools/add-status-badge-v1.ts create mode 100644 packages/pen-mcp/src/tools/add-step-card-v1.ts create mode 100644 packages/pen-mcp/src/tools/add-stepper-v1.ts create mode 100644 packages/pen-mcp/src/tools/add-switch-v1.ts diff --git a/apps/web/server/api/mcp/exec-tool.post.ts b/apps/web/server/api/mcp/exec-tool.post.ts index e4cf9cf24..a4a5be658 100644 --- a/apps/web/server/api/mcp/exec-tool.post.ts +++ b/apps/web/server/api/mcp/exec-tool.post.ts @@ -168,6 +168,16 @@ import { buildSegmentedControlV1, buildSelectV1, buildShareRowV1, + buildSidebarNavV1, + buildSkeletonV1, + buildSocialLoginRowV1, + buildSpinnerV1, + buildStatCardV1, + buildStatGridV1, + buildStatusBadgeV1, + buildStepCardV1, + buildStepperV1, + buildSwitchV1, findNodeInTree, insertNodeInTree, type ElementTree, @@ -407,6 +417,17 @@ const SERVER_BUILDERS: Record = { buildSegmentedControlV1(a as Parameters[0]), add_select_v1: (a) => buildSelectV1(a as Parameters[0]), add_share_row_v1: (a) => buildShareRowV1(a as Parameters[0]), + add_sidebar_nav_v1: (a) => buildSidebarNavV1(a as Parameters[0]), + add_skeleton_v1: (a) => buildSkeletonV1(a as Parameters[0]), + add_social_login_row_v1: (a) => + buildSocialLoginRowV1(a as Parameters[0]), + add_spinner_v1: (a) => buildSpinnerV1(a as Parameters[0]), + add_stat_card_v1: (a) => buildStatCardV1(a as Parameters[0]), + add_stat_grid_v1: (a) => buildStatGridV1(a as Parameters[0]), + add_status_badge_v1: (a) => buildStatusBadgeV1(a as Parameters[0]), + add_step_card_v1: (a) => buildStepCardV1(a as Parameters[0]), + add_stepper_v1: (a) => buildStepperV1(a as Parameters[0]), + add_switch_v1: (a) => buildSwitchV1(a as Parameters[0]), }; interface ExecToolBody { diff --git a/apps/web/src/services/ai/__tests__/shim-server-parity-cases.ts b/apps/web/src/services/ai/__tests__/shim-server-parity-cases.ts index 751ab08a4..959f3759c 100644 --- a/apps/web/src/services/ai/__tests__/shim-server-parity-cases.ts +++ b/apps/web/src/services/ai/__tests__/shim-server-parity-cases.ts @@ -177,6 +177,16 @@ import { buildSegmentedControlV1, buildSelectV1, buildShareRowV1, + buildSidebarNavV1, + buildSkeletonV1, + buildSocialLoginRowV1, + buildSpinnerV1, + buildStatCardV1, + buildStatGridV1, + buildStatusBadgeV1, + buildStepCardV1, + buildStepperV1, + buildSwitchV1, } from '@zseven-w/pen-core'; export interface BuilderCase { @@ -1210,4 +1220,67 @@ export const CASES: BuilderCase[] = [ }, build: (a) => buildShareRowV1(a as unknown as Parameters[0]), }, + { + toolName: 'add_sidebar_nav_v1', + args: { + items: [ + { label: 'Dashboard', icon: 'layout-dashboard', active: true }, + { label: 'Settings', icon: 'settings' }, + ], + theme: 'light', + }, + build: (a) => buildSidebarNavV1(a as unknown as Parameters[0]), + }, + { + toolName: 'add_skeleton_v1', + args: { rows: 3, theme: 'light' }, + build: (a) => buildSkeletonV1(a as unknown as Parameters[0]), + }, + { + toolName: 'add_social_login_row_v1', + args: { providers: [{ name: 'google' }, { name: 'apple' }], theme: 'light' }, + build: (a) => + buildSocialLoginRowV1(a as unknown as Parameters[0]), + }, + { + toolName: 'add_spinner_v1', + args: { size: 32, theme: 'light' }, + build: (a) => buildSpinnerV1(a as unknown as Parameters[0]), + }, + { + toolName: 'add_stat_card_v1', + args: { label: 'Revenue', value: '$12.4k', theme: 'light' }, + build: (a) => buildStatCardV1(a as unknown as Parameters[0]), + }, + { + toolName: 'add_stat_grid_v1', + args: { + items: [ + { value: '1,284', label: 'Users' }, + { value: '$12.4k', label: 'Revenue' }, + ], + theme: 'light', + }, + build: (a) => buildStatGridV1(a as unknown as Parameters[0]), + }, + { + toolName: 'add_status_badge_v1', + args: { label: 'Online', tone: 'success', theme: 'light' }, + build: (a) => buildStatusBadgeV1(a as unknown as Parameters[0]), + }, + { + toolName: 'add_step_card_v1', + args: { number: 1, title: 'Create account', description: 'Sign up with email.', theme: 'light' }, + build: (a) => buildStepCardV1(a as unknown as Parameters[0]), + }, + { + toolName: 'add_stepper_v1', + args: { total: 3, current: 1, theme: 'light' }, + build: (a) => buildStepperV1(a as unknown as Parameters[0]), + }, + { + toolName: 'add_switch_v1', + args: { active: true, theme: 'light' }, + build: (a) => buildSwitchV1(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 040231ec0..9292689c3 100644 --- a/apps/web/src/services/ai/element-tool-shims/index.ts +++ b/apps/web/src/services/ai/element-tool-shims/index.ts @@ -186,6 +186,16 @@ import { buildSegmentedControlV1, buildSelectV1, buildShareRowV1, + buildSidebarNavV1, + buildSkeletonV1, + buildSocialLoginRowV1, + buildSpinnerV1, + buildStatCardV1, + buildStatGridV1, + buildStatusBadgeV1, + buildStepCardV1, + buildStepperV1, + buildSwitchV1, type ActionMenuParams, type ActivityRingParams, type AlertParams, @@ -324,6 +334,16 @@ import { type SegmentedControlV1Params, type SelectV1Params, type ShareRowV1Params, + type SidebarNavV1Params, + type SkeletonV1Params, + type SocialLoginRowV1Params, + type SpinnerV1Params, + type StatCardV1Params, + type StatGridV1Params, + type StatusBadgeV1Params, + type StepCardV1Params, + type StepperV1Params, + type SwitchV1Params, type KbdParams, type LinkParams, type ListRowParams, @@ -611,6 +631,16 @@ export const ELEMENT_SHIMS: Record = { add_segmented_control_v1: wrap(buildSegmentedControlV1), add_select_v1: wrap(buildSelectV1), add_share_row_v1: wrap(buildShareRowV1), + add_sidebar_nav_v1: wrap(buildSidebarNavV1), + add_skeleton_v1: wrap(buildSkeletonV1), + add_social_login_row_v1: wrap(buildSocialLoginRowV1), + add_spinner_v1: wrap(buildSpinnerV1), + add_stat_card_v1: wrap(buildStatCardV1), + add_stat_grid_v1: wrap(buildStatGridV1), + add_status_badge_v1: wrap(buildStatusBadgeV1), + add_step_card_v1: wrap(buildStepCardV1), + add_stepper_v1: wrap(buildStepperV1), + add_switch_v1: wrap(buildSwitchV1), }; export function getElementShim(name: string): ElementShim | undefined { diff --git a/packages/pen-ai-skills/skills/phases/generation/elements.md b/packages/pen-ai-skills/skills/phases/generation/elements.md index 4e80212bf..ec3f746e7 100644 --- a/packages/pen-ai-skills/skills/phases/generation/elements.md +++ b/packages/pen-ai-skills/skills/phases/generation/elements.md @@ -59,6 +59,7 @@ Rows (horizontal, in-card or scrolling): 3. Row of filter chips / category tabs (label + optional icon, active state) → `add_nav_chip_row_v0` (scroll) 3b. Theme-aware variant (no hardcoded colors; all modes identical — accepts theme for API consistency) → `add_nav_chip_row_v1` 4. Non-scrolling 2-5 stats inline (auto-share width) → `add_stat_grid_v0` + 4b. Theme-aware variant (no explicit fills in v0; all modes identical — accepts theme for API consistency) → `add_stat_grid_v1` Containers and single elements: @@ -109,6 +110,7 @@ Forms: Controls (toggle / choice / tabs): 20. iOS/Material toggle switch (51×31, thumb floats) → `add_switch_v0` + 20b. Theme-aware variant (iOS HIG values #34C759/#E5E5EA are builder-private per §3.4; all modes identical) → `add_switch_v1` 21. Checkbox + inline label (20×20 box, `check` icon inside when checked) → `add_checkbox_v0` 21b. Theme-aware variant (accent fill checked, border token unchecked) → `add_checkbox_v1` 22. Radio button + inline label (20×20 ring, dot inside when selected) → `add_radio_v0` @@ -134,6 +136,7 @@ Floating / nav / wizard: 30. Breadcrumb trail with chevron separators (last crumb auto-active) → `add_breadcrumb_v0` 30b. Theme-aware variant (accepts `theme` param for API consistency; output identical across themes) → `add_breadcrumb_v1` 31. Horizontal numbered stepper (circles + fill_container connectors) → `add_stepper_v0` + 31b. Theme-aware variant (pending fill → border; pending number → textMuted; accent + done-white stay hardcoded) → `add_stepper_v1` Ratings & pagination: @@ -191,10 +194,12 @@ Chrome / modals: Status / presence: 47. Status badge (small colored dot + short label, "● Online" pattern, tone-enum'd) → `add_status_badge_v0` + 47b. Theme-aware variant (tone colors are status semantics kept hardcoded; all modes identical) → `add_status_badge_v1` Feedback / loading: 48. Loading spinner (static ring + 3/4 arc) → `add_spinner_v0` + 48b. Theme-aware variant (track_color/active_color are caller params; accepts theme for API consistency; all modes identical) → `add_spinner_v1` 49. Tooltip pill (dark pill + white text, hover-hint appearance) → `add_tooltip_v0` Analytics / KPIs: @@ -219,6 +224,7 @@ Calendars: Loading / placeholder: 43. Loading skeleton (N gray rectangles, last row ~60% width) → `add_skeleton_v0` + 43b. Theme-aware variant (row fill → surface2 in dark/system so bars are visible on dark bg) → `add_skeleton_v1` Pagination: @@ -271,10 +277,12 @@ Messaging: Dashboard KPIs: 62. Big-number stat card (standalone metric tile — label + huge value + optional delta/icon) → `add_stat_card_v0` + 62b. Theme-aware variant (bg → surface; border → border; label → textMuted; icon → textSubtle; value → textPrimary; delta tones stay hardcoded) → `add_stat_card_v1` Auth / login: 63. Social auth provider buttons ("Continue with Google / Apple / Microsoft", OAuth/SSO row, third-party sign-in) → `add_social_login_row_v0` + 63b. Theme-aware variant (button bg → surface; border → border; icon → textMuted; label → textPrimary in dark/system) → `add_social_login_row_v1` Pricing / monetization: @@ -300,6 +308,7 @@ Compliance / disclosure: Desktop / dashboard rails: 69. Persistent vertical sidebar (left rail with icon+label rows, optional brand title, active item gets pill bg) → `add_sidebar_nav_v0` + 69b. Theme-aware variant (bg → surface; title + active label → textPrimary; inactive → textMuted; active item bg → surface2) → `add_sidebar_nav_v1` Presence / collaboration: @@ -381,6 +390,7 @@ Calendar: Onboarding: 89. Numbered step card (circle/check + title + description) → `add_step_card_v0` + 89b. Theme-aware variant (title → textPrimary; desc → textMuted; incomplete ring bg → surface; accent stays hardcoded) → `add_step_card_v1` 90. None match → fall through to `batch_design` @@ -393,6 +403,7 @@ PREFER an element tool when the spec says any of: - "horizontal scrolling cards", "swipeable row", "chip row", "pills" → `add_card_row_v0` - "metric tiles", "KPI cards", "dashboard stats" (SCROLLING row) → `add_metric_row_v0` - "stats row", "3 metrics side by side", "summary bar" (NON-scrolling grid) → `add_stat_grid_v0` +- "dark stats grid", "dark-mode stat grid", "theme-aware stat grid", "暗色指标网格" → `add_stat_grid_v1` (no explicit fills; all modes identical — accepts theme for API consistency). - "category filter chips", "quick-access shortcuts" → `add_nav_chip_row_v0` - "section title with See all / View more" → `add_section_header_v0` - "dark section header", "dark-mode section title", "theme-aware section header", "暗色版块标题" → `add_section_header_v1` (no color change; accepts theme for API consistency) @@ -420,6 +431,7 @@ PREFER an element tool when the spec says any of: - "dropdown", "select", "picker", "combo box", "下拉选择", "选择器" → `add_select_v0` - "dark select", "dark-mode dropdown", "theme-aware select", "暗色下拉" → `add_select_v1` (placeholder → textSubtle in dark/system) - "skeleton", "loading placeholder", "shimmer", "loading state", "placeholder lines", "骨架屏", "加载中占位" → `add_skeleton_v0` +- "dark skeleton", "dark-mode skeleton", "theme-aware skeleton", "暗色骨架屏" → `add_skeleton_v1` (row fill → surface2 so bars are visible on dark bg). - "line chart", "trend chart", "折线图" → `add_chart_line_v0` - "pie chart", "donut chart", "饼图" → `add_chart_pie_v0` - "image placeholder", "photo slot", "upload zone", "hero image area", "cover placeholder", "图片占位" → `add_image_placeholder_v0` @@ -430,9 +442,12 @@ PREFER an element tool when the spec says any of: - "dark toast", "dark-mode snackbar", "theme-aware toast", "system theme toast", "暗色 toast", "暗色浮层通知" → `add_toast_v1` (accepts `theme` param; toasts use INVERTED contrast — `"dark"` gives a light pill with dark fg. Use `"system"` when `applySemanticPalette(doc)` is seeded) - "dark empty chart", "dark-mode no-data placeholder", "theme-aware chart empty state", "暗色空图表", "暗色无数据占位" → `add_empty_chart_v1` (accepts `theme` param; use inside dark-theme dashboards so the empty slot doesn't punch a light rectangle into dark surfaces. Use `"system"` when `applySemanticPalette(doc)` is seeded) - "status", "online indicator", "presence dot", "health status", "busy indicator", "状态", "在线" → `add_status_badge_v0` +- "dark status badge", "dark-mode status indicator", "theme-aware status badge", "暗色状态徽标" → `add_status_badge_v1` (tone colors are status semantics kept hardcoded; all modes identical). - "spinner", "loading spinner", "progress circle", "loader", "加载圈" → `add_spinner_v0` +- "dark spinner", "dark-mode spinner", "theme-aware spinner", "暗色加载圈" → `add_spinner_v1` (track_color/active_color are caller params; accepts theme for API consistency). - "tooltip", "hover hint", "help tip", "提示浮层" → `add_tooltip_v0` - "toggle", "switch", "on/off", "开关" → `add_switch_v0` +- "dark toggle", "dark switch", "theme-aware switch", "暗色开关" → `add_switch_v1` (iOS HIG values kept hardcoded; all modes identical — accepts theme for API consistency). - "checkbox", "agreement", "select option", "复选框" → `add_checkbox_v0` - "radio", "single choice", "单选" → `add_radio_v0` (stack multiple in a vertical parent) - "dark radio", "dark-mode radio button", "theme-aware radio", "暗色单选" → `add_radio_v1` (accent stays brand-invariant; unselected ring → border token) @@ -447,6 +462,7 @@ PREFER an element tool when the spec says any of: - "FAB", "floating action button", "compose button", "新建按钮" → `add_fab_v0` - "breadcrumb", "nav path", "面包屑" → `add_breadcrumb_v0` - "stepper", "progress steps", "wizard nav", "步骤条" → `add_stepper_v0` +- "dark stepper", "dark-mode stepper", "theme-aware stepper", "暗色步骤条" → `add_stepper_v1` (pending fill → border; pending number → textMuted in dark/system). - "rating", "review stars", "评分" → `add_rating_stars_v0` - "dark rating", "dark-mode stars", "theme-aware rating", "暗色评分" → `add_rating_stars_v1` (no color change; accepts theme for API consistency) - "carousel dots", "slide indicator", "轮播指示" → `add_carousel_dots_v0` @@ -471,7 +487,9 @@ PREFER an element tool when the spec says any of: - "attachment", "attached file", "uploaded file", "file item", "file list row", "附件", "已上传文件" → `add_attachment_row_v0` (for upload-in-progress state, compose `add_progress_bar_v0` below) - "chat message", "message bubble", "conversation row", "iMessage bubble", "chat UI", "聊天气泡", "消息气泡" → `add_chat_bubble_v0` (side="left" for from-others, side="right" for from-self) - "KPI card", "big number card", "metric tile", "stat widget", "featured metric", "关键指标卡", "数据大屏卡片" → `add_stat_card_v0` (distinct from `add_stat_grid_v0` which is multi-cell side-by-side) +- "dark stat card", "dark KPI card", "dark-mode metric tile", "暗色指标卡" → `add_stat_card_v1` (bg → surface; border → border; label → textMuted; icon → textSubtle; value → textPrimary in dark/system). - "Continue with Google", "Sign in with Apple", "social login", "OAuth buttons", "SSO providers", "third-party login", "第三方登录", "社交登录", "OAuth 登录" → `add_social_login_row_v0` (orientation="vertical" for stacked full-width on mobile; orientation="horizontal" for the compact "or sign in with..." icon-only row) +- "dark social login", "dark-mode social auth buttons", "theme-aware social login", "暗色第三方登录" → `add_social_login_row_v1` (button bg → surface; border → border; icon → textMuted; label → textPrimary in dark/system). - "pricing card", "plan card", "SaaS tier", "subscription plan", "pricing tier", "billing card", "价格卡", "套餐卡", "定价卡片" → `add_pricing_card_v0` (set one tile's `emphasis: "featured"` to visually recommend it — auto-gets "Most popular" badge unless `badge` overrides). For a 3-tier pricing section, call this 3× under the same parent section. - "slider", "range input", "volume control", "opacity slider", "brightness slider", "filter slider", "滑块", "滑动条", "音量条" → `add_range_slider_v0` (single-handle; set `show_value=true` + `value_suffix="%"` to render the readout). For a dual-handle range (min+max), still fall through to batch_design. - "dark slider", "dark-mode range input", "theme-aware slider", "暗色滑块" → `add_range_slider_v1` (accent invariant; thumb bg → surface; remaining → border; label/value tokenized) @@ -479,6 +497,7 @@ PREFER an element tool when the spec says any of: - "newsletter signup", "subscribe form", "subscribe to newsletter", "promo code input", "apply discount", "send message input", "chat composer", "search with submit", "订阅", "应用优惠码", "发送消息" → `add_input_with_action_v0` (action_kind="text" for "Subscribe" pill button, action_kind="icon" for chat send arrow). - "cookie banner", "cookie consent", "GDPR banner", "CCPA banner", "privacy notice", "cookie disclosure", "cookie 提示", "隐私同意条" → `add_cookie_banner_v0` (set `show_settings_link: true` for fine-grained GDPR consent UX). Caller positions sticky-bottom; the tool emits the banner card itself. - "sidebar", "side nav", "sidebar nav", "left rail", "dashboard nav rail", "admin sidebar", "settings sidebar", "docs sidebar", "vertical nav", "侧边栏", "侧边导航", "左侧导航" → `add_sidebar_nav_v0` (desktop persistent rail; pass `title` for a brand row above the items, mark current page with `active: true` on the item). +- "dark sidebar", "dark-mode sidebar nav", "theme-aware sidebar", "暗色侧边栏" → `add_sidebar_nav_v1` (bg → surface; active label → textPrimary; inactive → textMuted; active item bg → surface2 in dark/system). - "stacked avatars", "avatar group", "avatar stack", "team avatars", "5 contributors", "online users", "+N more", "viewers row", "presence indicator", "成员头像", "团队头像", "在线用户", "头像组" → `add_avatar_group_v0` (renders up to `max_visible` ringed avatar circles + a "+N" overflow tile; pen-core flex doesn't allow negative gap so the white ring + 4px gap is the affordance, not literal overlap). - "data table row", "table row", "table header row", "customer row", "order row", "report row", "transaction row", "users table", "数据表行", "表格行", "表头行" → `add_data_table_row_v0` (desktop pattern; pass `header: true` for the column-header row, `selected: true` to tint a hover/selected body row). For row separators stack `add_divider_v0` between rows. - "filter chip", "filter tag", "applied filter", "selected criterion", "category pill", "removable tag", "Status: Active ×", "可移除标签", "筛选标签" → `add_tag_v0` (single chip with optional × close icon, default removable=true; pass `tone` for accent / success / warning / error palettes). @@ -510,6 +529,7 @@ PREFER an element tool when the spec says any of: - "audit log row", "activity feed entry", "recent activity item", "audit entry", "审计日志条目", "活动记录", "操作日志行" → `add_activity_log_v0` (single line: optional tinted icon dot + " " + right-aligned timestamp). - "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). - "onboarding step", "how-it-works step", "tutorial step card", "setup checklist item", "操作步骤卡片", "教程步骤", "新手引导步骤" → `add_step_card_v0` (numbered circle / check + title + description, stacks vertically). +- "dark step card", "dark-mode onboarding step", "theme-aware step card", "暗色步骤卡片" → `add_step_card_v1` (title → textPrimary; desc → textMuted; ring bg → surface in dark/system). STILL use batch_design when (emit a SINGLE batch_design for the whole response — do NOT mix with element-tool calls): diff --git a/packages/pen-core/src/element-builders/index.ts b/packages/pen-core/src/element-builders/index.ts index 677c8a09e..ead7e8f5f 100644 --- a/packages/pen-core/src/element-builders/index.ts +++ b/packages/pen-core/src/element-builders/index.ts @@ -244,3 +244,25 @@ export { buildSectionHeaderV1, type SectionHeaderV1Params } from './section-head export { buildSegmentedControlV1, type SegmentedControlV1Params } from './segmented-control-v1.js'; export { buildSelectV1, type SelectV1Params } from './select-v1.js'; export { buildShareRowV1, type ShareRowV1Params } from './share-row-v1.js'; +export { + buildSidebarNavV1, + type SidebarNavV1Item, + type SidebarNavV1Params, +} from './sidebar-nav-v1.js'; +export { buildSkeletonV1, type SkeletonV1Params } from './skeleton-v1.js'; +export { + buildSocialLoginRowV1, + type SocialLoginV1Provider, + type SocialLoginRowV1Params, +} from './social-login-row-v1.js'; +export { buildSpinnerV1, type SpinnerV1Params } from './spinner-v1.js'; +export { buildStatCardV1, type StatCardV1Params, type StatCardV1Trend } from './stat-card-v1.js'; +export { buildStatGridV1, type StatGridV1Item, type StatGridV1Params } from './stat-grid-v1.js'; +export { + buildStatusBadgeV1, + type StatusBadgeV1Params, + type StatusBadgeV1Tone, +} from './status-badge-v1.js'; +export { buildStepCardV1, type StepCardV1Params } from './step-card-v1.js'; +export { buildStepperV1, type StepperV1Params } from './stepper-v1.js'; +export { buildSwitchV1, type SwitchV1Params } from './switch-v1.js'; diff --git a/packages/pen-core/src/element-builders/sidebar-nav-v1.ts b/packages/pen-core/src/element-builders/sidebar-nav-v1.ts new file mode 100644 index 000000000..667cb582c --- /dev/null +++ b/packages/pen-core/src/element-builders/sidebar-nav-v1.ts @@ -0,0 +1,138 @@ +import type { ElementTree } from './helpers.js'; +import { resolveTheme, type V1Theme } from './resolve-theme.js'; + +export interface SidebarNavV1Item { + label: string; + icon: string; + active?: boolean; +} + +export interface SidebarNavV1Params { + items: SidebarNavV1Item[]; + /** Optional brand / section title row above the items. */ + title?: string; + /** Sidebar width in px. Default 240. Clamped 180..320. */ + width?: number; + /** + * Theme mode. + * - `'light'` (default): byte-parity with add_sidebar_nav_v0. + * - `'dark'`: sidebar bg → surface; title + active label → textPrimary; + * inactive label → textMuted; active item bg → surface2. + * - `'system'`: $color-* refs for all color slots. + */ + theme?: V1Theme; +} + +/** + * Sidebar navigation (v1) — theme-aware variant of buildSidebarNav. + * Light mode is byte-equal to add_sidebar_nav_v0. + * + * Color mapping: + * sidebar bg (#FFFFFF) → surface token + * title text (#0F172A slate-900) → textPrimary token + * active label (#0F172A slate-900) → textPrimary token + * active item fill (#F1F5F9 slate-100) → surface2 token + * inactive label (#475569 slate-600) → textMuted token + */ +export function buildSidebarNavV1(params: SidebarNavV1Params): ElementTree { + const width = Math.min(320, Math.max(180, Math.floor(params.width ?? 240))); + const theme = params.theme ?? 'light'; + const isLight = theme === 'light'; + const t = resolveTheme(theme); + + // Sidebar bg: light → #FFFFFF, dark/system → surface + const sidebarBg = isLight ? '#FFFFFF' : t.colors.surface; + // Title text: light → slate-900 (#0F172A), dark/system → textPrimary + const titleColor = isLight ? '#0F172A' : t.colors.textPrimary; + // Active label: light → slate-900 (#0F172A), dark/system → textPrimary + const activeLabelColor = isLight ? '#0F172A' : t.colors.textPrimary; + // Active item bg: light → slate-100 (#F1F5F9), dark/system → surface2 + const activeItemBg = isLight ? '#F1F5F9' : t.colors.surface2; + // Inactive label: light → slate-600 (#475569), dark/system → textMuted + const inactiveLabelColor = isLight ? '#475569' : t.colors.textMuted; + + const children: ElementTree[] = []; + if (params.title) { + children.push({ + type: 'frame', + name: 'Sidebar Title', + role: 'sidebar-nav-title', + width: 'fill_container', + height: 'fit_content', + layout: 'horizontal', + alignItems: 'center', + padding: [8, 12, 24, 12], + children: [ + { + type: 'text', + name: 'Title', + role: 'sidebar-nav-title-text', + content: params.title, + fontSize: 16, + fontWeight: 700, + fill: [{ type: 'solid', color: titleColor }], + }, + ], + }); + } + for (const item of params.items) { + children.push(buildItemV1(item, activeLabelColor, activeItemBg, inactiveLabelColor)); + } + return { + type: 'frame', + name: 'Sidebar Nav', + role: 'sidebar-nav', + width, + height: 'fill_container', + layout: 'vertical', + gap: 4, + padding: [16, 12], + fill: [{ type: 'solid', color: sidebarBg }], + children, + }; +} + +function buildItemV1( + item: SidebarNavV1Item, + activeLabelColor: string, + activeItemBg: string, + inactiveLabelColor: string, +): ElementTree { + const active = item.active === true; + const node: ElementTree = { + type: 'frame', + name: `Item (${item.label})`, + role: active ? 'sidebar-nav-item-active' : 'sidebar-nav-item', + width: 'fill_container', + height: 40, + cornerRadius: 8, + layout: 'horizontal', + alignItems: 'center', + gap: 12, + padding: [0, 12], + children: [ + { + type: 'icon_font', + name: 'Icon', + role: 'sidebar-nav-icon', + iconFontName: item.icon, + iconFontFamily: 'lucide', + width: 18, + height: 18, + }, + { + type: 'text', + name: 'Label', + role: 'sidebar-nav-label', + content: item.label, + fontSize: 14, + fontWeight: active ? 600 : 500, + fill: [{ type: 'solid', color: active ? activeLabelColor : inactiveLabelColor }], + }, + ], + }; + if (active) { + node.fill = [{ type: 'solid', color: activeItemBg }]; + } + return node; +} diff --git a/packages/pen-core/src/element-builders/skeleton-v1.ts b/packages/pen-core/src/element-builders/skeleton-v1.ts new file mode 100644 index 000000000..2a6e46e19 --- /dev/null +++ b/packages/pen-core/src/element-builders/skeleton-v1.ts @@ -0,0 +1,79 @@ +import type { ElementTree } from './helpers.js'; +import { resolveTheme, type V1Theme } from './resolve-theme.js'; + +export interface SkeletonV1Params { + /** Number of skeleton rows to emit (clamped 1..20). Default 3. */ + rows?: number; + /** Height per row in px (clamped 4..48). Default 16. */ + row_height?: number; + /** Gap between rows in px (clamped 0..32). Default 12. */ + row_gap?: number; + /** + * When true, the LAST row is 60% width (simulates an unfinished + * paragraph line — the classic "shimmer" pattern). Default true. + */ + last_row_short?: boolean; + /** + * Theme mode. + * - `'light'` (default): byte-parity with add_skeleton_v0 (slate-200 rows). + * - `'dark'`: row fill → surface2 (#334155) — visible against dark page bg. + * - `'system'`: $color-surface-2 ref for row fills. + */ + theme?: V1Theme; +} + +/** + * Loading skeleton (v1) — theme-aware variant of buildSkeleton. + * Light mode is byte-equal to add_skeleton_v0. + * + * Color mapping: + * skeleton row fill (#E2E8F0 slate-200) → surface2 token + */ +export function buildSkeletonV1(params: SkeletonV1Params): ElementTree { + const rows = Math.max(1, Math.min(20, params.rows ?? 3)); + const rowHeight = Math.max(4, Math.min(48, params.row_height ?? 16)); + const rowGap = Math.max(0, Math.min(32, params.row_gap ?? 12)); + const lastShort = params.last_row_short ?? true; + const theme = params.theme ?? 'light'; + const isLight = theme === 'light'; + const t = resolveTheme(theme); + + // Row fill: light → slate-200 (#E2E8F0), dark/system → surface2 + const rowFill = isLight ? '#E2E8F0' : t.colors.surface2; + + const children: ElementTree[] = []; + for (let i = 0; i < rows; i++) { + const isLast = i === rows - 1; + if (isLast && lastShort && rows > 1) { + children.push({ + type: 'rectangle', + role: 'skeleton-row', + width: 220, + height: rowHeight, + cornerRadius: 4, + fill: [{ type: 'solid', color: rowFill }], + }); + } else { + children.push({ + type: 'rectangle', + role: 'skeleton-row', + width: 'fill_container', + height: rowHeight, + cornerRadius: 4, + fill: [{ type: 'solid', color: rowFill }], + }); + } + } + + return { + type: 'frame', + name: 'Skeleton', + role: 'skeleton', + width: 'fill_container', + height: 'fit_content', + layout: 'vertical', + gap: rowGap, + alignItems: 'start', + children, + }; +} diff --git a/packages/pen-core/src/element-builders/social-login-row-v1.ts b/packages/pen-core/src/element-builders/social-login-row-v1.ts new file mode 100644 index 000000000..403355bee --- /dev/null +++ b/packages/pen-core/src/element-builders/social-login-row-v1.ts @@ -0,0 +1,163 @@ +import type { ElementTree } from './helpers.js'; +import { resolveTheme, type V1Theme } from './resolve-theme.js'; + +export interface SocialLoginV1Provider { + /** + * Provider name. Displayed inline as "Continue with {name}". + * Known names ('google', 'apple', 'microsoft', 'github', + * 'facebook', 'twitter', 'x', 'linkedin') auto-resolve to the + * matching lucide icon. Override via explicit `icon`. + */ + name: string; + /** + * Optional lucide icon override. Takes precedence over the + * known-name mapping. + */ + icon?: string; +} + +export interface SocialLoginRowV1Params { + /** Array of providers to render. 2-4 recommended; clamped to 6 max. */ + providers: SocialLoginV1Provider[]; + /** + * Layout orientation. Default 'vertical' (stacked full-width buttons). + * 'horizontal' renders icon-only pills side-by-side. + */ + orientation?: 'vertical' | 'horizontal'; + /** Button width in px. Default 320. Min 200. Ignored on horizontal orientation. */ + width?: number; + /** + * Theme mode. + * - `'light'` (default): byte-parity with add_social_login_row_v0. + * - `'dark'`: button bg → surface; border → border; icon fill → textMuted; + * label → textPrimary. + * - `'system'`: $color-* refs for all color slots. + */ + theme?: V1Theme; +} + +const KNOWN_ICONS: Record = { + google: 'chrome', + apple: 'apple', + microsoft: 'monitor', + github: 'github', + gitlab: 'git-branch', + facebook: 'facebook', + twitter: 'twitter', + x: 'twitter', + linkedin: 'linkedin', + discord: 'message-circle', + slack: 'hash', + email: 'mail', + phone: 'smartphone', +}; + +/** + * Social-auth provider button row (v1) — theme-aware variant of buildSocialLoginRow. + * Light mode is byte-equal to add_social_login_row_v0. + * + * Color mapping: + * button bg (#FFFFFF white) → surface token + * border (#E2E8F0 slate-200) → border token + * icon fill (#334155 slate-700) → textMuted token + * label text (#0F172A slate-900) → textPrimary token + */ +export function buildSocialLoginRowV1(params: SocialLoginRowV1Params): ElementTree { + const raw = Array.isArray(params.providers) ? params.providers : []; + if (raw.length === 0) { + throw new Error('buildSocialLoginRowV1: providers array must not be empty'); + } + const providers = raw.slice(0, 6); + const orientation = params.orientation ?? 'vertical'; + const isVertical = orientation === 'vertical'; + const width = Math.max(200, Math.floor(params.width ?? 320)); + const theme = params.theme ?? 'light'; + const isLight = theme === 'light'; + const t = resolveTheme(theme); + + // Button bg: light → white (#FFFFFF), dark/system → surface + const buttonBg = isLight ? '#FFFFFF' : t.colors.surface; + // Border: light → slate-200 (#E2E8F0), dark/system → border + const borderColor = isLight ? '#E2E8F0' : t.colors.border; + // Icon fill: light → slate-700 (#334155), dark/system → textMuted + const iconFill = isLight ? '#334155' : t.colors.textMuted; + // Label: light → slate-900 (#0F172A), dark/system → textPrimary + const labelColor = isLight ? '#0F172A' : t.colors.textPrimary; + + const buttons: ElementTree[] = providers.map((provider) => { + const lowerName = provider.name.toLowerCase(); + const icon = provider.icon ?? KNOWN_ICONS[lowerName] ?? 'log-in'; + const label = `Continue with ${provider.name}`; + const prettyLabel = + provider.name.charAt(0) === provider.name.charAt(0).toUpperCase() + ? label + : `Continue with ${provider.name.charAt(0).toUpperCase() + provider.name.slice(1)}`; + + const iconNode: ElementTree = { + type: 'icon_font', + name: `${provider.name} Icon`, + role: 'social-login-button-icon', + iconFontName: icon, + iconFontFamily: 'lucide', + width: 20, + height: 20, + fill: [{ type: 'solid', color: iconFill }], + }; + + if (isVertical) { + return { + type: 'frame', + name: `${provider.name} Button`, + role: 'social-login-button', + width: 'fill_container', + height: 48, + cornerRadius: 12, + layout: 'horizontal', + alignItems: 'center', + gap: 12, + padding: [0, 16], + fill: [{ type: 'solid', color: buttonBg }], + stroke: { thickness: 1, fill: [{ type: 'solid', color: borderColor }] }, + children: [ + iconNode, + { + type: 'text', + name: 'Label', + role: 'social-login-button-label', + content: prettyLabel, + fontSize: 14, + fontWeight: 500, + fill: [{ type: 'solid', color: labelColor }], + }, + ], + }; + } + // Horizontal: icon-only square pill + return { + type: 'frame', + name: `${provider.name} Button`, + role: 'social-login-button-compact', + width: 48, + height: 48, + cornerRadius: 12, + layout: 'horizontal', + alignItems: 'center', + justifyContent: 'center', + fill: [{ type: 'solid', color: buttonBg }], + stroke: { thickness: 1, fill: [{ type: 'solid', color: borderColor }] }, + children: [iconNode], + }; + }); + + return { + type: 'frame', + name: 'Social Login Row', + role: 'social-login-row', + width: isVertical ? width : 'fit_content', + height: 'fit_content', + layout: isVertical ? 'vertical' : 'horizontal', + alignItems: 'center', + gap: 10, + children: buttons, + }; +} diff --git a/packages/pen-core/src/element-builders/spinner-v1.ts b/packages/pen-core/src/element-builders/spinner-v1.ts new file mode 100644 index 000000000..f54a3cb74 --- /dev/null +++ b/packages/pen-core/src/element-builders/spinner-v1.ts @@ -0,0 +1,77 @@ +import type { ElementTree } from './helpers.js'; +import type { V1Theme } from './resolve-theme.js'; + +export interface SpinnerV1Params { + /** Outer diameter in px. Default 32. Clamped 16..128. */ + size?: number; + /** Stroke thickness in px. Default 3. Clamped 1..16. */ + thickness?: number; + /** Track color (the static ring). Default #E2E8F0 (slate-200). */ + track_color?: string; + /** Active arc color. Default #2563EB (blue-600). */ + active_color?: string; + /** + * Theme mode. + * - `'light'` (default): byte-parity with add_spinner_v0. + * - `'dark'`: identical (track_color/active_color are caller-provided or + * iOS/Material defaults — these are UI state colors, not surface colors). + * - `'system'`: identical. + * + * NOTE: track_color and active_color are caller-overridable parameters and + * are treated as builder-private defaults (#E2E8F0 / #2563EB). They are + * not tokenized across theme modes — use explicit track_color/active_color + * overrides if you need theme-specific spinner colors. + */ + theme?: V1Theme; +} + +/** + * Loading spinner (v1) — theme-aware variant of buildSpinner. + * Light mode is byte-equal to add_spinner_v0. + * + * track_color and active_color are caller-controllable defaults. + * Since no surface/text colors are hardcoded in the v0 builder body + * (only the param defaults #E2E8F0 and #2563EB), all theme modes + * emit the same tree — the caller provides explicit colors when needed. + */ +export function buildSpinnerV1(params: SpinnerV1Params): ElementTree { + const size = Math.max(16, Math.min(128, Math.floor(params.size ?? 32))); + const thickness = Math.max(1, Math.min(16, Math.floor(params.thickness ?? 3))); + const trackColor = params.track_color ?? '#E2E8F0'; + const activeColor = params.active_color ?? '#2563EB'; + + return { + type: 'frame', + name: 'Spinner', + role: 'spinner', + width: size, + height: size, + layout: 'none', + children: [ + { + type: 'ellipse', + name: 'Track', + role: 'spinner-track', + x: 0, + y: 0, + width: size, + height: size, + fill: [], + stroke: { thickness, fill: [{ type: 'solid', color: trackColor }] }, + }, + { + type: 'ellipse', + name: 'Active Arc', + role: 'spinner-arc', + x: 0, + y: 0, + width: size, + height: size, + startAngle: -90, + sweepAngle: 270, + fill: [], + stroke: { thickness, fill: [{ type: 'solid', color: activeColor }] }, + }, + ], + }; +} diff --git a/packages/pen-core/src/element-builders/stat-card-v1.ts b/packages/pen-core/src/element-builders/stat-card-v1.ts new file mode 100644 index 000000000..728e4add6 --- /dev/null +++ b/packages/pen-core/src/element-builders/stat-card-v1.ts @@ -0,0 +1,146 @@ +import type { ElementTree } from './helpers.js'; +import { resolveTheme, type V1Theme } from './resolve-theme.js'; + +export type StatCardV1Trend = 'up' | 'down' | 'flat'; + +export interface StatCardV1Params { + /** Small label above the big number (e.g. "Monthly revenue"). Required. */ + label: string; + /** The big number / primary metric (e.g. "$12.4k", "1,284", "98.2%"). Required. */ + value: string; + /** Optional leading icon (lucide name, e.g. "trending-up" / "users" / "flame"). */ + icon?: string; + /** + * Optional delta text shown beneath the big number (e.g. "+8% vs last week"). + * Rendered in a tone-matched color. + */ + delta?: string; + /** Trend direction for the delta tone. Default 'flat'. */ + trend?: StatCardV1Trend; + /** Card width in px. Default 240. Min 160. */ + width?: number; + /** Corner radius. Default 16. */ + corner_radius?: number; + /** + * Theme mode. + * - `'light'` (default): byte-parity with add_stat_card_v0. + * - `'dark'`: card bg → surface; border → border; label → textMuted; + * icon fill → textSubtle; value → textPrimary. Delta tone colors + * (success/destructive/slate) are kept hardcoded — status semantics. + * - `'system'`: $color-* refs for surface/border/text slots. + */ + theme?: V1Theme; +} + +/** + * Stat card (v1) — theme-aware variant of buildStatCard. + * Light mode is byte-equal to add_stat_card_v0. + * + * Color mapping: + * card bg (#FFFFFF white) → surface token + * border (#E2E8F0 slate-200) → border token + * label text (#64748B slate-500) → textMuted token + * icon fill (#94A3B8 slate-400) → textSubtle token + * value text (#0F172A slate-900) → textPrimary token + * + * Delta colors stay hardcoded (status semantics, theme-independent): + * up → #10B981 (emerald-500) + * down → #EF4444 (red-500) + * flat → #64748B (slate-500) + */ +export function buildStatCardV1(params: StatCardV1Params): ElementTree { + const width = Math.max(160, Math.floor(params.width ?? 240)); + const cornerRadius = Math.max(0, Math.floor(params.corner_radius ?? 16)); + const trend: StatCardV1Trend = params.trend ?? 'flat'; + const deltaColor = trend === 'up' ? '#10B981' : trend === 'down' ? '#EF4444' : '#64748B'; + const theme = params.theme ?? 'light'; + const isLight = theme === 'light'; + const t = resolveTheme(theme); + + // Card bg: light → #FFFFFF, dark/system → surface + const cardBg = isLight ? '#FFFFFF' : t.colors.surface; + // Border: light → slate-200 (#E2E8F0), dark/system → border + const borderColor = isLight ? '#E2E8F0' : t.colors.border; + // Label text: light → slate-500 (#64748B), dark/system → textMuted + const labelColor = isLight ? '#64748B' : t.colors.textMuted; + // Icon fill: light → slate-400 (#94A3B8), dark/system → textSubtle + const iconColor = isLight ? '#94A3B8' : t.colors.textSubtle; + // Value text: light → slate-900 (#0F172A), dark/system → textPrimary + const valueColor = isLight ? '#0F172A' : t.colors.textPrimary; + + const headerChildren: ElementTree[] = [ + { + type: 'text', + name: 'Label', + role: 'stat-card-label', + content: params.label.toUpperCase(), + fontSize: 12, + fontWeight: 500, + letterSpacing: 1, + fill: [{ type: 'solid', color: labelColor }], + }, + ]; + if (params.icon) { + headerChildren.push({ + type: 'icon_font', + name: 'Icon', + role: 'stat-card-icon', + iconFontName: params.icon, + iconFontFamily: 'lucide', + width: 18, + height: 18, + fill: [{ type: 'solid', color: iconColor }], + }); + } + + const cardChildren: ElementTree[] = [ + { + type: 'frame', + name: 'Header', + role: 'stat-card-header', + width: 'fill_container', + height: 'fit_content', + layout: 'horizontal', + alignItems: 'center', + justifyContent: 'space-between', + children: headerChildren, + }, + { + type: 'text', + name: 'Value', + role: 'stat-card-value', + content: params.value, + fontSize: 32, + fontWeight: 700, + lineHeight: 1.1, + fill: [{ type: 'solid', color: valueColor }], + }, + ]; + + if (params.delta) { + cardChildren.push({ + type: 'text', + name: 'Delta', + role: 'stat-card-delta', + content: params.delta, + fontSize: 12, + fontWeight: 500, + fill: [{ type: 'solid', color: deltaColor }], + }); + } + + return { + type: 'frame', + name: 'Stat Card', + role: 'stat-card', + width, + height: 'fit_content', + cornerRadius, + layout: 'vertical', + gap: 12, + padding: 20, + fill: [{ type: 'solid', color: cardBg }], + stroke: { thickness: 1, fill: [{ type: 'solid', color: borderColor }] }, + children: cardChildren, + }; +} diff --git a/packages/pen-core/src/element-builders/stat-grid-v1.ts b/packages/pen-core/src/element-builders/stat-grid-v1.ts new file mode 100644 index 000000000..cb4e25fa7 --- /dev/null +++ b/packages/pen-core/src/element-builders/stat-grid-v1.ts @@ -0,0 +1,88 @@ +import type { ElementTree } from './helpers.js'; +import type { V1Theme } from './resolve-theme.js'; + +export interface StatGridV1Item { + value: string; + label: string; + icon?: string; +} + +export interface StatGridV1Params { + items: StatGridV1Item[]; + gap?: number; + /** + * Theme mode. + * - `'light'` (default): byte-parity with add_stat_grid_v0. + * - `'dark'`: identical (no explicit fill colors in v0 — text inherits). + * - `'system'`: identical. + */ + theme?: V1Theme; +} + +/** + * Stat grid (v1) — theme-aware variant of buildStatGrid. + * No hardcoded colors in v0 (text inherits canvas theme color), + * so all theme modes are identical and byte-parity with v0 is guaranteed. + * Accepts theme param for API consistency. + * + * Non-scrolling stat grid — 2-5 items share the row via fill_container. + */ +export function buildStatGridV1(params: StatGridV1Params): ElementTree { + const gap = params.gap ?? 16; + const cells = params.items.map((item) => buildCellV1(item)); + return { + type: 'frame', + name: 'Stat Grid', + role: 'stat-grid', + width: 'fill_container', + height: 'fit_content', + layout: 'horizontal', + gap, + alignItems: 'center', + justifyContent: 'space_between', + children: cells, + }; +} + +function buildCellV1(item: StatGridV1Item): ElementTree { + const children: ElementTree[] = []; + if (item.icon) { + children.push({ + type: 'icon_font', + name: 'Icon', + iconFontName: item.icon, + iconFontFamily: 'lucide', + width: 20, + height: 20, + }); + } + children.push({ + type: 'text', + name: 'Value', + role: 'heading', + content: item.value, + fontSize: 24, + fontWeight: 700, + width: 'fill_container', + }); + children.push({ + type: 'text', + name: 'Label', + role: 'body', + content: item.label, + fontSize: 12, + fontWeight: 500, + width: 'fill_container', + }); + return { + type: 'frame', + name: 'Stat Cell', + role: 'stat-cell', + width: 'fill_container', + height: 'fit_content', + layout: 'vertical', + alignItems: 'center', + gap: 4, + children, + }; +} diff --git a/packages/pen-core/src/element-builders/status-badge-v1.ts b/packages/pen-core/src/element-builders/status-badge-v1.ts new file mode 100644 index 000000000..6e860cc21 --- /dev/null +++ b/packages/pen-core/src/element-builders/status-badge-v1.ts @@ -0,0 +1,86 @@ +import type { ElementTree } from './helpers.js'; +import type { V1Theme } from './resolve-theme.js'; + +export type StatusBadgeV1Tone = 'success' | 'warning' | 'error' | 'info' | 'neutral'; + +export interface StatusBadgeV1Params { + label: string; + /** + * Semantic tone — picks the dot color. Default 'neutral'. + */ + tone?: StatusBadgeV1Tone; + /** + * Theme mode. + * - `'light'` (default): byte-parity with add_status_badge_v0. + * - `'dark'`: identical (dot colors are status semantics, theme-independent). + * - `'system'`: identical. + * + * NOTE: status tone colors (success=emerald, warning=amber, error=red, + * info=blue, neutral=slate) are intentionally theme-independent and kept + * hardcoded across all theme modes per spec §3.4. + */ + theme?: V1Theme; +} + +/** + * Status badge (v1) — theme-aware variant of buildStatusBadge. + * Light mode is byte-equal to add_status_badge_v0. + * + * Dot colors are status semantics (not surface colors), kept hardcoded + * across all theme modes. Accepts theme param for API consistency. + * + * Structure: + * frame(fit_content, horizontal, gap=6, alignItems=center) + * ├ frame(8×8, cornerRadius=4, fill=, role='status-dot') + * └ text(label, 13/500) + */ +export function buildStatusBadgeV1(params: StatusBadgeV1Params): ElementTree { + const tone: StatusBadgeV1Tone = params.tone ?? 'neutral'; + const dotColor = dotColorForTone(tone); + + return { + type: 'frame', + name: 'Status Badge', + role: 'status-badge', + width: 'fit_content', + height: 'fit_content', + layout: 'horizontal', + alignItems: 'center', + gap: 6, + children: [ + { + type: 'frame', + name: 'Status Dot', + role: 'status-dot', + width: 8, + height: 8, + cornerRadius: 4, + fill: [{ type: 'solid', color: dotColor }], + }, + { + type: 'text', + name: 'Label', + role: 'status-label', + content: params.label, + fontSize: 13, + fontWeight: 500, + }, + ], + }; +} + +function dotColorForTone(tone: StatusBadgeV1Tone): string { + switch (tone) { + case 'success': + return '#10B981'; // emerald-500 + case 'warning': + return '#F59E0B'; // amber-500 + case 'error': + return '#EF4444'; // red-500 + case 'info': + return '#3B82F6'; // blue-500 + case 'neutral': + default: + return '#94A3B8'; // slate-400 + } +} diff --git a/packages/pen-core/src/element-builders/step-card-v1.ts b/packages/pen-core/src/element-builders/step-card-v1.ts new file mode 100644 index 000000000..5f93698a7 --- /dev/null +++ b/packages/pen-core/src/element-builders/step-card-v1.ts @@ -0,0 +1,142 @@ +import type { ElementTree } from './helpers.js'; +import { resolveTheme, type V1Theme } from './resolve-theme.js'; + +export interface StepCardV1Params { + /** + * Step index — short label rendered inside the 36px marker circle. + * Keep it 1–3 characters (e.g. 1, "01", "1.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; + /** + * Theme mode. + * - `'light'` (default): byte-parity with add_step_card_v0. + * - `'dark'`: title text → textPrimary; description → textMuted; + * incomplete circle bg → surface (instead of #FFFFFF). Accent stays + * hardcoded (#2563EB) — brand color, theme-independent. + * - `'system'`: $color-* refs for title, description, and incomplete circle bg. + */ + theme?: V1Theme; +} + +const ACCENT = '#2563EB'; + +/** + * Step card (v1) — theme-aware variant of buildStepCard. + * Light mode is byte-equal to add_step_card_v0. + * + * Color mapping: + * title text (#0F172A slate-900) → textPrimary token + * desc text (#475569 slate-600) → textMuted token + * ring bg (#FFFFFF white) → surface token (incomplete state only) + * accent (#2563EB blue-600) → kept hardcoded (brand color) + * check icon (#FFFFFF white) → kept hardcoded (on-accent always white) + */ +export function buildStepCardV1(params: StepCardV1Params): ElementTree { + const completed = params.completed === true; + const numberText = String(params.number); + const theme = params.theme ?? 'light'; + const isLight = theme === 'light'; + const t = resolveTheme(theme); + + // Title text: light → slate-900 (#0F172A), dark/system → textPrimary + const titleColor = isLight ? '#0F172A' : t.colors.textPrimary; + // Description text: light → slate-600 (#475569), dark/system → textMuted + const descColor = isLight ? '#475569' : t.colors.textMuted; + // Incomplete circle bg: light → #FFFFFF, dark/system → surface + const ringBg = isLight ? '#FFFFFF' : t.colors.surface; + + const circle: ElementTree = { + type: 'frame', + name: 'Number Circle', + role: 'step-card-circle', + width: 36, + height: 36, + cornerRadius: 18, + fill: [{ type: 'solid', color: completed ? ACCENT : ringBg }], + stroke: completed ? undefined : { thickness: 2, fill: [{ type: 'solid', color: ACCENT }] }, + clipContent: true, + 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: titleColor }], + }, + { + 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: descColor }], + }, + ], + }, + ], + }; +} diff --git a/packages/pen-core/src/element-builders/stepper-v1.ts b/packages/pen-core/src/element-builders/stepper-v1.ts new file mode 100644 index 000000000..5cfaab619 --- /dev/null +++ b/packages/pen-core/src/element-builders/stepper-v1.ts @@ -0,0 +1,89 @@ +import type { ElementTree } from './helpers.js'; +import { resolveTheme, type V1Theme } from './resolve-theme.js'; + +export interface StepperV1Params { + total: number; + current?: number; + /** + * Theme mode. + * - `'light'` (default): byte-parity with add_stepper_v0. + * - `'dark'`: pending circle fill → border (#334155); pending number → textMuted; + * pending connector → border. Accent (#2563EB) and white (#FFFFFF on done) + * stay hardcoded — brand/UI constants. + * - `'system'`: $color-* refs for pending fill, pending number color, pending connector. + */ + theme?: V1Theme; +} + +/** + * Stepper (v1) — theme-aware variant of buildStepper. + * Light mode is byte-equal to add_stepper_v0. + * + * Color mapping: + * done circle fill (#2563EB blue-600) → kept hardcoded (accent/brand) + * done number text (#FFFFFF white) → kept hardcoded (on-accent) + * done connector (#2563EB blue-600) → kept hardcoded (accent/brand) + * pending circle fill (#E5E7EB gray-200) → border token + * pending number text (#6B7280 gray-500) → textMuted token + * pending connector (#E5E7EB gray-200) → border token + */ +export function buildStepperV1(params: StepperV1Params): ElementTree { + const total = Math.max(1, Math.floor(params.total)); + const current = Math.max(0, Math.min(total - 1, Math.floor(params.current ?? 0))); + const theme = params.theme ?? 'light'; + const isLight = theme === 'light'; + const t = resolveTheme(theme); + + // Pending circle fill: light → gray-200 (#E5E7EB), dark/system → border + const pendingFill = isLight ? '#E5E7EB' : t.colors.border; + // Pending number text: light → gray-500 (#6B7280), dark/system → textMuted + const pendingText = isLight ? '#6B7280' : t.colors.textMuted; + + const children: ElementTree[] = []; + for (let i = 0; i < total; i += 1) { + const done = i <= current; + children.push({ + type: 'frame', + name: `Step ${i + 1}`, + role: done ? 'step-active' : 'step', + width: 24, + height: 24, + cornerRadius: 12, + fill: [{ type: 'solid', color: done ? '#2563EB' : pendingFill }], + layout: 'horizontal', + alignItems: 'center', + justifyContent: 'center', + children: [ + { + type: 'text', + name: 'Number', + content: String(i + 1), + fontSize: 13, + fontWeight: 600, + fill: [{ type: 'solid', color: done ? '#FFFFFF' : pendingText }], + }, + ], + }); + if (i < total - 1) { + const doneConnector = i < current; + children.push({ + type: 'rectangle', + name: `Connector ${i}`, + role: doneConnector ? 'step-connector-active' : 'step-connector', + width: 'fill_container', + height: 2, + fill: [{ type: 'solid', color: doneConnector ? '#2563EB' : pendingFill }], + }); + } + } + return { + type: 'frame', + name: 'Stepper', + role: 'stepper', + width: 'fill_container', + layout: 'horizontal', + alignItems: 'center', + gap: 0, + children, + }; +} diff --git a/packages/pen-core/src/element-builders/switch-v1.ts b/packages/pen-core/src/element-builders/switch-v1.ts new file mode 100644 index 000000000..5c762a8cd --- /dev/null +++ b/packages/pen-core/src/element-builders/switch-v1.ts @@ -0,0 +1,56 @@ +import type { ElementTree } from './helpers.js'; +import type { V1Theme } from './resolve-theme.js'; + +export interface SwitchV1Params { + active?: boolean; + /** + * Theme mode. + * - `'light'` (default): byte-parity with add_switch_v0. + * - `'dark'`: identical (iOS HIG values #34C759/#E5E5EA are builder-private + * literals per spec §3.4 — not tokenized across theme modes). + * - `'system'`: identical. + * + * NOTE: #34C759 (iOS green) and #E5E5EA (iOS off-gray) are iOS HIG + * builder-private constants. #FFFFFF (thumb) is a gray-scale constant. + * All stay hardcoded across all theme modes per spec §3.4. + */ + theme?: V1Theme; +} + +/** + * Toggle switch (v1) — theme-aware variant of buildSwitch. + * Light mode is byte-equal to add_switch_v0. + * + * iOS/Material toggle switch. Fixed 51×31 (iOS HIG), thumb 27×27 white. + * active=true → iOS green track + thumb pushed right. + * + * #34C759 and #E5E5EA are iOS HIG builder-private literals — not surface + * colors — so they remain hardcoded across all three theme modes. + */ +export function buildSwitchV1(params: SwitchV1Params): ElementTree { + const active = params.active === true; + return { + type: 'frame', + name: active ? 'Switch (on)' : 'Switch (off)', + role: 'switch', + width: 51, + height: 31, + cornerRadius: 16, + fill: [{ type: 'solid', color: active ? '#34C759' : '#E5E5EA' }], + layout: 'horizontal', + alignItems: 'center', + justifyContent: active ? 'flex-end' : 'flex-start', + padding: [2], + children: [ + { + type: 'frame', + name: 'Thumb', + role: 'switch-thumb', + width: 27, + height: 27, + cornerRadius: 14, + fill: [{ type: 'solid', color: '#FFFFFF' }], + }, + ], + }; +} diff --git a/packages/pen-core/src/index.ts b/packages/pen-core/src/index.ts index e2dd076bd..d140ac5b3 100644 --- a/packages/pen-core/src/index.ts +++ b/packages/pen-core/src/index.ts @@ -579,4 +579,29 @@ export { type SelectV1Params, buildShareRowV1, type ShareRowV1Params, + buildSidebarNavV1, + type SidebarNavV1Item, + type SidebarNavV1Params, + buildSkeletonV1, + type SkeletonV1Params, + buildSocialLoginRowV1, + type SocialLoginV1Provider, + type SocialLoginRowV1Params, + buildSpinnerV1, + type SpinnerV1Params, + buildStatCardV1, + type StatCardV1Params, + type StatCardV1Trend, + buildStatGridV1, + type StatGridV1Item, + type StatGridV1Params, + buildStatusBadgeV1, + type StatusBadgeV1Params, + type StatusBadgeV1Tone, + buildStepCardV1, + type StepCardV1Params, + buildStepperV1, + type StepperV1Params, + buildSwitchV1, + type SwitchV1Params, } from './element-builders/index.js'; diff --git a/packages/pen-mcp/src/routes/element-tool-defs-ext-8.ts b/packages/pen-mcp/src/routes/element-tool-defs-ext-8.ts index 03f7fd891..eaed84b4c 100644 --- a/packages/pen-mcp/src/routes/element-tool-defs-ext-8.ts +++ b/packages/pen-mcp/src/routes/element-tool-defs-ext-8.ts @@ -1,6 +1,8 @@ // Extension tool definitions — shard 8 of 8 (siblings: base / ext / // ext-2 / ext-3 / ext-4 / ext-5 / ext-6 / ext-7). Houses P3 batch-7 v1 tools -// (search_bar, segmented_control, select, share_row, range_slider). +// (search_bar, segmented_control, select, share_row, range_slider) and +// P3 batch-8 v1 tools (sidebar_nav, skeleton, social_login_row, spinner, +// stat_card, stat_grid, status_badge, step_card, stepper, switch). // Each shard caps at the repo's 800-line ceiling. // // When adding a new tool: pick whichever shard has the fewest tools @@ -190,4 +192,339 @@ export const ELEMENT_TOOL_DEFINITIONS_EXT_8 = [ required: [], }, }, + { + name: 'add_sidebar_nav_v1', + description: + 'Theme-aware vertical sidebar navigation (v1). theme="light" (default): byte-parity ' + + 'with add_sidebar_nav_v0. theme="dark": bg → surface (#1E293B); title + active label ' + + '→ textPrimary; inactive label → textMuted; active item bg → surface2. ' + + 'theme="system": $color-* refs. Desktop dashboard / docs / admin left rail. schemaVersion 1.0', + inputSchema: { + type: 'object' as const, + properties: { + schemaVersion: schemaVersionProp, + filePath: filePathProp, + items: { + type: 'array', + items: { + type: 'object', + properties: { + label: { type: 'string' }, + icon: { type: 'string', description: 'Lucide icon slug.' }, + active: { type: 'boolean', description: 'Marks item as currently selected.' }, + }, + required: ['label', 'icon'], + }, + description: 'Navigation items. Required.', + }, + title: { type: 'string', description: 'Optional brand/section title above items.' }, + width: { + type: 'number', + description: 'Sidebar width in px. Default 240. Clamped 180..320.', + }, + theme: { + type: 'string', + enum: ['light', 'dark', 'system'], + description: 'Theme variant. Default "light".', + }, + parent_id: parentIdProp, + pageId: pageIdProp, + }, + required: ['items'], + }, + }, + { + name: 'add_skeleton_v1', + description: + 'Theme-aware loading skeleton (v1). theme="light" (default): byte-parity with ' + + 'add_skeleton_v0 (slate-200 rows). theme="dark": row fill → surface2 (#334155) — ' + + 'visible on dark bg. theme="system": $color-surface-2 ref. Stacked gray bars ' + + 'mimicking text lines. schemaVersion 1.0', + inputSchema: { + type: 'object' as const, + properties: { + schemaVersion: schemaVersionProp, + filePath: filePathProp, + rows: { + type: 'number', + description: 'Number of skeleton rows (clamped 1..20). Default 3.', + }, + row_height: { + type: 'number', + description: 'Row height in px (clamped 4..48). Default 16.', + }, + row_gap: { + type: 'number', + description: 'Gap between rows in px (clamped 0..32). Default 12.', + }, + last_row_short: { + type: 'boolean', + description: 'Last row is 60% width (paragraph-end pattern). Default true.', + }, + theme: { + type: 'string', + enum: ['light', 'dark', 'system'], + description: 'Theme variant. Default "light".', + }, + parent_id: parentIdProp, + pageId: pageIdProp, + }, + required: [], + }, + }, + { + name: 'add_social_login_row_v1', + description: + 'Theme-aware social-auth provider button row (v1). theme="light" (default): byte-parity ' + + 'with add_social_login_row_v0. theme="dark": button bg → surface; border → border; ' + + 'icon fill → textMuted; label → textPrimary. theme="system": $color-* refs. ' + + '"Continue with Google / Apple / GitHub" pattern. schemaVersion 1.0', + inputSchema: { + type: 'object' as const, + properties: { + schemaVersion: schemaVersionProp, + filePath: filePathProp, + providers: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'Provider name (e.g. "google", "apple").' }, + icon: { type: 'string', description: 'Optional lucide icon override.' }, + }, + required: ['name'], + }, + description: 'Providers to render (2-4 recommended, max 6). Required.', + }, + orientation: { + type: 'string', + enum: ['vertical', 'horizontal'], + description: 'Layout orientation. Default "vertical" (stacked full-width buttons).', + }, + width: { + type: 'number', + description: 'Button width in px (vertical only). Default 320. Min 200.', + }, + theme: { + type: 'string', + enum: ['light', 'dark', 'system'], + description: 'Theme variant. Default "light".', + }, + parent_id: parentIdProp, + pageId: pageIdProp, + }, + required: ['providers'], + }, + }, + { + name: 'add_spinner_v1', + description: + 'Theme-aware loading spinner (v1). theme="light" (default): byte-parity with ' + + 'add_spinner_v0. All theme modes identical — track_color/active_color are caller-' + + 'overridable params (default #E2E8F0 track, #2563EB arc). Accepts theme for API ' + + 'consistency. 3/4-sweep arc; static still-frame. schemaVersion 1.0', + inputSchema: { + type: 'object' as const, + properties: { + schemaVersion: schemaVersionProp, + filePath: filePathProp, + size: { + type: 'number', + description: 'Outer diameter in px (clamped 16..128). Default 32.', + }, + thickness: { + type: 'number', + description: 'Stroke thickness in px (clamped 1..16). Default 3.', + }, + track_color: { type: 'string', description: 'Static ring color. Default "#E2E8F0".' }, + active_color: { type: 'string', description: 'Active arc color. Default "#2563EB".' }, + theme: { + type: 'string', + enum: ['light', 'dark', 'system'], + description: 'Theme variant. Default "light". All modes identical for this tool.', + }, + parent_id: parentIdProp, + pageId: pageIdProp, + }, + required: [], + }, + }, + { + name: 'add_stat_card_v1', + description: + 'Theme-aware big-number stat card (v1). theme="light" (default): byte-parity with ' + + 'add_stat_card_v0. theme="dark": bg → surface; border → border; label → textMuted; ' + + 'icon → textSubtle; value → textPrimary. Delta tones (success/error/flat) stay ' + + 'hardcoded — status semantics. theme="system": $color-* refs. schemaVersion 1.0', + inputSchema: { + type: 'object' as const, + properties: { + schemaVersion: schemaVersionProp, + filePath: filePathProp, + label: { type: 'string', description: 'Metric label shown above value. Required.' }, + value: { type: 'string', description: 'Primary metric value (e.g. "$12.4k"). Required.' }, + icon: { type: 'string', description: 'Optional lucide icon in header corner.' }, + delta: { type: 'string', description: 'Optional delta text (e.g. "+8% vs last week").' }, + trend: { + type: 'string', + enum: ['up', 'down', 'flat'], + description: 'Trend direction for delta tone. Default "flat".', + }, + width: { type: 'number', description: 'Card width in px. Default 240. Min 160.' }, + corner_radius: { type: 'number', description: 'Corner radius. Default 16.' }, + theme: { + type: 'string', + enum: ['light', 'dark', 'system'], + description: 'Theme variant. Default "light".', + }, + parent_id: parentIdProp, + pageId: pageIdProp, + }, + required: ['label', 'value'], + }, + }, + { + name: 'add_stat_grid_v1', + description: + 'Theme-aware non-scrolling stat grid (v1). theme="light" (default): byte-parity with ' + + 'add_stat_grid_v0. All modes identical — no explicit fill colors in v0 (text inherits). ' + + 'Accepts theme for API consistency. 2-5 fill_container cells side-by-side. schemaVersion 1.0', + inputSchema: { + type: 'object' as const, + properties: { + schemaVersion: schemaVersionProp, + filePath: filePathProp, + items: { + type: 'array', + items: { + type: 'object', + properties: { + value: { type: 'string', description: 'Metric value (e.g. "1,284").' }, + label: { type: 'string', description: 'Metric label.' }, + icon: { type: 'string', description: 'Optional lucide icon slug.' }, + }, + required: ['value', 'label'], + }, + description: 'Stat items (2-5). Required.', + }, + gap: { type: 'number', description: 'Gap between cells in px. Default 16.' }, + theme: { + type: 'string', + enum: ['light', 'dark', 'system'], + description: 'Theme variant. Default "light". All modes identical for this tool.', + }, + parent_id: parentIdProp, + pageId: pageIdProp, + }, + required: ['items'], + }, + }, + { + name: 'add_status_badge_v1', + description: + 'Theme-aware status indicator pill (v1). theme="light" (default): byte-parity with ' + + 'add_status_badge_v0. All modes identical — dot colors are status semantics ' + + '(success=emerald, warning=amber, error=red, info=blue, neutral=slate), kept ' + + 'hardcoded. Accepts theme for API consistency. schemaVersion 1.0', + inputSchema: { + type: 'object' as const, + properties: { + schemaVersion: schemaVersionProp, + filePath: filePathProp, + label: { type: 'string', description: 'Status label text. Required.' }, + tone: { + type: 'string', + enum: ['success', 'warning', 'error', 'info', 'neutral'], + description: 'Status tone. Default "neutral".', + }, + theme: { + type: 'string', + enum: ['light', 'dark', 'system'], + description: 'Theme variant. Default "light". All modes identical for this tool.', + }, + parent_id: parentIdProp, + pageId: pageIdProp, + }, + required: ['label'], + }, + }, + { + name: 'add_step_card_v1', + description: + 'Theme-aware onboarding step card (v1). theme="light" (default): byte-parity with ' + + 'add_step_card_v0. theme="dark": title → textPrimary; description → textMuted; ' + + 'incomplete circle bg → surface. Accent (#2563EB) and check icon white stay hardcoded. ' + + 'theme="system": $color-* refs. Numbered circle + title + description. schemaVersion 1.0', + inputSchema: { + type: 'object' as const, + properties: { + schemaVersion: schemaVersionProp, + filePath: filePathProp, + number: { + description: 'Step index shown in circle (1-3 chars, e.g. 1, "01"). Required.', + oneOf: [{ type: 'string' }, { type: 'number' }], + }, + title: { type: 'string', description: 'Step title. Required.' }, + description: { type: 'string', description: 'Step description body. Required.' }, + completed: { type: 'boolean', description: 'Filled accent circle vs ring. Default false.' }, + theme: { + type: 'string', + enum: ['light', 'dark', 'system'], + description: 'Theme variant. Default "light".', + }, + parent_id: parentIdProp, + pageId: pageIdProp, + }, + required: ['number', 'title', 'description'], + }, + }, + { + name: 'add_stepper_v1', + description: + 'Theme-aware horizontal numbered stepper (v1). theme="light" (default): byte-parity ' + + 'with add_stepper_v0. theme="dark": pending circle fill → border (#334155); pending ' + + 'number → textMuted; pending connector → border. Accent (#2563EB) and done-state ' + + 'white stay hardcoded. theme="system": $color-* refs for pending slots. schemaVersion 1.0', + inputSchema: { + type: 'object' as const, + properties: { + schemaVersion: schemaVersionProp, + filePath: filePathProp, + total: { type: 'number', description: 'Total step count. Required.' }, + current: { type: 'number', description: 'Current active step index (0-based). Default 0.' }, + theme: { + type: 'string', + enum: ['light', 'dark', 'system'], + description: 'Theme variant. Default "light".', + }, + parent_id: parentIdProp, + pageId: pageIdProp, + }, + required: ['total'], + }, + }, + { + name: 'add_switch_v1', + description: + 'Theme-aware iOS/Material toggle switch (v1). theme="light" (default): byte-parity ' + + 'with add_switch_v0. All modes identical — #34C759 (iOS green active) and #E5E5EA ' + + '(iOS gray inactive) are builder-private iOS HIG literals (spec §3.4), not tokenized. ' + + '#FFFFFF thumb stays hardcoded. 51×31px, cornerRadius=16. schemaVersion 1.0', + inputSchema: { + type: 'object' as const, + properties: { + schemaVersion: schemaVersionProp, + filePath: filePathProp, + active: { type: 'boolean', description: 'Switch on/off state. Default false.' }, + theme: { + type: 'string', + enum: ['light', 'dark', 'system'], + description: 'Theme variant. Default "light". All modes identical for this tool.', + }, + parent_id: parentIdProp, + pageId: pageIdProp, + }, + required: [], + }, + }, ]; diff --git a/packages/pen-mcp/src/routes/element-tool-defs.ts b/packages/pen-mcp/src/routes/element-tool-defs.ts index e3fc92058..339fc8dc7 100644 --- a/packages/pen-mcp/src/routes/element-tool-defs.ts +++ b/packages/pen-mcp/src/routes/element-tool-defs.ts @@ -182,6 +182,16 @@ import { handleAddSectionHeaderV1 } from '../tools/add-section-header-v1'; import { handleAddSegmentedControlV1 } from '../tools/add-segmented-control-v1'; import { handleAddSelectV1 } from '../tools/add-select-v1'; import { handleAddShareRowV1 } from '../tools/add-share-row-v1'; +import { handleAddSidebarNavV1 } from '../tools/add-sidebar-nav-v1'; +import { handleAddSkeletonV1 } from '../tools/add-skeleton-v1'; +import { handleAddSocialLoginRowV1 } from '../tools/add-social-login-row-v1'; +import { handleAddSpinnerV1 } from '../tools/add-spinner-v1'; +import { handleAddStatCardV1 } from '../tools/add-stat-card-v1'; +import { handleAddStatGridV1 } from '../tools/add-stat-grid-v1'; +import { handleAddStatusBadgeV1 } from '../tools/add-status-badge-v1'; +import { handleAddStepCardV1 } from '../tools/add-step-card-v1'; +import { handleAddStepperV1 } from '../tools/add-stepper-v1'; +import { handleAddSwitchV1 } from '../tools/add-switch-v1'; 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'; @@ -558,6 +568,26 @@ async function dispatchElementToolCall(name: string, a: any): Promise { return JSON.stringify(await handleAddSelectV1(a), null, 2); case 'add_share_row_v1': return JSON.stringify(await handleAddShareRowV1(a), null, 2); + case 'add_sidebar_nav_v1': + return JSON.stringify(await handleAddSidebarNavV1(a), null, 2); + case 'add_skeleton_v1': + return JSON.stringify(await handleAddSkeletonV1(a), null, 2); + case 'add_social_login_row_v1': + return JSON.stringify(await handleAddSocialLoginRowV1(a), null, 2); + case 'add_spinner_v1': + return JSON.stringify(await handleAddSpinnerV1(a), null, 2); + case 'add_stat_card_v1': + return JSON.stringify(await handleAddStatCardV1(a), null, 2); + case 'add_stat_grid_v1': + return JSON.stringify(await handleAddStatGridV1(a), null, 2); + case 'add_status_badge_v1': + return JSON.stringify(await handleAddStatusBadgeV1(a), null, 2); + case 'add_step_card_v1': + return JSON.stringify(await handleAddStepCardV1(a), null, 2); + case 'add_stepper_v1': + return JSON.stringify(await handleAddStepperV1(a), null, 2); + case 'add_switch_v1': + return JSON.stringify(await handleAddSwitchV1(a), null, 2); default: return ''; } diff --git a/packages/pen-mcp/src/tools/add-sidebar-nav-v1.ts b/packages/pen-mcp/src/tools/add-sidebar-nav-v1.ts new file mode 100644 index 000000000..875e7f26f --- /dev/null +++ b/packages/pen-mcp/src/tools/add-sidebar-nav-v1.ts @@ -0,0 +1,28 @@ +import { + assignIdsRecursively, + buildSidebarNavV1, + type SidebarNavV1Params, +} from '@zseven-w/pen-core'; +import type { handleBatchDesign } from './batch-design'; +import { ensureParentExists, insertElementTree } from './element-tool-helpers'; + +export interface AddSidebarNavV1Params extends SidebarNavV1Params { + parent_id?: string; + filePath?: string; + pageId?: string; +} + +export type { SidebarNavV1Item as AddSidebarNavV1Item } from '@zseven-w/pen-core'; + +/** + * Persistent vertical sidebar navigation (v1) — theme-aware variant of add_sidebar_nav_v0. + * Supports 'light' (v0 byte-parity), 'dark', and 'system' theme modes. + */ +export async function handleAddSidebarNavV1( + params: AddSidebarNavV1Params, +): Promise>> { + await ensureParentExists(params); + const tree = buildSidebarNavV1(params); + assignIdsRecursively(tree); + return insertElementTree({ binding: 'sidebarNav', tree, ...params }); +} diff --git a/packages/pen-mcp/src/tools/add-skeleton-v1.ts b/packages/pen-mcp/src/tools/add-skeleton-v1.ts new file mode 100644 index 000000000..b4bae5ea6 --- /dev/null +++ b/packages/pen-mcp/src/tools/add-skeleton-v1.ts @@ -0,0 +1,22 @@ +import { assignIdsRecursively, buildSkeletonV1, type SkeletonV1Params } from '@zseven-w/pen-core'; +import type { handleBatchDesign } from './batch-design'; +import { ensureParentExists, insertElementTree } from './element-tool-helpers'; + +export interface AddSkeletonV1Params extends SkeletonV1Params { + parent_id?: string; + filePath?: string; + pageId?: string; +} + +/** + * Loading skeleton (v1) — theme-aware variant of add_skeleton_v0. + * Supports 'light' (v0 byte-parity), 'dark', and 'system' theme modes. + */ +export async function handleAddSkeletonV1( + params: AddSkeletonV1Params, +): Promise>> { + await ensureParentExists(params); + const tree = buildSkeletonV1(params); + assignIdsRecursively(tree); + return insertElementTree({ binding: 'skeleton', tree, ...params }); +} diff --git a/packages/pen-mcp/src/tools/add-social-login-row-v1.ts b/packages/pen-mcp/src/tools/add-social-login-row-v1.ts new file mode 100644 index 000000000..6d6c2ccb3 --- /dev/null +++ b/packages/pen-mcp/src/tools/add-social-login-row-v1.ts @@ -0,0 +1,28 @@ +import { + assignIdsRecursively, + buildSocialLoginRowV1, + type SocialLoginRowV1Params, +} from '@zseven-w/pen-core'; +import type { handleBatchDesign } from './batch-design'; +import { ensureParentExists, insertElementTree } from './element-tool-helpers'; + +export interface AddSocialLoginRowV1Params extends SocialLoginRowV1Params { + parent_id?: string; + filePath?: string; + pageId?: string; +} + +export type { SocialLoginV1Provider as AddSocialLoginV1Provider } from '@zseven-w/pen-core'; + +/** + * Social-auth provider button row (v1) — theme-aware variant of add_social_login_row_v0. + * Supports 'light' (v0 byte-parity), 'dark', and 'system' theme modes. + */ +export async function handleAddSocialLoginRowV1( + params: AddSocialLoginRowV1Params, +): Promise>> { + await ensureParentExists(params); + const tree = buildSocialLoginRowV1(params); + assignIdsRecursively(tree); + return insertElementTree({ binding: 'socialLoginRow', tree, ...params }); +} diff --git a/packages/pen-mcp/src/tools/add-spinner-v1.ts b/packages/pen-mcp/src/tools/add-spinner-v1.ts new file mode 100644 index 000000000..f28dd531c --- /dev/null +++ b/packages/pen-mcp/src/tools/add-spinner-v1.ts @@ -0,0 +1,23 @@ +import { assignIdsRecursively, buildSpinnerV1, type SpinnerV1Params } from '@zseven-w/pen-core'; +import type { handleBatchDesign } from './batch-design'; +import { ensureParentExists, insertElementTree } from './element-tool-helpers'; + +export interface AddSpinnerV1Params extends SpinnerV1Params { + parent_id?: string; + filePath?: string; + pageId?: string; +} + +/** + * Loading spinner (v1) — theme-aware variant of add_spinner_v0. + * Accepts theme param for API consistency. track_color/active_color are + * caller-overridable; no surface colors are hardcoded in the builder. + */ +export async function handleAddSpinnerV1( + params: AddSpinnerV1Params, +): Promise>> { + await ensureParentExists(params); + const tree = buildSpinnerV1(params); + assignIdsRecursively(tree); + return insertElementTree({ binding: 'spinner', tree, ...params }); +} diff --git a/packages/pen-mcp/src/tools/add-stat-card-v1.ts b/packages/pen-mcp/src/tools/add-stat-card-v1.ts new file mode 100644 index 000000000..ca5eae0ce --- /dev/null +++ b/packages/pen-mcp/src/tools/add-stat-card-v1.ts @@ -0,0 +1,23 @@ +import { assignIdsRecursively, buildStatCardV1, type StatCardV1Params } from '@zseven-w/pen-core'; +import type { handleBatchDesign } from './batch-design'; +import { ensureParentExists, insertElementTree } from './element-tool-helpers'; + +export interface AddStatCardV1Params extends StatCardV1Params { + parent_id?: string; + filePath?: string; + pageId?: string; +} + +/** + * Big-number stat card (v1) — theme-aware variant of add_stat_card_v0. + * Supports 'light' (v0 byte-parity), 'dark', and 'system' theme modes. + * Delta tone colors (success/error/flat) are kept hardcoded — status semantics. + */ +export async function handleAddStatCardV1( + params: AddStatCardV1Params, +): Promise>> { + await ensureParentExists(params); + const tree = buildStatCardV1(params); + assignIdsRecursively(tree); + return insertElementTree({ binding: 'statCard', tree, ...params }); +} diff --git a/packages/pen-mcp/src/tools/add-stat-grid-v1.ts b/packages/pen-mcp/src/tools/add-stat-grid-v1.ts new file mode 100644 index 000000000..bfeb0ab1e --- /dev/null +++ b/packages/pen-mcp/src/tools/add-stat-grid-v1.ts @@ -0,0 +1,25 @@ +import { assignIdsRecursively, buildStatGridV1, type StatGridV1Params } from '@zseven-w/pen-core'; +import type { handleBatchDesign } from './batch-design'; +import { ensureParentExists, insertElementTree } from './element-tool-helpers'; + +export interface AddStatGridV1Params extends StatGridV1Params { + parent_id?: string; + filePath?: string; + pageId?: string; +} + +export type { StatGridV1Item as AddStatGridV1Item } from '@zseven-w/pen-core'; + +/** + * Stat grid (v1) — theme-aware variant of add_stat_grid_v0. + * No hardcoded colors in v0 (text inherits). All modes are byte-parity with v0. + * Accepts theme param for API consistency. + */ +export async function handleAddStatGridV1( + params: AddStatGridV1Params, +): Promise>> { + await ensureParentExists(params); + const tree = buildStatGridV1(params); + assignIdsRecursively(tree); + return insertElementTree({ binding: 'statGrid', tree, ...params }); +} diff --git a/packages/pen-mcp/src/tools/add-status-badge-v1.ts b/packages/pen-mcp/src/tools/add-status-badge-v1.ts new file mode 100644 index 000000000..57bf7b9b9 --- /dev/null +++ b/packages/pen-mcp/src/tools/add-status-badge-v1.ts @@ -0,0 +1,27 @@ +import { + assignIdsRecursively, + buildStatusBadgeV1, + type StatusBadgeV1Params, +} from '@zseven-w/pen-core'; +import type { handleBatchDesign } from './batch-design'; +import { ensureParentExists, insertElementTree } from './element-tool-helpers'; + +export interface AddStatusBadgeV1Params extends StatusBadgeV1Params { + parent_id?: string; + filePath?: string; + pageId?: string; +} + +/** + * Status badge (v1) — theme-aware variant of add_status_badge_v0. + * Dot tone colors are status semantics (kept hardcoded). All modes byte-parity with v0. + * Accepts theme param for API consistency. + */ +export async function handleAddStatusBadgeV1( + params: AddStatusBadgeV1Params, +): Promise>> { + await ensureParentExists(params); + const tree = buildStatusBadgeV1(params); + assignIdsRecursively(tree); + return insertElementTree({ binding: 'statusBadge', tree, ...params }); +} diff --git a/packages/pen-mcp/src/tools/add-step-card-v1.ts b/packages/pen-mcp/src/tools/add-step-card-v1.ts new file mode 100644 index 000000000..88bbc48c8 --- /dev/null +++ b/packages/pen-mcp/src/tools/add-step-card-v1.ts @@ -0,0 +1,23 @@ +import { assignIdsRecursively, buildStepCardV1, type StepCardV1Params } from '@zseven-w/pen-core'; +import type { handleBatchDesign } from './batch-design'; +import { ensureParentExists, insertElementTree } from './element-tool-helpers'; + +export interface AddStepCardV1Params extends StepCardV1Params { + parent_id?: string; + filePath?: string; + pageId?: string; +} + +/** + * Onboarding step card (v1) — theme-aware variant of add_step_card_v0. + * Supports 'light' (v0 byte-parity), 'dark', and 'system' theme modes. + * Accent (#2563EB) and check-icon white (#FFFFFF) stay hardcoded. + */ +export async function handleAddStepCardV1( + params: AddStepCardV1Params, +): Promise>> { + await ensureParentExists(params); + const tree = buildStepCardV1(params); + assignIdsRecursively(tree); + return insertElementTree({ binding: 'stepCard', tree, ...params }); +} diff --git a/packages/pen-mcp/src/tools/add-stepper-v1.ts b/packages/pen-mcp/src/tools/add-stepper-v1.ts new file mode 100644 index 000000000..0825f9500 --- /dev/null +++ b/packages/pen-mcp/src/tools/add-stepper-v1.ts @@ -0,0 +1,23 @@ +import { assignIdsRecursively, buildStepperV1, type StepperV1Params } from '@zseven-w/pen-core'; +import type { handleBatchDesign } from './batch-design'; +import { ensureParentExists, insertElementTree } from './element-tool-helpers'; + +export interface AddStepperV1Params extends StepperV1Params { + parent_id?: string; + filePath?: string; + pageId?: string; +} + +/** + * Horizontal numbered stepper (v1) — theme-aware variant of add_stepper_v0. + * Supports 'light' (v0 byte-parity), 'dark', and 'system' theme modes. + * Accent (#2563EB) and done-state white (#FFFFFF) stay hardcoded. + */ +export async function handleAddStepperV1( + params: AddStepperV1Params, +): Promise>> { + await ensureParentExists(params); + const tree = buildStepperV1(params); + assignIdsRecursively(tree); + return insertElementTree({ binding: 'stepper', tree, ...params }); +} diff --git a/packages/pen-mcp/src/tools/add-switch-v1.ts b/packages/pen-mcp/src/tools/add-switch-v1.ts new file mode 100644 index 000000000..d4d5b1265 --- /dev/null +++ b/packages/pen-mcp/src/tools/add-switch-v1.ts @@ -0,0 +1,24 @@ +import { assignIdsRecursively, buildSwitchV1, type SwitchV1Params } from '@zseven-w/pen-core'; +import type { handleBatchDesign } from './batch-design'; +import { ensureParentExists, insertElementTree } from './element-tool-helpers'; + +export interface AddSwitchV1Params extends SwitchV1Params { + parent_id?: string; + filePath?: string; + pageId?: string; +} + +/** + * Toggle switch (v1) — theme-aware variant of add_switch_v0. + * iOS HIG values (#34C759 active, #E5E5EA inactive, #FFFFFF thumb) are builder-private + * literals (spec §3.4) — kept hardcoded across all theme modes. + * Accepts theme param for API consistency. + */ +export async function handleAddSwitchV1( + params: AddSwitchV1Params, +): Promise>> { + await ensureParentExists(params); + const tree = buildSwitchV1(params); + assignIdsRecursively(tree); + return insertElementTree({ binding: 'switch', tree, ...params }); +}