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();
});
});

View File

@ -183,6 +183,7 @@ describe('Migrator', () => {
'001_initial.sql',
'002_attachments.sql',
'003_todo_tags.sql',
'004_user_prefs.sql',
]);
});
});

View File

@ -0,0 +1,68 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('next/headers', () => ({
cookies: vi.fn(),
}));
import { cookies } from 'next/headers';
import { getLocale, getTheme, translate } from '@/lib/i18n/server';
const mockedCookies = vi.mocked(cookies);
function mockCookie(value: string | undefined) {
mockedCookies.mockResolvedValue({
get: () => (value === undefined ? undefined : { value }),
} as never);
}
function mockCookieByName(map: Record<string, string | undefined>) {
mockedCookies.mockResolvedValue({
get: (name: string) =>
map[name] === undefined ? undefined : { value: map[name] },
} as never);
}
describe('i18n/server', () => {
beforeEach(() => vi.clearAllMocks());
describe('getLocale', () => {
it('defaults to en when the cookie is absent', async () => {
mockCookie(undefined);
expect(await getLocale()).toBe('en');
});
it('returns ja when the cookie is ja', async () => {
mockCookieByName({ locale: 'ja' });
expect(await getLocale()).toBe('ja');
});
it('falls back to en for an unknown value', async () => {
mockCookieByName({ locale: 'fr' });
expect(await getLocale()).toBe('en');
});
});
describe('getTheme', () => {
it('defaults to dark when the cookie is absent', async () => {
mockCookie(undefined);
expect(await getTheme()).toBe('dark');
});
it('returns light when the cookie is light', async () => {
mockCookieByName({ theme: 'light' });
expect(await getTheme()).toBe('light');
});
it('falls back to dark for an unknown value', async () => {
mockCookieByName({ theme: 'pink' });
expect(await getTheme()).toBe('dark');
});
});
describe('translate', () => {
it('returns the localized string for each locale', () => {
expect(translate('nav.board', 'en')).toBe('Board');
expect(translate('nav.board', 'ja')).toBe('掲示板');
});
it('falls back to the english string then the key for unknown keys', () => {
// @ts-expect-error -- nonexistent key on purpose
expect(translate('nonexistent.key', 'en')).toBe('nonexistent.key');
});
});
});

View File

@ -123,5 +123,29 @@ describe('UserRepository', () => {
// UPDATE affects 0 rows; findById returns null
expect(result).toBeNull();
});
it('defaults theme to dark and locale to en on create', () => {
const created = repo.create({
name: 'Pref',
email: 'p@example.com',
passwordHash: 'h',
});
expect(created.theme).toBe('dark');
expect(created.locale).toBe('en');
});
it('updates theme and locale', () => {
const created = repo.create({
name: 'Pref',
email: 'p@example.com',
passwordHash: 'h',
});
const updated = repo.update(created.id, {
theme: 'light',
locale: 'ja',
});
expect(updated?.theme).toBe('light');
expect(updated?.locale).toBe('ja');
});
});
});

View File

@ -3,7 +3,12 @@ import { createMigratedTestDb } from '@/tests/helpers/db';
import type { SqliteDatabase } from '@/lib/db/sqlite';
import { UserRepository } from '@/repositories/UserRepository';
import { AuthService } from '@/services/AuthService';
import { ConflictError, UnauthorizedError, NotFoundError } from '@/lib/errors';
import {
ConflictError,
UnauthorizedError,
NotFoundError,
ValidationError,
} from '@/lib/errors';
import { verifySessionToken } from '@/lib/auth/session';
import bcrypt from 'bcrypt';
@ -162,6 +167,38 @@ describe('AuthService', () => {
NotFoundError
);
});
it('updates theme and locale', () => {
const created = authService.register({
name: 'Alice',
email: 'alice@example.com',
password: 'password123',
});
const updated = authService.updateProfile(created.id, {
theme: 'light',
locale: 'ja',
});
expect(updated.theme).toBe('light');
expect(updated.locale).toBe('ja');
});
it('rejects an invalid theme or locale', () => {
const created = authService.register({
name: 'Alice',
email: 'alice@example.com',
password: 'password123',
});
expect(() =>
authService.updateProfile(created.id, {
theme: 'pink' as never,
})
).toThrow(ValidationError);
expect(() =>
authService.updateProfile(created.id, {
locale: 'fr' as never,
})
).toThrow(ValidationError);
});
});
describe('createWithRole', () => {