diff --git a/apps/web/server/api/mcp/exec-tool.post.ts b/apps/web/server/api/mcp/exec-tool.post.ts index 9e03414d1..c86a0318f 100644 --- a/apps/web/server/api/mcp/exec-tool.post.ts +++ b/apps/web/server/api/mcp/exec-tool.post.ts @@ -118,6 +118,16 @@ import { buildCalendarGridV1, buildAvatarGroupV1, buildCalloutV1, + buildChartBarsV1, + buildChartLineV1, + buildChartPieV1, + buildChatBubbleV1, + buildCheckboxV1, + buildChipInputV1, + buildCodeBlockV1, + buildColorSwatchV1, + buildComboboxV1, + buildCommentV1, findNodeInTree, insertNodeInTree, type ElementTree, @@ -300,6 +310,16 @@ const SERVER_BUILDERS: Record = { add_calendar_grid_v1: (a) => buildCalendarGridV1(a as Parameters[0]), add_avatar_group_v1: (a) => buildAvatarGroupV1(a as Parameters[0]), add_callout_v1: (a) => buildCalloutV1(a as Parameters[0]), + add_chart_bars_v1: (a) => buildChartBarsV1(a as Parameters[0]), + add_chart_line_v1: (a) => buildChartLineV1(a as Parameters[0]), + add_chart_pie_v1: (a) => buildChartPieV1(a as Parameters[0]), + add_chat_bubble_v1: (a) => buildChatBubbleV1(a as Parameters[0]), + add_checkbox_v1: (a) => buildCheckboxV1(a as Parameters[0]), + add_chip_input_v1: (a) => buildChipInputV1(a as Parameters[0]), + add_code_block_v1: (a) => buildCodeBlockV1(a as Parameters[0]), + add_color_swatch_v1: (a) => buildColorSwatchV1(a as Parameters[0]), + add_combobox_v1: (a) => buildComboboxV1(a as Parameters[0]), + add_comment_v1: (a) => buildCommentV1(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 46e271f57..e3b7a62be 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 @@ -127,6 +127,16 @@ import { buildCalendarGridV1, buildAvatarGroupV1, buildCalloutV1, + buildChartBarsV1, + buildChartLineV1, + buildChartPieV1, + buildChatBubbleV1, + buildCheckboxV1, + buildChipInputV1, + buildCodeBlockV1, + buildColorSwatchV1, + buildComboboxV1, + buildCommentV1, } from '@zseven-w/pen-core'; export interface BuilderCase { @@ -843,4 +853,57 @@ export const CASES: BuilderCase[] = [ args: { body: 'This is a callout note.', tone: 'info', theme: 'light' }, build: (a) => buildCalloutV1(a as unknown as Parameters[0]), }, + { + toolName: 'add_chart_bars_v1', + args: { values: [40, 80, 60], theme: 'light' }, + build: (a) => buildChartBarsV1(a as unknown as Parameters[0]), + }, + { + toolName: 'add_chart_line_v1', + args: { values: [30, 70, 50, 90], theme: 'light' }, + build: (a) => buildChartLineV1(a as unknown as Parameters[0]), + }, + { + toolName: 'add_chart_pie_v1', + args: { values: [40, 30, 30], theme: 'light' }, + build: (a) => buildChartPieV1(a as unknown as Parameters[0]), + }, + { + toolName: 'add_chat_bubble_v1', + args: { message: 'Hello!', side: 'left', theme: 'light' }, + build: (a) => buildChatBubbleV1(a as unknown as Parameters[0]), + }, + { + toolName: 'add_checkbox_v1', + args: { label: 'Accept terms', checked: false, theme: 'light' }, + build: (a) => buildCheckboxV1(a as unknown as Parameters[0]), + }, + { + toolName: 'add_chip_input_v1', + args: { label: 'Tags', chips: ['react', 'typescript'], theme: 'light' }, + build: (a) => buildChipInputV1(a as unknown as Parameters[0]), + }, + { + toolName: 'add_code_block_v1', + args: { code: 'const x = 1;', language: 'ts', theme: 'light' }, + build: (a) => buildCodeBlockV1(a as unknown as Parameters[0]), + }, + { + toolName: 'add_color_swatch_v1', + args: { color: '#2563EB', label: 'Primary', theme: 'light' }, + build: (a) => buildColorSwatchV1(a as unknown as Parameters[0]), + }, + { + toolName: 'add_combobox_v1', + args: { + options: [{ label: 'Option A' }, { label: 'Option B', highlighted: true }], + theme: 'light', + }, + build: (a) => buildComboboxV1(a as unknown as Parameters[0]), + }, + { + toolName: 'add_comment_v1', + args: { author: 'Alice', body: 'Great work!', theme: 'light' }, + build: (a) => buildCommentV1(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 6447d4cc1..3fb29f090 100644 --- a/apps/web/src/services/ai/element-tool-shims/index.ts +++ b/apps/web/src/services/ai/element-tool-shims/index.ts @@ -136,6 +136,16 @@ import { buildCalendarGridV1, buildAvatarGroupV1, buildCalloutV1, + buildChartBarsV1, + buildChartLineV1, + buildChartPieV1, + buildChatBubbleV1, + buildCheckboxV1, + buildChipInputV1, + buildCodeBlockV1, + buildColorSwatchV1, + buildComboboxV1, + buildCommentV1, type ActionMenuParams, type ActivityRingParams, type AlertParams, @@ -224,6 +234,16 @@ import { type CalendarGridV1Params, type AvatarGroupV1Params, type CalloutV1Params, + type ChartBarsV1Params, + type ChartLineV1Params, + type ChartPieV1Params, + type ChatBubbleV1Params, + type CheckboxV1Params, + type ChipInputV1Params, + type CodeBlockV1Params, + type ColorSwatchV1Params, + type ComboboxV1Params, + type CommentV1Params, type KbdParams, type LinkParams, type ListRowParams, @@ -461,6 +481,16 @@ export const ELEMENT_SHIMS: Record = { add_calendar_grid_v1: wrap(buildCalendarGridV1), add_avatar_group_v1: wrap(buildAvatarGroupV1), add_callout_v1: wrap(buildCalloutV1), + add_chart_bars_v1: wrap(buildChartBarsV1), + add_chart_line_v1: wrap(buildChartLineV1), + add_chart_pie_v1: wrap(buildChartPieV1), + add_chat_bubble_v1: wrap(buildChatBubbleV1), + add_checkbox_v1: wrap(buildCheckboxV1), + add_chip_input_v1: wrap(buildChipInputV1), + add_code_block_v1: wrap(buildCodeBlockV1), + add_color_swatch_v1: wrap(buildColorSwatchV1), + add_combobox_v1: wrap(buildComboboxV1), + add_comment_v1: wrap(buildCommentV1), }; 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 72fe2b0f4..20796a882 100644 --- a/packages/pen-ai-skills/skills/phases/generation/elements.md +++ b/packages/pen-ai-skills/skills/phases/generation/elements.md @@ -62,11 +62,11 @@ Containers and single elements: 5. Section header (big title + optional "See all" action) → `add_section_header_v0` 6. Bottom tab bar (inline flow, 3-5 nav items) → `add_bottom_nav_v0` - 6b. Theme-aware variant (accepts `theme` param for API consistency; output identical across themes) → `add_bottom_nav_v1` + 6b. Theme-aware variant (accepts `theme` param for API consistency; output identical across themes) → `add_bottom_nav_v1` 7. Mobile top bar (leading icon + centered title + trailing icon) → `add_top_nav_bar_v0` 8. Icon-only button (44×44, hit-target safe) → `add_icon_button_v0` 9. Apple-style progress ring with centered text → `add_activity_ring_v0` - 9b. Theme-aware variant (accepts `theme` param for API consistency; output identical across themes) → `add_activity_ring_v1` + 9b. Theme-aware variant (accepts `theme` param for API consistency; output identical across themes) → `add_activity_ring_v1` Atoms (1-2 node building blocks): @@ -102,6 +102,7 @@ Controls (toggle / choice / tabs): 20. iOS/Material toggle switch (51×31, thumb floats) → `add_switch_v0` 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` 23. Horizontal top tabs with underline on active (fontWeight 600 + 2px sibling rectangle underline) → `add_tabs_v0` 24. iOS pill-style segmented control (equal-width segments, active floats white) → `add_segmented_control_v0` @@ -137,16 +138,21 @@ Content blocks: 37. Quoted passage with optional author attribution → `add_quote_block_v0` 38. Preformatted code block (fill_container, wraps, gray-50 bg) → `add_code_block_v0` + 38b. Theme-aware variant (bg → surface2 in dark/system) → `add_code_block_v1` Design-system: 39. Color swatch (colored square + optional token label) → `add_color_swatch_v0` + 39b. Theme-aware variant (same tree; theme param for API consistency) → `add_color_swatch_v1` Charts / data visualization: 40. Bar-chart skeleton (one rectangle per value, bottom-aligned) → `add_chart_bars_v0` 40b. Line-chart skeleton (polyline + optional dots) → `add_chart_line_v0` 40c. Pie-chart skeleton (colored ellipse slices via arc angles) → `add_chart_pie_v0` + 40d. Theme-aware bar chart (chart-1 token in dark/system) → `add_chart_bars_v1` + 40e. Theme-aware line chart (chart-1 token in dark/system) → `add_chart_line_v1` + 40f. Theme-aware pie chart (chart-1..6 tokens in dark/system) → `add_chart_pie_v1` Media / placeholder: @@ -156,6 +162,7 @@ Media / placeholder: Social / UGC: 45. Comment row (circular avatar + author/timestamp header + body) → `add_comment_v0` + 45b. Theme-aware variant (avatar bg → surface2, timestamp → textMuted in dark/system) → `add_comment_v1` Chrome / modals: @@ -203,6 +210,7 @@ Collapsible content: Tag / multi-value inputs: 54. Chip input / tag input (pills + removable × + inline caret, wrap layout) → `add_chip_input_v0` + 54b. Theme-aware variant (chip bg → surface2, field bg → surface, placeholder → textSubtle) → `add_chip_input_v1` Chart empty state: @@ -233,6 +241,7 @@ Attachments: Messaging: 61. Chat message bubble (left=from-others slate bg / right=from-self accent bg; optional author + timestamp) → `add_chat_bubble_v0` + 61b. Theme-aware variant (surface2 bg for left, accent bg for right, textMuted for author/timestamp) → `add_chat_bubble_v1` Dashboard KPIs: @@ -287,6 +296,7 @@ Side panels & docking: Forms (open state) & toolbars: 76. Open-state combobox / autocomplete (input + visible dropdown rows) → `add_combobox_v0` + 76b. Theme-aware variant (surface/border/surface2 tokens in dark/system) → `add_combobox_v1` 77. Desktop toolbar (icon button row with optional dividers) → `add_toolbar_v0` Doc / inline feedback: diff --git a/packages/pen-core/src/element-builders/chart-bars-v1.ts b/packages/pen-core/src/element-builders/chart-bars-v1.ts new file mode 100644 index 000000000..5a92bb037 --- /dev/null +++ b/packages/pen-core/src/element-builders/chart-bars-v1.ts @@ -0,0 +1,64 @@ +import type { ElementTree } from './helpers.js'; +import { resolveTheme, type V1Theme } from './resolve-theme.js'; + +export interface ChartBarsV1Params { + values: number[]; + bar_width?: number; + gap?: number; + chart_height?: number; + /** + * Theme mode. + * - `'light'` (default): byte-parity with add_chart_bars_v0. + * - `'dark'`: bar color uses chart-1 token from semantic palette (same hue, dark-mode variant). + * - `'system'`: emits `$color-chart-1` ref for bar fill. + */ + theme?: V1Theme; +} + +/** + * Bar-chart skeleton — theme-aware version of buildChartBars. + * Light mode is byte-equal to add_chart_bars_v0. + * + * Bar color maps to chart-1 (blue series). In all three modes the + * chart-1 token resolves the same way — chart colors are single-value + * (no theme axis) — so light/dark produce the same hex (#3B82F6 in + * the palette, but v0 used #2563EB for byte-parity; light mode keeps + * #2563EB for the migration contract). + */ +export function buildChartBarsV1(params: ChartBarsV1Params): ElementTree { + const raw = Array.isArray(params.values) ? params.values : []; + if (raw.length === 0) { + throw new Error('buildChartBarsV1: values must contain at least one number'); + } + const values = raw.map((v) => (Number.isFinite(v) ? Math.max(0, v) : 0)); + const max = Math.max(1, ...values); + const barWidth = Math.max(4, Math.floor(params.bar_width ?? 24)); + const gap = Math.max(0, Math.floor(params.gap ?? 12)); + const chartHeight = Math.max(40, Math.floor(params.chart_height ?? 160)); + const theme = params.theme ?? 'light'; + const t = resolveTheme(theme); + + // Light mode: byte-parity with v0 (#2563EB). Dark/system: use chart-1 token. + const barColor = theme === 'light' ? '#2563EB' : t.chartColors.chart1; + + const children: ElementTree[] = values.map((v, i) => ({ + type: 'rectangle', + name: `Bar ${i + 1}`, + role: 'chart-bar', + width: barWidth, + height: Math.max(2, Math.round((v / max) * chartHeight)), + cornerRadius: 4, + fill: [{ type: 'solid', color: barColor }], + })); + return { + type: 'frame', + name: 'Chart Bars', + role: 'chart-bars', + width: 'fit_content', + height: chartHeight, + layout: 'horizontal', + alignItems: 'flex-end', + gap, + children, + }; +} diff --git a/packages/pen-core/src/element-builders/chart-line-v1.ts b/packages/pen-core/src/element-builders/chart-line-v1.ts new file mode 100644 index 000000000..725e727fb --- /dev/null +++ b/packages/pen-core/src/element-builders/chart-line-v1.ts @@ -0,0 +1,101 @@ +import type { ElementTree } from './helpers.js'; +import { resolveTheme, type V1Theme } from './resolve-theme.js'; + +export interface ChartLineV1Params { + values: number[]; + /** Width per data point slot (px). Default 32, min 8. */ + point_spacing?: number; + /** Chart height (px). Default 160, min 40. */ + chart_height?: number; + /** Whether to emit a filled dot at each data point. Default true. */ + dots?: boolean; + /** + * Stroke color for the line. Default #2563EB (Tailwind blue-600). + * Only used when `theme='light'` (v0 byte-parity). For dark/system + * modes, chart-1 token is used instead (ignores this field). + */ + stroke_color?: string; + /** + * Theme mode. + * - `'light'` (default): byte-parity with add_chart_line_v0. + * - `'dark'`: line/dot color uses chart-1 token from semantic palette. + * - `'system'`: emits `$color-chart-1` ref for line and dot fills. + */ + theme?: V1Theme; +} + +/** + * Line-chart skeleton — theme-aware version of buildChartLine. + * Light mode is byte-equal to add_chart_line_v0. + * + * Stroke/dot color maps to chart-1 (blue series). Light mode preserves + * v0's #2563EB (or caller-supplied stroke_color) for byte-parity. + */ +export function buildChartLineV1(params: ChartLineV1Params): ElementTree { + const raw = Array.isArray(params.values) ? params.values : []; + if (raw.length === 0) { + throw new Error('buildChartLineV1: values must contain at least one number'); + } + const values = raw.map((v) => (Number.isFinite(v) ? Math.max(0, v) : 0)); + const max = Math.max(1, ...values); + const spacing = Math.max(8, Math.floor(params.point_spacing ?? 32)); + const chartHeight = Math.max(40, Math.floor(params.chart_height ?? 160)); + const dots = params.dots ?? true; + const theme = params.theme ?? 'light'; + const t = resolveTheme(theme); + + // Light mode: byte-parity with v0 (stroke_color param or #2563EB). + // Dark/system: use chart-1 token. + const strokeColor = theme === 'light' ? (params.stroke_color ?? '#2563EB') : t.chartColors.chart1; + const totalWidth = spacing * values.length; + + const points = values.map((v, i) => { + const x = i * spacing + spacing / 2; + const yRaw = chartHeight - (v / max) * chartHeight; + const y = v > 0 ? Math.min(yRaw, chartHeight - 2) : chartHeight - 2; + return { x, y }; + }); + + const dPath = points + .map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`) + .join(' '); + + const children: ElementTree[] = [ + { + type: 'path', + name: 'Line', + role: 'chart-line-path', + d: dPath, + width: totalWidth, + height: chartHeight, + fill: [], + stroke: { thickness: 2, fill: [{ type: 'solid', color: strokeColor }] }, + }, + ]; + + if (dots) { + for (let i = 0; i < points.length; i++) { + const p = points[i]; + children.push({ + type: 'ellipse', + name: `Dot ${i + 1}`, + role: 'chart-line-dot', + x: p.x - 4, + y: p.y - 4, + width: 8, + height: 8, + fill: [{ type: 'solid', color: strokeColor }], + }); + } + } + + return { + type: 'frame', + name: 'Chart Line', + role: 'chart-line', + width: totalWidth, + height: chartHeight, + layout: 'none', + children, + }; +} diff --git a/packages/pen-core/src/element-builders/chart-pie-v1.ts b/packages/pen-core/src/element-builders/chart-pie-v1.ts new file mode 100644 index 000000000..0c9ac20bc --- /dev/null +++ b/packages/pen-core/src/element-builders/chart-pie-v1.ts @@ -0,0 +1,105 @@ +import type { ElementTree } from './helpers.js'; +import { resolveTheme, type V1Theme } from './resolve-theme.js'; + +export interface ChartPieV1Params { + /** Slice values (any numeric scale; normalized internally). */ + values: number[]; + /** Pie diameter (px, width=height). Default 160, min 40. */ + diameter?: number; + /** + * Optional per-slice colors (hex). If fewer than values.length, + * the default chart palette fills the rest. + * + * Note: In dark/system modes, caller-supplied colors are passed + * through unchanged (they are treated as intentional brand colors). + * Only the default palette rotates through chart tokens. + */ + colors?: string[]; + /** Inner cut-out radius as fraction of outer (0..0.9). Default 0 (full pie). */ + inner_radius_ratio?: number; + /** + * Theme mode. + * - `'light'` (default): byte-parity with add_chart_pie_v0 (uses v0 DEFAULT_PALETTE). + * - `'dark'`: default palette uses chart-1..6 tokens from semantic palette. + * - `'system'`: default palette emits `$color-chart-1..6` refs. + * + * When `colors` param is supplied, those values are always used as-is + * regardless of theme (caller takes ownership of color choice). + */ + theme?: V1Theme; +} + +// v0 byte-parity palette — MUST match buildChartPie's DEFAULT_PALETTE exactly +const V0_DEFAULT_PALETTE = ['#2563EB', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899']; + +/** + * Pie-chart skeleton — theme-aware version of buildChartPie. + * Light mode is byte-equal to add_chart_pie_v0. + * + * Default slice colors map to chart-1..6 tokens in dark/system modes. + * Caller-supplied `colors` are passed through unchanged in all modes. + */ +export function buildChartPieV1(params: ChartPieV1Params): ElementTree { + const raw = Array.isArray(params.values) ? params.values : []; + if (raw.length === 0) { + throw new Error('buildChartPieV1: values must contain at least one number'); + } + const values = raw.map((v) => (Number.isFinite(v) ? Math.max(0, v) : 0)); + const total = values.reduce((s, v) => s + v, 0); + if (total <= 0) { + throw new Error('buildChartPieV1: values must sum to > 0'); + } + const diameter = Math.max(40, Math.floor(params.diameter ?? 160)); + const innerRatio = Math.max(0, Math.min(0.9, params.inner_radius_ratio ?? 0)); + const innerRadius = innerRatio > 0 ? innerRatio : undefined; + const callerColors = params.colors ?? []; + const theme = params.theme ?? 'light'; + const t = resolveTheme(theme); + + // Build default palette for this theme + const defaultPalette = + theme === 'light' + ? V0_DEFAULT_PALETTE + : [ + t.chartColors.chart1, + t.chartColors.chart2, + t.chartColors.chart3, + t.chartColors.chart4, + t.chartColors.chart5, + t.chartColors.chart6, + ]; + + const children: ElementTree[] = []; + let currentAngle = -90; + for (let i = 0; i < values.length; i++) { + const fraction = values[i] / total; + const sweep = fraction * 360; + // Caller-supplied colors take precedence; fall back to default palette + const color = callerColors[i] ?? defaultPalette[i % defaultPalette.length]; + const slice: ElementTree = { + type: 'ellipse', + name: `Slice ${i + 1}`, + role: 'chart-pie-slice', + x: 0, + y: 0, + width: diameter, + height: diameter, + startAngle: currentAngle, + sweepAngle: sweep, + fill: [{ type: 'solid', color }], + }; + if (innerRadius !== undefined) slice.innerRadius = innerRadius; + children.push(slice); + currentAngle += sweep; + } + + return { + type: 'frame', + name: 'Chart Pie', + role: 'chart-pie', + width: diameter, + height: diameter, + layout: 'none', + children, + }; +} diff --git a/packages/pen-core/src/element-builders/chat-bubble-v1.ts b/packages/pen-core/src/element-builders/chat-bubble-v1.ts new file mode 100644 index 000000000..39bae5f97 --- /dev/null +++ b/packages/pen-core/src/element-builders/chat-bubble-v1.ts @@ -0,0 +1,123 @@ +import type { ElementTree } from './helpers.js'; +import { resolveTheme, type V1Theme } from './resolve-theme.js'; + +export type ChatBubbleV1Side = 'left' | 'right'; + +export interface ChatBubbleV1Params { + /** Message body text. Required. */ + message: string; + /** + * Which side the bubble aligns to. + * - `'left'` (default): from-someone-else. + * - `'right'`: from-self. Author suppressed. Accent-colored surface. + */ + side?: ChatBubbleV1Side; + /** Sender display name. ONLY rendered on `side: 'left'`. */ + author?: string; + /** Relative time string (e.g. "2m", "Just now"). Rendered below the bubble. */ + timestamp?: string; + /** Max bubble width in px. Default 280. Clamped 160..480. */ + max_width?: number; + /** + * Accent color for self-side bubbles (right). Default #2563EB. + * Only used in light mode (v0 byte-parity). Dark/system modes use + * the semantic accent token ($color-accent / dark-mode hex). + */ + accent_color?: string; + /** + * Theme mode. + * - `'light'` (default): byte-parity with add_chat_bubble_v0. + * - `'dark'`: dark-mode fills using semantic palette tokens. + * - `'system'`: emits `$color-*` refs for all fill fields. + */ + theme?: V1Theme; +} + +/** + * Chat bubble — theme-aware version of buildChatBubble. + * Light mode is byte-equal to add_chat_bubble_v0. + * + * Left-side (from-others): surface2 bg, textPrimary text, textMuted author/timestamp. + * Right-side (from-self): accent bg, white text (white kept across themes — text on + * accent is always white). Author/timestamp: textMuted in dark/system. + */ +export function buildChatBubbleV1(params: ChatBubbleV1Params): ElementTree { + const side: ChatBubbleV1Side = params.side ?? 'left'; + const maxWidth = Math.max(160, Math.min(480, Math.floor(params.max_width ?? 280))); + const theme = params.theme ?? 'light'; + const t = resolveTheme(theme); + const isLight = theme === 'light'; + const isSelf = side === 'right'; + + // Light mode: byte-parity with v0 + const accent = isLight ? (params.accent_color ?? '#2563EB') : t.colors.accent; + const surfaceBg = isSelf ? accent : isLight ? '#F1F5F9' : t.colors.surface2; + const textColor = isSelf ? '#FFFFFF' : isLight ? '#0F172A' : t.colors.textPrimary; + const mutedColor = isLight ? '#64748B' : t.colors.textMuted; + + const alignItems: 'flex-start' | 'flex-end' = isSelf ? 'flex-end' : 'flex-start'; + + const outerChildren: ElementTree[] = []; + + if (!isSelf && params.author) { + outerChildren.push({ + type: 'text', + name: 'Author', + role: 'chat-bubble-author', + content: params.author, + fontSize: 12, + fontWeight: 500, + fill: [{ type: 'solid', color: mutedColor }], + }); + } + + outerChildren.push({ + type: 'frame', + name: 'Surface', + role: 'chat-bubble-surface', + width: maxWidth, + height: 'fit_content', + cornerRadius: 16, + layout: 'vertical', + padding: [10, 14], + fill: [{ type: 'solid', color: surfaceBg }], + children: [ + { + type: 'text', + name: 'Message', + role: 'chat-bubble-message', + content: params.message, + fontSize: 14, + fontWeight: 400, + lineHeight: 1.4, + width: 'fill_container', + textGrowth: 'fixed-width', + fill: [{ type: 'solid', color: textColor }], + }, + ], + }); + + if (params.timestamp) { + outerChildren.push({ + type: 'text', + name: 'Timestamp', + role: 'chat-bubble-timestamp', + content: params.timestamp, + fontSize: 11, + fontWeight: 400, + fill: [{ type: 'solid', color: mutedColor }], + }); + } + + return { + type: 'frame', + name: 'Chat Bubble', + role: isSelf ? 'chat-bubble-right' : 'chat-bubble-left', + width: 'fill_container', + height: 'fit_content', + layout: 'vertical', + alignItems, + gap: 4, + children: outerChildren, + }; +} diff --git a/packages/pen-core/src/element-builders/checkbox-v1.ts b/packages/pen-core/src/element-builders/checkbox-v1.ts new file mode 100644 index 000000000..372479352 --- /dev/null +++ b/packages/pen-core/src/element-builders/checkbox-v1.ts @@ -0,0 +1,80 @@ +import type { ElementTree } from './helpers.js'; +import { resolveTheme, type V1Theme } from './resolve-theme.js'; + +export interface CheckboxV1Params { + label: string; + checked?: boolean; + /** + * Theme mode. + * - `'light'` (default): byte-parity with add_checkbox_v0. + * - `'dark'`: accent fill for checked, dark border for unchecked. + * - `'system'`: emits `$color-*` refs for all fill fields. + */ + theme?: V1Theme; +} + +/** + * Checkbox + label pair — theme-aware version of buildCheckbox. + * Light mode is byte-equal to add_checkbox_v0. + * + * Checked state: accent fill + white check icon. + * Unchecked state: transparent fill + border stroke (border token). + * v0's unchecked stroke was #9CA3AF (gray-400); mapped to border token in dark/system. + */ +export function buildCheckboxV1(params: CheckboxV1Params): ElementTree { + const checked = params.checked === true; + const theme = params.theme ?? 'light'; + const t = resolveTheme(theme); + const isLight = theme === 'light'; + + // Light mode: byte-parity with v0 + const accentColor = isLight ? '#2563EB' : t.colors.accent; + const borderColor = isLight ? '#9CA3AF' : t.colors.border; + + const box: ElementTree = { + type: 'frame', + name: checked ? 'Checkbox (checked)' : 'Checkbox', + role: checked ? 'checkbox-checked' : 'checkbox', + width: 20, + height: 20, + cornerRadius: 4, + layout: 'horizontal', + alignItems: 'center', + justifyContent: 'center', + children: [] as ElementTree[], + }; + if (checked) { + box.fill = [{ type: 'solid', color: accentColor }]; + (box.children as ElementTree[]).push({ + type: 'icon_font', + name: 'Check', + iconFontName: 'check', + iconFontFamily: 'lucide', + width: 14, + height: 14, + fill: [{ type: 'solid', color: '#FFFFFF' }], + }); + } else { + box.fill = []; + box.stroke = { thickness: 1.5, fill: [{ type: 'solid', color: borderColor }] }; + } + return { + type: 'frame', + name: `Checkbox Row (${params.label})`, + role: 'checkbox-row', + layout: 'horizontal', + alignItems: 'center', + gap: 8, + children: [ + box, + { + type: 'text', + name: 'Label', + role: 'label', + content: params.label, + fontSize: 14, + fontWeight: 400, + }, + ], + }; +} diff --git a/packages/pen-core/src/element-builders/chip-input-v1.ts b/packages/pen-core/src/element-builders/chip-input-v1.ts new file mode 100644 index 000000000..925b1d28c --- /dev/null +++ b/packages/pen-core/src/element-builders/chip-input-v1.ts @@ -0,0 +1,125 @@ +import type { ElementTree } from './helpers.js'; +import { resolveTheme, type V1Theme } from './resolve-theme.js'; + +export interface ChipInputV1Params { + /** Field label shown above the input. Required. */ + label: string; + /** + * Current chip values. Each becomes a pill with a small × icon. + * Empty array is valid (placeholder-only state). + */ + chips?: string[]; + /** Placeholder text shown when no chips OR after the last chip. */ + placeholder?: string; + /** When true, appends " *" to the label. */ + required?: boolean; + /** + * Theme mode. + * - `'light'` (default): byte-parity with add_chip_input_v0. + * - `'dark'`: dark fills for chip bg, field bg, border, icon, placeholder. + * - `'system'`: emits `$color-*` refs for all fill fields. + */ + theme?: V1Theme; +} + +/** + * Chip / tag input — theme-aware version of buildChipInput. + * Light mode is byte-equal to add_chip_input_v0. + * + * Color mapping: + * chip bg (#F1F5F9) → surface2 + * chip icon (#64748B) → textMuted + * field bg (#FFFFFF) → surface + * field border (#E2E8F0) → border + * placeholder (#94A3B8) → textSubtle + */ +export function buildChipInputV1(params: ChipInputV1Params): ElementTree { + const labelText = params.required === true ? `${params.label} *` : params.label; + const chips = params.chips ?? []; + const theme = params.theme ?? 'light'; + const t = resolveTheme(theme); + const isLight = theme === 'light'; + + const chipBg = isLight ? '#F1F5F9' : t.colors.surface2; + const chipIconColor = isLight ? '#64748B' : t.colors.textMuted; + const fieldBg = isLight ? '#FFFFFF' : t.colors.surface; + const fieldBorder = isLight ? '#E2E8F0' : t.colors.border; + const placeholderColor = isLight ? '#94A3B8' : t.colors.textSubtle; + + const fieldChildren: ElementTree[] = chips.map((value) => ({ + type: 'frame', + name: `Chip (${value})`, + role: 'chip', + height: 'fit_content', + layout: 'horizontal', + alignItems: 'center', + gap: 4, + padding: [6, 4, 6, 10], + cornerRadius: 16, + fill: [{ type: 'solid', color: chipBg }], + children: [ + { + type: 'text', + name: 'Value', + content: value, + fontSize: 13, + fontWeight: 500, + }, + { + type: 'icon_font', + name: 'Remove', + iconFontName: 'x', + iconFontFamily: 'lucide', + width: 14, + height: 14, + fill: [{ type: 'solid', color: chipIconColor }], + }, + ], + })); + + fieldChildren.push({ + type: 'text', + name: 'Caret', + role: 'chip-input-caret', + content: params.placeholder ?? (chips.length === 0 ? 'Add tag…' : ''), + fontSize: 14, + fontWeight: 400, + fill: [{ type: 'solid', color: placeholderColor }], + }); + + return { + type: 'frame', + name: 'Chip Input', + role: 'chip-input', + width: 'fill_container', + height: 'fit_content', + layout: 'vertical', + gap: 6, + children: [ + { + type: 'text', + name: 'Label', + role: 'chip-input-label', + content: labelText, + fontSize: 13, + fontWeight: 500, + }, + { + type: 'frame', + name: 'Field', + role: 'chip-input-field', + width: 'fill_container', + height: 'fit_content', + layout: 'horizontal', + alignItems: 'center', + layoutWrap: 'wrap', + gap: 6, + padding: [8, 12], + cornerRadius: 8, + fill: [{ type: 'solid', color: fieldBg }], + stroke: { thickness: 1, fill: [{ type: 'solid', color: fieldBorder }] }, + children: fieldChildren, + }, + ], + }; +} diff --git a/packages/pen-core/src/element-builders/code-block-v1.ts b/packages/pen-core/src/element-builders/code-block-v1.ts new file mode 100644 index 000000000..ca406444f --- /dev/null +++ b/packages/pen-core/src/element-builders/code-block-v1.ts @@ -0,0 +1,57 @@ +import type { ElementTree } from './helpers.js'; +import { resolveTheme, type V1Theme } from './resolve-theme.js'; + +export interface CodeBlockV1Params { + code: string; + language?: string; + /** + * Theme mode. + * - `'light'` (default): byte-parity with add_code_block_v0. + * - `'dark'`: dark background for the code block surface. + * - `'system'`: emits `$color-surface-2` ref for background fill. + */ + theme?: V1Theme; +} + +/** + * Preformatted code block — theme-aware version of buildCodeBlock. + * Light mode is byte-equal to add_code_block_v0. + * + * Background (#F3F4F6 = gray-100) maps to surface2 token in dark/system modes. + * Code text has no explicit fill in v0 (relies on canvas default) so we + * keep it unfilled in all modes — theme-agnostic. + */ +export function buildCodeBlockV1(params: CodeBlockV1Params): ElementTree { + const theme = params.theme ?? 'light'; + const t = resolveTheme(theme); + const isLight = theme === 'light'; + + // Light: byte-parity (#F3F4F6). Dark/system: surface2 token. + const bgColor = isLight ? '#F3F4F6' : t.colors.surface2; + + const nameSuffix = params.language ? ` (${params.language})` : ''; + return { + type: 'frame', + name: `Code Block${nameSuffix}`, + role: 'code-block', + width: 'fill_container', + height: 'fit_content', + layout: 'vertical', + padding: [12, 16], + cornerRadius: 8, + fill: [{ type: 'solid', color: bgColor }], + children: [ + { + type: 'text', + name: 'Code', + role: 'code', + content: params.code, + fontSize: 13, + fontWeight: 400, + lineHeight: 1.5, + width: 'fill_container', + textGrowth: 'fixed-width', + }, + ], + }; +} diff --git a/packages/pen-core/src/element-builders/color-swatch-v1.ts b/packages/pen-core/src/element-builders/color-swatch-v1.ts new file mode 100644 index 000000000..12fe02b17 --- /dev/null +++ b/packages/pen-core/src/element-builders/color-swatch-v1.ts @@ -0,0 +1,64 @@ +import type { ElementTree } from './helpers.js'; +import type { V1Theme } from './resolve-theme.js'; + +export interface ColorSwatchV1Params { + color: string; + label?: string; + size?: number; + /** + * Theme mode. + * - `'light'` (default): byte-parity with add_color_swatch_v0. + * - `'dark'`: identical tree — color swatch passes `color` through unchanged. + * - `'system'`: identical tree — color swatch passes `color` through unchanged. + * + * Note: `color` is intentionally theme-agnostic — the whole point of a + * design-system swatch is to display a specific color. No fill tokens are + * applied; the caller supplies the swatch color directly. Theme parameter + * exists only for API consistency with other v1 tools. + */ + theme?: V1Theme; +} + +/** + * Design-system color swatch — theme-aware version of buildColorSwatch. + * Light mode is byte-equal to add_color_swatch_v0. + * + * This v1 is structurally identical across all theme modes — the swatch + * color is caller-supplied and not tokenized. Theme parameter exists for + * consistency; dark/system produce the same tree as light. + */ +export function buildColorSwatchV1(params: ColorSwatchV1Params): ElementTree { + const size = Math.max(16, Math.floor(params.size ?? 64)); + const children: ElementTree[] = [ + { + type: 'frame', + name: 'Swatch Square', + role: 'color-swatch-square', + width: size, + height: size, + cornerRadius: 12, + fill: [{ type: 'solid', color: params.color }], + }, + ]; + if (params.label) { + children.push({ + type: 'text', + name: 'Label', + role: 'color-swatch-label', + content: params.label, + fontSize: 12, + fontWeight: 500, + }); + } + return { + type: 'frame', + name: 'Color Swatch', + role: 'color-swatch', + width: 'fit_content', + height: 'fit_content', + layout: 'vertical', + alignItems: 'center', + gap: 8, + children, + }; +} diff --git a/packages/pen-core/src/element-builders/combobox-v1.ts b/packages/pen-core/src/element-builders/combobox-v1.ts new file mode 100644 index 000000000..ef1554a43 --- /dev/null +++ b/packages/pen-core/src/element-builders/combobox-v1.ts @@ -0,0 +1,162 @@ +import type { ElementTree } from './helpers.js'; +import { resolveTheme, type V1Theme } from './resolve-theme.js'; + +export interface ComboboxV1Option { + label: string; + /** Mark a row as the currently highlighted suggestion (surface2 bg). */ + highlighted?: boolean; +} + +export interface ComboboxV1Params { + /** Placeholder shown when value is empty. */ + placeholder?: string; + /** Pre-filled query text. */ + value?: string; + /** Suggestion rows shown in the open dropdown. Required (1+). */ + options: ComboboxV1Option[]; + /** Optional label above the field (e.g. "Country"). */ + label?: string; + /** + * Theme mode. + * - `'light'` (default): byte-parity with add_combobox_v0. + * - `'dark'`: dark surface/text/border for all fill fields. + * - `'system'`: emits `$color-*` refs for all fill fields. + */ + theme?: V1Theme; +} + +/** + * Combobox / autocomplete — theme-aware version of buildCombobox. + * Light mode is byte-equal to add_combobox_v0. + * + * Color mapping: + * label text (#0F172A) → textPrimary + * input bg (#FFFFFF) → surface + * input stroke (#2563EB) → accent (focus ring — intentionally accent in all modes) + * search icon (#64748B) → textMuted + * value text (#0F172A) → textPrimary + * placeholder (#94A3B8) → textSubtle + * dropdown bg (#FFFFFF) → surface + * dropdown border (#E2E8F0) → border + * option hover bg (#F1F5F9) → surface2 + * option text (#0F172A) → textPrimary + * shadow color (#0F172A1A) → kept as-is (shadow is theme-agnostic in v0) + */ +export function buildComboboxV1(params: ComboboxV1Params): ElementTree { + const placeholder = params.placeholder ?? 'Search...'; + const value = params.value ?? ''; + const theme = params.theme ?? 'light'; + const t = resolveTheme(theme); + const isLight = theme === 'light'; + + const labelTextColor = isLight ? '#0F172A' : t.colors.textPrimary; + const inputBg = isLight ? '#FFFFFF' : t.colors.surface; + const inputStroke = isLight ? '#2563EB' : t.colors.accent; + const searchIconColor = isLight ? '#64748B' : t.colors.textMuted; + const valueColor = isLight ? '#0F172A' : t.colors.textPrimary; + const placeholderColor = isLight ? '#94A3B8' : t.colors.textSubtle; + const dropdownBg = isLight ? '#FFFFFF' : t.colors.surface; + const dropdownBorder = isLight ? '#E2E8F0' : t.colors.border; + const optionHoverBg = isLight ? '#F1F5F9' : t.colors.surface2; + const optionTextColor = isLight ? '#0F172A' : t.colors.textPrimary; + + const stackChildren: ElementTree[] = []; + if (params.label) { + stackChildren.push({ + type: 'text', + name: 'Label', + role: 'combobox-label', + content: params.label, + fontSize: 13, + fontWeight: 500, + fill: [{ type: 'solid', color: labelTextColor }], + }); + } + stackChildren.push({ + type: 'frame', + name: 'Input', + role: 'combobox-input', + width: 'fill_container', + height: 44, + cornerRadius: 8, + layout: 'horizontal', + alignItems: 'center', + gap: 8, + padding: [0, 12], + fill: [{ type: 'solid', color: inputBg }], + stroke: { thickness: 1, fill: [{ type: 'solid', color: inputStroke }] }, + children: [ + { + type: 'icon_font', + name: 'Search Icon', + iconFontName: 'search', + iconFontFamily: 'lucide', + width: 16, + height: 16, + fill: [{ type: 'solid', color: searchIconColor }], + }, + { + type: 'text', + name: 'Value', + role: 'combobox-value', + content: value || placeholder, + fontSize: 14, + fontWeight: 400, + fill: [{ type: 'solid', color: value ? valueColor : placeholderColor }], + }, + ], + }); + stackChildren.push({ + type: 'frame', + name: 'Dropdown', + role: 'combobox-dropdown', + width: 'fill_container', + height: 'fit_content', + cornerRadius: 8, + layout: 'vertical', + fill: [{ type: 'solid', color: dropdownBg }], + stroke: { thickness: 1, fill: [{ type: 'solid', color: dropdownBorder }] }, + effects: [ + { + type: 'shadow', + offsetX: 0, + offsetY: 4, + blur: 12, + spread: 0, + color: '#0F172A1A', + }, + ], + children: params.options.map((opt, i) => ({ + type: 'frame', + name: `Option ${i + 1}`, + role: opt.highlighted ? 'combobox-option-active' : 'combobox-option', + width: 'fill_container', + height: 36, + layout: 'horizontal', + alignItems: 'center', + padding: [0, 12], + ...(opt.highlighted ? { fill: [{ type: 'solid', color: optionHoverBg }] } : {}), + children: [ + { + type: 'text', + name: 'Label', + role: 'combobox-option-label', + content: opt.label, + fontSize: 14, + fontWeight: opt.highlighted ? 500 : 400, + fill: [{ type: 'solid', color: optionTextColor }], + }, + ], + })), + }); + return { + type: 'frame', + name: 'Combobox', + role: 'combobox', + width: 'fill_container', + height: 'fit_content', + layout: 'vertical', + gap: 6, + children: stackChildren, + }; +} diff --git a/packages/pen-core/src/element-builders/comment-v1.ts b/packages/pen-core/src/element-builders/comment-v1.ts new file mode 100644 index 000000000..57e52041a --- /dev/null +++ b/packages/pen-core/src/element-builders/comment-v1.ts @@ -0,0 +1,141 @@ +import type { ElementTree } from './helpers.js'; +import { resolveTheme, type V1Theme } from './resolve-theme.js'; + +export interface CommentV1Params { + /** Author display name (shown in bold inline with timestamp). */ + author: string; + /** Relative timestamp string ("2 hours ago", "Just now", "3d"). */ + timestamp?: string; + /** Body text. Supports multi-line (rendered as a single text node). */ + body: string; + /** + * Avatar initial (1-2 chars). When absent, the avatar is rendered + * as a blank circle (placeholder for a user photo). + */ + avatar_initial?: string; + /** Avatar diameter in px. Default 40. Min 24. */ + avatar_size?: number; + /** + * Theme mode. + * - `'light'` (default): byte-parity with add_comment_v0. + * - `'dark'`: dark fills for avatar bg, initial text, timestamp. + * - `'system'`: emits `$color-*` refs for all fill fields. + */ + theme?: V1Theme; +} + +/** + * Comment row — theme-aware version of buildComment. + * Light mode is byte-equal to add_comment_v0. + * + * Color mapping: + * avatar bg (#E2E8F0) → surface2 (light gray placeholder) + * initial text (#475569) → textBody + * timestamp (#64748B) → textMuted + * + * Author text and body text have no explicit fill in v0; kept unfilled + * in all modes for byte-parity. + */ +export function buildCommentV1(params: CommentV1Params): ElementTree { + const avatarSize = Math.max(24, Math.floor(params.avatar_size ?? 40)); + const avatarRadius = avatarSize / 2; + const theme = params.theme ?? 'light'; + const t = resolveTheme(theme); + const isLight = theme === 'light'; + + const avatarBg = isLight ? '#E2E8F0' : t.colors.surface2; + const initialColor = isLight ? '#475569' : t.colors.textBody; + const timestampColor = isLight ? '#64748B' : t.colors.textMuted; + + const avatarChildren: ElementTree[] = []; + if (params.avatar_initial) { + avatarChildren.push({ + type: 'text', + name: 'Initial', + role: 'avatar-initial', + content: params.avatar_initial.slice(0, 2).toUpperCase(), + fontSize: Math.max(10, Math.round(avatarSize * 0.4)), + fontWeight: 600, + fill: [{ type: 'solid', color: initialColor }], + }); + } + + const headerChildren: ElementTree[] = [ + { + type: 'text', + name: 'Author', + role: 'comment-author', + content: params.author, + fontSize: 14, + fontWeight: 600, + }, + ]; + if (params.timestamp) { + headerChildren.push({ + type: 'text', + name: 'Timestamp', + role: 'comment-timestamp', + content: params.timestamp, + fontSize: 12, + fontWeight: 400, + fill: [{ type: 'solid', color: timestampColor }], + }); + } + + return { + type: 'frame', + name: 'Comment', + role: 'comment', + width: 'fill_container', + height: 'fit_content', + layout: 'horizontal', + alignItems: 'start', + gap: 12, + children: [ + { + type: 'frame', + name: 'Avatar', + role: 'comment-avatar', + width: avatarSize, + height: avatarSize, + cornerRadius: avatarRadius, + layout: 'horizontal', + alignItems: 'center', + justifyContent: 'center', + fill: [{ type: 'solid', color: avatarBg }], + children: avatarChildren, + }, + { + type: 'frame', + name: 'Body Column', + role: 'comment-body-column', + width: 'fill_container', + height: 'fit_content', + layout: 'vertical', + gap: 4, + children: [ + { + type: 'frame', + name: 'Header', + role: 'comment-header', + width: 'fit_content', + height: 'fit_content', + layout: 'horizontal', + alignItems: 'baseline', + gap: 6, + children: headerChildren, + }, + { + type: 'text', + name: 'Body', + role: 'comment-body', + content: params.body, + fontSize: 14, + fontWeight: 400, + lineHeight: 1.5, + }, + ], + }, + ], + }; +} diff --git a/packages/pen-core/src/element-builders/index.ts b/packages/pen-core/src/element-builders/index.ts index 8a74fc08b..0e6fb43af 100644 --- a/packages/pen-core/src/element-builders/index.ts +++ b/packages/pen-core/src/element-builders/index.ts @@ -174,3 +174,17 @@ export { type AvatarGroupV1Params, } from './avatar-group-v1.js'; export { buildCalloutV1, type CalloutV1Tone, type CalloutV1Params } from './callout-v1.js'; +export { buildChartBarsV1, type ChartBarsV1Params } from './chart-bars-v1.js'; +export { buildChartLineV1, type ChartLineV1Params } from './chart-line-v1.js'; +export { buildChartPieV1, type ChartPieV1Params } from './chart-pie-v1.js'; +export { + buildChatBubbleV1, + type ChatBubbleV1Params, + type ChatBubbleV1Side, +} from './chat-bubble-v1.js'; +export { buildCheckboxV1, type CheckboxV1Params } from './checkbox-v1.js'; +export { buildChipInputV1, type ChipInputV1Params } from './chip-input-v1.js'; +export { buildCodeBlockV1, type CodeBlockV1Params } from './code-block-v1.js'; +export { buildColorSwatchV1, type ColorSwatchV1Params } from './color-swatch-v1.js'; +export { buildComboboxV1, type ComboboxV1Option, type ComboboxV1Params } from './combobox-v1.js'; +export { buildCommentV1, type CommentV1Params } from './comment-v1.js'; diff --git a/packages/pen-core/src/index.ts b/packages/pen-core/src/index.ts index 64d765740..24345e004 100644 --- a/packages/pen-core/src/index.ts +++ b/packages/pen-core/src/index.ts @@ -472,4 +472,26 @@ export { buildCalloutV1, type CalloutV1Tone, type CalloutV1Params, + buildChartBarsV1, + type ChartBarsV1Params, + buildChartLineV1, + type ChartLineV1Params, + buildChartPieV1, + type ChartPieV1Params, + buildChatBubbleV1, + type ChatBubbleV1Params, + type ChatBubbleV1Side, + buildCheckboxV1, + type CheckboxV1Params, + buildChipInputV1, + type ChipInputV1Params, + buildCodeBlockV1, + type CodeBlockV1Params, + buildColorSwatchV1, + type ColorSwatchV1Params, + buildComboboxV1, + type ComboboxV1Option, + type ComboboxV1Params, + buildCommentV1, + type CommentV1Params, } from './element-builders/index.js'; diff --git a/packages/pen-mcp/src/__tests__/element-tools-contract.test.ts b/packages/pen-mcp/src/__tests__/element-tools-contract.test.ts index 8386fc2c1..84bf62eb1 100644 --- a/packages/pen-mcp/src/__tests__/element-tools-contract.test.ts +++ b/packages/pen-mcp/src/__tests__/element-tools-contract.test.ts @@ -109,6 +109,16 @@ const ELEMENT_TOOL_NAMES = [ 'add_calendar_grid_v1', 'add_avatar_group_v1', 'add_callout_v1', + 'add_chart_bars_v1', + 'add_chart_line_v1', + 'add_chart_pie_v1', + 'add_chat_bubble_v1', + 'add_checkbox_v1', + 'add_chip_input_v1', + 'add_code_block_v1', + 'add_color_swatch_v1', + 'add_combobox_v1', + 'add_comment_v1', ]; describe('element tools — v0-MUST contract', () => { diff --git a/packages/pen-mcp/src/routes/element-tool-defs-ext-5.ts b/packages/pen-mcp/src/routes/element-tool-defs-ext-5.ts index e3ac2d166..b8949667d 100644 --- a/packages/pen-mcp/src/routes/element-tool-defs-ext-5.ts +++ b/packages/pen-mcp/src/routes/element-tool-defs-ext-5.ts @@ -471,4 +471,328 @@ export const ELEMENT_TOOL_DEFINITIONS_EXT_5 = [ required: ['body'], }, }, + { + name: 'add_chart_bars_v1', + description: + 'Theme-aware bar-chart skeleton (v1). theme="light" (default): byte-parity with ' + + 'add_chart_bars_v0. theme="dark" / "system": bar color maps to chart-1 token. ' + + 'Bottom-aligned rectangles proportional to max value. No axes / labels. schemaVersion 1.0', + inputSchema: { + type: 'object' as const, + properties: { + schemaVersion: schemaVersionProp, + filePath: filePathProp, + values: { + type: 'array', + description: 'Numeric values (≥1). Proportional to tallest bar.', + items: { type: 'number' }, + }, + bar_width: { type: 'number', description: 'Bar width px. Default 24, min 4.' }, + gap: { type: 'number', description: 'Gap between bars px. Default 12.' }, + chart_height: { type: 'number', description: 'Chart height px. Default 160, min 40.' }, + theme: { + type: 'string', + enum: ['light', 'dark', 'system'], + description: 'Theme variant. Default "light".', + }, + parent_id: parentIdProp, + pageId: pageIdProp, + }, + required: ['values'], + }, + }, + { + name: 'add_chart_line_v1', + description: + 'Theme-aware line-chart skeleton (v1). theme="light" (default): byte-parity with ' + + 'add_chart_line_v0. theme="dark" / "system": line/dot color maps to chart-1 token. ' + + 'Polyline through N data points with optional dots. No axes / labels. schemaVersion 1.0', + inputSchema: { + type: 'object' as const, + properties: { + schemaVersion: schemaVersionProp, + filePath: filePathProp, + values: { + type: 'array', + description: 'Numeric values (≥1). Y-axis inverted (0 = bottom).', + items: { type: 'number' }, + }, + point_spacing: { type: 'number', description: 'Width per data point slot px. Default 32.' }, + chart_height: { type: 'number', description: 'Chart height px. Default 160, min 40.' }, + dots: { type: 'boolean', description: 'Emit dot ellipses at each point. Default true.' }, + stroke_color: { + type: 'string', + description: 'Line color (light mode only). Default #2563EB.', + }, + theme: { + type: 'string', + enum: ['light', 'dark', 'system'], + description: 'Theme variant. Default "light".', + }, + parent_id: parentIdProp, + pageId: pageIdProp, + }, + required: ['values'], + }, + }, + { + name: 'add_chart_pie_v1', + description: + 'Theme-aware pie/donut-chart skeleton (v1). theme="light" (default): byte-parity with ' + + 'add_chart_pie_v0 (v0 default palette). theme="dark" / "system": default palette uses ' + + 'chart-1..6 tokens. Caller-supplied `colors` always pass through unchanged. schemaVersion 1.0', + inputSchema: { + type: 'object' as const, + properties: { + schemaVersion: schemaVersionProp, + filePath: filePathProp, + values: { + type: 'array', + description: 'Slice values (≥1, sum > 0). Normalized internally.', + items: { type: 'number' }, + }, + diameter: { type: 'number', description: 'Pie diameter px. Default 160, min 40.' }, + colors: { + type: 'array', + description: 'Optional per-slice hex colors. Falls back to default chart palette.', + items: { type: 'string' }, + }, + inner_radius_ratio: { + type: 'number', + description: 'Donut hole size as fraction of outer radius (0..0.9). Default 0.', + }, + theme: { + type: 'string', + enum: ['light', 'dark', 'system'], + description: 'Theme variant. Default "light".', + }, + parent_id: parentIdProp, + pageId: pageIdProp, + }, + required: ['values'], + }, + }, + { + name: 'add_chat_bubble_v1', + description: + 'Theme-aware chat bubble (v1). theme="light" (default): byte-parity with add_chat_bubble_v0. ' + + 'theme="dark" / "system": left-side → surface2 bg + textPrimary text; ' + + 'right-side (self) → accent bg + white text. schemaVersion 1.0', + inputSchema: { + type: 'object' as const, + properties: { + schemaVersion: schemaVersionProp, + filePath: filePathProp, + message: { type: 'string', description: 'Message body text.' }, + side: { + type: 'string', + enum: ['left', 'right'], + description: '"left" = from-others (default), "right" = from-self (accent bg).', + }, + author: { + type: 'string', + description: 'Sender display name. Only rendered on left-side bubbles.', + }, + timestamp: { type: 'string', description: 'Relative time string (e.g. "2m").' }, + max_width: { + type: 'number', + description: 'Max bubble width px. Default 280, clamped 160..480.', + }, + accent_color: { + type: 'string', + description: 'Right-side accent color (light mode only). Default #2563EB.', + }, + theme: { + type: 'string', + enum: ['light', 'dark', 'system'], + description: 'Theme variant. Default "light".', + }, + parent_id: parentIdProp, + pageId: pageIdProp, + }, + required: ['message'], + }, + }, + { + name: 'add_checkbox_v1', + description: + 'Theme-aware checkbox + label pair (v1). theme="light" (default): byte-parity with ' + + 'add_checkbox_v0. theme="dark" / "system": accent fill for checked, border token for unchecked. ' + + '20×20 box with cornerRadius=4. schemaVersion 1.0', + inputSchema: { + type: 'object' as const, + properties: { + schemaVersion: schemaVersionProp, + filePath: filePathProp, + label: { type: 'string', description: 'Label text. Required.' }, + checked: { type: 'boolean', description: 'Checked state. Default false.' }, + theme: { + type: 'string', + enum: ['light', 'dark', 'system'], + description: 'Theme variant. Default "light".', + }, + parent_id: parentIdProp, + pageId: pageIdProp, + }, + required: ['label'], + }, + }, + { + name: 'add_chip_input_v1', + description: + 'Theme-aware chip / tag input (v1). theme="light" (default): byte-parity with ' + + 'add_chip_input_v0. theme="dark" / "system": chip bg → surface2, field bg → surface, ' + + 'border → border token, placeholder → textSubtle. schemaVersion 1.0', + inputSchema: { + type: 'object' as const, + properties: { + schemaVersion: schemaVersionProp, + filePath: filePathProp, + label: { type: 'string', description: 'Field label shown above input. Required.' }, + chips: { + type: 'array', + description: 'Current chip values. Each becomes a pill with × icon.', + items: { type: 'string' }, + }, + placeholder: { + type: 'string', + description: 'Placeholder text after last chip. Default "Add tag…".', + }, + required: { + type: 'boolean', + description: 'When true, appends " *" to the label.', + }, + theme: { + type: 'string', + enum: ['light', 'dark', 'system'], + description: 'Theme variant. Default "light".', + }, + parent_id: parentIdProp, + pageId: pageIdProp, + }, + required: ['label'], + }, + }, + { + name: 'add_code_block_v1', + description: + 'Theme-aware preformatted code block (v1). theme="light" (default): byte-parity with ' + + 'add_code_block_v0 (gray-100 bg). theme="dark" / "system": bg maps to surface2 token. ' + + 'fill_container frame with monospace text. schemaVersion 1.0', + inputSchema: { + type: 'object' as const, + properties: { + schemaVersion: schemaVersionProp, + filePath: filePathProp, + code: { type: 'string', description: 'Code content (verbatim, newlines preserved).' }, + language: { type: 'string', description: 'Language tag for frame name only.' }, + theme: { + type: 'string', + enum: ['light', 'dark', 'system'], + description: 'Theme variant. Default "light".', + }, + parent_id: parentIdProp, + pageId: pageIdProp, + }, + required: ['code'], + }, + }, + { + name: 'add_color_swatch_v1', + description: + 'Theme-aware design-system color swatch (v1). theme="light" (default): byte-parity with ' + + 'add_color_swatch_v0. All theme modes produce identical trees — swatch color is caller-supplied ' + + 'and not tokenized. Accepts hex or $variable ref. schemaVersion 1.0', + inputSchema: { + type: 'object' as const, + properties: { + schemaVersion: schemaVersionProp, + filePath: filePathProp, + color: { + type: 'string', + description: 'Swatch color hex (#2563EB) or $variable ref ($color-primary). Required.', + }, + label: { type: 'string', description: 'Optional label shown below the swatch.' }, + size: { type: 'number', description: 'Swatch square size px. Default 64, min 16.' }, + theme: { + type: 'string', + enum: ['light', 'dark', 'system'], + description: 'Theme variant. Default "light". All modes produce identical output.', + }, + parent_id: parentIdProp, + pageId: pageIdProp, + }, + required: ['color'], + }, + }, + { + name: 'add_combobox_v1', + description: + 'Theme-aware combobox / autocomplete (v1). theme="light" (default): byte-parity with ' + + 'add_combobox_v0. theme="dark" / "system": input/dropdown bg → surface, option hover → surface2, ' + + 'border → border token, focus ring → accent. Open-state input with suggestion list. schemaVersion 1.0', + inputSchema: { + type: 'object' as const, + properties: { + schemaVersion: schemaVersionProp, + filePath: filePathProp, + options: { + type: 'array', + description: 'Suggestion rows (1+). Each: { label, highlighted? }.', + items: { + type: 'object', + properties: { + label: { type: 'string' }, + highlighted: { + type: 'boolean', + description: 'When true, renders with surface2 bg.', + }, + }, + required: ['label'], + }, + }, + placeholder: { type: 'string', description: 'Placeholder text. Default "Search...".' }, + value: { type: 'string', description: 'Pre-filled query text.' }, + label: { type: 'string', description: 'Optional label above the field.' }, + theme: { + type: 'string', + enum: ['light', 'dark', 'system'], + description: 'Theme variant. Default "light".', + }, + parent_id: parentIdProp, + pageId: pageIdProp, + }, + required: ['options'], + }, + }, + { + name: 'add_comment_v1', + description: + 'Theme-aware comment row (v1). theme="light" (default): byte-parity with add_comment_v0. ' + + 'theme="dark" / "system": avatar bg → surface2, initial text → textBody, timestamp → textMuted. ' + + 'Circular avatar + (author + timestamp) header + body text. schemaVersion 1.0', + inputSchema: { + type: 'object' as const, + properties: { + schemaVersion: schemaVersionProp, + filePath: filePathProp, + author: { type: 'string', description: 'Author display name. Required.' }, + body: { type: 'string', description: 'Comment body text. Required.' }, + timestamp: { type: 'string', description: 'Relative timestamp ("2 hours ago").' }, + avatar_initial: { + type: 'string', + description: '1-2 char avatar initial. Absent → blank circle.', + }, + avatar_size: { type: 'number', description: 'Avatar diameter px. Default 40, min 24.' }, + theme: { + type: 'string', + enum: ['light', 'dark', 'system'], + description: 'Theme variant. Default "light".', + }, + parent_id: parentIdProp, + pageId: pageIdProp, + }, + required: ['author', 'body'], + }, + }, ]; diff --git a/packages/pen-mcp/src/routes/element-tool-defs.ts b/packages/pen-mcp/src/routes/element-tool-defs.ts index b454ae346..04b4b9d2f 100644 --- a/packages/pen-mcp/src/routes/element-tool-defs.ts +++ b/packages/pen-mcp/src/routes/element-tool-defs.ts @@ -132,6 +132,16 @@ import { handleAddAttachmentRowV1 } from '../tools/add-attachment-row-v1'; import { handleAddCalendarGridV1 } from '../tools/add-calendar-grid-v1'; import { handleAddAvatarGroupV1 } from '../tools/add-avatar-group-v1'; import { handleAddCalloutV1 } from '../tools/add-callout-v1'; +import { handleAddChartBarsV1 } from '../tools/add-chart-bars-v1'; +import { handleAddChartLineV1 } from '../tools/add-chart-line-v1'; +import { handleAddChartPieV1 } from '../tools/add-chart-pie-v1'; +import { handleAddChatBubbleV1 } from '../tools/add-chat-bubble-v1'; +import { handleAddCheckboxV1 } from '../tools/add-checkbox-v1'; +import { handleAddChipInputV1 } from '../tools/add-chip-input-v1'; +import { handleAddCodeBlockV1 } from '../tools/add-code-block-v1'; +import { handleAddColorSwatchV1 } from '../tools/add-color-swatch-v1'; +import { handleAddComboboxV1 } from '../tools/add-combobox-v1'; +import { handleAddCommentV1 } from '../tools/add-comment-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'; @@ -402,6 +412,26 @@ async function dispatchElementToolCall(name: string, a: any): Promise { return JSON.stringify(await handleAddAvatarGroupV1(a), null, 2); case 'add_callout_v1': return JSON.stringify(await handleAddCalloutV1(a), null, 2); + case 'add_chart_bars_v1': + return JSON.stringify(await handleAddChartBarsV1(a), null, 2); + case 'add_chart_line_v1': + return JSON.stringify(await handleAddChartLineV1(a), null, 2); + case 'add_chart_pie_v1': + return JSON.stringify(await handleAddChartPieV1(a), null, 2); + case 'add_chat_bubble_v1': + return JSON.stringify(await handleAddChatBubbleV1(a), null, 2); + case 'add_checkbox_v1': + return JSON.stringify(await handleAddCheckboxV1(a), null, 2); + case 'add_chip_input_v1': + return JSON.stringify(await handleAddChipInputV1(a), null, 2); + case 'add_code_block_v1': + return JSON.stringify(await handleAddCodeBlockV1(a), null, 2); + case 'add_color_swatch_v1': + return JSON.stringify(await handleAddColorSwatchV1(a), null, 2); + case 'add_combobox_v1': + return JSON.stringify(await handleAddComboboxV1(a), null, 2); + case 'add_comment_v1': + return JSON.stringify(await handleAddCommentV1(a), null, 2); default: return ''; } diff --git a/packages/pen-mcp/src/tools/add-chart-bars-v1.ts b/packages/pen-mcp/src/tools/add-chart-bars-v1.ts new file mode 100644 index 000000000..b72352fc4 --- /dev/null +++ b/packages/pen-mcp/src/tools/add-chart-bars-v1.ts @@ -0,0 +1,24 @@ +import { assignIdsRecursively, buildChartBarsV1, type ChartBarsV1Params } from '@zseven-w/pen-core'; +import type { handleBatchDesign } from './batch-design'; +import { ensureParentExists, insertElementTree } from './element-tool-helpers'; + +export interface AddChartBarsV1Params extends ChartBarsV1Params { + parent_id?: string; + filePath?: string; + pageId?: string; +} + +/** + * Bar-chart skeleton (v1) — theme-aware variant of add_chart_bars_v0. + * Supports 'light' (v0 byte-parity), 'dark', and 'system' theme modes. + * Bar color maps to chart-1 token in dark/system modes. + * Tree build delegated to `buildChartBarsV1` in `@zseven-w/pen-core`. + */ +export async function handleAddChartBarsV1( + params: AddChartBarsV1Params, +): Promise>> { + await ensureParentExists(params); + const tree = buildChartBarsV1(params); + assignIdsRecursively(tree); + return insertElementTree({ binding: 'cb', tree, ...params }); +} diff --git a/packages/pen-mcp/src/tools/add-chart-line-v1.ts b/packages/pen-mcp/src/tools/add-chart-line-v1.ts new file mode 100644 index 000000000..de281a34b --- /dev/null +++ b/packages/pen-mcp/src/tools/add-chart-line-v1.ts @@ -0,0 +1,24 @@ +import { assignIdsRecursively, buildChartLineV1, type ChartLineV1Params } from '@zseven-w/pen-core'; +import type { handleBatchDesign } from './batch-design'; +import { ensureParentExists, insertElementTree } from './element-tool-helpers'; + +export interface AddChartLineV1Params extends ChartLineV1Params { + parent_id?: string; + filePath?: string; + pageId?: string; +} + +/** + * Line-chart skeleton (v1) — theme-aware variant of add_chart_line_v0. + * Supports 'light' (v0 byte-parity), 'dark', and 'system' theme modes. + * Line/dot color maps to chart-1 token in dark/system modes. + * Tree build delegated to `buildChartLineV1` in `@zseven-w/pen-core`. + */ +export async function handleAddChartLineV1( + params: AddChartLineV1Params, +): Promise>> { + await ensureParentExists(params); + const tree = buildChartLineV1(params); + assignIdsRecursively(tree); + return insertElementTree({ binding: 'cl', tree, ...params }); +} diff --git a/packages/pen-mcp/src/tools/add-chart-pie-v1.ts b/packages/pen-mcp/src/tools/add-chart-pie-v1.ts new file mode 100644 index 000000000..6053013ee --- /dev/null +++ b/packages/pen-mcp/src/tools/add-chart-pie-v1.ts @@ -0,0 +1,25 @@ +import { assignIdsRecursively, buildChartPieV1, type ChartPieV1Params } from '@zseven-w/pen-core'; +import type { handleBatchDesign } from './batch-design'; +import { ensureParentExists, insertElementTree } from './element-tool-helpers'; + +export interface AddChartPieV1Params extends ChartPieV1Params { + parent_id?: string; + filePath?: string; + pageId?: string; +} + +/** + * Pie-chart skeleton (v1) — theme-aware variant of add_chart_pie_v0. + * Supports 'light' (v0 byte-parity), 'dark', and 'system' theme modes. + * Default slice palette maps to chart-1..6 tokens in dark/system modes. + * Caller-supplied `colors` are always passed through unchanged. + * Tree build delegated to `buildChartPieV1` in `@zseven-w/pen-core`. + */ +export async function handleAddChartPieV1( + params: AddChartPieV1Params, +): Promise>> { + await ensureParentExists(params); + const tree = buildChartPieV1(params); + assignIdsRecursively(tree); + return insertElementTree({ binding: 'cp', tree, ...params }); +} diff --git a/packages/pen-mcp/src/tools/add-chat-bubble-v1.ts b/packages/pen-mcp/src/tools/add-chat-bubble-v1.ts new file mode 100644 index 000000000..57baae2d1 --- /dev/null +++ b/packages/pen-mcp/src/tools/add-chat-bubble-v1.ts @@ -0,0 +1,28 @@ +import { + assignIdsRecursively, + buildChatBubbleV1, + type ChatBubbleV1Params, +} from '@zseven-w/pen-core'; +import type { handleBatchDesign } from './batch-design'; +import { ensureParentExists, insertElementTree } from './element-tool-helpers'; + +export interface AddChatBubbleV1Params extends ChatBubbleV1Params { + parent_id?: string; + filePath?: string; + pageId?: string; +} + +/** + * Chat bubble (v1) — theme-aware variant of add_chat_bubble_v0. + * Supports 'light' (v0 byte-parity), 'dark', and 'system' theme modes. + * Left-side: surface2 bg, textPrimary text. Right-side: accent bg, white text. + * Tree build delegated to `buildChatBubbleV1` in `@zseven-w/pen-core`. + */ +export async function handleAddChatBubbleV1( + params: AddChatBubbleV1Params, +): Promise>> { + await ensureParentExists(params); + const tree = buildChatBubbleV1(params); + assignIdsRecursively(tree); + return insertElementTree({ binding: 'cb', tree, ...params }); +} diff --git a/packages/pen-mcp/src/tools/add-checkbox-v1.ts b/packages/pen-mcp/src/tools/add-checkbox-v1.ts new file mode 100644 index 000000000..92e793468 --- /dev/null +++ b/packages/pen-mcp/src/tools/add-checkbox-v1.ts @@ -0,0 +1,24 @@ +import { assignIdsRecursively, buildCheckboxV1, type CheckboxV1Params } from '@zseven-w/pen-core'; +import type { handleBatchDesign } from './batch-design'; +import { ensureParentExists, insertElementTree } from './element-tool-helpers'; + +export interface AddCheckboxV1Params extends CheckboxV1Params { + parent_id?: string; + filePath?: string; + pageId?: string; +} + +/** + * Checkbox + label (v1) — theme-aware variant of add_checkbox_v0. + * Supports 'light' (v0 byte-parity), 'dark', and 'system' theme modes. + * Checked: accent fill + white icon. Unchecked: transparent + border stroke. + * Tree build delegated to `buildCheckboxV1` in `@zseven-w/pen-core`. + */ +export async function handleAddCheckboxV1( + params: AddCheckboxV1Params, +): Promise>> { + await ensureParentExists(params); + const tree = buildCheckboxV1(params); + assignIdsRecursively(tree); + return insertElementTree({ binding: 'ch', tree, ...params }); +} diff --git a/packages/pen-mcp/src/tools/add-chip-input-v1.ts b/packages/pen-mcp/src/tools/add-chip-input-v1.ts new file mode 100644 index 000000000..e41afb946 --- /dev/null +++ b/packages/pen-mcp/src/tools/add-chip-input-v1.ts @@ -0,0 +1,24 @@ +import { assignIdsRecursively, buildChipInputV1, type ChipInputV1Params } from '@zseven-w/pen-core'; +import type { handleBatchDesign } from './batch-design'; +import { ensureParentExists, insertElementTree } from './element-tool-helpers'; + +export interface AddChipInputV1Params extends ChipInputV1Params { + parent_id?: string; + filePath?: string; + pageId?: string; +} + +/** + * Chip / tag input (v1) — theme-aware variant of add_chip_input_v0. + * Supports 'light' (v0 byte-parity), 'dark', and 'system' theme modes. + * Chip bg → surface2, field bg → surface, border → border token. + * Tree build delegated to `buildChipInputV1` in `@zseven-w/pen-core`. + */ +export async function handleAddChipInputV1( + params: AddChipInputV1Params, +): Promise>> { + await ensureParentExists(params); + const tree = buildChipInputV1(params); + assignIdsRecursively(tree); + return insertElementTree({ binding: 'ci', tree, ...params }); +} diff --git a/packages/pen-mcp/src/tools/add-code-block-v1.ts b/packages/pen-mcp/src/tools/add-code-block-v1.ts new file mode 100644 index 000000000..220c3f37e --- /dev/null +++ b/packages/pen-mcp/src/tools/add-code-block-v1.ts @@ -0,0 +1,24 @@ +import { assignIdsRecursively, buildCodeBlockV1, type CodeBlockV1Params } from '@zseven-w/pen-core'; +import type { handleBatchDesign } from './batch-design'; +import { ensureParentExists, insertElementTree } from './element-tool-helpers'; + +export interface AddCodeBlockV1Params extends CodeBlockV1Params { + parent_id?: string; + filePath?: string; + pageId?: string; +} + +/** + * Preformatted code block (v1) — theme-aware variant of add_code_block_v0. + * Supports 'light' (v0 byte-parity), 'dark', and 'system' theme modes. + * Background (#F3F4F6) maps to surface2 token in dark/system modes. + * Tree build delegated to `buildCodeBlockV1` in `@zseven-w/pen-core`. + */ +export async function handleAddCodeBlockV1( + params: AddCodeBlockV1Params, +): Promise>> { + await ensureParentExists(params); + const tree = buildCodeBlockV1(params); + assignIdsRecursively(tree); + return insertElementTree({ binding: 'cod', tree, ...params }); +} diff --git a/packages/pen-mcp/src/tools/add-color-swatch-v1.ts b/packages/pen-mcp/src/tools/add-color-swatch-v1.ts new file mode 100644 index 000000000..956218164 --- /dev/null +++ b/packages/pen-mcp/src/tools/add-color-swatch-v1.ts @@ -0,0 +1,28 @@ +import { + assignIdsRecursively, + buildColorSwatchV1, + type ColorSwatchV1Params, +} from '@zseven-w/pen-core'; +import type { handleBatchDesign } from './batch-design'; +import { ensureParentExists, insertElementTree } from './element-tool-helpers'; + +export interface AddColorSwatchV1Params extends ColorSwatchV1Params { + parent_id?: string; + filePath?: string; + pageId?: string; +} + +/** + * Design-system color swatch (v1) — theme-aware variant of add_color_swatch_v0. + * Supports 'light', 'dark', and 'system' theme modes (all produce identical trees — + * the swatch color is caller-supplied and not tokenized). + * Tree build delegated to `buildColorSwatchV1` in `@zseven-w/pen-core`. + */ +export async function handleAddColorSwatchV1( + params: AddColorSwatchV1Params, +): Promise>> { + await ensureParentExists(params); + const tree = buildColorSwatchV1(params); + assignIdsRecursively(tree); + return insertElementTree({ binding: 'cs', tree, ...params }); +} diff --git a/packages/pen-mcp/src/tools/add-combobox-v1.ts b/packages/pen-mcp/src/tools/add-combobox-v1.ts new file mode 100644 index 000000000..55b68ffbe --- /dev/null +++ b/packages/pen-mcp/src/tools/add-combobox-v1.ts @@ -0,0 +1,24 @@ +import { assignIdsRecursively, buildComboboxV1, type ComboboxV1Params } from '@zseven-w/pen-core'; +import type { handleBatchDesign } from './batch-design'; +import { ensureParentExists, insertElementTree } from './element-tool-helpers'; + +export interface AddComboboxV1Params extends ComboboxV1Params { + parent_id?: string; + filePath?: string; + pageId?: string; +} + +/** + * Combobox / autocomplete (v1) — theme-aware variant of add_combobox_v0. + * Supports 'light' (v0 byte-parity), 'dark', and 'system' theme modes. + * Input/dropdown bg → surface, option hover → surface2, stroke → border/accent. + * Tree build delegated to `buildComboboxV1` in `@zseven-w/pen-core`. + */ +export async function handleAddComboboxV1( + params: AddComboboxV1Params, +): Promise>> { + await ensureParentExists(params); + const tree = buildComboboxV1(params); + assignIdsRecursively(tree); + return insertElementTree({ binding: 'cmb', tree, ...params }); +} diff --git a/packages/pen-mcp/src/tools/add-comment-v1.ts b/packages/pen-mcp/src/tools/add-comment-v1.ts new file mode 100644 index 000000000..e068fec14 --- /dev/null +++ b/packages/pen-mcp/src/tools/add-comment-v1.ts @@ -0,0 +1,24 @@ +import { assignIdsRecursively, buildCommentV1, type CommentV1Params } from '@zseven-w/pen-core'; +import type { handleBatchDesign } from './batch-design'; +import { ensureParentExists, insertElementTree } from './element-tool-helpers'; + +export interface AddCommentV1Params extends CommentV1Params { + parent_id?: string; + filePath?: string; + pageId?: string; +} + +/** + * Comment row (v1) — theme-aware variant of add_comment_v0. + * Supports 'light' (v0 byte-parity), 'dark', and 'system' theme modes. + * Avatar bg → surface2, initial text → textBody, timestamp → textMuted. + * Tree build delegated to `buildCommentV1` in `@zseven-w/pen-core`. + */ +export async function handleAddCommentV1( + params: AddCommentV1Params, +): Promise>> { + await ensureParentExists(params); + const tree = buildCommentV1(params); + assignIdsRecursively(tree); + return insertElementTree({ binding: 'cmt', tree, ...params }); +}