From ddfb3fa81bb5c792a220df0261afc85eb36e12f1 Mon Sep 17 00:00:00 2001 From: Fini Date: Wed, 29 Apr 2026 09:50:21 +0800 Subject: [PATCH] feat(element-tools): add 5 atom v1 tools with theme parameter (P3 batch 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit avatar-v1, badge-v1, divider-v1, body_text-v1, icon_label-v1 — each with full 9-touchpoint coverage (pen-core builder + index + pen-mcp handler + schema shard + dispatcher + apps/web shim + SERVER_BUILDERS + parity test + elements.md). Light mode is byte-equal to v0; dark/system modes produce identical output since all 5 tools emit zero hardcoded color fills — theme param accepted for API consistency across all v1 tools. New shard element-tool-defs-ext-5.ts created (ext-4 was at 739 lines). All 2026 pen-core + pen-mcp tests pass; format:check + tsc clean. --- apps/web/server/api/mcp/exec-tool.post.ts | 10 ++ .../ai/__tests__/shim-server-parity-cases.ts | 30 ++++ .../services/ai/element-tool-shims/index.ts | 15 ++ .../skills/phases/generation/elements.md | 5 + .../pen-core/src/__tests__/avatar-v1.test.ts | 67 ++++++++ .../pen-core/src/__tests__/badge-v1.test.ts | 56 +++++++ .../src/__tests__/body-text-v1.test.ts | 81 ++++++++++ .../pen-core/src/__tests__/divider-v1.test.ts | 75 +++++++++ .../src/__tests__/icon-label-v1.test.ts | 75 +++++++++ .../src/element-builders/avatar-v1.ts | 53 +++++++ .../pen-core/src/element-builders/badge-v1.ts | 49 ++++++ .../src/element-builders/body-text-v1.ts | 47 ++++++ .../src/element-builders/divider-v1.ts | 40 +++++ .../src/element-builders/icon-label-v1.ts | 59 +++++++ .../pen-core/src/element-builders/index.ts | 5 + packages/pen-core/src/index.ts | 11 ++ .../src/routes/element-tool-defs-ext-5.ts | 149 ++++++++++++++++++ .../pen-mcp/src/routes/element-tool-defs.ts | 17 ++ packages/pen-mcp/src/tools/add-avatar-v1.ts | 24 +++ packages/pen-mcp/src/tools/add-badge-v1.ts | 24 +++ .../pen-mcp/src/tools/add-body-text-v1.ts | 25 +++ packages/pen-mcp/src/tools/add-divider-v1.ts | 24 +++ .../pen-mcp/src/tools/add-icon-label-v1.ts | 24 +++ 23 files changed, 965 insertions(+) create mode 100644 packages/pen-core/src/__tests__/avatar-v1.test.ts create mode 100644 packages/pen-core/src/__tests__/badge-v1.test.ts create mode 100644 packages/pen-core/src/__tests__/body-text-v1.test.ts create mode 100644 packages/pen-core/src/__tests__/divider-v1.test.ts create mode 100644 packages/pen-core/src/__tests__/icon-label-v1.test.ts create mode 100644 packages/pen-core/src/element-builders/avatar-v1.ts create mode 100644 packages/pen-core/src/element-builders/badge-v1.ts create mode 100644 packages/pen-core/src/element-builders/body-text-v1.ts create mode 100644 packages/pen-core/src/element-builders/divider-v1.ts create mode 100644 packages/pen-core/src/element-builders/icon-label-v1.ts create mode 100644 packages/pen-mcp/src/routes/element-tool-defs-ext-5.ts create mode 100644 packages/pen-mcp/src/tools/add-avatar-v1.ts create mode 100644 packages/pen-mcp/src/tools/add-badge-v1.ts create mode 100644 packages/pen-mcp/src/tools/add-body-text-v1.ts create mode 100644 packages/pen-mcp/src/tools/add-divider-v1.ts create mode 100644 packages/pen-mcp/src/tools/add-icon-label-v1.ts diff --git a/apps/web/server/api/mcp/exec-tool.post.ts b/apps/web/server/api/mcp/exec-tool.post.ts index b22607a57..129d85b38 100644 --- a/apps/web/server/api/mcp/exec-tool.post.ts +++ b/apps/web/server/api/mcp/exec-tool.post.ts @@ -103,6 +103,11 @@ import { buildSettingRowV1, buildMemberRowV1, buildActivityLogV1, + buildAvatarV1, + buildBadgeV1, + buildDividerV1, + buildBodyTextV1, + buildIconLabelV1, findNodeInTree, insertNodeInTree, type ElementTree, @@ -269,6 +274,11 @@ const SERVER_BUILDERS: Record = { add_setting_row_v1: (a) => buildSettingRowV1(a as Parameters[0]), add_member_row_v1: (a) => buildMemberRowV1(a as Parameters[0]), add_activity_log_v1: (a) => buildActivityLogV1(a as Parameters[0]), + add_avatar_v1: (a) => buildAvatarV1(a as Parameters[0]), + add_badge_v1: (a) => buildBadgeV1(a as Parameters[0]), + add_divider_v1: (a) => buildDividerV1(a as Parameters[0]), + add_body_text_v1: (a) => buildBodyTextV1(a as Parameters[0]), + add_icon_label_v1: (a) => buildIconLabelV1(a as Parameters[0]), }; interface ExecToolBody { diff --git a/apps/web/src/services/ai/__tests__/shim-server-parity-cases.ts b/apps/web/src/services/ai/__tests__/shim-server-parity-cases.ts index a3207dd10..804f1f5ad 100644 --- a/apps/web/src/services/ai/__tests__/shim-server-parity-cases.ts +++ b/apps/web/src/services/ai/__tests__/shim-server-parity-cases.ts @@ -112,6 +112,11 @@ import { buildSettingRowV1, buildMemberRowV1, buildActivityLogV1, + buildAvatarV1, + buildBadgeV1, + buildDividerV1, + buildBodyTextV1, + buildIconLabelV1, } from '@zseven-w/pen-core'; export interface BuilderCase { @@ -747,4 +752,29 @@ export const CASES: BuilderCase[] = [ }, build: (a) => buildActivityLogV1(a as unknown as Parameters[0]), }, + { + toolName: 'add_avatar_v1', + args: { initial: 'SL', size: 48, theme: 'light' }, + build: (a) => buildAvatarV1(a as unknown as Parameters[0]), + }, + { + toolName: 'add_badge_v1', + args: { label: 'NEW', theme: 'light' }, + build: (a) => buildBadgeV1(a as unknown as Parameters[0]), + }, + { + toolName: 'add_divider_v1', + args: { orientation: 'horizontal', thickness: 1, theme: 'light' }, + build: (a) => buildDividerV1(a as unknown as Parameters[0]), + }, + { + toolName: 'add_body_text_v1', + args: { content: 'The quick brown fox jumps over the lazy dog.', theme: 'light' }, + build: (a) => buildBodyTextV1(a as unknown as Parameters[0]), + }, + { + toolName: 'add_icon_label_v1', + args: { icon: 'star', label: 'Favorites', theme: 'light' }, + build: (a) => buildIconLabelV1(a as unknown as Parameters[0]), + }, ]; 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 23223c294..325813f61 100644 --- a/apps/web/src/services/ai/element-tool-shims/index.ts +++ b/apps/web/src/services/ai/element-tool-shims/index.ts @@ -121,6 +121,11 @@ import { buildSettingRowV1, buildMemberRowV1, buildActivityLogV1, + buildAvatarV1, + buildBadgeV1, + buildDividerV1, + buildBodyTextV1, + buildIconLabelV1, type ActionMenuParams, type ActivityRingParams, type AlertParams, @@ -194,6 +199,11 @@ import { type SettingRowV1Params, type MemberRowV1Params, type ActivityLogV1Params, + type AvatarV1Params, + type BadgeV1Params, + type DividerV1Params, + type BodyTextV1Params, + type IconLabelV1Params, type KbdParams, type LinkParams, type ListRowParams, @@ -416,6 +426,11 @@ export const ELEMENT_SHIMS: Record = { add_setting_row_v1: wrap(buildSettingRowV1), add_member_row_v1: wrap(buildMemberRowV1), add_activity_log_v1: wrap(buildActivityLogV1), + add_avatar_v1: wrap(buildAvatarV1), + add_badge_v1: wrap(buildBadgeV1), + add_divider_v1: wrap(buildDividerV1), + add_body_text_v1: wrap(buildBodyTextV1), + add_icon_label_v1: wrap(buildIconLabelV1), }; export function getElementShim(name: string): ElementShim | undefined { diff --git a/packages/pen-ai-skills/skills/phases/generation/elements.md b/packages/pen-ai-skills/skills/phases/generation/elements.md index d9b89be5a..50bb0da92 100644 --- a/packages/pen-ai-skills/skills/phases/generation/elements.md +++ b/packages/pen-ai-skills/skills/phases/generation/elements.md @@ -69,8 +69,11 @@ Containers and single elements: Atoms (1-2 node building blocks): 10. Hairline separator between list rows / sections → `add_divider_v0` + 10b. Theme-aware variant (accepts `theme` param for API consistency; output identical across themes) → `add_divider_v1` 11. Short inline pill / tag / "NEW" / "BETA" / count badge → `add_badge_v0` + 11b. Theme-aware variant (accepts `theme` param for API consistency; output identical across themes) → `add_badge_v1` 12. Circular avatar (with optional initial / empty for later image fill) → `add_avatar_v0` + 12b. Theme-aware variant (accepts `theme` param for API consistency; output identical across themes) → `add_avatar_v1` Text + button primitives: @@ -78,10 +81,12 @@ Text + button primitives: 14. Heading with enforced fontSize/lineHeight per level + AUTO CJK script detection (SC/JP/KR) → `add_heading_v0` 14b. Theme-aware variant (supports `theme: 'light' | 'dark' | 'system'`) → `add_heading_v1` 15. Body text (Inter everywhere — CJK gets lineHeight 1.6 + letterSpacing 0, Latin 1.5) → `add_body_text_v0` + 15b. Theme-aware variant (accepts `theme` param for API consistency; output identical across themes) → `add_body_text_v1` Composition: 16. Icon + text inline pair (menu items, breadcrumbs, status indicators) → `add_icon_label_v0` + 16b. Theme-aware variant (accepts `theme` param for API consistency; output identical across themes) → `add_icon_label_v1` 17. iOS/Material list row (leading icon + title/subtitle stack + trailing icon) → `add_list_row_v0` Forms: diff --git a/packages/pen-core/src/__tests__/avatar-v1.test.ts b/packages/pen-core/src/__tests__/avatar-v1.test.ts new file mode 100644 index 000000000..867f7fb0a --- /dev/null +++ b/packages/pen-core/src/__tests__/avatar-v1.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'vitest'; +import { buildAvatar } from '../element-builders/avatar.js'; +import { buildAvatarV1 } from '../element-builders/avatar-v1.js'; + +function stripTheme>(obj: T): Omit { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { theme: _t, ...rest } = obj; + return rest; +} + +describe('buildAvatarV1 — byte-parity with v0 (light)', () => { + it('no initial: frame shape matches v0', () => { + const v0 = buildAvatar({}) as Record; + const v1 = buildAvatarV1({}) as Record; + expect(JSON.stringify(stripTheme(v1))).toBe(JSON.stringify(v0)); + }); + + it('with initial: full tree matches v0', () => { + const v0 = buildAvatar({ initial: 'SL', size: 48 }) as Record; + const v1 = buildAvatarV1({ initial: 'SL', size: 48 }) as Record; + expect(JSON.stringify(stripTheme(v1))).toBe(JSON.stringify(v0)); + }); + + it('explicit theme=light still matches v0', () => { + const v0 = buildAvatar({ initial: 'A' }) as Record; + const v1 = buildAvatarV1({ initial: 'A', theme: 'light' }) as Record; + expect(JSON.stringify(stripTheme(v1))).toBe(JSON.stringify(v0)); + }); + + it('cornerRadius = size/2', () => { + const v1 = buildAvatarV1({ size: 60 }) as Record; + expect(v1.cornerRadius).toBe(30); + }); + + it('initial fontSize = max(12, round(size * 0.4))', () => { + const v1 = buildAvatarV1({ initial: 'X', size: 40 }) as Record; + const children = v1.children as Array>; + expect(children[0].fontSize).toBe(16); // max(12, round(40 * 0.4)) = 16 + }); +}); + +describe('buildAvatarV1 — dark mode (no-color tool, identical to light)', () => { + it('theme=dark output identical to theme=light', () => { + const light = buildAvatarV1({ initial: 'SL', size: 48 }) as Record; + const dark = buildAvatarV1({ initial: 'SL', size: 48, theme: 'dark' }) as Record< + string, + unknown + >; + expect(JSON.stringify(stripTheme(light))).toBe(JSON.stringify(stripTheme(dark))); + }); +}); + +describe('buildAvatarV1 — system mode (no-color tool, identical to light)', () => { + it('theme=system output identical to theme=light', () => { + const light = buildAvatarV1({ initial: 'SL', size: 48 }) as Record; + const system = buildAvatarV1({ initial: 'SL', size: 48, theme: 'system' }) as Record< + string, + unknown + >; + expect(JSON.stringify(stripTheme(light))).toBe(JSON.stringify(stripTheme(system))); + }); + + it('no $color-* refs emitted (theme param is accepted but has no effect)', () => { + const system = JSON.stringify(buildAvatarV1({ initial: 'SL', theme: 'system' })); + expect(system).not.toContain('$color-'); + }); +}); diff --git a/packages/pen-core/src/__tests__/badge-v1.test.ts b/packages/pen-core/src/__tests__/badge-v1.test.ts new file mode 100644 index 000000000..468cfa19d --- /dev/null +++ b/packages/pen-core/src/__tests__/badge-v1.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; +import { buildBadge } from '../element-builders/badge.js'; +import { buildBadgeV1 } from '../element-builders/badge-v1.js'; + +function stripTheme>(obj: T): Omit { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { theme: _t, ...rest } = obj; + return rest; +} + +describe('buildBadgeV1 — byte-parity with v0 (light)', () => { + it('output matches v0 (no theme)', () => { + const v0 = buildBadge({ label: 'NEW' }) as Record; + const v1 = buildBadgeV1({ label: 'NEW' }) as Record; + expect(JSON.stringify(stripTheme(v1))).toBe(JSON.stringify(v0)); + }); + + it('explicit theme=light still matches v0', () => { + const v0 = buildBadge({ label: 'BETA' }) as Record; + const v1 = buildBadgeV1({ label: 'BETA', theme: 'light' }) as Record; + expect(JSON.stringify(stripTheme(v1))).toBe(JSON.stringify(v0)); + }); + + it('cornerRadius=999 (full pill)', () => { + const v1 = buildBadgeV1({ label: '42' }) as Record; + expect(v1.cornerRadius).toBe(999); + }); + + it('label child: fontSize=11, fontWeight=600', () => { + const v1 = buildBadgeV1({ label: 'BETA' }) as Record; + const children = v1.children as Array>; + expect(children[0].fontSize).toBe(11); + expect(children[0].fontWeight).toBe(600); + }); +}); + +describe('buildBadgeV1 — dark mode (no-color tool, identical to light)', () => { + it('theme=dark output identical to theme=light', () => { + const light = buildBadgeV1({ label: 'NEW' }) as Record; + const dark = buildBadgeV1({ label: 'NEW', theme: 'dark' }) as Record; + expect(JSON.stringify(stripTheme(light))).toBe(JSON.stringify(stripTheme(dark))); + }); +}); + +describe('buildBadgeV1 — system mode (no-color tool, identical to light)', () => { + it('theme=system output identical to theme=light', () => { + const light = buildBadgeV1({ label: 'NEW' }) as Record; + const system = buildBadgeV1({ label: 'NEW', theme: 'system' }) as Record; + expect(JSON.stringify(stripTheme(light))).toBe(JSON.stringify(stripTheme(system))); + }); + + it('no $color-* refs emitted', () => { + const system = JSON.stringify(buildBadgeV1({ label: 'TEST', theme: 'system' })); + expect(system).not.toContain('$color-'); + }); +}); diff --git a/packages/pen-core/src/__tests__/body-text-v1.test.ts b/packages/pen-core/src/__tests__/body-text-v1.test.ts new file mode 100644 index 000000000..fc03446ac --- /dev/null +++ b/packages/pen-core/src/__tests__/body-text-v1.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from 'vitest'; +import { buildBodyText } from '../element-builders/body-text.js'; +import { buildBodyTextV1 } from '../element-builders/body-text-v1.js'; + +function stripTheme>(obj: T): Omit { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { theme: _t, ...rest } = obj; + return rest; +} + +const LATIN_TEXT = 'The quick brown fox jumps over the lazy dog.'; +const CJK_TEXT = '快速的棕色狐狸跳过了懒惰的狗。'; + +describe('buildBodyTextV1 — byte-parity with v0 (light)', () => { + it('Latin content matches v0', () => { + const v0 = buildBodyText({ content: LATIN_TEXT }) as Record; + const v1 = buildBodyTextV1({ content: LATIN_TEXT }) as Record; + expect(JSON.stringify(stripTheme(v1))).toBe(JSON.stringify(v0)); + }); + + it('CJK content matches v0 (lineHeight=1.6, letterSpacing=0)', () => { + const v0 = buildBodyText({ content: CJK_TEXT }) as Record; + const v1 = buildBodyTextV1({ content: CJK_TEXT }) as Record; + expect(JSON.stringify(stripTheme(v1))).toBe(JSON.stringify(v0)); + }); + + it('explicit theme=light still matches v0', () => { + const v0 = buildBodyText({ content: LATIN_TEXT }) as Record; + const v1 = buildBodyTextV1({ content: LATIN_TEXT, theme: 'light' }) as Record; + expect(JSON.stringify(stripTheme(v1))).toBe(JSON.stringify(v0)); + }); + + it('Latin: lineHeight=1.5, no letterSpacing', () => { + const v1 = buildBodyTextV1({ content: LATIN_TEXT }) as Record; + expect(v1.lineHeight).toBe(1.5); + expect(v1.letterSpacing).toBeUndefined(); + }); + + it('CJK: lineHeight=1.6, letterSpacing=0', () => { + const v1 = buildBodyTextV1({ content: CJK_TEXT }) as Record; + expect(v1.lineHeight).toBe(1.6); + expect(v1.letterSpacing).toBe(0); + }); + + it('always fontFamily=Inter, width=fill_container, textGrowth=fixed-width', () => { + const v1 = buildBodyTextV1({ content: LATIN_TEXT }) as Record; + expect(v1.fontFamily).toBe('Inter'); + expect(v1.width).toBe('fill_container'); + expect(v1.textGrowth).toBe('fixed-width'); + }); +}); + +describe('buildBodyTextV1 — dark mode (no-color tool, identical to light)', () => { + it('theme=dark output identical to theme=light (Latin)', () => { + const light = buildBodyTextV1({ content: LATIN_TEXT }) as Record; + const dark = buildBodyTextV1({ content: LATIN_TEXT, theme: 'dark' }) as Record; + expect(JSON.stringify(stripTheme(light))).toBe(JSON.stringify(stripTheme(dark))); + }); + + it('theme=dark output identical to theme=light (CJK)', () => { + const light = buildBodyTextV1({ content: CJK_TEXT }) as Record; + const dark = buildBodyTextV1({ content: CJK_TEXT, theme: 'dark' }) as Record; + expect(JSON.stringify(stripTheme(light))).toBe(JSON.stringify(stripTheme(dark))); + }); +}); + +describe('buildBodyTextV1 — system mode (no-color tool, identical to light)', () => { + it('theme=system output identical to theme=light', () => { + const light = buildBodyTextV1({ content: LATIN_TEXT }) as Record; + const system = buildBodyTextV1({ content: LATIN_TEXT, theme: 'system' }) as Record< + string, + unknown + >; + expect(JSON.stringify(stripTheme(light))).toBe(JSON.stringify(stripTheme(system))); + }); + + it('no $color-* refs emitted', () => { + const system = JSON.stringify(buildBodyTextV1({ content: LATIN_TEXT, theme: 'system' })); + expect(system).not.toContain('$color-'); + }); +}); diff --git a/packages/pen-core/src/__tests__/divider-v1.test.ts b/packages/pen-core/src/__tests__/divider-v1.test.ts new file mode 100644 index 000000000..0ba00ae22 --- /dev/null +++ b/packages/pen-core/src/__tests__/divider-v1.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from 'vitest'; +import { buildDivider } from '../element-builders/divider.js'; +import { buildDividerV1 } from '../element-builders/divider-v1.js'; + +function stripTheme>(obj: T): Omit { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { theme: _t, ...rest } = obj; + return rest; +} + +describe('buildDividerV1 — byte-parity with v0 (light)', () => { + it('default horizontal matches v0', () => { + const v0 = buildDivider({}) as Record; + const v1 = buildDividerV1({}) as Record; + expect(JSON.stringify(stripTheme(v1))).toBe(JSON.stringify(v0)); + }); + + it('vertical orientation matches v0', () => { + const v0 = buildDivider({ orientation: 'vertical' }) as Record; + const v1 = buildDividerV1({ orientation: 'vertical' }) as Record; + expect(JSON.stringify(stripTheme(v1))).toBe(JSON.stringify(v0)); + }); + + it('custom thickness matches v0', () => { + const v0 = buildDivider({ thickness: 2 }) as Record; + const v1 = buildDividerV1({ thickness: 2 }) as Record; + expect(JSON.stringify(stripTheme(v1))).toBe(JSON.stringify(v0)); + }); + + it('explicit theme=light still matches v0', () => { + const v0 = buildDivider({ orientation: 'horizontal', thickness: 1 }) as Record; + const v1 = buildDividerV1({ + orientation: 'horizontal', + thickness: 1, + theme: 'light', + }) as Record; + expect(JSON.stringify(stripTheme(v1))).toBe(JSON.stringify(v0)); + }); + + it('horizontal: width=fill_container, height=1', () => { + const v1 = buildDividerV1({}) as Record; + expect(v1.width).toBe('fill_container'); + expect(v1.height).toBe(1); + }); + + it('vertical: width=1, height=fill_container', () => { + const v1 = buildDividerV1({ orientation: 'vertical' }) as Record; + expect(v1.width).toBe(1); + expect(v1.height).toBe('fill_container'); + }); +}); + +describe('buildDividerV1 — dark mode (no-color tool, identical to light)', () => { + it('theme=dark output identical to theme=light', () => { + const light = buildDividerV1({ orientation: 'horizontal' }) as Record; + const dark = buildDividerV1({ orientation: 'horizontal', theme: 'dark' }) as Record< + string, + unknown + >; + expect(JSON.stringify(stripTheme(light))).toBe(JSON.stringify(stripTheme(dark))); + }); +}); + +describe('buildDividerV1 — system mode (no-color tool, identical to light)', () => { + it('theme=system output identical to theme=light', () => { + const light = buildDividerV1({}) as Record; + const system = buildDividerV1({ theme: 'system' }) as Record; + expect(JSON.stringify(stripTheme(light))).toBe(JSON.stringify(stripTheme(system))); + }); + + it('no $color-* refs emitted', () => { + const system = JSON.stringify(buildDividerV1({ theme: 'system' })); + expect(system).not.toContain('$color-'); + }); +}); diff --git a/packages/pen-core/src/__tests__/icon-label-v1.test.ts b/packages/pen-core/src/__tests__/icon-label-v1.test.ts new file mode 100644 index 000000000..7bccfe273 --- /dev/null +++ b/packages/pen-core/src/__tests__/icon-label-v1.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from 'vitest'; +import { buildIconLabel } from '../element-builders/icon-label.js'; +import { buildIconLabelV1 } from '../element-builders/icon-label-v1.js'; + +function stripTheme>(obj: T): Omit { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { theme: _t, ...rest } = obj; + return rest; +} + +const BASIC = { icon: 'star', label: 'Favorites' }; + +describe('buildIconLabelV1 — byte-parity with v0 (light)', () => { + it('default params match v0', () => { + const v0 = buildIconLabel(BASIC) as Record; + const v1 = buildIconLabelV1(BASIC) as Record; + expect(JSON.stringify(stripTheme(v1))).toBe(JSON.stringify(v0)); + }); + + it('custom gap matches v0', () => { + const v0 = buildIconLabel({ ...BASIC, gap: 12 }) as Record; + const v1 = buildIconLabelV1({ ...BASIC, gap: 12 }) as Record; + expect(JSON.stringify(stripTheme(v1))).toBe(JSON.stringify(v0)); + }); + + it('explicit theme=light still matches v0', () => { + const v0 = buildIconLabel(BASIC) as Record; + const v1 = buildIconLabelV1({ ...BASIC, theme: 'light' }) as Record; + expect(JSON.stringify(stripTheme(v1))).toBe(JSON.stringify(v0)); + }); + + it('icon child: icon_font with lucide family, 16×16', () => { + const v1 = buildIconLabelV1(BASIC) as Record; + const children = v1.children as Array>; + const icon = children[0]; + expect(icon.type).toBe('icon_font'); + expect(icon.iconFontFamily).toBe('lucide'); + expect(icon.width).toBe(16); + expect(icon.height).toBe(16); + }); + + it('label child: fontSize=14, fontWeight=500', () => { + const v1 = buildIconLabelV1(BASIC) as Record; + const children = v1.children as Array>; + const label = children[1]; + expect(label.fontSize).toBe(14); + expect(label.fontWeight).toBe(500); + }); + + it('default gap=8', () => { + const v1 = buildIconLabelV1(BASIC) as Record; + expect(v1.gap).toBe(8); + }); +}); + +describe('buildIconLabelV1 — dark mode (no-color tool, identical to light)', () => { + it('theme=dark output identical to theme=light', () => { + const light = buildIconLabelV1(BASIC) as Record; + const dark = buildIconLabelV1({ ...BASIC, theme: 'dark' }) as Record; + expect(JSON.stringify(stripTheme(light))).toBe(JSON.stringify(stripTheme(dark))); + }); +}); + +describe('buildIconLabelV1 — system mode (no-color tool, identical to light)', () => { + it('theme=system output identical to theme=light', () => { + const light = buildIconLabelV1(BASIC) as Record; + const system = buildIconLabelV1({ ...BASIC, theme: 'system' }) as Record; + expect(JSON.stringify(stripTheme(light))).toBe(JSON.stringify(stripTheme(system))); + }); + + it('no $color-* refs emitted', () => { + const system = JSON.stringify(buildIconLabelV1({ ...BASIC, theme: 'system' })); + expect(system).not.toContain('$color-'); + }); +}); diff --git a/packages/pen-core/src/element-builders/avatar-v1.ts b/packages/pen-core/src/element-builders/avatar-v1.ts new file mode 100644 index 000000000..b72a39ef5 --- /dev/null +++ b/packages/pen-core/src/element-builders/avatar-v1.ts @@ -0,0 +1,53 @@ +import type { ElementTree } from './helpers.js'; +import type { V1Theme } from './resolve-theme.js'; + +export interface AvatarV1Params { + initial?: string; + size?: number; + /** + * Theme mode. + * - `'light'` (default): byte-parity with add_avatar_v0. + * - `'dark'`: byte-parity with light (avatar has no hardcoded color fills). + * - `'system'`: byte-parity with light (no $color-* refs needed). + * + * `buildAvatar` emits no fill literals — the frame and text nodes carry no + * fill, relying on the canvas default / parent context. Theme param is + * accepted for API consistency with other v1 tools. + */ + theme?: V1Theme; +} + +/** + * Circular avatar with optional centered initial — theme-aware version of + * buildAvatar. Light mode is byte-equal to add_avatar_v0. + * + * Since buildAvatar emits no hardcoded color fills, all three theme modes + * produce identical output. The theme parameter exists for API consistency + * so callers can pass theme uniformly across all v1 element tools. + */ +export function buildAvatarV1(params: AvatarV1Params): ElementTree { + const size = params.size ?? 40; + const children: ElementTree[] = []; + if (params.initial) { + children.push({ + type: 'text', + name: 'Initial', + role: 'heading', + content: params.initial, + fontSize: Math.max(12, Math.round(size * 0.4)), + fontWeight: 600, + }); + } + return { + type: 'frame', + name: 'Avatar', + role: 'avatar', + width: size, + height: size, + cornerRadius: size / 2, + layout: 'horizontal', + alignItems: 'center', + justifyContent: 'center', + children, + }; +} diff --git a/packages/pen-core/src/element-builders/badge-v1.ts b/packages/pen-core/src/element-builders/badge-v1.ts new file mode 100644 index 000000000..3d96e4133 --- /dev/null +++ b/packages/pen-core/src/element-builders/badge-v1.ts @@ -0,0 +1,49 @@ +import type { ElementTree } from './helpers.js'; +import type { V1Theme } from './resolve-theme.js'; + +export interface BadgeV1Params { + label: string; + /** + * Theme mode. + * - `'light'` (default): byte-parity with add_badge_v0. + * - `'dark'`: byte-parity with light (badge has no hardcoded color fills). + * - `'system'`: byte-parity with light (no $color-* refs needed). + * + * `buildBadge` emits no fill literals — colors are applied by the caller + * via a follow-up batch_design U-op. Theme param is accepted for API + * consistency with other v1 tools. + */ + theme?: V1Theme; +} + +/** + * Short inline badge / pill / tag — theme-aware version of buildBadge. + * Light mode is byte-equal to add_badge_v0. + * + * Since buildBadge emits no hardcoded color fills, all three theme modes + * produce identical output. The theme parameter exists for API consistency + * so callers can pass theme uniformly across all v1 element tools. + */ +export function buildBadgeV1(params: BadgeV1Params): ElementTree { + return { + type: 'frame', + name: 'Badge', + role: 'badge', + width: 'fit_content', + height: 'fit_content', + layout: 'horizontal', + alignItems: 'center', + padding: [4, 10], + cornerRadius: 999, + children: [ + { + type: 'text', + name: 'Label', + role: 'label', + content: params.label, + fontSize: 11, + fontWeight: 600, + }, + ], + }; +} diff --git a/packages/pen-core/src/element-builders/body-text-v1.ts b/packages/pen-core/src/element-builders/body-text-v1.ts new file mode 100644 index 000000000..746eeb15d --- /dev/null +++ b/packages/pen-core/src/element-builders/body-text-v1.ts @@ -0,0 +1,47 @@ +import { detectCjkScript } from './cjk-detect.js'; +import type { ElementTree } from './helpers.js'; +import type { V1Theme } from './resolve-theme.js'; + +export interface BodyTextV1Params { + content: string; + /** + * Theme mode. + * - `'light'` (default): byte-parity with add_body_text_v0. + * - `'dark'`: byte-parity with light (body text has no hardcoded color fills). + * - `'system'`: byte-parity with light (no $color-* refs needed). + * + * `buildBodyText` emits no fill literals — text color is inherited from + * the canvas default. Theme param is accepted for API consistency with + * other v1 tools. + */ + theme?: V1Theme; +} + +/** + * Body / description text — theme-aware version of buildBodyText. + * Light mode is byte-equal to add_body_text_v0. + * + * CJK detection is preserved: CJK content gets lineHeight=1.6 + letterSpacing=0; + * Latin content gets lineHeight=1.5 with no letterSpacing override. + * + * Since buildBodyText emits no hardcoded color fills, all three theme modes + * produce identical output. The theme parameter exists for API consistency + * so callers can pass theme uniformly across all v1 element tools. + */ +export function buildBodyTextV1(params: BodyTextV1Params): ElementTree { + const isCjk = detectCjkScript(params.content) !== null; + const body: ElementTree = { + type: 'text', + name: 'Body', + role: 'body', + content: params.content, + fontSize: 16, + fontWeight: 400, + fontFamily: 'Inter', + lineHeight: isCjk ? 1.6 : 1.5, + width: 'fill_container', + textGrowth: 'fixed-width', + }; + if (isCjk) body.letterSpacing = 0; + return body; +} diff --git a/packages/pen-core/src/element-builders/divider-v1.ts b/packages/pen-core/src/element-builders/divider-v1.ts new file mode 100644 index 000000000..6718f8c86 --- /dev/null +++ b/packages/pen-core/src/element-builders/divider-v1.ts @@ -0,0 +1,40 @@ +import type { ElementTree } from './helpers.js'; +import type { V1Theme } from './resolve-theme.js'; + +export type DividerV1Orientation = 'horizontal' | 'vertical'; + +export interface DividerV1Params { + orientation?: DividerV1Orientation; + thickness?: number; + /** + * Theme mode. + * - `'light'` (default): byte-parity with add_divider_v0. + * - `'dark'`: byte-parity with light (divider has no hardcoded color fills). + * - `'system'`: byte-parity with light (no $color-* refs needed). + * + * `buildDivider` emits no fill literals — colors are inherited from ambient + * theme / Style Guide via a follow-up batch_design U-op. Theme param is + * accepted for API consistency with other v1 tools. + */ + theme?: V1Theme; +} + +/** + * Hairline divider — theme-aware version of buildDivider. + * Light mode is byte-equal to add_divider_v0. + * + * Since buildDivider emits no hardcoded color fills, all three theme modes + * produce identical output. The theme parameter exists for API consistency + * so callers can pass theme uniformly across all v1 element tools. + */ +export function buildDividerV1(params: DividerV1Params): ElementTree { + const orientation = params.orientation ?? 'horizontal'; + const thickness = params.thickness ?? 1; + return { + type: 'rectangle', + name: 'Divider', + role: 'divider', + width: orientation === 'horizontal' ? 'fill_container' : thickness, + height: orientation === 'horizontal' ? thickness : 'fill_container', + }; +} diff --git a/packages/pen-core/src/element-builders/icon-label-v1.ts b/packages/pen-core/src/element-builders/icon-label-v1.ts new file mode 100644 index 000000000..397ae81a9 --- /dev/null +++ b/packages/pen-core/src/element-builders/icon-label-v1.ts @@ -0,0 +1,59 @@ +import type { ElementTree } from './helpers.js'; +import type { V1Theme } from './resolve-theme.js'; + +export interface IconLabelV1Params { + icon: string; + label: string; + gap?: number; + /** + * Theme mode. + * - `'light'` (default): byte-parity with add_icon_label_v0. + * - `'dark'`: byte-parity with light (icon_label has no hardcoded color fills). + * - `'system'`: byte-parity with light (no $color-* refs needed). + * + * `buildIconLabel` emits no fill literals — icon and text colors are + * inherited from the canvas default / parent context. Theme param is + * accepted for API consistency with other v1 tools. + */ + theme?: V1Theme; +} + +/** + * Atomic icon + label pair — theme-aware version of buildIconLabel. + * Light mode is byte-equal to add_icon_label_v0. + * + * Since buildIconLabel emits no hardcoded color fills, all three theme modes + * produce identical output. The theme parameter exists for API consistency + * so callers can pass theme uniformly across all v1 element tools. + */ +export function buildIconLabelV1(params: IconLabelV1Params): ElementTree { + const gap = params.gap ?? 8; + return { + type: 'frame', + name: 'Icon Label', + role: 'icon-label', + width: 'fit_content', + height: 'fit_content', + layout: 'horizontal', + alignItems: 'center', + gap, + children: [ + { + type: 'icon_font', + name: 'Icon', + iconFontName: params.icon, + iconFontFamily: 'lucide', + width: 16, + height: 16, + }, + { + type: 'text', + name: 'Label', + role: 'label', + content: params.label, + fontSize: 14, + fontWeight: 500, + }, + ], + }; +} diff --git a/packages/pen-core/src/element-builders/index.ts b/packages/pen-core/src/element-builders/index.ts index fc520a664..8346e82a1 100644 --- a/packages/pen-core/src/element-builders/index.ts +++ b/packages/pen-core/src/element-builders/index.ts @@ -147,3 +147,8 @@ export { type MemberRowV1Trailing, } from './member-row-v1.js'; export { buildActivityLogV1, type ActivityLogV1Params } from './activity-log-v1.js'; +export { buildAvatarV1, type AvatarV1Params } from './avatar-v1.js'; +export { buildBadgeV1, type BadgeV1Params } from './badge-v1.js'; +export { buildDividerV1, type DividerV1Orientation, type DividerV1Params } from './divider-v1.js'; +export { buildBodyTextV1, type BodyTextV1Params } from './body-text-v1.js'; +export { buildIconLabelV1, type IconLabelV1Params } from './icon-label-v1.js'; diff --git a/packages/pen-core/src/index.ts b/packages/pen-core/src/index.ts index 25479dd8b..b4b960a9d 100644 --- a/packages/pen-core/src/index.ts +++ b/packages/pen-core/src/index.ts @@ -436,4 +436,15 @@ export { type MemberRowV1Trailing, buildActivityLogV1, type ActivityLogV1Params, + buildAvatarV1, + type AvatarV1Params, + buildBadgeV1, + type BadgeV1Params, + buildDividerV1, + type DividerV1Orientation, + type DividerV1Params, + buildBodyTextV1, + type BodyTextV1Params, + buildIconLabelV1, + type IconLabelV1Params, } from './element-builders/index.js'; diff --git a/packages/pen-mcp/src/routes/element-tool-defs-ext-5.ts b/packages/pen-mcp/src/routes/element-tool-defs-ext-5.ts new file mode 100644 index 000000000..b41a54e0d --- /dev/null +++ b/packages/pen-mcp/src/routes/element-tool-defs-ext-5.ts @@ -0,0 +1,149 @@ +// Extension tool definitions — shard 5 of 5 (siblings: base / ext / +// ext-2 / ext-3 / ext-4). Houses P3 batch-1 v1 tools (avatar, badge, +// divider, body_text, icon_label). Each shard caps at the repo's +// 800-line ceiling. +// +// When adding a new tool: pick whichever shard has the fewest tools +// to keep the split balanced. Run `wc -l` on all shard files before +// committing. + +import { + schemaVersionProp, + filePathProp, + parentIdProp, + pageIdProp, +} from './element-tool-def-props'; + +export const ELEMENT_TOOL_DEFINITIONS_EXT_5 = [ + { + name: 'add_avatar_v1', + description: + 'Theme-aware circular avatar (v1). theme="light" (default): byte-parity with ' + + 'add_avatar_v0 (no fill attrs — inherits from parent context). theme="dark" / "system": ' + + 'identical output (avatar has no hardcoded color fills). Accepts theme param for API ' + + 'consistency across all v1 tools. schemaVersion 1.0', + inputSchema: { + type: 'object' as const, + properties: { + schemaVersion: schemaVersionProp, + filePath: filePathProp, + initial: { type: 'string', description: 'Optional centered initial (1-2 chars).' }, + size: { type: 'number', description: 'Avatar diameter px. Default 40.' }, + theme: { + type: 'string', + enum: ['light', 'dark', 'system'], + description: 'Theme variant. Default "light". All modes produce identical output.', + }, + parent_id: parentIdProp, + pageId: pageIdProp, + }, + required: [], + }, + }, + { + name: 'add_badge_v1', + description: + 'Theme-aware short inline badge / pill / tag (v1). theme="light" (default): byte-parity ' + + 'with add_badge_v0 (no fill attrs — caller applies colors via batch_design U-op). ' + + 'theme="dark" / "system": identical output (badge has no hardcoded color fills). ' + + 'cornerRadius=999 (full pill), fontSize=11, fontWeight=600. schemaVersion 1.0', + inputSchema: { + type: 'object' as const, + properties: { + schemaVersion: schemaVersionProp, + filePath: filePathProp, + label: { type: 'string', description: 'Badge text (e.g. "NEW", "BETA", "42").' }, + theme: { + type: 'string', + enum: ['light', 'dark', 'system'], + description: 'Theme variant. Default "light". All modes produce identical output.', + }, + parent_id: parentIdProp, + pageId: pageIdProp, + }, + required: ['label'], + }, + }, + { + name: 'add_divider_v1', + description: + 'Theme-aware hairline divider (v1). theme="light" (default): byte-parity with ' + + 'add_divider_v0 (no fill attrs — colors inherited from ambient theme). theme="dark" / ' + + '"system": identical output (divider has no hardcoded color fills). Horizontal = ' + + 'fill_container × thickness px; vertical = thickness × fill_container. schemaVersion 1.0', + inputSchema: { + type: 'object' as const, + properties: { + schemaVersion: schemaVersionProp, + filePath: filePathProp, + orientation: { + type: 'string', + enum: ['horizontal', 'vertical'], + description: 'Divider direction. Default "horizontal".', + }, + thickness: { type: 'number', description: 'Thickness in px. Default 1.' }, + theme: { + type: 'string', + enum: ['light', 'dark', 'system'], + description: 'Theme variant. Default "light". All modes produce identical output.', + }, + parent_id: parentIdProp, + pageId: pageIdProp, + }, + required: [], + }, + }, + { + name: 'add_body_text_v1', + description: + 'Theme-aware body / description text (v1). theme="light" (default): byte-parity with ' + + 'add_body_text_v0 (no fill attrs — text color inherited from canvas default). ' + + 'theme="dark" / "system": identical output (body text has no hardcoded color fills). ' + + 'CJK auto-detection applies in all modes (lineHeight=1.6 + letterSpacing=0 for CJK, ' + + 'lineHeight=1.5 for Latin). Always Inter, width=fill_container, textGrowth=fixed-width. ' + + 'schemaVersion 1.0', + inputSchema: { + type: 'object' as const, + properties: { + schemaVersion: schemaVersionProp, + filePath: filePathProp, + content: { type: 'string', description: 'Paragraph text content.' }, + theme: { + type: 'string', + enum: ['light', 'dark', 'system'], + description: 'Theme variant. Default "light". All modes produce identical output.', + }, + parent_id: parentIdProp, + pageId: pageIdProp, + }, + required: ['content'], + }, + }, + { + name: 'add_icon_label_v1', + description: + 'Theme-aware atomic icon + label pair (v1). theme="light" (default): byte-parity with ' + + 'add_icon_label_v0 (no fill attrs — icon and text colors inherited from parent context). ' + + 'theme="dark" / "system": identical output (icon_label has no hardcoded color fills). ' + + 'Horizontal layout, alignItems=center, icon leads (16×16 lucide), label 14/500. ' + + 'schemaVersion 1.0', + inputSchema: { + type: 'object' as const, + properties: { + schemaVersion: schemaVersionProp, + filePath: filePathProp, + icon: { type: 'string', description: 'Lucide icon slug (e.g. "star", "user").' }, + label: { type: 'string', description: 'Label text.' }, + gap: { type: 'number', description: 'Gap between icon and label px. Default 8.' }, + theme: { + type: 'string', + enum: ['light', 'dark', 'system'], + description: 'Theme variant. Default "light". All modes produce identical output.', + }, + parent_id: parentIdProp, + pageId: pageIdProp, + }, + required: ['icon', 'label'], + }, + }, +]; diff --git a/packages/pen-mcp/src/routes/element-tool-defs.ts b/packages/pen-mcp/src/routes/element-tool-defs.ts index d7b49faa4..973fa17da 100644 --- a/packages/pen-mcp/src/routes/element-tool-defs.ts +++ b/packages/pen-mcp/src/routes/element-tool-defs.ts @@ -117,12 +117,18 @@ import { handleAddCardRowV1 } from '../tools/add-card-row-v1'; import { handleAddSettingRowV1 } from '../tools/add-setting-row-v1'; import { handleAddMemberRowV1 } from '../tools/add-member-row-v1'; import { handleAddActivityLogV1 } from '../tools/add-activity-log-v1'; +import { handleAddAvatarV1 } from '../tools/add-avatar-v1'; +import { handleAddBadgeV1 } from '../tools/add-badge-v1'; +import { handleAddDividerV1 } from '../tools/add-divider-v1'; +import { handleAddBodyTextV1 } from '../tools/add-body-text-v1'; +import { handleAddIconLabelV1 } from '../tools/add-icon-label-v1'; import { recordElementToolCall } from '../metrics/element-tool-metrics'; import { ELEMENT_TOOL_DEFINITIONS_BASE } from './element-tool-defs-base'; import { ELEMENT_TOOL_DEFINITIONS_EXT } from './element-tool-defs-ext'; import { ELEMENT_TOOL_DEFINITIONS_EXT_2 } from './element-tool-defs-ext-2'; import { ELEMENT_TOOL_DEFINITIONS_EXT_3 } from './element-tool-defs-ext-3'; import { ELEMENT_TOOL_DEFINITIONS_EXT_4 } from './element-tool-defs-ext-4'; +import { ELEMENT_TOOL_DEFINITIONS_EXT_5 } from './element-tool-defs-ext-5'; export const ELEMENT_TOOL_DEFINITIONS = [ ...ELEMENT_TOOL_DEFINITIONS_BASE, @@ -130,6 +136,7 @@ export const ELEMENT_TOOL_DEFINITIONS = [ ...ELEMENT_TOOL_DEFINITIONS_EXT_2, ...ELEMENT_TOOL_DEFINITIONS_EXT_3, ...ELEMENT_TOOL_DEFINITIONS_EXT_4, + ...ELEMENT_TOOL_DEFINITIONS_EXT_5, ]; export const ELEMENT_TOOL_NAMES: ReadonlySet = new Set( @@ -355,6 +362,16 @@ async function dispatchElementToolCall(name: string, a: any): Promise { return JSON.stringify(await handleAddMemberRowV1(a), null, 2); case 'add_activity_log_v1': return JSON.stringify(await handleAddActivityLogV1(a), null, 2); + case 'add_avatar_v1': + return JSON.stringify(await handleAddAvatarV1(a), null, 2); + case 'add_badge_v1': + return JSON.stringify(await handleAddBadgeV1(a), null, 2); + case 'add_divider_v1': + return JSON.stringify(await handleAddDividerV1(a), null, 2); + case 'add_body_text_v1': + return JSON.stringify(await handleAddBodyTextV1(a), null, 2); + case 'add_icon_label_v1': + return JSON.stringify(await handleAddIconLabelV1(a), null, 2); default: return ''; } diff --git a/packages/pen-mcp/src/tools/add-avatar-v1.ts b/packages/pen-mcp/src/tools/add-avatar-v1.ts new file mode 100644 index 000000000..ab14af50c --- /dev/null +++ b/packages/pen-mcp/src/tools/add-avatar-v1.ts @@ -0,0 +1,24 @@ +import { assignIdsRecursively, buildAvatarV1, type AvatarV1Params } from '@zseven-w/pen-core'; +import type { handleBatchDesign } from './batch-design'; +import { ensureParentExists, insertElementTree } from './element-tool-helpers'; + +export interface AddAvatarV1Params extends AvatarV1Params { + parent_id?: string; + filePath?: string; + pageId?: string; +} + +/** + * Circular avatar with optional initial (v1) — theme-aware variant of add_avatar_v0. + * Supports 'light' (v0 byte-parity), 'dark', and 'system' theme modes. + * Since buildAvatar emits no color fills, all three modes produce identical output. + * Tree build delegated to `buildAvatarV1` in `@zseven-w/pen-core`. + */ +export async function handleAddAvatarV1( + params: AddAvatarV1Params, +): Promise>> { + await ensureParentExists(params); + const tree = buildAvatarV1(params); + assignIdsRecursively(tree); + return insertElementTree({ binding: 'av', tree, ...params }); +} diff --git a/packages/pen-mcp/src/tools/add-badge-v1.ts b/packages/pen-mcp/src/tools/add-badge-v1.ts new file mode 100644 index 000000000..99a698cb0 --- /dev/null +++ b/packages/pen-mcp/src/tools/add-badge-v1.ts @@ -0,0 +1,24 @@ +import { assignIdsRecursively, buildBadgeV1, type BadgeV1Params } from '@zseven-w/pen-core'; +import type { handleBatchDesign } from './batch-design'; +import { ensureParentExists, insertElementTree } from './element-tool-helpers'; + +export interface AddBadgeV1Params extends BadgeV1Params { + parent_id?: string; + filePath?: string; + pageId?: string; +} + +/** + * Short inline badge / pill / tag (v1) — theme-aware variant of add_badge_v0. + * Supports 'light' (v0 byte-parity), 'dark', and 'system' theme modes. + * Since buildBadge emits no color fills, all three modes produce identical output. + * Tree build delegated to `buildBadgeV1` in `@zseven-w/pen-core`. + */ +export async function handleAddBadgeV1( + params: AddBadgeV1Params, +): Promise>> { + await ensureParentExists(params); + const tree = buildBadgeV1(params); + assignIdsRecursively(tree); + return insertElementTree({ binding: 'bg', tree, ...params }); +} diff --git a/packages/pen-mcp/src/tools/add-body-text-v1.ts b/packages/pen-mcp/src/tools/add-body-text-v1.ts new file mode 100644 index 000000000..607cde642 --- /dev/null +++ b/packages/pen-mcp/src/tools/add-body-text-v1.ts @@ -0,0 +1,25 @@ +import { assignIdsRecursively, buildBodyTextV1, type BodyTextV1Params } from '@zseven-w/pen-core'; +import type { handleBatchDesign } from './batch-design'; +import { ensureParentExists, insertElementTree } from './element-tool-helpers'; + +export interface AddBodyTextV1Params extends BodyTextV1Params { + parent_id?: string; + filePath?: string; + pageId?: string; +} + +/** + * Body / description text (v1) — theme-aware variant of add_body_text_v0. + * Supports 'light' (v0 byte-parity), 'dark', and 'system' theme modes. + * CJK auto-detection is preserved in all modes. + * Since buildBodyText emits no color fills, all three modes produce identical output. + * Tree build delegated to `buildBodyTextV1` in `@zseven-w/pen-core`. + */ +export async function handleAddBodyTextV1( + params: AddBodyTextV1Params, +): Promise>> { + await ensureParentExists(params); + const tree = buildBodyTextV1(params); + assignIdsRecursively(tree); + return insertElementTree({ binding: 'bt', tree, ...params }); +} diff --git a/packages/pen-mcp/src/tools/add-divider-v1.ts b/packages/pen-mcp/src/tools/add-divider-v1.ts new file mode 100644 index 000000000..2089ba84c --- /dev/null +++ b/packages/pen-mcp/src/tools/add-divider-v1.ts @@ -0,0 +1,24 @@ +import { assignIdsRecursively, buildDividerV1, type DividerV1Params } from '@zseven-w/pen-core'; +import type { handleBatchDesign } from './batch-design'; +import { ensureParentExists, insertElementTree } from './element-tool-helpers'; + +export interface AddDividerV1Params extends DividerV1Params { + parent_id?: string; + filePath?: string; + pageId?: string; +} + +/** + * Hairline divider (v1) — theme-aware variant of add_divider_v0. + * Supports 'light' (v0 byte-parity), 'dark', and 'system' theme modes. + * Since buildDivider emits no color fills, all three modes produce identical output. + * Tree build delegated to `buildDividerV1` in `@zseven-w/pen-core`. + */ +export async function handleAddDividerV1( + params: AddDividerV1Params, +): Promise>> { + await ensureParentExists(params); + const tree = buildDividerV1(params); + assignIdsRecursively(tree); + return insertElementTree({ binding: 'dv', tree, ...params }); +} diff --git a/packages/pen-mcp/src/tools/add-icon-label-v1.ts b/packages/pen-mcp/src/tools/add-icon-label-v1.ts new file mode 100644 index 000000000..459f3d755 --- /dev/null +++ b/packages/pen-mcp/src/tools/add-icon-label-v1.ts @@ -0,0 +1,24 @@ +import { assignIdsRecursively, buildIconLabelV1, type IconLabelV1Params } from '@zseven-w/pen-core'; +import type { handleBatchDesign } from './batch-design'; +import { ensureParentExists, insertElementTree } from './element-tool-helpers'; + +export interface AddIconLabelV1Params extends IconLabelV1Params { + parent_id?: string; + filePath?: string; + pageId?: string; +} + +/** + * Atomic icon + label pair (v1) — theme-aware variant of add_icon_label_v0. + * Supports 'light' (v0 byte-parity), 'dark', and 'system' theme modes. + * Since buildIconLabel emits no color fills, all three modes produce identical output. + * Tree build delegated to `buildIconLabelV1` in `@zseven-w/pen-core`. + */ +export async function handleAddIconLabelV1( + params: AddIconLabelV1Params, +): Promise>> { + await ensureParentExists(params); + const tree = buildIconLabelV1(params); + assignIdsRecursively(tree); + return insertElementTree({ binding: 'il', tree, ...params }); +}