From d1022e74d5e608fdc1820b06996e42646fcdded0 Mon Sep 17 00:00:00 2001 From: Fini Date: Sat, 25 Apr 2026 01:37:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai):=20add=5Fempty=5Fchart=5Fv1=20?= =?UTF-8?q?=E2=80=94=20theme-aware=20no-data=20placeholder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third theme-aware v1 (after add_modal_shell_v1 and add_toast_v1). Same dashed-border "no data yet" tile shape as v0 with a `theme` param that swaps 5 colors (bg / border / icon / title / subtitle): - light (default): byte-parity with v0 (slate-50 / slate-300) - dark: slate-800 bg + slate-600 border + slate-200 title - system: $color-surface-2 / $color-border / $color-text-primary refs — requires applySemanticPalette(doc) seeded Lets dark-theme dashboards keep a matching empty-slot surface instead of punching a light rectangle out of dark cards. --- apps/web/server/api/mcp/exec-tool.post.ts | 2 + .../ai/__tests__/shim-server-parity.test.ts | 6 + .../services/ai/element-tool-shims/index.ts | 3 + .../skills/phases/generation/elements.md | 5 + .../src/element-builders/empty-chart-v1.ts | 146 ++++++++++++++++++ .../pen-core/src/element-builders/index.ts | 5 + packages/pen-core/src/index.ts | 3 + .../src/__tests__/add-empty-chart-v1.test.ts | 144 +++++++++++++++++ .../__tests__/element-tools-contract.test.ts | 1 + .../src/routes/element-tool-defs-ext-3.ts | 38 +++++ .../pen-mcp/src/routes/element-tool-defs.ts | 3 + .../pen-mcp/src/tools/add-empty-chart-v1.ts | 28 ++++ 12 files changed, 384 insertions(+) create mode 100644 packages/pen-core/src/element-builders/empty-chart-v1.ts create mode 100644 packages/pen-mcp/src/__tests__/add-empty-chart-v1.test.ts create mode 100644 packages/pen-mcp/src/tools/add-empty-chart-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 e17bc51c2..bfb6db4c5 100644 --- a/apps/web/server/api/mcp/exec-tool.post.ts +++ b/apps/web/server/api/mcp/exec-tool.post.ts @@ -43,6 +43,7 @@ import { buildPricingCard, buildToastV1, buildRangeSlider, + buildEmptyChartV1, buildKbd, buildLink, buildListRow, @@ -208,6 +209,7 @@ const SERVER_BUILDERS: Record = { add_pricing_card_v0: (a) => buildPricingCard(a as Parameters[0]), add_toast_v1: (a) => buildToastV1(a as Parameters[0]), add_range_slider_v0: (a) => buildRangeSlider(a as Parameters[0]), + add_empty_chart_v1: (a) => buildEmptyChartV1(a as Parameters[0]), }; interface ExecToolBody { diff --git a/apps/web/src/services/ai/__tests__/shim-server-parity.test.ts b/apps/web/src/services/ai/__tests__/shim-server-parity.test.ts index 0f48f49d4..d8d3bda54 100644 --- a/apps/web/src/services/ai/__tests__/shim-server-parity.test.ts +++ b/apps/web/src/services/ai/__tests__/shim-server-parity.test.ts @@ -30,6 +30,7 @@ import { buildDatePicker, buildDivider, buildEmptyChart, + buildEmptyChartV1, buildEmptyState, buildFab, buildFaqItem, @@ -507,6 +508,11 @@ const CASES: BuilderCase[] = [ args: { value: 40, label: 'Volume', show_value: true, value_suffix: '%' }, build: (a) => buildRangeSlider(a as unknown as Parameters[0]), }, + { + toolName: 'add_empty_chart_v1', + args: { width: 320, height: 200, icon: 'line-chart', title: 'No data yet', theme: 'dark' }, + build: (a) => buildEmptyChartV1(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 0be9b8417..1b272d735 100644 --- a/apps/web/src/services/ai/element-tool-shims/index.ts +++ b/apps/web/src/services/ai/element-tool-shims/index.ts @@ -61,6 +61,7 @@ import { buildPricingCard, buildToastV1, buildRangeSlider, + buildEmptyChartV1, buildKbd, buildLink, buildListRow, @@ -134,6 +135,7 @@ import { type PricingCardParams, type ToastV1Params, type RangeSliderParams, + type EmptyChartV1Params, type KbdParams, type LinkParams, type ListRowParams, @@ -326,6 +328,7 @@ export const ELEMENT_SHIMS: Record = { add_pricing_card_v0: wrap(buildPricingCard), add_toast_v1: wrap(buildToastV1), add_range_slider_v0: wrap(buildRangeSlider), + add_empty_chart_v1: wrap(buildEmptyChartV1), }; 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 e9819ad9c..0beef609d 100644 --- a/packages/pen-ai-skills/skills/phases/generation/elements.md +++ b/packages/pen-ai-skills/skills/phases/generation/elements.md @@ -254,6 +254,7 @@ PREFER an element tool when the spec says any of: - "modal", "dialog", "popup", "confirm dialog", "模态框", "弹窗" → `add_modal_shell_v0` - "dark modal", "dark-mode dialog", "theme-aware modal", "system theme modal", "暗色弹窗", "主题感知弹窗" → `add_modal_shell_v1` (accepts `theme` param; use `"system"` when the document has `applySemanticPalette(doc)` seeded) - "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` - "spinner", "loading spinner", "progress circle", "loader", "加载圈" → `add_spinner_v0` - "tooltip", "hover hint", "help tip", "提示浮层" → `add_tooltip_v0` @@ -543,6 +544,10 @@ add_toast_v1({ message: "Changes saved", theme: "system" }) // add_range_slider_v0({ value: 60, label: "Volume", show_value: true, value_suffix: "%" }) add_range_slider_v0({ value: 128, min: 0, max: 255, label: "Brightness", show_value: true }) + +add_empty_chart_v1({ icon: "line-chart" }) // default light = v0 parity +add_empty_chart_v1({ icon: "pie-chart", theme: "dark" }) // dashboard dark-mode "no data" slot +add_empty_chart_v1({ icon: "bar-chart-2", theme: "system" }) // $color-* refs — requires applySemanticPalette(doc) seeded ``` ## Composition pattern diff --git a/packages/pen-core/src/element-builders/empty-chart-v1.ts b/packages/pen-core/src/element-builders/empty-chart-v1.ts new file mode 100644 index 000000000..d0660815e --- /dev/null +++ b/packages/pen-core/src/element-builders/empty-chart-v1.ts @@ -0,0 +1,146 @@ +import type { ElementTree } from './helpers.js'; + +export type EmptyChartV1Theme = 'light' | 'dark' | 'system'; + +export interface EmptyChartV1Params { + /** Width in px. Default 320. Min 120. */ + width?: number; + /** Height in px. Default 200. Min 100. */ + height?: number; + /** Title shown beneath the icon. Default "No data yet". */ + title?: string; + /** Subtitle / hint. Default "Data will appear here once tracking begins." */ + subtitle?: string; + /** Icon above the title. Default "bar-chart-2". Use "line-chart" / "pie-chart" to hint the widget type. */ + icon?: string; + /** Corner radius. Default 12. */ + corner_radius?: number; + /** + * Theme variant. Default `'light'` — byte-parity with + * `add_empty_chart_v0` so callers upgrade by changing the tool + * name only. + * + * - `'light'`: v0 parity (slate-50 bg, slate-300 dashed border, + * slate-400 icon, slate-700 title, slate-500 subtitle) + * - `'dark'`: hardcoded dark-mode hex (slate-800 bg, slate-600 + * dashed border, slate-400 icon, slate-200 title, slate-400 + * subtitle) + * - `'system'`: emits `$color-surface-2` bg / `$color-border` + * dashed stroke / `$color-text-muted` icon+subtitle / `$color-text-primary` + * title refs; render tracks `themes.Mode` at paint time. Caller + * MUST have seeded the document with `applySemanticPalette()`. + */ + theme?: EmptyChartV1Theme; +} + +interface ResolvedColors { + bg: string; + border: string; + icon: string; + title: string; + subtitle: string; +} + +function resolveTheme(theme: EmptyChartV1Theme): ResolvedColors { + if (theme === 'system') { + return { + bg: '$color-surface-2', + border: '$color-border', + icon: '$color-text-muted', + title: '$color-text-primary', + subtitle: '$color-text-muted', + }; + } + if (theme === 'dark') { + return { + bg: '#1E293B', // slate-800 — surface-2 Dark + border: '#475569', // slate-600 — border Dark + icon: '#94A3B8', // slate-400 — text-muted Dark + title: '#E2E8F0', // slate-200 — text-primary Dark + subtitle: '#94A3B8', // slate-400 — text-muted Dark + }; + } + // Default: light — byte-parity with v0 + return { + bg: '#F8FAFC', + border: '#CBD5E1', + icon: '#94A3B8', + title: '#334155', + subtitle: '#64748B', + }; +} + +/** + * Theme-aware empty-chart placeholder (v1). Same dashed-border tile + * shape as `add_empty_chart_v0` with an added `theme` param that + * swaps the 5 colors (bg, border, icon, title, subtitle) while + * preserving layout. Use inside dark-theme dashboards so the empty + * slot doesn't punch a light rectangle out of the surrounding + * cards. + * + * v0 parity: `buildEmptyChartV1({ ..., theme: 'light' })` (or theme + * omitted) produces a tree byte-equivalent to v0 output, modulo ids. + */ +export function buildEmptyChartV1(params: EmptyChartV1Params): ElementTree { + const width = Math.max(120, Math.floor(params.width ?? 320)); + const height = Math.max(100, Math.floor(params.height ?? 200)); + const cornerRadius = Math.max(0, Math.floor(params.corner_radius ?? 12)); + const title = params.title ?? 'No data yet'; + const subtitle = params.subtitle ?? 'Data will appear here once tracking begins.'; + const icon = params.icon ?? 'bar-chart-2'; + const theme = params.theme ?? 'light'; + const c = resolveTheme(theme); + + return { + type: 'frame', + name: 'Empty Chart', + role: 'empty-chart', + width, + height, + cornerRadius, + layout: 'vertical', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + paddingTop: 24, + paddingBottom: 24, + paddingLeft: 24, + paddingRight: 24, + fill: [{ type: 'solid', color: c.bg }], + stroke: { + thickness: 1, + fill: [{ type: 'solid', color: c.border }], + strokeDashArray: [4, 4], + }, + children: [ + { + type: 'icon_font', + name: 'Icon', + role: 'empty-chart-icon', + iconFontName: icon, + iconFontFamily: 'lucide', + width: 40, + height: 40, + fill: [{ type: 'solid', color: c.icon }], + }, + { + type: 'text', + name: 'Title', + role: 'empty-chart-title', + content: title, + fontSize: 14, + fontWeight: 600, + fill: [{ type: 'solid', color: c.title }], + }, + { + type: 'text', + name: 'Subtitle', + role: 'empty-chart-subtitle', + content: subtitle, + fontSize: 12, + fontWeight: 400, + fill: [{ type: 'solid', color: c.subtitle }], + }, + ], + }; +} diff --git a/packages/pen-core/src/element-builders/index.ts b/packages/pen-core/src/element-builders/index.ts index 96bc9cad0..9ecc5cfe5 100644 --- a/packages/pen-core/src/element-builders/index.ts +++ b/packages/pen-core/src/element-builders/index.ts @@ -92,3 +92,8 @@ export { export { buildPricingCard, type PricingCardParams } from './pricing-card.js'; export { buildToastV1, type ToastV1Params, type ToastV1Theme } from './toast-v1.js'; export { buildRangeSlider, type RangeSliderParams } from './range-slider.js'; +export { + buildEmptyChartV1, + type EmptyChartV1Params, + type EmptyChartV1Theme, +} from './empty-chart-v1.js'; diff --git a/packages/pen-core/src/index.ts b/packages/pen-core/src/index.ts index f6848def2..4db28c390 100644 --- a/packages/pen-core/src/index.ts +++ b/packages/pen-core/src/index.ts @@ -259,6 +259,7 @@ export { buildPricingCard, buildToastV1, buildRangeSlider, + buildEmptyChartV1, cjkFontFamily, detectCjkScript, type ElementTree, @@ -355,4 +356,6 @@ export { type ToastV1Params, type ToastV1Theme, type RangeSliderParams, + type EmptyChartV1Params, + type EmptyChartV1Theme, } from './element-builders/index.js'; diff --git a/packages/pen-mcp/src/__tests__/add-empty-chart-v1.test.ts b/packages/pen-mcp/src/__tests__/add-empty-chart-v1.test.ts new file mode 100644 index 000000000..25c5a0df1 --- /dev/null +++ b/packages/pen-mcp/src/__tests__/add-empty-chart-v1.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { writeFile, unlink, readFile, mkdir } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { buildEmptyChart, buildEmptyChartV1 } from '@zseven-w/pen-core'; +import { DESIGN_TOOL_DEFINITIONS, DESIGN_TOOL_NAMES } from '../routes/design-routes'; +import { handleAddEmptyChartV1 } from '../tools/add-empty-chart-v1'; +import { invalidateCache } from '../document-manager'; + +const TMP = join(tmpdir(), 'openpencil-add-empty-chart-v1'); +const EMPTY = JSON.stringify({ version: '1.0.0', children: [] }); + +async function fresh(name: string): Promise { + const fp = join(TMP, name); + await writeFile(fp, EMPTY, 'utf-8'); + return fp; +} +async function readDoc(fp: string): Promise> { + return JSON.parse(await readFile(fp, 'utf-8')); +} +function getRoot(doc: Record): Record { + const pages = doc['pages'] as Array<{ children?: Record[] }> | undefined; + const top = doc['children'] as Record[] | undefined; + const root = (top ?? pages?.[0]?.children)?.[0]; + if (!root) throw new Error('no root'); + return root; +} +function findByRole(n: Record, role: string): Record | undefined { + if (n.role === role) return n; + const kids = (n.children ?? []) as Record[]; + for (const c of kids) { + const hit = findByRole(c, role); + if (hit) return hit; + } + return undefined; +} +function fillColor(n: Record | undefined): string | undefined { + const fills = n?.fill as Array<{ color?: string }> | undefined; + return fills?.[0]?.color; +} +function strokeColor(n: Record): string | undefined { + const stroke = n.stroke as { fill?: Array<{ color?: string }> } | undefined; + return stroke?.fill?.[0]?.color; +} +function stripIds(n: unknown): unknown { + if (Array.isArray(n)) return n.map(stripIds); + if (n && typeof n === 'object') { + const out: Record = {}; + for (const [k, v] of Object.entries(n as Record)) { + if (k === 'id') continue; + out[k] = stripIds(v); + } + return out; + } + return n; +} + +beforeEach(async () => { + await mkdir(TMP, { recursive: true }); +}); +afterEach(async () => { + for (const f of ['e.op']) { + try { + const fp = join(TMP, f); + invalidateCache(fp); + await unlink(fp); + } catch {} + } +}); + +describe('add_empty_chart_v1', () => { + it('registered; no required fields (all defaults)', () => { + expect(DESIGN_TOOL_NAMES.has('add_empty_chart_v1')).toBe(true); + const def = DESIGN_TOOL_DEFINITIONS.find((t) => t.name === 'add_empty_chart_v1'); + const req = def?.inputSchema.required as string[] | undefined; + expect(req === undefined || req.length === 0).toBe(true); + }); + + it('schema exposes theme=["light","dark","system"]', () => { + const def = DESIGN_TOOL_DEFINITIONS.find((t) => t.name === 'add_empty_chart_v1'); + const props = def?.inputSchema.properties as Record | undefined; + expect(props?.theme?.enum).toEqual(['light', 'dark', 'system']); + }); + + it('default theme=light → slate-50 bg + slate-300 dashed border (v0 parity colors)', async () => { + const fp = await fresh('e.op'); + await handleAddEmptyChartV1({ filePath: fp }); + const root = getRoot(await readDoc(fp)); + expect(fillColor(root)).toBe('#F8FAFC'); + expect(strokeColor(root)).toBe('#CBD5E1'); + expect(fillColor(findByRole(root, 'empty-chart-title'))).toBe('#334155'); + }); + + it('theme=light is byte-parity with buildEmptyChart v0 (modulo ids)', () => { + const args = { width: 320, height: 200, title: 'Empty', icon: 'line-chart' }; + const v0 = stripIds(buildEmptyChart(args)); + const v1 = stripIds(buildEmptyChartV1({ ...args, theme: 'light' })); + expect(v1).toEqual(v0); + }); + + it('theme=dark → slate-800 bg, slate-200 title, slate-400 subtitle', async () => { + const fp = await fresh('e.op'); + await handleAddEmptyChartV1({ filePath: fp, theme: 'dark' }); + const root = getRoot(await readDoc(fp)); + expect(fillColor(root)).toBe('#1E293B'); + expect(strokeColor(root)).toBe('#475569'); + expect(fillColor(findByRole(root, 'empty-chart-title'))).toBe('#E2E8F0'); + expect(fillColor(findByRole(root, 'empty-chart-subtitle'))).toBe('#94A3B8'); + }); + + it('theme=system → $color-* refs for bg + border + title + subtitle', async () => { + const fp = await fresh('e.op'); + await handleAddEmptyChartV1({ filePath: fp, theme: 'system' }); + const root = getRoot(await readDoc(fp)); + expect(fillColor(root)).toBe('$color-surface-2'); + expect(strokeColor(root)).toBe('$color-border'); + expect(fillColor(findByRole(root, 'empty-chart-title'))).toBe('$color-text-primary'); + expect(fillColor(findByRole(root, 'empty-chart-subtitle'))).toBe('$color-text-muted'); + }); + + it('icon override works', async () => { + const fp = await fresh('e.op'); + await handleAddEmptyChartV1({ filePath: fp, icon: 'pie-chart', theme: 'dark' }); + const root = getRoot(await readDoc(fp)); + expect(findByRole(root, 'empty-chart-icon')!.iconFontName).toBe('pie-chart'); + }); + + it('dashed stroke preserved across themes (strokeDashArray=[4,4])', async () => { + const fp = await fresh('e.op'); + await handleAddEmptyChartV1({ filePath: fp, theme: 'system' }); + const root = getRoot(await readDoc(fp)); + const stroke = root.stroke as { strokeDashArray?: number[] }; + expect(stroke.strokeDashArray).toEqual([4, 4]); + }); + + it('throws on bogus parent_id AND leaves file untouched', async () => { + const fp = await fresh('e.op'); + const before = await readFile(fp, 'utf-8'); + await expect(handleAddEmptyChartV1({ filePath: fp, parent_id: 'nope' })).rejects.toThrow( + /parent_id.*not found/, + ); + expect(await readFile(fp, 'utf-8')).toBe(before); + }); +}); 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 8c9cc46c8..b9e38999c 100644 --- a/packages/pen-mcp/src/__tests__/element-tools-contract.test.ts +++ b/packages/pen-mcp/src/__tests__/element-tools-contract.test.ts @@ -81,6 +81,7 @@ const ELEMENT_TOOL_NAMES = [ 'add_pricing_card_v0', 'add_toast_v1', 'add_range_slider_v0', + 'add_empty_chart_v1', ]; describe('element tools — v0-MUST contract', () => { diff --git a/packages/pen-mcp/src/routes/element-tool-defs-ext-3.ts b/packages/pen-mcp/src/routes/element-tool-defs-ext-3.ts index ec3f60806..5fa746243 100644 --- a/packages/pen-mcp/src/routes/element-tool-defs-ext-3.ts +++ b/packages/pen-mcp/src/routes/element-tool-defs-ext-3.ts @@ -569,4 +569,42 @@ export const ELEMENT_TOOL_DEFINITIONS_EXT_3 = [ }, }, }, + { + name: 'add_empty_chart_v1', + description: + 'Theme-aware variant of add_empty_chart_v0 — same dashed-border "no data yet" tile shape with ' + + 'an added `theme` param controlling 5 colors (bg, border, icon, title, subtitle). ' + + 'theme="light" (default): byte-parity with v0 (slate-50 bg, slate-300 dashed border). ' + + 'theme="dark": hardcoded dark-mode palette (slate-800 bg, slate-600 border, slate-200 title) — ' + + 'use inside dark-theme dashboards so the empty slot matches surrounding card surfaces. ' + + 'theme="system": emits $color-surface-2 bg / $color-border stroke / $color-text-muted + ' + + '$color-text-primary text refs; render tracks themes.Mode. REQUIRES `applySemanticPalette(doc)` ' + + 'seeded when theme="system". Use when the spec says "dark-mode empty chart", "empty state in ' + + 'dark dashboard", "theme-aware no-data placeholder". schemaVersion 1.0', + inputSchema: { + type: 'object' as const, + properties: { + schemaVersion: schemaVersionProp, + filePath: filePathProp, + width: { type: 'number', description: 'Width in px (default 320, min 120)' }, + height: { type: 'number', description: 'Height in px (default 200, min 100)' }, + title: { type: 'string', description: 'Headline above subtitle (default "No data yet")' }, + subtitle: { type: 'string', description: 'Hint beneath title' }, + icon: { + type: 'string', + description: + 'Lucide icon above title (default "bar-chart-2"; "line-chart" / "pie-chart" to match the widget it replaces)', + }, + corner_radius: { type: 'number', description: 'Corner radius (default 12)' }, + theme: { + type: 'string', + enum: ['light', 'dark', 'system'], + description: + 'Theme variant. "light" (default) = v0 parity. "dark" = hardcoded dark hex. "system" = $color-* refs; requires applySemanticPalette(doc).', + }, + parent_id: parentIdProp, + pageId: pageIdProp, + }, + }, + }, ]; diff --git a/packages/pen-mcp/src/routes/element-tool-defs.ts b/packages/pen-mcp/src/routes/element-tool-defs.ts index 94fb66017..9128a2362 100644 --- a/packages/pen-mcp/src/routes/element-tool-defs.ts +++ b/packages/pen-mcp/src/routes/element-tool-defs.ts @@ -87,6 +87,7 @@ import { handleAddSocialLoginRowV0 } from '../tools/add-social-login-row-v0'; import { handleAddPricingCardV0 } from '../tools/add-pricing-card-v0'; import { handleAddToastV1 } from '../tools/add-toast-v1'; import { handleAddRangeSliderV0 } from '../tools/add-range-slider-v0'; +import { handleAddEmptyChartV1 } from '../tools/add-empty-chart-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'; @@ -263,6 +264,8 @@ async function dispatchElementToolCall(name: string, a: any): Promise { return JSON.stringify(await handleAddToastV1(a), null, 2); case 'add_range_slider_v0': return JSON.stringify(await handleAddRangeSliderV0(a), null, 2); + case 'add_empty_chart_v1': + return JSON.stringify(await handleAddEmptyChartV1(a), null, 2); default: return ''; } diff --git a/packages/pen-mcp/src/tools/add-empty-chart-v1.ts b/packages/pen-mcp/src/tools/add-empty-chart-v1.ts new file mode 100644 index 000000000..31a788502 --- /dev/null +++ b/packages/pen-mcp/src/tools/add-empty-chart-v1.ts @@ -0,0 +1,28 @@ +import { + assignIdsRecursively, + buildEmptyChartV1, + type EmptyChartV1Params, +} from '@zseven-w/pen-core'; +import type { handleBatchDesign } from './batch-design'; +import { ensureParentExists, insertElementTree } from './element-tool-helpers'; + +export interface AddEmptyChartV1Params extends EmptyChartV1Params { + parent_id?: string; + filePath?: string; + pageId?: string; +} + +/** + * Empty chart placeholder (v1) — theme-aware variant of + * add_empty_chart_v0. Tree build delegated to `buildEmptyChartV1`. + * See the builder's JSDoc for the theme-variant contract + * (light / dark / system). + */ +export async function handleAddEmptyChartV1( + params: AddEmptyChartV1Params, +): Promise>> { + await ensureParentExists(params); + const e = buildEmptyChartV1(params); + assignIdsRecursively(e); + return insertElementTree({ binding: 'emptyChart', tree: e, ...params }); +}