diff --git a/AGENTS.md b/AGENTS.md index 3f6dc41..3622f5f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2179,8 +2179,9 @@ similar are internal endpoints/hook names, not credits — leave them.) pages (`common`, `nav`, `chromeUi`, `desktopNav`, `navSheet`, `pwa`, `home`, `about`, `faq`, `onboarding`); more namespaces land in later batches. - **Partial by design:** a missing `th` key falls back to English. `check:i18n` - is now **3-way** — en/es stay key-for-key identical (unchanged), `th` may be - partial and is reported as **coverage %**; a `th` key absent from `en` fails. + is now **multi-way** — en/es stay key-for-key identical (unchanged), `th` (and + `ja`) may be partial and are reported as **coverage %**; a `th`/`ja` key absent + from `en` fails. - **Font:** `@fontsource/kanit` (Kanit) imported in `main.tsx` and added to the CSS + Tailwind font stacks — ⚠️ Outfit/Inter/Bebas/Barlow have **no Thai glyphs**, so without it Thai silently falls back to a system font. @@ -2189,6 +2190,23 @@ similar are internal endpoints/hook names, not credits — leave them.) - **Do-not-translate is broader than tricks for Thai:** core skate vocabulary stays English (spot, park, bowl, DIY, vert, crew, line, Stash, The Lab, Nostr, Lightning, Zap, Relay, sats) because Thai skaters use the English terms. + +### Japanese (日本語) — fourth locale (2026-10-07) +- Added `ja` alongside en/es/th on the **same framework**. Translations live in + **`src/i18n/ja/`** as fragments composed in `index.ts`. Batch 1 = `common`, + `nav`, `pwa`, `home`; more namespaces land in later batches. +- **Partial by design** (same as `th`): a missing `ja` key falls back to English. + `check-i18n.mjs` reports `ja` coverage and fails if a `ja` key is absent from `en`. +- **Font:** `@fontsource/noto-sans-jp` (Noto Sans JP) imported in `main.tsx` and + added to the CSS + Tailwind font stacks — ⚠️ Outfit/Inter/Bebas/Barlow/Kanit have + **no Japanese glyphs**, so without it Japanese falls back to a system font. +- **Detection:** a Japanese-device browser (`ja*`) starts in `ja` (`detectLanguage`). +- **Katakana/English for skate vocabulary** (spot→スポット, park→パーク, crew→クルー; + Stash/The Lab/Nostr/Lightning/Zap/sats stay English). Trick names stay English. +- **Castellano term change (2026-10-07):** crew is **"peña"** in `es.ts`, not + "cuadrilla" — a case-preserving replace of 158 occurrences (`Cuadrilla→Peña`, + `cuadrilla→peña`, `CUADRILLA→PEÑA`). ⚠️ Do **not** use `perl -CSD` on these files + (it mojibakes UTF-8); use a UTF-8-safe edit/Node. - The app ships **English + Spanish (Castellano, Spain)**. The framework already exists: `src/contexts/language.ts` (context/types/hooks) + `LanguageContext.tsx` (provider), `src/i18n/en.ts` + `src/i18n/es.ts` (one default-export object diff --git a/package-lock.json b/package-lock.json index 71384de..f9cc924 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@fontsource/barlow-condensed": "^5.2.8", "@fontsource/bebas-neue": "^5.2.7", "@fontsource/kanit": "^5.3.0", + "@fontsource/noto-sans-jp": "^5.3.0", "@getalby/sdk": "^5.1.1", "@hookform/resolvers": "^3.9.0", "@modelcontextprotocol/sdk": "^1.29.0", @@ -1511,6 +1512,15 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@fontsource/noto-sans-jp": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/noto-sans-jp/-/noto-sans-jp-5.3.0.tgz", + "integrity": "sha512-OZbBzZ8LrFRs2RFT2Cc0HUV2C2ZeSfyEXhDJWR0EZuZaraJfRubpvrFmUnCljuBDEkw8Vn6CE/+nf9dj9b+0lg==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@getalby/lightning-tools": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@getalby/lightning-tools/-/lightning-tools-5.2.0.tgz", diff --git a/package.json b/package.json index 94d7375..d6ae91e 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@fontsource/barlow-condensed": "^5.2.8", "@fontsource/bebas-neue": "^5.2.7", "@fontsource/kanit": "^5.3.0", + "@fontsource/noto-sans-jp": "^5.3.0", "@getalby/sdk": "^5.1.1", "@hookform/resolvers": "^3.9.0", "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/scripts/check-i18n.mjs b/scripts/check-i18n.mjs index 457a59d..cbd1839 100644 --- a/scripts/check-i18n.mjs +++ b/scripts/check-i18n.mjs @@ -102,6 +102,7 @@ async function main() { const en = flatten(await loadTranslations(join(SRC, 'i18n/en.ts'))); const es = flatten(await loadTranslations(join(SRC, 'i18n/es.ts'))); const th = flatten(await loadTranslations(join(SRC, 'i18n/th/index.ts'))); + const ja = flatten(await loadTranslations(join(SRC, 'i18n/ja/index.ts'))); const problems = []; @@ -123,6 +124,11 @@ async function main() { for (const key of th) if (!en.has(key)) problems.push(`th has a key not in en: ${key}`); const thCoverage = en.size ? Math.round(((en.size - missingTh) / en.size) * 100) : 100; + // 4. Japanese may also be PARTIAL (same rules as Thai). + const missingJa = [...en].filter((k) => !ja.has(k)).length; + for (const key of ja) if (!en.has(key)) problems.push(`ja has a key not in en: ${key}`); + const jaCoverage = en.size ? Math.round(((en.size - missingJa) / en.size) * 100) : 100; + if (problems.length) { console.error(`\n✖ i18n check failed (${problems.length}):`); for (const p of problems.slice(0, 200)) console.error(` • ${p}`); @@ -130,7 +136,7 @@ async function main() { process.exit(1); } - console.log(`✓ i18n check passed — ${en.size} keys in en/es, ${used.size} used · th coverage ${thCoverage}% (${en.size - missingTh}/${en.size})`); + console.log(`✓ i18n check passed — ${en.size} keys in en/es, ${used.size} used · th ${thCoverage}% (${en.size - missingTh}/${en.size}) · ja ${jaCoverage}% (${en.size - missingJa}/${en.size})`); } main().catch((err) => { diff --git a/src/components/LanguageToggle.tsx b/src/components/LanguageToggle.tsx index d9604b9..16ebd37 100644 --- a/src/components/LanguageToggle.tsx +++ b/src/components/LanguageToggle.tsx @@ -20,6 +20,7 @@ const languages: LanguageOption[] = [ { code: 'en', name: 'English', nativeName: 'English', flag: '🇬🇧' }, { code: 'es', name: 'Spanish', nativeName: 'Español', flag: '🇪🇸' }, { code: 'th', name: 'Thai', nativeName: 'ไทย', flag: '🇹🇭' }, + { code: 'ja', name: 'Japanese', nativeName: '日本語', flag: '🇯🇵' }, ]; interface LanguageToggleProps { diff --git a/src/components/SasquatchGuide.tsx b/src/components/SasquatchGuide.tsx index 7dc5354..df4d572 100644 --- a/src/components/SasquatchGuide.tsx +++ b/src/components/SasquatchGuide.tsx @@ -1115,7 +1115,9 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) { ? '\n\nAlways reply in Spanish (Castellano). Keep skateboarding trick names in English (kickflip, ollie, nollie, heelflip, etc.) and never translate them.' : language === 'th' ? '\n\nAlways reply in Thai (ไทย) with a friendly, casual skate tone. Keep skateboarding trick names in English (kickflip, ollie, nollie, heelflip, etc.) and core skate vocabulary (spot, park, bowl, DIY, vert, crew, line) in English — never translate those.' - : ''; + : language === 'ja' + ? '\n\nAlways reply in Japanese (日本語) with a friendly, casual skate tone. Keep skateboarding trick names in English (kickflip, ollie, nollie, heelflip, etc.) and core skate vocabulary in English or katakana (spot, park, bowl, DIY, vert, crew, line) — never translate the trick names.' + : ''; const systemPrompt = (personal.available && isPro ? `${SYSTEM_PROMPT}${PERSONAL_TOOLS_PROMPT}` : SYSTEM_PROMPT) + languageDirective; diff --git a/src/contexts/LanguageContext.tsx b/src/contexts/LanguageContext.tsx index 4123146..ea62d2e 100644 --- a/src/contexts/LanguageContext.tsx +++ b/src/contexts/LanguageContext.tsx @@ -23,6 +23,7 @@ function detectLanguage(): Language { if (typeof navigator === 'undefined') return 'en'; const tags = [navigator.language, ...(navigator.languages ?? [])].map((t) => t?.toLowerCase() ?? ''); if (tags.some((t) => t.startsWith('th'))) return 'th'; + if (tags.some((t) => t.startsWith('ja'))) return 'ja'; if (tags.some((t) => t.startsWith('es'))) return 'es'; return 'en'; } diff --git a/src/contexts/language.ts b/src/contexts/language.ts index 59186a0..79ec37f 100644 --- a/src/contexts/language.ts +++ b/src/contexts/language.ts @@ -2,6 +2,7 @@ import { createContext, useContext } from 'react'; import en from '@/i18n/en'; import es from '@/i18n/es'; import th from '@/i18n/th'; +import ja from '@/i18n/ja'; /** * Language context + types + hooks. Split from the `LanguageProvider` component @@ -9,7 +10,7 @@ import th from '@/i18n/th'; * `react-refresh/only-export-components` rule. */ -export type Language = 'en' | 'es' | 'th'; +export type Language = 'en' | 'es' | 'th' | 'ja'; type TranslationKey = string; type TranslationValue = string | { [key: string]: TranslationValue }; @@ -31,7 +32,7 @@ export interface LanguageContextType { export const LanguageContext = createContext(undefined); -export const translations: Record = { en, es, th }; +export const translations: Record = { en, es, th, ja }; /** Get a nested value from an object using dot notation (`nav.home`). */ export function getNestedValue(obj: Translations, path: string): string | undefined { diff --git a/src/i18n/ja/batch1.ts b/src/i18n/ja/batch1.ts new file mode 100644 index 0000000..8a61f95 --- /dev/null +++ b/src/i18n/ja/batch1.ts @@ -0,0 +1,150 @@ +/** + * Japanese (日本語) — batch 1: app shell + static pages. + * + * Partial by design: any key not yet translated falls back to English, and + * `scripts/check-i18n.mjs` reports coverage. Skate/brand vocabulary stays + * English or katakana (Spot→スポット, Park→パーク, Crew→クルー, Stash, The Lab, + * Nostr, Lightning, Zap, sats), because Japanese skaters use those. + */ +const jaBatch1 = { + common: { + loading: '読み込み中...', + error: 'エラー', + save: '保存', + cancel: 'キャンセル', + delete: '削除', + edit: '編集', + add: '追加', + remove: '削除', + create: '作成', + submit: '送信', + close: '閉じる', + back: '戻る', + next: '次へ', + done: '完了', + search: '検索', + filter: '絞り込み', + sort: '並べ替え', + share: 'シェア', + more: 'もっと見る', + copy: 'コピー', + copied: 'コピーしました!', + viewAll: 'すべて表示', + seeMore: 'もっと見る', + showMore: 'もっと表示', + showLess: '表示を減らす', + noResults: '結果が見つかりません', + tryAgain: 'もう一度試す', + learnMore: '詳しく見る', + getStarted: 'はじめる', + yes: 'はい', + no: 'いいえ', + or: 'または', + and: 'と', + free: '無料', + new: '新着', + popular: '人気', + featured: '注目', + verified: '認証済み', + sats: 'sats', + clear: 'クリア', + clearFilters: 'フィルターをクリア', + clearAll: 'すべてクリア', + hide: '非表示', + undo: '元に戻す', + }, + nav: { + home: 'ホーム', + map: 'スポット', + lab: 'The Lab', + events: 'イベント', + crews: 'クルー', + wallet: 'ウォレット', + settings: '設定', + more: 'もっと見る', + rewards: 'リワード', + stats: 'コミュニティ統計', + about: '概要', + food: 'フード', + marketplace: 'マーケットプレイス', + mints: 'ミント', + appShop: 'アプリショップ', + diy: 'DIY', + trades: 'マーケットプレイス', + stash: 'Stash', + collections: 'コレクション', + contacts: '連絡先', + messages: 'メッセージ', + skaters: 'スケーター', + glossary: '用語集', + riderCard: 'ライダーカード', + supporters: 'サポーター ⚡', + newsletter: 'ニュースレター', + viewProfile: 'プロフィールを見る', + leaderboards: 'リーダーボード', + }, + pwa: { + installTitle: 'SkateSpotsをインストール', + installSubtitle: 'アプリの全機能を体験', + addToHomeScreen: 'ホーム画面に追加', + installApp: 'アプリをインストール', + maybeLater: 'あとで', + gotIt: '了解!', + benefits: { + fast: '超高速', + offline: 'オフライン対応', + fullAccess: '全機能', + }, + iosStep1: '共有ボタンをタップ', + iosStep1Hint: 'Safariの下部にあります', + iosStep2: 'スクロールして「ホーム画面に追加」をタップ', + iosStep2Hint: '共有メニュー内', + iosStep3: '「追加」をタップしてインストール', + iosStep3Hint: 'ホーム画面にアプリが表示されます', + androidStep1: 'メニューボタンをタップ', + androidStep1Hint: 'ブラウザの(三点)メニュー', + androidStep2: '「ホーム画面に追加」を選択', + androidStep2Hint: 'または「アプリをインストール」', + androidStep3: '確認してインストール', + androidStep3Hint: 'ホーム画面にアプリが表示されます', + tagline: '無料 • オフライン対応 • 高速で信頼できる', + }, + home: { + tagline: 'コミュニティ主導 • リレー基盤', + title: 'SKATE SPOTS', + subtitle: 'スポットを発見 • 集まる • トリックを学ぶ • 地元を応援', + description: '本当のスケートはオフラインで起きる — このツールはそれを見つける手助けをします。', + betaWarning: 'ベータ - 開発中 • 自己責任で利用してください', + pwaHint: 'プログレッシブウェブアプリ - 端末にインストール', + exploreMap: 'マップを見る', + addSpot: 'スポットを追加', + globalSpots: '世界中のスポット', + openCommunity: 'オープンなコミュニティ', + lightningZaps: 'Lightning Zaps', + featuredSpots: '注目のスポット', + featuredSpotsDesc: '世界中のコミュニティが認証したスケートスポット', + upcomingEvents: '開催予定のイベント', + upcomingEventsDesc: '近日開催のコンテスト、ミートアップ、セッション', + noSpotsYet: 'まだスポットがありません', + noSpotsHint: '最初のスケートスポットを追加しましょう!', + noEventsYet: '予定されたイベントはありません', + noEventsHint: 'また後で確認するか、作成しましょう!', + theLab: 'The Lab', + theLabDesc: 'トリックを学び、コーチを見つけて、スケートを上達させよう', + coaches: 'コーチ', + coachesDesc: '上達を手助けしてくれる経験豊富なスケーターから学ぶ', + filmers: 'フィルマー', + filmersDesc: 'フィルマーとつながって最高のクリップを撮ろう', + findCoach: 'コーチを探す', + becomeCoach: 'コーチになる', + findFilmer: 'フィルマーを探す', + becomeFilmer: 'フィルマーになる', + viewAllSpots: 'すべてのスポットを見る', + viewAllEvents: 'すべてのイベントを見る', + createEvent: 'イベントを作成', + readyToShare: 'シェアする準備はできた?', + readyToShareDesc: 'お気に入りのスポットを追加してスケートコミュニティを育てましょう。あなたがシェアする一つ一つが、仲間が新しい場所を見つける助けになります。', + }, +}; + +export default jaBatch1; diff --git a/src/i18n/ja/index.ts b/src/i18n/ja/index.ts new file mode 100644 index 0000000..89dc00e --- /dev/null +++ b/src/i18n/ja/index.ts @@ -0,0 +1,15 @@ +import batch1 from './batch1'; + +/** + * Japanese (日本語) translations — composed from fragments so parallel batches + * never touch one giant file (same pattern as `i18n/th/`). + * + * Partial by design: any key not yet translated falls back to English, and + * `scripts/check-i18n.mjs` reports the coverage. Add new fragments here as the + * remaining namespaces land. + */ +const ja = { + ...batch1, +}; + +export default ja; diff --git a/src/index.css b/src/index.css index 2ce8f1c..e50364a 100644 --- a/src/index.css +++ b/src/index.css @@ -149,18 +149,18 @@ /* ── Display font utilities ───────────────────────────────────────────────── */ /* Bebas Neue — used for all major headlines */ .font-display { - font-family: 'Bebas Neue', 'Kanit', 'Impact', 'Arial Black', sans-serif; + font-family: 'Bebas Neue', 'Kanit', 'Noto Sans JP', 'Impact', 'Arial Black', sans-serif; letter-spacing: 0.04em; } /* Barlow Condensed — used for subheadings, labels, nav */ .font-condensed { - font-family: 'Barlow Condensed', 'Kanit', 'Arial Narrow', sans-serif; + font-family: 'Barlow Condensed', 'Kanit', 'Noto Sans JP', 'Arial Narrow', sans-serif; } .neon-header { animation: neon-glow 2s ease-in-out infinite; - font-family: 'Bebas Neue', 'Kanit', 'Impact', 'Arial Black', sans-serif; + font-family: 'Bebas Neue', 'Kanit', 'Noto Sans JP', 'Impact', 'Arial Black', sans-serif; letter-spacing: 0.06em; } @@ -714,7 +714,7 @@ select { position: relative; overflow: hidden; flex-shrink: 0; - font-family: 'Inter Variable', 'Inter', 'Kanit', system-ui, sans-serif; + font-family: 'Inter Variable', 'Inter', 'Kanit', 'Noto Sans JP', system-ui, sans-serif; box-shadow: 0 8px 40px rgba(0,0,0,0.6), 0 2px 8px rgba(0,0,0,0.4); } diff --git a/src/lib/dateFnsI18n.ts b/src/lib/dateFnsI18n.ts index 4590eb1..9962e5e 100644 --- a/src/lib/dateFnsI18n.ts +++ b/src/lib/dateFnsI18n.ts @@ -19,7 +19,7 @@ import { formatDistance as _formatDistance, format as _format, } from 'date-fns'; -import { enUS, es as esLocale, th as thLocale } from 'date-fns/locale'; +import { enUS, es as esLocale, th as thLocale, ja as jaLocale } from 'date-fns/locale'; import type { Locale } from 'date-fns'; import type { Language } from '@/contexts/language'; @@ -34,6 +34,9 @@ export function setDateLocale(language: Language): void { } else if (language === 'th') { activeLocale = thLocale; activeCode = 'th-TH'; + } else if (language === 'ja') { + activeLocale = jaLocale; + activeCode = 'ja-JP'; } else { activeLocale = enUS; activeCode = 'en-US'; diff --git a/src/main.tsx b/src/main.tsx index 1afdd2c..f74b121 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -39,6 +39,10 @@ import '@fontsource/kanit/400.css'; import '@fontsource/kanit/500.css'; import '@fontsource/kanit/600.css'; import '@fontsource/kanit/700.css'; +// Japanese glyphs — Noto Sans JP (Outfit/Inter/Bebas/Barlow/Kanit have none) +import '@fontsource/noto-sans-jp/400.css'; +import '@fontsource/noto-sans-jp/500.css'; +import '@fontsource/noto-sans-jp/700.css'; // Leaflet CSS is loaded via CDN in index.html diff --git a/tailwind.config.ts b/tailwind.config.ts index cb44f2b..51405f5 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -28,7 +28,7 @@ export default { }, extend: { fontFamily: { - sans: ['Outfit Variable', 'Outfit', 'Kanit', 'system-ui', 'sans-serif'], + sans: ['Outfit Variable', 'Outfit', 'Kanit', 'Noto Sans JP', 'system-ui', 'sans-serif'], }, colors: { border: 'hsl(var(--border))',