Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@

做完按「複製測試結果」,JSON 會複製到剪貼簿(並下載備份),可貼給 AI 或維修人員判讀。

介面深淺主題自動跟隨系統外觀(`prefers-color-scheme`),深色為預設設計、淺色完整支援。

### 判讀基準

- 事件頻率:健康值約 90–120 Hz(120 為硬體上限)
Expand Down Expand Up @@ -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)
Expand Down
18 changes: 18 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion src/panels/trail.ts
Original file line number Diff line number Diff line change
@@ -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 = `
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
96 changes: 72 additions & 24 deletions src/styles.css
Original file line number Diff line number Diff line change
@@ -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);
}
12 changes: 12 additions & 0 deletions src/theme.ts
Original file line number Diff line number Diff line change
@@ -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;
}
88 changes: 88 additions & 0 deletions tests/contrast.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>; light: Record<string, string> } {
const css = readFileSync(resolve(process.cwd(), 'src/styles.css'), 'utf8');
const grab = (block: string): Record<string, string> => {
const vars: Record<string, string> = {};
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');
});
});
30 changes: 30 additions & 0 deletions tests/theme.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"types": ["vitest/globals"]
"types": ["vitest/globals", "node"]
},
"include": ["src", "tests"]
}