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 { 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 { ChatSendButton } from "../chat-send-button";
import { ImageBubble } from "../image-bubble"; import { ImageBubble } from "../image-bubble";
import { MessageAvatar } from "../message-avatar"; import { MessageAvatar } from "../message-avatar";
import { PrivateMessageCard } from "../private-message-card"; import { PrivateMessageCard } from "../private-message-card";
import { TextBubble } from "../text-bubble"; import { TextBubble } from "../text-bubble";
vi.mock("@/router/use-app-navigator", () => ({
useAppNavigator: () => ({
openSubscription: vi.fn(),
}),
}));
describe("chat Tailwind components", () => { describe("chat Tailwind components", () => {
it("renders MessageAvatar AI and user branches with image utilities", () => { it("renders MessageAvatar AI and user branches with image utilities", () => {
const aiHtml = renderToStaticMarkup(<MessageAvatar isFromAI={true} />); const aiHtml = renderToStaticMarkup(<MessageAvatar isFromAI={true} />);
@@ -116,4 +124,40 @@ describe("chat Tailwind components", () => {
); );
expect(paywalledHtml).toContain("data:image/png;base64,/locked-image.png"); 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 { ROUTES } from "@/router/routes";
import { useAppNavigator } from "@/router/use-app-navigator"; import { useAppNavigator } from "@/router/use-app-navigator";
import styles from "./chat-header.module.css";
export interface ChatHeaderProps { export interface ChatHeaderProps {
isGuest: boolean; isGuest: boolean;
offerBanner?: ReactNode; offerBanner?: ReactNode;
@@ -23,14 +21,18 @@ export function ChatHeader({ isGuest, offerBanner }: ChatHeaderProps) {
if (isGuest) { if (isGuest) {
return ( return (
<header className={styles.header}> <header className="flex shrink-0 flex-col items-stretch bg-transparent p-0">
<button <button
type="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)} onClick={() => navigator.openAuth(ROUTES.chat)}
aria-label="Sign up to unlock more features" 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> <span>Sign up to unlock more features</span>
</button> </button>
</header> </header>
@@ -38,18 +40,22 @@ export function ChatHeader({ isGuest, offerBanner }: ChatHeaderProps) {
} }
return ( return (
<header className={styles.header}> <header className="flex shrink-0 flex-col items-stretch bg-transparent p-0">
{offerBanner} {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 一致) */} {/* 菜单按钮(点击 → push 到 /sidebar,与 Dart MenuButton 一致) */}
<button <button
type="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)} onClick={() => navigator.push(ROUTES.sidebar)}
aria-label="Menu" 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> </button>
</div> </div>
</header> </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 { useAppNavigator } from "@/router/use-app-navigator";
import styles from "./chat-insufficient-credits-banner.module.css";
export interface ChatInsufficientCreditsBannerProps { export interface ChatInsufficientCreditsBannerProps {
title?: string; title?: string;
description?: string; description?: string;
@@ -51,12 +49,12 @@ export function ChatInsufficientCreditsBanner({
return ( return (
<div <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" role="status"
aria-live="polite" aria-live="polite"
data-testid="chat-insufficient-credits-banner" 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) => ( {titleLines.map((line, index) => (
<span key={`${line}-${index}`}> <span key={`${line}-${index}`}>
{line} {line}
@@ -65,11 +63,13 @@ export function ChatInsufficientCreditsBanner({
))} ))}
</p> </p>
{description ? ( {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} ) : null}
<button <button
type="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} onClick={handleClick}
aria-label={ctaLabel} aria-label={ctaLabel}
> >
@@ -1,10 +1,25 @@
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { SplashBackground } from "../splash-background"; import { SplashBackground } from "../splash-background";
import { SplashButton } from "../splash-button";
import { SplashContent } from "../splash-content"; import { SplashContent } from "../splash-content";
import { SplashLogo } from "../splash-logo"; import { SplashLogo } from "../splash-logo";
const authStateMock = vi.hoisted(() => ({
value: {
authPanelMode: "facebook",
isLoading: false,
errorMessage: null,
loginStatus: "notLoggedIn",
hasInitialized: true,
},
}));
vi.mock("@/stores/auth/auth-context", () => ({
useAuthState: () => authStateMock.value,
}));
describe("splash Tailwind components", () => { describe("splash Tailwind components", () => {
it("renders SplashLogo with Tailwind wrapper and image classes", () => { it("renders SplashLogo with Tailwind wrapper and image classes", () => {
const html = renderToStaticMarkup(<SplashLogo />); const html = renderToStaticMarkup(<SplashLogo />);
@@ -32,4 +47,31 @@ describe("splash Tailwind components", () => {
expect(html).toContain("whitespace-pre-line"); expect(html).toContain("whitespace-pre-line");
expect(html).toContain("Welcome to my secret hideout"); expect(html).toContain("Welcome to my secret hideout");
}); });
it("renders SplashButton ready and loading states", () => {
authStateMock.value = {
...authStateMock.value,
hasInitialized: true,
isLoading: false,
};
const readyHtml = renderToStaticMarkup(
<SplashButton onStartChat={() => undefined} />,
);
authStateMock.value = {
...authStateMock.value,
hasInitialized: false,
isLoading: false,
};
const loadingHtml = renderToStaticMarkup(
<SplashButton onStartChat={() => undefined} />,
);
expect(readyHtml).toContain("z-[2]");
expect(readyHtml).toContain("max-w-[480px]");
expect(readyHtml).toContain("bg-[linear-gradient(to_right");
expect(readyHtml).toContain("Start Chatting");
expect(loadingHtml).toContain("disabled");
expect(loadingHtml).toContain("animate-spin");
});
}); });
@@ -1,46 +0,0 @@
.wrapper {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: clamp(var(--spacing-md, 12px), 4.815vw, var(--spacing-26, 26px));
width: 100%;
padding: 0 clamp(var(--spacing-sm, 8px), 2.963vw, var(--spacing-lg, 16px));
z-index: 2;
}
.facebookButton {
width: 100%;
max-width: 480px;
min-height: var(--responsive-control-height, 48px);
padding: 0 clamp(var(--spacing-lg, 16px), 5.556vw, 30px);
border: 0;
border-radius: var(--radius-full, 999px);
/* 渐变色引用 tokens/colors.css 中定义的 token */
background: linear-gradient(
to right,
var(--color-button-gradient-start),
var(--color-button-gradient-end)
);
color: #ffffff;
font-weight: 700;
cursor: pointer;
font-style: italic;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 1 1 auto;
box-shadow: 0 0 10px rgba(248, 89, 168, 0.3);
}
.facebookButton:disabled {
cursor: not-allowed;
opacity: 0.7;
}
.facebookLabel {
font-size: var(--responsive-section-title, var(--font-size-xxl));
font-weight: 700;
color: #ffffff;
white-space: nowrap;
}
+5 -4
View File
@@ -4,7 +4,6 @@
*/ */
import { useAuthState } from "@/stores/auth/auth-context"; import { useAuthState } from "@/stores/auth/auth-context";
import { LoadingIndicator } from "@/app/_components/core/loading-indicator"; import { LoadingIndicator } from "@/app/_components/core/loading-indicator";
import styles from "./splash-button.module.css";
export interface SplashButtonProps { export interface SplashButtonProps {
onStartChat: () => void; onStartChat: () => void;
@@ -15,17 +14,19 @@ export function SplashButton({ onStartChat }: SplashButtonProps) {
const isLoading = !state.hasInitialized || state.isLoading; const isLoading = !state.hasInitialized || state.isLoading;
return ( return (
<div className={styles.wrapper}> <div className="z-[2] flex w-full flex-row items-center justify-center gap-[clamp(var(--spacing-md,12px),4.815vw,var(--spacing-26,26px))] px-[clamp(var(--spacing-sm,8px),2.963vw,var(--spacing-lg,16px))]">
<button <button
type="button" type="button"
onClick={onStartChat} onClick={onStartChat}
disabled={isLoading} disabled={isLoading}
className={styles.facebookButton} className="inline-flex min-h-[var(--responsive-control-height,48px)] w-full max-w-[480px] flex-auto cursor-pointer items-center justify-center rounded-[var(--radius-full,999px)] border-0 bg-[linear-gradient(to_right,var(--color-button-gradient-start),var(--color-button-gradient-end))] px-[clamp(var(--spacing-lg,16px),5.556vw,30px)] font-bold italic text-white shadow-[0_0_10px_rgba(248,89,168,0.3)] disabled:cursor-not-allowed disabled:opacity-70"
> >
{isLoading ? ( {isLoading ? (
<LoadingIndicator color="#ffffff" /> <LoadingIndicator color="#ffffff" />
) : ( ) : (
<span className={styles.facebookLabel}>Start Chatting</span> <span className="whitespace-nowrap text-[var(--responsive-section-title,var(--font-size-xxl))] font-bold text-white">
Start Chatting
</span>
)} )}
</button> </button>
</div> </div>