feat(ux): dark/light theme + en/ja i18n + user preferences

UX全体にテーマと言語設定を追加。

- lib/db/migrations/004_user_prefs.sql: users に theme/locale 列を追加(既定 dark/en)
- lib/i18n/: dictionary(en/ja) + I18nProvider(クライアントcontext: locale/theme/t/setLocale/setTheme) + server.ts(SSR用 getLocale/getTheme/translate) + constants.ts
- app/layout.tsx: theme/locale Cookie を読み <html class/lang> をSSR、I18nProvider でラップ(フラッシュなし)
- tailwind darkMode:'class' + 全画面に dark: バリアントを一括付与(Nodeスクリプト lookbehind で安全に変換)
- components/layout/ThemeToggle: 即時クラス切替+Cookie+永続化
- app/profile: テーマ/言語セレクタ、chrome翻訳(Header/ProjectNav/login/dashboard/profile)
- PATCH /api/users/me: theme/locale を受理(バリデーション→400)、Cookieを設定
- E2E: locale=ja storageState で既存JAアサーションを維持、theme-i18n.spec で既定en/darkと切替を検証
This commit is contained in:
Ken Yasue
2026-06-25 11:37:44 +02:00
parent 6a134a29bd
commit 921cdc457d
74 changed files with 1092 additions and 279 deletions

View File

@ -22,12 +22,16 @@ test.describe('chat (SSE realtime)', () => {
const memberEmail = unique('member') + '@example.com';
// メンバーを先に登録(UIで作成+ログイン)
const memberContext = await browser.newContext();
const memberContext = await browser.newContext({
storageState: 'tests/e2e/locale-ja.json',
});
const memberPage = await memberContext.newPage();
await registerAndLogin(memberPage, memberEmail, 'Member');
// オーナー登録+プロジェクト作成
const ownerContext = await browser.newContext();
const ownerContext = await browser.newContext({
storageState: 'tests/e2e/locale-ja.json',
});
const ownerPage = await ownerContext.newPage();
await registerAndLogin(ownerPage, ownerEmail, 'Owner');
await ownerPage.getByLabel('プロジェクト名').fill(unique('Proj'));

15
tests/e2e/locale-ja.json Normal file
View File

@ -0,0 +1,15 @@
{
"cookies": [
{
"name": "locale",
"value": "ja",
"domain": "localhost",
"path": "/",
"expires": -1,
"httpOnly": false,
"secure": false,
"sameSite": "Lax"
}
],
"origins": []
}

View File

@ -0,0 +1,75 @@
import { test, expect, type Page } from '@playwright/test';
function unique(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`;
}
async function setupOwner(page: Page): Promise<number> {
const email = unique('owner') + '@example.com';
await page.goto('/login');
await page.getByRole('button', { name: '新規登録はこちら' }).click();
await page.getByLabel('表示名').fill('Owner');
await page.getByLabel('メールアドレス').fill(email);
await page.getByLabel('パスワード').fill('password123');
await page.getByRole('button', { name: '登録する' }).click();
await expect(page).toHaveURL(/\/dashboard/);
await page.getByLabel('プロジェクト名').fill(unique('Proj'));
await page.getByRole('button', { name: '新規プロジェクト' }).click();
await expect(page).toHaveURL(/\/projects\/\d+$/);
return Number(page.url().match(/\/projects\/(\d+)/)![1]);
}
test.describe('theme & i18n', () => {
test('a fresh visitor gets English locale and dark theme by default', async ({
browser,
}) => {
// storageState を持たない = 初回訪問者(既定 en / dark)
const ctx = await browser.newContext();
const page = await ctx.newPage();
await page.goto('/login');
await expect(page.getByRole('heading', { name: 'Login' })).toBeVisible();
const className = await page.evaluate(
() => document.documentElement.className
);
expect(className).toContain('dark');
await ctx.close();
});
test('theme toggle switches dark<->light and persists', async ({ page }) => {
await setupOwner(page);
// 既定は dark
expect(
await page.evaluate(() => document.documentElement.className)
).toContain('dark');
await page.getByTestId('theme-toggle').click();
expect(
await page.evaluate(() => document.documentElement.className)
).not.toContain('dark');
// リロード後もライトが維持される(Cookie永続化)
await page.reload();
expect(
await page.evaluate(() => document.documentElement.className)
).not.toContain('dark');
});
test('language switch ja -> en updates chrome and persists', async ({
page,
}) => {
const projectId = await setupOwner(page);
await page.goto(`/projects/${projectId}`);
// E2E既定は ja → ヘッダーに ダッシュボード
await expect(
page.getByRole('link', { name: 'ダッシュボード' })
).toBeVisible();
// プロフィールで en に切替
await page.goto('/profile');
await page.getByTestId('profile-locale-select').selectOption('en');
// クロムが英語に切替
await page.goto(`/projects/${projectId}`);
await expect(page.getByRole('link', { name: 'Dashboard' })).toBeVisible();
});
});