feat(ai): add_pagination_v0 — Google-style pagination bar (57th tool)

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.
This commit is contained in:
Fini 2026-04-22 09:20:00 +08:00
parent c9620b42df
commit fa69986fc8
12 changed files with 380 additions and 1 deletions

View file

@ -35,6 +35,7 @@ import {
buildMetricRow,
buildNavChipRow,
buildNotificationRow,
buildPagination,
buildPrice,
buildProgressBar,
buildQuoteBlock,
@ -174,6 +175,7 @@ const SERVER_BUILDERS: Record<string, BuilderFn> = {
add_chart_bars_v0: (a) => buildChartBars(a as Parameters<typeof buildChartBars>[0]),
add_timeline_v0: (a) => buildTimeline(a as Parameters<typeof buildTimeline>[0]),
add_calendar_grid_v0: (a) => buildCalendarGrid(a as Parameters<typeof buildCalendarGrid>[0]),
add_pagination_v0: (a) => buildPagination(a as Parameters<typeof buildPagination>[0]),
};
interface ExecToolBody {

View file

@ -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<typeof buildCalendarGrid>[0]),
},
{
toolName: 'add_pagination_v0',
args: { total: 10, current: 5 },
build: (a) => buildPagination(a as unknown as Parameters<typeof buildPagination>[0]),
},
];
/**

View file

@ -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<string, ElementShim> = {
add_chart_bars_v0: wrap<ChartBarsParams>(buildChartBars),
add_timeline_v0: wrap<TimelineParams>(buildTimeline),
add_calendar_grid_v0: wrap<CalendarGridParams>(buildCalendarGrid),
add_pagination_v0: wrap<PaginationParams>(buildPagination),
};
export function getElementShim(name: string): ElementShim | undefined {

View file

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

View file

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

View file

@ -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<number>([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,
};
}

View file

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

View file

@ -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<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;
}
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<string, unknown>[];
// 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<string, unknown>[];
// 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<string, unknown>[];
// 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<string, unknown>[];
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<string, unknown>[];
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<string, unknown>[];
// 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);
});
});

View file

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

View file

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

View file

@ -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<strin
return JSON.stringify(await handleAddTimelineV0(a), null, 2);
case 'add_calendar_grid_v0':
return JSON.stringify(await handleAddCalendarGridV0(a), null, 2);
case 'add_pagination_v0':
return JSON.stringify(await handleAddPaginationV0(a), null, 2);
default:
return '';
}

View file

@ -0,0 +1,19 @@
import { assignIdsRecursively, buildPagination, type PaginationParams } from '@zseven-w/pen-core';
import type { handleBatchDesign } from './batch-design';
import { ensureParentExists, insertElementTree } from './element-tool-helpers';
export interface AddPaginationV0Params extends PaginationParams {
parent_id?: string;
filePath?: string;
pageId?: string;
}
/** Pagination bar. Tree build delegated to `buildPagination`. */
export async function handleAddPaginationV0(
params: AddPaginationV0Params,
): Promise<Awaited<ReturnType<typeof handleBatchDesign>>> {
await ensureParentExists(params);
const p = buildPagination(params);
assignIdsRecursively(p);
return insertElementTree({ binding: 'pagination', tree: p, ...params });
}