feat(ai): add_social_login_row_v0 — social auth button row (69th tool)

The "Continue with Google / Apple / Microsoft" row. Vertical default
(stacked full-width 48px buttons) or horizontal (compact icon-only
48×48 pills). Known provider names (google, apple, github, microsoft,
facebook, twitter, linkedin, discord, slack, gitlab, email, phone)
auto-map to lucide icons; `icon` param overrides for SSO/SAML/Okta.
This commit is contained in:
Fini 2026-04-25 01:19:00 +08:00
parent 127eccdefb
commit e0b926c256
12 changed files with 438 additions and 1 deletions

View file

@ -39,6 +39,7 @@ import {
buildAttachmentRow,
buildChatBubble,
buildStatCard,
buildSocialLoginRow,
buildKbd,
buildLink,
buildListRow,
@ -199,6 +200,8 @@ const SERVER_BUILDERS: Record<string, BuilderFn> = {
add_attachment_row_v0: (a) => buildAttachmentRow(a as Parameters<typeof buildAttachmentRow>[0]),
add_chat_bubble_v0: (a) => buildChatBubble(a as Parameters<typeof buildChatBubble>[0]),
add_stat_card_v0: (a) => buildStatCard(a as Parameters<typeof buildStatCard>[0]),
add_social_login_row_v0: (a) =>
buildSocialLoginRow(a as Parameters<typeof buildSocialLoginRow>[0]),
};
interface ExecToolBody {

View file

@ -45,6 +45,7 @@ import {
buildOtpInput,
buildAttachmentRow,
buildChatBubble,
buildSocialLoginRow,
buildStatCard,
buildKbd,
buildLink,
@ -477,6 +478,11 @@ const CASES: BuilderCase[] = [
},
build: (a) => buildStatCard(a as unknown as Parameters<typeof buildStatCard>[0]),
},
{
toolName: 'add_social_login_row_v0',
args: { providers: [{ name: 'google' }, { name: 'apple' }] },
build: (a) => buildSocialLoginRow(a as unknown as Parameters<typeof buildSocialLoginRow>[0]),
},
];
/**

View file

@ -57,6 +57,7 @@ import {
buildAttachmentRow,
buildChatBubble,
buildStatCard,
buildSocialLoginRow,
buildKbd,
buildLink,
buildListRow,
@ -126,6 +127,7 @@ import {
type AttachmentRowParams,
type ChatBubbleParams,
type StatCardParams,
type SocialLoginRowParams,
type KbdParams,
type LinkParams,
type ListRowParams,
@ -314,6 +316,7 @@ export const ELEMENT_SHIMS: Record<string, ElementShim> = {
add_attachment_row_v0: wrap<AttachmentRowParams>(buildAttachmentRow),
add_chat_bubble_v0: wrap<ChatBubbleParams>(buildChatBubble),
add_stat_card_v0: wrap<StatCardParams>(buildStatCard),
add_social_login_row_v0: wrap<SocialLoginRowParams>(buildSocialLoginRow),
};
export function getElementShim(name: string): ElementShim | undefined {

View file

@ -204,7 +204,11 @@ Dashboard KPIs:
62. Big-number stat card (standalone metric tile — label + huge value + optional delta/icon) → `add_stat_card_v0`
63. None match → fall through to `batch_design`
Auth / login:
63. Social auth provider buttons ("Continue with Google / Apple / Microsoft", OAuth/SSO row, third-party sign-in) → `add_social_login_row_v0`
64. None match → fall through to `batch_design`
**Disambiguation**: if you need a ROW of 3 metrics that should NOT scroll (e.g. a stats strip inside a card), use `add_stat_grid_v0`, NOT `add_metric_row_v0`. The grid uses `fill_container` per cell so it never overflows; the metric row uses fixed-px cells + scroll wrapper.
@ -278,6 +282,7 @@ PREFER an element tool when the spec says any of:
- "attachment", "attached file", "uploaded file", "file item", "file list row", "附件", "已上传文件" → `add_attachment_row_v0` (for upload-in-progress state, compose `add_progress_bar_v0` below)
- "chat message", "message bubble", "conversation row", "iMessage bubble", "chat UI", "聊天气泡", "消息气泡" → `add_chat_bubble_v0` (side="left" for from-others, side="right" for from-self)
- "KPI card", "big number card", "metric tile", "stat widget", "featured metric", "关键指标卡", "数据大屏卡片" → `add_stat_card_v0` (distinct from `add_stat_grid_v0` which is multi-cell side-by-side)
- "Continue with Google", "Sign in with Apple", "social login", "OAuth buttons", "SSO providers", "third-party login", "第三方登录", "社交登录", "OAuth 登录" → `add_social_login_row_v0` (orientation="vertical" for stacked full-width on mobile; orientation="horizontal" for the compact "or sign in with..." icon-only row)
STILL use batch_design when:
@ -513,6 +518,9 @@ add_chat_bubble_v0({ message: "My order hasn't arrived.", side: "right", timesta
add_stat_card_v0({ label: "Monthly revenue", value: "$12.4k", icon: "trending-up", delta: "+8% vs last week", trend: "up" })
add_stat_card_v0({ label: "Active users", value: "1,284", icon: "users" }) // no delta = static snapshot
add_social_login_row_v0({ providers: [{ name: "Google" }, { name: "Apple" }, { name: "Microsoft" }] }) // stacked "Continue with X" buttons
add_social_login_row_v0({ providers: [{ name: "Google" }, { name: "GitHub" }, { name: "Slack" }], orientation: "horizontal" }) // compact icon-only row
```
## Composition pattern

View file

@ -84,3 +84,8 @@ export { buildOtpInput, type OtpInputParams } from './otp-input.js';
export { buildAttachmentRow, type AttachmentRowParams } from './attachment-row.js';
export { buildChatBubble, type ChatBubbleParams, type ChatBubbleSide } from './chat-bubble.js';
export { buildStatCard, type StatCardParams, type StatCardTrend } from './stat-card.js';
export {
buildSocialLoginRow,
type SocialLoginRowParams,
type SocialLoginProvider,
} from './social-login-row.js';

View file

@ -0,0 +1,161 @@
import type { ElementTree } from './helpers.js';
export interface SocialLoginProvider {
/**
* Provider name. Displayed inline as "Continue with {name}".
* Known names ('google', 'apple', 'microsoft', 'github',
* 'facebook', 'twitter', 'x', 'linkedin') auto-resolve to the
* matching lucide icon. Override via explicit `icon`.
*/
name: string;
/**
* Optional lucide icon override. Takes precedence over the
* known-name mapping. Use this for providers we don't have a
* default mapping for (e.g. "Okta", "SAML SSO").
*/
icon?: string;
}
export interface SocialLoginRowParams {
/** Array of providers to render. 2-4 recommended; clamped to 6 max. */
providers: SocialLoginProvider[];
/**
* Layout orientation. Default 'vertical' (stacked full-width
* buttons) the pattern on most modern mobile login screens.
* 'horizontal' renders icon-only pills side-by-side, the more
* compact "also sign in with..." pattern.
*/
orientation?: 'vertical' | 'horizontal';
/** Button width in px. Default 320. Min 200. Ignored on horizontal orientation. */
width?: number;
}
const KNOWN_ICONS: Record<string, string> = {
google: 'chrome',
apple: 'apple',
microsoft: 'monitor',
github: 'github',
gitlab: 'git-branch',
facebook: 'facebook',
twitter: 'twitter',
x: 'twitter',
linkedin: 'linkedin',
discord: 'message-circle',
slack: 'hash',
email: 'mail',
phone: 'smartphone',
};
/**
* Social-auth provider button row the "Continue with Google /
* Apple / Microsoft" pattern from every login / signup screen.
*
* Structure (vertical, default):
* frame(width, fit_content, layout=vertical, gap=10,
* role='social-login-row')
* frame(fill_container, 48px high, cornerRadius=12,
* bg=#FFFFFF, stroke=#E2E8F0, layout=horizontal,
* alignItems=center, gap=12, padding=[0,16],
* role='social-login-button')
* icon_font(provider.icon, 20×20, slate-700)
* text("Continue with {Name}", 14/500, slate-900)
*
* Structure (horizontal): same buttons but `layout='horizontal'`
* wrapper, buttons collapse to 48×48 square with only the icon
* (no label). The compact variant for "quick alt login" rows.
*
* Why it's a tool: enough variation in provider order / icon
* choice / orientation that models hand-crafting this via
* batch_design frequently invert the icon+label order or miss the
* button-height constraint. Narrow tool locks the shape.
*/
export function buildSocialLoginRow(params: SocialLoginRowParams): ElementTree {
const raw = Array.isArray(params.providers) ? params.providers : [];
if (raw.length === 0) {
throw new Error('buildSocialLoginRow: providers array must not be empty');
}
const providers = raw.slice(0, 6);
const orientation = params.orientation ?? 'vertical';
const isVertical = orientation === 'vertical';
const width = Math.max(200, Math.floor(params.width ?? 320));
const buttons: ElementTree[] = providers.map((provider) => {
const lowerName = provider.name.toLowerCase();
const icon = provider.icon ?? KNOWN_ICONS[lowerName] ?? 'log-in';
const label = `Continue with ${provider.name}`;
// Capitalize properly: "google" → "Google". If already capitalized,
// keep the caller's casing.
const prettyLabel =
provider.name.charAt(0) === provider.name.charAt(0).toUpperCase()
? label
: `Continue with ${provider.name.charAt(0).toUpperCase() + provider.name.slice(1)}`;
const iconNode: ElementTree = {
type: 'icon_font',
name: `${provider.name} Icon`,
role: 'social-login-button-icon',
iconFontName: icon,
iconFontFamily: 'lucide',
width: 20,
height: 20,
fill: [{ type: 'solid', color: '#334155' }],
};
if (isVertical) {
return {
type: 'frame',
name: `${provider.name} Button`,
role: 'social-login-button',
width: 'fill_container',
height: 48,
cornerRadius: 12,
layout: 'horizontal',
alignItems: 'center',
gap: 12,
paddingLeft: 16,
paddingRight: 16,
fill: [{ type: 'solid', color: '#FFFFFF' }],
stroke: { thickness: 1, fill: [{ type: 'solid', color: '#E2E8F0' }] },
children: [
iconNode,
{
type: 'text',
name: 'Label',
role: 'social-login-button-label',
content: prettyLabel,
fontSize: 14,
fontWeight: 500,
fill: [{ type: 'solid', color: '#0F172A' }],
},
],
};
}
// Horizontal: icon-only square pill
return {
type: 'frame',
name: `${provider.name} Button`,
role: 'social-login-button-compact',
width: 48,
height: 48,
cornerRadius: 12,
layout: 'horizontal',
alignItems: 'center',
justifyContent: 'center',
fill: [{ type: 'solid', color: '#FFFFFF' }],
stroke: { thickness: 1, fill: [{ type: 'solid', color: '#E2E8F0' }] },
children: [iconNode],
};
});
return {
type: 'frame',
name: 'Social Login Row',
role: 'social-login-row',
width: isVertical ? width : 'fit_content',
height: 'fit_content',
layout: isVertical ? 'vertical' : 'horizontal',
alignItems: 'center',
gap: 10,
children: buttons,
};
}

View file

@ -255,6 +255,7 @@ export {
buildAttachmentRow,
buildChatBubble,
buildStatCard,
buildSocialLoginRow,
cjkFontFamily,
detectCjkScript,
type ElementTree,
@ -345,4 +346,6 @@ export {
type ChatBubbleSide,
type StatCardParams,
type StatCardTrend,
type SocialLoginRowParams,
type SocialLoginProvider,
} from './element-builders/index.js';

View file

@ -0,0 +1,175 @@
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 { handleAddSocialLoginRowV0 } from '../tools/add-social-login-row-v0';
import { invalidateCache } from '../document-manager';
const TMP = join(tmpdir(), 'openpencil-add-social-login-row-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;
}
function findAllByRole(n: Record<string, unknown>, role: string): Record<string, unknown>[] {
const out: Record<string, unknown>[] = [];
if (n.role === role) out.push(n);
const kids = (n.children ?? []) as Record<string, unknown>[];
for (const c of kids) out.push(...findAllByRole(c, role));
return out;
}
function findByRole(n: Record<string, unknown>, role: string): Record<string, unknown> | undefined {
return findAllByRole(n, role)[0];
}
beforeEach(async () => {
await mkdir(TMP, { recursive: true });
});
afterEach(async () => {
for (const f of ['s.op']) {
try {
const fp = join(TMP, f);
invalidateCache(fp);
await unlink(fp);
} catch {}
}
});
describe('add_social_login_row_v0', () => {
it('registered; required=[providers]', () => {
expect(DESIGN_TOOL_NAMES.has('add_social_login_row_v0')).toBe(true);
const def = DESIGN_TOOL_DEFINITIONS.find((t) => t.name === 'add_social_login_row_v0');
expect(def?.inputSchema.required).toEqual(['providers']);
});
it('default orientation=vertical → full-width 48px buttons with icon + "Continue with X" label', async () => {
const fp = await fresh('s.op');
await handleAddSocialLoginRowV0({
filePath: fp,
providers: [{ name: 'google' }, { name: 'apple' }, { name: 'microsoft' }],
});
const root = getRoot(await readDoc(fp));
expect(root.role).toBe('social-login-row');
expect(root.layout).toBe('vertical');
const buttons = findAllByRole(root, 'social-login-button');
expect(buttons).toHaveLength(3);
expect(buttons[0].width).toBe('fill_container');
expect(buttons[0].height).toBe(48);
const labels = findAllByRole(root, 'social-login-button-label');
expect(labels.map((l) => l.content)).toEqual([
'Continue with Google',
'Continue with Apple',
'Continue with Microsoft',
]);
});
it('horizontal orientation → 48×48 icon-only compact pills, no labels', async () => {
const fp = await fresh('s.op');
await handleAddSocialLoginRowV0({
filePath: fp,
providers: [{ name: 'google' }, { name: 'github' }],
orientation: 'horizontal',
});
const root = getRoot(await readDoc(fp));
expect(root.layout).toBe('horizontal');
const compactButtons = findAllByRole(root, 'social-login-button-compact');
expect(compactButtons).toHaveLength(2);
expect(compactButtons[0].width).toBe(48);
expect(compactButtons[0].height).toBe(48);
// No labels in horizontal variant
expect(findAllByRole(root, 'social-login-button-label')).toHaveLength(0);
// Full-size "button" role not used for compact
expect(findAllByRole(root, 'social-login-button')).toHaveLength(0);
});
it('known provider names map to lucide icons (google → chrome, github → github)', async () => {
const fp = await fresh('s.op');
await handleAddSocialLoginRowV0({
filePath: fp,
providers: [{ name: 'google' }, { name: 'github' }],
});
const root = getRoot(await readDoc(fp));
const icons = findAllByRole(root, 'social-login-button-icon');
expect(icons[0].iconFontName).toBe('chrome');
expect(icons[1].iconFontName).toBe('github');
expect(icons[0].iconFontFamily).toBe('lucide');
});
it('explicit icon overrides known-name mapping', async () => {
const fp = await fresh('s.op');
await handleAddSocialLoginRowV0({
filePath: fp,
providers: [{ name: 'google', icon: 'star' }],
});
const root = getRoot(await readDoc(fp));
expect(findByRole(root, 'social-login-button-icon')!.iconFontName).toBe('star');
});
it('unknown provider falls back to log-in icon', async () => {
const fp = await fresh('s.op');
await handleAddSocialLoginRowV0({
filePath: fp,
providers: [{ name: 'Okta' }],
});
const root = getRoot(await readDoc(fp));
expect(findByRole(root, 'social-login-button-icon')!.iconFontName).toBe('log-in');
});
it('clamps to 6 providers max', async () => {
const fp = await fresh('s.op');
const many = Array.from({ length: 10 }, (_, i) => ({ name: `p${i}` }));
await handleAddSocialLoginRowV0({ filePath: fp, providers: many });
const root = getRoot(await readDoc(fp));
expect(findAllByRole(root, 'social-login-button')).toHaveLength(6);
});
it('empty providers array throws (not silent success)', async () => {
const fp = await fresh('s.op');
const before = await readFile(fp, 'utf-8');
await expect(handleAddSocialLoginRowV0({ filePath: fp, providers: [] })).rejects.toThrow(
/providers.*must not be empty/,
);
expect(await readFile(fp, 'utf-8')).toBe(before);
});
it('width clamps (< 200 → 200)', async () => {
const fp = await fresh('s.op');
await handleAddSocialLoginRowV0({
filePath: fp,
providers: [{ name: 'google' }],
width: 100,
});
const root = getRoot(await readDoc(fp));
expect(root.width).toBe(200);
});
it('throws on bogus parent_id AND leaves file untouched', async () => {
const fp = await fresh('s.op');
const before = await readFile(fp, 'utf-8');
await expect(
handleAddSocialLoginRowV0({
filePath: fp,
providers: [{ name: 'google' }],
parent_id: 'nope',
}),
).rejects.toThrow(/parent_id.*not found/);
expect(await readFile(fp, 'utf-8')).toBe(before);
});
});

View file

@ -77,6 +77,7 @@ const ELEMENT_TOOL_NAMES = [
'add_attachment_row_v0',
'add_chat_bubble_v0',
'add_stat_card_v0',
'add_social_login_row_v0',
];
describe('element tools — v0-MUST contract', () => {

View file

@ -409,4 +409,48 @@ export const ELEMENT_TOOL_DEFINITIONS_EXT_3 = [
required: ['label', 'value'],
},
},
{
name: 'add_social_login_row_v0',
description:
'Social-auth provider button row — the "Continue with Google / Apple / Microsoft" pattern ' +
'on login / signup screens. Two orientations: "vertical" (default, full-width stacked buttons ' +
'with icon + "Continue with {Name}" label) and "horizontal" (compact icon-only square pills ' +
'side-by-side). Known provider names (google / apple / microsoft / github / facebook / twitter ' +
'/ x / linkedin / discord / slack / email / phone) auto-resolve to lucide icons; override via ' +
'`providers[i].icon`. Use for "social login", "Sign in with Google", "OAuth buttons", "SSO row", ' +
'"第三方登录", "社交登录". schemaVersion 1.0',
inputSchema: {
type: 'object' as const,
properties: {
schemaVersion: schemaVersionProp,
filePath: filePathProp,
providers: {
type: 'array',
description:
'Provider list, 1-6 items. Each item: { name: string, icon?: string }. Known names auto-map icons.',
items: {
type: 'object',
properties: {
name: { type: 'string' },
icon: { type: 'string', description: 'Optional lucide icon override' },
},
required: ['name'],
},
},
orientation: {
type: 'string',
enum: ['vertical', 'horizontal'],
description:
'"vertical" (default) = full-width stacked buttons w/ label. "horizontal" = compact icon-only pills.',
},
width: {
type: 'number',
description: 'Button width in px (default 320, min 200, vertical only)',
},
parent_id: parentIdProp,
pageId: pageIdProp,
},
required: ['providers'],
},
},
];

View file

@ -83,6 +83,7 @@ import { handleAddOtpInputV0 } from '../tools/add-otp-input-v0';
import { handleAddAttachmentRowV0 } from '../tools/add-attachment-row-v0';
import { handleAddChatBubbleV0 } from '../tools/add-chat-bubble-v0';
import { handleAddStatCardV0 } from '../tools/add-stat-card-v0';
import { handleAddSocialLoginRowV0 } from '../tools/add-social-login-row-v0';
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';
@ -251,6 +252,8 @@ async function dispatchElementToolCall(name: string, a: any): Promise<string> {
return JSON.stringify(await handleAddChatBubbleV0(a), null, 2);
case 'add_stat_card_v0':
return JSON.stringify(await handleAddStatCardV0(a), null, 2);
case 'add_social_login_row_v0':
return JSON.stringify(await handleAddSocialLoginRowV0(a), null, 2);
default:
return '';
}

View file

@ -0,0 +1,25 @@
import {
assignIdsRecursively,
buildSocialLoginRow,
type SocialLoginRowParams,
} from '@zseven-w/pen-core';
import type { handleBatchDesign } from './batch-design';
import { ensureParentExists, insertElementTree } from './element-tool-helpers';
export interface AddSocialLoginRowV0Params extends SocialLoginRowParams {
parent_id?: string;
filePath?: string;
pageId?: string;
}
export type { SocialLoginProvider as AddSocialLoginProvider } from '@zseven-w/pen-core';
/** Social-auth button row. Tree build delegated to `buildSocialLoginRow`. */
export async function handleAddSocialLoginRowV0(
params: AddSocialLoginRowV0Params,
): Promise<Awaited<ReturnType<typeof handleBatchDesign>>> {
await ensureParentExists(params);
const r = buildSocialLoginRow(params);
assignIdsRecursively(r);
return insertElementTree({ binding: 'socialLogin', tree: r, ...params });
}