feat(ai): add_status_badge_v0 — semantic status indicator (51st tool)
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).
This commit is contained in:
parent
c6e938e66a
commit
baa414bded
|
|
@ -43,6 +43,7 @@ import {
|
|||
buildSelect,
|
||||
buildSkeleton,
|
||||
buildStatGrid,
|
||||
buildStatusBadge,
|
||||
buildStepper,
|
||||
buildSwitch,
|
||||
buildTabs,
|
||||
|
|
@ -146,6 +147,7 @@ const SERVER_BUILDERS: Record<string, BuilderFn> = {
|
|||
buildImagePlaceholder(a as Parameters<typeof buildImagePlaceholder>[0]),
|
||||
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_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]),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<typeof buildModalShell>[0]),
|
||||
},
|
||||
{
|
||||
toolName: 'add_status_badge_v0',
|
||||
args: { label: 'Online', tone: 'success' },
|
||||
build: (a) => buildStatusBadge(a as unknown as Parameters<typeof buildStatusBadge>[0]),
|
||||
},
|
||||
{
|
||||
toolName: 'add_form_field_v0',
|
||||
args: { label: 'Email' },
|
||||
|
|
|
|||
|
|
@ -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<string, ElementShim> = {
|
|||
add_image_placeholder_v0: wrap<ImagePlaceholderParams>(buildImagePlaceholder),
|
||||
add_comment_v0: wrap<CommentParams>(buildComment),
|
||||
add_modal_shell_v0: wrap<ModalShellParams>(buildModalShell),
|
||||
add_status_badge_v0: wrap<StatusBadgeParams>(buildStatusBadge),
|
||||
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 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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
79
packages/pen-core/src/element-builders/status-badge.ts
Normal file
79
packages/pen-core/src/element-builders/status-badge.ts
Normal file
|
|
@ -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=<toneColor>, 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
120
packages/pen-mcp/src/__tests__/add-status-badge-v0.test.ts
Normal file
120
packages/pen-mcp/src/__tests__/add-status-badge-v0.test.ts
Normal file
|
|
@ -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<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_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<string, { enum?: string[] }>)
|
||||
?.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<string, unknown>[];
|
||||
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<string, unknown>[])[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<string, unknown>[])[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<string, unknown>[])[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<string, unknown>[])[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<string, unknown>[])[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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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<strin
|
|||
return JSON.stringify(await handleAddCommentV0(a), null, 2);
|
||||
case 'add_modal_shell_v0':
|
||||
return JSON.stringify(await handleAddModalShellV0(a), null, 2);
|
||||
case 'add_status_badge_v0':
|
||||
return JSON.stringify(await handleAddStatusBadgeV0(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-status-badge-v0.ts
Normal file
22
packages/pen-mcp/src/tools/add-status-badge-v0.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { assignIdsRecursively, buildStatusBadge, type StatusBadgeParams } from '@zseven-w/pen-core';
|
||||
import type { handleBatchDesign } from './batch-design';
|
||||
import { ensureParentExists, insertElementTree } from './element-tool-helpers';
|
||||
|
||||
export interface AddStatusBadgeV0Params extends StatusBadgeParams {
|
||||
parent_id?: string;
|
||||
filePath?: string;
|
||||
pageId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status indicator: colored dot + label ("● Online"). Tree build
|
||||
* delegated to `buildStatusBadge`.
|
||||
*/
|
||||
export async function handleAddStatusBadgeV0(
|
||||
params: AddStatusBadgeV0Params,
|
||||
): Promise<Awaited<ReturnType<typeof handleBatchDesign>>> {
|
||||
await ensureParentExists(params);
|
||||
const badge = buildStatusBadge(params);
|
||||
assignIdsRecursively(badge);
|
||||
return insertElementTree({ binding: 'sb', tree: badge, ...params });
|
||||
}
|
||||
Loading…
Reference in a new issue