From fa69986fc882670318e5ff60978bb63ea2d1f17a Mon Sep 17 00:00:00 2001 From: Fini Date: Wed, 22 Apr 2026 09:20:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai):=20add=5Fpagination=5Fv0=20=E2=80=94?= =?UTF-8?q?=20Google-style=20pagination=20bar=20(57th=20tool)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an N-tool for list/table footer pagination: row of page- number pills flanked by optional prev/next chevron buttons. Active page renders filled with the accent color, inactive pages are ghost. Long ranges collapse with "…" Google-style (always show 1 and total, plus a ±siblings window around current). Wired through all 3 paths: pen-core buildPagination + pen-mcp handler + apps/web browser shim + Nitro SERVER_BUILDERS. Both parity tests (shim-server-parity, element-tool-registry-parity) pick up the entry automatically. Handler test covers 7 cases: small range no ellipsis, 10-page ellipsis, start-edge current, accent override, show_arrows=false, total=1 single pill, bogus parent_id rejection. Also updates packages/pen-ai-skills/skills/phases/generation/ elements.md: decision tree §52, keyword triggers (pagination / page nav / 分页 / 分页条), minimal usage example. The stale- integration guard (design-prompt-elements.test.ts) now passes. --- 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 | 10 +- .../pen-core/src/element-builders/index.ts | 1 + .../src/element-builders/pagination.ts | 172 ++++++++++++++++++ packages/pen-core/src/index.ts | 2 + .../src/__tests__/add-pagination-v0.test.ts | 126 +++++++++++++ .../__tests__/element-tools-contract.test.ts | 1 + .../src/routes/element-tool-defs-ext.ts | 36 ++++ .../pen-mcp/src/routes/element-tool-defs.ts | 3 + .../pen-mcp/src/tools/add-pagination-v0.ts | 19 ++ 12 files changed, 380 insertions(+), 1 deletion(-) create mode 100644 packages/pen-core/src/element-builders/pagination.ts create mode 100644 packages/pen-mcp/src/__tests__/add-pagination-v0.test.ts create mode 100644 packages/pen-mcp/src/tools/add-pagination-v0.ts diff --git a/apps/web/server/api/mcp/exec-tool.post.ts b/apps/web/server/api/mcp/exec-tool.post.ts index 12282ca26..cfa37d66b 100644 --- a/apps/web/server/api/mcp/exec-tool.post.ts +++ b/apps/web/server/api/mcp/exec-tool.post.ts @@ -35,6 +35,7 @@ import { buildMetricRow, buildNavChipRow, buildNotificationRow, + buildPagination, buildPrice, buildProgressBar, buildQuoteBlock, @@ -174,6 +175,7 @@ const SERVER_BUILDERS: Record = { add_chart_bars_v0: (a) => buildChartBars(a as Parameters[0]), add_timeline_v0: (a) => buildTimeline(a as Parameters[0]), add_calendar_grid_v0: (a) => buildCalendarGrid(a as Parameters[0]), + add_pagination_v0: (a) => buildPagination(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 32c3a3404..f37300672 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 @@ -42,6 +42,7 @@ import { buildMetricRow, buildNavChipRow, buildNotificationRow, + buildPagination, buildPrice, buildProgressBar, buildQuoteBlock, @@ -393,6 +394,11 @@ const CASES: BuilderCase[] = [ args: {}, build: (a) => buildCalendarGrid(a as unknown as Parameters[0]), }, + { + toolName: 'add_pagination_v0', + args: { total: 10, current: 5 }, + build: (a) => buildPagination(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 32b0fde82..57f1c8315 100644 --- a/apps/web/src/services/ai/element-tool-shims/index.ts +++ b/apps/web/src/services/ai/element-tool-shims/index.ts @@ -53,6 +53,7 @@ import { buildMetricRow, buildNavChipRow, buildNotificationRow, + buildPagination, buildPrice, buildProgressBar, buildQuoteBlock, @@ -110,6 +111,7 @@ import { type MetricRowParams, type NavChipRowParams, type NotificationRowParams, + type PaginationParams, type PriceParams, type ProgressBarParams, type QuoteBlockParams, @@ -278,6 +280,7 @@ export const ELEMENT_SHIMS: Record = { add_chart_bars_v0: wrap(buildChartBars), add_timeline_v0: wrap(buildTimeline), add_calendar_grid_v0: wrap(buildCalendarGrid), + add_pagination_v0: wrap(buildPagination), }; 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 a5d210891..2184722ac 100644 --- a/packages/pen-ai-skills/skills/phases/generation/elements.md +++ b/packages/pen-ai-skills/skills/phases/generation/elements.md @@ -159,7 +159,11 @@ Loading / placeholder: 43. Loading skeleton (N gray rectangles, last row ~60% width) → `add_skeleton_v0` -44. None match → fall through to `batch_design` +Pagination: + +52. Pagination bar (numbered pills + prev/next arrows, Google-style ellipses for big ranges) → `add_pagination_v0` + +53. 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. @@ -221,6 +225,7 @@ PREFER an element tool when the spec says any of: - "bar chart", "histogram skeleton", "weekly steps", "柱状图" → `add_chart_bars_v0` - "timeline", "activity history", "vertical stepper", "时间线", "动态" → `add_timeline_v0` - "calendar", "date picker grid", "month view", "日历" → `add_calendar_grid_v0` +- "pagination", "page nav", "page numbers", "prev/next pages", "分页", "分页条" → `add_pagination_v0` STILL use batch_design when: @@ -414,6 +419,9 @@ add_timeline_v0({ add_calendar_grid_v0({}) // vanilla 30-day month, Sun-start add_calendar_grid_v0({ days_in_month: 31, start_day_offset: 2, today: 15, selected_day: 22 }) + +add_pagination_v0({ total: 10, current: 5 }) // 1 … 4 [5] 6 … 10 +add_pagination_v0({ total: 3, current: 1, show_arrows: false }) // no prev/next ``` ## Composition pattern diff --git a/packages/pen-core/src/element-builders/index.ts b/packages/pen-core/src/element-builders/index.ts index c919c027f..dee759fbe 100644 --- a/packages/pen-core/src/element-builders/index.ts +++ b/packages/pen-core/src/element-builders/index.ts @@ -68,3 +68,4 @@ export { buildColorSwatch, type ColorSwatchParams } from './color-swatch.js'; export { buildChartBars, type ChartBarsParams } from './chart-bars.js'; export { buildTimeline, type TimelineItem, type TimelineParams } from './timeline.js'; export { buildCalendarGrid, type CalendarGridParams } from './calendar-grid.js'; +export { buildPagination, type PaginationParams } from './pagination.js'; diff --git a/packages/pen-core/src/element-builders/pagination.ts b/packages/pen-core/src/element-builders/pagination.ts new file mode 100644 index 000000000..5a2a2fed9 --- /dev/null +++ b/packages/pen-core/src/element-builders/pagination.ts @@ -0,0 +1,172 @@ +import type { ElementTree } from './helpers.js'; + +export interface PaginationParams { + /** Total number of pages. Clamped to >= 1. */ + total: number; + /** 1-based index of the current page. Clamped to [1, total]. */ + current?: number; + /** + * How many pages to show on each side of `current` before collapsing + * the rest into an ellipsis. Default 1 → e.g. "1 … 4 [5] 6 … 10". + */ + siblings?: number; + /** Include arrow buttons on either end. Default true. */ + show_arrows?: boolean; + /** Accent color for the active page pill. Default slate-900. */ + accent_color?: string; +} + +/** + * Pagination bar for list / table screens: a row of page-number + * pills flanked by optional prev/next arrow buttons. The current + * page is rendered as a filled pill; all other pages are ghost + * (label only, no fill). Page-range collapse uses Google-style + * ellipses when there are too many pages to show inline. + * + * Structure: + * frame(horizontal, gap=4, alignItems=center, role='pagination') + * ├ [icon-button chevron-left] ← if show_arrows + * ├ pill(36×32) for first page if collapsed + * ├ text('…') if gap ← role='pagination-ellipsis' + * ├ pill for each page in window ← active one filled + * ├ text('…') if gap + * ├ pill for last page if collapsed + * └ [icon-button chevron-right] ← if show_arrows + * + * Pill: `frame(36×32, cornerRadius=6, layout=horizontal, center) + * > text(fontSize=13)`. Active pill fills with `accent_color` + * and text is white; inactive pill has no fill, text slate-700. + */ +export function buildPagination(params: PaginationParams): ElementTree { + const total = Math.max(1, Math.floor(params.total)); + const current = Math.max(1, Math.min(total, Math.floor(params.current ?? 1))); + const siblings = Math.max(0, Math.floor(params.siblings ?? 1)); + const showArrows = params.show_arrows !== false; + const accent = params.accent_color ?? '#0F172A'; + + // Build the visible-page window: always include 1 and total, plus a + // [current - siblings, current + siblings] band. Insert ellipses + // where there's a gap. + const pages = new Set([1, total, current]); + for (let i = 1; i <= siblings; i += 1) { + pages.add(current - i); + pages.add(current + i); + } + const sorted = Array.from(pages) + .filter((p) => p >= 1 && p <= total) + .sort((a, b) => a - b); + + type Entry = { kind: 'page'; n: number } | { kind: 'ellipsis' }; + const entries: Entry[] = []; + for (let i = 0; i < sorted.length; i += 1) { + entries.push({ kind: 'page', n: sorted[i] }); + const next = sorted[i + 1]; + if (next !== undefined && next > sorted[i] + 1) { + entries.push({ kind: 'ellipsis' }); + } + } + + const children: ElementTree[] = []; + + if (showArrows) { + children.push({ + type: 'frame', + name: 'Prev', + role: 'pagination-prev', + width: 32, + height: 32, + cornerRadius: 6, + layout: 'horizontal', + alignItems: 'center', + justifyContent: 'center', + fill: [], + children: [ + { + type: 'icon_font', + name: 'Prev Icon', + iconFontName: 'chevron-left', + iconFontFamily: 'lucide', + width: 16, + height: 16, + fill: [{ type: 'solid', color: '#334155' }], + }, + ], + }); + } + + for (const e of entries) { + if (e.kind === 'ellipsis') { + children.push({ + type: 'text', + name: 'Ellipsis', + role: 'pagination-ellipsis', + content: '…', + fontSize: 13, + fontWeight: 400, + fill: [{ type: 'solid', color: '#64748B' }], + }); + continue; + } + const isActive = e.n === current; + children.push({ + type: 'frame', + name: `Page ${e.n}`, + role: isActive ? 'pagination-page-active' : 'pagination-page', + width: 36, + height: 32, + cornerRadius: 6, + layout: 'horizontal', + alignItems: 'center', + justifyContent: 'center', + fill: isActive ? [{ type: 'solid', color: accent }] : [], + children: [ + { + type: 'text', + name: 'Label', + content: String(e.n), + fontSize: 13, + fontWeight: isActive ? 600 : 400, + fill: [{ type: 'solid', color: isActive ? '#FFFFFF' : '#334155' }], + }, + ], + }); + } + + if (showArrows) { + children.push({ + type: 'frame', + name: 'Next', + role: 'pagination-next', + width: 32, + height: 32, + cornerRadius: 6, + layout: 'horizontal', + alignItems: 'center', + justifyContent: 'center', + fill: [], + children: [ + { + type: 'icon_font', + name: 'Next Icon', + iconFontName: 'chevron-right', + iconFontFamily: 'lucide', + width: 16, + height: 16, + fill: [{ type: 'solid', color: '#334155' }], + }, + ], + }); + } + + return { + type: 'frame', + name: 'Pagination', + role: 'pagination', + width: 'fit_content', + height: 'fit_content', + layout: 'horizontal', + alignItems: 'center', + gap: 4, + children, + }; +} diff --git a/packages/pen-core/src/index.ts b/packages/pen-core/src/index.ts index 1d612273f..c10749ac1 100644 --- a/packages/pen-core/src/index.ts +++ b/packages/pen-core/src/index.ts @@ -230,6 +230,7 @@ export { buildChartBars, buildTimeline, buildCalendarGrid, + buildPagination, cjkFontFamily, detectCjkScript, type ElementTree, @@ -304,4 +305,5 @@ export { type TimelineItem, type TimelineParams, type CalendarGridParams, + type PaginationParams, } from './element-builders/index.js'; diff --git a/packages/pen-mcp/src/__tests__/add-pagination-v0.test.ts b/packages/pen-mcp/src/__tests__/add-pagination-v0.test.ts new file mode 100644 index 000000000..70265a7db --- /dev/null +++ b/packages/pen-mcp/src/__tests__/add-pagination-v0.test.ts @@ -0,0 +1,126 @@ +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 { handleAddPaginationV0 } from '../tools/add-pagination-v0'; +import { invalidateCache } from '../document-manager'; + +const TMP = join(tmpdir(), 'openpencil-add-pagination-v0'); +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; +} + +beforeEach(async () => { + await mkdir(TMP, { recursive: true }); +}); +afterEach(async () => { + for (const f of ['a.op']) { + try { + const fp = join(TMP, f); + invalidateCache(fp); + await unlink(fp); + } catch {} + } +}); + +describe('add_pagination_v0', () => { + it('registered; required=[total]', () => { + expect(DESIGN_TOOL_NAMES.has('add_pagination_v0')).toBe(true); + const def = DESIGN_TOOL_DEFINITIONS.find((t) => t.name === 'add_pagination_v0'); + expect(def?.inputSchema.required).toEqual(['total']); + }); + + it('small range (total=3) → all pages shown, no ellipsis, arrows present', async () => { + const fp = await fresh('a.op'); + await handleAddPaginationV0({ filePath: fp, total: 3, current: 2 }); + const p = getRoot(await readDoc(fp)); + expect(p.role).toBe('pagination'); + const kids = p.children as Record[]; + // prev + 3 pages + next = 5 + expect(kids.length).toBe(5); + expect(kids[0].role).toBe('pagination-prev'); + expect(kids[4].role).toBe('pagination-next'); + // middle pill is active + const active = kids[2]; + expect(active.role).toBe('pagination-page-active'); + const activeFill = active.fill as Array<{ color: string }>; + expect(activeFill[0].color).toBe('#0F172A'); + }); + + it('large range with ellipsis: total=10, current=5 → 1 … 4 [5] 6 … 10', async () => { + const fp = await fresh('a.op'); + await handleAddPaginationV0({ filePath: fp, total: 10, current: 5, show_arrows: false }); + const p = getRoot(await readDoc(fp)); + const kids = p.children as Record[]; + // no arrows → 1, ellipsis, 4, 5, 6, ellipsis, 10 = 7 + expect(kids.length).toBe(7); + const ellipsisCount = kids.filter((k) => k.role === 'pagination-ellipsis').length; + expect(ellipsisCount).toBe(2); + const activeCount = kids.filter((k) => k.role === 'pagination-page-active').length; + expect(activeCount).toBe(1); + }); + + it('current at start: total=10, current=1 → no left ellipsis', async () => { + const fp = await fresh('a.op'); + await handleAddPaginationV0({ filePath: fp, total: 10, current: 1, show_arrows: false }); + const p = getRoot(await readDoc(fp)); + const kids = p.children as Record[]; + // 1, 2, ellipsis, 10 = 4 + expect(kids.length).toBe(4); + expect(kids[0].role).toBe('pagination-page-active'); + expect(kids[2].role).toBe('pagination-ellipsis'); + }); + + it('accent_color overrides active fill', async () => { + const fp = await fresh('a.op'); + await handleAddPaginationV0({ filePath: fp, total: 3, current: 1, accent_color: '#4F46E5' }); + const p = getRoot(await readDoc(fp)); + const kids = p.children as Record[]; + const active = kids.find((k) => k.role === 'pagination-page-active')!; + const fill = active.fill as Array<{ color: string }>; + expect(fill[0].color).toBe('#4F46E5'); + }); + + it('show_arrows=false drops prev/next', async () => { + const fp = await fresh('a.op'); + await handleAddPaginationV0({ filePath: fp, total: 3, current: 1, show_arrows: false }); + const p = getRoot(await readDoc(fp)); + const kids = p.children as Record[]; + expect(kids.find((k) => k.role === 'pagination-prev')).toBeUndefined(); + expect(kids.find((k) => k.role === 'pagination-next')).toBeUndefined(); + }); + + it('total=1 → single active pill (+ arrows if default)', async () => { + const fp = await fresh('a.op'); + await handleAddPaginationV0({ filePath: fp, total: 1 }); + const p = getRoot(await readDoc(fp)); + const kids = p.children as Record[]; + // prev + 1 active + next + expect(kids.length).toBe(3); + expect(kids[1].role).toBe('pagination-page-active'); + }); + + it('throws on bogus parent_id AND leaves file untouched', async () => { + const fp = await fresh('a.op'); + const before = await readFile(fp, 'utf-8'); + await expect( + handleAddPaginationV0({ filePath: fp, total: 5, 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 d9dad4572..5fbf8bfbd 100644 --- a/packages/pen-mcp/src/__tests__/element-tools-contract.test.ts +++ b/packages/pen-mcp/src/__tests__/element-tools-contract.test.ts @@ -65,6 +65,7 @@ const ELEMENT_TOOL_NAMES = [ 'add_chart_bars_v0', 'add_timeline_v0', 'add_calendar_grid_v0', + 'add_pagination_v0', ]; describe('element tools — v0-MUST contract', () => { diff --git a/packages/pen-mcp/src/routes/element-tool-defs-ext.ts b/packages/pen-mcp/src/routes/element-tool-defs-ext.ts index f969d2a52..6f480804a 100644 --- a/packages/pen-mcp/src/routes/element-tool-defs-ext.ts +++ b/packages/pen-mcp/src/routes/element-tool-defs-ext.ts @@ -1063,4 +1063,40 @@ export const ELEMENT_TOOL_DEFINITIONS_EXT = [ required: ['label'], }, }, + { + name: 'add_pagination_v0', + description: + 'Pagination bar for list/table footers: row of page-number pills flanked by optional prev/next ' + + 'arrow buttons. Active page renders as a filled pill (accent color, white text); inactive ' + + 'pages are ghost (no fill). Collapses long page ranges with "…" ellipses Google-style ' + + '(always shows 1 and total, plus a ±siblings window around current). Use for "pagination", ' + + '"page nav", "分页", "分页条". schemaVersion 1.0', + inputSchema: { + type: 'object' as const, + properties: { + schemaVersion: schemaVersionProp, + filePath: filePathProp, + total: { type: 'number', description: 'Total number of pages (>= 1)' }, + current: { + type: 'number', + description: '1-based current page (clamped to [1, total], default 1)', + }, + siblings: { + type: 'number', + description: 'Pages shown on each side of current before ellipsis (default 1, min 0)', + }, + show_arrows: { + type: 'boolean', + description: 'Include prev/next chevron buttons (default true)', + }, + accent_color: { + type: 'string', + description: 'Hex color for active page pill (default #0F172A slate-900)', + }, + parent_id: parentIdProp, + pageId: pageIdProp, + }, + required: ['total'], + }, + }, ]; diff --git a/packages/pen-mcp/src/routes/element-tool-defs.ts b/packages/pen-mcp/src/routes/element-tool-defs.ts index 3852d8e23..a2bfa7c9c 100644 --- a/packages/pen-mcp/src/routes/element-tool-defs.ts +++ b/packages/pen-mcp/src/routes/element-tool-defs.ts @@ -71,6 +71,7 @@ import { handleAddColorSwatchV0 } from '../tools/add-color-swatch-v0'; import { handleAddChartBarsV0 } from '../tools/add-chart-bars-v0'; import { handleAddTimelineV0 } from '../tools/add-timeline-v0'; import { handleAddCalendarGridV0 } from '../tools/add-calendar-grid-v0'; +import { handleAddPaginationV0 } from '../tools/add-pagination-v0'; import { ELEMENT_TOOL_DEFINITIONS_BASE } from './element-tool-defs-base'; import { ELEMENT_TOOL_DEFINITIONS_EXT } from './element-tool-defs-ext'; @@ -198,6 +199,8 @@ export async function handleElementToolCall(name: string, a: any): Promise>> { + await ensureParentExists(params); + const p = buildPagination(params); + assignIdsRecursively(p); + return insertElementTree({ binding: 'pagination', tree: p, ...params }); +}