feat: add WeChat design system, login-flow skill, and fix API mode tool_calls bug (#1083)

* feat: add WeChat design system, login-flow skill, and fix API mode tool_calls bug

- Add WeChat design system (design-systems/wechat/) with full brand spec
  including color palette, typography, and component rules for chat UI
- Add login-flow skill (skills/login-flow/) for mobile authentication flows
  with P0 checklist, example HTML, and i18n registration across 3 locales
- Fix DeepSeek V4 bug: API/BYOK mode (streamFormat=plain) models now receive
  a directive to emit only <artifact> HTML blocks and suppress tool_calls,
  since plain adapters proxy to external providers that cannot execute tools

* fix: restore full server.ts and WeChat DESIGN.md from ad46d8cd commit

Restore files that were corrupted in PR #1083 head branch.
The WeChat DESIGN.md was reduced to a single line (filename only)
and server.ts was reduced to ~1 line. Both are restored to their
original ad46d8cd state with full content.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: restore full server.ts and WeChat DESIGN.md from ad46d8cd

Restore files corrupted in PR #1083:
- apps/daemon/src/server.ts: restored 7106-line file
- design-systems/wechat/DESIGN.md: restored 301-line WeChat design spec
- skills/login-flow/SKILL.md: restored from local working state
- skills/login-flow/example.html: restored 351-line example HTML

* fix: only suppress tool_calls when streamFormat='plain' explicitly, remove nonexistent assets/template.html

1. streamFormat check now requires explicit 'plain' value instead of defaulting
   to 'plain' when undefined. This prevents normal tool-using chat runs from
   incorrectly inheriting the API/BYOK tool_calls suppression rule.

2. login-flow SKILL.md: removed reference to assets/template.html since that file
   does not exist in the skill bundle and derivePreflight() would inject a hard
   instruction to read it before any other tool, causing pre-flight to fail.

* fix: thread streamFormat to composeSystemPrompt in server.ts call

Previously the composeSystemPrompt call at line ~4940 omitted streamFormat,
causing the composer to default to 'plain' and suppress tool_calls even
for tool-using chat runs. Now streamFormat is passed through from the
adapter definition so the API mode rule only fires when streamFormat='plain'
is explicitly set.

* fix: WeChat category metadata, font-family, and login-flow example interactivity

WeChat DESIGN.md:
- Add Category: Social & Messaging metadata so it appears correctly in picker
- Fix font-family declaration: remove invalid -webkit-font-family prefix,
  use standard font-family so downstream CSS generation works correctly

skills/login-flow/example.html:
- Add password toggle click handler so show/hide actually works
- Change Apple icon fill from hardcoded #fff to currentColor so it is
  visible on light backgrounds

* fix: mirror streamFormat suppression in contracts composer and add WeChat i18n

1. packages/contracts/src/prompts/system.ts: Add streamFormat parameter to
   ComposeInput and ComposeInput interface, mirroring the same suppression
   rule from daemon prompts/system.ts. When streamFormat='plain' is passed,
   a directive is appended telling models not to emit tool_calls and to only
   output <artifact> HTML blocks.

2. apps/web/src/i18n/content.{ts,fr,ru}.ts: Add WeChat design system entries:
   - Add 'wechat' to DE/FR/RU_DESIGN_SYSTEM_IDS_WITH_EN_FALLBACK arrays
   - Add 'wechat' summary to DE/FR/RU_DESIGN_SYSTEM_SUMMARIES
   - Add 'Social & Messaging' category to DE/FR/RU_DESIGN_SYSTEM_CATEGORIES
     (matching the Category: Social & Messaging metadata in WeChat DESIGN.md)

* fix: thread streamFormat='plain' into web composeSystemPrompt for api mode

* test: focus localized content coverage on missing resources

---------

Co-authored-by: Open Design Contributor <z@open-design.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: mrcfps <mrc@powerformer.com>
This commit is contained in:
code-Y 2026-05-10 20:38:33 +08:00 committed by GitHub
parent bfedbeca0f
commit 84f768d4a2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 799 additions and 4 deletions

View file

@ -81,6 +81,7 @@ export const BASE_SYSTEM_PROMPT = OFFICIAL_DESIGNER_PROMPT;
export interface ComposeInput {
agentId?: string | null | undefined;
includeCodexImagegenOverride?: boolean | undefined;
streamFormat?: string | undefined;
skillBody?: string | undefined;
skillName?: string | undefined;
skillMode?:
@ -148,6 +149,7 @@ export function composeSystemPrompt({
critiqueBrand,
critiqueSkill,
connectedExternalMcp,
streamFormat,
}: ComposeInput): string {
// Discovery + philosophy goes FIRST so its hard rules ("emit a form on
// turn 1", "branch on brand on turn 2", "TodoWrite on turn 3", run
@ -247,6 +249,16 @@ export function composeSystemPrompt({
const mcpDirective = renderConnectedExternalMcpDirective(connectedExternalMcp);
if (mcpDirective) parts.push(mcpDirective);
// Suppress tool_calls in API/BYOK mode (streamFormat === 'plain').
// Only fires when the caller explicitly passes streamFormat='plain';
// does NOT fire when streamFormat is omitted, so non-plain (tool-using)
// adapters are unaffected and normal chat runs can still use tools.
if (streamFormat === 'plain') {
parts.push(
'\n\n## API mode rule\n\nDo not emit tool_calls. Output only <artifact> HTML blocks. Any tool description in your internal reasoning must not appear in the response.',
);
}
return parts.join('');
}

View file

@ -5921,6 +5921,7 @@ export async function startServer({
const prompt = composeSystemPrompt({
agentId,
includeCodexImagegenOverride: false,
streamFormat,
skillBody,
skillName,
skillMode,

View file

@ -689,6 +689,7 @@ export function ProjectView({
designSystemTitle,
metadata: project.metadata,
template,
streamFormat: config.mode === 'api' ? 'plain' : undefined,
});
}, [
project.skillId,
@ -696,6 +697,7 @@ export function ProjectView({
project.metadata,
skills,
designSystems,
config.mode,
]);
const persistMessage = useCallback(

View file

@ -287,6 +287,7 @@ export const FR_DESIGN_SYSTEM_SUMMARIES: Record<string, string> = {
wise: 'Transfert dargent. Accent vert lumineux, amical et clair.',
'x-ai': 'Lab IA dElon Musk. Look monochrome strict, minimalisme futuriste.',
xiaohongshu: 'Plateforme social lifestyle UGC. Rouge de marque singulier, radius généreux, content-first.',
wechat: 'Mini programmes WeChat. Vert frais (#07C160), PingFang SC, UI à bulle de chat, barre donglets.',
zapier: 'Plateforme dautomatisation. Orange chaud, amical, porté par lillustration.',
};
@ -306,6 +307,7 @@ export const FR_DESIGN_SYSTEM_CATEGORIES: Record<string, string> = {
'Fintech & Crypto': 'Fintech & crypto',
'E-Commerce & Retail': 'E-commerce & retail',
'Media & Consumer': 'Médias & grand public',
'Social & Messaging': 'Réseaux sociaux & messageries',
Automotive: 'Automobile',
'Editorial & Print': 'Éditorial & print',
'Editorial · Studio': 'Éditorial · Studio',
@ -360,6 +362,7 @@ export const FR_SKILL_IDS_WITH_EN_FALLBACK = [
'ib-pitch-book',
'last30days',
'live-dashboard',
'login-flow',
'orbit-general',
'orbit-github',
'orbit-gmail',
@ -458,6 +461,7 @@ export const FR_DESIGN_SYSTEM_IDS_WITH_EN_FALLBACK = [
'urdu',
'vibrant',
'vintage',
'wechat',
'webex',
] as const;

View file

@ -287,6 +287,7 @@ export const RU_DESIGN_SYSTEM_SUMMARIES: Record<string, string> = {
wise: 'Денежные переводы. Яркий зеленый акцент, дружелюбно и ясно.',
'x-ai': 'AI-лаборатория Илона Маска. Строгий монохром, футуристический минимализм.',
xiaohongshu: 'Lifestyle UGC-соцсеть. Единый фирменный красный, щедрый радиус, content-first.',
wechat: 'Мини-программы WeChat. Свежий зелёный (#07C160), PingFang SC, UI с чат-пузырями, панель вкладок.',
zapier: 'Платформа автоматизации. Теплый оранжевый, дружелюбная иллюстративная подача.',
};
@ -306,6 +307,7 @@ export const RU_DESIGN_SYSTEM_CATEGORIES: Record<string, string> = {
'Fintech & Crypto': 'Финтех и крипто',
'E-Commerce & Retail': 'Электронная коммерция и ритейл',
'Media & Consumer': 'Медиа и потребительские',
'Social & Messaging': 'Социальные сети и мессенджеры',
Automotive: 'Автомобили',
'Editorial & Print': 'Редакционные и печатные',
'Editorial · Studio': 'Редакционная студия',
@ -360,6 +362,7 @@ export const RU_SKILL_IDS_WITH_EN_FALLBACK = [
'ib-pitch-book',
'last30days',
'live-dashboard',
'login-flow',
'orbit-general',
'orbit-github',
'orbit-gmail',
@ -458,6 +461,7 @@ export const RU_DESIGN_SYSTEM_IDS_WITH_EN_FALLBACK = [
'urdu',
'vibrant',
'vintage',
'wechat',
'webex',
] as const;

View file

@ -334,6 +334,7 @@ const DE_DESIGN_SYSTEM_SUMMARIES: Record<string, string> = {
wise: 'Geldtransfer. Leuchtend grüner Akzent, freundlich und klar.',
'x-ai': 'Elon Musks AI-Lab. Strenger Monochrom-Look, futuristischer Minimalismus.',
xiaohongshu: 'Lifestyle-UGC-Social-Plattform. Singuläres Brand-Rot, großzügiger Radius, content-first.',
wechat: 'WeChat Mini Programs. Frisches Grün (#07C160), PingFang SC, Chat-Bubble-UI, Tab-Leiste.',
zapier: 'Automatisierungsplattform. Warmes Orange, freundlich illustrationsgetrieben.',
};
@ -353,6 +354,7 @@ const DE_DESIGN_SYSTEM_CATEGORIES: Record<string, string> = {
'Fintech & Crypto': 'Fintech & Krypto',
'E-Commerce & Retail': 'E-Commerce & Handel',
'Media & Consumer': 'Medien & Consumer',
'Social & Messaging': 'Social & Messaging',
Automotive: 'Automotive',
'Editorial & Print': 'Editorial & Print',
'Editorial · Studio': 'Editorial · Studio',
@ -407,6 +409,7 @@ const DE_SKILL_IDS_WITH_EN_FALLBACK = [
'ib-pitch-book',
'last30days',
'live-dashboard',
'login-flow',
'orbit-general',
'orbit-github',
'orbit-gmail',
@ -508,6 +511,7 @@ const DE_DESIGN_SYSTEM_IDS_WITH_EN_FALLBACK = [
'vibrant',
'vintage',
'webex',
'wechat',
] as const;
const DE_PROMPT_TEMPLATE_CATEGORIES: Record<string, string> = {

View file

@ -0,0 +1,302 @@
# WeChat Design System
> Category: Social & Messaging
> Brand visual language for WeChat Mini Programs, official accounts, and open ecosystem extensions.
## Brand Identity
WeChat's identity is built on simplicity, cleanness, and trust — reflecting its role as a super-app that connects people, services, and businesses.
---
## Color Palette
### Brand Colors
| Token | Hex | Usage |
|---|----|----|
| `--wechat-green` | `#07C160` | Primary brand, CTA buttons, active states |
| `--wechat-green-light` | `#10B160` | Hover state for primary actions |
| `--wechat-green-dark` | `#059050` | Pressed/active state |
### Chat Bubble Colors
| Token | Hex | Usage |
|---|----|----|
| `--wechat-bubble-self` | `#95EC69` | Outgoing message bubbles |
| `--wechat-bubble-other` | `#FFFFFF` | Incoming message bubbles |
| `--wechat-bubble-text` | `#1A1A1A` | Primary text in bubbles |
### UI Neutrals
| Token | Hex | Usage |
|---|----|----|
| `--wechat-bg` | `#EDEDED` | Page/app background |
| `--wechat-surface` | `#F7F7F7` | Card, modal surfaces |
| `--wechat-border` | `#E0E0E0` | Dividers, borders |
| `--wechat-ink` | `#1A1A1A` | Primary text |
| `--wechat-muted` | `#888888` | Secondary text, timestamps |
### Functional Colors
| Token | Hex | Usage |
|---|----|----|
| `--wechat-red` | `#FA5151` | Errors, destructive actions |
| `--wechat-orange` | `#FAB702` | Warnings |
| `--wechat-blue` | `#576B95` | Links, info states |
---
## Typography
### Font Stack
```
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", Helvetica, Arial, sans-serif;
```
### Type Scale
| Role | Size | Weight | Line Height |
|---|---|---|---|
| Page Title | 18px | 600 | 1.3 |
| Section Header | 16px | 600 | 1.4 |
| Body Text | 15px | 400 | 1.6 |
| Secondary Text | 13px | 400 | 1.5 |
| Caption/Timestamp | 11px | 400 | 1.4 |
| Button Label | 16px | 500 | 1.0 |
---
## Spacing System
4px base unit.
| Token | Value |
|---|-----|
| `--space-xs` | 4px |
| `--space-sm` | 8px |
| `--space-md` | 12px |
| `--space-lg` | 16px |
| `--space-xl` | 24px |
| `--space-2xl` | 32px |
### Border Radius
| Token | Value |
|---|-----|
| `--radius-sm` | 4px |
| `--radius-md` | 8px |
| `--radius-lg` | 16px |
| `--radius-bubble` | 16px (with directional corner clip) |
| `--radius-full` | 9999px (avatars, pills) |
---
## Components
### Chat Bubble
```css
.wechat-bubble {
max-width: 70%;
padding: 10px 14px;
border-radius: var(--radius-bubble);
font-size: 15px;
line-height: 1.6;
position: relative;
}
.wechat-bubble.self {
background: var(--wechat-bubble-self);
color: var(--wechat-bubble-text);
border-top-right-radius: 4px;
margin-left: auto;
}
.wechat-bubble.other {
background: var(--wechat-bubble-other);
color: var(--wechat-bubble-text);
border-top-left-radius: 4px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
}
```
### Primary Button (Send / Confirm)
```css
.btn-wechat-primary {
background: var(--wechat-green);
color: #fff;
border: none;
border-radius: var(--radius-md);
padding: 12px 32px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
}
.btn-wechat-primary:hover {
background: var(--wechat-green-light);
}
.btn-wechat-primary:active {
background: var(--wechat-green-dark);
}
```
### Tab Bar
```css
.tab-bar {
display: flex;
background: var(--wechat-surface);
border-top: 1px solid var(--wechat-border);
padding: 8px 0 calc(8px + env(safe-area-inset-bottom));
}
.tab-bar-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
color: var(--wechat-muted);
font-size: 10px;
cursor: pointer;
transition: color 0.15s;
}
.tab-bar-item.active {
color: var(--wechat-green);
}
.tab-bar-item svg {
width: 24px;
height: 24px;
}
```
### Message Input Bar
```css
.input-bar {
display: flex;
align-items: flex-end;
gap: 8px;
padding: 10px 12px calc(10px + env(safe-area-inset-bottom));
background: var(--wechat-surface);
border-top: 1px solid var(--wechat-border);
}
.input-bar textarea {
flex: 1;
min-height: 36px;
max-height: 100px;
padding: 8px 12px;
background: var(--wechat-bg);
border: 1px solid var(--wechat-border);
border-radius: var(--radius-lg);
font-size: 15px;
line-height: 1.5;
resize: none;
outline: none;
}
.input-bar textarea:focus {
border-color: var(--wechat-green);
}
```
### Avatar
```css
.avatar {
width: 40px;
height: 40px;
border-radius: var(--radius-full);
object-fit: cover;
background: var(--wechat-border);
}
.avatar.sm { width: 32px; height: 32px; }
.avatar.lg { width: 56px; height: 56px; }
```
### Timestamp Badge
```css
.timestamp {
display: inline-block;
padding: 4px 8px;
background: rgba(0, 0, 0, 0.08);
border-radius: var(--radius-sm);
font-size: 11px;
color: var(--wechat-muted);
text-align: center;
}
```
---
## Motion & Animation
| Token | Value |
|---|-----|
| `--duration-instant` | 100ms |
| `--duration-fast` | 200ms |
| `--duration-normal` | 300ms |
| `--ease-default` | cubic-bezier(0.25, 0.1, 0.25, 1) |
Chat message entry: fade-in + slight slide up, 200ms.
---
## Dark Mode
| Token | Light | Dark |
|---|---|---|
| `--wechat-bg` | `#EDEDED` | `#1A1A1A` |
| `--wechat-surface` | `#F7F7F7` | `#2C2C2C` |
| `--wechat-ink` | `#1A1A1A` | `#F7F7F7` |
| `--wechat-bubble-self` | `#95EC69` | `#4CAF50` |
| `--wechat-bubble-other` | `#FFFFFF` | `#2C2C2C` |
---
## Usage
```css
:root {
--wechat-green: #07C160;
--wechat-green-light: #10B160;
--wechat-green-dark: #059050;
--wechat-bubble-self: #95EC69;
--wechat-bubble-other: #FFFFFF;
--wechat-bubble-text: #1A1A1A;
--wechat-bg: #EDEDED;
--wechat-surface: #F7F7F7;
--wechat-border: #E0E0E0;
--wechat-ink: #1A1A1A;
--wechat-muted: #888888;
--wechat-red: #FA5151;
--wechat-orange: #FAB702;
--wechat-blue: #576B95;
--space-xs: 4px;
--space-sm: 8px;
--space-md: 12px;
--space-lg: 16px;
--space-xl: 24px;
--space-2xl: 32px;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 16px;
--radius-bubble: 16px;
--radius-full: 9999px;
--duration-instant: 100ms;
--duration-fast: 200ms;
--duration-normal: 300ms;
--ease-default: cubic-bezier(0.25, 0.1, 0.25, 1);
}
```

View file

@ -40,6 +40,25 @@ function sorted(values: Iterable<string>): string[] {
return [...values].sort((a, b) => a.localeCompare(b));
}
function uniqueSorted(values: Iterable<string>): string[] {
return sorted(new Set(values));
}
function findMissingIds(localizedIds: Iterable<string>, discoveredIds: Iterable<string>): string[] {
const localized = new Set(localizedIds);
const discovered = new Set(discoveredIds);
return sorted([...discovered].filter((id) => !localized.has(id)));
}
function expectExactResourceCoverage(
label: string,
localizedIds: Iterable<string>,
discoveredIds: Iterable<string>,
): void {
const missing = findMissingIds(localizedIds, discoveredIds);
expect(missing, `${label} should cover every discovered resource`).toEqual([]);
}
async function entriesWithFile(root: string, fileName: string): Promise<string[]> {
const entries = await readdir(root, { withFileTypes: true });
const ids: string[] = [];
@ -141,12 +160,16 @@ describe('localized display content coverage', () => {
readPromptTemplateSummaries(),
]);
expect(sorted(ids.skills), 'skills display copy').toEqual(skillIds);
expect(sorted(ids.designSystems), 'design-system summaries').toEqual(
expectExactResourceCoverage('skills display copy', ids.skills, skillIds);
expectExactResourceCoverage(
'design-system summaries',
ids.designSystems,
designSystemIds,
);
expect(sorted(ids.promptTemplates), 'prompt-template metadata').toEqual(
sorted(promptTemplateSummaries.map((template) => template.id)),
expectExactResourceCoverage(
'prompt-template metadata',
ids.promptTemplates,
uniqueSorted(promptTemplateSummaries.map((template) => template.id)),
);
});

View file

@ -60,6 +60,9 @@ export interface ComposeInput {
// Snapshot of HTML files that the agent should treat as a starting
// reference rather than a fixed deliverable.
template?: ProjectTemplate | undefined;
// When set to 'plain', suppresses tool_calls so API/BYOK-mode models
// only emit <artifact> blocks (they cannot execute tools).
streamFormat?: string | undefined;
}
export function composeSystemPrompt({
@ -70,6 +73,7 @@ export function composeSystemPrompt({
designSystemTitle,
metadata,
template,
streamFormat,
}: ComposeInput): string {
// Discovery + philosophy goes FIRST so its hard rules ("emit a form on
// turn 1", "branch on brand on turn 2", "TodoWrite on turn 3", run
@ -131,6 +135,16 @@ export function composeSystemPrompt({
parts.push(MEDIA_GENERATION_CONTRACT);
}
// Suppress tool_calls in API/BYOK mode (streamFormat === 'plain').
// Only fires when the caller explicitly passes streamFormat='plain';
// does NOT fire when streamFormat is omitted, so non-plain (tool-using)
// adapters are unaffected and normal chat runs can still use tools.
if (streamFormat === 'plain') {
parts.push(
'\n\n## API mode rule\n\nDo not emit tool_calls. Output only <artifact> HTML blocks. Any tool description in your internal reasoning must not appear in the response.',
);
}
return parts.join('');
}

View file

@ -0,0 +1,48 @@
---
name: login-flow
description: Mobile login and authentication flow screens
od:
mode: prototype
platform: mobile
triggers:
- login
- sign in
- 注册登录
- 登录注册
- 手机号登录
- 验证码登录
- 密码登录
---
# Login Flow Skill
A skill for generating mobile-first login and authentication screens. Use this when the user wants a sign-in experience for a mobile app, including phone + SMS verification, password-based login, and social SSO options.
## Workflow
1. **Read reference files first** (see below)
2. **Clarify auth method**: phone/SMS, password, or social SSO
3. **Checklist gate** — verify P0 items before emitting `<artifact>`
4. **Build the HTML prototype** with proper states (default, loading, error)
5. **Wrap in `<artifact>` tag** referencing the output file
## Side Files
- `references/checklist.md` — P0/P1 acceptance criteria
## Output
A single standalone HTML file implementing the login screen with:
- Labels above inputs (never placeholder-only)
- Password field with show/hide toggle
- Social SSO buttons with SVG icons
- Error states below fields
- Loading spinner in primary CTA
- Touch targets minimum 44px
## Mobile-First Constraints
- Viewport: 375px wide (iPhone standard)
- No horizontal scroll
- Safe area insets for notched devices
- Input keyboards: `tel` for phone, `password` for password fields

View file

@ -0,0 +1,362 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Nova · Login</title>
<style>
:root {
--bg: #F7F8FA;
--surface: #FFFFFF;
--ink: #1A1A2E;
--muted: #6B7280;
--border: #E5E7EB;
--brand: #4F46E5;
--brand-hover: #4338CA;
--error: #DC2626;
--success: #059669;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.phone {
width: 375px;
background: var(--surface);
border-radius: 40px;
overflow: hidden;
box-shadow: 0 25px 50px -12px rgba(0,0,0,0.15);
display: flex;
flex-direction: column;
}
.statusbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 24px 4px;
font-size: 14px;
font-weight: 600;
color: var(--ink);
}
.statusbar .right {
display: flex;
gap: 6px;
align-items: center;
}
.status-icon {
width: 16px;
height: 10px;
background: var(--ink);
border-radius: 2px;
position: relative;
}
.status-icon::after {
content: '';
position: absolute;
right: -3px;
top: 2px;
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--ink);
}
.content {
flex: 1;
padding: 32px 28px;
display: flex;
flex-direction: column;
gap: 24px;
}
.logo-area {
text-align: center;
padding: 8px 0 4px;
}
.logo {
width: 56px;
height: 56px;
background: var(--brand);
border-radius: 14px;
margin: 0 auto 12px;
display: flex;
align-items: center;
justify-content: center;
}
.logo svg { width: 32px; height: 32px; fill: white; }
.app-name {
font-size: 20px;
font-weight: 700;
color: var(--ink);
letter-spacing: -0.02em;
}
.header { text-align: center; }
.header h1 {
font-size: 24px;
font-weight: 700;
color: var(--ink);
margin-bottom: 8px;
letter-spacing: -0.02em;
}
.header p {
font-size: 14px;
color: var(--muted);
line-height: 1.5;
}
.form { display: flex; flex-direction: column; gap: 16px; }
.field { display: flex; flex-direction: column; gap: 6px; }
.field label {
font-size: 13px;
font-weight: 600;
color: var(--ink);
}
.input-wrap {
position: relative;
display: flex;
align-items: center;
}
.input-wrap input {
width: 100%;
height: 48px;
padding: 0 16px;
border: 1.5px solid var(--border);
border-radius: 12px;
font-size: 16px;
color: var(--ink);
background: var(--surface);
transition: border-color 0.2s;
}
.input-wrap input:focus {
outline: none;
border-color: var(--brand);
}
.input-wrap input::placeholder { color: var(--muted); }
.input-wrap .toggle {
position: absolute;
right: 14px;
width: 24px;
height: 24px;
background: none;
border: none;
cursor: pointer;
color: var(--muted);
display: flex;
align-items: center;
justify-content: center;
}
.input-wrap .toggle:hover { color: var(--ink); }
.phone-input { display: flex; gap: 8px; }
.phone-input .country {
width: 90px;
height: 48px;
padding: 0 12px;
border: 1.5px solid var(--border);
border-radius: 12px;
font-size: 16px;
color: var(--ink);
background: var(--surface);
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
.phone-input .country .flag { font-size: 18px; }
.phone-input input { flex: 1; }
.row { display: flex; justify-content: flex-end; }
.forgot {
font-size: 13px;
color: var(--brand);
text-decoration: none;
font-weight: 500;
}
.forgot:hover { text-decoration: underline; }
.btn-primary {
width: 100%;
height: 52px;
background: var(--brand);
color: white;
border: none;
border-radius: 12px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: background-color 0.2s;
}
.btn-primary:hover { background: var(--brand-hover); }
.btn-primary:disabled {
background: #A5B4FC;
cursor: not-allowed;
}
.btn-primary .spinner {
width: 18px;
height: 18px;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.divider {
display: flex;
align-items: center;
gap: 16px;
color: var(--muted);
font-size: 13px;
}
.divider::before,
.divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--border);
}
.social { display: flex; gap: 12px; }
.social-btn {
flex: 1;
height: 48px;
border: 1.5px solid var(--border);
border-radius: 12px;
background: var(--surface);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 14px;
font-weight: 500;
color: var(--ink);
transition: background-color 0.2s;
}
.social-btn:hover { background: var(--bg); }
.social-btn svg { width: 20px; height: 20px; }
.footer {
text-align: center;
font-size: 14px;
color: var(--muted);
padding-top: 8px;
}
.footer a {
color: var(--brand);
text-decoration: none;
font-weight: 500;
}
.footer a:hover { text-decoration: underline; }
.error-msg {
font-size: 12px;
color: var(--error);
margin-top: 4px;
display: none;
}
.field.has-error input { border-color: var(--error); }
.field.has-error .error-msg { display: block; }
</style>
</head>
<body>
<div class="phone">
<div class="statusbar">
<span>9:41</span>
<div class="right">
<span>5G</span>
<span class="status-icon"></span>
</div>
</div>
<div class="content">
<div class="logo-area">
<div class="logo">
<svg viewBox="0 0 24 24"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
</div>
<div class="app-name">Nova</div>
</div>
<div class="header">
<h1>Welcome back</h1>
<p>Sign in to continue to your account</p>
</div>
<form class="form" onsubmit="return false;">
<div class="field">
<label for="phone">Phone number</label>
<div class="phone-input">
<div class="country">
<span class="flag">+86</span>
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor"><path d="M3 5l3 3 3-3"/></svg>
</div>
<input type="tel" id="phone" placeholder="123 4567 8900" />
</div>
<span class="error-msg">Please enter a valid phone number</span>
</div>
<div class="field">
<label for="password">Password</label>
<div class="input-wrap">
<input type="password" id="password" placeholder="Enter your password" />
<button type="button" class="toggle" aria-label="Show password">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
</button>
</div>
<span class="error-msg">Password is required</span>
</div>
<div class="row">
<a href="#" class="forgot">Forgot password?</a>
</div>
<button type="submit" class="btn-primary">
<span class="btn-text">Sign in</span>
</button>
<div class="divider">or continue with</div>
<div class="social">
<button type="button" class="social-btn">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.025-1.632 2.815-.181.79-.065 1.62.116 2.43.143.4.367.78.666 1.106.315.345.69.647 1.114.918.927.568 2.013.858 3.183.955 1.18.098 2.063-.018 2.94-.123 1.05-.126 1.92-.78 2.528-1.54 1.12-1.38 1.62-3.24 1.337-4.6z"/></svg>
Apple
</button>
<button type="button" class="social-btn">
<svg viewBox="0 0 24 24"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>
Google
</button>
</div>
</form>
<div class="footer">
Don't have an account? <a href="#">Sign up</a>
</div>
</div>
</div>
</body>
<script>
const toggleBtn = document.querySelector('.toggle');
const passwordInput = document.getElementById('password');
if (toggleBtn && passwordInput) {
toggleBtn.addEventListener('click', function () {
const isPassword = passwordInput.type === 'password';
passwordInput.type = isPassword ? 'text' : 'password';
toggleBtn.setAttribute('aria-label', isPassword ? 'Hide password' : 'Show password');
});
}
</script>
</html>

View file

@ -0,0 +1,19 @@
# Login Flow Checklist
P0 (must pass before emitting artifact):
- [ ] Labels above inputs, never placeholder-only
- [ ] Password field has show/hide toggle
- [ ] Social buttons use SVG icons, not emoji
- [ ] Touch targets are minimum 44px
- [ ] Error states show red text below the field
- [ ] Primary CTA button has hover/active states
- [ ] No placeholder text like "example@email.com" without indication
P1 (should pass):
- [ ] Loading spinner in button during submission
- [ ] "Forgot password" link present
- [ ] "Don't have an account" link present
- [ ] Country picker for phone input (if phone auth)
- [ ] Input focus state uses brand color