diff --git a/.changeset/converge-uiux-rate-basis.md b/.changeset/converge-uiux-rate-basis.md new file mode 100644 index 000000000..c4a865ebe --- /dev/null +++ b/.changeset/converge-uiux-rate-basis.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': minor +--- + +換算結果新增計價基準標示(臺銀賣出/買入、換錢所等六種基準 pill),收藏與全幣種清單標示臺銀賣出價基準與 TWD 單位;計算機輸入時即時顯示目標幣別換算預覽;換算結果加入螢幕閱讀器 aria-live 宣告;多幣別收藏星與字級補足無障礙觸控目標與對比下限。 diff --git a/.changeset/fix-nitro-theme-ssot.md b/.changeset/fix-nitro-theme-ssot.md new file mode 100644 index 000000000..ff284e544 --- /dev/null +++ b/.changeset/fix-nitro-theme-ssot.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +對齊 nitro 主題色票 SSOT:themes.ts primary 從 #00D4FF 改為 #0096E6,與 index.css 保持一致 diff --git a/.changeset/fix-ratewise-pwa-ios-eviction.md b/.changeset/fix-ratewise-pwa-ios-eviction.md new file mode 100644 index 000000000..4322d4802 --- /dev/null +++ b/.changeset/fix-ratewise-pwa-ios-eviction.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +修復 iOS PWA precache 驅逐後 3s timeout 導致在線用戶看到 offline.html 的假離線問題,改用 precache-first 冷啟動策略 diff --git a/.changeset/quiet-breadcrumb-token-refactor.md b/.changeset/quiet-breadcrumb-token-refactor.md new file mode 100644 index 000000000..a442a70c0 --- /dev/null +++ b/.changeset/quiet-breadcrumb-token-refactor.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +收斂內容頁返回導覽、麵包屑與核心 UI shell 的 design token,改善 PWA 與小螢幕顯示穩定性。 diff --git a/.changeset/ratewise-a11y-touch-target-focus.md b/.changeset/ratewise-a11y-touch-target-focus.md new file mode 100644 index 000000000..f7c89f418 --- /dev/null +++ b/.changeset/ratewise-a11y-touch-target-focus.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +無障礙與操作體驗改善:評分視窗星星按鈕、開放資料頁複製按鈕、Email 連結與計算機關閉鈕補齊 44px 觸控目標與鍵盤焦點指示;小型標籤與代碼區塊圓角統一為設計系統規格。 diff --git a/.changeset/ratewise-artifact-ssot.md b/.changeset/ratewise-artifact-ssot.md new file mode 100644 index 000000000..c2440f801 --- /dev/null +++ b/.changeset/ratewise-artifact-ssot.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +Clarify generated artifact buckets and remove local build report files from tracked source. diff --git a/.changeset/ratewise-calculator-preview-derived-ssot.md b/.changeset/ratewise-calculator-preview-derived-ssot.md new file mode 100644 index 000000000..d1afd8d89 --- /dev/null +++ b/.changeset/ratewise-calculator-preview-derived-ssot.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +內部收斂:計算機即時預覽改以衍生值(useMemo)取代冗餘狀態與同步 effect,並將計價基準的 i18n key 集中為單一來源。對外顯示與換算行為不變,僅提升可維護性與一致性。 diff --git a/.changeset/ratewise-currency-route-registry.md b/.changeset/ratewise-currency-route-registry.md new file mode 100644 index 000000000..1ccd9aa45 --- /dev/null +++ b/.changeset/ratewise-currency-route-registry.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +Consolidate currency landing route registration behind a registry with parity checks. diff --git a/.changeset/ratewise-data-pr-governance.md b/.changeset/ratewise-data-pr-governance.md new file mode 100644 index 000000000..fad31a664 --- /dev/null +++ b/.changeset/ratewise-data-pr-governance.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +Strengthen scheduled rate data update governance by requiring generated data PRs to pass branch protection before merge. diff --git a/.changeset/ratewise-design-token-ssot.md b/.changeset/ratewise-design-token-ssot.md new file mode 100644 index 000000000..0770af7a7 --- /dev/null +++ b/.changeset/ratewise-design-token-ssot.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +收斂設計 Token 為語義化 SSOT:圓角與陰影全站統一為語義 token,恢復 Tailwind 標準預設值避免第三方整合風險 diff --git a/.changeset/ratewise-eslint-8593-redundant-assertions.md b/.changeset/ratewise-eslint-8593-redundant-assertions.md new file mode 100644 index 000000000..d58770f1a --- /dev/null +++ b/.changeset/ratewise-eslint-8593-redundant-assertions.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +移除 typescript-eslint 8.59.3 新規則標記的冗餘型別斷言(MiniTrendChart、exchangeRateService);內部 lint 清理,使用者無可見影響。 diff --git a/.changeset/ratewise-exchange-shop-ratemode-nav-header.md b/.changeset/ratewise-exchange-shop-ratemode-nav-header.md new file mode 100644 index 000000000..c0cf9153c --- /dev/null +++ b/.changeset/ratewise-exchange-shop-ratemode-nav-header.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +修正換錢所匯率模式選價:選「中間價」時正確以(買入+賣出)÷2 計算、選「賣出」時雙向採用賣出價,趨勢圖與多幣別列同步跟隨所選模式;內容頁頂部「返回+麵包屑」導覽改為不固定的圓角按鈕樣式,修復部分 iOS PWA 用戶頂部內容被遮擋的問題。 diff --git a/.changeset/ratewise-opendata-mobile-overflow.md b/.changeset/ratewise-opendata-mobile-overflow.md new file mode 100644 index 000000000..4e856b39b --- /dev/null +++ b/.changeset/ratewise-opendata-mobile-overflow.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +修正開放資料頁 API 端點路徑在小螢幕(320px 等窄視口)造成的水平溢出,長路徑現在會正確斷行。 diff --git a/.changeset/ratewise-pwa-shell-brand-sync.md b/.changeset/ratewise-pwa-shell-brand-sync.md new file mode 100644 index 000000000..2866c9e6d --- /dev/null +++ b/.changeset/ratewise-pwa-shell-brand-sync.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +PWA shell 品牌色同步至 Violet 主題,CDN 備援日誌降噪至 debug 等級 diff --git a/.changeset/ratewise-radius-ssot-guard.md b/.changeset/ratewise-radius-ssot-guard.md new file mode 100644 index 000000000..580d7c9d1 --- /dev/null +++ b/.changeset/ratewise-radius-ssot-guard.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +視覺一致性收斂:匯率卡趨勢圖底部圓角對齊卡片外框、骨架載入與小型元素圓角統一為設計系統規格,並新增圓角防回歸守門測試。 diff --git a/.changeset/ratewise-sw-bounded-nav.md b/.changeset/ratewise-sw-bounded-nav.md new file mode 100644 index 000000000..ec8b3accb --- /dev/null +++ b/.changeset/ratewise-sw-bounded-nav.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +離線/弱網下載入更穩定,不再可能卡白屏。 diff --git a/.github/workflows/update-moneybox-rates.yml b/.github/workflows/update-moneybox-rates.yml index 20e61b8b5..b09d1c9dc 100644 --- a/.github/workflows/update-moneybox-rates.yml +++ b/.github/workflows/update-moneybox-rates.yml @@ -94,13 +94,26 @@ jobs: id: save-history if: steps.fetch-rates.outcome == 'success' run: | - CURRENT_DATE=$(TZ=Asia/Taipei date +%Y-%m-%d) - MONEYBOX_HISTORY_FILE="${MONEYBOX_HISTORY_DIR}/${CURRENT_DATE}.json" - mkdir -p "$MONEYBOX_HISTORY_DIR" if [[ ! -f "$MONEYBOX_FETCH_OUTPUT_FILE" ]]; then echo "❌ Current MoneyBox fetch snapshot missing: ${MONEYBOX_FETCH_OUTPUT_FILE}" exit 1 fi + # history 檔名 SSOT:直接採用資料本身宣告的首爾掛牌日(updateTime), + # 與 fetch script 的 date-rollover 判斷 (extractSeoulSnapshotDate) 同源, + # 避免 runner wall-clock 與資料日期在跨日視窗不一致。提取失敗時 fallback 首爾 wall-clock。 + CURRENT_DATE=$(node --input-type=module -e " + import { readFileSync } from 'node:fs'; + const { extractSeoulSnapshotDate } = await import('./scripts/fetch-moneybox-rates.js'); + const data = JSON.parse(readFileSync(process.env.MONEYBOX_FETCH_OUTPUT_FILE, 'utf8')); + const d = extractSeoulSnapshotDate(data); + if (d) process.stdout.write(d); + ") + if [[ ! "$CURRENT_DATE" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then + CURRENT_DATE=$(TZ=Asia/Seoul date +%Y-%m-%d) + echo "⚠️ 無法從資料提取首爾 snapshot date,fallback 至首爾 wall-clock: ${CURRENT_DATE}" + fi + MONEYBOX_HISTORY_FILE="${MONEYBOX_HISTORY_DIR}/${CURRENT_DATE}.json" + mkdir -p "$MONEYBOX_HISTORY_DIR" if [[ ! -f "$MONEYBOX_HISTORY_FILE" ]] || [[ -n "$(git status --short --untracked-files=all -- "$MONEYBOX_LATEST_FILE")" ]]; then cp "$MONEYBOX_FETCH_OUTPUT_FILE" "$MONEYBOX_HISTORY_FILE" echo "✅ MoneyBox history snapshot refreshed: ${MONEYBOX_HISTORY_FILE}" @@ -135,6 +148,9 @@ jobs: if [[ -n "$(git status --short --untracked-files=all -- "$MONEYBOX_HISTORY_DIR/")" ]]; then echo "changed=true" >> $GITHUB_OUTPUT fi + if [[ ! -f "$MONEYBOX_AGGREGATE_FILE" ]]; then + echo "changed=true" >> $GITHUB_OUTPUT + fi if [[ "$RETIRED_ALIASES_CHANGED" == "true" ]]; then echo "changed=true" >> $GITHUB_OUTPUT fi diff --git a/.gitignore b/.gitignore index a7e5f240d..10a3d9eb0 100644 --- a/.gitignore +++ b/.gitignore @@ -128,6 +128,9 @@ squirrel.toml .agents/skills/*/references/ .agents/skills/*/AGENTS.md +# Third-party local skill bundles (tool-synced, not project source) +.agents/skills/impeccable/ + # AI tool local skill copies (synced from .agents/skills/) .claude/ .codex/ diff --git a/AGENTS.md b/AGENTS.md index e2a692f1b..a22822a8b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -823,5 +823,30 @@ Agent 在結案或提交時,應能提供下列證據(依任務適用性) --- +## Learned User Preferences + +- 偏好極簡 UI 設計:卡片與按鈕不加外框(border/outline),保持乾淨俐落 +- 多幣別轉換器中,非主幣項目使用極淺背景色 token,避免視覺突兀 +- 匯率顯示採 Google 模式:上方為原始幣、下方為目標幣,顯示買入價與其倒數,不使用「買入/賣出」術語 +- 匯率來源切換為三態(即期/現金/換錢所),無資料者灰色顯示並自動 fallback 至下一個可用來源 +- RateWise UI/UX 標竿為韓系金融 App(Toss、Wowpass),並行多面向審查追求高真度體驗 +- Kawaii 主題色票偏好較淺、較飽和粉嫩,避免暗沉色;Nitro/Ocean 等深色主題 primary 按鈕使用淺白字 +- 頁面資訊應精簡去重,避免重複雜亂堆疊;單幣別輸入應減少 Modal 閘門與多步摩擦 +- 所有修復需保持原子化,不得順便重構或加入不必要註解 +- 期望持續迭代直到完美收斂:監控 Codex review 並逐一解決、推送、驗證 + +## Learned Workspace Facts + +- `docs/prompt/UIUX.md` 為 UI/UX 變更的正式審查清單,用於審視未提交改動 +- `feat/ratewise-fintech-uiux-p0` 為最新 RateWise UI/UX 分支(含 governance 與計價基準 pill);若被 worktree 占用,開發/審查使用 `.claude/worktrees/ratewise-uiux-p0` +- 2026 UX 規格與 Phase 1 計畫見 `docs/superpowers/specs/` 與 `docs/superpowers/plans/` +- 品牌名稱 SSOT 為「HaoRate 匯率好工具」 +- RateWise 趨勢圖曾因 `TREND_CHART_DEFER_MS = 10000` 導致感知延遲,需保持 defer 時間在合理範圍 +- SEO 生產驗證三腳本組合:`verify-production-resources.mjs`(資源可達)→ `verify-all-apps.mjs`(語義)→ `verify-precache-assets.mjs`(PWA live) +- Codex review 收斂為標準工作流:使用 `pnpm review:codex:audit` 盤點 → `gh` 逐條回覆 → 標記 resolved +- 使用者會透過瀏覽器元素選取(browser_element)直接指定要修改的 UI 節點 + +--- + **最後更新**: 2026-05-02T09:00:00+0800 **版本**: v5.5(SEO 迭代 SOP 與監控治理補齊) diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 000000000..396be7d32 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,231 @@ +--- +name: RateWise +description: 台灣銀行牌告導向的安靜匯率換算工具 +colors: + background: '#F8FAFC' + surface: '#FFFFFF' + surface-elevated: '#F8FAFC' + surface-sunken: '#F1F5F9' + text: '#0F172A' + text-muted: '#64748B' + primary: '#7C3AED' + secondary: '#6366F1' + accent: '#8B5CF6' + border: '#E2E8F0' + info: '#0EA5E9' + success: '#22C55E' + warning: '#F59E0B' + destructive: '#DC2626' +typography: + display: + fontFamily: 'Inter, Noto Sans TC, system-ui, -apple-system, sans-serif' + fontSize: 'clamp(1.875rem, 4vw, 2.25rem)' + fontWeight: 700 + lineHeight: 1.2 + headline: + fontFamily: 'Inter, Noto Sans TC, system-ui, -apple-system, sans-serif' + fontSize: '1.5rem' + fontWeight: 700 + lineHeight: 1.333 + title: + fontFamily: 'Inter, Noto Sans TC, system-ui, -apple-system, sans-serif' + fontSize: '1.25rem' + fontWeight: 600 + lineHeight: 1.4 + body: + fontFamily: 'Inter, Noto Sans TC, system-ui, -apple-system, sans-serif' + fontSize: '1rem' + fontWeight: 400 + lineHeight: 1.6 + label: + fontFamily: 'Inter, Noto Sans TC, system-ui, -apple-system, sans-serif' + fontSize: '0.75rem' + fontWeight: 600 + lineHeight: 1.333 + letterSpacing: '0.16em' +rounded: + sm: '4px' + md: '6px' + lg: '8px' + card: '24px' + panel: '16px' + control: '16px' + icon: '12px' + compact: '8px' + pill: '999px' +spacing: + xs: '8px' + sm: '12px' + md: '16px' + lg: '24px' + xl: '32px' +components: + button-primary: + backgroundColor: '{colors.primary}' + textColor: '{colors.surface}' + rounded: '{rounded.lg}' + padding: '0 16px' + height: '44px' + button-secondary: + backgroundColor: '{colors.surface-elevated}' + textColor: '{colors.text}' + rounded: '{rounded.lg}' + padding: '0 16px' + height: '44px' + card-panel: + backgroundColor: '{colors.surface}' + textColor: '{colors.text}' + rounded: '{rounded.card}' + padding: '20px' + notification-surface: + backgroundColor: '{colors.surface}' + textColor: '{colors.text}' + rounded: '{rounded.lg}' + padding: '14px 24px' + list-row: + backgroundColor: '{colors.surface}' + textColor: '{colors.text}' + rounded: '{rounded.lg}' + padding: '12px 16px' +--- + +## Overview + +**Creative North Star: "The Quiet Exchange Desk"** + +RateWise 的預設視覺系統是一套安靜、克制、資訊優先的產品介面。它應該像使用者在付款前最後確認匯率的工作台,不像行情盤,也不像裝飾型 SaaS 首頁。畫面首先服務判讀速度,其次才是品牌辨識。 + +預設產品語法以 `zen` 風格為基準,其他 `nitro`、`kawaii`、`classic`、`ocean`、`forest` 僅作使用者可切換的外觀變體。所有變體都必須保留相同的資訊層級、相同的元件節奏與相同的互動語意,不能用造型改寫產品心智模型。 + +這套系統明確排斥加密交易平台式的霓虹高飽和、AI 樣板 SaaS 式的 glow 與漸層文字,以及生活風格工具式的過度情緒化語氣。它的辨識度來自安靜表面、清楚數字、穩定留白與一致詞彙,不來自特效。 + +Key Characteristics: + +- 冷靜、精準、可靠。 +- 預設使用 restrained color strategy,不做大面積裝飾性漸層。 +- 同一概念在首頁、收藏、多幣別、設定、SEO 內容頁使用同一種權重與元件語法。 +- 觸控優先,但桌面版要有足夠寬度與資訊節奏,不能像手機稿被放大。 + +## Colors + +整體色彩以 violet / indigo 品牌色搭配冷靜中性色為主,將主色集中在主要操作、狀態與導引,而不是大片情緒背景。 + +### Primary + +- **Exchange Violet** (`#7C3AED`): 主要 CTA、焦點與需要立即判讀的互動元素。預設不拿來鋪滿大面積背景。 + +### Secondary + +- **Indigo Signal** (`#6366F1`): 支援型資訊、次要狀態與品牌層次,用於輔助,不與 Primary 爭主導權。 + +### Tertiary + +- **Soft Violet Accent** (`#8B5CF6`): 僅用於小範圍高亮,例如通知裝飾、局部導引與互動後的柔性回饋。 + +### Neutral + +- **Ledger Mist** (`#F8FAFC`): 頁面背景,維持乾淨但不刺眼的工作台底色。 +- **Paper Surface** (`#FFFFFF`): 主卡片與主要容器背景。 +- **Raised Surface** (`#F8FAFC`): 次層容器、segmented controls、次要按鈕背景。 +- **Sunken Surface** (`#F1F5F9`): 按壓後或低階容器的收斂層次。 +- **Ink 900** (`#0F172A`): 主要文字、標題與高重要度數字。 +- **Slate Note** (`#64748B`): 次要說明、輔助文與低權重標籤。 +- **Rule Line** (`#E2E8F0`): 邊框、分隔與可讀但不搶戲的結構線。 + +### Named Rules + +**The Quiet Surface Rule.** 背景與容器先用中性層次解決階層,再考慮色彩。若同一畫面已經靠邊框、留白與標題完成分層,就不要再加漸層或 glow。 + +## Typography + +**Display Font:** Inter, Noto Sans TC, system-ui, -apple-system, sans-serif +**Body Font:** Inter, Noto Sans TC, system-ui, -apple-system, sans-serif +**Label/Mono Font:** ui-monospace, SFMono-Regular, Menlo, Monaco, monospace + +字體策略以高可讀性的無襯線系統為主,讓中英文與數字混排時仍保持穩定節奏。數值顯示、更新時間與版本等資料型內容,應優先使用 tabular numerals 或 monospace 輔助,而不是額外裝飾。 + +### Hierarchy + +- **Display** (700, `clamp(1.875rem, 4vw, 2.25rem)`, 1.2): 頁面主標題與少數需要建立主場景的首屏標題。 +- **Headline** (700, `1.5rem`, 1.333): 核心模組標題,例如主要卡片或內容頁區塊標題。 +- **Title** (600, `1.25rem`, 1.4): 次級區塊、卡片標題與清單模組標題。 +- **Body** (400, `1rem`, 1.6): 正文與說明文。內容型頁面的段落寬度維持在約 65 至 72 字元。 +- **Label** (600, `0.75rem`, 1.333, `0.16em`): eyebrow、區塊導引與輕量 metadata。只在需要建立節奏時使用大寫標籤,不可濫用。 + +### Named Rules + +**The Number First Rule.** 與匯率、金額、更新時間相關的資訊優先度高於裝飾型標題。若文字階層與數值判讀衝突,優先讓數值更清楚。 + +## Elevation + +RateWise 的深度語法以 tonal layering 為主,陰影為輔。絕大多數層次差異先靠 `surface`、`surface-elevated`、`surface-sunken` 解決,陰影只用來表達浮起、hover 或暫時性提示,不作常態性戲劇效果。 + +### Shadow Vocabulary + +- **Resting Card** (`shadow-card`): 預設卡片與主要內容面板,值由 `shadowTokens.values.card` 統一維護。 +- **Hover Lift** (`shadow-card-hover`): hover 後的輕微浮起,只作互動回饋。 +- **Transient Surface** (`shadow-floating`): 通知、tooltip、底部工作表與臨時浮層。 + +### Named Rules + +**The Lift Must Mean State Rule.** 若一個陰影不代表 hover、焦點、暫時提示或浮層,就不應存在。不要用大陰影補救階層不清的版面。 + +## Components + +### Buttons + +- **Shape:** 互動控制預設 `rounded-control`(16px),主要尺寸高度至少 `44px`,少數輕量操作可用膠囊型圓角。 +- **Primary:** `primary` 實底配 `surface` 文字,只用於明確主動作,例如更新、確認、開始轉換。 +- **Hover / Focus:** hover 允許極輕微上浮與陰影加深;focus 使用清楚的 `ring-primary`,不得用模糊光暈取代焦點樣式。 +- **Secondary / Ghost / Danger:** 次要按鈕用 `surface-elevated` 加邊框;ghost 只在低視覺權重工具列使用;danger 維持明確語義,但版面仍要克制。 + +### Chips + +- **Style:** 小尺寸、圓角、邊框明確,優先作為 eyebrow、狀態或輕量切換標籤,而不是厚重 badge。 +- **State:** 選中狀態主要依靠邊框、底色與文字色改變,不使用高飽和螢光效果。 + +### Cards / Containers + +- **Corner Style:** 主內容面板使用 `rounded-card`(24px),次層容器與互動列使用 `rounded-panel` / `rounded-control`(16px);只允許 pill 用於 chip、badge 或膠囊型控制。 +- **Background:** 主卡片用 `surface`,次層與安靜模組用 `surface-elevated`,不要在正式產品頁中使用玻璃卡。 +- **Shadow Strategy:** 預設使用 resting card 陰影;hover 或可拖曳狀態才升到更高層級。 +- **Border:** 絕大多數卡片都有 `border-border/70` 左右的結構線,取代彩色 accent stripe。 +- **Internal Padding:** 主要面板 `20px` 至 `24px`,輕量列項 `12px` 至 `16px`。 + +### Inputs / Fields + +- **Style:** 以乾淨背景、清楚邊界與穩定留白為主,不用擬物或發光效果。 +- **Focus:** focus 由 ring 與邊框色帶出,不改變版面尺寸。 +- **Error / Disabled:** 直接用語義色與可讀文字說明,不靠震動或炫目動畫表達。 + +### Navigation + +- **Top / Bottom Navigation:** 使用半透明背景與輕微 blur 只作可讀性補強,不是視覺主角。導覽本體要融入產品殼層。 +- **Segmented / Tabs:** 活動狀態使用 `surface-elevated`、文字加重與輕陰影,不用大片主色底。 + +### Notifications + +- **Style:** 更新通知、離線提示與評分提示統一使用安靜的 `surface` 浮層,不再使用品牌漸層底。 +- **Icon Treatment:** 狀態圖標放在小型 elevated 容器中,用 `primary` 或 `warning` 文字色表達狀態。 +- **Action Pattern:** 通知內的主要與關閉操作共用同一套 action token,避免各通知自成一格。 + +## Do's and Don'ts + +- **Do:** 先用字級、留白、邊框與表面層次建立階層,再決定是否需要色彩。 +- **Do:** 讓首頁、收藏、多幣別、設定與 SEO 內容頁共用同一套 panel、row、eyebrow 與 secondary CTA 語法。 +- **Do:** 保留 `prefers-reduced-motion`、清楚 focus ring、足夠對比與至少 44px 的觸控目標。 +- **Do:** 將 theme styles 視為外觀皮膚,不得改變資訊密度、元件語意或互動模型。 +- **Don't:** 不要使用漸層文字、彩色側邊條、裝飾性 glass、過量 glow、或沒有語義的大面積品牌漸層。 +- **Don't:** 不要把金融工具做成加密交易平台、AI 樣板 SaaS,或可愛生活風首頁。 +- **Don't:** 不要靠巢狀卡片與大陰影堆層級,也不要用 8 至 10px 當作常態正文。 +- **Don't:** 不要在不同頁面為相同概念發明第二套顏色、第二套間距或第二套按鈕語法。 + +## Engineering Decisions + +### Tailwind borderRadius Alias Override + +本專案刻意將 Tailwind 標準 `rounded-sm`/`md`/`lg`/`xl` 值上調至語義 token 對齊值(如 `lg` = 1rem 而非 Tailwind 預設 0.5rem),以確保遺漏遷移的 `rounded-lg` 不會產生舊值視覺殘留。此決策的前提是 RateWise 不引入依賴 Tailwind 預設 radius 值的第三方 UI 元件。若未來整合外部元件庫,需重新評估此 override 策略。 + +### Shadow Token 淺色模式限定 + +`shadowTokens` 的陰影基底色使用硬編碼 `rgb(15 23 42 / ...)` (slate-900),僅適用淺色模式。若未來支援深色模式,陰影值需改為 CSS variable 或主題條件值。 diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 000000000..37a820fd1 --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,40 @@ +# Product + +## Register + +product + +## Users + +以台灣為主要使用情境的旅客、跨境消費者、自由工作者與小型商務使用者。 +他們通常在出國前、付款前或比價當下開啟 RateWise,希望用最短時間看懂真實換匯成本,而不是市場中間價。 + +## Product Purpose + +RateWise 提供以臺灣銀行牌告為核心的匯率換算體驗,重點是快速、可信、可重複使用。 +產品應在首頁、收藏、多幣別、歷史與 SEO 幣別頁之間維持一致心智模型,讓使用者能在手機與桌面上用最少操作完成換算、比較與回查。 + +## Brand Personality + +冷靜、精準、可靠。 +語氣應直接、節制、資訊優先,像成熟的金融工具,而不是行銷頁或加密貨幣儀表板。 + +## Anti-references + +- 不要像加密交易平台,避免霓虹、黑底高飽和、過度行情感。 +- 不要像 AI 樣板 SaaS,避免大量 glow、漸層文字、裝飾性 glass。 +- 不要像生活風格或可愛工具,避免預設介面過度玩味與情緒化。 +- 不要為了視覺新奇重做既有操作模型,標準工具行為優先。 + +## Design Principles + +- 實際換匯成本優先,任何視覺表現都不能壓過核心數字與方向。 +- 熟悉的工具型介面優先於新奇,使用者不應學習新的操作語法。 +- 資訊可密集,但層級必須清楚,次要訊息只能輔助,不能搶焦點。 +- 色彩與動效保持克制,只用於狀態、主動作與導引,不做裝飾。 +- 同一概念在所有頁面使用相同詞彙、相同權重與相同元件語言。 + +## Accessibility & Inclusion + +以 WCAG 2.2 AA 為基準,保留清楚焦點樣式、足夠對比、合理觸控尺寸與 `prefers-reduced-motion` 支援。 +繁體中文是預設語境,其他語言介面也必須維持相同資訊層級與可讀性。 diff --git a/apps/ratewise/docs/dev/014_design_token_architecture.md b/apps/ratewise/docs/dev/014_design_token_architecture.md index 581a2a30a..fdb9501d4 100644 --- a/apps/ratewise/docs/dev/014_design_token_architecture.md +++ b/apps/ratewise/docs/dev/014_design_token_architecture.md @@ -1,22 +1,24 @@ # Design Token SSOT 架構文檔 +> 狀態:實作架構說明。當前正式視覺規範請以 root `DESIGN.md` 為主,本文保留技術實作背景與歷史決策。 + > **建立時間**: 2026-01-17T00:30:00+08:00 -> **最後更新**: 2026-01-17T00:30:00+08:00 -> **版本**: 1.0.0 +> **最後更新**: 2026-05-23T00:00:00+08:00 +> **版本**: 1.1.0 > **狀態**: ✅ 已完成 --- ## 1. 概述 -RateWise 採用 **SSOT (Single Source of Truth) Design Token** 架構,透過 CSS Variables 實現動態主題切換,支援 6 種風格和淺/深/自動模式。 +RateWise 採用 **SSOT (Single Source of Truth) Design Token** 架構,透過 CSS Variables 實現風格切換,支援 6 種介面風格。正式產品視覺北極星與色票以 root `DESIGN.md` 為準;runtime 實作以 `src/config/themes.ts`、`src/config/design-tokens.ts` 與 `src/index.css` 為準。 ### 1.1 核心設計原則 1. **語義化命名**: 使用 `--color-primary`、`--color-accent` 等語義名稱,而非 `--color-blue-500` -2. **主題隔離**: 透過 `data-style` 和 `data-mode` 屬性控制主題變數 +2. **風格隔離**: 透過 `data-style` 屬性控制風格變數;現行 runtime 不使用 `data-mode` 3. **FOUC 防護**: 同步腳本在 `` 中初始化主題,避免閃爍 -4. **向後相容**: 支援 Tailwind CSS 任意屬性語法 `bg-[rgb(var(--color-primary))]` +4. **語義 class 優先**: 正式頁優先使用 `bg-surface`、`text-text-muted`、`border-border` 等 Tailwind semantic class;任意 `rgb(var())` 僅保留於 token 定義、SVG/Canvas、內部展示頁與必要 fallback --- @@ -37,14 +39,14 @@ apps/ratewise/src/ ## 3. 支援的風格 -| 風格 ID | 名稱 | 主色調 | 說明 | -| --------- | ------- | ----------- | ---------------------- | -| `zen` | Zen | 紫/靛藍 | 極簡專業,適合金融應用 | -| `nitro` | Nitro | 青色/霓虹 | 深色科技感 | -| `kawaii` | Kawaii | 粉紅/珊瑚 | 可愛粉嫩 | -| `classic` | Classic | 琥珀/棕色 | 復古書卷 | -| `ocean` | Ocean | 青藍/海洋藍 | 海洋深邃 | -| `forest` | Forest | 翠綠/森林綠 | 自然森林 | +| 風格 ID | 名稱 | 主色調 | 說明 | +| --------- | ------- | ----------- | ------------------------ | +| `zen` | Zen | 冷靜藍系 | 極簡專業,為產品預設基準 | +| `nitro` | Nitro | 青色/霓虹 | 深色科技感 | +| `kawaii` | Kawaii | 粉紅/珊瑚 | 可愛粉嫩 | +| `classic` | Classic | 琥珀/棕色 | 復古書卷 | +| `ocean` | Ocean | 青藍/海洋藍 | 海洋深邃 | +| `forest` | Forest | 翠綠/森林綠 | 自然森林 | --- @@ -95,12 +97,7 @@ apps/ratewise/src/ [data-style="ocean"] { ... } [data-style="forest"] { ... } -/* 模式選擇器 */ -[data-mode="light"] { ... } -[data-mode="dark"] { ... } - -/* 組合選擇器 */ -[data-style="zen"][data-mode="dark"] { ... } +/* 注意:現行 runtime 已移除 data-mode / dark mode。 */ ``` --- @@ -110,15 +107,10 @@ apps/ratewise/src/ ### 5.1 在組件中使用 ```tsx -// 使用 Tailwind 任意屬性 -
- Primary Background -
- -// 使用 CSS-in-JS -
- Primary Background -
+// 正式頁優先使用 semantic class +
Primary Background
+ +// 例外:色票預覽、SVG/Canvas 或 fallback HTML 才直接讀 CSS variable。 ``` ### 5.2 在 Hook 中使用 @@ -127,7 +119,7 @@ apps/ratewise/src/ import { useAppTheme } from '../hooks/useAppTheme'; function MyComponent() { - const { style, mode, setStyle, setMode, toggleMode } = useAppTheme(); + const { style, setStyle, resetTheme } = useAppTheme(); return ; } @@ -164,30 +156,16 @@ function TrendChart() { (function () { var STORAGE_KEY = 'ratewise-theme'; var DEFAULT_STYLE = 'zen'; - var DEFAULT_MODE = 'light'; - try { var stored = localStorage.getItem(STORAGE_KEY); if (stored) { var config = JSON.parse(stored); var style = config.style || DEFAULT_STYLE; - var mode = config.mode || DEFAULT_MODE; - - // 處理自動模式 - if (mode === 'auto') { - mode = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; - } document.documentElement.dataset.style = style; - document.documentElement.dataset.mode = mode; - - if (mode === 'dark') { - document.documentElement.classList.add('dark'); - } } } catch (e) { document.documentElement.dataset.style = DEFAULT_STYLE; - document.documentElement.dataset.mode = DEFAULT_MODE; } })(); @@ -203,7 +181,6 @@ const isFirstMount = useRef(true); useEffect(() => { if (isFirstMount.current) { isFirstMount.current = false; - setIsLoaded(true); return; // 首次掛載不重新應用主題 } applyTheme(config); @@ -228,12 +205,13 @@ useEffect(() => { - `src/config/__tests__/theme-consistency.test.ts` - 主題一致性測試 - `src/config/__tests__/seo-paths.test.ts` - SEO 路徑測試 -- 瀏覽器 QA 測試 - 6 種風格 + 淺/深模式組合 +- 瀏覽器 QA 測試 - 6 種風格 + PWA safe-area / responsive header 檢查 --- ## 9. 變更歷史 -| 版本 | 日期 | 變更內容 | -| ----- | ---------- | ------------------------------------- | -| 1.0.0 | 2026-01-17 | 初始文檔建立,包含 6 種風格 SSOT 架構 | +| 版本 | 日期 | 變更內容 | +| ----- | ---------- | --------------------------------------------------------------------------------------------------- | +| 1.1.0 | 2026-05-23 | 對齊現行僅 `data-style` 架構,移除文件中的 `data-mode` 操作說明,補充正式頁 semantic class 優先規則 | +| 1.0.0 | 2026-01-17 | 初始文檔建立,包含 6 種風格 SSOT 架構 | diff --git a/apps/ratewise/index.html b/apps/ratewise/index.html index c5cf65faf..e703bc4b9 100644 --- a/apps/ratewise/index.html +++ b/apps/ratewise/index.html @@ -4,7 +4,7 @@ - + @@ -36,9 +36,6 @@ - - - @@ -47,6 +44,24 @@ + + + diff --git a/apps/ratewise/seo-paths.config.mjs b/apps/ratewise/seo-paths.config.mjs index 81e29d592..ceb777653 100644 --- a/apps/ratewise/seo-paths.config.mjs +++ b/apps/ratewise/seo-paths.config.mjs @@ -308,8 +308,8 @@ export function getIncludedRoutes(paths) { export const SITE_CONFIG = { url: withTrailingSlash('https://app.haotool.org/ratewise/'), name: APP_INFO.name, - title: `${APP_INFO.shortName} — 台灣最精準匯率換算器`, - description: `${APP_INFO.shortName} 是台灣最精準的匯率換算工具,顯示臺灣銀行牌告的實際買入賣出價(非中間價),讓你換匯前清楚知道要付多少台幣。支援 18 種貨幣即時換算、現金/即期匯率切換、7-30 天歷史趨勢圖、PWA 離線使用,每 5 分鐘自動同步,免費無廣告無註冊。`, + title: `${APP_INFO.shortName} — 台銀牌告買賣價匯率換算器`, + description: `${APP_INFO.shortName} 以臺灣銀行牌告買賣價為核心,顯示實際買入賣出價(非中間價),協助換匯前參考估算台幣成本。支援 18 種貨幣換算、現金/即期匯率切換、7-30 天歷史趨勢圖、PWA 離線使用,約每 5 分鐘檢查更新,免費無廣告無註冊。`, }; /** diff --git a/apps/ratewise/src/__tests__/markdown-mirror.test.ts b/apps/ratewise/src/__tests__/markdown-mirror.test.ts index 9315e8be4..b3024be5f 100644 --- a/apps/ratewise/src/__tests__/markdown-mirror.test.ts +++ b/apps/ratewise/src/__tests__/markdown-mirror.test.ts @@ -135,7 +135,7 @@ describe('Authority guide Markdown mirrors', () => { const authorityGuides = [ { slug: 'sell-rate-vs-mid-rate', - heading: '賣出價比中間價更接近你真正要付的台幣', + heading: '賣出價比中間價更接近臨櫃換匯成本', keyContent: '中間價', faqKeyword: '換匯', }, diff --git a/apps/ratewise/src/__tests__/sw.test.ts b/apps/ratewise/src/__tests__/sw.test.ts index 50c167314..17e34de00 100644 --- a/apps/ratewise/src/__tests__/sw.test.ts +++ b/apps/ratewise/src/__tests__/sw.test.ts @@ -9,7 +9,52 @@ * [test:2026-01-10] PWA 離線功能測試 */ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest'; + +const { matchPrecacheMock, navigationHandlerRef } = vi.hoisted(() => ({ + matchPrecacheMock: vi.fn(), + navigationHandlerRef: { + current: null as + | ((params: { event: ExtendableEvent; request: Request }) => Promise) + | null, + }, +})); + +vi.mock('workbox-core', () => ({ + clientsClaim: vi.fn(), +})); + +vi.mock('workbox-precaching', () => ({ + cleanupOutdatedCaches: vi.fn(), + matchPrecache: (...args: unknown[]) => matchPrecacheMock(...args), + precacheAndRoute: vi.fn(), +})); + +vi.mock('workbox-routing', () => ({ + NavigationRoute: class NavigationRoute { + constructor( + handler: (params: { event: ExtendableEvent; request: Request }) => Promise, + ) { + navigationHandlerRef.current = handler; + } + }, + registerRoute: vi.fn(), + setCatchHandler: vi.fn(), +})); + +vi.mock('workbox-strategies', () => ({ + CacheFirst: class CacheFirst {}, + NetworkOnly: class NetworkOnly {}, + StaleWhileRevalidate: class StaleWhileRevalidate {}, +})); + +vi.mock('workbox-cacheable-response', () => ({ + CacheableResponsePlugin: class CacheableResponsePlugin {}, +})); + +vi.mock('workbox-expiration', () => ({ + ExpirationPlugin: class ExpirationPlugin {}, +})); // Mock ServiceWorkerGlobalScope const mockScope = 'https://example.com/ratewise/'; @@ -151,22 +196,28 @@ describe('Service Worker Cache Strategies', () => { 'static-resources': { strategy: 'CacheFirst', maxAge: 30 * 24 * 60 * 60 }, }; - it('should use NavigationRoute + bounded SWR-style handler for zero-white-screen navigation', async () => { + it('should use NavigationRoute + hybrid SWR + precache-first handler for zero-white-screen navigation', async () => { const fs = await import('node:fs/promises'); const path = await import('node:path'); const swPath = path.resolve(__dirname, '../sw.ts'); const sourceCode = await fs.readFile(swPath, 'utf-8'); - // 已 install 過的 PWA:暖快取 SWR;冷快取 precache-first,避免 3s timeout 假離線。 + // 暖快取:cache hit 立即返回,背景 revalidate(SWR)。 + // 冷快取:precache index.html 立即回傳,背景抓最新版本寫入 html-cache。 + // 避免 3s timeout 在 iOS precache 被驅逐時錯誤回傳 offline.html 給在線用戶。 expect(sourceCode).toContain('handleNavigationRequest'); expect(sourceCode).toContain('new NavigationRoute(handleNavigationRequest)'); - expect(sourceCode).toContain('event.waitUntil('); expect(sourceCode).toContain('fetchAndCacheNavigation(request, cache)'); expect(sourceCode).toContain("matchPrecache('index.html')"); // 防回歸:禁止重新引入 NetworkFirst navigation(cold-start 白屏根因之一)。 expect(sourceCode).not.toContain('new NetworkFirst('); - expect(sourceCode).not.toContain('NAVIGATION_NETWORK_TIMEOUT_MS'); + // 防回歸:禁止重新引入 3s 全域 navigation timeout(iOS eviction 假離線根因)。 + expect(sourceCode).not.toContain('const NAVIGATION_NETWORK_TIMEOUT_MS'); + expect(sourceCode).not.toContain('Promise.race([networkResponse, timeoutFallback])'); + // case 3(precache 已 miss)允許 8s bounded race,避免 hung network 無限白屏。 + expect(sourceCode).toContain('const NAVIGATION_FETCH_TIMEOUT_MS = 8000'); + expect(sourceCode).toContain('navigation-fetch-timeout'); }); it('should have correct historical rates cache configuration', () => { @@ -192,6 +243,18 @@ describe('Service Worker Cache Strategies', () => { expect(sourceCode).toContain('new NetworkOnly('); }); + it('should cache both legacy and provider-scoped aggregate history routes', async () => { + const fs = await import('node:fs/promises'); + const path = await import('node:path'); + + const swPath = path.resolve(__dirname, '../sw.ts'); + const sourceCode = await fs.readFile(swPath, 'utf-8'); + + expect(sourceCode).toContain('/public/rates/history-30d.json'); + expect(sourceCode).toContain('/public/rates/providers/moneybox/history-30d.json'); + expect(sourceCode).toContain("cacheName: 'history-aggregate-cache'"); + }); + // 🔴 RED: JS/CSS 應使用 CacheFirst(Vite hash-based filenames 是 immutable) it('should use CacheFirst for JS/CSS static resources (hash-based filenames are immutable)', async () => { const fs = await import('node:fs/promises'); @@ -235,14 +298,15 @@ describe('Service Worker Cache Strategies', () => { const swPath = path.resolve(__dirname, '../sw.ts'); const sourceCode = await fs.readFile(swPath, 'utf-8'); - // precache-first navigation → resolveOfflineDocumentFallback helper(含三層 fallback + emergency HTML)。 + // precache-first SWR navigation → resolveOfflineDocumentFallback helper(含三層 fallback + emergency HTML)。 + // 注意:已從 Promise.race timeout 改為 precache-first 方案(避免 iOS precache 被驅逐時誤回 offline.html)。 expect(sourceCode).toContain('new NavigationRoute('); expect(sourceCode).toContain('resolveOfflineDocumentFallback'); expect(sourceCode).toContain("emergencyReason: 'emergency-navigation-fallback'"); expect(sourceCode).toContain("matchPrecache('index.html')"); + expect(sourceCode).toContain('fetchAndCacheNavigation'); // 防回歸:navigation 不可重新引入 NetworkFirst(cold-start 白屏根因之一)。 expect(sourceCode).not.toContain('new NetworkFirst('); - expect(sourceCode).not.toContain('NAVIGATION_NETWORK_TIMEOUT_MS'); }); it('should clear stale navigation HTML runtime cache when a new worker activates', async () => { @@ -381,3 +445,118 @@ describe('Service Worker Denylist', () => { expect(isDenied('/faq')).toBe(false); }); }); + +describe('handleNavigationRequest', () => { + const htmlCacheName = 'html-cache'; + const navigationUrl = 'https://example.com/ratewise/about'; + const offlineHtml = 'offline fallback'; + + let htmlCache: { + match: ReturnType; + put: ReturnType; + }; + let cachesOpen: ReturnType; + let cachesMatch: ReturnType; + + beforeAll(async () => { + htmlCache = { + match: vi.fn(), + put: vi.fn(), + }; + cachesOpen = vi.fn().mockResolvedValue(htmlCache); + cachesMatch = vi.fn().mockResolvedValue(undefined); + + vi.stubGlobal('caches', { + open: cachesOpen, + match: cachesMatch, + keys: vi.fn().mockResolvedValue([]), + delete: vi.fn(), + }); + + await import('../sw.ts'); + expect(navigationHandlerRef.current).not.toBeNull(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + cachesOpen.mockResolvedValue(htmlCache); + cachesMatch.mockResolvedValue(undefined); + htmlCache.match.mockReset(); + htmlCache.put.mockReset(); + matchPrecacheMock.mockReset(); + }); + + function createNavigationEvent(): ExtendableEvent { + return { waitUntil: vi.fn() } as unknown as ExtendableEvent; + } + + function createOfflineFallbackResponse(): Response { + return new Response(offlineHtml, { + status: 200, + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }); + } + + it('case 2: precache hit resolves instantly without timer dependency', async () => { + vi.useFakeTimers(); + + const precachedShell = new Response('precached index', { + status: 200, + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }); + + htmlCache.match.mockResolvedValue(undefined); + matchPrecacheMock.mockImplementation((url: string) => + Promise.resolve(url === 'index.html' ? precachedShell : null), + ); + vi.stubGlobal( + 'fetch', + vi.fn(() => new Promise(() => undefined)), + ); + + const handler = navigationHandlerRef.current!; + const response = await handler({ + event: createNavigationEvent(), + request: new Request(navigationUrl), + }); + + expect(response).toBe(precachedShell); + expect(matchPrecacheMock).toHaveBeenCalledWith('index.html'); + await vi.runAllTimersAsync(); + }); + + it('case 3: hung network falls back to offline.html after bounded timeout', async () => { + vi.useFakeTimers(); + + const offlineFallback = createOfflineFallbackResponse(); + + htmlCache.match.mockResolvedValue(undefined); + matchPrecacheMock.mockImplementation((url: string) => { + if (url === 'index.html') return Promise.resolve(null); + if (url === 'offline.html') return Promise.resolve(offlineFallback); + return Promise.resolve(null); + }); + cachesMatch.mockResolvedValue(undefined); + vi.stubGlobal( + 'fetch', + vi.fn(() => new Promise(() => undefined)), + ); + + const handler = navigationHandlerRef.current!; + const responsePromise = handler({ + event: createNavigationEvent(), + request: new Request(navigationUrl), + }); + + await vi.advanceTimersByTimeAsync(8000); + + const response = await responsePromise; + const body = await response.text(); + + expect(body).toBe(offlineHtml); + expect(matchPrecacheMock).toHaveBeenCalledWith('index.html'); + expect(matchPrecacheMock).toHaveBeenCalledWith('offline.html'); + expect(cachesOpen).toHaveBeenCalledWith(htmlCacheName); + }); +}); diff --git a/apps/ratewise/src/components/AnswerCapsule.tsx b/apps/ratewise/src/components/AnswerCapsule.tsx index f09690432..d452bf058 100644 --- a/apps/ratewise/src/components/AnswerCapsule.tsx +++ b/apps/ratewise/src/components/AnswerCapsule.tsx @@ -1,4 +1,5 @@ import type { FAQEntry } from '../config/seo-metadata'; +import { contentPageTokens } from '../config/design-tokens'; interface AnswerCapsuleProps { title?: string; @@ -9,16 +10,14 @@ export function AnswerCapsule({ title = '快速答案', items }: AnswerCapsulePr if (items.length === 0) return null; return ( -
-

{title}

+
+

即時重點

+

{title}

{items.map((item) => ( -
+

{item.question}

-

{item.answer}

+

{item.answer}

))}
diff --git a/apps/ratewise/src/components/AppLayout.tsx b/apps/ratewise/src/components/AppLayout.tsx index b34238642..4c9f3f4fe 100644 --- a/apps/ratewise/src/components/AppLayout.tsx +++ b/apps/ratewise/src/components/AppLayout.tsx @@ -43,19 +43,12 @@ function AppLazyGlobalPrompts({ void attempt; return React.lazy(() => import('./RatingModal').then((m) => ({ default: m.RatingModal }))); }, [attempt]); - const LazyPwaInstallGuide = React.useMemo(() => { - void attempt; - return React.lazy(() => - import('./PwaInstallGuide').then((m) => ({ default: m.PwaInstallGuide })), - ); - }, [attempt]); return ( - ); } @@ -99,8 +92,8 @@ function Header() {
-
+
{/* 品牌 Logo + 標題(使用 span 而非 h1,避免每頁重複 h1)*/} -
+
- - {isZhTW ? APP_INFO.name : t('app.title')} + + {isZhTW ? APP_INFO.shortName : t('app.title')}
@@ -208,15 +193,9 @@ export function AppLayout() { * 使用 --app-height(由 JS 設定)而非 100dvh,確保 WebView 環境高度正確。 * Fallback:100dvh(JS 尚未執行時,或 SSG 初始渲染)。 */}
- {location.pathname === '/' ? ( -
-

{HOMEPAGE_SEO.content.heading}

-
- ) : null} - {/* Desktop sidebar (≥768px) */}
@@ -231,7 +210,7 @@ export function AppLayout() {
+ {location.pathname === '/' ? ( +
+

{HOMEPAGE_SEO.content.heading}

+
+ ) : null} {/* enter-only:key 變化觸發 remount,新頁面從方向滑入淡入(CSS @keyframes) */}
-
-
- {/* 頁面頂部導航:返回 + 麵包屑(PageNavHeader SSOT 模組)。 */} +
+
-
-

{page.heading}

-

{page.intro}

+
+

專題指南

+

{page.heading}

+

{page.intro}

-
-

重點整理

-