From fb306dcf79705bb5b20b3a366432fe58042bc53a Mon Sep 17 00:00:00 2001 From: Fini Date: Wed, 22 Apr 2026 08:55:00 +0800 Subject: [PATCH] feat(ai): add_spinner_v0 + add_tooltip_v0 (52nd, 53rd tools) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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). --- apps/web/server/api/mcp/exec-tool.post.ts | 4 + .../anti-patterns-builder-clean.test.ts | 4 + .../__tests__/detectors-builder-clean.test.ts | 4 + .../role-resolver-builder-coverage.test.ts | 4 + .../ai/__tests__/shim-server-parity.test.ts | 12 ++ .../services/ai/element-tool-shims/index.ts | 6 + packages/pen-ai-skills/corpus/ab-v1/README.md | 4 +- .../corpus/ab-v1/mobile-help-tooltip.yaml | 14 +++ .../skills/phases/generation/elements.md | 9 ++ .../corpus/__tests__/corpus-loader.test.ts | 9 +- .../__tests__/element-builders-layout.test.ts | 4 + ...nt-builders-normalize-preservation.test.ts | 4 + .../element-builders-performance.test.ts | 4 + ...t-builders-post-process-idempotent.test.ts | 4 + .../pen-core/src/element-builders/index.ts | 2 + .../pen-core/src/element-builders/spinner.ts | 69 ++++++++++++ .../pen-core/src/element-builders/tooltip.ts | 56 +++++++++ packages/pen-core/src/index.ts | 4 + .../src/__tests__/add-spinner-v0.test.ts | 106 ++++++++++++++++++ .../src/__tests__/add-tooltip-v0.test.ts | 83 ++++++++++++++ .../src/routes/element-tool-defs-ext.ts | 57 ++++++++++ .../pen-mcp/src/routes/element-tool-defs.ts | 6 + packages/pen-mcp/src/tools/add-spinner-v0.ts | 22 ++++ packages/pen-mcp/src/tools/add-tooltip-v0.ts | 22 ++++ 24 files changed, 507 insertions(+), 6 deletions(-) create mode 100644 packages/pen-ai-skills/corpus/ab-v1/mobile-help-tooltip.yaml create mode 100644 packages/pen-core/src/element-builders/spinner.ts create mode 100644 packages/pen-core/src/element-builders/tooltip.ts create mode 100644 packages/pen-mcp/src/__tests__/add-spinner-v0.test.ts create mode 100644 packages/pen-mcp/src/__tests__/add-tooltip-v0.test.ts create mode 100644 packages/pen-mcp/src/tools/add-spinner-v0.ts create mode 100644 packages/pen-mcp/src/tools/add-tooltip-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 f28d7b93e..163110e3f 100644 --- a/apps/web/server/api/mcp/exec-tool.post.ts +++ b/apps/web/server/api/mcp/exec-tool.post.ts @@ -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 = { add_comment_v0: (a) => buildComment(a as Parameters[0]), add_modal_shell_v0: (a) => buildModalShell(a as Parameters[0]), add_status_badge_v0: (a) => buildStatusBadge(a as Parameters[0]), + add_spinner_v0: (a) => buildSpinner(a as Parameters[0]), + add_tooltip_v0: (a) => buildTooltip(a as Parameters[0]), add_nav_chip_row_v0: (a) => buildNavChipRow(a as Parameters[0]), add_activity_ring_v0: (a) => buildActivityRing(a as Parameters[0]), add_rating_stars_v0: (a) => buildRatingStars(a as Parameters[0]), diff --git a/apps/web/src/services/ai/__tests__/anti-patterns-builder-clean.test.ts b/apps/web/src/services/ai/__tests__/anti-patterns-builder-clean.test.ts index bab1e36ca..05ba5f48d 100644 --- a/apps/web/src/services/ai/__tests__/anti-patterns-builder-clean.test.ts +++ b/apps/web/src/services/ai/__tests__/anti-patterns-builder-clean.test.ts @@ -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', diff --git a/apps/web/src/services/ai/__tests__/detectors-builder-clean.test.ts b/apps/web/src/services/ai/__tests__/detectors-builder-clean.test.ts index c1941ec22..bece63724 100644 --- a/apps/web/src/services/ai/__tests__/detectors-builder-clean.test.ts +++ b/apps/web/src/services/ai/__tests__/detectors-builder-clean.test.ts @@ -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', diff --git a/apps/web/src/services/ai/__tests__/role-resolver-builder-coverage.test.ts b/apps/web/src/services/ai/__tests__/role-resolver-builder-coverage.test.ts index 1a10663d8..9b557a69a 100644 --- a/apps/web/src/services/ai/__tests__/role-resolver-builder-coverage.test.ts +++ b/apps/web/src/services/ai/__tests__/role-resolver-builder-coverage.test.ts @@ -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', 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 f576013f7..68a537ab4 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 @@ -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[0]), }, + { + toolName: 'add_spinner_v0', + args: {}, + build: (a) => buildSpinner(a as unknown as Parameters[0]), + }, + { + toolName: 'add_tooltip_v0', + args: { text: 'Help' }, + build: (a) => buildTooltip(a as unknown as Parameters[0]), + }, { toolName: 'add_form_field_v0', args: { label: 'Email' }, 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 ed694972a..b24e36af7 100644 --- a/apps/web/src/services/ai/element-tool-shims/index.ts +++ b/apps/web/src/services/ai/element-tool-shims/index.ts @@ -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 = { add_comment_v0: wrap(buildComment), add_modal_shell_v0: wrap(buildModalShell), add_status_badge_v0: wrap(buildStatusBadge), + add_spinner_v0: wrap(buildSpinner), + add_tooltip_v0: wrap(buildTooltip), add_nav_chip_row_v0: wrap(buildNavChipRow), add_activity_ring_v0: wrap(buildActivityRing), add_rating_stars_v0: wrap(buildRatingStars), diff --git a/packages/pen-ai-skills/corpus/ab-v1/README.md b/packages/pen-ai-skills/corpus/ab-v1/README.md index 4f7fe2587..59aa61a31 100644 --- a/packages/pen-ai-skills/corpus/ab-v1/README.md +++ b/packages/pen-ai-skills/corpus/ab-v1/README.md @@ -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. diff --git a/packages/pen-ai-skills/corpus/ab-v1/mobile-help-tooltip.yaml b/packages/pen-ai-skills/corpus/ab-v1/mobile-help-tooltip.yaml new file mode 100644 index 000000000..4e6283cfa --- /dev/null +++ b/packages/pen-ai-skills/corpus/ab-v1/mobile-help-tooltip.yaml @@ -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 diff --git a/packages/pen-ai-skills/skills/phases/generation/elements.md b/packages/pen-ai-skills/skills/phases/generation/elements.md index ababc6ad3..ba5d1d13d 100644 --- a/packages/pen-ai-skills/skills/phases/generation/elements.md +++ b/packages/pen-ai-skills/skills/phases/generation/elements.md @@ -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 diff --git a/packages/pen-ai-skills/src/corpus/__tests__/corpus-loader.test.ts b/packages/pen-ai-skills/src/corpus/__tests__/corpus-loader.test.ts index 5a10aa05f..b96ece8ba 100644 --- a/packages/pen-ai-skills/src/corpus/__tests__/corpus-loader.test.ts +++ b/packages/pen-ai-skills/src/corpus/__tests__/corpus-loader.test.ts @@ -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', ]), ); }); diff --git a/packages/pen-core/src/__tests__/element-builders-layout.test.ts b/packages/pen-core/src/__tests__/element-builders-layout.test.ts index 0f90512d8..6e74673bc 100644 --- a/packages/pen-core/src/__tests__/element-builders-layout.test.ts +++ b/packages/pen-core/src/__tests__/element-builders-layout.test.ts @@ -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 diff --git a/packages/pen-core/src/__tests__/element-builders-normalize-preservation.test.ts b/packages/pen-core/src/__tests__/element-builders-normalize-preservation.test.ts index 4e3d6babe..c0a748ae8 100644 --- a/packages/pen-core/src/__tests__/element-builders-normalize-preservation.test.ts +++ b/packages/pen-core/src/__tests__/element-builders-normalize-preservation.test.ts @@ -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', diff --git a/packages/pen-core/src/__tests__/element-builders-performance.test.ts b/packages/pen-core/src/__tests__/element-builders-performance.test.ts index 56e1f20b4..589bcb9cb 100644 --- a/packages/pen-core/src/__tests__/element-builders-performance.test.ts +++ b/packages/pen-core/src/__tests__/element-builders-performance.test.ts @@ -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', diff --git a/packages/pen-core/src/__tests__/element-builders-post-process-idempotent.test.ts b/packages/pen-core/src/__tests__/element-builders-post-process-idempotent.test.ts index 69b24e342..b1b7b1838 100644 --- a/packages/pen-core/src/__tests__/element-builders-post-process-idempotent.test.ts +++ b/packages/pen-core/src/__tests__/element-builders-post-process-idempotent.test.ts @@ -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', diff --git a/packages/pen-core/src/element-builders/index.ts b/packages/pen-core/src/element-builders/index.ts index b96fb9143..b7c9f64f8 100644 --- a/packages/pen-core/src/element-builders/index.ts +++ b/packages/pen-core/src/element-builders/index.ts @@ -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'; diff --git a/packages/pen-core/src/element-builders/spinner.ts b/packages/pen-core/src/element-builders/spinner.ts new file mode 100644 index 000000000..292946c6b --- /dev/null +++ b/packages/pen-core/src/element-builders/spinner.ts @@ -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 }] }, + }, + ], + }; +} diff --git a/packages/pen-core/src/element-builders/tooltip.ts b/packages/pen-core/src/element-builders/tooltip.ts new file mode 100644 index 000000000..2133f4a76 --- /dev/null +++ b/packages/pen-core/src/element-builders/tooltip.ts @@ -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' }], + }, + ], + }; +} diff --git a/packages/pen-core/src/index.ts b/packages/pen-core/src/index.ts index c4e5d9fc1..ebc1b7094 100644 --- a/packages/pen-core/src/index.ts +++ b/packages/pen-core/src/index.ts @@ -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, diff --git a/packages/pen-mcp/src/__tests__/add-spinner-v0.test.ts b/packages/pen-mcp/src/__tests__/add-spinner-v0.test.ts new file mode 100644 index 000000000..00b0ee365 --- /dev/null +++ b/packages/pen-mcp/src/__tests__/add-spinner-v0.test.ts @@ -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 { + 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_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[]; + 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[])[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[]; + 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); + }); +}); diff --git a/packages/pen-mcp/src/__tests__/add-tooltip-v0.test.ts b/packages/pen-mcp/src/__tests__/add-tooltip-v0.test.ts new file mode 100644 index 000000000..6f2b3f250 --- /dev/null +++ b/packages/pen-mcp/src/__tests__/add-tooltip-v0.test.ts @@ -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 { + 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_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[]; + 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); + }); +}); 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 383fccde1..abba4c959 100644 --- a/packages/pen-mcp/src/routes/element-tool-defs-ext.ts +++ b/packages/pen-mcp/src/routes/element-tool-defs-ext.ts @@ -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-` on outer role. Default "top".', + }, + parent_id: parentIdProp, + pageId: pageIdProp, + }, + required: ['text'], + }, + }, { name: 'add_image_placeholder_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 a94c02199..b4d71a1d7 100644 --- a/packages/pen-mcp/src/routes/element-tool-defs.ts +++ b/packages/pen-mcp/src/routes/element-tool-defs.ts @@ -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>> { + await ensureParentExists(params); + const s = buildSpinner(params); + assignIdsRecursively(s); + return insertElementTree({ binding: 'sp', tree: s, ...params }); +} diff --git a/packages/pen-mcp/src/tools/add-tooltip-v0.ts b/packages/pen-mcp/src/tools/add-tooltip-v0.ts new file mode 100644 index 000000000..4412ebc95 --- /dev/null +++ b/packages/pen-mcp/src/tools/add-tooltip-v0.ts @@ -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>> { + await ensureParentExists(params); + const t = buildTooltip(params); + assignIdsRecursively(t); + return insertElementTree({ binding: 'tt', tree: t, ...params }); +}