Skip to content
Merged
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
151 changes: 110 additions & 41 deletions PROJECT.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Japanese small business owners — restaurants, retail shops, manufacturers, and
- Design tokens (colour, typography, spacing, radius, shadows, motion)
- Component specifications (Button, Input, Card, Badge, Navigation, Loading, Empty States, Toast)
- Page layout system (breakpoints, containers, mobile/desktop structure)
- Japanese localisation rules (fonts, UI copy, date/number formatting)
- Multi-locale support: Japanese (`ja`), English (`en`), Simplified Chinese (`zh-Hans`)
- Shared infrastructure setup (Supabase, Stripe, Vercel, Sentry, PostHog, Resend)
- Two initial products: ShiftMate and FaxBridge
- Japan legal compliance pages (プライバシーポリシー, 利用規約, 特定商取引法に基づく表記)
Expand All @@ -52,7 +52,8 @@ Japanese small business owners — restaurants, retail shops, manufacturers, and
- Framer Motion or page transitions in MVP
- Warning toasts (use inline form errors instead)
- Full-page spinners for data fetching (skeletons only)
- Internationalisation for languages other than Japanese
- RTL script support or Traditional Chinese (`zh-Hant`)
- Machine translation or browser `Accept-Language` auto-detection in MVP

---

Expand All @@ -77,7 +78,7 @@ Japanese small business owners — restaurants, retail shops, manufacturers, and
## Constraints

- Mobile-first — 390px primary target; Japanese SMB owners use smartphones
- All user-facing text in Japanese
- All user-facing text in the active locale (`ja` | `en` | `zh-Hans`) — default is Japanese
- CSS variables for all tokens — never hardcode hex or px values
- TypeScript strict mode — no `any`, explicit return types on all functions
- shadcn/ui customised to Mori spec, never used raw
Expand Down Expand Up @@ -137,11 +138,22 @@ All values are defined as CSS custom properties on `:root`. **Never hardcode hex

```css
:root {
/* === Font Families === */
/* === Font Families (locale-aware) === */
/* CJK default — Japanese and Chinese use this base stack */
--font-body:
'Hiragino Kaku Gothic ProN', 'Hiragino Sans', 'Noto Sans JP', 'Yu Gothic', sans-serif;
--font-heading:
'Hiragino Kaku Gothic ProN', 'Hiragino Sans', 'Noto Sans JP', 'Yu Gothic', sans-serif;
/* Legacy alias — kept for backward compatibility */
--font-sans:
'Geist', 'Hiragino Kaku Gothic ProN', 'Hiragino Sans', 'Yu Gothic', 'Meiryo', sans-serif;
--font-mono: 'Geist Mono', 'Osaka-Mono', monospace;

/* === Line Height (locale-aware) === */
/* CJK scripts need looser leading than Latin */
--leading-body: 1.8;
--leading-heading: 1.4;

/* === Type Scale === */
--text-xs: 0.75rem; /* 12px — labels, captions */
--text-sm: 0.875rem; /* 14px — secondary body */
Expand All @@ -157,14 +169,27 @@ All values are defined as CSS custom properties on `:root`. **Never hardcode hex
--font-medium: 500;
--font-semibold: 600; /* use sparingly, headings only */
}

/* Locale overrides — applied via data-locale on <html> */
[data-locale='en'] {
--font-body: 'Inter', 'Helvetica Neue', Arial, sans-serif;
--font-heading: 'Inter', 'Helvetica Neue', Arial, sans-serif;
--leading-body: 1.6;
--leading-heading: 1.25;
}
[data-locale='zh-Hans'] {
--font-body: 'PingFang SC', 'Noto Sans SC', 'Microsoft YaHei', sans-serif;
--font-heading: 'PingFang SC', 'Noto Sans SC', 'Microsoft YaHei', sans-serif;
}
```

**Rules:**

- Body text: `--text-base`, `--font-normal`, line-height `1.7`
- Body text: `--text-base`, `--font-normal`, line-height `var(--leading-body)`
- UI labels: `--text-sm`, `--font-medium`
- Headings: `--font-semibold`, never bold (700) in UI
- Japanese text inherits font-family fallback automatically — no special handling needed
- Headings: `--font-semibold`, line-height `var(--leading-heading)`, never bold (700) in UI
- Always reference `var(--font-body)` or `var(--font-heading)` — never hardcode a font family
- The design system does **not** load web fonts — each product loads fonts via `next/font`

### Spacing Scale

Expand Down Expand Up @@ -430,56 +455,77 @@ Mobile: Desktop:

---

## Japanese Language & Localisation
## Localisation

Supported locales: **`ja`** (Japanese, default) · **`en`** (English) · **`zh-Hans`** (Simplified Chinese)

### Locale Setup (Next.js App Router)

```ts
// next.config.ts
i18n: {
locales: ['ja', 'en', 'zh'],
defaultLocale: 'ja',
}

// app/[locale]/layout.tsx — set data-locale on <html> for CSS overrides
<html lang={locale} data-locale={locale}>
```

Legacy unprefixed routes must redirect to `/ja/` equivalents via `next.config.ts` `redirects`.

### Font Rendering

```css
body {
font-family: var(--font-sans);
font-family: var(--font-body);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
/* Do NOT set font-feature-settings — let the system handle it */
}

.ui-label-ja {
letter-spacing: 0.025em;
line-height: var(--leading-body);
}
```

### UI Copy Guidelines

```
Actions: ✓ 保存する / 削除する / 追加する ✗ 保存 / 削除 (too abrupt)
Loading: 処理中...
Success: 完了しました
Error: エラーが発生しました
Empty: ✓ まだシフトがありません ✗ データがありません (too cold)
Confirm: 本当に削除しますか?この操作は取り消せません。
[キャンセル] [削除する] — destructive on the right, always
```
Use `useCopy()` from `@mori/ui` to get locale-aware strings. Never hardcode copy in components.

| Pattern | `ja` | `en` | `zh-Hans` |
| ---------------- | ---------------------------------------------- | --------------------------------------- | ------------------ |
| Save action | 保存する | Save | 保存 |
| Delete action | 削除する | Delete | 删除 |
| Cancel | キャンセル | Cancel | 取消 |
| Loading | 処理中... | Loading... | 处理中... |
| Success | ✓ 保存しました | ✓ Saved | ✓ 已保存 |
| Error | エラーが発生しました。もう一度お試しください。 | Something went wrong. Please try again. | 发生错误,请重试。 |
| Empty state | まだデータがありません | No data yet | 暂无数据 |
| Destructive hint | この操作は取り消せません。 | This action cannot be undone. | 此操作无法撤消。 |

**Rules:**

- Japanese actions end in `する` — `保存する`, `削除する`, `追加する` (never `保存`, `削除`)
- Destructive action always on the RIGHT in confirm dialogs — all locales
- `zh-Hans` copy is marked `// TODO: zh-Hans review` until human-reviewed

### Date & Number Formatting

Use `formatDate` and `formatCurrency` from `src/lib/locale/format.ts`:

```tsx
const formatDate = (date: Date) =>
new Intl.DateTimeFormat('ja-JP', {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'short',
}).format(date)
// → 2025年1月28日(火)

const formatYen = (amount: number) => `¥${amount.toLocaleString('ja-JP')}`
// → ¥1,980

const formatTime = (date: Date) =>
new Intl.DateTimeFormat('ja-JP', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(date)
import { formatDate, formatCurrency } from '@mori/ui'

formatDate(new Date('2025-01-28'), 'ja') // → 2025年1月28日(火)
formatDate(new Date('2025-01-28'), 'en') // → Tuesday, January 28, 2025
formatDate(new Date('2025-01-28'), 'zh-Hans') // → 2025年1月28日 星期二

formatCurrency(1980, 'ja') // → ¥1,980 (all locales — products are JPY-priced)
formatCurrency(1980, 'en') // → ¥1,980
formatCurrency(1980, 'zh-Hans') // → ¥1,980

// Time (24-hour, all locales)
const formatTime = (date: Date, locale: string) =>
new Intl.DateTimeFormat(locale, { hour: '2-digit', minute: '2-digit', hour12: false }).format(
date
)
// → 09:00
```

Expand Down Expand Up @@ -561,6 +607,29 @@ NEXT_PUBLIC_POSTHOG_KEY=

---

## Locale Persistence (Supabase)

Add a `locale` column to `user_preferences` so the user's locale follows them across devices:

```sql
-- Migration: add locale preference to user_preferences
alter table user_preferences
add column if not exists locale text default 'ja';
```

**Pattern:**

1. On login: read `user_preferences.locale`, redirect to `/<locale>/dashboard`
2. When user switches locale (e.g. via a language picker): update `user_preferences.locale` and reload

```ts
// On locale switch
await supabase.from('user_preferences').upsert({ user_id: userId, locale: newLocale })
router.push(`/${newLocale}${pathname}`)
```

---

## Code Quality Rules

```
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-31
101 changes: 101 additions & 0 deletions openspec/changes/archive/2026-03-31-localisation-ja-en-zh/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
## Context

Mori DS currently assumes Japanese as the only locale. UI copy is hardcoded in Japanese across component specs, PROJECT.md conventions, and product repos. Typography tokens assume CJK character metrics. There is no locale context, no switching mechanism, and no structured copy catalogue.

This design adds first-class multi-locale support for Japanese (`ja`), English (`en`), and Simplified Chinese (`zh-Hans`) without introducing a third-party i18n library, keeping the stack lean and fully type-safe.

## Goals / Non-Goals

**Goals:**
- Define a `Locale` type and locale context consumed by all Mori components
- Establish a structured copy catalogue (`MoriCopy`) keyed by locale, covering all standard UI patterns
- Define per-locale typography tokens (font stacks, line-height) as CSS variable overrides on `[data-locale]`
- Specify locale detection and switching for Next.js App Router (URL prefix strategy)
- Provide locale-aware formatting utilities for dates, currency, and numbers using native `Intl`
- Keep design system and product repos fully type-safe — accessing a missing copy key is a compile error

**Non-Goals:**
- Right-to-left (RTL) script support
- Traditional Chinese (`zh-Hant`)
- Machine translation or auto-detection from browser `Accept-Language` in MVP
- Per-component copy overrides (product repos use the catalogue; they don't fork it)
- iOS localisation (deferred to Phase 2)

## Decisions

### Decision 1: No third-party i18n library — use a typed copy catalogue + `Intl`

**Choice:** A hand-written `MoriCopy` TypeScript object (keyed `{ ja, en, 'zh-Hans' }`) plus native `Intl.DateTimeFormat`, `Intl.NumberFormat`.

**Alternatives considered:**
- `next-intl`: Mature, file-based, but adds a runtime dependency and its own routing adapter. Overkill for three locales and a controlled copy set.
- `react-i18next`: Popular but adds ~30 kB and requires a separate translation file pipeline. Excessive for a design system with a bounded copy catalogue.

**Rationale:** The copy catalogue is small and bounded. Type-safety via TypeScript literal types gives us compile-time exhaustiveness checking with zero runtime cost. Native `Intl` handles all formatting needs.

---

### Decision 2: URL-prefix locale routing (`/ja/`, `/en/`, `/zh/`)

**Choice:** Next.js built-in `i18n` config with `locales: ['ja', 'en', 'zh']` and `defaultLocale: 'ja'`. All routes are prefixed; `ja` prefix is canonical but can be omitted in links via `as` prop.

**Alternatives considered:**
- Cookie/header-based detection with no URL change: Breaks deep links, makes locale invisible to SEO crawlers, harder to test.
- Subdomain strategy (`en.shiftmate.jp`): Requires DNS config per locale; too operationally heavy for MVP.

**Rationale:** URL prefix is the standard Next.js approach, works out of the box with App Router, and makes locale explicit for both users and search engines. Vercel edge routing handles redirects automatically.

---

### Decision 3: Per-locale typography via `[data-locale]` CSS attribute on `<html>`

**Choice:** Set `data-locale="ja|en|zh"` on the `<html>` element in the root layout. CSS rules scoped to `[data-locale="en"]` override font-stack and line-height tokens.

**Alternatives considered:**
- Separate CSS variable sets per locale injected via inline `style`: Works but pollutes JSX and is harder to audit.
- Tailwind `variant` strategy: Not standard in Tailwind v3; requires plugin.

**Rationale:** CSS attribute selectors on `<html>` are zero-JS, SSR-safe, and easy to inspect in DevTools. A single `[data-locale="en"] { --font-body: ...; --leading-body: ...; }` block is sufficient.

---

### Decision 4: Locale stored in Supabase user profile, not local storage

**Choice:** `user_preferences.locale` column (`text`, default `'ja'`). On first login, locale is inferred from the URL prefix and saved. Subsequent logins restore the saved locale.

**Alternatives considered:**
- `localStorage` only: Lost on new device, not synced across browser/mobile.
- Cookie only: Works for SSR but not queryable server-side in Supabase RLS context.

**Rationale:** Server-side persistence means the locale follows the user across devices. Supabase RLS already protects the column. The URL prefix takes precedence for unauthenticated pages.

## Risks / Trade-offs

**[Risk] Copy catalogue divergence** — As products add feature-specific copy, they may fork the catalogue rather than contributing back.
→ Mitigation: The `MoriCopy` type is the source of truth in this repo. Product repos extend it via a `ProductCopy` type that intersects with `MoriCopy`; they cannot shadow core keys.

**[Risk] CJK line-height assumptions bleed into English layouts** — Existing components are designed with CJK metrics. English text at the same line-height will feel loose.
→ Mitigation: `localisation-typography` spec defines explicit `--leading-body` overrides per locale. All components must use `--leading-body` rather than a hardcoded value.

**[Risk] Simplified Chinese copy quality** — Machine-translated zh-Hans copy will feel unnatural to native speakers.
→ Mitigation: Initial zh-Hans copy is human-reviewed before any public release. Copy is marked `// TODO: zh-Hans review` until confirmed.

**[Risk] URL prefix breaks existing ShiftMate/FaxBridge links** — Existing `/dashboard` paths become `/ja/dashboard`.
→ Mitigation: Next.js `redirects` config maps legacy unprefixed paths to `/ja/` equivalents. Old links continue to work.

## Migration Plan

1. Merge this change into Mori DS main; publish updated `PROJECT.md` and spec files
2. Add `i18n` config to Next.js in ShiftMate and FaxBridge repos
3. Add `user_preferences.locale` column via Supabase migration (nullable, default `'ja'`)
4. Wrap root layout with `LocaleProvider`; set `data-locale` on `<html>`
5. Replace all hardcoded Japanese copy strings with `copy[locale].*` references
6. Add `redirects` for legacy unprefixed routes
7. QA: smoke-test all three locales on mobile (390px) and desktop

Rollback: Remove `i18n` config from Next.js; legacy paths continue to work. User preference column can remain (nullable, unused).

## Open Questions

- Should `zh-Hans` use `¥` (yen) or `¥`/`元` for currency display when a Chinese-market product is added? (Deferred — current products are Japan-only priced in JPY)
- Do LINE API notifications (ShiftMate) need locale-aware templates? (Out of scope for this change; tracked separately)
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
## Why

The Mori Design System is currently Japanese-only, with English and Chinese explicitly out of scope. Supporting Japanese, English, and Simplified Chinese expands the addressable market to include non-Japanese-speaking operators (e.g. Chinese-owned restaurants, international franchise staff) and enables future products to launch outside Japan without a design system rewrite.

## What Changes

- Add a `locale` context (`ja` | `en` | `zh-Hans`) to the design system, driving UI copy, date/number formatting, and typography
- Define locale-aware typography tokens (font stacks, line-height adjustments) for CJK vs Latin scripts
- Specify a locale-detection and locale-switching pattern (URL prefix or user preference stored in Supabase profile)
- Update all component specs to reference locale-aware copy tokens rather than hardcoded Japanese strings
- Update Japanese UI copy conventions doc to become a multi-locale copy conventions doc
- Define English and Simplified Chinese equivalents for all standard UI copy patterns (actions, errors, empty states, confirmations)
- Remove "i18n for languages other than Japanese" from the Out of Scope list in PROJECT.md

## Capabilities

### New Capabilities

- `localisation-core`: Locale context, detection, and switching mechanism; supported locales (`ja`, `en`, `zh-Hans`); locale-aware formatting utilities for dates, currency, and numbers
- `localisation-copy`: Standard UI copy patterns (actions, errors, loading, empty states, confirmations, success messages) in all three locales
- `localisation-typography`: Per-locale font stack and typographic adjustment tokens (CJK vs Latin line-height, font-size scaling)

### Modified Capabilities

- `design-tokens`: Add locale-aware typography token variants (font stacks, line-height) alongside existing fixed tokens

## Impact

- **PROJECT.md**: Remove i18n from Out of Scope; add locale tokens to design tokens section; update UI conventions section to be multi-locale
- **All component specs**: Copy strings must reference locale-aware copy keys rather than hardcoded Japanese text
- **Product repos** (ShiftMate, FaxBridge): Must wrap root layout with locale provider; update all hardcoded Japanese copy to use copy keys
- **Supabase schema**: `user_preferences` table needs a `locale` column
- **Next.js routing**: URL prefix strategy (`/ja/`, `/en/`, `/zh/`) or cookie/header-based detection (TBD in design)
- **No new dependencies required** — use native `Intl` APIs and Next.js built-in i18n routing
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
## MODIFIED Requirements

### Requirement: Token file covers all six groups
`src/tokens/globals.css` SHALL define CSS custom properties on `:root` for all six token groups specified in PROJECT.md: colour palette, typography scale, spacing scale, border radius, shadows, and motion. The file SHALL additionally define locale-specific overrides for typography tokens (`--font-body`, `--font-heading`, `--leading-body`, `--leading-heading`) scoped to `[data-locale]` attribute selectors as specified in the `localisation-typography` spec.

#### Scenario: All groups present
- **WHEN** the CSS file is parsed
- **THEN** it SHALL contain a custom property for every token listed in PROJECT.md — no group is missing, no token is omitted

#### Scenario: Mobile viewport — tokens load
- **WHEN** a product imports `globals.css` on a 390px viewport
- **THEN** all `:root` custom properties are available and resolve correctly — no breakpoint or media query restricts token definitions

#### Scenario: Locale typography overrides present
- **WHEN** `globals.css` is parsed
- **THEN** it SHALL contain `[data-locale="en"]` and `[data-locale="zh-Hans"]` blocks that override `--font-body`, `--font-heading`, `--leading-body`, and `--leading-heading`
Loading
Loading