From a9899dbe4e6db2112f1e14a3e746ea2a9a663060 Mon Sep 17 00:00:00 2001 From: TTigger Date: Fri, 10 Jul 2026 23:07:09 +0800 Subject: [PATCH] feat(stage-profile): midnight dark export theme (stage B item 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 出圖主題新增「午夜黑」(bg #171A21 / ink #F0EEE6 / accent #E8956D), 解掉「透明匯出疊在深色背景上看不見字」的老問題 —— 深色主題的墨色 是淺色,去背 PNG 疊在深色簡報/限動上可讀(已實際合成深灰底目測驗證)。 - THEMES / THEME_EN / ProfileTheme 抽到 src/lib/profileThemes.ts(純資料), 讓 vitest 守門測試吃得到;StageProfile.tsx 改為 import - 新增 profileThemes.test.ts:用既有 contrast.ts 對每個主題驗 WCAG 對比 (ink ≥ 7、muted ≥ 4.4、accent ≥ 2.2,門檻取自現況實測下限), 並要求至少一個深色主題且其墨色為淺色 - e2e:主題切換測試補午夜黑底色斷言 測試:typecheck:api ✓ / 513 unit ✓(+5)/ 54 e2e ✓ Co-Authored-By: Claude Fable 5 --- e2e/stage-profile.spec.ts | 3 ++ src/lib/__tests__/profileThemes.test.ts | 50 +++++++++++++++++++++++++ src/lib/profileThemes.ts | 31 +++++++++++++++ src/tools/StageProfile.tsx | 30 +-------------- 4 files changed, 86 insertions(+), 28 deletions(-) create mode 100644 src/lib/__tests__/profileThemes.test.ts create mode 100644 src/lib/profileThemes.ts diff --git a/e2e/stage-profile.spec.ts b/e2e/stage-profile.spec.ts index a5b1474..c52aede 100644 --- a/e2e/stage-profile.spec.ts +++ b/e2e/stage-profile.spec.ts @@ -86,6 +86,9 @@ test('上傳 GPX → 生成剖面圖與爬坡分級 → 下載 PNG', async ({ pa // 出圖主題切換 → SVG 底色跟著換(環義粉 #FFF6FA) await page.getByRole('button', { name: '環義粉' }).click(); await expect(page.getByRole('img', { name: '賽段剖面圖' }).locator('rect').first()).toHaveAttribute('fill', '#FFF6FA'); + // 深色主題(午夜黑 #171A21) + await page.getByRole('button', { name: '午夜黑' }).click(); + await expect(page.getByRole('img', { name: '賽段剖面圖' }).locator('rect').first()).toHaveAttribute('fill', '#171A21'); await page.getByRole('button', { name: 'Tiglet 暖橘' }).click(); await expect(page.getByRole('img', { name: '賽段剖面圖' }).locator('rect').first()).toHaveAttribute('fill', '#FAF9F5'); diff --git a/src/lib/__tests__/profileThemes.test.ts b/src/lib/__tests__/profileThemes.test.ts new file mode 100644 index 0000000..2bfa4d5 --- /dev/null +++ b/src/lib/__tests__/profileThemes.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { THEMES, THEME_EN } from '../profileThemes'; +import { contrastRatio, relativeLuminance } from '../contrast'; + +// 出圖主題的可讀性守門:新主題(尤其深色)不能比現有主題最低水準更差。 +// 門檻取自現況實測的下限(環義粉 muted 4.49、環法黃 accent 2.23), +// 不是理想值 —— 收緊門檻前要先動既有主題。 + +describe('profile themes', () => { + it('每個主題都有完整色票與英文名', () => { + for (const th of THEMES) { + expect(th.id).toMatch(/^[a-z]+$/); + expect(th.label.length).toBeGreaterThan(0); + expect(THEME_EN[th.id], `THEME_EN 缺 ${th.id}`).toBeTruthy(); + for (const c of [th.bg, th.ink, th.muted, th.grid, th.accent]) { + expect(c).toMatch(/^#[0-9A-Fa-f]{6}$/); + } + } + }); + + it('id 不重複', () => { + const ids = THEMES.map((t) => t.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it('標題與座標文字在底色上讀得清楚', () => { + for (const th of THEMES) { + expect(contrastRatio(th.ink, th.bg), `${th.id} ink`).toBeGreaterThanOrEqual(7); + expect(contrastRatio(th.muted, th.bg), `${th.id} muted`).toBeGreaterThanOrEqual(4.4); + expect(contrastRatio(th.accent, th.bg), `${th.id} accent`).toBeGreaterThanOrEqual(2.2); + } + }); + + it('格線壓得住但看得見(比底色明顯、比正文淡)', () => { + for (const th of THEMES) { + const grid = contrastRatio(th.grid, th.bg); + expect(grid, `${th.id} grid too faint`).toBeGreaterThanOrEqual(1.2); + expect(grid, `${th.id} grid too loud`).toBeLessThan(contrastRatio(th.muted, th.bg)); + } + }); + + it('至少有一個深色主題(透明匯出疊深色背景時字才看得見)', () => { + const dark = THEMES.filter((t) => relativeLuminance(t.bg) < 0.2); + expect(dark.length).toBeGreaterThanOrEqual(1); + for (const th of dark) { + // 深色主題的墨色必須是淺色,去背 PNG 疊在深色簡報/限動上才可讀 + expect(relativeLuminance(th.ink), `${th.id} ink 應為淺色`).toBeGreaterThan(0.7); + } + }); +}); diff --git a/src/lib/profileThemes.ts b/src/lib/profileThemes.ts new file mode 100644 index 0000000..360cd0a --- /dev/null +++ b/src/lib/profileThemes.ts @@ -0,0 +1,31 @@ +// 賽段剖面圖的出圖主題(純資料,供 StageProfile 與測試共用)。 +// SVG 用固定色票(非 CSS 變數),匯出的 PNG 才不會受網站深淺色主題影響。 +// 出圖主題只換底/墨/點綴色,坡度色階是語意(多陡),不隨主題變。 + +export interface ProfileTheme { + id: string; + label: string; + bg: string; + ink: string; + muted: string; + grid: string; + accent: string; +} + +export const THEMES: ProfileTheme[] = [ + { id: 'tiglet', label: 'Tiglet 暖橘', bg: '#FAF9F5', ink: '#1A1A18', muted: '#6B6A63', grid: '#D6D1C4', accent: '#D97757' }, + { id: 'tour', label: '環法黃', bg: '#FFFFFF', ink: '#14141E', muted: '#5A5A66', grid: '#E2E2E8', accent: '#D4A800' }, + { id: 'giro', label: '環義粉', bg: '#FFF6FA', ink: '#2B1A22', muted: '#8A6A78', grid: '#F0D4E2', accent: '#E5518D' }, + { id: 'vuelta', label: '環西紅', bg: '#FFFAF6', ink: '#241514', muted: '#8A6A62', grid: '#F0DCD0', accent: '#DA291C' }, + // 深色主題:墨色是淺色,所以「去背匯出疊在深色背景」也讀得清楚 + { id: 'night', label: '午夜黑', bg: '#171A21', ink: '#F0EEE6', muted: '#A9A79E', grid: '#3A3E48', accent: '#E8956D' }, +]; + +// 出圖主題名稱的英文(按鈕 UI 用;id 對映,不動 THEMES 本體) +export const THEME_EN: Record = { + tiglet: 'Tiglet warm', + tour: 'Tour yellow', + giro: 'Giro pink', + vuelta: 'Vuelta red', + night: 'Midnight black', +}; diff --git a/src/tools/StageProfile.tsx b/src/tools/StageProfile.tsx index 09f5c1c..f012414 100644 --- a/src/tools/StageProfile.tsx +++ b/src/tools/StageProfile.tsx @@ -4,6 +4,7 @@ import ShareLinkButton from '../components/ShareLinkButton'; import Tabs from '../components/Tabs'; import Toggle from '../components/Toggle'; import { simplifyProfile, encodeRouteShare, decodeRouteShare } from '../lib/routeShare'; +import { THEMES, THEME_EN, type ProfileTheme } from '../lib/profileThemes'; import type { Locale } from '../lib/i18n'; import { parseGpx, @@ -312,34 +313,7 @@ const L = { type Dict = (typeof L)[Locale]; -// 出圖主題名稱的英文(按鈕 UI 用;id 對映,不動 THEMES 本體) -const THEME_EN: Record = { - tiglet: 'Tiglet warm', - tour: 'Tour yellow', - giro: 'Giro pink', - vuelta: 'Vuelta red', -}; - -// 檔案完全在本機解析;SVG 用固定色票(非 CSS 變數), -// 匯出的 PNG 才不會受深淺色主題影響。出圖主題只換底/墨/點綴色, -// 坡度色階是語意(多陡),不隨主題變。 - -export interface ProfileTheme { - id: string; - label: string; - bg: string; - ink: string; - muted: string; - grid: string; - accent: string; -} - -const THEMES: ProfileTheme[] = [ - { id: 'tiglet', label: 'Tiglet 暖橘', bg: '#FAF9F5', ink: '#1A1A18', muted: '#6B6A63', grid: '#D6D1C4', accent: '#D97757' }, - { id: 'tour', label: '環法黃', bg: '#FFFFFF', ink: '#14141E', muted: '#5A5A66', grid: '#E2E2E8', accent: '#D4A800' }, - { id: 'giro', label: '環義粉', bg: '#FFF6FA', ink: '#2B1A22', muted: '#8A6A78', grid: '#F0D4E2', accent: '#E5518D' }, - { id: 'vuelta', label: '環西紅', bg: '#FFFAF6', ink: '#241514', muted: '#8A6A62', grid: '#F0DCD0', accent: '#DA291C' }, -]; +// 出圖主題資料在 src/lib/profileThemes.ts(純資料抽成 lib,讓對比守門測試吃得到) const W = 840; const H = 380;