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:
parent
baa414bded
commit
fb306dcf79
|
|
@ -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]),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
14
packages/pen-ai-skills/corpus/ab-v1/mobile-help-tooltip.yaml
Normal file
14
packages/pen-ai-skills/corpus/ab-v1/mobile-help-tooltip.yaml
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
69
packages/pen-core/src/element-builders/spinner.ts
Normal file
69
packages/pen-core/src/element-builders/spinner.ts
Normal 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 }] },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
56
packages/pen-core/src/element-builders/tooltip.ts
Normal file
56
packages/pen-core/src/element-builders/tooltip.ts
Normal 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' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
106
packages/pen-mcp/src/__tests__/add-spinner-v0.test.ts
Normal file
106
packages/pen-mcp/src/__tests__/add-spinner-v0.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
83
packages/pen-mcp/src/__tests__/add-tooltip-v0.test.ts
Normal file
83
packages/pen-mcp/src/__tests__/add-tooltip-v0.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
22
packages/pen-mcp/src/tools/add-spinner-v0.ts
Normal file
22
packages/pen-mcp/src/tools/add-spinner-v0.ts
Normal 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 });
|
||||
}
|
||||
22
packages/pen-mcp/src/tools/add-tooltip-v0.ts
Normal file
22
packages/pen-mcp/src/tools/add-tooltip-v0.ts
Normal 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 });
|
||||
}
|
||||
Loading…
Reference in a new issue