refactor(app): migrate shared styles to tailwind

This commit is contained in:
2026-07-13 15:46:28 +08:00
parent 4682b4bf3f
commit 51462660a4
13 changed files with 145 additions and 289 deletions
@@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest";
import { Checkbox } from "../checkbox"; import { Checkbox } from "../checkbox";
import { LoadingIndicator } from "../loading-indicator"; import { LoadingIndicator } from "../loading-indicator";
import { PageLoadingFallback } from "../page-loading-fallback"; import { PageLoadingFallback } from "../page-loading-fallback";
import { ResponsiveMobileShell } from "../responsive-mobile-shell";
describe("core Tailwind components", () => { describe("core Tailwind components", () => {
it("renders LoadingIndicator with accessible status and dynamic sizing", () => { it("renders LoadingIndicator with accessible status and dynamic sizing", () => {
@@ -54,4 +55,22 @@ describe("core Tailwind components", () => {
expect(labeledHtml).toContain("gap-(--spacing-sm)"); expect(labeledHtml).toContain("gap-(--spacing-sm)");
}); });
it("renders ResponsiveMobileShell with bounded canvas utilities", () => {
const html = renderToStaticMarkup(
<ResponsiveMobileShell
background="#fff"
outerBackground="#000"
className="custom-content"
>
Content
</ResponsiveMobileShell>,
);
expect(html).toContain("min-h-(--app-viewport-height,100dvh)");
expect(html).toContain("max-w-(--app-max-width,540px)");
expect(html).toContain("overflow-x-clip");
expect(html).toContain("custom-content");
expect(html).toContain("Content");
});
}); });
@@ -1,7 +1,5 @@
import type { CSSProperties, ReactNode } from "react"; import type { CSSProperties, ReactNode } from "react";
import styles from "./responsive-mobile-shell.module.css";
export interface ResponsiveMobileShellProps { export interface ResponsiveMobileShellProps {
children: ReactNode; children: ReactNode;
/** Background used inside the mobile canvas. */ /** Background used inside the mobile canvas. */
@@ -24,8 +22,18 @@ export function ResponsiveMobileShell({
} as CSSProperties; } as CSSProperties;
return ( return (
<div className={styles.shell} style={style}> <div
<div className={[styles.content, className].filter(Boolean).join(" ")}> className="flex min-h-(--app-viewport-height,100dvh) w-full min-w-0 flex-[1_1_auto] justify-center overflow-x-hidden bg-(--mobile-shell-outer-background,#000000)"
style={style}
>
<div
className={[
"relative flex min-h-(--app-viewport-height,100dvh) w-full min-w-0 max-w-(--app-max-width,540px) flex-[1_1_auto] flex-col overflow-x-hidden overflow-x-clip bg-(--mobile-shell-background,transparent)",
className,
]
.filter(Boolean)
.join(" ")}
>
{children} {children}
</div> </div>
</div> </div>
+12 -5
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo, type CSSProperties } from "react";
import Image from "next/image"; import Image from "next/image";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
@@ -35,7 +35,11 @@ import { useChatUnlockNavigationFlow } from "./hooks/use-chat-unlock-navigation-
import { useChatGuestLogin } from "./hooks/use-chat-guest-login"; import { useChatGuestLogin } from "./hooks/use-chat-guest-login";
import { useChatMessageLimitBanner } from "./hooks/use-chat-message-limit-banner"; import { useChatMessageLimitBanner } from "./hooks/use-chat-message-limit-banner";
import { useChatPromotionBootstrap } from "./hooks/use-chat-promotion-bootstrap"; import { useChatPromotionBootstrap } from "./hooks/use-chat-promotion-bootstrap";
import styles from "./components/chat-screen.module.css";
const chatShellStyle = {
"--chat-message-font-size": "clamp(16px, 3.333vw, 18px)",
"--chat-message-line-height": "1.5",
} as CSSProperties;
export function ChatScreen() { export function ChatScreen() {
const router = useRouter(); const router = useRouter();
@@ -188,8 +192,11 @@ export function ChatScreen() {
return ( return (
<MobileShell> <MobileShell>
<div className={styles.shell}> <div
<div className={styles.background}> className="relative flex h-(--app-viewport-height,100dvh) flex-col overflow-hidden bg-(--color-dark-background,#111) text-(--color-text-primary,#fff)"
style={chatShellStyle}
>
<div className="pointer-events-none absolute inset-0 z-0">
<Image <Image
src="/images/chat/bg-chatpage.png" src="/images/chat/bg-chatpage.png"
alt="" alt=""
@@ -199,7 +206,7 @@ export function ChatScreen() {
/> />
</div> </div>
<div className={styles.layout}> <div className="relative z-[1] flex min-h-0 flex-[1_1_auto] flex-col">
{/* isGuest 派生自 auth.loginStatus */} {/* isGuest 派生自 auth.loginStatus */}
<ChatHeader <ChatHeader
isGuest={isGuest} isGuest={isGuest}
@@ -1,30 +0,0 @@
/* ChatScreen 编排层样式(仅 shell 容器 + 背景) */
.shell {
--chat-message-font-size: clamp(16px, 3.333vw, 18px);
--chat-message-line-height: 1.5;
display: flex;
flex-direction: column;
height: var(--app-viewport-height, 100dvh);
background: var(--color-dark-background, #111);
color: var(--color-text-primary, #fff);
position: relative;
overflow: hidden;
}
.background {
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
}
.layout {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
}
@@ -0,0 +1,41 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { UserHeader } from "../user-header";
describe("sidebar Tailwind components", () => {
it("renders guest, member, and VIP header states", () => {
const guestHtml = renderToStaticMarkup(
<UserHeader
state="guest"
name="User name"
avatarUrl={null}
onLoginClick={() => undefined}
/>,
);
const memberHtml = renderToStaticMarkup(
<UserHeader state="member" name="Chase" avatarUrl={null} />,
);
const vipHtml = renderToStaticMarkup(
<UserHeader state="vip" name="Chase" avatarUrl={null} />,
);
expect(guestHtml).toContain("flex w-full items-center");
expect(guestHtml).toContain('aria-label="Log in"');
expect(guestHtml).toContain("login");
expect(memberHtml).toContain("VIP membership not activated");
expect(vipHtml).toContain("VIP Member");
expect(vipHtml).toContain("rounded-full");
});
it("renders the loading skeleton with stable utility dimensions", () => {
const html = renderToStaticMarkup(
<UserHeader state="member" name="Chase" avatarUrl={null} isLoading />,
);
expect(html).toContain('aria-hidden="true"');
expect(html).toContain("w-[60%]");
expect(html).toContain("w-[80%]");
expect(html).toContain("w-[40%]");
});
});
@@ -1,132 +0,0 @@
.row {
display: flex;
align-items: center;
gap: var(--spacing-md, 12px);
width: 100%;
box-sizing: border-box;
}
.avatar {
flex: 0 0 auto;
width: var(--sidebar-avatar-size, 56px);
height: var(--sidebar-avatar-size, 56px);
border-radius: 50%;
background: var(--color-accent, #f84d96);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
color: #ffffff;
}
.avatarImg {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.avatarIcon {
color: inherit;
}
.text {
display: flex;
flex-direction: column;
gap: var(--sidebar-card-gap-y, 4px);
flex: 1 1 auto;
min-width: 0;
}
.name {
margin: 0;
font-size: var(--font-responsive-md, 16px);
font-weight: 600;
color: #000000;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.subtitle {
margin: 0;
font-size: clamp(12px, 2.593vw, 14px);
color: var(--color-text-secondary, #9e9e9e);
line-height: 1.2;
}
/* "VIP Member" 粉色 pill(含小钻石图标) */
.vipMemberPill {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs, 4px);
align-self: flex-start;
padding: var(--responsive-pill-padding-sm, 3px 8px);
border-radius: var(--radius-full, 999px);
background: var(--color-pill-bg, rgba(248, 77, 150, 0.10));
color: var(--color-accent, #f84d96);
font-size: var(--font-size-xs, 10px);
font-weight: 600;
line-height: 1;
}
.diamondIcon {
width: clamp(12px, 2.593vw, 14px);
height: clamp(11px, 2.407vw, 13px);
display: block;
flex-shrink: 0;
}
/* guest 状态下的 "login" 粉色胶囊按钮 */
.loginPill {
display: inline-flex;
align-items: center;
justify-content: center;
padding: clamp(5px, 1.111vw, 6px) var(--spacing-lg, 16px);
border-radius: var(--radius-full, 999px);
background: linear-gradient(
90deg,
var(--color-button-gradient-start, #ff67e0),
var(--color-button-gradient-end, #ff52a2)
);
color: var(--color-text-primary, #ffffff);
font-size: clamp(12px, 2.593vw, 14px);
font-weight: 600;
border: 0;
cursor: pointer;
flex-shrink: 0;
}
.loginPill:hover {
opacity: 0.9;
}
.loginPill:focus-visible {
outline: 2px solid var(--color-accent, #f84d96);
outline-offset: 2px;
}
/* ============================================================
* 骨架占位(isLoading 时显示)
* ============================================================ */
.skeleton {
display: block;
background: var(--color-dark-gray, #e5e5e5);
border-radius: var(--radius-md, 8px);
}
.skeletonMd {
width: 60%;
height: clamp(16px, 3.333vw, 18px);
}
.skeletonSm {
width: 80%;
height: clamp(12px, 2.593vw, 14px);
}
.skeletonXs {
width: 40%;
height: clamp(12px, 2.593vw, 14px);
}
+18 -16
View File
@@ -5,8 +5,6 @@ import Image from "next/image";
import { UserMessageAvatar } from "@/app/_components"; import { UserMessageAvatar } from "@/app/_components";
import type { SidebarUserState } from "@/app/sidebar/sidebar-view-model"; import type { SidebarUserState } from "@/app/sidebar/sidebar-view-model";
import styles from "./user-header.module.css";
export interface UserHeaderProps { export interface UserHeaderProps {
/** 派生状态:未登录 / 已登录未 VIP / 已登录且 VIP */ /** 派生状态:未登录 / 已登录未 VIP / 已登录且 VIP */
state: SidebarUserState; state: SidebarUserState;
@@ -37,38 +35,42 @@ export function UserHeader({
}: UserHeaderProps) { }: UserHeaderProps) {
if (isLoading) { if (isLoading) {
return ( return (
<div className={styles.row} aria-hidden="true"> <div className="flex w-full items-center gap-(--spacing-md,12px)" aria-hidden="true">
<div className={styles.avatar} /> <div className="flex size-(--sidebar-avatar-size,56px) shrink-0 items-center justify-center overflow-hidden rounded-full bg-(--color-accent,#f84d96) text-white" />
<div className={styles.text}> <div className="flex min-w-0 flex-auto flex-col gap-(--sidebar-card-gap-y,4px)">
<span className={`${styles.skeleton} ${styles.skeletonMd}`} /> <span className="block h-[clamp(16px,3.333vw,18px)] w-[60%] rounded-(--radius-md,8px) bg-(--color-dark-gray,#e5e5e5)" />
<span className={`${styles.skeleton} ${styles.skeletonSm}`} /> <span className="block h-[clamp(12px,2.593vw,14px)] w-[80%] rounded-(--radius-md,8px) bg-(--color-dark-gray,#e5e5e5)" />
<span className={`${styles.skeleton} ${styles.skeletonXs}`} /> <span className="block h-[clamp(12px,2.593vw,14px)] w-[40%] rounded-(--radius-md,8px) bg-(--color-dark-gray,#e5e5e5)" />
</div> </div>
</div> </div>
); );
} }
return ( return (
<div className={styles.row}> <div className="flex w-full items-center gap-(--spacing-md,12px)">
<UserMessageAvatar <UserMessageAvatar
avatarUrl={avatarUrl} avatarUrl={avatarUrl}
className={styles.avatar} className="flex size-(--sidebar-avatar-size,56px) shrink-0 items-center justify-center overflow-hidden rounded-full bg-(--color-accent,#f84d96) text-white"
size="var(--sidebar-avatar-size, 56px)" size="var(--sidebar-avatar-size, 56px)"
/> />
<div className={styles.text}> <div className="flex min-w-0 flex-auto flex-col gap-(--sidebar-card-gap-y,4px)">
<p className={styles.name}>{name}</p> <p className="m-0 overflow-hidden text-ellipsis whitespace-nowrap text-(length:--font-responsive-md,16px) font-semibold leading-[1.2] text-black">
{name}
</p>
{state === "member" && ( {state === "member" && (
<p className={styles.subtitle}>VIP membership not activated</p> <p className="m-0 text-(length:clamp(12px,2.593vw,14px)) leading-[1.2] text-(--color-text-secondary,#9e9e9e)">
VIP membership not activated
</p>
)} )}
{state === "vip" && ( {state === "vip" && (
<span className={styles.vipMemberPill}> <span className="inline-flex self-start items-center gap-(--spacing-xs,4px) rounded-full bg-(--color-pill-bg,rgba(248,77,150,0.10)) px-[clamp(7px,1.667vw,9px)] py-[clamp(3px,0.741vw,4px)] text-(length:--font-size-xs,10px) font-semibold leading-none text-(--color-accent,#f84d96)">
<Image <Image
src="/images/sidebar/ic_user_vip.png" src="/images/sidebar/ic_user_vip.png"
alt="" alt=""
width={39} width={39}
height={36} height={36}
className={styles.diamondIcon} className="block h-[clamp(11px,2.407vw,13px)] w-[clamp(12px,2.593vw,14px)] shrink-0"
/> />
<span>VIP Member</span> <span>VIP Member</span>
</span> </span>
@@ -78,7 +80,7 @@ export function UserHeader({
{state === "guest" && ( {state === "guest" && (
<button <button
type="button" type="button"
className={styles.loginPill} className="inline-flex shrink-0 cursor-pointer items-center justify-center rounded-full border-0 bg-[linear-gradient(90deg,var(--color-button-gradient-start,#ff67e0),var(--color-button-gradient-end,#ff52a2))] px-(--spacing-lg,16px) py-[clamp(5px,1.111vw,6px)] text-(length:clamp(12px,2.593vw,14px)) font-semibold text-(--color-text-primary,#ffffff) hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--color-accent,#f84d96)"
onClick={onLoginClick} onClick={onLoginClick}
aria-label="Log in" aria-label="Log in"
> >
@@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest";
import { SubscriptionCtaButton } from "../subscription-cta-button"; import { SubscriptionCtaButton } from "../subscription-cta-button";
import { SubscriptionPaymentMethod } from "../subscription-payment-method"; import { SubscriptionPaymentMethod } from "../subscription-payment-method";
import { SubscriptionPaymentSuccessDialog } from "../subscription-payment-success-dialog"; import { SubscriptionPaymentSuccessDialog } from "../subscription-payment-success-dialog";
import { StripePaymentDialog } from "../stripe-payment-dialog";
describe("subscription Tailwind components", () => { describe("subscription Tailwind components", () => {
it("renders SubscriptionCtaButton with loading state", () => { it("renders SubscriptionCtaButton with loading state", () => {
@@ -68,4 +69,19 @@ describe("subscription Tailwind components", () => {
expect(html).toContain("-translate-y-0.5"); expect(html).toContain("-translate-y-0.5");
expect(html).toContain("/images/subscription/gcash-logo.svg"); expect(html).toContain("/images/subscription/gcash-logo.svg");
}); });
it("renders StripePaymentDialog fallback with Tailwind dialog utilities", () => {
const html = renderToStaticMarkup(
<StripePaymentDialog
clientSecret="client-secret"
onClose={() => undefined}
/>,
);
expect(html).toContain('role="alertdialog"');
expect(html).toContain("fixed inset-0");
expect(html).toContain("max-w-(--dialog-wide-max-width,420px)");
expect(html).toContain("Payment unavailable");
expect(html).toContain("bg-[linear-gradient(var(--color-button-gradient-start,#ff67e0)");
});
}); });
@@ -1,98 +0,0 @@
.overlay {
position: fixed;
inset: 0;
z-index: 70;
display: flex;
align-items: center;
justify-content: center;
padding:
calc(var(--dialog-safe-margin, 20px) + var(--app-safe-top, 0px))
calc(var(--dialog-safe-margin, 20px) + var(--app-safe-right, 0px))
calc(var(--dialog-safe-margin, 20px) + var(--app-safe-bottom, 0px))
calc(var(--dialog-safe-margin, 20px) + var(--app-safe-left, 0px));
background: rgba(0, 0, 0, 0.5);
}
.dialog {
width: 100%;
max-width: var(--dialog-wide-max-width, 420px);
max-height: calc(
var(--app-visible-height, 100dvh) -
calc(var(--dialog-safe-margin, 20px) * 2)
);
overflow-y: auto;
border-radius: var(--responsive-card-radius, 32px);
background: var(--color-page-background, #ffffff);
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.16);
padding:
var(--responsive-card-padding-lg, 24px)
var(--responsive-card-padding, 18px)
var(--responsive-card-padding, 18px);
}
.header {
margin-bottom: var(--page-section-gap, 18px);
text-align: left;
}
.title {
margin: 0 0 clamp(7px, 1.481vw, 8px);
color: var(--color-text-foreground, #171717);
font-size: var(--responsive-page-title, 22px);
font-weight: 700;
line-height: 1.2;
}
.content {
margin: 0;
color: #393939;
font-size: var(--responsive-body, 14px);
line-height: 1.5;
}
.form {
display: flex;
flex-direction: column;
gap: var(--page-section-gap, 18px);
}
.error {
margin: 0;
color: #c0392b;
font-size: var(--responsive-caption, 13px);
line-height: 1.45;
}
.actions {
display: flex;
gap: var(--spacing-md, 12px);
width: 100%;
}
.button {
flex: 1 1 auto;
min-height: var(--pwa-button-height, 44px);
border: 0;
border-radius: var(--radius-bottom-sheet, 28px);
font-size: var(--responsive-body, 16px);
font-weight: 600;
cursor: pointer;
}
.button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.secondary {
background: var(--color-text-secondary, #9e9e9e);
color: var(--color-page-background, #ffffff);
}
.primary {
background: linear-gradient(
var(--color-button-gradient-start, #ff67e0),
var(--color-button-gradient-end, #ff52a2)
);
color: var(--color-page-background, #ffffff);
}
@@ -0,0 +1,20 @@
export const stripePaymentDialogStyles = {
overlay:
"fixed inset-0 z-70 flex items-center justify-center bg-[rgba(0,0,0,0.5)] pb-[calc(var(--dialog-safe-margin,20px)+var(--app-safe-bottom,0px))] pl-[calc(var(--dialog-safe-margin,20px)+var(--app-safe-left,0px))] pr-[calc(var(--dialog-safe-margin,20px)+var(--app-safe-right,0px))] pt-[calc(var(--dialog-safe-margin,20px)+var(--app-safe-top,0px))]",
dialog:
"max-h-[calc(var(--app-visible-height,100dvh)-calc(var(--dialog-safe-margin,20px)*2))] w-full max-w-(--dialog-wide-max-width,420px) overflow-y-auto rounded-(--responsive-card-radius,32px) bg-(--color-page-background,#ffffff) px-(--responsive-card-padding,18px) pb-(--responsive-card-padding,18px) pt-(--responsive-card-padding-lg,24px) shadow-[0_18px_40px_rgba(0,0,0,0.16)]",
header: "mb-(--page-section-gap,18px) text-left",
title:
"m-0 mb-[clamp(7px,1.481vw,8px)] text-(length:--responsive-page-title,22px) font-bold leading-[1.2] text-(--color-text-foreground,#171717)",
content:
"m-0 text-(length:--responsive-body,14px) leading-[1.5] text-[#393939]",
form: "flex flex-col gap-(--page-section-gap,18px)",
error:
"m-0 text-(length:--responsive-caption,13px) leading-[1.45] text-[#c0392b]",
actions: "flex w-full gap-(--spacing-md,12px)",
button:
"min-h-(--pwa-button-height,44px) flex-auto cursor-pointer rounded-(--radius-bottom-sheet,28px) border-0 text-(length:--responsive-body,16px) font-semibold disabled:cursor-not-allowed disabled:opacity-55",
secondary: "bg-(--color-text-secondary,#9e9e9e) text-(--color-page-background,#ffffff)",
primary:
"bg-[linear-gradient(var(--color-button-gradient-start,#ff67e0),var(--color-button-gradient-end,#ff52a2))] text-(--color-page-background,#ffffff)",
} as const;
@@ -17,7 +17,7 @@ import { ExceptionHandler } from "@/core/errors";
import { ROUTES } from "@/router/routes"; import { ROUTES } from "@/router/routes";
import { Logger } from "@/utils"; import { Logger } from "@/utils";
import styles from "./stripe-payment-dialog.module.css"; import { stripePaymentDialogStyles as styles } from "./stripe-payment-dialog.styles";
const log = new Logger("SubscriptionStripePaymentDialog"); const log = new Logger("SubscriptionStripePaymentDialog");
const stripePublishableKey = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY; const stripePublishableKey = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY;
@@ -177,7 +177,10 @@ function StripePaymentForm({
<form className={styles.form} onSubmit={handleSubmit}> <form className={styles.form} onSubmit={handleSubmit}>
<PaymentElement onLoadError={handleLoadError} /> <PaymentElement onLoadError={handleLoadError} />
{errorMessage ? ( {errorMessage ? (
<p role="alert" className={styles.error}> <p
role="alert"
className={styles.error}
>
{errorMessage} {errorMessage}
</p> </p>
) : null} ) : null}
@@ -12,7 +12,7 @@ import {
import { Logger } from "@/utils"; import { Logger } from "@/utils";
import { StripePaymentDialog } from "./stripe-payment-dialog"; import { StripePaymentDialog } from "./stripe-payment-dialog";
import dialogStyles from "./stripe-payment-dialog.module.css"; import { stripePaymentDialogStyles as dialogStyles } from "./stripe-payment-dialog.styles";
import { SubscriptionCtaButton } from "./subscription-cta-button"; import { SubscriptionCtaButton } from "./subscription-cta-button";
const log = new Logger("SubscriptionCheckoutButton"); const log = new Logger("SubscriptionCheckoutButton");
+1 -1
View File
@@ -9,7 +9,7 @@ import {
import { Logger } from "@/utils"; import { Logger } from "@/utils";
import { StripePaymentDialog } from "../subscription/components/stripe-payment-dialog"; import { StripePaymentDialog } from "../subscription/components/stripe-payment-dialog";
import dialogStyles from "../subscription/components/stripe-payment-dialog.module.css"; import { stripePaymentDialogStyles as dialogStyles } from "../subscription/components/stripe-payment-dialog.styles";
import styles from "./tip-screen.module.css"; import styles from "./tip-screen.module.css";
const log = new Logger("TipCheckoutButton"); const log = new Logger("TipCheckoutButton");