feat(ai): add_empty_chart_v1 — theme-aware no-data placeholder

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.
This commit is contained in:
Fini 2026-04-25 01:37:40 +08:00
parent aea75fa198
commit d1022e74d5
12 changed files with 384 additions and 0 deletions

View file

@ -43,6 +43,7 @@ import {
buildPricingCard,
buildToastV1,
buildRangeSlider,
buildEmptyChartV1,
buildKbd,
buildLink,
buildListRow,
@ -208,6 +209,7 @@ const SERVER_BUILDERS: Record<string, BuilderFn> = {
add_pricing_card_v0: (a) => buildPricingCard(a as Parameters<typeof buildPricingCard>[0]),
add_toast_v1: (a) => buildToastV1(a as Parameters<typeof buildToastV1>[0]),
add_range_slider_v0: (a) => buildRangeSlider(a as Parameters<typeof buildRangeSlider>[0]),
add_empty_chart_v1: (a) => buildEmptyChartV1(a as Parameters<typeof buildEmptyChartV1>[0]),
};
interface ExecToolBody {

View file

@ -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<typeof buildRangeSlider>[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<typeof buildEmptyChartV1>[0]),
},
];
/**

View file

@ -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<string, ElementShim> = {
add_pricing_card_v0: wrap<PricingCardParams>(buildPricingCard),
add_toast_v1: wrap<ToastV1Params>(buildToastV1),
add_range_slider_v0: wrap<RangeSliderParams>(buildRangeSlider),
add_empty_chart_v1: wrap<EmptyChartV1Params>(buildEmptyChartV1),
};
export function getElementShim(name: string): ElementShim | undefined {

View file

@ -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

View file

@ -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 }],
},
],
};
}

View file

@ -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';

View file

@ -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';

View file

@ -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<string> {
const fp = join(TMP, name);
await writeFile(fp, EMPTY, 'utf-8');
return fp;
}
async function readDoc(fp: string): Promise<Record<string, unknown>> {
return JSON.parse(await readFile(fp, 'utf-8'));
}
function getRoot(doc: Record<string, unknown>): Record<string, unknown> {
const pages = doc['pages'] as Array<{ children?: Record<string, unknown>[] }> | undefined;
const top = doc['children'] as Record<string, unknown>[] | undefined;
const root = (top ?? pages?.[0]?.children)?.[0];
if (!root) throw new Error('no root');
return root;
}
function findByRole(n: Record<string, unknown>, role: string): Record<string, unknown> | undefined {
if (n.role === role) return n;
const kids = (n.children ?? []) as Record<string, unknown>[];
for (const c of kids) {
const hit = findByRole(c, role);
if (hit) return hit;
}
return undefined;
}
function fillColor(n: Record<string, unknown> | undefined): string | undefined {
const fills = n?.fill as Array<{ color?: string }> | undefined;
return fills?.[0]?.color;
}
function strokeColor(n: Record<string, unknown>): 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<string, unknown> = {};
for (const [k, v] of Object.entries(n as Record<string, unknown>)) {
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<string, { enum?: unknown }> | 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);
});
});

View file

@ -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', () => {

View file

@ -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,
},
},
},
];

View file

@ -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<string> {
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 '';
}

View file

@ -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<Awaited<ReturnType<typeof handleBatchDesign>>> {
await ensureParentExists(params);
const e = buildEmptyChartV1(params);
assignIdsRecursively(e);
return insertElementTree({ binding: 'emptyChart', tree: e, ...params });
}