feat: upgrade Liquid Glass design system to full spec (v4.6.0)

- Upgrade CSS variables with proper glass transparency, fixed gradient
  backgrounds, cubic-bezier(0.2, 0.8, 0.2, 1) fluid transitions, and
  enhanced shadow/border values matching the Liquid Glass touchstone
- Add full backdrop-filter: blur(25px) saturate(180%) to glass-card,
  glass-input, glass-popover, and glass-backdrop CSS classes
- Add lensing inner-glow hover effects and toast-in animation
- Update all UI components (Button, Card, Input, Badge, Switch,
  SegmentedControl, ConfirmDialog, ModalBackdrop, ModalHeader,
  BackToTop, Navbar) for strict Liquid Glass compliance
- Enforce rounded-2xl / rounded-full consistency across all components
- Bump version to 4.6.0, update @vercel/analytics to ^2.0.1

Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
kuekhaoyang
2026-03-15 15:47:16 +08:00
co-authored by Claude Opus 4.6
parent b424047c5b
commit df137c97f3
18 changed files with 292 additions and 258 deletions
+30 -27
View File
@@ -24,19 +24,35 @@ export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) {
const handleLogout = () => {
clearSession();
// Navigate to root to clear search query params
window.location.href = '/';
};
const iconBtnClass = `
w-8 h-8 sm:w-10 sm:h-10
flex items-center justify-center
rounded-[var(--radius-full)]
bg-[var(--glass-bg)]
border border-[var(--glass-border)]
text-[var(--text-color)]
hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]
transition-all duration-200
cursor-pointer
`;
return (
<nav className="sticky top-0 z-[2000] pt-4 pb-2" style={{
transform: 'translate3d(0, 0, 0)',
willChange: 'transform'
}}>
<nav className="sticky top-0 z-[2000] pt-4 pb-2">
<div className="max-w-7xl mx-auto px-4">
<div className="bg-[var(--glass-bg)] border border-[var(--glass-border)] shadow-[var(--shadow-sm)] px-3 sm:px-6 py-2 sm:py-4 rounded-[var(--radius-2xl)]" style={{
transform: 'translate3d(0, 0, 0)'
}}>
<div className="
bg-[var(--glass-bg)]
backdrop-blur-[25px] saturate-[180%]
[-webkit-backdrop-filter:blur(25px)_saturate(180%)]
border border-[var(--glass-border)]
shadow-[var(--shadow-md)]
px-3 sm:px-6 py-2 sm:py-4
rounded-[var(--radius-2xl)]
transition-all duration-[0.4s]
[transition-timing-function:cubic-bezier(0.2,0.8,0.2,1)]
">
<div className="flex items-center justify-between gap-2 sm:gap-4">
<Link
href={isPremiumMode ? '/premium' : '/'}
@@ -60,20 +76,12 @@ export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) {
</Link>
<div className="flex items-center gap-2 sm:gap-3 flex-shrink-0">
{/* IPTV Link - only show if user has iptv_access or no auth configured */}
{hasPermission('iptv_access') && (
<Link
href="/iptv"
className="w-8 h-8 sm:w-10 sm:h-10 flex items-center justify-center rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all duration-200 cursor-pointer"
aria-label="直播"
title="直播"
data-focusable
>
<Icons.TV size={16} className="sm:w-5 sm:h-5" />
</Link>
<Link href="/iptv" className={iconBtnClass} aria-label="直播" title="直播" data-focusable>
<Icons.TV size={16} className="sm:w-5 sm:h-5" />
</Link>
)}
{/* User Info */}
{session && (
<div className="hidden sm:flex items-center gap-2">
<div className="flex items-center gap-1.5 px-2.5 py-1 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-full)] text-xs">
@@ -89,7 +97,7 @@ export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) {
</div>
<button
onClick={handleLogout}
className="w-8 h-8 sm:w-8 sm:h-8 flex items-center justify-center rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color-secondary)] hover:text-red-500 hover:border-red-500/30 transition-all duration-200 cursor-pointer"
className="w-8 h-8 flex items-center justify-center rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color-secondary)] hover:text-red-500 hover:border-red-500/30 transition-all duration-200 cursor-pointer"
aria-label="退出登录"
title="退出登录"
>
@@ -101,17 +109,12 @@ export function Navbar({ onReset, isPremiumMode = false }: NavbarProps) {
href="https://github.com/KuekHaoYang/KVideo"
target="_blank"
rel="noopener noreferrer"
className="w-8 h-8 sm:w-10 sm:h-10 flex items-center justify-center rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all duration-200 cursor-pointer hidden sm:flex"
className={`${iconBtnClass} hidden sm:flex`}
aria-label="GitHub 仓库"
>
<Icons.Github size={20} />
</a>
<Link
href={settingsHref}
className="w-8 h-8 sm:w-10 sm:h-10 flex items-center justify-center rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all duration-200 cursor-pointer"
aria-label="设置"
data-focusable
>
<Link href={settingsHref} className={iconBtnClass} aria-label="设置" data-focusable>
<svg className="w-4 h-4 sm:w-5 sm:h-5" viewBox="0 -960 960 960" fill="currentColor">
<path d="m370-80-16-128q-13-5-24.5-12T307-235l-119 50L78-375l103-78q-1-7-1-13.5v-27q0-6.5 1-13.5L78-585l110-190 119 50q11-8 23-15t24-12l16-128h220l16 128q13 5 24.5 12t22.5 15l119-50 110 190-103 78q1 7 1 13.5v27q0 6.5-2 13.5l103 78-110 190-118-50q-11 8-23 15t-24 12L590-80H370Zm70-80h79l14-106q31-8 57.5-23.5T639-327l99 41 39-68-86-65q5-14 7-29.5t2-31.5q0-16-2-31.5t-7-29.5l86-65-39-68-99 42q-22-23-48.5-38.5T533-694l-13-106h-79l-14 106q-31 8-57.5 23.5T321-633l-99-41-39 68 86 64q-5 15-7 30t-2 32q0 16 2 31t7 30l-86 65 39 68 99-42q22 23 48.5 38.5T427-266l13 106Zm42-180q58 0 99-41t41-99q0-58-41-99t-99-41q-59 0-99.5 41T342-480q0 58 40.5 99t99.5 41Zm-2-140Z" />
</svg>
+11 -5
View File
@@ -38,11 +38,17 @@ export function BackToTop() {
return (
<button
onClick={scrollToTop}
className={`fixed bottom-8 right-8 z-[9999] p-3 rounded-full
bg-[var(--glass-bg)] border border-[var(--glass-border)]
shadow-[var(--shadow-md)] backdrop-blur-xl
text-[var(--text-color)] transition-all duration-300 ease-out
hover:bg-[color-mix(in_srgb,var(--accent-color)_15%,transparent)]
className={`fixed bottom-8 right-8 z-[9999] p-3
rounded-[var(--radius-full)]
bg-[var(--glass-bg)]
backdrop-blur-[25px] saturate-[180%]
[-webkit-backdrop-filter:blur(25px)_saturate(180%)]
border border-[var(--glass-border)]
shadow-[var(--shadow-md)]
text-[var(--text-color)]
transition-all duration-[0.4s]
[transition-timing-function:cubic-bezier(0.2,0.8,0.2,1)]
hover:bg-[color-mix(in_srgb,var(--accent-color)_15%,transparent)]
hover:scale-110 active:scale-95
${isVisible
? 'opacity-100 translate-y-0 scale-100'
+2 -15
View File
@@ -22,14 +22,8 @@ const BadgeComponent = memo(function Badge({
const iconElement = icon && (
<span
className={`inline-flex items-center justify-center ${iconPosition === 'left' ? 'mr-1' : 'ml-1'
}`}
style={{
width: '0.875em',
height: '0.875em',
transform: 'translateZ(0)',
willChange: 'auto',
}}
className={`inline-flex items-center justify-center ${iconPosition === 'left' ? 'mr-1' : 'ml-1'}`}
style={{ width: '0.875em', height: '0.875em' }}
>
{icon}
</span>
@@ -45,10 +39,6 @@ const BadgeComponent = memo(function Badge({
${variants[variant]}
${className}
`}
style={{
transform: 'translateZ(0)',
willChange: 'auto',
}}
>
{icon && iconPosition === 'left' && iconElement}
{children}
@@ -57,7 +47,4 @@ const BadgeComponent = memo(function Badge({
);
});
// Export both named and default for compatibility
export const Badge = BadgeComponent;
+23 -19
View File
@@ -11,31 +11,37 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
className = '',
...props
}, ref) => {
const baseStyles = "inline-flex items-center justify-center px-4 py-2.5 md:px-6 md:py-3 font-semibold text-sm md:text-base transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed min-h-[44px] touch-manipulation cursor-pointer";
const baseStyles = `
inline-flex items-center justify-center
px-4 py-2.5 md:px-6 md:py-3
font-semibold text-sm md:text-base
rounded-[var(--radius-2xl)]
transition-all duration-200
disabled:opacity-50 disabled:cursor-not-allowed
disabled:transform-none disabled:filter-none disabled:shadow-none
min-h-[44px] touch-manipulation cursor-pointer
`;
const variants = {
primary: `
bg-[var(--accent-color)]
text-white
border-none
rounded-[var(--radius-2xl)]
shadow-[0_2px_8px_color-mix(in_srgb,var(--shadow-color)_50%,transparent)]
hover:brightness-110
hover:shadow-[0_4px_12px_color-mix(in_srgb,var(--shadow-color)_70%,transparent)]
active:scale-[0.98]
bg-[var(--accent-color)]
text-white
border-none
shadow-[var(--shadow-sm)]
hover:translate-y-[-2px]
hover:brightness-110
hover:shadow-[0_4px_8px_var(--shadow-color)]
active:translate-y-0 active:scale-[0.98]
active:brightness-95
`,
secondary: `
bg-[var(--glass-bg)]
backdrop-blur-xl
bg-[var(--glass-bg)]
backdrop-blur-[25px] saturate-[180%]
[-webkit-backdrop-filter:blur(25px)_saturate(180%)]
saturate-[180%]
border
border-[var(--glass-border)]
rounded-[var(--radius-2xl)]
border border-[var(--glass-border)]
text-[var(--text-color)]
shadow-[0_2px_8px_color-mix(in_srgb,var(--shadow-color)_50%,transparent)]
hover:shadow-[0_4px_12px_color-mix(in_srgb,var(--shadow-color)_70%,transparent)]
shadow-[var(--shadow-sm)]
hover:shadow-[0_4px_8px_var(--shadow-color)]
active:scale-[0.98]
`,
ghost: `
@@ -58,5 +64,3 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
});
Button.displayName = 'Button';
+5 -9
View File
@@ -11,27 +11,26 @@ interface CardProps {
export function Card({ children, className = '', hover = true, blur = true, onClick, style }: CardProps) {
const hoverStyles = hover
? "hover:translate-y-[-2px] hover:shadow-[0_8px_24px_var(--shadow-color)] cursor-pointer transition-transform duration-200 ease-out"
? "hover:translate-y-[-5px] hover:scale-[1.02] hover:shadow-[0_8px_24px_var(--shadow-color)] hover:z-10 cursor-pointer"
: "";
// Conditionally apply blur classes - NOW DISABLED GLOBALLY via CSS override, but logic kept for structure
const blurClasses = blur
? "bg-[var(--glass-bg)]" // Removed backdrop-blur classes here as they are expensive
: "bg-[var(--bg-color)]/90"; // More opaque fallback
? "bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] [-webkit-backdrop-filter:blur(25px)_saturate(180%)]"
: "bg-[var(--bg-color)]/90";
const baseClasses = `
${blurClasses}
rounded-[var(--radius-2xl)]
shadow-[0_2px_8px_var(--shadow-color)] md:shadow-[var(--shadow-md)]
shadow-[var(--shadow-md)]
border
border-[var(--glass-border)]
p-4 md:p-6
relative
transition-all duration-[0.4s] [transition-timing-function:cubic-bezier(0.2,0.8,0.2,1)]
${hoverStyles}
${className}
`;
// Use semantic button when interactive
if (onClick) {
return (
<button
@@ -45,12 +44,9 @@ export function Card({ children, className = '', hover = true, blur = true, onCl
);
}
// Use div for non-interactive cards
return (
<div className={baseClasses} style={style}>
{children}
</div>
);
}
+5 -22
View File
@@ -27,36 +27,26 @@ export function ConfirmDialog({
variant = 'warning',
dangerous = false,
}: ConfirmDialogProps) {
const dialogRef = useRef<HTMLDivElement>(null);
const cancelButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (isOpen) {
// Focus the cancel button when dialog opens
cancelButtonRef.current?.focus();
// Prevent body scroll
document.body.style.overflow = 'hidden';
} else {
// Restore body scroll
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
return () => { document.body.style.overflow = ''; };
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onCancel();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onCancel]);
@@ -68,46 +58,39 @@ export function ConfirmDialog({
warning: 'bg-[var(--accent-color)] hover:brightness-110',
info: 'bg-blue-500 hover:bg-blue-600',
};
// Use dangerous prop to override variant
const finalVariant = dangerous ? 'danger' : variant;
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-[9998] bg-black/30 backdrop-blur-sm animate-fade-in"
className="fixed inset-0 z-[9998] bg-black/30 backdrop-blur-[8px] [-webkit-backdrop-filter:blur(8px)] animate-fade-in"
onClick={onCancel}
aria-hidden="true"
/>
{/* Dialog */}
<div
ref={dialogRef}
role="alertdialog"
aria-modal="true"
aria-labelledby="dialog-title"
aria-describedby="dialog-description"
className="fixed top-1/2 left-1/2 z-[9999] w-[90%] max-w-md -translate-x-1/2 -translate-y-1/2 animate-slide-up"
className="fixed top-1/2 left-1/2 z-[9999] w-[90%] max-w-md -translate-x-1/2 -translate-y-1/2 animate-scale-in"
>
<Card className="p-6">
{/* Header */}
<Card hover={false} className="p-6">
<h2
id="dialog-title"
className="text-xl font-semibold text-[var(--text-color)] mb-3"
>
{title}
</h2>
{/* Message */}
<p
id="dialog-description"
className="text-[var(--text-color-secondary)] mb-6 leading-relaxed"
>
{message}
</p>
{/* Actions */}
<div className="flex gap-3 justify-end">
<Button
ref={cancelButtonRef}
+5 -8
View File
@@ -18,21 +18,19 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
ref={ref}
className={`
w-full px-4 py-3 md:px-6 md:py-4
text-base md:text-[var(--text-color)]
text-base
bg-[var(--glass-bg)]
backdrop-blur-[10px]
saturate-[150%]
backdrop-blur-[10px] saturate-[150%]
[-webkit-backdrop-filter:blur(10px)_saturate(150%)]
border
border-[var(--glass-border)]
border border-[var(--glass-border)]
rounded-[var(--radius-2xl)]
text-[var(--text-color)]
placeholder:text-[var(--text-color-secondary)]
focus:outline-none
focus:border-[var(--accent-color)]
focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent-color)_30%,transparent)]
transition-all
duration-[var(--transition-fluid)]
transition-all duration-[0.4s]
[transition-timing-function:cubic-bezier(0.2,0.8,0.2,1)]
touch-manipulation
${error ? 'border-red-500' : ''}
${className}
@@ -48,4 +46,3 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
);
Input.displayName = 'Input';
+10 -3
View File
@@ -1,5 +1,5 @@
/**
* Reusable modal backdrop component
* Reusable modal backdrop component - Liquid Glass design
*/
interface ModalBackdropProps {
@@ -10,9 +10,16 @@ interface ModalBackdropProps {
export function ModalBackdrop({ isOpen, onClose }: ModalBackdropProps) {
return (
<div
className={`fixed inset-0 z-[9998] bg-black/30 backdrop-blur-md transition-opacity duration-300 ${isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
}`}
className={`
fixed inset-0 z-[9998]
bg-black/30
backdrop-blur-[8px]
[-webkit-backdrop-filter:blur(8px)]
transition-opacity duration-300
${isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'}
`}
onClick={onClose}
aria-hidden="true"
/>
);
}
+11 -2
View File
@@ -1,5 +1,5 @@
/**
* Reusable modal header component
* Reusable modal header component - Liquid Glass design
*/
interface ModalHeaderProps {
@@ -15,7 +15,16 @@ export function ModalHeader({ title, onClose }: ModalHeaderProps) {
</h3>
<button
onClick={onClose}
className="w-8 h-8 flex items-center justify-center rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all duration-200 cursor-pointer"
className="
w-8 h-8 flex items-center justify-center
rounded-[var(--radius-full)]
bg-[var(--glass-bg)]
border border-[var(--glass-border)]
text-[var(--text-color)]
hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)]
transition-all duration-200
cursor-pointer
"
aria-label="关闭"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
+16 -7
View File
@@ -38,7 +38,6 @@ export function SegmentedControl<T extends string>({
};
updateIndicator();
// Update on window resize as well
window.addEventListener('resize', updateIndicator);
return () => window.removeEventListener('resize', updateIndicator);
}, [value, options]);
@@ -47,14 +46,19 @@ export function SegmentedControl<T extends string>({
<div
ref={containerRef}
className={`
relative flex p-1 bg-[var(--glass-bg)] backdrop-blur-xl
border border-[var(--glass-border)] rounded-[var(--radius-2xl)]
shadow-[var(--shadow-sm)] ${className}
relative flex p-1
bg-[var(--glass-bg)]
backdrop-blur-[25px] saturate-[180%]
[-webkit-backdrop-filter:blur(25px)_saturate(180%)]
border border-[var(--glass-border)]
rounded-[var(--radius-2xl)]
shadow-[var(--shadow-sm)]
${className}
`}
>
{/* Sliding Indicator */}
<div
className="absolute top-1 bottom-1 bg-[var(--accent-color)] rounded-[calc(var(--radius-2xl)-4px)] shadow-[0_2px_8px_rgba(0,122,255,0.3)] transition-all duration-300 cubic-bezier(0.2, 0.8, 0.2, 1)"
className="absolute top-1 bottom-1 bg-[var(--accent-color)] rounded-[calc(var(--radius-2xl)-4px)] shadow-[0_2px_8px_color-mix(in_srgb,var(--accent-color)_30%,transparent)] transition-all duration-300 [transition-timing-function:cubic-bezier(0.2,0.8,0.2,1)]"
style={{
left: `${indicatorStyle.left}px`,
width: `${indicatorStyle.width}px`,
@@ -68,8 +72,13 @@ export function SegmentedControl<T extends string>({
data-value={option.value}
onClick={() => onChange(option.value)}
className={`
relative z-10 flex-1 py-2 px-4 text-sm font-semibold transition-colors duration-200
${value === option.value ? 'text-white' : 'text-[var(--text-color-secondary)] hover:text-[var(--text-color)]'}
relative z-10 flex-1 py-2 px-4
text-sm font-semibold
transition-colors duration-200
${value === option.value
? 'text-white'
: 'text-[var(--text-color-secondary)] hover:text-[var(--text-color)]'
}
`}
>
{option.label}
+21 -19
View File
@@ -25,11 +25,11 @@ export function Switch({
return (
<label
className={`
switch relative inline-flex items-center cursor-pointer
h-[30px] w-[50px] shrink-0
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
${className}
`}
relative inline-flex items-center cursor-pointer
h-[30px] w-[50px] shrink-0
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
${className}
`}
>
<input
type="checkbox"
@@ -41,20 +41,22 @@ export function Switch({
/>
<div
className={`
switch-slider w-full h-full rounded-[var(--radius-full)]
bg-[color-mix(in_srgb,var(--text-color)_20%,transparent)]
peer-checked:bg-[var(--accent-color)]
transition-colors duration-[0.4s] cubic-bezier(0.2,0.8,0.2,1)
before:content-[''] before:absolute before:h-[26px] before:w-[26px]
before:left-[2px] before:bottom-[2px]
before:bg-white before:rounded-[var(--radius-full)]
before:transition-transform before:duration-[0.4s]
before:cubic-bezier(0.2,0.8,0.2,1)
before:shadow-[0_1px_3px_rgba(0,0,0,0.2)]
peer-checked:before:translate-x-[20px]
active:before:scale-95
`}
></div>
w-full h-full rounded-[var(--radius-full)]
bg-[color-mix(in_srgb,var(--text-color)_20%,transparent)]
peer-checked:bg-[var(--accent-color)]
transition-[background-color] duration-[0.4s]
[transition-timing-function:cubic-bezier(0.2,0.8,0.2,1)]
before:content-[''] before:absolute
before:h-[26px] before:w-[26px]
before:left-[2px] before:bottom-[2px]
before:bg-white before:rounded-[var(--radius-full)]
before:transition-transform before:duration-[0.4s]
before:[transition-timing-function:cubic-bezier(0.2,0.8,0.2,1)]
before:shadow-[0_1px_3px_rgba(0,0,0,0.2)]
peer-checked:before:translate-x-[20px]
active:before:scale-95
`}
/>
</label>
);
}