From baa414bdedeffa55b326ddbbbe1882a4466cd490 Mon Sep 17 00:00:00 2001 From: Fini Date: Wed, 22 Apr 2026 08:50:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai):=20add=5Fstatus=5Fbadge=5Fv0=20?= =?UTF-8?q?=E2=80=94=20semantic=20status=20indicator=20(51st=20tool)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Small colored dot + short label: "● Online" / "● Busy" / "● Error" pattern. Distinguished from the more general add_badge_v0 (just a pill label) by always having a dot. tone enum picks dot color: - success → emerald #10B981 - warning → amber #F59E0B - error → red #EF4444 - info → blue #3B82F6 - neutral → slate #94A3B8 (default) Dot uses `frame + cornerRadius=4`, NEVER `ellipse` — an 8×8 ellipse is the classic "status dot via stacked ellipses" anti-pattern bait. Keeping it a frame stays clean of rewriteLlmAntiPatterns. Regression test locked in pen-mcp/add-status-badge-v0.test.ts. Wired across all three paths + elements.md entry + keyword map + examples + ab-v1/dashboard-server-status.yaml corpus prompt + parametric builder test CASES in 7 files. Tool count: 50 → 51. Test count: 3114 → 3138 (+24). --- apps/web/server/api/mcp/exec-tool.post.ts | 2 + .../anti-patterns-builder-clean.test.ts | 2 + .../__tests__/detectors-builder-clean.test.ts | 2 + .../role-resolver-builder-coverage.test.ts | 2 + .../ai/__tests__/shim-server-parity.test.ts | 6 + .../services/ai/element-tool-shims/index.ts | 3 + packages/pen-ai-skills/corpus/ab-v1/README.md | 4 +- .../corpus/ab-v1/dashboard-server-status.yaml | 15 +++ .../skills/phases/generation/elements.md | 7 + .../corpus/__tests__/corpus-loader.test.ts | 9 +- .../__tests__/element-builders-layout.test.ts | 6 + ...nt-builders-normalize-preservation.test.ts | 2 + .../element-builders-performance.test.ts | 2 + ...t-builders-post-process-idempotent.test.ts | 2 + .../pen-core/src/element-builders/index.ts | 1 + .../src/element-builders/status-badge.ts | 79 ++++++++++++ packages/pen-core/src/index.ts | 3 + .../src/__tests__/add-status-badge-v0.test.ts | 120 ++++++++++++++++++ .../src/routes/element-tool-defs-ext.ts | 25 ++++ .../pen-mcp/src/routes/element-tool-defs.ts | 3 + .../pen-mcp/src/tools/add-status-badge-v0.ts | 22 ++++ 21 files changed, 311 insertions(+), 6 deletions(-) create mode 100644 packages/pen-ai-skills/corpus/ab-v1/dashboard-server-status.yaml create mode 100644 packages/pen-core/src/element-builders/status-badge.ts create mode 100644 packages/pen-mcp/src/__tests__/add-status-badge-v0.test.ts create mode 100644 packages/pen-mcp/src/tools/add-status-badge-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 4ddd880dc..f28d7b93e 100644 --- a/apps/web/server/api/mcp/exec-tool.post.ts +++ b/apps/web/server/api/mcp/exec-tool.post.ts @@ -43,6 +43,7 @@ import { buildSelect, buildSkeleton, buildStatGrid, + buildStatusBadge, buildStepper, buildSwitch, buildTabs, @@ -146,6 +147,7 @@ const SERVER_BUILDERS: Record = { buildImagePlaceholder(a as Parameters[0]), 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_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 964b552c7..bab1e36ca 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 @@ -52,6 +52,7 @@ import { buildSelect, buildSkeleton, buildStatGrid, + buildStatusBadge, buildStepper, buildSwitch, buildTabs, @@ -189,6 +190,7 @@ const CASES: BuilderCase[] = [ build: () => buildComment({ author: 'Alice', body: 'Great post!', avatar_initial: 'A' }), }, { name: 'modal-shell', build: () => buildModalShell({ title: 'Confirm' }) }, + { name: 'status-badge', build: () => buildStatusBadge({ label: 'Online', tone: 'success' }) }, { 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 00f0ebd08..c1941ec22 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 @@ -44,6 +44,7 @@ import { buildSelect, buildSkeleton, buildStatGrid, + buildStatusBadge, buildStepper, buildSwitch, buildTabs, @@ -183,6 +184,7 @@ const CASES: BuilderCase[] = [ build: () => buildComment({ author: 'Alice', body: 'Great post!', avatar_initial: 'A' }), }, { name: 'modal-shell', build: () => buildModalShell({ title: 'Confirm' }) }, + { name: 'status-badge', build: () => buildStatusBadge({ label: 'Online', tone: 'success' }) }, { 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 b28c49b58..1a10663d8 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 @@ -53,6 +53,7 @@ import { buildSelect, buildSkeleton, buildStatGrid, + buildStatusBadge, buildStepper, buildSwitch, buildTabs, @@ -206,6 +207,7 @@ const CASES: BuilderCase[] = [ build: () => buildComment({ author: 'Alice', body: 'Great post!', avatar_initial: 'A' }), }, { name: 'modal-shell', build: () => buildModalShell({ title: 'Confirm' }) }, + { name: 'status-badge', build: () => buildStatusBadge({ label: 'Online', tone: 'success' }) }, { 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 efccf45c8..f576013f7 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 @@ -50,6 +50,7 @@ import { buildSelect, buildSkeleton, buildStatGrid, + buildStatusBadge, buildStepper, buildSwitch, buildTabs, @@ -285,6 +286,11 @@ const CASES: BuilderCase[] = [ args: { title: 'Confirm', subtitle: 'Are you sure?' }, build: (a) => buildModalShell(a as unknown as Parameters[0]), }, + { + toolName: 'add_status_badge_v0', + args: { label: 'Online', tone: 'success' }, + build: (a) => buildStatusBadge(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 66ab5fb34..ed694972a 100644 --- a/apps/web/src/services/ai/element-tool-shims/index.ts +++ b/apps/web/src/services/ai/element-tool-shims/index.ts @@ -61,6 +61,7 @@ import { buildSelect, buildSkeleton, buildStatGrid, + buildStatusBadge, buildStepper, buildSwitch, buildTabs, @@ -112,6 +113,7 @@ import { type SelectParams, type SkeletonParams, type StatGridParams, + type StatusBadgeParams, type StepperParams, type SwitchParams, type TabsParams, @@ -247,6 +249,7 @@ export const ELEMENT_SHIMS: Record = { add_image_placeholder_v0: wrap(buildImagePlaceholder), add_comment_v0: wrap(buildComment), add_modal_shell_v0: wrap(buildModalShell), + add_status_badge_v0: wrap(buildStatusBadge), 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 2465ecd6e..4f7fe2587 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 8 element tools added after the +Supplemental corpus covering the 9 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: 8 `obvious` prompts, one per new tool. No `optional` prompts +Split: 9 `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/dashboard-server-status.yaml b/packages/pen-ai-skills/corpus/ab-v1/dashboard-server-status.yaml new file mode 100644 index 000000000..90c2dc64e --- /dev/null +++ b/packages/pen-ai-skills/corpus/ab-v1/dashboard-server-status.yaml @@ -0,0 +1,15 @@ +id: dashboard-server-status +category: dashboard +difficulty: obvious +prompt: | + Design ONLY a single status indicator: a small green dot (about + 8px) with the label "Online" next to it, suggesting a server or + service is healthy. Compact inline layout. Do NOT include + surrounding dashboard chrome, service name, metrics, or any + other content — just the dot + label. +expected: + must_contain_roles: + - status-badge + - status-dot + - status-label +expected_tool_if_any: add_status_badge_v0 diff --git a/packages/pen-ai-skills/skills/phases/generation/elements.md b/packages/pen-ai-skills/skills/phases/generation/elements.md index 55d7b1298..ababc6ad3 100644 --- a/packages/pen-ai-skills/skills/phases/generation/elements.md +++ b/packages/pen-ai-skills/skills/phases/generation/elements.md @@ -129,6 +129,10 @@ Chrome / modals: 46. Modal dialog shell (dimmed backdrop + centered card + title — body composed separately) → `add_modal_shell_v0` +Status / presence: + +47. Status badge (small colored dot + short label, "● Online" pattern, tone-enum'd) → `add_status_badge_v0` + Activity / history: 41. Vertical timeline (dots + fixed 24px connectors + content; no padding/gap) → `add_timeline_v0` @@ -176,6 +180,7 @@ PREFER an element tool when the spec says any of: - "image placeholder", "photo slot", "upload zone", "hero image area", "cover placeholder", "图片占位" → `add_image_placeholder_v0` - "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` - "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) @@ -313,6 +318,8 @@ add_chart_pie_v0({ values: [1, 1, 1, 1], inner_radius_ratio: 0.5 }) // donut add_image_placeholder_v0({ width: 320, height: 200, label: "Upload cover" }) add_comment_v0({ author: "Sarah", timestamp: "2h ago", body: "Looks great!", avatar_initial: "S" }) 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_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 30c8b8b26..5a10aa05f 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 8 prompts, all obvious, one per new tool', () => { + it('loads 9 prompts, all obvious, one per new tool', () => { const prompts = loadCorpus(REPO_CORPUS_V1_DIR); - expect(prompts).toHaveLength(8); - expect(new Set(prompts.map((p) => p.id)).size).toBe(8); + expect(prompts).toHaveLength(9); + expect(new Set(prompts.map((p) => p.id)).size).toBe(9); for (const p of prompts) { expect(p.difficulty).toBe('obvious'); expect(p.expected_tool_if_any).toMatch(/^add_[a-z_]+_v0$/); } }); - it('covers the 8 specific tools added 2026-04-22', () => { + it('covers the 9 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( @@ -92,6 +92,7 @@ describe('loadCorpus — v1 supplemental corpus (new tools)', () => { 'add_image_placeholder_v0', 'add_comment_v0', 'add_modal_shell_v0', + 'add_status_badge_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 1d2266a95..0f90512d8 100644 --- a/packages/pen-core/src/__tests__/element-builders-layout.test.ts +++ b/packages/pen-core/src/__tests__/element-builders-layout.test.ts @@ -46,6 +46,7 @@ import { buildSelect, buildSkeleton, buildStatGrid, + buildStatusBadge, buildStepper, buildSwitch, buildTabs, @@ -199,6 +200,11 @@ const CASES: LayoutCase[] = [ wrap: true, }, { name: 'buildModalShell', tree: () => buildModalShell({ title: 'Confirm' }), wrap: true }, + { + name: 'buildStatusBadge', + tree: () => buildStatusBadge({ label: 'Online', tone: 'success' }), + 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 5ec579923..4e3d6babe 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 @@ -44,6 +44,7 @@ import { buildSelect, buildSkeleton, buildStatGrid, + buildStatusBadge, buildStepper, buildSwitch, buildTabs, @@ -185,6 +186,7 @@ const CASES: BuilderCase[] = [ build: () => buildComment({ author: 'Alice', body: 'Great post!', avatar_initial: 'A' }), }, { name: 'modal-shell', build: () => buildModalShell({ title: 'Confirm' }) }, + { name: 'status-badge', build: () => buildStatusBadge({ label: 'Online', tone: 'success' }) }, { 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 bc1a018cf..56e1f20b4 100644 --- a/packages/pen-core/src/__tests__/element-builders-performance.test.ts +++ b/packages/pen-core/src/__tests__/element-builders-performance.test.ts @@ -42,6 +42,7 @@ import { buildSelect, buildSkeleton, buildStatGrid, + buildStatusBadge, buildStepper, buildSwitch, buildTabs, @@ -165,6 +166,7 @@ const CASES: PerfCase[] = [ build: () => buildComment({ author: 'Alice', body: 'Great post!', avatar_initial: 'A' }), }, { name: 'modal-shell', build: () => buildModalShell({ title: 'Confirm' }) }, + { name: 'status-badge', build: () => buildStatusBadge({ label: 'Online', tone: 'success' }) }, { 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 b8b3af22a..69b24e342 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 @@ -44,6 +44,7 @@ import { buildSelect, buildSkeleton, buildStatGrid, + buildStatusBadge, buildStepper, buildSwitch, buildTabs, @@ -189,6 +190,7 @@ const CASES: BuilderCase[] = [ build: () => buildComment({ author: 'Alice', body: 'Great post!', avatar_initial: 'A' }), }, { name: 'modal-shell', build: () => buildModalShell({ title: 'Confirm' }) }, + { name: 'status-badge', build: () => buildStatusBadge({ label: 'Online', tone: 'success' }) }, { 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 66db2a3a2..b96fb9143 100644 --- a/packages/pen-core/src/element-builders/index.ts +++ b/packages/pen-core/src/element-builders/index.ts @@ -45,6 +45,7 @@ export { buildChartPie, type ChartPieParams } from './chart-pie.js'; export { buildImagePlaceholder, type ImagePlaceholderParams } from './image-placeholder.js'; 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 { 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/status-badge.ts b/packages/pen-core/src/element-builders/status-badge.ts new file mode 100644 index 000000000..f90645bd5 --- /dev/null +++ b/packages/pen-core/src/element-builders/status-badge.ts @@ -0,0 +1,79 @@ +import type { ElementTree } from './helpers.js'; + +export type StatusBadgeTone = 'success' | 'warning' | 'error' | 'info' | 'neutral'; + +export interface StatusBadgeParams { + label: string; + /** + * Semantic tone — picks the dot color + subtle text color. When + * callers want a custom color palette, override via a follow-up + * batch_design U-op on the returned nodes. Default 'neutral'. + */ + tone?: StatusBadgeTone; +} + +/** + * Status indicator pill: small colored dot + short label. The "● + * Online" / "● Busy" / "● Error" pattern. Always emits a dot + * (that's what makes it visually a "status") so it stays distinct + * from the more general `add_badge_v0` which is just a pill label. + * + * Structure: + * frame(fit_content, horizontal, gap=6, alignItems=center) + * ├ frame(8×8, cornerRadius=4, fill=, role='status-dot') + * └ text(label, 13/500) + * + * Dot uses `frame + cornerRadius=4` (pill), NOT `ellipse` — a small + * 8×8 ellipse is the classic "status dot = stacked ellipses" anti- + * pattern bait. Keep it a frame to stay clean of rewriteLlmAntiPatterns. + */ +export function buildStatusBadge(params: StatusBadgeParams): ElementTree { + const tone: StatusBadgeTone = params.tone ?? 'neutral'; + const dotColor = dotColorForTone(tone); + + return { + type: 'frame', + name: 'Status Badge', + role: 'status-badge', + width: 'fit_content', + height: 'fit_content', + layout: 'horizontal', + alignItems: 'center', + gap: 6, + children: [ + { + type: 'frame', + name: 'Status Dot', + role: 'status-dot', + width: 8, + height: 8, + cornerRadius: 4, + fill: [{ type: 'solid', color: dotColor }], + }, + { + type: 'text', + name: 'Label', + role: 'status-label', + content: params.label, + fontSize: 13, + fontWeight: 500, + }, + ], + }; +} + +function dotColorForTone(tone: StatusBadgeTone): string { + switch (tone) { + case 'success': + return '#10B981'; // emerald-500 + case 'warning': + return '#F59E0B'; // amber-500 + case 'error': + return '#EF4444'; // red-500 + case 'info': + return '#3B82F6'; // blue-500 + case 'neutral': + default: + return '#94A3B8'; // slate-400 + } +} diff --git a/packages/pen-core/src/index.ts b/packages/pen-core/src/index.ts index e4079a933..c4e5d9fc1 100644 --- a/packages/pen-core/src/index.ts +++ b/packages/pen-core/src/index.ts @@ -211,6 +211,7 @@ export { buildImagePlaceholder, buildComment, buildModalShell, + buildStatusBadge, buildNavChipRow, buildActivityRing, buildRatingStars, @@ -275,6 +276,8 @@ export { type ImagePlaceholderParams, type CommentParams, type ModalShellParams, + type StatusBadgeParams, + type StatusBadgeTone, type NavChipRowItem, type NavChipRowParams, type ActivityRingParams, diff --git a/packages/pen-mcp/src/__tests__/add-status-badge-v0.test.ts b/packages/pen-mcp/src/__tests__/add-status-badge-v0.test.ts new file mode 100644 index 000000000..ba89654da --- /dev/null +++ b/packages/pen-mcp/src/__tests__/add-status-badge-v0.test.ts @@ -0,0 +1,120 @@ +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 { handleAddStatusBadgeV0 } from '../tools/add-status-badge-v0'; +import { invalidateCache } from '../document-manager'; + +const TMP = join(tmpdir(), 'openpencil-add-status-badge-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_status_badge_v0', () => { + it('registered; required label, tone enum', () => { + expect(DESIGN_TOOL_NAMES.has('add_status_badge_v0')).toBe(true); + const def = DESIGN_TOOL_DEFINITIONS.find((t) => t.name === 'add_status_badge_v0'); + expect(def?.inputSchema.required).toEqual(['label']); + const toneProp = (def?.inputSchema.properties as unknown as Record) + ?.tone; + expect(toneProp?.enum).toEqual(['success', 'warning', 'error', 'info', 'neutral']); + }); + + it('default tone (neutral): slate-400 dot + label', async () => { + const fp = await fresh('a.op'); + await handleAddStatusBadgeV0({ filePath: fp, label: 'Idle' }); + const badge = getRoot(await readDoc(fp)); + expect(badge.role).toBe('status-badge'); + expect(badge.layout).toBe('horizontal'); + const kids = badge.children as Record[]; + expect(kids.length).toBe(2); + const dot = kids[0]; + expect(dot.role).toBe('status-dot'); + expect(dot.cornerRadius).toBe(4); + expect(dot.width).toBe(8); + const dotFill = dot.fill as Array<{ color: string }>; + expect(dotFill[0].color).toBe('#94A3B8'); // slate-400 + expect(kids[1].content).toBe('Idle'); + }); + + it('tone=success → emerald dot', async () => { + const fp = await fresh('a.op'); + await handleAddStatusBadgeV0({ filePath: fp, label: 'Online', tone: 'success' }); + const dot = (getRoot(await readDoc(fp)).children as Record[])[0]; + const dotFill = dot.fill as Array<{ color: string }>; + expect(dotFill[0].color).toBe('#10B981'); + }); + + it('tone=warning → amber', async () => { + const fp = await fresh('a.op'); + await handleAddStatusBadgeV0({ filePath: fp, label: 'Degraded', tone: 'warning' }); + const dot = (getRoot(await readDoc(fp)).children as Record[])[0]; + const dotFill = dot.fill as Array<{ color: string }>; + expect(dotFill[0].color).toBe('#F59E0B'); + }); + + it('tone=error → red', async () => { + const fp = await fresh('a.op'); + await handleAddStatusBadgeV0({ filePath: fp, label: 'Down', tone: 'error' }); + const dot = (getRoot(await readDoc(fp)).children as Record[])[0]; + const dotFill = dot.fill as Array<{ color: string }>; + expect(dotFill[0].color).toBe('#EF4444'); + }); + + it('tone=info → blue', async () => { + const fp = await fresh('a.op'); + await handleAddStatusBadgeV0({ filePath: fp, label: 'New', tone: 'info' }); + const dot = (getRoot(await readDoc(fp)).children as Record[])[0]; + const dotFill = dot.fill as Array<{ color: string }>; + expect(dotFill[0].color).toBe('#3B82F6'); + }); + + it('dot is a FRAME with cornerRadius=4 (not an ellipse — anti-pattern avoidance)', async () => { + // 8×8 pill via frame+cornerRadius, NOT a tiny ellipse. Ellipse + // at that size is the classic "status dot = stacked ellipses" + // bait for rewriteLlmAntiPatterns. This test locks the frame + // approach in place. + const fp = await fresh('a.op'); + await handleAddStatusBadgeV0({ filePath: fp, label: 'X' }); + const dot = (getRoot(await readDoc(fp)).children as Record[])[0]; + expect(dot.type).toBe('frame'); + expect(dot.type).not.toBe('ellipse'); + }); + + 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( + handleAddStatusBadgeV0({ filePath: fp, label: '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 95b232f82..383fccde1 100644 --- a/packages/pen-mcp/src/routes/element-tool-defs-ext.ts +++ b/packages/pen-mcp/src/routes/element-tool-defs-ext.ts @@ -638,6 +638,31 @@ export const ELEMENT_TOOL_DEFINITIONS_EXT = [ required: [], }, }, + { + name: 'add_status_badge_v0', + description: + 'Semantic status indicator: small colored dot + short label (e.g. "● Online", "● Busy", ' + + '"● Error"). ALWAYS has a dot — that is what makes it visually a status, distinguishing ' + + 'from the more general add_badge_v0 (pill label without dot). tone picks dot color: ' + + 'success (green) / warning (amber) / error (red) / info (blue) / neutral (slate, default). ' + + 'Use for "status", "presence", "health indicator", "状态". schemaVersion 1.0', + inputSchema: { + type: 'object' as const, + properties: { + schemaVersion: schemaVersionProp, + filePath: filePathProp, + label: { type: 'string', description: 'Label text (e.g. "Online")' }, + tone: { + type: 'string', + enum: ['success', 'warning', 'error', 'info', 'neutral'], + description: 'Dot color tone. Default "neutral" (slate gray).', + }, + parent_id: parentIdProp, + pageId: pageIdProp, + }, + required: ['label'], + }, + }, { 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 35eec5e6c..a94c02199 100644 --- a/packages/pen-mcp/src/routes/element-tool-defs.ts +++ b/packages/pen-mcp/src/routes/element-tool-defs.ts @@ -42,6 +42,7 @@ import { handleAddChartPieV0 } from '../tools/add-chart-pie-v0'; 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 { handleAddSwitchV0 } from '../tools/add-switch-v0'; import { handleAddCheckboxV0 } from '../tools/add-checkbox-v0'; import { handleAddRadioV0 } from '../tools/add-radio-v0'; @@ -134,6 +135,8 @@ export async function handleElementToolCall(name: string, a: any): Promise>> { + await ensureParentExists(params); + const badge = buildStatusBadge(params); + assignIdsRecursively(badge); + return insertElementTree({ binding: 'sb', tree: badge, ...params }); +}