feat(ai): add_data_table_row_v0 — desktop tabular row (79th tool)

This commit is contained in:
Fini 2026-04-27 08:40:00 +08:00
parent 4442d8fe26
commit d24b587b6c
12 changed files with 368 additions and 1 deletions

View file

@ -49,6 +49,7 @@ import {
buildCookieBanner,
buildSidebarNav,
buildAvatarGroup,
buildDataTableRow,
buildKbd,
buildLink,
buildListRow,
@ -221,6 +222,7 @@ const SERVER_BUILDERS: Record<string, BuilderFn> = {
add_cookie_banner_v0: (a) => buildCookieBanner(a as Parameters<typeof buildCookieBanner>[0]),
add_sidebar_nav_v0: (a) => buildSidebarNav(a as Parameters<typeof buildSidebarNav>[0]),
add_avatar_group_v0: (a) => buildAvatarGroup(a as Parameters<typeof buildAvatarGroup>[0]),
add_data_table_row_v0: (a) => buildDataTableRow(a as Parameters<typeof buildDataTableRow>[0]),
};
interface ExecToolBody {

View file

@ -30,6 +30,7 @@ import {
buildCookieBanner,
buildSidebarNav,
buildAvatarGroup,
buildDataTableRow,
buildDatePicker,
buildDivider,
buildEmptyChart,
@ -558,6 +559,19 @@ const CASES: BuilderCase[] = [
},
build: (a) => buildAvatarGroup(a as unknown as Parameters<typeof buildAvatarGroup>[0]),
},
{
toolName: 'add_data_table_row_v0',
args: {
columns: [
{ content: 'Sarah' },
{ content: 'sarah@acme.com' },
{ content: 'Active' },
{ content: '$1,240' },
],
selected: true,
},
build: (a) => buildDataTableRow(a as unknown as Parameters<typeof buildDataTableRow>[0]),
},
];
/**

View file

@ -67,6 +67,7 @@ import {
buildCookieBanner,
buildSidebarNav,
buildAvatarGroup,
buildDataTableRow,
buildKbd,
buildLink,
buildListRow,
@ -146,6 +147,7 @@ import {
type CookieBannerParams,
type SidebarNavParams,
type AvatarGroupParams,
type DataTableRowParams,
type KbdParams,
type LinkParams,
type ListRowParams,
@ -344,6 +346,7 @@ export const ELEMENT_SHIMS: Record<string, ElementShim> = {
add_cookie_banner_v0: wrap<CookieBannerParams>(buildCookieBanner),
add_sidebar_nav_v0: wrap<SidebarNavParams>(buildSidebarNav),
add_avatar_group_v0: wrap<AvatarGroupParams>(buildAvatarGroup),
add_data_table_row_v0: wrap<DataTableRowParams>(buildDataTableRow),
};
export function getElementShim(name: string): ElementShim | undefined {

View file

@ -232,7 +232,11 @@ Presence / collaboration:
70. Stacked avatar group (team / online users / "+N more" tile, white-ringed circles in a tight horizontal row) → `add_avatar_group_v0`
71. None match → fall through to `batch_design`
Tabular data:
71. Desktop data-table row (N column-aligned cells in a single horizontal row, header / body / selected variants) → `add_data_table_row_v0`
72. None match → fall through to `batch_design`
**Disambiguation**: if you need a ROW of 3 metrics that should NOT scroll (e.g. a stats strip inside a card), use `add_stat_grid_v0`, NOT `add_metric_row_v0`. The grid uses `fill_container` per cell so it never overflows; the metric row uses fixed-px cells + scroll wrapper.
@ -316,6 +320,7 @@ PREFER an element tool when the spec says any of:
- "cookie banner", "cookie consent", "GDPR banner", "CCPA banner", "privacy notice", "cookie disclosure", "cookie 提示", "隐私同意条" → `add_cookie_banner_v0` (set `show_settings_link: true` for fine-grained GDPR consent UX). Caller positions sticky-bottom; the tool emits the banner card itself.
- "sidebar", "side nav", "sidebar nav", "left rail", "dashboard nav rail", "admin sidebar", "settings sidebar", "docs sidebar", "vertical nav", "侧边栏", "侧边导航", "左侧导航" → `add_sidebar_nav_v0` (desktop persistent rail; pass `title` for a brand row above the items, mark current page with `active: true` on the item). Different from `add_bottom_nav_v0` (mobile bottom tabs) and `add_top_nav_bar_v0` (mobile top header).
- "stacked avatars", "avatar group", "avatar stack", "team avatars", "5 contributors", "online users", "+N more", "viewers row", "presence indicator", "团队成员", "在线用户", "头像组" → `add_avatar_group_v0` (renders up to `max_visible` ringed avatar circles + a "+N" overflow tile; pen-core flex doesn't allow negative gap so the white ring + 4px gap is the affordance, not literal overlap). Different from `add_avatar_v0` which is a single tile.
- "data table row", "table row", "table header row", "customer row", "order row", "report row", "transaction row", "users table", "数据表行", "表格行", "表头行" → `add_data_table_row_v0` (desktop pattern; pass `header: true` for the column-header row, `selected: true` to tint a hover/selected body row). For row separators stack `add_divider_v0` between rows. Different from `add_list_row_v0` which is the iOS / mobile leading-icon list cell.
STILL use batch_design when:
@ -606,6 +611,33 @@ add_avatar_group_v0({
max_visible: 4,
}) // renders 4 ringed circles + "+3" overflow tile
add_avatar_group_v0({ items: [{ initial: "A" }, { initial: "B" }, { initial: "C" }], size: 24 }) // compact 3-up
add_data_table_row_v0({
header: true,
columns: [
{ content: "Customer" },
{ content: "Email" },
{ content: "Status" },
{ content: "Total" },
],
})
add_data_table_row_v0({
columns: [
{ content: "Sarah Lee" },
{ content: "sarah@acme.com" },
{ content: "Active" },
{ content: "$1,240" },
],
})
add_data_table_row_v0({
selected: true,
columns: [
{ content: "Alex Park" },
{ content: "alex@acme.com" },
{ content: "Pending" },
{ content: "$680" },
],
}) // tinted hover/selected row
```
## Composition pattern

View file

@ -0,0 +1,87 @@
import type { ElementTree } from './helpers.js';
export interface DataTableRowColumn {
content: string;
}
export interface DataTableRowParams {
columns: DataTableRowColumn[];
/** Render as the table header row (smaller, slate-500, slightly bolder). */
header?: boolean;
/** Mark as the currently selected/hover row (slate-50 fill). */
selected?: boolean;
}
/**
* Desktop / dashboard data-table row — N evenly-fill_container cells
* laid out horizontally with a 16px gap (no vertical dividers, matches
* modern Linear / Stripe styling). Distinct from `add_list_row_v0`,
* which is the iOS / mobile leading-icon + title-stack + trailing-
* chevron pattern.
*
* Header rows use 12/600 slate-500 typography and a 40px row height.
* Body rows use 14/400 slate-900 typography and a 48px row height,
* optionally tinted slate-50 when `selected` is true.
*
* For row separators stack `add_divider_v0` between successive rows;
* the tool does not emit its own bottom border.
*/
export function buildDataTableRow(params: DataTableRowParams): ElementTree {
const isHeader = params.header === true;
const isSelected = !isHeader && params.selected === true;
const cellTextSize = isHeader ? 12 : 14;
const cellTextWeight = isHeader ? 600 : 400;
const cellTextColor = isHeader ? '#64748B' : '#0F172A';
const rowHeight = isHeader ? 40 : 48;
const node: ElementTree = {
type: 'frame',
name: isHeader ? 'Data Table Header Row' : 'Data Table Row',
role: isHeader ? 'data-table-header-row' : 'data-table-row',
width: 'fill_container',
height: rowHeight,
layout: 'horizontal',
padding: [0, 16],
gap: 16,
alignItems: 'center',
children: params.columns.map((col, i) =>
buildCell(col, i, isHeader, cellTextSize, cellTextWeight, cellTextColor),
),
};
if (isSelected) {
node.fill = [{ type: 'solid', color: '#F8FAFC' }];
}
return node;
}
function buildCell(
col: DataTableRowColumn,
index: number,
isHeader: boolean,
size: number,
weight: number,
color: string,
): ElementTree {
return {
type: 'frame',
name: `Cell ${index + 1}`,
role: isHeader ? 'data-table-header-cell' : 'data-table-cell',
width: 'fill_container',
height: 'fit_content',
layout: 'horizontal',
alignItems: 'center',
children: [
{
type: 'text',
name: 'Cell Text',
role: isHeader ? 'data-table-header-text' : 'data-table-cell-text',
content: col.content,
fontSize: size,
fontWeight: weight,
fill: [{ type: 'solid', color }],
},
],
};
}

View file

@ -19,6 +19,11 @@ export { buildDivider, type DividerOrientation, type DividerParams } from './div
export { buildBadge, type BadgeParams } from './badge.js';
export { buildAvatar, type AvatarParams } from './avatar.js';
export { buildAvatarGroup, type AvatarGroupItem, type AvatarGroupParams } from './avatar-group.js';
export {
buildDataTableRow,
type DataTableRowColumn,
type DataTableRowParams,
} from './data-table-row.js';
export { buildIconButton, type IconButtonParams } from './icon-button.js';
export { buildIconLabel, type IconLabelParams } from './icon-label.js';
export { buildStatGrid, type StatGridItem, type StatGridParams } from './stat-grid.js';

View file

@ -265,6 +265,7 @@ export {
buildCookieBanner,
buildSidebarNav,
buildAvatarGroup,
buildDataTableRow,
cjkFontFamily,
detectCjkScript,
type ElementTree,
@ -371,4 +372,6 @@ export {
type SidebarNavParams,
type AvatarGroupItem,
type AvatarGroupParams,
type DataTableRowColumn,
type DataTableRowParams,
} from './element-builders/index.js';

View file

@ -0,0 +1,141 @@
/**
* Unit tests for add_data_table_row_v0 — desktop tabular row.
* Distinct surface from `add_list_row_v0` (iOS / mobile list cell).
*/
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 { DESIGN_TOOL_DEFINITIONS, DESIGN_TOOL_NAMES } from '../routes/design-routes';
import { handleAddDataTableRowV0 } from '../tools/add-data-table-row-v0';
import { invalidateCache } from '../document-manager';
const TMP_DIR = join(tmpdir(), 'openpencil-add-data-table-row-v0-tests');
const EMPTY_DOC = JSON.stringify({ version: '1.0.0', children: [] });
async function fresh(name: string): Promise<string> {
const fp = join(TMP_DIR, name);
await writeFile(fp, EMPTY_DOC, '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 pageChildren = pages?.[0]?.children;
const topChildren = doc['children'] as Record<string, unknown>[] | undefined;
const root = pageChildren?.[0] ?? topChildren?.[0];
if (!root) throw new Error('expected root');
return root;
}
beforeEach(async () => {
await mkdir(TMP_DIR, { recursive: true });
});
afterEach(async () => {
for (const f of ['body.op', 'header.op', 'selected.op', 'parent.op']) {
try {
const fp = join(TMP_DIR, f);
invalidateCache(fp);
await unlink(fp);
} catch {}
}
});
describe('add_data_table_row_v0 — registration', () => {
it('is registered in DESIGN_TOOL_DEFINITIONS + NAMES', () => {
expect(DESIGN_TOOL_DEFINITIONS.map((t) => t.name)).toContain('add_data_table_row_v0');
expect(DESIGN_TOOL_NAMES.has('add_data_table_row_v0')).toBe(true);
});
});
describe('add_data_table_row_v0 — structure', () => {
it('emits body row: data-table-row, height=48, padding=[0,16], gap=16, no fill, slate-900 14/400 cells', async () => {
const fp = await fresh('body.op');
await handleAddDataTableRowV0({
filePath: fp,
columns: [{ content: 'Sarah' }, { content: 'sarah@acme.com' }, { content: 'Active' }],
});
const row = getRoot(await readDoc(fp));
expect(row.role).toBe('data-table-row');
expect(row.height).toBe(48);
expect(row.padding).toEqual([0, 16]);
expect(row.gap).toBe(16);
expect(row.layout).toBe('horizontal');
expect(row.fill).toBeUndefined();
const cells = row.children as Record<string, unknown>[];
expect(cells.length).toBe(3);
for (const cell of cells) {
expect(cell.role).toBe('data-table-cell');
expect(cell.width).toBe('fill_container');
const text = (cell.children as Record<string, unknown>[])[0];
expect(text.role).toBe('data-table-cell-text');
expect(text.fontSize).toBe(14);
expect(text.fontWeight).toBe(400);
expect((text.fill as Array<{ color: string }>)[0].color).toBe('#0F172A');
}
});
it('emits header row: data-table-header-row, height=40, slate-500 12/600 cells', async () => {
const fp = await fresh('header.op');
await handleAddDataTableRowV0({
filePath: fp,
header: true,
columns: [{ content: 'Customer' }, { content: 'Status' }],
});
const row = getRoot(await readDoc(fp));
expect(row.role).toBe('data-table-header-row');
expect(row.height).toBe(40);
expect(row.fill).toBeUndefined();
const cells = row.children as Record<string, unknown>[];
for (const cell of cells) {
expect(cell.role).toBe('data-table-header-cell');
const text = (cell.children as Record<string, unknown>[])[0];
expect(text.role).toBe('data-table-header-text');
expect(text.fontSize).toBe(12);
expect(text.fontWeight).toBe(600);
expect((text.fill as Array<{ color: string }>)[0].color).toBe('#64748B');
}
});
it('selected body row is tinted slate-50; selected on header is ignored', async () => {
const fp = await fresh('selected.op');
await handleAddDataTableRowV0({
filePath: fp,
selected: true,
columns: [{ content: 'Alex' }, { content: 'Pending' }],
});
const tinted = getRoot(await readDoc(fp));
expect((tinted.fill as Array<{ color: string }>)[0].color).toBe('#F8FAFC');
invalidateCache(fp);
await writeFile(fp, EMPTY_DOC, 'utf-8');
await handleAddDataTableRowV0({
filePath: fp,
header: true,
selected: true,
columns: [{ content: 'Customer' }],
});
const headerRow = getRoot(await readDoc(fp));
expect(headerRow.fill).toBeUndefined();
});
it('throws on bogus parent_id AND leaves file untouched (side-effect invariant)', async () => {
const fp = await fresh('parent.op');
const before = await readFile(fp, 'utf-8');
await expect(
handleAddDataTableRowV0({
filePath: fp,
columns: [{ content: 'X' }],
parent_id: 'bogus-parent',
}),
).rejects.toThrow(/parent_id.*not found/);
const after = await readFile(fp, 'utf-8');
expect(after).toBe(before);
});
});

View file

@ -87,6 +87,7 @@ const ELEMENT_TOOL_NAMES = [
'add_cookie_banner_v0',
'add_sidebar_nav_v0',
'add_avatar_group_v0',
'add_data_table_row_v0',
];
describe('element tools — v0-MUST contract', () => {

View file

@ -479,6 +479,52 @@ export const ELEMENT_TOOL_DEFINITIONS_EXT_2 = [
required: ['label'],
},
},
{
name: 'add_data_table_row_v0',
description:
'Desktop / dashboard data-table row — N evenly-spaced cells laid out horizontally (no ' +
'vertical dividers, modern Linear / Stripe styling). Set `header: true` for the column-' +
'header row (smaller, bolder, slate-500). Set `selected: true` on a body row to tint it ' +
'slate-50. For row separators stack `add_divider_v0` between successive rows. Distinct ' +
'from add_list_row_v0 (iOS / mobile leading-icon list cell). Use for "table row", "data ' +
'row", "customer row", "order row", "table header", "数据表行", "表格行". ' +
'schemaVersion 1.0',
inputSchema: {
type: 'object' as const,
properties: {
schemaVersion: schemaVersionProp,
filePath: filePathProp,
columns: {
type: 'array',
description:
'Cells in column order. Each cell renders as a fill_container frame so all columns ' +
'share remaining width evenly.',
items: {
type: 'object',
properties: {
content: {
type: 'string',
description: 'Cell text (e.g. "Sarah Lee", "$1,240", "Active").',
},
},
required: ['content'],
},
},
header: {
type: 'boolean',
description: 'Render as the table header row (12/600 slate-500, 40px tall).',
},
selected: {
type: 'boolean',
description:
'Tint a body row slate-50 to mark hover / selected state. Ignored on header.',
},
parent_id: parentIdProp,
pageId: pageIdProp,
},
required: ['columns'],
},
},
{
name: 'add_avatar_group_v0',
description:

View file

@ -93,6 +93,7 @@ import { handleAddInputWithActionV0 } from '../tools/add-input-with-action-v0';
import { handleAddCookieBannerV0 } from '../tools/add-cookie-banner-v0';
import { handleAddSidebarNavV0 } from '../tools/add-sidebar-nav-v0';
import { handleAddAvatarGroupV0 } from '../tools/add-avatar-group-v0';
import { handleAddDataTableRowV0 } from '../tools/add-data-table-row-v0';
import { recordElementToolCall } from '../metrics/element-tool-metrics';
import { ELEMENT_TOOL_DEFINITIONS_BASE } from './element-tool-defs-base';
import { ELEMENT_TOOL_DEFINITIONS_EXT } from './element-tool-defs-ext';
@ -281,6 +282,8 @@ async function dispatchElementToolCall(name: string, a: any): Promise<string> {
return JSON.stringify(await handleAddSidebarNavV0(a), null, 2);
case 'add_avatar_group_v0':
return JSON.stringify(await handleAddAvatarGroupV0(a), null, 2);
case 'add_data_table_row_v0':
return JSON.stringify(await handleAddDataTableRowV0(a), null, 2);
default:
return '';
}

View file

@ -0,0 +1,30 @@
import {
assignIdsRecursively,
buildDataTableRow,
type DataTableRowParams,
} from '@zseven-w/pen-core';
import type { handleBatchDesign } from './batch-design';
import { ensureParentExists, insertElementTree } from './element-tool-helpers';
export interface AddDataTableRowV0Params extends DataTableRowParams {
parent_id?: string;
filePath?: string;
pageId?: string;
}
export type { DataTableRowColumn as AddDataTableRowV0Column } from '@zseven-w/pen-core';
/**
* Desktop / dashboard data-table row. Tree build delegated to
* `@zseven-w/pen-core`'s `buildDataTableRow`. Distinct from
* `add_list_row_v0` (mobile / iOS list cell) — use this for tabular
* customer / order / report data with column-aligned cells.
*/
export async function handleAddDataTableRowV0(
params: AddDataTableRowV0Params,
): Promise<Awaited<ReturnType<typeof handleBatchDesign>>> {
await ensureParentExists(params);
const row = buildDataTableRow(params);
assignIdsRecursively(row);
return insertElementTree({ binding: 'dataTableRow', tree: row, ...params });
}