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 漂移