refactor(app): migrate banner styles to tailwind

This commit is contained in:
2026-07-13 10:28:31 +08:00
parent da04eaa372
commit 181e0aee19
8 changed files with 114 additions and 192 deletions
@@ -1,12 +1,20 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { ChatHeader } from "../chat-header";
import { ChatInsufficientCreditsBanner } from "../chat-insufficient-credits-banner";
import { ChatSendButton } from "../chat-send-button";
import { ImageBubble } from "../image-bubble";
import { MessageAvatar } from "../message-avatar";
import { PrivateMessageCard } from "../private-message-card";
import { TextBubble } from "../text-bubble";
vi.mock("@/router/use-app-navigator", () => ({
useAppNavigator: () => ({
openSubscription: vi.fn(),
}),
}));
describe("chat Tailwind components", () => {
it("renders MessageAvatar AI and user branches with image utilities", () => {
const aiHtml = renderToStaticMarkup(<MessageAvatar isFromAI={true} />);
@@ -116,4 +124,40 @@ describe("chat Tailwind components", () => {
);
expect(paywalledHtml).toContain("data:image/png;base64,/locked-image.png");
});
it("renders ChatInsufficientCreditsBanner with split title and CTA", () => {
const html = renderToStaticMarkup(
<ChatInsufficientCreditsBanner
title={"Need credits\nTop up now"}
description="Credits keep the conversation going."
ctaLabel="Top up"
onUnlock={() => undefined}
/>,
);
expect(html).toContain('role="status"');
expect(html).toContain('aria-live="polite"');
expect(html).toContain("Need credits");
expect(html).toContain("<br/>");
expect(html).toContain("Top up now");
expect(html).toContain("Credits keep the conversation going.");
expect(html).toContain('aria-label="Top up"');
expect(html).toContain("bg-[linear-gradient(135deg,#f96ade_0%,#f657a0_100%)]");
expect(html).toContain("active:scale-[0.98]");
});
it("renders ChatHeader guest and member branches", () => {
const guestHtml = renderToStaticMarkup(<ChatHeader isGuest={true} />);
const memberHtml = renderToStaticMarkup(
<ChatHeader isGuest={false} offerBanner={<div>Offer</div>} />,
);
expect(guestHtml).toContain('aria-label="Sign up to unlock more features"');
expect(guestHtml).toContain("bg-[var(--color-accent,#f84d96)]");
expect(guestHtml).toContain("size-[var(--icon-size-sm,16px)]");
expect(memberHtml).toContain("Offer");
expect(memberHtml).toContain('aria-label="Menu"');
expect(memberHtml).toContain("bg-[rgba(13,11,20,0.7)]");
expect(memberHtml).toContain("size-[var(--icon-size-xl,32px)]");
});
});
@@ -1,60 +0,0 @@
/* ChatHeader 顶部栏样式(与 Dart chat_header.dart 对齐) */
/* 外层:等价于 Dart `Column(mainAxisSize: MainAxisSize.min)` — 无 padding/背景 */
.header {
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: stretch;
padding: 0;
background: transparent;
}
/* 游客模式横幅(Dart: Container(color: accent, padding: vertical 8 / horizontal 12) */
.guestBanner {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-sm, 8px);
padding: var(--spacing-sm, 8px) var(--spacing-md, 12px);
background: var(--color-accent, #f84d96);
color: #fff;
font-size: var(--responsive-caption, var(--font-size-sm, 12px));
font-weight: 500;
cursor: pointer;
border: 0;
text-align: center;
}
.guestBannerIcon {
width: var(--icon-size-sm, 16px);
height: var(--icon-size-sm, 16px);
}
/* 登录模式行容器(Dart: Container(margin-left 12, padding: vertical 8 / horizontal 12) */
.headerRow {
display: flex;
align-items: center;
justify-content: space-between;
margin-left: var(--spacing-md, 12px);
padding: var(--spacing-sm, 8px) var(--spacing-md, 12px);
}
/* 菜单按钮(Dart: Container(padding 8, bg #0d0b14 @ 70%, radius 32) */
.menuButton {
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-sm, 8px);
background: rgba(13, 11, 20, 0.7); /* AppColors.darkBackground #0d0b14 @ alpha 0.7 */
border-radius: var(--radius-full, 999px);
border: 0;
cursor: pointer;
color: var(--color-text-primary, #fff);
}
.menuIcon {
width: var(--icon-size-xl, 32px);
height: var(--icon-size-xl, 32px);
}
+15 -9
View File
@@ -11,8 +11,6 @@ import { Lock, Menu } from "lucide-react";
import { ROUTES } from "@/router/routes";
import { useAppNavigator } from "@/router/use-app-navigator";
import styles from "./chat-header.module.css";
export interface ChatHeaderProps {
isGuest: boolean;
offerBanner?: ReactNode;
@@ -23,14 +21,18 @@ export function ChatHeader({ isGuest, offerBanner }: ChatHeaderProps) {
if (isGuest) {
return (
<header className={styles.header}>
<header className="flex shrink-0 flex-col items-stretch bg-transparent p-0">
<button
type="button"
className={styles.guestBanner}
className="flex w-full cursor-pointer items-center justify-center gap-[var(--spacing-sm,8px)] border-0 bg-[var(--color-accent,#f84d96)] px-[var(--spacing-md,12px)] py-[var(--spacing-sm,8px)] text-center text-[var(--responsive-caption,var(--font-size-sm,12px))] font-medium text-white"
onClick={() => navigator.openAuth(ROUTES.chat)}
aria-label="Sign up to unlock more features"
>
<Lock className={styles.guestBannerIcon} size={16} aria-hidden="true" />
<Lock
className="size-[var(--icon-size-sm,16px)]"
size={16}
aria-hidden="true"
/>
<span>Sign up to unlock more features</span>
</button>
</header>
@@ -38,18 +40,22 @@ export function ChatHeader({ isGuest, offerBanner }: ChatHeaderProps) {
}
return (
<header className={styles.header}>
<header className="flex shrink-0 flex-col items-stretch bg-transparent p-0">
{offerBanner}
<div className={styles.headerRow}>
<div className="ml-[var(--spacing-md,12px)] flex items-center justify-between px-[var(--spacing-md,12px)] py-[var(--spacing-sm,8px)]">
{/* 菜单按钮(点击 → push 到 /sidebar,与 Dart MenuButton 一致) */}
<button
type="button"
className={styles.menuButton}
className="flex cursor-pointer items-center justify-center rounded-[var(--radius-full,999px)] border-0 bg-[rgba(13,11,20,0.7)] p-[var(--spacing-sm,8px)] text-[var(--color-text-primary,#fff)]"
onClick={() => navigator.push(ROUTES.sidebar)}
aria-label="Menu"
>
<Menu className={styles.menuIcon} size={24} aria-hidden="true" />
<Menu
className="size-[var(--icon-size-xl,32px)]"
size={24}
aria-hidden="true"
/>
</button>
</div>
</header>
@@ -1,65 +0,0 @@
/* ChatInsufficientCreditsBanner — 积分不足横幅
* 视觉规格(与设计稿对齐):
* - 粉→品红渐变背景(与 splash gradient 一致)
* - 居中文案 + 渐变 pill 按钮
* - 圆角 + 阴影(与 /sidebar 已有卡片的视觉重量对齐)
*/
.banner {
flex: 0 0 auto;
margin:
0
var(--chat-inline-padding, 16px)
var(--page-section-gap, 16px);
padding:
var(--page-section-gap-lg, 24px)
var(--responsive-card-padding, 16px);
border-radius: var(--responsive-card-radius-sm, 24px);
background: linear-gradient(135deg, #f96ade 0%, #f657a0 100%);
text-align: center;
box-shadow: 0 4px 16px rgba(248, 77, 150, 0.25);
}
.text {
color: #fff;
font-size: var(--responsive-card-title, 18px);
font-weight: 600;
line-height: 1.4;
margin: 0 0 var(--spacing-sm, 8px);
/* 与 splash 标题的粉色字保持视觉一致(白底渐变叠加层需要白字保证可读性) */
opacity: 0.95;
}
.description {
max-width: min(100%, 360px);
margin: 0 auto var(--spacing-lg, 16px);
color: rgba(255, 255, 255, 0.88);
font-size: var(--responsive-caption, 14px);
font-weight: 600;
line-height: 1.4;
}
.cta {
width: 100%;
min-height: var(--responsive-control-height, 48px);
padding: 0 var(--responsive-card-padding-lg, 24px);
border: none;
border-radius: 999px;
background: linear-gradient(135deg, #ffb8e0 0%, #f57ec0 100%);
color: #fff;
font-size: var(--responsive-card-title, 18px);
font-weight: 700;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
transition:
transform 0.15s ease,
box-shadow 0.15s ease;
}
.cta:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.cta:active {
transform: scale(0.98);
}
@@ -18,8 +18,6 @@
*/
import { useAppNavigator } from "@/router/use-app-navigator";
import styles from "./chat-insufficient-credits-banner.module.css";
export interface ChatInsufficientCreditsBannerProps {
title?: string;
description?: string;
@@ -51,12 +49,12 @@ export function ChatInsufficientCreditsBanner({
return (
<div
className={styles.banner}
className="mx-[var(--chat-inline-padding,16px)] mb-[var(--page-section-gap,16px)] shrink-0 rounded-[var(--responsive-card-radius-sm,24px)] bg-[linear-gradient(135deg,#f96ade_0%,#f657a0_100%)] px-[var(--responsive-card-padding,16px)] py-[var(--page-section-gap-lg,24px)] text-center shadow-[0_4px_16px_rgba(248,77,150,0.25)]"
role="status"
aria-live="polite"
data-testid="chat-insufficient-credits-banner"
>
<p className={styles.text}>
<p className="m-0 mb-[var(--spacing-sm,8px)] text-[var(--responsive-card-title,18px)] font-semibold leading-[1.4] text-white opacity-95">
{titleLines.map((line, index) => (
<span key={`${line}-${index}`}>
{line}
@@ -65,11 +63,13 @@ export function ChatInsufficientCreditsBanner({
))}
</p>
{description ? (
<p className={styles.description}>{description}</p>
<p className="mx-auto mb-[var(--spacing-lg,16px)] mt-0 max-w-[min(100%,360px)] text-[var(--responsive-caption,14px)] font-semibold leading-[1.4] text-[rgba(255,255,255,0.88)]">
{description}
</p>
) : null}
<button
type="button"
className={styles.cta}
className="min-h-[var(--responsive-control-height,48px)] w-full cursor-pointer rounded-full border-0 bg-[linear-gradient(135deg,#ffb8e0_0%,#f57ec0_100%)] px-[var(--responsive-card-padding-lg,24px)] text-[var(--responsive-card-title,18px)] font-bold text-white shadow-[0_2px_8px_rgba(0,0,0,0.15)] transition-[box-shadow,transform] duration-150 hover:shadow-[0_4px_12px_rgba(0,0,0,0.2)] active:scale-[0.98]"
onClick={handleClick}
aria-label={ctaLabel}
>