Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ratewise-homepage-cls-454.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@app/ratewise': patch
---

首頁換算器預留固定高度,減少匯率卡片與計價基準 pill 載入時的版面跳動(CLS)
11 changes: 11 additions & 0 deletions apps/ratewise/src/config/design-tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -711,6 +711,17 @@ export const singleConverterLayoutTokens = {
/** 次要匯率文字 */
rateSubText: 'text-sm short:text-xs tiny:text-xs micro:text-[10px] nano:text-[10px]',

/** 匯率文字區塊最小高度(含計價基準 pill 槽位,避免 async 載入位移) */
rateTextBlock:
'min-h-[5rem] compact:min-h-[4.5rem] short:min-h-[4rem] tiny:min-h-[3.75rem] micro:min-h-[3.5rem] nano:min-h-[3.25rem]',

/** 計價基準 pill 槽位(無 pill 時仍保留高度) */
rateBasisSlot: 'mt-1.5 flex min-h-6 items-center justify-center',

/** 匯率卡片本體最小高度(資訊區 + 圖表) */
cardMinHeight:
'min-h-[10.5rem] compact:min-h-[9.5rem] short:min-h-[8.75rem] tiny:min-h-[8rem] micro:min-h-[7.25rem] nano:min-h-[6.75rem]',

/** 趨勢圖高度 - 線性遞減 */
chartHeight: 'h-20 compact:h-16 short:h-14 tiny:h-12 micro:h-10 nano:h-8',

Expand Down
20 changes: 12 additions & 8 deletions apps/ratewise/src/features/ratewise/RateWise.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -263,12 +263,12 @@ const RateWise = ({ rememberConverterView = true }: { rememberConverterView?: bo
</div>
</section>

{/* 資料來源與更新時間區塊 - 現代化精簡設計,固定在底部 */}
{!ratesLoading && lastUpdate && (
<section
data-testid="ratewise-data-source"
className={`${rateWiseLayoutTokens.info.base} ${rateWiseLayoutTokens.info.visibility}`}
>
{/* 資料來源與更新時間區塊 - 固定最小高度避免載入完成後 footer 位移 */}
<section
data-testid="ratewise-data-source"
className={`${rateWiseLayoutTokens.info.base} ${rateWiseLayoutTokens.info.visibility} min-h-6`}
>
{!ratesLoading && lastUpdate ? (
<AnimatePresence mode="wait">
{effectiveRateSource === 'exchange-shop' &&
moneyBoxRate?.currency === exchangeShopCurrency ? (
Expand All @@ -292,8 +292,12 @@ const RateWise = ({ rememberConverterView = true }: { rememberConverterView?: bo
</div>
)}
</AnimatePresence>
</section>
)}
) : (
<span className="invisible block text-[10px]" aria-hidden="true">
&#8203;
</span>
)}
</section>
</div>
</div>
</>
Expand Down
25 changes: 12 additions & 13 deletions apps/ratewise/src/features/ratewise/components/SingleConverter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,6 @@ export const SingleConverter = ({
const [trendData, setTrendData] = useState<MiniTrendDataPoint[]>([]);
const [_loadingTrend, setLoadingTrend] = useState(false);
const [isSwapping, setIsSwapping] = useState(false);
const [showTrend, setShowTrend] = useState(false);
const [trendDateKey, setTrendDateKey] = useState(() => getLocalDateKey());
const swapButtonRef = useRef<HTMLButtonElement>(null);

Expand Down Expand Up @@ -157,12 +156,6 @@ export const SingleConverter = ({
);
const reverseRate = getReciprocalExchangeRate(exchangeRate);

// 趨勢圖進場動畫
useEffect(() => {
const timer = setTimeout(() => setShowTrend(true), 300);
return () => clearTimeout(timer);
}, []);

// 處理交換按鈕點擊
const handleSwap = () => {
setIsSwapping(true);
Expand Down Expand Up @@ -486,7 +479,7 @@ export const SingleConverter = ({
<div className={singleConverterLayoutTokens.rateCard.section}>
{/* 匯率卡片 - 一體化設計,無切分感 */}
<div
className={`relative bg-gradient-to-b from-surface-card to-surface-elevated rounded-xl w-full group cursor-pointer hover:shadow-xl transition-all duration-500 border border-border/50 hover:border-primary/30 ${singleConverterLayoutTokens.rateCard.cardSpacing}`}
className={`relative bg-gradient-to-b from-surface-card to-surface-elevated rounded-xl w-full group cursor-pointer hover:shadow-xl transition-all duration-500 border border-border/50 hover:border-primary/30 ${singleConverterLayoutTokens.rateCard.cardSpacing} ${singleConverterLayoutTokens.rateCard.cardMinHeight}`}
>
{/*
* 微光效果 - 極淺的漸層覆蓋
Expand All @@ -509,8 +502,8 @@ export const SingleConverter = ({
onRateSourceChange={onRateSourceChange ?? (() => undefined)}
/>

{/* 匯率顯示 - 使用 SSOT text 色 */}
<div className="w-full">
{/* 匯率顯示 - 使用 SSOT text 色;固定高度避免計價基準 pill / live 匯率載入 CLS */}
<div className={`w-full ${singleConverterLayoutTokens.rateCard.rateTextBlock}`}>
<div
className={`${singleConverterLayoutTokens.rateCard.rateText} font-bold tabular-nums text-text mb-1 transition-transform duration-300 group-hover:scale-105`}
>
Expand All @@ -521,15 +514,21 @@ export const SingleConverter = ({
>
1 {toCurrency} = {formatExchangeRate(reverseRate)} {fromCurrency}
</div>
<div
className={singleConverterLayoutTokens.rateCard.rateBasisSlot}
aria-hidden="true"
>
<span className="invisible rounded-full border border-transparent px-2 py-0.5 text-xs">
&#8203;
</span>
</div>
</div>
</div>

{/* 滿版趨勢圖 - 無獨立背景,繼承父元素漸層實現一體化 */}
<div
data-testid="trend-chart"
className={`relative w-full ${singleConverterLayoutTokens.rateCard.chartHeight} ${singleConverterLayoutTokens.rateCard.chartHoverHeight} transition-[height,opacity,transform] duration-500 will-change-[height,opacity,transform] overflow-hidden rounded-b-xl ${
showTrend ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'
}`}
className={`relative w-full ${singleConverterLayoutTokens.rateCard.chartHeight} ${singleConverterLayoutTokens.rateCard.chartHoverHeight} overflow-hidden rounded-b-xl`}
>
<div className="absolute inset-0 transition-transform duration-500 group-hover:scale-105">
<ErrorBoundary
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,13 @@ describe('SingleConverter - 核心功能測試', () => {
expect(trendChart).toHaveClass(className);
});
});

it('匯率文字區塊應預留計價基準 pill 槽位高度', () => {
render(<SingleConverter {...mockProps} />);

const rateTextBlock = screen.getByText(/1 TWD =/).parentElement;
expect(rateTextBlock).toHaveClass(singleConverterLayoutTokens.rateCard.rateTextBlock);
});
});

describe('匯率類型切換', () => {
Expand Down Expand Up @@ -433,21 +440,15 @@ describe('SingleConverter - 核心功能測試', () => {
});
});

describe('趨勢圖進場動畫', () => {
it('should show trend chart after 300ms delay', () => {
describe('趨勢圖 CLS 穩定', () => {
it('趨勢圖容器應自初始渲染起保持可見且固定高度', () => {
render(<SingleConverter {...mockProps} />);

// 初始狀態不應該顯示趨勢圖(opacity-0)
const trendContainer = document.querySelector('.opacity-0.translate-y-4');
expect(trendContainer).toBeInTheDocument();

// 300ms 後應該顯示(opacity-100)
act(() => {
vi.advanceTimersByTime(400);
const trendChart = screen.getByTestId('trend-chart');
expect(trendChart).not.toHaveClass('opacity-0');
singleConverterLayoutTokens.rateCard.chartHeight.split(' ').forEach((className) => {
expect(trendChart).toHaveClass(className);
});

const visibleTrend = document.querySelector('.opacity-100.translate-y-0');
expect(visibleTrend).toBeInTheDocument();
});
});

Expand Down
7 changes: 6 additions & 1 deletion docs/dev/002_development_reward_penalty_log.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

> 版本:outline-v2-ultra
> 原則:每筆只保留日期、ID、原因、解法。
> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+74
> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+75

## 新增模板(4 行)

Expand All @@ -13,6 +13,11 @@

## 條目(新→舊)

- 日期:2026-06-27
- ID:reward-ratewise-homepage-cls-454-hotfix
- 原因:#441 計價基準 pill 與趨勢圖進場動畫使首頁 CLS 達 0.237,超過 LHCI 門檻 0.1
- 解法:SingleConverter 匯率卡片固定 min-height、預留 pill 槽位、移除趨勢圖 translate 動畫;RateWise footer 保留 min-h-6

- 日期:2026-06-27
- ID:reward-moneybox-schema-migration-cdn-fallback
- 原因:MoneyBox fetch 僅比對匯率數字,schema v2 遷移未觸發 data commit/purge;jsDelivr @data 分支快取最長 12h
Expand Down
Loading