feat(ai): add_spinner_v0 + add_tooltip_v0 (52nd, 53rd tools)

- add_spinner_v0: static loading spinner — full ring (track) + 270°
  arc (active). Sits at size=32 default, clamped 16..128. Two
  ellipses at SAME origin with DIFFERENT sweep ranges — NOT the
  "stacked ellipses for ring" anti-pattern (rewriteLlmAntiPatterns
  only fires when both are full-sweep duplicates).
- add_tooltip_v0: small dark pill (#111827) + white text for
  hover hints. position param ("top"/"bottom"/"left"/"right")
  encodes a role hint (`tooltip-top` etc.) for downstream position
  logic; visual body is identical. NO arrow pointer (pen-core has
  no clean triangle primitive — caller composes via batch_design
  rectangle + rotate if needed).

Wired across all three paths. elements.md adds "Feedback / loading"
section (#48, #49). ab-v1 corpus +1 prompt (mobile-help-tooltip);
spinner omitted from corpus for now — the prompt wording is too
ambiguous for "obvious" difficulty.

Tool count: 51 → 53. Test count: 3138 → 3180 (+42).
This commit is contained in:
Fini 2026-04-22 08:55:00 +08:00
parent baa414bded
commit fb306dcf79
24 changed files with 507 additions and 6 deletions

View file

@ -44,6 +44,7 @@ import {
buildSkeleton,
buildStatGrid,
buildStatusBadge,
buildSpinner,
buildStepper,
buildSwitch,
buildTabs,
@ -51,6 +52,7 @@ import {
buildTextButton,
buildTimeline,
buildToast,
buildTooltip,
buildTopNavBar,
findNodeInTree,
insertNodeInTree,
@ -148,6 +150,8 @@ const SERVER_BUILDERS: Record<string, BuilderFn> = {
add_comment_v0: (a) => buildComment(a as Parameters<typeof buildComment>[0]),
add_modal_shell_v0: (a) => buildModalShell(a as Parameters<typeof buildModalShell>[0]),
add_status_badge_v0: (a) => buildStatusBadge(a as Parameters<typeof buildStatusBadge>[0]),
add_spinner_v0: (a) => buildSpinner(a as Parameters<typeof buildSpinner>[0]),
add_tooltip_v0: (a) => buildTooltip(a as Parameters<typeof buildTooltip>[0]),
add_nav_chip_row_v0: (a) => buildNavChipRow(a as Parameters<typeof buildNavChipRow>[0]),
add_activity_ring_v0: (a) => buildActivityRing(a as Parameters<typeof buildActivityRing>[0]),
add_rating_stars_v0: (a) => buildRatingStars(a as Parameters<typeof buildRatingStars>[0]),

View file

@ -53,6 +53,7 @@ import {
buildSkeleton,
buildStatGrid,
buildStatusBadge,
buildSpinner,
buildStepper,
buildSwitch,
buildTabs,
@ -60,6 +61,7 @@ import {
buildTextButton,
buildTimeline,
buildToast,
buildTooltip,
buildTopNavBar,
assignIdsRecursively,
type ElementTree,
@ -191,6 +193,8 @@ const CASES: BuilderCase[] = [
},
{ name: 'modal-shell', build: () => buildModalShell({ title: 'Confirm' }) },
{ name: 'status-badge', build: () => buildStatusBadge({ label: 'Online', tone: 'success' }) },
{ name: 'spinner', build: () => buildSpinner({}) },
{ name: 'tooltip', build: () => buildTooltip({ text: 'Help' }) },
{ name: 'chart-bars', build: () => buildChartBars({ values: [4, 7, 3, 9, 5] }) },
{
name: 'empty-state',

View file

@ -45,6 +45,7 @@ import {
buildSkeleton,
buildStatGrid,
buildStatusBadge,
buildSpinner,
buildStepper,
buildSwitch,
buildTabs,
@ -52,6 +53,7 @@ import {
buildTextButton,
buildTimeline,
buildToast,
buildTooltip,
buildTopNavBar,
type ElementTree,
} from '@zseven-w/pen-core';
@ -185,6 +187,8 @@ const CASES: BuilderCase[] = [
},
{ name: 'modal-shell', build: () => buildModalShell({ title: 'Confirm' }) },
{ name: 'status-badge', build: () => buildStatusBadge({ label: 'Online', tone: 'success' }) },
{ name: 'spinner', build: () => buildSpinner({}) },
{ name: 'tooltip', build: () => buildTooltip({ text: 'Help' }) },
{ name: 'chart-bars', build: () => buildChartBars({ values: [4, 7, 3, 9, 5] }) },
{
name: 'empty-state',

View file

@ -54,6 +54,7 @@ import {
buildSkeleton,
buildStatGrid,
buildStatusBadge,
buildSpinner,
buildStepper,
buildSwitch,
buildTabs,
@ -61,6 +62,7 @@ import {
buildTextButton,
buildTimeline,
buildToast,
buildTooltip,
buildTopNavBar,
assignIdsRecursively,
type ElementTree,
@ -208,6 +210,8 @@ const CASES: BuilderCase[] = [
},
{ name: 'modal-shell', build: () => buildModalShell({ title: 'Confirm' }) },
{ name: 'status-badge', build: () => buildStatusBadge({ label: 'Online', tone: 'success' }) },
{ name: 'spinner', build: () => buildSpinner({}) },
{ name: 'tooltip', build: () => buildTooltip({ text: 'Help' }) },
{ name: 'chart-bars', build: () => buildChartBars({ values: [4, 7, 3, 9, 5] }) },
{
name: 'empty-state',

View file

@ -51,6 +51,7 @@ import {
buildSkeleton,
buildStatGrid,
buildStatusBadge,
buildSpinner,
buildStepper,
buildSwitch,
buildTabs,
@ -58,6 +59,7 @@ import {
buildTextButton,
buildTimeline,
buildToast,
buildTooltip,
buildTopNavBar,
} from '@zseven-w/pen-core';
import { ELEMENT_TOOL_NAMES } from '@zseven-w/pen-mcp';
@ -291,6 +293,16 @@ const CASES: BuilderCase[] = [
args: { label: 'Online', tone: 'success' },
build: (a) => buildStatusBadge(a as unknown as Parameters<typeof buildStatusBadge>[0]),
},
{
toolName: 'add_spinner_v0',
args: {},
build: (a) => buildSpinner(a as unknown as Parameters<typeof buildSpinner>[0]),
},
{
toolName: 'add_tooltip_v0',
args: { text: 'Help' },
build: (a) => buildTooltip(a as unknown as Parameters<typeof buildTooltip>[0]),
},
{
toolName: 'add_form_field_v0',
args: { label: 'Email' },

View file

@ -62,6 +62,7 @@ import {
buildSkeleton,
buildStatGrid,
buildStatusBadge,
buildSpinner,
buildStepper,
buildSwitch,
buildTabs,
@ -69,6 +70,7 @@ import {
buildTextButton,
buildTimeline,
buildToast,
buildTooltip,
buildTopNavBar,
type ActivityRingParams,
type AlertParams,
@ -114,6 +116,7 @@ import {
type SkeletonParams,
type StatGridParams,
type StatusBadgeParams,
type SpinnerParams,
type StepperParams,
type SwitchParams,
type TabsParams,
@ -121,6 +124,7 @@ import {
type TextButtonParams,
type TimelineParams,
type ToastParams,
type TooltipParams,
type TopNavBarParams,
} from '@zseven-w/pen-core';
import type { PenNode } from '@/types/pen';
@ -250,6 +254,8 @@ export const ELEMENT_SHIMS: Record<string, ElementShim> = {
add_comment_v0: wrap<CommentParams>(buildComment),
add_modal_shell_v0: wrap<ModalShellParams>(buildModalShell),
add_status_badge_v0: wrap<StatusBadgeParams>(buildStatusBadge),
add_spinner_v0: wrap<SpinnerParams>(buildSpinner),
add_tooltip_v0: wrap<TooltipParams>(buildTooltip),
add_nav_chip_row_v0: wrap<NavChipRowParams>(buildNavChipRow),
add_activity_ring_v0: wrap<ActivityRingParams>(buildActivityRing),
add_rating_stars_v0: wrap<RatingStarsParams>(buildRatingStars),

View file

@ -1,11 +1,11 @@
# A/B Corpus v1
Supplemental corpus covering the 9 element tools added after the
Supplemental corpus covering the 10 element tools added after the
ab-v0 baseline was frozen (2026-04-20). Intended for an A/B v2 run
that measures routing + legality on the new tool surface without
re-running the full 24-prompt v0 corpus.
Split: 9 `obvious` prompts, one per new tool. No `optional` prompts
Split: 10 `obvious` prompts, one per new tool. No `optional` prompts
in v1 — the new tools are narrow enough that any reasonable design
request either maps to them directly or doesn't, and the optional
slot is already well-populated in v0.

View file

@ -0,0 +1,14 @@
id: mobile-help-tooltip
category: mobile
difficulty: obvious
prompt: |
Design ONLY a small dark tooltip pill with the help text "Click
to remove" — hover-hint appearance, rounded dark background,
white compact text. Do NOT include the button/element it would
point to, no arrow pointer, no surrounding chrome — just the
tooltip pill itself.
expected:
must_contain_roles:
- tooltip-top
- tooltip-text
expected_tool_if_any: add_tooltip_v0

View file

@ -133,6 +133,11 @@ Status / presence:
47. Status badge (small colored dot + short label, "● Online" pattern, tone-enum'd) → `add_status_badge_v0`
Feedback / loading:
48. Loading spinner (static ring + 3/4 arc) → `add_spinner_v0`
49. Tooltip pill (dark pill + white text, hover-hint appearance) → `add_tooltip_v0`
Activity / history:
41. Vertical timeline (dots + fixed 24px connectors + content; no padding/gap) → `add_timeline_v0`
@ -181,6 +186,8 @@ PREFER an element tool when the spec says any of:
- "comment", "reply", "feedback row", "review row", "评论" → `add_comment_v0`
- "modal", "dialog", "popup", "confirm dialog", "模态框", "弹窗" → `add_modal_shell_v0`
- "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`
- "toggle", "switch", "on/off", "开关" → `add_switch_v0`
- "checkbox", "agreement", "select option", "复选框" → `add_checkbox_v0`
- "radio", "single choice", "单选" → `add_radio_v0` (stack multiple in a vertical parent)
@ -320,6 +327,8 @@ add_comment_v0({ author: "Sarah", timestamp: "2h ago", body: "Looks great!", ava
add_modal_shell_v0({ title: "Confirm delete", subtitle: "This cannot be undone." })
add_status_badge_v0({ label: "Online", tone: "success" })
add_status_badge_v0({ label: "Degraded", tone: "warning" })
add_spinner_v0({ size: 40 })
add_tooltip_v0({ text: "Click to delete" })
add_switch_v0({}) // off (default)
add_switch_v0({ active: true }) // on — iOS green

View file

@ -69,17 +69,17 @@ describe('loadCorpus — v1 supplemental corpus (new tools)', () => {
// comment, modal_shell). All obvious — one prompt per tool so an
// A/B v2 run can measure routing + legality on the new surface
// without re-running all 24 v0 prompts. See `corpus/ab-v1/README.md`.
it('loads 9 prompts, all obvious, one per new tool', () => {
it('loads 10 prompts, all obvious, one per new tool', () => {
const prompts = loadCorpus(REPO_CORPUS_V1_DIR);
expect(prompts).toHaveLength(9);
expect(new Set(prompts.map((p) => p.id)).size).toBe(9);
expect(prompts).toHaveLength(10);
expect(new Set(prompts.map((p) => p.id)).size).toBe(10);
for (const p of prompts) {
expect(p.difficulty).toBe('obvious');
expect(p.expected_tool_if_any).toMatch(/^add_[a-z_]+_v0$/);
}
});
it('covers the 9 specific tools added 2026-04-22', () => {
it('covers the 10 specific tools added 2026-04-22', () => {
const prompts = loadCorpus(REPO_CORPUS_V1_DIR);
const tools = new Set(prompts.map((p) => p.expected_tool_if_any));
expect(tools).toEqual(
@ -93,6 +93,7 @@ describe('loadCorpus — v1 supplemental corpus (new tools)', () => {
'add_comment_v0',
'add_modal_shell_v0',
'add_status_badge_v0',
'add_tooltip_v0',
]),
);
});

View file

@ -47,12 +47,14 @@ import {
buildSkeleton,
buildStatGrid,
buildStatusBadge,
buildSpinner,
buildStepper,
buildSwitch,
buildTabs,
buildTextButton,
buildTimeline,
buildToast,
buildTooltip,
buildTopNavBar,
type ElementTree,
} from '../element-builders/index.js';
@ -205,6 +207,8 @@ const CASES: LayoutCase[] = [
tree: () => buildStatusBadge({ label: 'Online', tone: 'success' }),
wrap: true,
},
{ name: 'buildSpinner', tree: () => buildSpinner({}) },
{ name: 'buildTooltip', tree: () => buildTooltip({ text: 'Help' }), wrap: true },
{ name: 'buildChartBars', tree: () => buildChartBars({ values: [4, 7, 3, 9, 5] }) },
// Composites

View file

@ -45,6 +45,7 @@ import {
buildSkeleton,
buildStatGrid,
buildStatusBadge,
buildSpinner,
buildStepper,
buildSwitch,
buildTabs,
@ -52,6 +53,7 @@ import {
buildTextButton,
buildTimeline,
buildToast,
buildTooltip,
buildTopNavBar,
type ElementTree,
} from '../element-builders/index.js';
@ -187,6 +189,8 @@ const CASES: BuilderCase[] = [
},
{ name: 'modal-shell', build: () => buildModalShell({ title: 'Confirm' }) },
{ name: 'status-badge', build: () => buildStatusBadge({ label: 'Online', tone: 'success' }) },
{ name: 'spinner', build: () => buildSpinner({}) },
{ name: 'tooltip', build: () => buildTooltip({ text: 'Help' }) },
{ name: 'chart-bars', build: () => buildChartBars({ values: [4, 7, 3, 9, 5] }) },
{
name: 'empty-state',

View file

@ -43,6 +43,7 @@ import {
buildSkeleton,
buildStatGrid,
buildStatusBadge,
buildSpinner,
buildStepper,
buildSwitch,
buildTabs,
@ -50,6 +51,7 @@ import {
buildTextButton,
buildTimeline,
buildToast,
buildTooltip,
buildTopNavBar,
type ElementTree,
} from '../element-builders/index.js';
@ -167,6 +169,8 @@ const CASES: PerfCase[] = [
},
{ name: 'modal-shell', build: () => buildModalShell({ title: 'Confirm' }) },
{ name: 'status-badge', build: () => buildStatusBadge({ label: 'Online', tone: 'success' }) },
{ name: 'spinner', build: () => buildSpinner({}) },
{ name: 'tooltip', build: () => buildTooltip({ text: 'Help' }) },
{ name: 'chart-bars', build: () => buildChartBars({ values: [4, 7, 3, 9, 5, 8] }) },
{
name: 'empty-state',

View file

@ -45,6 +45,7 @@ import {
buildSkeleton,
buildStatGrid,
buildStatusBadge,
buildSpinner,
buildStepper,
buildSwitch,
buildTabs,
@ -52,6 +53,7 @@ import {
buildTextButton,
buildTimeline,
buildToast,
buildTooltip,
buildTopNavBar,
type ElementTree,
} from '../element-builders/index.js';
@ -191,6 +193,8 @@ const CASES: BuilderCase[] = [
},
{ name: 'modal-shell', build: () => buildModalShell({ title: 'Confirm' }) },
{ name: 'status-badge', build: () => buildStatusBadge({ label: 'Online', tone: 'success' }) },
{ name: 'spinner', build: () => buildSpinner({}) },
{ name: 'tooltip', build: () => buildTooltip({ text: 'Help' }) },
{ name: 'chart-bars', build: () => buildChartBars({ values: [4, 7, 3, 9, 5] }) },
{
name: 'empty-state',

View file

@ -46,6 +46,8 @@ export { buildImagePlaceholder, type ImagePlaceholderParams } from './image-plac
export { buildComment, type CommentParams } from './comment.js';
export { buildModalShell, type ModalShellParams } from './modal-shell.js';
export { buildStatusBadge, type StatusBadgeParams, type StatusBadgeTone } from './status-badge.js';
export { buildSpinner, type SpinnerParams } from './spinner.js';
export { buildTooltip, type TooltipParams } from './tooltip.js';
export { buildNavChipRow, type NavChipRowItem, type NavChipRowParams } from './nav-chip-row.js';
export { buildActivityRing, type ActivityRingParams } from './activity-ring.js';
export { buildRatingStars, type RatingStarsParams } from './rating-stars.js';

View file

@ -0,0 +1,69 @@
import type { ElementTree } from './helpers.js';
export interface SpinnerParams {
/** Outer diameter in px. Default 32. Clamped 16..128. */
size?: number;
/** Stroke thickness in px. Default 3. Clamped 1..16. */
thickness?: number;
/** Track color (the static ring). Default #E2E8F0 (slate-200). */
track_color?: string;
/** Active arc color. Default #2563EB (blue-600). */
active_color?: string;
}
/**
* Loading spinner — a ring with a 3/4 sweep active arc. Static
* (no animation; pen-core is still-frame only). Appearance matches
* typical Material/iOS spinner mid-animation.
*
* Structure (two stacked ellipse nodes at same origin):
* frame(size², layout=none)
* ├ ellipse(size², track_color stroke only, role='spinner-track')
* └ ellipse(size², active_color stroke, sweepAngle=270, role='spinner-arc')
*
* NOT the "stacked ellipses for a ring" anti-pattern: these ellipses
* have DIFFERENT sweep ranges — track is a full ring (implicit 360),
* active is a 270° arc. rewriteLlmAntiPatterns only rewrites when
* both ellipses are full-sweep duplicates.
*/
export function buildSpinner(params: SpinnerParams): ElementTree {
const size = Math.max(16, Math.min(128, Math.floor(params.size ?? 32)));
const thickness = Math.max(1, Math.min(16, Math.floor(params.thickness ?? 3)));
const trackColor = params.track_color ?? '#E2E8F0';
const activeColor = params.active_color ?? '#2563EB';
return {
type: 'frame',
name: 'Spinner',
role: 'spinner',
width: size,
height: size,
layout: 'none',
children: [
{
type: 'ellipse',
name: 'Track',
role: 'spinner-track',
x: 0,
y: 0,
width: size,
height: size,
fill: [],
stroke: { thickness, fill: [{ type: 'solid', color: trackColor }] },
},
{
type: 'ellipse',
name: 'Active Arc',
role: 'spinner-arc',
x: 0,
y: 0,
width: size,
height: size,
startAngle: -90,
sweepAngle: 270,
fill: [],
stroke: { thickness, fill: [{ type: 'solid', color: activeColor }] },
},
],
};
}

View file

@ -0,0 +1,56 @@
import type { ElementTree } from './helpers.js';
export interface TooltipParams {
/** Tooltip body text (1-2 short lines). */
text: string;
/**
* Where the tooltip is relative to the anchor — used only to set
* the `role` hint so downstream post-passes can position it. The
* tooltip itself does NOT render an arrow pointer (pen-core has
* no triangle primitive that composes cleanly); callers that
* need an arrow compose one via batch_design rectangle + rotate.
* Default "top".
*/
position?: 'top' | 'bottom' | 'left' | 'right';
}
/**
* Tooltip pill — small dark pill with white text. Compact informative
* hover / help label. The open state only (positioned by caller).
*
* Structure:
* frame(fit_content, horizontal, padding=[6,10], cornerRadius=6,
* fill=#111827 slate-900)
* └ text(text, 12/500, fill=#FFFFFF, role='tooltip-text')
*
* `role` on the outer frame encodes the position variant
* (`tooltip-top`/`tooltip-bottom`/`tooltip-left`/`tooltip-right`)
* so downstream positioning logic can differentiate. The visual
* body is identical regardless.
*/
export function buildTooltip(params: TooltipParams): ElementTree {
const position = params.position ?? 'top';
return {
type: 'frame',
name: 'Tooltip',
role: `tooltip-${position}`,
width: 'fit_content',
height: 'fit_content',
layout: 'horizontal',
alignItems: 'center',
padding: [6, 10],
cornerRadius: 6,
fill: [{ type: 'solid', color: '#111827' }],
children: [
{
type: 'text',
name: 'Text',
role: 'tooltip-text',
content: params.text,
fontSize: 12,
fontWeight: 500,
fill: [{ type: 'solid', color: '#FFFFFF' }],
},
],
};
}

View file

@ -212,6 +212,8 @@ export {
buildComment,
buildModalShell,
buildStatusBadge,
buildSpinner,
buildTooltip,
buildNavChipRow,
buildActivityRing,
buildRatingStars,
@ -278,6 +280,8 @@ export {
type ModalShellParams,
type StatusBadgeParams,
type StatusBadgeTone,
type SpinnerParams,
type TooltipParams,
type NavChipRowItem,
type NavChipRowParams,
type ActivityRingParams,

View file

@ -0,0 +1,106 @@
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 { handleAddSpinnerV0 } from '../tools/add-spinner-v0';
import { invalidateCache } from '../document-manager';
const TMP = join(tmpdir(), 'openpencil-add-spinner-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_spinner_v0', () => {
it('registered; no required params', () => {
expect(DESIGN_TOOL_NAMES.has('add_spinner_v0')).toBe(true);
const def = DESIGN_TOOL_DEFINITIONS.find((t) => t.name === 'add_spinner_v0');
expect(def?.inputSchema.required).toEqual([]);
});
it('default: 32×32 frame + 2 ellipses (track + active arc)', async () => {
const fp = await fresh('a.op');
await handleAddSpinnerV0({ filePath: fp });
const sp = getRoot(await readDoc(fp));
expect(sp.role).toBe('spinner');
expect(sp.width).toBe(32);
expect(sp.layout).toBe('none');
const kids = sp.children as Record<string, unknown>[];
expect(kids.length).toBe(2);
expect(kids[0].role).toBe('spinner-track');
expect(kids[1].role).toBe('spinner-arc');
// Track is full ring (no startAngle); arc is 270° starting at -90
expect(kids[0].startAngle).toBeUndefined();
expect(kids[1].startAngle).toBe(-90);
expect(kids[1].sweepAngle).toBe(270);
});
it('size clamped 16..128', async () => {
const fp = await fresh('a.op');
await handleAddSpinnerV0({ filePath: fp, size: 8 });
expect(getRoot(await readDoc(fp)).width).toBe(16);
await writeFile(fp, EMPTY, 'utf-8');
invalidateCache(fp);
await handleAddSpinnerV0({ filePath: fp, size: 999 });
expect(getRoot(await readDoc(fp)).width).toBe(128);
});
it('thickness clamped 1..16', async () => {
const fp = await fresh('a.op');
await handleAddSpinnerV0({ filePath: fp, thickness: 99 });
const arc = (getRoot(await readDoc(fp)).children as Record<string, unknown>[])[1];
const stroke = arc.stroke as { thickness: number };
expect(stroke.thickness).toBe(16);
});
it('custom colors applied', async () => {
const fp = await fresh('a.op');
await handleAddSpinnerV0({
filePath: fp,
track_color: '#FFFFFF',
active_color: '#FF0000',
});
const kids = getRoot(await readDoc(fp)).children as Record<string, unknown>[];
const trackStroke = kids[0].stroke as { fill: Array<{ color: string }> };
const arcStroke = kids[1].stroke as { fill: Array<{ color: string }> };
expect(trackStroke.fill[0].color).toBe('#FFFFFF');
expect(arcStroke.fill[0].color).toBe('#FF0000');
});
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(handleAddSpinnerV0({ filePath: fp, parent_id: 'nope' })).rejects.toThrow(
/parent_id.*not found/,
);
expect(await readFile(fp, 'utf-8')).toBe(before);
});
});

View file

@ -0,0 +1,83 @@
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 { handleAddTooltipV0 } from '../tools/add-tooltip-v0';
import { invalidateCache } from '../document-manager';
const TMP = join(tmpdir(), 'openpencil-add-tooltip-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_tooltip_v0', () => {
it('registered; required text', () => {
expect(DESIGN_TOOL_NAMES.has('add_tooltip_v0')).toBe(true);
const def = DESIGN_TOOL_DEFINITIONS.find((t) => t.name === 'add_tooltip_v0');
expect(def?.inputSchema.required).toEqual(['text']);
});
it('default: tooltip-top role + dark pill + white text', async () => {
const fp = await fresh('a.op');
await handleAddTooltipV0({ filePath: fp, text: 'Click to edit' });
const tt = getRoot(await readDoc(fp));
expect(tt.role).toBe('tooltip-top');
expect(tt.cornerRadius).toBe(6);
expect(tt.padding).toEqual([6, 10]);
const fill = tt.fill as Array<{ color: string }>;
expect(fill[0].color).toBe('#111827');
const kids = tt.children as Record<string, unknown>[];
expect(kids.length).toBe(1);
expect(kids[0].content).toBe('Click to edit');
const textFill = kids[0].fill as Array<{ color: string }>;
expect(textFill[0].color).toBe('#FFFFFF');
});
it('position sets role variant', async () => {
const fp = await fresh('a.op');
await handleAddTooltipV0({ filePath: fp, text: 'Bottom', position: 'bottom' });
expect(getRoot(await readDoc(fp)).role).toBe('tooltip-bottom');
await writeFile(fp, EMPTY, 'utf-8');
invalidateCache(fp);
await handleAddTooltipV0({ filePath: fp, text: 'Left', position: 'left' });
expect(getRoot(await readDoc(fp)).role).toBe('tooltip-left');
});
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(
handleAddTooltipV0({ filePath: fp, text: 'X', parent_id: 'nope' }),
).rejects.toThrow(/parent_id.*not found/);
expect(await readFile(fp, 'utf-8')).toBe(before);
});
});

View file

@ -663,6 +663,63 @@ export const ELEMENT_TOOL_DEFINITIONS_EXT = [
required: ['label'],
},
},
{
name: 'add_spinner_v0',
description:
'Loading spinner (static): a ring + 3/4-sweep active arc. No animation — pen-core is ' +
'still-frame, so use size + thickness to match the desired visual and rely on the app to ' +
'animate if needed. Use for "loading spinner", "progress circle", "loader", "加载圈". ' +
'schemaVersion 1.0',
inputSchema: {
type: 'object' as const,
properties: {
schemaVersion: schemaVersionProp,
filePath: filePathProp,
size: { type: 'number', description: 'Outer diameter in px (default 32, clamped 16..128)' },
thickness: {
type: 'number',
description: 'Stroke thickness in px (default 3, clamped 1..16)',
},
track_color: {
type: 'string',
description: 'Track ring color (default #E2E8F0 slate-200)',
},
active_color: {
type: 'string',
description: 'Active arc color (default #2563EB blue-600)',
},
parent_id: parentIdProp,
pageId: pageIdProp,
},
required: [],
},
},
{
name: 'add_tooltip_v0',
description:
'Tooltip pill: small dark (#111827) pill with white text, typical hover-hint appearance. ' +
'Only emits the open-state body — caller positions it. position ("top"/"bottom"/"left"/"right") ' +
'encodes a role hint for downstream positioning logic but the visual body is identical. ' +
'NO arrow pointer (pen-core has no clean triangle primitive); compose one via batch_design ' +
'rectangle + rotate if needed. Use for "tooltip", "help hint", "hover label", "提示浮层". ' +
'schemaVersion 1.0',
inputSchema: {
type: 'object' as const,
properties: {
schemaVersion: schemaVersionProp,
filePath: filePathProp,
text: { type: 'string', description: 'Tooltip body text (1-2 short lines)' },
position: {
type: 'string',
enum: ['top', 'bottom', 'left', 'right'],
description: 'Position hint; sets `tooltip-<position>` on outer role. Default "top".',
},
parent_id: parentIdProp,
pageId: pageIdProp,
},
required: ['text'],
},
},
{
name: 'add_image_placeholder_v0',
description:

View file

@ -43,6 +43,8 @@ import { handleAddImagePlaceholderV0 } from '../tools/add-image-placeholder-v0';
import { handleAddCommentV0 } from '../tools/add-comment-v0';
import { handleAddModalShellV0 } from '../tools/add-modal-shell-v0';
import { handleAddStatusBadgeV0 } from '../tools/add-status-badge-v0';
import { handleAddSpinnerV0 } from '../tools/add-spinner-v0';
import { handleAddTooltipV0 } from '../tools/add-tooltip-v0';
import { handleAddSwitchV0 } from '../tools/add-switch-v0';
import { handleAddCheckboxV0 } from '../tools/add-checkbox-v0';
import { handleAddRadioV0 } from '../tools/add-radio-v0';
@ -137,6 +139,10 @@ export async function handleElementToolCall(name: string, a: any): Promise<strin
return JSON.stringify(await handleAddModalShellV0(a), null, 2);
case 'add_status_badge_v0':
return JSON.stringify(await handleAddStatusBadgeV0(a), null, 2);
case 'add_spinner_v0':
return JSON.stringify(await handleAddSpinnerV0(a), null, 2);
case 'add_tooltip_v0':
return JSON.stringify(await handleAddTooltipV0(a), null, 2);
case 'add_switch_v0':
return JSON.stringify(await handleAddSwitchV0(a), null, 2);
case 'add_checkbox_v0':

View file

@ -0,0 +1,22 @@
import { assignIdsRecursively, buildSpinner, type SpinnerParams } from '@zseven-w/pen-core';
import type { handleBatchDesign } from './batch-design';
import { ensureParentExists, insertElementTree } from './element-tool-helpers';
export interface AddSpinnerV0Params extends SpinnerParams {
parent_id?: string;
filePath?: string;
pageId?: string;
}
/**
* Loading spinner — ring + 3/4 sweep active arc (static, no animation).
* Tree build delegated to `buildSpinner`.
*/
export async function handleAddSpinnerV0(
params: AddSpinnerV0Params,
): Promise<Awaited<ReturnType<typeof handleBatchDesign>>> {
await ensureParentExists(params);
const s = buildSpinner(params);
assignIdsRecursively(s);
return insertElementTree({ binding: 'sp', tree: s, ...params });
}

View file

@ -0,0 +1,22 @@
import { assignIdsRecursively, buildTooltip, type TooltipParams } from '@zseven-w/pen-core';
import type { handleBatchDesign } from './batch-design';
import { ensureParentExists, insertElementTree } from './element-tool-helpers';
export interface AddTooltipV0Params extends TooltipParams {
parent_id?: string;
filePath?: string;
pageId?: string;
}
/**
* Tooltip pill — small dark pill with white text. Tree build
* delegated to `buildTooltip`.
*/
export async function handleAddTooltipV0(
params: AddTooltipV0Params,
): Promise<Awaited<ReturnType<typeof handleBatchDesign>>> {
await ensureParentExists(params);
const t = buildTooltip(params);
assignIdsRecursively(t);
return insertElementTree({ binding: 'tt', tree: t, ...params });
}