refactor(chat): migrate overlay styles to tailwind

This commit is contained in:
2026-07-13 11:30:07 +08:00
parent 8e0c4ef95f
commit 173d4a3c18
5 changed files with 79 additions and 232 deletions
@@ -1,6 +1,7 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { BrowserHintOverlay } from "../browser-hint-overlay";
import { ChatHeader } from "../chat-header";
import { ChatInsufficientCreditsBanner } from "../chat-insufficient-credits-banner";
import { ChatInputTextField } from "../chat-input-text-field";
@@ -8,6 +9,7 @@ import { ChatSendButton } from "../chat-send-button";
import { ImageBubble } from "../image-bubble";
import { MessageAvatar } from "../message-avatar";
import { PrivateMessageCard } from "../private-message-card";
import { PwaInstallDialog } from "../pwa-install-dialog";
import { TextBubble } from "../text-bubble";
vi.mock("@/router/use-app-navigator", () => ({
@@ -181,4 +183,42 @@ describe("chat Tailwind components", () => {
expect(html).toContain("Message Elio");
expect(html).toContain("Hello");
});
it("renders BrowserHintOverlay visibility and expanded styles", () => {
expect(renderToStaticMarkup(<BrowserHintOverlay />)).toBe("");
const html = renderToStaticMarkup(
<BrowserHintOverlay
forceShow
autoCollapseDelayMs={0}
title="Open elsewhere"
description="Better experience"
/>,
);
expect(html).toContain('aria-expanded="true"');
expect(html).toContain('aria-label="Open elsewhere. Better experience"');
expect(html).toContain("grid-cols-[auto_minmax(0,1fr)]");
expect(html).toContain("backdrop-blur-[12px]");
expect(html).toContain("hover:opacity-[0.94]");
expect(html).toContain("Open elsewhere");
expect(html).toContain("Better experience");
});
it("renders PwaInstallDialog with Tailwind dialog layout", () => {
const html = renderToStaticMarkup(
<PwaInstallDialog onClose={() => undefined} onInstall={() => undefined} />,
);
expect(html).toContain('role="dialog"');
expect(html).toContain('aria-modal="true"');
expect(html).toContain('aria-labelledby="pwa-dialog-title"');
expect(html).toContain("fixed inset-0");
expect(html).toContain("max-w-[var(--pwa-install-dialog-max-width,360px)]");
expect(html).toContain("size-[var(--icon-size-86,86px)]");
expect(html).toContain("%2Fimages%2Ficons%2Ficon-add-to-home.png");
expect(html).toContain("Add to Home Screen");
expect(html).toContain("Cancel");
expect(html).toContain("OK");
});
});
@@ -1,122 +0,0 @@
/* BrowserHintOverlay 浏览器提示样式 */
.overlay {
position: absolute;
top: var(--spacing-2, 8px);
right: var(--spacing-2, 8px);
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 8px;
z-index: 20;
width: fit-content;
max-width: min(74vw, 260px);
min-height: 48px;
padding: 8px 10px;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 18px;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.04)),
var(--color-blur-background, rgba(13, 11, 20, 0.86));
box-shadow:
0 12px 28px rgba(0, 0, 0, 0.24),
inset 0 1px 0 rgba(255, 255, 255, 0.16);
color: #fff;
cursor: pointer;
pointer-events: auto;
backdrop-filter: blur(12px);
transition: border-color 0.16s ease, box-shadow 0.16s ease,
opacity 0.16s ease, padding 0.18s ease, transform 0.16s ease;
-webkit-tap-highlight-color: transparent;
}
.expanded {
width: fit-content;
}
.collapsed {
display: inline-flex;
width: 42px;
height: 42px;
min-width: 42px;
min-height: 42px;
align-items: center;
justify-content: center;
padding: 5px;
border-radius: 999px;
}
.iconWrap {
display: inline-flex;
width: 30px;
height: 30px;
align-items: center;
justify-content: center;
border-radius: 999px;
background: rgba(255, 255, 255, 0.15);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.16);
color: #fff;
transition: background 0.18s ease, transform 0.18s ease;
}
.collapsed .iconWrap {
width: 30px;
height: 30px;
}
.copy {
display: flex;
min-width: 0;
max-width: 190px;
flex-direction: column;
gap: 2px;
text-align: left;
opacity: 1;
overflow: hidden;
transform: translateX(0);
transition: max-width 0.2s ease, opacity 0.14s ease,
transform 0.18s ease;
white-space: normal;
}
.collapsed .copy {
display: none;
opacity: 0;
pointer-events: none;
transform: translateX(6px);
}
.title {
color: #fff;
font-size: var(--font-size-sm, 12px);
font-weight: 800;
line-height: 1.25;
overflow-wrap: break-word;
}
.description {
color: rgba(255, 255, 255, 0.72);
font-size: 11px;
font-weight: 650;
line-height: 1.2;
overflow-wrap: break-word;
}
.overlay:hover {
border-color: rgba(255, 255, 255, 0.28);
box-shadow:
0 14px 32px rgba(0, 0, 0, 0.28),
inset 0 1px 0 rgba(255, 255, 255, 0.18);
opacity: 0.94;
transform: translateY(-1px);
}
.overlay:active {
opacity: 0.88;
transform: translateY(1px) scale(0.99);
}
.overlay:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.88);
outline-offset: 4px;
}
@@ -15,8 +15,6 @@ import { ExternalLink } from "lucide-react";
import { openChatInExternalBrowser } from "@/lib/chat/chat_external_browser";
import styles from "./browser-hint-overlay.module.css";
export interface BrowserHintOverlayProps {
/** 自定义按钮标题 */
title?: string;
@@ -31,6 +29,12 @@ export interface BrowserHintOverlayProps {
const DEFAULT_TITLE = "Open in external browser";
const DEFAULT_DESCRIPTION = "Find me more easily";
const DEFAULT_AUTO_COLLAPSE_DELAY_MS = 3500;
const OVERLAY_BASE_CLASS_NAME =
"pointer-events-auto absolute right-[var(--spacing-2,8px)] top-[var(--spacing-2,8px)] z-20 box-border min-h-12 max-w-[min(74vw,260px)] cursor-pointer border border-[rgba(255,255,255,0.18)] bg-[linear-gradient(135deg,rgba(255,255,255,0.14),rgba(255,255,255,0.04)),var(--color-blur-background,rgba(13,11,20,0.86))] text-white shadow-[0_12px_28px_rgba(0,0,0,0.24),inset_0_1px_0_rgba(255,255,255,0.16)] backdrop-blur-[12px] backdrop-saturate-[1.2] transition-[border-color,box-shadow,opacity,padding,transform] duration-[180ms] [-webkit-tap-highlight-color:transparent] hover:-translate-y-px hover:border-[rgba(255,255,255,0.28)] hover:opacity-[0.94] hover:shadow-[0_14px_32px_rgba(0,0,0,0.28),inset_0_1px_0_rgba(255,255,255,0.18)] active:translate-y-px active:scale-[0.99] active:opacity-[0.88] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[rgba(255,255,255,0.88)]";
const OVERLAY_EXPANDED_CLASS_NAME =
"grid w-fit grid-cols-[auto_minmax(0,1fr)] items-center gap-2 rounded-[18px] px-2.5 py-2";
const OVERLAY_COLLAPSED_CLASS_NAME =
"inline-flex size-[42px] min-h-[42px] min-w-[42px] items-center justify-center rounded-full p-[5px]";
export function BrowserHintOverlay({
title = DEFAULT_TITLE,
@@ -77,19 +81,34 @@ export function BrowserHintOverlay({
return (
<button
type="button"
className={`${styles.overlay} ${
isExpanded ? styles.expanded : styles.collapsed
}`}
className={[
OVERLAY_BASE_CLASS_NAME,
isExpanded ? OVERLAY_EXPANDED_CLASS_NAME : OVERLAY_COLLAPSED_CLASS_NAME,
].join(" ")}
aria-label={ariaLabel}
aria-expanded={isExpanded}
onClick={handleClick}
>
<span className={styles.iconWrap} aria-hidden="true">
<span
className="inline-flex size-[30px] items-center justify-center rounded-full bg-[rgba(255,255,255,0.15)] text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.16)] transition-[background,transform] duration-[180ms]"
aria-hidden="true"
>
<ExternalLink size={15} strokeWidth={2.4} />
</span>
<span className={styles.copy} aria-hidden={!isExpanded}>
<span className={styles.title}>{title}</span>
<span className={styles.description}>{description}</span>
<span
className={
isExpanded
? "flex min-w-0 max-w-[190px] translate-x-0 flex-col gap-0.5 overflow-hidden whitespace-normal text-left opacity-100 transition-[max-width,opacity,transform] duration-200"
: "hidden translate-x-1.5 pointer-events-none opacity-0 transition-[max-width,opacity,transform] duration-200"
}
aria-hidden={!isExpanded}
>
<span className="break-words text-[var(--font-size-sm,12px)] font-extrabold leading-[1.25] text-white">
{title}
</span>
<span className="break-words text-[11px] font-semibold leading-[1.2] text-[rgba(255,255,255,0.72)]">
{description}
</span>
</span>
</button>
);
@@ -1,91 +0,0 @@
/* PwaInstallDialog PWA 安装弹窗样式
*
* 视觉规格:白底 + 大圆角 + 渐变按钮 + 86×86 图标
*/
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 60;
padding:
calc(var(--dialog-safe-margin, 16px) + var(--app-safe-top, 0px))
calc(var(--dialog-safe-margin, 16px) + var(--app-safe-right, 0px))
calc(var(--dialog-safe-margin, 16px) + var(--app-safe-bottom, 0px))
calc(var(--dialog-safe-margin, 16px) + var(--app-safe-left, 0px));
}
.dialog {
width: 100%;
max-width: var(--pwa-install-dialog-max-width, 360px);
background: var(--color-page-background, #ffffff);
border-radius: var(--responsive-card-radius, 40px);
padding:
var(--responsive-card-padding-lg, 20px)
var(--responsive-card-padding, 16px)
var(--responsive-card-padding, 16px);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.icon {
width: var(--icon-size-86, 86px);
height: var(--icon-size-86, 86px);
margin-bottom: var(--spacing-lg, 16px);
object-fit: contain;
}
.title {
font-size: var(--responsive-page-title, var(--font-size-22, 22px));
font-weight: 700;
margin: 0 0 var(--spacing-sm, 8px);
color: var(--color-text-foreground, #171717);
line-height: 1.2;
}
.content {
font-size: var(--responsive-body, var(--font-size-lg, 16px));
line-height: 1.5;
color: #393939;
margin:
0
var(--responsive-card-padding-lg, 36px)
var(--page-section-gap-lg, 24px);
}
.actions {
display: flex;
gap: var(--spacing-md, 12px);
width: 100%;
}
.btn {
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, var(--font-size-lg, 16px));
font-weight: 600;
cursor: pointer;
color: var(--color-page-background, #ffffff);
display: flex;
align-items: center;
justify-content: center;
}
.btnSecondary {
background: var(--color-text-secondary, #9e9e9e);
}
.btnPrimary {
background: linear-gradient(
var(--color-button-gradient-start, #ff67e0),
var(--color-button-gradient-end, #ff52a2)
);
}
+11 -10
View File
@@ -9,8 +9,6 @@
*/
import Image from "next/image";
import styles from "./pwa-install-dialog.module.css";
export interface PwaInstallDialogProps {
onClose: () => void;
onInstall?: () => void | Promise<void>;
@@ -31,37 +29,40 @@ export function PwaInstallDialog({ onClose, onInstall }: PwaInstallDialogProps)
return (
<div
className={styles.overlay}
className="fixed inset-0 z-[60] flex items-center justify-center bg-[rgba(0,0,0,0.5)] pb-[calc(var(--dialog-safe-margin,16px)+var(--app-safe-bottom,0px))] pl-[calc(var(--dialog-safe-margin,16px)+var(--app-safe-left,0px))] pr-[calc(var(--dialog-safe-margin,16px)+var(--app-safe-right,0px))] pt-[calc(var(--dialog-safe-margin,16px)+var(--app-safe-top,0px))]"
role="dialog"
aria-modal="true"
aria-labelledby="pwa-dialog-title"
>
<div className={styles.dialog}>
<div className="flex w-full max-w-[var(--pwa-install-dialog-max-width,360px)] flex-col items-center rounded-[var(--responsive-card-radius,40px)] bg-[var(--color-page-background,#ffffff)] px-[var(--responsive-card-padding,16px)] pb-[var(--responsive-card-padding,16px)] pt-[var(--responsive-card-padding-lg,20px)] text-center shadow-[0_10px_20px_rgba(0,0,0,0.1)]">
<Image
className={styles.icon}
className="mb-[var(--spacing-lg,16px)] size-[var(--icon-size-86,86px)] object-contain"
src="/images/icons/icon-add-to-home.png"
alt=""
width={86}
height={86}
priority
/>
<h2 id="pwa-dialog-title" className={styles.title}>
<h2
id="pwa-dialog-title"
className="m-0 mb-[var(--spacing-sm,8px)] text-[var(--responsive-page-title,var(--font-size-22,22px))] font-bold leading-[1.2] text-[var(--color-text-foreground,#171717)]"
>
Add to Home Screen
</h2>
<p className={styles.content}>
<p className="mx-[var(--responsive-card-padding-lg,36px)] mb-[var(--page-section-gap-lg,24px)] mt-0 text-[var(--responsive-body,var(--font-size-lg,16px))] leading-[1.5] text-[#393939]">
Add CozSweet to Home Screen{"\n"}for the best experience
</p>
<div className={styles.actions}>
<div className="flex w-full gap-[var(--spacing-md,12px)]">
<button
type="button"
className={`${styles.btn} ${styles.btnSecondary}`}
className="flex min-h-[var(--pwa-button-height,44px)] flex-auto cursor-pointer items-center justify-center rounded-[var(--radius-bottom-sheet,28px)] border-0 bg-[var(--color-text-secondary,#9e9e9e)] text-[var(--responsive-body,var(--font-size-lg,16px))] font-semibold text-[var(--color-page-background,#ffffff)]"
onClick={onClose}
>
Cancel
</button>
<button
type="button"
className={`${styles.btn} ${styles.btnPrimary}`}
className="flex min-h-[var(--pwa-button-height,44px)] flex-auto cursor-pointer items-center justify-center rounded-[var(--radius-bottom-sheet,28px)] border-0 bg-[linear-gradient(var(--color-button-gradient-start,#ff67e0),var(--color-button-gradient-end,#ff52a2))] text-[var(--responsive-body,var(--font-size-lg,16px))] font-semibold text-[var(--color-page-background,#ffffff)]"
onClick={handleInstall}
>
OK