From 34c8e2db4e9cbe18c1c66a4810187d92b2d0ed17 Mon Sep 17 00:00:00 2001 From: chenhang Date: Thu, 2 Jul 2026 18:05:17 +0800 Subject: [PATCH] feat: Implement responsive page layout components and CSS variables - Added PageScaffold component for shared page layout with safe-area padding and scroll management. - Introduced ResponsiveMobileShell for mobile canvas layout with customizable backgrounds. - Created ScrollablePage component for scrollable content areas. - Updated CSS styles for coins rules screen, sidebar components, and subscription sections to use responsive design tokens. - Added viewport CSS variables provider to synchronize CSS variables with the visual viewport. - Refactored breakpoints and dimensions for better responsiveness across devices. --- .../core/fixed-bottom-area.module.css | 19 ++++ .../_components/core/fixed-bottom-area.tsx | 33 +++++++ src/app/_components/core/index.ts | 4 + src/app/_components/core/mobile-shell.tsx | 23 +---- .../_components/core/page-scaffold.module.css | 50 +++++++++++ src/app/_components/core/page-scaffold.tsx | 49 +++++++++++ ...css => responsive-mobile-shell.module.css} | 18 +++- .../core/responsive-mobile-shell.tsx | 33 +++++++ .../core/scrollable-page.module.css | 8 ++ src/app/_components/core/scrollable-page.tsx | 16 ++++ .../coins-rules/coins-rules-screen.module.css | 40 +++++---- src/app/globals.css | 7 ++ .../components/sidebar-screen.module.css | 32 ++++--- .../components/sidebar-wallet-card.module.css | 18 ++-- ...ubscription-coins-offer-section.module.css | 28 +++--- .../components/subscription-screen.module.css | 34 ++++---- .../subscription-vip-offer-section.module.css | 36 ++++---- src/core/breakpoints.ts | 10 ++- src/hooks/use-breakpoint.ts | 8 +- src/providers/root-providers.tsx | 39 +++++---- src/providers/viewport-css-vars-provider.tsx | 87 +++++++++++++++++++ src/tokens/dimensions.css | 16 ++-- src/tokens/responsive.css | 30 ++++++- 23 files changed, 497 insertions(+), 141 deletions(-) create mode 100644 src/app/_components/core/fixed-bottom-area.module.css create mode 100644 src/app/_components/core/fixed-bottom-area.tsx create mode 100644 src/app/_components/core/page-scaffold.module.css create mode 100644 src/app/_components/core/page-scaffold.tsx rename src/app/_components/core/{mobile-shell.module.css => responsive-mobile-shell.module.css} (55%) create mode 100644 src/app/_components/core/responsive-mobile-shell.tsx create mode 100644 src/app/_components/core/scrollable-page.module.css create mode 100644 src/app/_components/core/scrollable-page.tsx create mode 100644 src/providers/viewport-css-vars-provider.tsx diff --git a/src/app/_components/core/fixed-bottom-area.module.css b/src/app/_components/core/fixed-bottom-area.module.css new file mode 100644 index 00000000..ab96a60d --- /dev/null +++ b/src/app/_components/core/fixed-bottom-area.module.css @@ -0,0 +1,19 @@ +.area { + position: sticky; + right: 0; + bottom: 0; + left: 0; + z-index: var(--fixed-bottom-z-index, 5); + flex: 0 0 auto; + padding: + var(--fixed-bottom-padding-top, 12px) + calc(var(--page-padding-x, 20px) + var(--app-safe-right, 0px)) + calc(var(--fixed-bottom-padding-bottom, 12px) + var(--app-safe-bottom, 0px)) + calc(var(--page-padding-x, 20px) + var(--app-safe-left, 0px)); + background: var(--fixed-bottom-background, transparent); +} + +.area[data-elevated="true"] { + box-shadow: 0 -14px 32px rgba(31, 21, 25, 0.08); + backdrop-filter: blur(18px); +} diff --git a/src/app/_components/core/fixed-bottom-area.tsx b/src/app/_components/core/fixed-bottom-area.tsx new file mode 100644 index 00000000..ce023655 --- /dev/null +++ b/src/app/_components/core/fixed-bottom-area.tsx @@ -0,0 +1,33 @@ +import type { CSSProperties, ReactNode } from "react"; + +import styles from "./fixed-bottom-area.module.css"; + +export interface FixedBottomAreaProps { + children: ReactNode; + className?: string; + background?: string; + elevated?: boolean; +} + +export function FixedBottomArea({ + children, + className, + background, + elevated = false, +}: FixedBottomAreaProps) { + const style = background + ? ({ + "--fixed-bottom-background": background, + } as CSSProperties) + : undefined; + + return ( +
+ {children} +
+ ); +} diff --git a/src/app/_components/core/index.ts b/src/app/_components/core/index.ts index edcd15ad..2558c34c 100644 --- a/src/app/_components/core/index.ts +++ b/src/app/_components/core/index.ts @@ -5,6 +5,10 @@ export * from "./bottom-sheet"; export * from "./checkbox"; export * from "./dialog"; +export * from "./fixed-bottom-area"; export * from "./loading-indicator"; export * from "./mobile-shell"; +export * from "./page-scaffold"; +export * from "./responsive-mobile-shell"; +export * from "./scrollable-page"; export * from "./settings-section"; diff --git a/src/app/_components/core/mobile-shell.tsx b/src/app/_components/core/mobile-shell.tsx index 52939c86..ecd29534 100644 --- a/src/app/_components/core/mobile-shell.tsx +++ b/src/app/_components/core/mobile-shell.tsx @@ -1,16 +1,6 @@ -/** - * 500px 移动端 Shell(Server Component) - * - * - * (`Breakpoints.mobileMaxWidth = 500.0`)。 - * - * 集中封装,调用方仅需 `{...}` 即可获得统一的移动端宽度约束。 - * - * 本组件无 hooks,可作为 Server Component 直接 SSR。 - */ import type { ReactNode } from "react"; -import styles from "./mobile-shell.module.css"; +import { ResponsiveMobileShell } from "./responsive-mobile-shell"; export interface MobileShellProps { children: ReactNode; @@ -26,13 +16,8 @@ export function MobileShell({ className, }: MobileShellProps) { return ( -
-
- {children} -
-
+ + {children} + ); } diff --git a/src/app/_components/core/page-scaffold.module.css b/src/app/_components/core/page-scaffold.module.css new file mode 100644 index 00000000..5cdad6f4 --- /dev/null +++ b/src/app/_components/core/page-scaffold.module.css @@ -0,0 +1,50 @@ +.root { + position: relative; + display: flex; + flex: 1 1 auto; + min-height: var(--app-viewport-height, 100dvh); + min-width: 0; + flex-direction: column; + box-sizing: border-box; + color: var(--color-text-foreground, #171717); + overflow: hidden; +} + +.root[data-scrollable="true"] { + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} + +.padded { + padding: + calc(var(--page-padding-y, 18px) + var(--app-safe-top, 0px)) + calc(var(--page-padding-x, 20px) + var(--app-safe-right, 0px)) + calc(var(--page-padding-y, 18px) + var(--app-safe-bottom, 0px)) + calc(var(--page-padding-x, 20px) + var(--app-safe-left, 0px)); +} + +.header, +.footer { + position: relative; + z-index: 1; + flex: 0 0 auto; +} + +.body { + position: relative; + z-index: 1; + display: flex; + flex: 1 1 auto; + min-height: 0; + min-width: 0; + flex-direction: column; +} + +.body[data-scrollable="true"] { + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} + +.footer { + padding-bottom: var(--app-safe-bottom, 0px); +} diff --git a/src/app/_components/core/page-scaffold.tsx b/src/app/_components/core/page-scaffold.tsx new file mode 100644 index 00000000..b1dc9588 --- /dev/null +++ b/src/app/_components/core/page-scaffold.tsx @@ -0,0 +1,49 @@ +import type { ReactNode } from "react"; + +import styles from "./page-scaffold.module.css"; + +export interface PageScaffoldProps { + children: ReactNode; + header?: ReactNode; + footer?: ReactNode; + className?: string; + bodyClassName?: string; + padded?: boolean; + scrollable?: boolean; + bodyScrollable?: boolean; +} + +/** + * Shared page layout primitive for mobile-first screens. + * + * Use it when migrating feature pages away from hand-written shell/padding + * styles. It keeps safe-area padding and scroll ownership in one place. + */ +export function PageScaffold({ + children, + header, + footer, + className, + bodyClassName, + padded = true, + scrollable = false, + bodyScrollable = false, +}: PageScaffoldProps) { + return ( +
+ {header ?
{header}
: null} +
+ {children} +
+ {footer ?
{footer}
: null} +
+ ); +} diff --git a/src/app/_components/core/mobile-shell.module.css b/src/app/_components/core/responsive-mobile-shell.module.css similarity index 55% rename from src/app/_components/core/mobile-shell.module.css rename to src/app/_components/core/responsive-mobile-shell.module.css index df2f07e6..bee6708b 100644 --- a/src/app/_components/core/mobile-shell.module.css +++ b/src/app/_components/core/responsive-mobile-shell.module.css @@ -1,19 +1,29 @@ .shell { display: flex; flex: 1 1 auto; - align-items: stretch; - justify-content: center; width: 100%; + min-width: 0; min-height: var(--app-viewport-height, 100dvh); - background: #000000; /* 显式黑,桌面端移动壳外圈为黑色 */ + justify-content: center; + background: var(--mobile-shell-outer-background, #000000); + overflow-x: hidden; } .content { position: relative; display: flex; flex: 1 1 auto; - flex-direction: column; + min-width: 0; width: 100%; max-width: var(--app-max-width, 540px); min-height: var(--app-viewport-height, 100dvh); + flex-direction: column; + background: var(--mobile-shell-background, transparent); + overflow-x: clip; +} + +@supports not (overflow: clip) { + .content { + overflow-x: hidden; + } } diff --git a/src/app/_components/core/responsive-mobile-shell.tsx b/src/app/_components/core/responsive-mobile-shell.tsx new file mode 100644 index 00000000..55f7e2a4 --- /dev/null +++ b/src/app/_components/core/responsive-mobile-shell.tsx @@ -0,0 +1,33 @@ +import type { CSSProperties, ReactNode } from "react"; + +import styles from "./responsive-mobile-shell.module.css"; + +export interface ResponsiveMobileShellProps { + children: ReactNode; + /** Background used inside the mobile canvas. */ + background?: string; + /** Background used outside the mobile canvas on wider screens. */ + outerBackground?: string; + /** Class applied to the mobile canvas. */ + className?: string; +} + +export function ResponsiveMobileShell({ + children, + background, + outerBackground, + className, +}: ResponsiveMobileShellProps) { + const style = { + "--mobile-shell-background": background, + "--mobile-shell-outer-background": outerBackground, + } as CSSProperties; + + return ( +
+
+ {children} +
+
+ ); +} diff --git a/src/app/_components/core/scrollable-page.module.css b/src/app/_components/core/scrollable-page.module.css new file mode 100644 index 00000000..4ed295fe --- /dev/null +++ b/src/app/_components/core/scrollable-page.module.css @@ -0,0 +1,8 @@ +.scroll { + flex: 1 1 auto; + min-height: 0; + min-width: 0; + overflow-y: auto; + overscroll-behavior-y: contain; + -webkit-overflow-scrolling: touch; +} diff --git a/src/app/_components/core/scrollable-page.tsx b/src/app/_components/core/scrollable-page.tsx new file mode 100644 index 00000000..aec3be45 --- /dev/null +++ b/src/app/_components/core/scrollable-page.tsx @@ -0,0 +1,16 @@ +import type { ReactNode } from "react"; + +import styles from "./scrollable-page.module.css"; + +export interface ScrollablePageProps { + children: ReactNode; + className?: string; +} + +export function ScrollablePage({ children, className }: ScrollablePageProps) { + return ( +
+ {children} +
+ ); +} diff --git a/src/app/coins-rules/coins-rules-screen.module.css b/src/app/coins-rules/coins-rules-screen.module.css index a6f37433..ae61949e 100644 --- a/src/app/coins-rules/coins-rules-screen.module.css +++ b/src/app/coins-rules/coins-rules-screen.module.css @@ -1,7 +1,11 @@ .screen { min-height: 100dvh; box-sizing: border-box; - padding: 18px 20px 28px; + padding: + calc(var(--page-padding-y, 18px) + var(--app-safe-top, 0px)) + calc(var(--page-padding-x, 20px) + var(--app-safe-right, 0px)) + calc(var(--page-padding-y, 28px) + var(--app-safe-bottom, 0px)) + calc(var(--page-padding-x, 20px) + var(--app-safe-left, 0px)); background: radial-gradient(circle at 16% 5%, rgba(255, 196, 143, 0.2), transparent 28%), radial-gradient(circle at 86% 8%, rgba(246, 87, 160, 0.2), transparent 26%), @@ -12,15 +16,15 @@ .header { display: flex; flex-direction: column; - gap: 18px; + gap: var(--page-section-gap, 18px); } .hero { position: relative; overflow: hidden; - padding: 22px; + padding: var(--responsive-card-padding-lg, 22px); border: 1px solid rgba(246, 87, 160, 0.14); - border-radius: 28px; + border-radius: var(--responsive-card-radius, 28px); background: linear-gradient(135deg, rgba(255, 255, 255, 0.88) 0%, rgba(255, 237, 246, 0.94) 100%), #ffffff; @@ -55,7 +59,7 @@ .eyebrow { margin: 16px 0 0; color: #f657a0; - font-size: 12px; + font-size: var(--responsive-micro, 12px); font-weight: 850; letter-spacing: 0.12em; line-height: 1; @@ -65,7 +69,7 @@ .title { margin: 8px 0 0; color: #171114; - font-size: 30px; + font-size: var(--responsive-page-title, 30px); font-weight: 900; letter-spacing: -0.8px; line-height: 1.08; @@ -75,7 +79,7 @@ max-width: 300px; margin: 10px 0 0; color: #6d5d64; - font-size: 15px; + font-size: var(--responsive-body, 15px); font-weight: 650; line-height: 1.55; } @@ -85,10 +89,10 @@ grid-template-columns: auto minmax(0, 1fr); gap: 12px; align-items: center; - margin-top: 16px; - padding: 16px; + margin-top: var(--page-section-gap, 16px); + padding: var(--responsive-card-padding, 16px); border: 1px solid rgba(248, 184, 62, 0.24); - border-radius: 22px; + border-radius: var(--responsive-card-radius-sm, 22px); background: rgba(255, 250, 235, 0.84); box-shadow: 0 12px 28px rgba(142, 93, 18, 0.08); } @@ -108,7 +112,7 @@ .freeTitle { margin: 0; color: #2a1f14; - font-size: 16px; + font-size: var(--responsive-body, 16px); font-weight: 850; line-height: 1.1; } @@ -116,7 +120,7 @@ .freeText { margin: 5px 0 0; color: #7a6040; - font-size: 13px; + font-size: var(--responsive-caption, 13px); font-weight: 650; line-height: 1.45; } @@ -125,7 +129,7 @@ display: flex; flex-direction: column; gap: 10px; - margin-top: 16px; + margin-top: var(--page-section-gap, 16px); } .ruleCard { @@ -133,9 +137,9 @@ grid-template-columns: auto minmax(0, 1fr) auto; gap: 12px; align-items: center; - padding: 14px; + padding: var(--responsive-card-padding, 14px); border: 1px solid rgba(25, 19, 22, 0.06); - border-radius: 20px; + border-radius: var(--responsive-card-radius-sm, 20px); background: rgba(255, 255, 255, 0.9); box-shadow: 0 10px 26px rgba(55, 36, 44, 0.06); animation: cardIn 0.42s ease both; @@ -165,7 +169,7 @@ .ruleTitle { margin: 0; color: #21191d; - font-size: 16px; + font-size: var(--responsive-body, 16px); font-weight: 850; line-height: 1.15; } @@ -177,7 +181,7 @@ border-radius: 999px; background: #edf4ff; color: #2e6eea; - font-size: 12px; + font-size: var(--responsive-micro, 12px); font-weight: 780; line-height: 1; } @@ -185,7 +189,7 @@ .cost { margin: 0; color: #171114; - font-size: 15px; + font-size: var(--responsive-body, 15px); font-weight: 900; line-height: 1.15; text-align: right; diff --git a/src/app/globals.css b/src/app/globals.css index 8f3bb0e6..1024195b 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -24,4 +24,11 @@ body { background: var(--color-page-background); color: var(--color-text-foreground); font-family: var(--font-system); + overflow-x: hidden; +} + +*, +*::before, +*::after { + box-sizing: border-box; } diff --git a/src/app/sidebar/components/sidebar-screen.module.css b/src/app/sidebar/components/sidebar-screen.module.css index b34c3e8a..fa83b332 100644 --- a/src/app/sidebar/components/sidebar-screen.module.css +++ b/src/app/sidebar/components/sidebar-screen.module.css @@ -6,7 +6,11 @@ overflow: hidden; min-height: 100dvh; box-sizing: border-box; - padding: 0 var(--page-padding-x, 28px) 28px; + padding: + var(--app-safe-top, 0px) + calc(var(--page-padding-x, 28px) + var(--app-safe-right, 0px)) + calc(var(--page-padding-y, 20px) + var(--app-safe-bottom, 0px)) + calc(var(--page-padding-x, 28px) + var(--app-safe-left, 0px)); background: radial-gradient(circle at 18% 6%, rgba(255, 206, 160, 0.22), transparent 28%), radial-gradient(circle at 92% 0%, rgba(246, 87, 160, 0.22), transparent 30%), @@ -49,13 +53,13 @@ } .topBar { - margin: 18px 0 -2px; + margin: var(--page-padding-y, 18px) 0 -2px; } .hero { - padding: 18px; + padding: var(--responsive-card-padding, 18px); border: 1px solid rgba(246, 87, 160, 0.12); - border-radius: 26px; + border-radius: var(--responsive-card-radius, 26px); background: linear-gradient(135deg, rgba(255, 255, 255, 0.76) 0%, rgba(255, 239, 246, 0.84) 100%), #ffffff; @@ -66,7 +70,7 @@ .kicker { margin: 0; color: #f657a0; - font-size: 12px; + font-size: var(--responsive-micro, 12px); font-weight: 850; letter-spacing: 0.14em; line-height: 1; @@ -76,7 +80,7 @@ .title { margin: 8px 0 0; color: #171114; - font-size: 34px; + font-size: var(--responsive-display-title, 34px); font-weight: 920; letter-spacing: -1px; line-height: 1; @@ -86,15 +90,15 @@ max-width: 320px; margin: 10px 0 0; color: #75636a; - font-size: 14px; + font-size: var(--responsive-caption, 14px); font-weight: 650; line-height: 1.5; } .userSlot { - padding: 18px; + padding: var(--responsive-card-padding, 18px); border: 1px solid rgba(25, 19, 22, 0.06); - border-radius: 26px; + border-radius: var(--responsive-card-radius, 26px); background: rgba(255, 255, 255, 0.84); box-shadow: 0 16px 40px rgba(55, 36, 44, 0.08); backdrop-filter: blur(18px); @@ -113,7 +117,7 @@ margin: 0 0 9px; padding: 0 4px; color: #817076; - font-size: 13px; + font-size: var(--responsive-caption, 13px); font-weight: 800; letter-spacing: 0.04em; text-transform: uppercase; @@ -125,9 +129,9 @@ align-items: center; gap: 12px; box-sizing: border-box; - padding: 16px; + padding: var(--responsive-card-padding, 16px); border: 1px solid rgba(25, 19, 22, 0.06); - border-radius: 22px; + border-radius: var(--responsive-card-radius-sm, 22px); background: rgba(255, 255, 255, 0.86); color: #171114; cursor: pointer; @@ -173,14 +177,14 @@ .logoutTitle { color: #ef4d64; - font-size: 16px; + font-size: var(--responsive-body, 16px); font-weight: 760; line-height: 1.15; } .logoutSubtitle { color: #817076; - font-size: 13px; + font-size: var(--responsive-caption, 13px); font-weight: 620; line-height: 1.25; } diff --git a/src/app/sidebar/components/sidebar-wallet-card.module.css b/src/app/sidebar/components/sidebar-wallet-card.module.css index 4c4d7208..c3679b32 100644 --- a/src/app/sidebar/components/sidebar-wallet-card.module.css +++ b/src/app/sidebar/components/sidebar-wallet-card.module.css @@ -1,13 +1,13 @@ .card { display: grid; grid-template-columns: minmax(0, 1fr) auto; - gap: 18px; + gap: var(--page-section-gap, 18px); align-items: center; width: 100%; box-sizing: border-box; - padding: 18px; + padding: var(--responsive-card-padding, 18px); border: 1px solid rgba(246, 87, 160, 0.16); - border-radius: 24px; + border-radius: var(--responsive-card-radius, 24px); background: linear-gradient(135deg, rgba(255, 255, 255, 0.92) 0%, rgba(255, 239, 246, 0.94) 100%), #ffffff; @@ -48,7 +48,7 @@ .title { margin: 0; color: #191316; - font-size: 18px; + font-size: var(--responsive-card-title, 18px); font-weight: 800; line-height: 1.15; } @@ -60,14 +60,14 @@ } .balanceValue { - font-size: 22px; + font-size: var(--responsive-page-title, 22px); font-weight: 850; letter-spacing: -0.4px; } .balanceUnit { color: #4f4449; - font-size: 16px; + font-size: var(--responsive-body, 16px); font-weight: 750; } @@ -83,7 +83,7 @@ color: #2e6eea; cursor: pointer; font: inherit; - font-size: 15px; + font-size: var(--responsive-body, 15px); font-weight: 760; line-height: 1.2; text-align: left; @@ -110,7 +110,7 @@ .topUpButton { display: inline-flex; min-width: 92px; - min-height: 44px; + min-height: var(--responsive-control-height, 44px); align-items: center; justify-content: center; padding: 0 18px; @@ -118,7 +118,7 @@ border-radius: 18px; cursor: pointer; font: inherit; - font-size: 15px; + font-size: var(--responsive-body, 15px); font-weight: 850; letter-spacing: 0.1px; transition: box-shadow 0.18s ease, opacity 0.18s ease, transform 0.18s ease, diff --git a/src/app/subscription/components/subscription-coins-offer-section.module.css b/src/app/subscription/components/subscription-coins-offer-section.module.css index 06c4f060..ef700ae6 100644 --- a/src/app/subscription/components/subscription-coins-offer-section.module.css +++ b/src/app/subscription/components/subscription-coins-offer-section.module.css @@ -1,7 +1,7 @@ .section { overflow: hidden; border: 1px solid rgba(17, 17, 17, 0.12); - border-radius: 28px; + border-radius: var(--responsive-card-radius, 28px); background: #fff8df; box-shadow: 0 16px 42px rgba(220, 162, 36, 0.12); backdrop-filter: blur(18px); @@ -9,7 +9,10 @@ .heading { position: relative; - padding: 18px 18px 22px; + padding: + var(--responsive-card-padding, 18px) + var(--responsive-card-padding, 18px) + var(--responsive-card-padding-lg, 22px); background: linear-gradient(135deg, #fffdf2 0%, #fff3c8 58%, #ffe8a6 100%), #fff3c8; @@ -18,7 +21,7 @@ .title { margin: 0; color: #181014; - font-size: 22px; + font-size: var(--responsive-page-title, 22px); font-weight: 800; line-height: 1.2; } @@ -38,8 +41,11 @@ z-index: 1; display: flex; flex-direction: column; - gap: 8px; - padding: 18px 12px 14px; + gap: var(--spacing-sm, 8px); + padding: + var(--responsive-card-padding, 18px) + clamp(10px, 2.222vw, 12px) + clamp(12px, 2.593vw, 14px); background: transparent; } @@ -48,10 +54,10 @@ grid-template-columns: 48px minmax(0, 1fr) auto auto; align-items: center; width: 100%; - min-height: 48px; + min-height: var(--responsive-control-height, 48px); padding: 8px 12px 8px 6px; border: 1px solid transparent; - border-radius: 18px; + border-radius: var(--responsive-card-radius-sm, 18px); background: #ffffff; color: #53484d; cursor: pointer; @@ -97,7 +103,7 @@ .coins { min-width: 0; - font-size: 16px; + font-size: var(--responsive-body, 16px); font-weight: 700; line-height: 1.2; } @@ -108,7 +114,7 @@ border-radius: 999px; background: linear-gradient(135deg, #ff5fae 0%, #ff8a34 100%); color: #ffffff; - font-size: 14px; + font-size: clamp(10px, 2.593vw, 14px); font-weight: 900; line-height: 1; white-space: nowrap; @@ -125,7 +131,7 @@ } .price { - font-size: 16px; + font-size: var(--responsive-body, 16px); font-weight: 800; line-height: 1.2; white-space: nowrap; @@ -134,7 +140,7 @@ .originalPrice { margin-top: 3px; color: #9c8b91; - font-size: 12px; + font-size: var(--responsive-micro, 12px); font-weight: 800; line-height: 1; text-decoration: line-through; diff --git a/src/app/subscription/components/subscription-screen.module.css b/src/app/subscription/components/subscription-screen.module.css index 43ecc274..7bb8f982 100644 --- a/src/app/subscription/components/subscription-screen.module.css +++ b/src/app/subscription/components/subscription-screen.module.css @@ -5,7 +5,11 @@ background: radial-gradient(circle at 8% 4%, rgba(255, 255, 255, 0.95) 0 90px, transparent 160px), linear-gradient(180deg, #fff9fb 0%, #fcf3f4 52%, #fffefe 100%); - padding: 18px 20px 10px; + padding: + calc(var(--page-padding-y, 18px) + var(--app-safe-top, 0px)) + calc(var(--page-padding-x, 20px) + var(--app-safe-right, 0px)) + calc(10px + var(--app-safe-bottom, 0px)) + calc(var(--page-padding-x, 20px) + var(--app-safe-left, 0px)); } .header { @@ -21,11 +25,11 @@ .firstRechargeBanner { display: flex; align-items: center; - gap: 12px; - margin-top: 14px; - padding: 12px 14px; + gap: var(--spacing-md, 12px); + margin-top: var(--page-section-gap, 14px); + padding: clamp(10px, 2.222vw, 12px) clamp(12px, 2.593vw, 14px); border: 1px solid rgba(255, 95, 174, 0.28); - border-radius: 22px; + border-radius: var(--responsive-card-radius-sm, 22px); background: linear-gradient(135deg, rgba(255, 255, 255, 0.96) 0%, rgba(255, 239, 248, 0.94) 100%), #ffffff; @@ -38,7 +42,7 @@ border-radius: 999px; background: linear-gradient(135deg, #ff5fae 0%, #ff8a34 100%); color: #ffffff; - font-size: 20; + font-size: clamp(20px, 5.185vw, 28px); font-weight: 900; line-height: 1; box-shadow: 0 10px 22px rgba(255, 95, 174, 0.25); @@ -51,7 +55,7 @@ .firstRechargeTitle { margin: 0; color: #181014; - font-size: 17px; + font-size: var(--responsive-card-title, 17px); font-weight: 900; line-height: 1.15; } @@ -59,7 +63,7 @@ .firstRechargeSubtitle { margin: 4px 0 0; color: #75656d; - font-size: 12px; + font-size: var(--responsive-micro, 12px); font-weight: 700; line-height: 1.25; } @@ -67,8 +71,8 @@ .offerStack { display: flex; flex-direction: column; - gap: 18px; - margin-top: 14px; + gap: var(--page-section-gap, 18px); + margin-top: var(--page-section-gap, 14px); } .userSlot { @@ -92,7 +96,7 @@ margin: 0; padding: var(--spacing-md) var(--spacing-xs); color: var(--color-text-secondary); - font-size: var(--font-size-sm); + font-size: var(--responsive-caption, var(--font-size-sm)); text-align: center; line-height: 1.5; } @@ -100,7 +104,7 @@ .autoRenewCaption { margin-top: var(--spacing-xxl); padding: 0 var(--spacing-xs); - font-size: var(--font-size-lg); + font-size: var(--responsive-body, var(--font-size-lg)); color: #3c3b3b; } @@ -113,11 +117,11 @@ } .paymentMethodSlot { - margin-top: 22px; + margin-top: var(--page-section-gap-lg, 22px); } .ctaSlot { - margin-top: 22px; + margin-top: var(--page-section-gap-lg, 22px); } .agreementSlot { @@ -126,7 +130,7 @@ } .agreementLabel { - font-size: 12px; + font-size: var(--responsive-micro, 12px); } .agreementLink { diff --git a/src/app/subscription/components/subscription-vip-offer-section.module.css b/src/app/subscription/components/subscription-vip-offer-section.module.css index de6a3639..5ec6f9e3 100644 --- a/src/app/subscription/components/subscription-vip-offer-section.module.css +++ b/src/app/subscription/components/subscription-vip-offer-section.module.css @@ -1,7 +1,7 @@ .section { overflow: hidden; border: 1px solid rgba(255, 71, 166, 0.28); - border-radius: 28px; + border-radius: var(--responsive-card-radius, 28px); background: #ffe2f0; box-shadow: 0 18px 45px rgba(255, 71, 166, 0.22); backdrop-filter: blur(18px); @@ -9,7 +9,10 @@ .summary { position: relative; - padding: 18px 18px 22px; + padding: + var(--responsive-card-padding, 18px) + var(--responsive-card-padding, 18px) + var(--responsive-card-padding-lg, 22px); background: linear-gradient(135deg, #ff9bd1 0%, #ff61ad 56%, #f657a0 100%), #ff61ad; @@ -25,20 +28,20 @@ .titleText { color: #181014; - font-size: 20px; + font-size: var(--responsive-section-title, 20px); font-weight: 800; } .renewText { color: rgba(255, 255, 255, 0.82); - font-size: 13px; + font-size: var(--responsive-caption, 13px); font-weight: 700; } .subtitle { margin: 8px 0 0; color: rgba(255, 255, 255, 0.9); - font-size: 15px; + font-size: var(--responsive-body, 15px); font-weight: 700; line-height: 1.3; } @@ -58,21 +61,24 @@ z-index: 1; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 9px; - padding: 18px 10px 12px; + gap: clamp(7px, 1.667vw, 9px); + padding: + var(--responsive-card-padding, 18px) + clamp(8px, 1.852vw, 10px) + clamp(10px, 2.222vw, 12px); } .planCard { position: relative; display: flex; min-width: 0; - min-height: 128px; + min-height: clamp(116px, 23.704vw, 128px); flex-direction: column; align-items: center; justify-content: center; - padding: 12px 6px 11px; + padding: clamp(10px, 2.222vw, 12px) 6px clamp(9px, 2.037vw, 11px); border: 1px solid rgba(246, 87, 160, 0.08); - border-radius: 20px; + border-radius: var(--responsive-card-radius-sm, 20px); background: #ffffff; color: #181014; cursor: pointer; @@ -111,7 +117,7 @@ border-radius: 999px; background: linear-gradient(135deg, #ff5fae 0%, #ff8a34 100%); color: #ffffff; - font-size: 16px; + font-size: clamp(12px, 2.963vw, 16px); font-weight: 900; line-height: 1; white-space: nowrap; @@ -121,7 +127,7 @@ .planTitle { margin-top: 20px; - font-size: 19px; + font-size: clamp(16px, 3.519vw, 19px); font-weight: 800; line-height: 1.15; white-space: nowrap; @@ -136,7 +142,7 @@ } .price { - font-size: 30px; + font-size: var(--font-responsive-number-lg, 30px); font-weight: 500; letter-spacing: -0.8px; } @@ -144,7 +150,7 @@ .currency { margin-bottom: 3px; color: #8b717a; - font-size: 12px; + font-size: var(--responsive-micro, 12px); font-weight: 700; text-transform: lowercase; } @@ -152,7 +158,7 @@ .originalPrice { margin-top: 10px; color: #9c8b91; - font-size: 14px; + font-size: var(--responsive-caption, 14px); font-weight: 800; line-height: 1; text-decoration: line-through; diff --git a/src/core/breakpoints.ts b/src/core/breakpoints.ts index 16bd50b2..91136b91 100644 --- a/src/core/breakpoints.ts +++ b/src/core/breakpoints.ts @@ -3,13 +3,15 @@ * * * - * 与 `src/design/breakpoints.css` 中的 CSS 变量保持一致: + * 与 `src/tokens/breakpoints.css` 中的 CSS 变量保持一致: * - 客户端运行时检查(matchMedia)通过 `useBreakpoint` hook - * - 服务端 / 客户端 CSS 通过 `src/design/breakpoints.css` 的 media query + * - 服务端 / 客户端 CSS 通过 `src/tokens/breakpoints.css` 的 media query */ export const Breakpoints = { - /** 手机设备最大宽度 */ - mobileMaxWidth: 500, + /** 手机设备最大宽度。 */ + mobileMaxWidth: 599.98, + /** 移动端画布最大宽度,来自 1080px @2x 设计稿。 */ + shellMaxWidth: 540, /** 平板设备最小宽度 */ tabletMinWidth: 600, /** 平板设备最大宽度 */ diff --git a/src/hooks/use-breakpoint.ts b/src/hooks/use-breakpoint.ts index f28277e6..6a0cb6f0 100644 --- a/src/hooks/use-breakpoint.ts +++ b/src/hooks/use-breakpoint.ts @@ -15,12 +15,12 @@ */ import { useSyncExternalStore } from "react"; -import type { DeviceSize } from "@/core/breakpoints"; +import { Breakpoints, type DeviceSize } from "@/core/breakpoints"; const QUERIES = { - mobile: "(max-width: 599.98px)", - tablet: "(min-width: 600px) and (max-width: 1023.98px)", - desktop: "(min-width: 1024px)", + mobile: `(max-width: ${Breakpoints.mobileMaxWidth}px)`, + tablet: `(min-width: ${Breakpoints.tabletMinWidth}px) and (max-width: ${Breakpoints.tabletMaxWidth}px)`, + desktop: `(min-width: ${Breakpoints.desktopMinWidth}px)`, } as const satisfies Record; const subscribe = (cb: () => void): (() => void) => { diff --git a/src/providers/root-providers.tsx b/src/providers/root-providers.tsx index df6cecdf..5c854c15 100644 --- a/src/providers/root-providers.tsx +++ b/src/providers/root-providers.tsx @@ -16,6 +16,7 @@ import type { ReactNode } from "react"; +import { ViewportCssVarsProvider } from "@/providers/viewport-css-vars-provider"; import { AuthProvider } from "@/stores/auth/auth-context"; import { AuthStatusChecker } from "@/stores/auth/auth-status-checker"; import { OAuthSessionSync } from "@/stores/auth/oauth-session-sync"; @@ -34,23 +35,25 @@ export interface RootProvidersProps { export function RootProviders({ children }: RootProvidersProps) { return ( - - {/* AuthStatusChecker 必须在 AuthProvider 内部(依赖 useAuthDispatch), - * 必须在 UserProvider/ChatProvider 外部(它们可能在 init 完成前就 mount) */} - - {/* OAuthSessionSync 同样依赖 AuthProvider 上下文 */} - - - - - - - - {children} -
- - - - + + + {/* AuthStatusChecker 必须在 AuthProvider 内部(依赖 useAuthDispatch), + * 必须在 UserProvider/ChatProvider 外部(它们可能在 init 完成前就 mount) */} + + {/* OAuthSessionSync 同样依赖 AuthProvider 上下文 */} + + + + + + + + {children} +
+ + + + + ); } diff --git a/src/providers/viewport-css-vars-provider.tsx b/src/providers/viewport-css-vars-provider.tsx new file mode 100644 index 00000000..375a8dbb --- /dev/null +++ b/src/providers/viewport-css-vars-provider.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { useEffect, type ReactNode } from "react"; + +const CSS_VAR_VIEWPORT_HEIGHT = "--app-viewport-height"; +const CSS_VAR_VISIBLE_HEIGHT = "--app-visible-height"; +const CSS_VAR_VISIBLE_OFFSET_TOP = "--app-visible-offset-top"; +const CSS_VAR_SAFE_TOP = "--app-safe-top"; +const CSS_VAR_SAFE_RIGHT = "--app-safe-right"; +const CSS_VAR_SAFE_BOTTOM = "--app-safe-bottom"; +const CSS_VAR_SAFE_LEFT = "--app-safe-left"; + +export interface ViewportCssVarsProviderProps { + children: ReactNode; +} + +/** + * Keeps viewport-related CSS variables in sync with the real visual viewport. + * + * CSS already has safe defaults (`100dvh` and `env(safe-area-inset-*)`). This + * client provider improves Safari/PWA/WebView behavior after hydration without + * pushing viewport measurements into React state. + */ +export function ViewportCssVarsProvider({ + children, +}: ViewportCssVarsProviderProps) { + useEffect(() => { + if (typeof window === "undefined") return; + + let frameId: number | null = null; + + const applyVars = () => { + frameId = null; + const root = document.documentElement; + const viewport = window.visualViewport; + const layoutHeight = window.innerHeight; + const layoutWidth = window.innerWidth; + const visibleHeight = viewport?.height ?? layoutHeight; + const visibleWidth = viewport?.width ?? layoutWidth; + const visibleOffsetTop = viewport?.offsetTop ?? 0; + + root.style.setProperty(CSS_VAR_VIEWPORT_HEIGHT, `${layoutHeight}px`); + root.style.setProperty(CSS_VAR_VISIBLE_HEIGHT, `${visibleHeight}px`); + root.style.setProperty( + CSS_VAR_VISIBLE_OFFSET_TOP, + `${visibleOffsetTop}px`, + ); + root.style.setProperty(CSS_VAR_SAFE_TOP, "env(safe-area-inset-top, 0px)"); + root.style.setProperty( + CSS_VAR_SAFE_RIGHT, + "env(safe-area-inset-right, 0px)", + ); + root.style.setProperty( + CSS_VAR_SAFE_BOTTOM, + "env(safe-area-inset-bottom, 0px)", + ); + root.style.setProperty( + CSS_VAR_SAFE_LEFT, + "env(safe-area-inset-left, 0px)", + ); + root.dataset.viewportWidth = String(Math.round(visibleWidth)); + root.dataset.viewportHeight = String(Math.round(visibleHeight)); + }; + + const scheduleApply = () => { + if (frameId != null) return; + frameId = window.requestAnimationFrame(applyVars); + }; + + applyVars(); + + window.addEventListener("resize", scheduleApply); + window.addEventListener("orientationchange", scheduleApply); + window.visualViewport?.addEventListener("resize", scheduleApply); + window.visualViewport?.addEventListener("scroll", scheduleApply); + + return () => { + if (frameId != null) window.cancelAnimationFrame(frameId); + window.removeEventListener("resize", scheduleApply); + window.removeEventListener("orientationchange", scheduleApply); + window.visualViewport?.removeEventListener("resize", scheduleApply); + window.visualViewport?.removeEventListener("scroll", scheduleApply); + }; + }, []); + + return children; +} diff --git a/src/tokens/dimensions.css b/src/tokens/dimensions.css index f03b3d7a..5dfa4b1d 100644 --- a/src/tokens/dimensions.css +++ b/src/tokens/dimensions.css @@ -6,8 +6,8 @@ */ :root { /* 按钮 */ - --button-height: 48px; - --button-height-lg: 56px; + --button-height: var(--responsive-control-height, 48px); + --button-height-lg: clamp(50px, 10.37vw, 56px); /* Splash Facebook 登录按钮 */ --splash-facebook-button-height: 56px; @@ -17,8 +17,8 @@ --pwa-button-height: 44px; /* 对话框 */ - --dialog-max-width: 360px; - --pwa-install-dialog-max-width: 360px; + --dialog-max-width: min(calc(100vw - 40px), 380px); + --pwa-install-dialog-max-width: min(calc(100vw - 40px), 380px); --pwa-install-container-size: 64px; /* 列表项 */ @@ -26,12 +26,12 @@ /* 消息气泡 */ --bubble-min-width: 60px; - --bubble-max-width: 280px; + --bubble-max-width: min(75vw, 280px); --bubble-min-height: 36px; /* 聊天媒体 */ - --chat-media-max-width: 240px; - --chat-media-max-height: 240px; + --chat-media-max-width: min(64vw, 260px); + --chat-media-max-height: min(64vw, 260px); /* ==================== Auth 页尺寸(与 Dart AppDimensions / Spacing 对齐) ==================== */ /* 输入框 / 主按钮高度(46px = Dart AppDimensions.height46) */ @@ -41,7 +41,7 @@ --auth-social-button-height: 40px; /* 主按钮固定宽度(Dart AppDimensions.buttonWidth = 334) */ - --auth-primary-button-width: 334px; + --auth-primary-button-width: min(100%, 334px); /* Logo 高度(120px) */ --auth-logo-height: 120px; diff --git a/src/tokens/responsive.css b/src/tokens/responsive.css index d2ad6b3f..acfc15f8 100644 --- a/src/tokens/responsive.css +++ b/src/tokens/responsive.css @@ -4,15 +4,41 @@ * 设计稿基准:1080x1920 @2x,因此 CSS 设计宽度为 540px。 * 原则: * - 桌面调试最大宽度使用 --shell-max-width / --app-max-width。 - * - 组件尺寸使用 clamp(min, design-ratio vw, design-value),在 360-540px + * - 组件尺寸使用 clamp(min, design-ratio vw, design-value),在 320-540px * 之间平滑缩放,并避免小屏过度压缩。 + * - 业务 CSS 优先使用语义 token,避免页面散落硬编码尺寸。 */ :root { --app-design-width: 540px; --app-max-width: var(--shell-max-width, 540px); + --app-viewport-height: 100dvh; + --app-visible-height: 100dvh; + --app-safe-top: env(safe-area-inset-top, 0px); + --app-safe-right: env(safe-area-inset-right, 0px); + --app-safe-bottom: env(safe-area-inset-bottom, 0px); + --app-safe-left: env(safe-area-inset-left, 0px); /* 56px @2x = 28px;小屏保留至少 22px,避免内容贴边。 */ - --page-padding-x: clamp(22px, 5.185vw, 28px); + --page-padding-x: clamp(18px, 5.185vw, 28px); + --page-padding-y: clamp(14px, 3.333vw, 20px); + --page-section-gap: clamp(12px, 3.333vw, 18px); + --page-section-gap-lg: clamp(16px, 4.444vw, 24px); + + /* 页面级语义尺寸 */ + --responsive-display-title: clamp(28px, 6.296vw, 34px); + --responsive-page-title: clamp(22px, 5.185vw, 28px); + --responsive-section-title: clamp(17px, 3.704vw, 20px); + --responsive-card-title: clamp(16px, 3.333vw, 18px); + --responsive-body: clamp(14px, 2.963vw, 16px); + --responsive-caption: clamp(12px, 2.407vw, 13px); + --responsive-micro: clamp(10px, 2.222vw, 12px); + + --responsive-card-radius: clamp(20px, 5.185vw, 28px); + --responsive-card-radius-sm: clamp(18px, 4.074vw, 22px); + --responsive-card-padding: clamp(14px, 3.333vw, 18px); + --responsive-card-padding-lg: clamp(18px, 4.074vw, 22px); + --responsive-control-height: clamp(44px, 8.889vw, 48px); + --responsive-icon-button-size: clamp(38px, 8.148vw, 44px); /* Sidebar 专用响应式尺寸 */ --sidebar-user-padding-y: clamp(18px, 3.704vw, 20px);