diff --git a/apps/web/server/api/mcp/exec-tool.post.ts b/apps/web/server/api/mcp/exec-tool.post.ts index c1266a59e..2e346b334 100644 --- a/apps/web/server/api/mcp/exec-tool.post.ts +++ b/apps/web/server/api/mcp/exec-tool.post.ts @@ -49,6 +49,7 @@ import { buildCookieBanner, buildSidebarNav, buildAvatarGroup, + buildDataTableRow, buildKbd, buildLink, buildListRow, @@ -221,6 +222,7 @@ const SERVER_BUILDERS: Record = { add_cookie_banner_v0: (a) => buildCookieBanner(a as Parameters[0]), add_sidebar_nav_v0: (a) => buildSidebarNav(a as Parameters[0]), add_avatar_group_v0: (a) => buildAvatarGroup(a as Parameters[0]), + add_data_table_row_v0: (a) => buildDataTableRow(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 76ff59cc7..b6638c342 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 { buildCookieBanner, buildSidebarNav, buildAvatarGroup, + buildDataTableRow, buildDatePicker, buildDivider, buildEmptyChart, @@ -558,6 +559,19 @@ const CASES: BuilderCase[] = [ }, build: (a) => buildAvatarGroup(a as unknown as Parameters[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[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 19df1598b..4cd319325 100644 --- a/apps/web/src/services/ai/element-tool-shims/index.ts +++ b/apps/web/src/services/ai/element-tool-shims/index.ts @@ -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 = { add_cookie_banner_v0: wrap(buildCookieBanner), add_sidebar_nav_v0: wrap(buildSidebarNav), add_avatar_group_v0: wrap(buildAvatarGroup), + add_data_table_row_v0: wrap(buildDataTableRow), }; 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 87193fb9e..42d07e953 100644 --- a/packages/pen-ai-skills/skills/phases/generation/elements.md +++ b/packages/pen-ai-skills/skills/phases/generation/elements.md @@ -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 diff --git a/packages/pen-core/src/element-builders/data-table-row.ts b/packages/pen-core/src/element-builders/data-table-row.ts new file mode 100644 index 000000000..cbfe4c8dd --- /dev/null +++ b/packages/pen-core/src/element-builders/data-table-row.ts @@ -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 }], + }, + ], + }; +} diff --git a/packages/pen-core/src/element-builders/index.ts b/packages/pen-core/src/element-builders/index.ts index be65ddc81..73c6eef6e 100644 --- a/packages/pen-core/src/element-builders/index.ts +++ b/packages/pen-core/src/element-builders/index.ts @@ -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'; diff --git a/packages/pen-core/src/index.ts b/packages/pen-core/src/index.ts index 9eaf01b07..04d0d67ca 100644 --- a/packages/pen-core/src/index.ts +++ b/packages/pen-core/src/index.ts @@ -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'; diff --git a/packages/pen-mcp/src/__tests__/add-data-table-row-v0.test.ts b/packages/pen-mcp/src/__tests__/add-data-table-row-v0.test.ts new file mode 100644 index 000000000..9ebf1cb81 --- /dev/null +++ b/packages/pen-mcp/src/__tests__/add-data-table-row-v0.test.ts @@ -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 { + const fp = join(TMP_DIR, name); + await writeFile(fp, EMPTY_DOC, '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 pageChildren = pages?.[0]?.children; + const topChildren = doc['children'] as Record[] | 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[]; + 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[])[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[]; + for (const cell of cells) { + expect(cell.role).toBe('data-table-header-cell'); + const text = (cell.children as Record[])[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); + }); +}); 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 a6019849f..d8d6b61fb 100644 --- a/packages/pen-mcp/src/__tests__/element-tools-contract.test.ts +++ b/packages/pen-mcp/src/__tests__/element-tools-contract.test.ts @@ -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', () => { diff --git a/packages/pen-mcp/src/routes/element-tool-defs-ext-2.ts b/packages/pen-mcp/src/routes/element-tool-defs-ext-2.ts index 74cdf1b05..43bcf3f76 100644 --- a/packages/pen-mcp/src/routes/element-tool-defs-ext-2.ts +++ b/packages/pen-mcp/src/routes/element-tool-defs-ext-2.ts @@ -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: diff --git a/packages/pen-mcp/src/routes/element-tool-defs.ts b/packages/pen-mcp/src/routes/element-tool-defs.ts index 9fa4986df..6b2513a96 100644 --- a/packages/pen-mcp/src/routes/element-tool-defs.ts +++ b/packages/pen-mcp/src/routes/element-tool-defs.ts @@ -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 { 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 ''; } diff --git a/packages/pen-mcp/src/tools/add-data-table-row-v0.ts b/packages/pen-mcp/src/tools/add-data-table-row-v0.ts new file mode 100644 index 000000000..9d6a145b2 --- /dev/null +++ b/packages/pen-mcp/src/tools/add-data-table-row-v0.ts @@ -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>> { + await ensureParentExists(params); + const row = buildDataTableRow(params); + assignIdsRecursively(row); + return insertElementTree({ binding: 'dataTableRow', tree: row, ...params }); +}