diff --git a/.changeset/rw3b-offline-template-theme-aware.md b/.changeset/rw3b-offline-template-theme-aware.md new file mode 100644 index 000000000..1d46f1707 --- /dev/null +++ b/.changeset/rw3b-offline-template-theme-aware.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +離線頁改為主題感知:依使用者主題顯示對應底色(深色主題不再閃淺紫),並保留斷網自我修復;同步狀態列顏色與 iOS 安全區。 diff --git a/.prettierignore b/.prettierignore index 194e97624..23dd34c6e 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,6 +2,9 @@ node_modules/ .pnpm-store/ +# 由 generate-offline-html.mjs 從模板生成(prebuild 禁 prettier,避免格式漂移) +apps/ratewise/public/offline.html + # Build output dist/ build/ diff --git a/apps/ratewise/public/offline.html b/apps/ratewise/public/offline.html index 6c6ff95c8..5f1bb3abb 100644 --- a/apps/ratewise/public/offline.html +++ b/apps/ratewise/public/offline.html @@ -2,13 +2,176 @@ - - + + + + + 離線模式 - HaoRate + diff --git a/apps/ratewise/scripts/generate-offline-html.mjs b/apps/ratewise/scripts/generate-offline-html.mjs index fc2a9d2fa..b2e33a6a3 100644 --- a/apps/ratewise/scripts/generate-offline-html.mjs +++ b/apps/ratewise/scripts/generate-offline-html.mjs @@ -2,23 +2,124 @@ import { readFileSync, writeFileSync } from 'node:fs'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { APP_INFO } from '../src/config/app-info.ts'; +import { STYLE_DEFINITIONS } from '../src/config/themes.ts'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(__dirname, '..'); +const zenColors = STYLE_DEFINITIONS.zen.colors; +const DARK_TEXT_COLOR = '2 6 23'; +const LIGHT_TEXT_COLOR = '255 255 255'; + +function rgbTripletToHex(rgbTriplet) { + return `#${rgbTriplet + .trim() + .split(/\s+/) + .map((value) => Number.parseInt(value, 10).toString(16).padStart(2, '0')) + .join('') + .toUpperCase()}`; +} + +function rgbTripletToRgba(rgbTriplet, alpha) { + const rgb = rgbTriplet + .trim() + .split(/\s+/) + .map((value) => Number.parseInt(value, 10)) + .join(', '); + + return `rgba(${rgb}, ${alpha})`; +} + +function rgbTripletToNumbers(rgbTriplet) { + return rgbTriplet + .trim() + .split(/\s+/) + .map((value) => Number.parseInt(value, 10)); +} + +function relativeLuminance(rgbTriplet) { + const [red, green, blue] = rgbTripletToNumbers(rgbTriplet).map((channel) => { + const value = channel / 255; + return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; + }); + + return red * 0.2126 + green * 0.7152 + blue * 0.0722; +} + +function contrastRatio(foreground, background) { + const foregroundLuminosity = relativeLuminance(foreground); + const backgroundLuminosity = relativeLuminance(background); + const lighter = Math.max(foregroundLuminosity, backgroundLuminosity); + const darker = Math.min(foregroundLuminosity, backgroundLuminosity); + + return (lighter + 0.05) / (darker + 0.05); +} + +function choosePrimaryForeground(primary) { + return contrastRatio(LIGHT_TEXT_COLOR, primary) >= contrastRatio(DARK_TEXT_COLOR, primary) + ? LIGHT_TEXT_COLOR + : DARK_TEXT_COLOR; +} + +function buildOfflineStyleBlock([styleName, definition]) { + const { colors } = definition; + const primaryForeground = choosePrimaryForeground(colors.primary); + + return `html[data-style='${styleName}'] { + --offline-theme-color: ${rgbTripletToHex(colors.primary)}; + --offline-background: ${rgbTripletToHex(colors.background)}; + --offline-surface: ${rgbTripletToHex(colors.surface)}; + --offline-border: ${rgbTripletToHex(colors.border)}; + --offline-text: ${rgbTripletToHex(colors.text)}; + --offline-text-muted: ${rgbTripletToHex(colors.textMuted)}; + --offline-primary: ${rgbTripletToHex(colors.primary)}; + --offline-primary-foreground: ${rgbTripletToHex(primaryForeground)}; + --offline-secondary: ${rgbTripletToHex(colors.secondary)}; + --offline-accent: ${rgbTripletToHex(colors.accent)}; + --offline-primary-tint: ${rgbTripletToRgba(colors.primary, 0.08)}; + --offline-primary-border-tint: ${rgbTripletToRgba(colors.primary, 0.18)}; + --offline-primary-shadow-soft: ${rgbTripletToRgba(colors.primary, 0.18)}; + --offline-primary-shadow-strong: ${rgbTripletToRgba(colors.primary, 0.26)}; + --offline-primary-shadow-hover: ${rgbTripletToRgba(colors.primary, 0.34)}; + --offline-warning-tint: ${rgbTripletToRgba(colors.warning, 0.16)}; + --offline-warning: ${rgbTripletToHex(colors.warning)}; + --offline-success-tint: ${rgbTripletToRgba(colors.success, 0.12)}; + --offline-success: ${rgbTripletToHex(colors.success)}; + }`; +} + +const themeColorMap = Object.fromEntries( + Object.entries(STYLE_DEFINITIONS).map(([styleName, definition]) => [ + styleName, + rgbTripletToHex(definition.colors.primary), + ]), +); + +const offlineThemeTokens = { + __THEME_COLOR__: rgbTripletToHex(zenColors.primary), + __OFFLINE_STYLE_BLOCKS__: Object.entries(STYLE_DEFINITIONS) + .map(buildOfflineStyleBlock) + .join('\n\n '), + __OFFLINE_THEME_COLOR_MAP__: JSON.stringify(themeColorMap), +}; function substitute(template) { - return template - .replace(/__BRAND_SHORT__/g, APP_INFO.shortName) - .replace(/__BRAND_FULL__/g, APP_INFO.name); + return Object.entries(offlineThemeTokens).reduce( + (content, [token, value]) => content.replace(new RegExp(token, 'g'), value), + template + .replace(/__BRAND_SHORT__/g, APP_INFO.shortName) + .replace(/__BRAND_FULL__/g, APP_INFO.name), + ); } +// 直接寫出模板替換結果;不經 prettier(遵守 prebuild 禁 prettier、防格式漂移)。 function generate(templatePath, outPath) { const template = readFileSync(resolve(ROOT, templatePath), 'utf-8'); - writeFileSync(resolve(ROOT, outPath), substitute(template)); + const content = substitute(template); + writeFileSync(resolve(ROOT, outPath), content); console.log(` ✅ ${outPath}`); } console.log('🧾 生成靜態資源(從品牌模板)...'); -generate('scripts/templates/offline.template.html', 'public/offline.html'); -generate('scripts/templates/security.template.txt', 'public/.well-known/security.txt'); +await generate('scripts/templates/offline.template.html', 'public/offline.html'); +await generate('scripts/templates/security.template.txt', 'public/.well-known/security.txt'); console.log('✅ 靜態品牌資源生成完成'); diff --git a/apps/ratewise/scripts/templates/offline.template.html b/apps/ratewise/scripts/templates/offline.template.html index b361cfe2d..693c36aa8 100644 --- a/apps/ratewise/scripts/templates/offline.template.html +++ b/apps/ratewise/scripts/templates/offline.template.html @@ -2,13 +2,46 @@ - - + + + + + 離線模式 - __BRAND_SHORT__ + diff --git a/docs/dev/002_development_reward_penalty_log.md b/docs/dev/002_development_reward_penalty_log.md index e5bfa3b81..7828f17c5 100644 --- a/docs/dev/002_development_reward_penalty_log.md +++ b/docs/dev/002_development_reward_penalty_log.md @@ -2,7 +2,7 @@ > 版本:outline-v2-ultra > 原則:每筆只保留日期、ID、原因、解法。 -> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+89 +> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+90 ## 新增模板(4 行) @@ -13,6 +13,11 @@ ## 條目(新→舊) +- 日期:2026-06-30 +- ID:reward-rw3b-offline-template-theme-aware +- 原因:離線頁背景/容器/圖示硬編紫色,nitro/深色主題使用者斷網時閃錯誤的淺紫,與主題不一致 +- 解法:合併 #433 主題感知模板(per-theme CSS + theme-color + safe-area)但植回 main 的 #508 自我修復腳本與 retry-btn(非整檔取避免回退死亡迴圈);generator 移除 prettier(紅線);驗證 nitro 離線頁深色 #020617 且 #508 導回 app 正常 + - 日期:2026-06-30 - ID:reward-rw2-nitro-theme-contrast-fix - 原因:Nitro 深色主題 textMuted 為 slate-500(深底對比過低,次要文字看不清)、primary 為過亮 cyan(按鈕白字對比不足);themes.ts chartLine 與 index.css 漂移