diff --git a/.gitignore b/.gitignore index 438bcfe..867c58a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,8 @@ coverage .vercel .DS_Store .env* + +# IDD tree-lock (idd-tree-lock.sh #183) — per-machine session state, not tracked +.claude/.idd/tree-lock +.claude/.idd/state/ +.claude/.idd/attachments/ diff --git a/README.md b/README.md index 983d50f..7c67b88 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ 做完按「複製測試結果」,JSON 會複製到剪貼簿(並下載備份),可貼給 AI 或維修人員判讀。 +介面深淺主題自動跟隨系統外觀(`prefers-color-scheme`),深色為預設設計、淺色完整支援。 + ### 判讀基準 - 事件頻率:健康值約 90–120 Hz(120 為硬體上限) @@ -50,6 +52,8 @@ Five interactive checks covering the five failure modes of a MacBook trackpad. T When done, press "Copy results": a JSON report is copied to the clipboard (and downloaded) — paste it to an AI or a technician. +The UI follows your system appearance automatically (`prefers-color-scheme`) — dark is the primary design, light fully supported. + ### Interpretation baseline - Event rate: ~90–120 Hz is healthy (120 is the hardware cap) diff --git a/package-lock.json b/package-lock.json index 5dec952..850b241 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "mac-trackpad-test", "version": "1.0.0", "devDependencies": { + "@types/node": "^26.1.1", "@vitest/coverage-v8": "^4.1.10", "jsdom": "^29.1.1", "typescript": "^7.0.2", @@ -713,6 +714,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, "node_modules/@typescript/typescript-aix-ppc64": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", @@ -2192,6 +2203,13 @@ "node": ">=20.18.1" } }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "8.1.4", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", diff --git a/package.json b/package.json index 80b44eb..6c5da73 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "coverage": "vitest run --coverage" }, "devDependencies": { + "@types/node": "^26.1.1", "@vitest/coverage-v8": "^4.1.10", "jsdom": "^29.1.1", "typescript": "^7.0.2", diff --git a/src/panels/trail.ts b/src/panels/trail.ts index 054d269..63fa6c2 100644 --- a/src/panels/trail.ts +++ b/src/panels/trail.ts @@ -1,6 +1,7 @@ import { detectStep, type Point } from '../core/jump-detector'; import type { Session } from '../core/session'; import { t } from '../i18n'; +import { cssColor } from '../theme'; export function mountTrail(el: HTMLElement, session: Session): void { el.innerHTML = ` @@ -16,6 +17,9 @@ export function mountTrail(el: HTMLElement, session: Session): void { const canvas = el.querySelector('canvas') as HTMLCanvasElement; const ctx2d = canvas.getContext('2d'); // jsdom 回 null,繪圖跳過、邏輯照常 + // mount 時解析一次主題色;jsdom / 舊瀏覽器退 fallback 固定色 + const colorStroke = cssColor('--blue', '#0a84ff'); + const colorJump = cssColor('--red', '#ff453a'); const hzEl = el.querySelector('.hz') as HTMLElement; const maxJumpEl = el.querySelector('.maxjump') as HTMLElement; const jumpsEl = el.querySelector('.jumps') as HTMLElement; @@ -60,7 +64,7 @@ export function mountTrail(el: HTMLElement, session: Session): void { jumpsEl.textContent = String(session.trail.jumps.length); } if (ctx2d && last) { - ctx2d.strokeStyle = step.isJump ? '#ff453a' : '#0a84ff'; + ctx2d.strokeStyle = step.isJump ? colorJump : colorStroke; ctx2d.beginPath(); ctx2d.moveTo(last.x, last.y); ctx2d.lineTo(curr.x, curr.y); diff --git a/src/styles.css b/src/styles.css index 5093dde..672658c 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,58 +1,106 @@ -:root { color-scheme: dark; } +:root { + color-scheme: light dark; + /* 深色(預設)色板 — iOS 系統色系 */ + --bg: #1c1c1e; + --card: #2c2c2e; + --inset: #1c1c1e; + --text: #f2f2f7; + --text-dim: #98989f; + --blue: #0a84ff; + --green: #30d158; + --red: #ff453a; + --yellow: #ffd60a; + --btn-bg: #48484a; + --btn-text: #f2f2f7; + --banner-bg: #3a2f00; + --banner-text: #ffd60a; + --cell-left-bg: #1f4d2e; + --cell-left-text: #7ee2a0; + --cell-both-bg: #1e3a5f; + --cell-both-text: #8ec9ff; + --on-accent: #f2f2f7; + --thresh: #f2f2f7aa; +} + +@media (prefers-color-scheme: light) { + :root { + --bg: #f2f2f7; + --card: #ffffff; + --inset: #e5e5ea; + --text: #1c1c1e; + --text-dim: #5a5a5f; + --blue: #0071e3; + --green: #1d7a3d; + --red: #d70015; + --yellow: #b25000; + --btn-bg: #d1d1d6; + --btn-text: #1c1c1e; + --banner-bg: #fff4cc; + --banner-text: #7a5200; + --cell-left-bg: #d7f5e0; + --cell-left-text: #1d7a3d; + --cell-both-bg: #d6e9ff; + --cell-both-text: #1d5fa0; + --on-accent: #ffffff; + --thresh: #1c1c1eaa; + } +} + * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: -apple-system, "PingFang TC", sans-serif; - background: #1c1c1e; color: #f2f2f7; + background: var(--bg); color: var(--text); padding: 24px; max-width: 900px; margin: 0 auto; } header { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; margin-bottom: 20px; } h1 { font-size: 22px; margin-bottom: 4px; } -.sub { color: #98989f; font-size: 13px; } +.sub { color: var(--text-dim); font-size: 13px; } .lang-btn { - background: #48484a; color: #f2f2f7; border: none; border-radius: 6px; + background: var(--btn-bg); color: var(--btn-text); border: none; border-radius: 6px; padding: 6px 14px; font-size: 13px; cursor: pointer; flex-shrink: 0; } .banner { - background: #3a2f00; color: #ffd60a; border-radius: 10px; + background: var(--banner-bg); color: var(--banner-text); border-radius: 10px; padding: 12px 16px; font-size: 13px; margin-bottom: 16px; } -section { background: #2c2c2e; border-radius: 12px; padding: 16px; margin-bottom: 16px; } +section { background: var(--card); border-radius: 12px; padding: 16px; margin-bottom: 16px; } h2 { font-size: 15px; margin-bottom: 4px; } -.hint { color: #98989f; font-size: 12px; margin-bottom: 10px; } +.hint { color: var(--text-dim); font-size: 12px; margin-bottom: 10px; } .notice-card { - background: #1c1c1e; border-radius: 8px; padding: 14px; - font-size: 13px; color: #98989f; margin-top: 8px; + background: var(--inset); border-radius: 8px; padding: 14px; + font-size: 13px; color: var(--text-dim); margin-top: 8px; display: flex; flex-direction: column; gap: 4px; } -.notice-card b { color: #ffd60a; } -.trail-canvas { background: #1c1c1e; border-radius: 8px; display: block; width: 100%; touch-action: none; } +.notice-card b { color: var(--banner-text); } +.trail-canvas { background: var(--inset); border-radius: 8px; display: block; width: 100%; touch-action: none; } button { - background: #48484a; color: #f2f2f7; border: none; border-radius: 6px; + background: var(--btn-bg); color: var(--btn-text); border: none; border-radius: 6px; padding: 4px 12px; font-size: 12px; cursor: pointer; margin-top: 8px; } .grid3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; height: 220px; } .cell { - background: #1c1c1e; border-radius: 8px; display: flex; + background: var(--inset); border-radius: 8px; display: flex; align-items: center; justify-content: center; - font-size: 12px; color: #98989f; user-select: none; -webkit-user-select: none; + font-size: 12px; color: var(--text-dim); user-select: none; -webkit-user-select: none; } -.cell.leftdone { background: #1f4d2e; color: #7ee2a0; } -.cell.bothdone { background: #1e3a5f; color: #8ec9ff; } +.cell.leftdone { background: var(--cell-left-bg); color: var(--cell-left-text); } +.cell.bothdone { background: var(--cell-both-bg); color: var(--cell-both-text); } .stats { display: flex; gap: 18px; flex-wrap: wrap; margin-top: 10px; font-size: 13px; } .stats b { font-variant-numeric: tabular-nums; } -.ok { color: #30d158; } -.forcebar-wrap { background: #1c1c1e; border-radius: 8px; height: 26px; overflow: hidden; position: relative; } +.ok { color: var(--green); } +.forcebar-wrap { background: var(--inset); border-radius: 8px; height: 26px; overflow: hidden; position: relative; } .forcebar { - background: linear-gradient(90deg, #30d158, #ffd60a, #ff453a); + background: linear-gradient(90deg, var(--green), var(--yellow), var(--red)); height: 100%; width: 100%; transform: scaleX(0); transform-origin: left; } -.force-thresh { position: absolute; top: 0; bottom: 0; left: 33%; width: 2px; background: #f2f2f7aa; } +.force-thresh { position: absolute; top: 0; bottom: 0; left: 33%; width: 2px; background: var(--thresh); } .scrollbox { - height: 140px; overflow: scroll; background: #1c1c1e; border-radius: 8px; - padding: 10px; font-size: 12px; color: #98989f; line-height: 2.2; + height: 140px; overflow: scroll; background: var(--inset); border-radius: 8px; + padding: 10px; font-size: 12px; color: var(--text-dim); line-height: 2.2; } -.pinchbox { height: 120px; background: #1c1c1e; border-radius: 8px; display: flex; align-items: center; justify-content: center; } +.pinchbox { height: 120px; background: var(--inset); border-radius: 8px; display: flex; align-items: center; justify-content: center; } .pinchtarget { - width: 60px; height: 60px; background: #0a84ff; border-radius: 12px; + width: 60px; height: 60px; background: var(--blue); border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 11px; + color: var(--on-accent); } diff --git a/src/theme.ts b/src/theme.ts new file mode 100644 index 0000000..d4139e3 --- /dev/null +++ b/src/theme.ts @@ -0,0 +1,12 @@ +// 讀取 CSS 自訂屬性。canvas 繪圖不吃 CSS 變數,需在 JS 端解析。 +// jsdom 的 computed style 可讀 inline 自訂屬性,但測試環境不載入 styles.css, +// 變數解析為空字串 → 退 fallback;SSR / 極舊環境無 getComputedStyle 同樣退 fallback。 +export function cssColor(varName: string, fallback: string): string { + if (typeof window === 'undefined' || typeof getComputedStyle !== 'function') { + return fallback; + } + const computed = getComputedStyle(document.documentElement) + .getPropertyValue(varName) + .trim(); + return computed || fallback; +} diff --git a/tests/contrast.test.ts b/tests/contrast.test.ts new file mode 100644 index 0000000..cf19ef6 --- /dev/null +++ b/tests/contrast.test.ts @@ -0,0 +1,88 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +// WCAG 2.x relative luminance + contrast ratio(機械驗證,取代人工判讀) +function srgbToLinear(c: number): number { + const v = c / 255; + return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; +} + +function luminance(hex: string): number { + const m = /^#([0-9a-f]{6})$/i.exec(hex.trim()); + if (!m) throw new Error(`非 6 位 hex:${hex}`); + const n = parseInt(m[1], 16); + const r = srgbToLinear((n >> 16) & 0xff); + const g = srgbToLinear((n >> 8) & 0xff); + const b = srgbToLinear(n & 0xff); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +function contrast(fg: string, bg: string): number { + const l1 = luminance(fg); + const l2 = luminance(bg); + const [hi, lo] = l1 > l2 ? [l1, l2] : [l2, l1]; + return (hi + 0.05) / (lo + 0.05); +} + +// 從 styles.css 抽出兩套色板::root 區塊(深色預設)與 @media light 覆寫 +function extractPalettes(): { dark: Record; light: Record } { + const css = readFileSync(resolve(process.cwd(), 'src/styles.css'), 'utf8'); + const grab = (block: string): Record => { + const vars: Record = {}; + for (const m of block.matchAll(/(--[a-z-]+):\s*(#[0-9a-fA-F]{6})\s*;/g)) { + vars[m[1]] = m[2]; + } + return vars; + }; + const lightStart = css.indexOf('@media (prefers-color-scheme: light)'); + if (lightStart < 0) throw new Error('styles.css 缺 light 覆寫區塊'); + const darkVars = grab(css.slice(0, lightStart)); + const lightOverrides = grab(css.slice(lightStart, css.indexOf('}', css.indexOf('}', lightStart) + 1) + 1)); + return { dark: darkVars, light: { ...darkVars, ...lightOverrides } }; +} + +// 文字/背景配對與門檻。資訊性文字 4.5(AA)。 +// --on-accent on --blue(pinch 目標的裝飾性標籤)採 per-theme 門檻: +// - 深色 3.0:launch 基線(main)的 .pinchtarget 無 color 屬性、繼承 body #f2f2f7, +// 對 --blue #0a84ff = 3.27 —— pre-existing,且 issue Expected(4) 深色不變優先。 +// - 淺色 4.5:R1 blocking 修正後鎖定,不允許回退(verify R2 finding)。 +type Threshold = number | { dark: number; light: number }; +const TEXT_PAIRS: Array<{ fg: string; bg: string; min: Threshold; note: string }> = [ + { fg: '--text', bg: '--bg', min: 4.5, note: '本文 on 頁底' }, + { fg: '--text', bg: '--card', min: 4.5, note: '本文 on 卡片' }, + { fg: '--text-dim', bg: '--card', min: 4.5, note: 'hint on 卡片' }, + { fg: '--text-dim', bg: '--inset', min: 4.5, note: 'cell/scrollbox/notice 文字 on inset' }, + { fg: '--btn-text', bg: '--btn-bg', min: 4.5, note: '按鈕' }, + { fg: '--banner-text', bg: '--banner-bg', min: 4.5, note: 'banner' }, + { fg: '--cell-left-text', bg: '--cell-left-bg', min: 4.5, note: '九宮格左鍵完成' }, + { fg: '--cell-both-text', bg: '--cell-both-bg', min: 4.5, note: '九宮格雙完成' }, + { fg: '--green', bg: '--card', min: 4.5, note: '.ok 狀態文字' }, + { fg: '--on-accent', bg: '--blue', min: { dark: 3.0, light: 4.5 }, note: 'pinch 目標裝飾標籤(per-theme 門檻,見上)' }, +]; + +describe.each(['dark', 'light'] as const)('%s 色板 WCAG 對比', (theme) => { + const palettes = extractPalettes(); + const palette = palettes[theme]; + + it.each(TEXT_PAIRS)('$fg on $bg ≥ $min($note)', ({ fg, bg, min }) => { + expect(palette[fg], `${theme} 缺變數 ${fg}`).toBeDefined(); + expect(palette[bg], `${theme} 缺變數 ${bg}`).toBeDefined(); + const threshold = typeof min === 'number' ? min : min[theme]; + const ratio = contrast(palette[fg], palette[bg]); + expect(ratio, `${theme}: ${fg}(${palette[fg]}) on ${bg}(${palette[bg]}) = ${ratio.toFixed(2)}`).toBeGreaterThanOrEqual(threshold); + }); +}); + +describe('深色預設值回歸鎖(issue Expected(4):既有深色外觀不變)', () => { + it('深色核心變數與上線版 hex 完全一致', () => { + const { dark } = extractPalettes(); + expect(dark['--bg']).toBe('#1c1c1e'); + expect(dark['--card']).toBe('#2c2c2e'); + expect(dark['--text']).toBe('#f2f2f7'); + expect(dark['--text-dim']).toBe('#98989f'); + expect(dark['--blue']).toBe('#0a84ff'); + expect(dark['--green']).toBe('#30d158'); + expect(dark['--red']).toBe('#ff453a'); + expect(dark['--yellow']).toBe('#ffd60a'); + }); +}); diff --git a/tests/theme.test.ts b/tests/theme.test.ts new file mode 100644 index 0000000..c4c3bee --- /dev/null +++ b/tests/theme.test.ts @@ -0,0 +1,30 @@ +import { cssColor } from '../src/theme'; + +describe('cssColor', () => { + afterEach(() => { + document.documentElement.style.removeProperty('--test-color'); + }); + + it('讀取已定義的 CSS 變數', () => { + document.documentElement.style.setProperty('--test-color', '#123456'); + expect(cssColor('--test-color', '#fallback')).toBe('#123456'); + }); + + it('未定義時回 fallback', () => { + expect(cssColor('--never-defined', '#abcdef')).toBe('#abcdef'); + }); + + it('無 getComputedStyle(SSR/極舊環境)時回 fallback', () => { + vi.stubGlobal('getComputedStyle', undefined); + try { + expect(cssColor('--test-color', '#fa11ba')).toBe('#fa11ba'); + } finally { + vi.unstubAllGlobals(); + } + }); + + it('值含空白時 trim', () => { + document.documentElement.style.setProperty('--test-color', ' #654321 '); + expect(cssColor('--test-color', '#fallback')).toBe('#654321'); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 5e6f50f..ac74002 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,7 +13,7 @@ "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, - "types": ["vitest/globals"] + "types": ["vitest/globals", "node"] }, "include": ["src", "tests"] }