d3ro-voice/site/src/i18n/index.ts
Yun Chan 7b1210b5d1 feat(desktop): honest, unified dictation popups with Esc to cancel
Wave 3 of the 2026-09-28 design overhaul.

- Esc (no modifiers) cancels an in-progress dictation, recording or
  processing: VoiceModeService.requestEscape() queues the dead 'escape'
  action. Clicking the capsule cancels only while recording, so a stray
  click during processing no longer throws the text away.
- Errors show in the user's language: bootstrap now passes error.code to
  the tip (the translated branch never ran), and one core classifier
  (voice-error-message) feeds both the tip and the main-window alert.
- Capsule: bars stay flat without input (no fake "alive" wobble before the
  mic opens), an indeterminate bar replaces the made-up percentage, the
  partial transcript stays visible while processing, an Esc hint, and a
  brief "Inserted" state so success is visible.
- All six popups share popups/_shared (Pretendard, one panel radius,
  selection, kbd hints, reduced motion). History/command popups get
  titles, listbox roles and window sizes from popup-list-geometry (empty
  states were clipped); the command popup's "0" key is registered;
  result popup explains why it appeared and can be closed; captions keep
  a theme-independent dark backing on purpose.
- Popup themes follow the OS when set to system and the document lang
  follows the app language.
- Site hero demo mirrors the new capsule (done state, indeterminate bar,
  Esc hint; 10 locales).
2026-09-28 20:46:24 +09:00

231 lines
7 KiB
TypeScript

import { createContext, useContext, type ReactNode } from 'react'
import { createElement } from 'react'
import { en } from './locales/en'
import { ko } from './locales/ko'
import { ja } from './locales/ja'
import { zh } from './locales/zh'
import { es } from './locales/es'
import { fr } from './locales/fr'
import { de } from './locales/de'
import { pt } from './locales/pt'
import { ru } from './locales/ru'
import { vi } from './locales/vi'
// ── Types ──────────────────────────────────────────────
export type Locale = 'en' | 'ko' | 'ja' | 'zh' | 'es' | 'fr' | 'de' | 'pt' | 'ru' | 'vi'
export interface TitledText {
title: string
body: string
}
export interface DemoSample {
raw: string
clean: string
}
export interface FaqItem {
q: string
a: string
}
export interface PlanCopy {
name: string
desc: string
cta: string
features: string[]
}
/**
* 사이트의 모든 문구. 컴포넌트에 문자열을 직접 쓰지 않는다.
* `{name}` 자리표시자는 `fmt()`로 채운다.
*/
export interface Translations {
meta: {
title: string
description: string
}
a11y: {
skipToContent: string
primaryNav: string
openMenu: string
closeMenu: string
language: string
opensInNewTab: string
}
nav: {
features: string
how: string
privacy: string
pricing: string
faq: string
download: string
}
hero: {
title1: string
title2: string
subtitle: string
hotkeyKey: string
hotkeyAction: string
trust: string
/** 방문자가 Windows가 아닐 때 다운로드 버튼 아래에 보인다. */
windowsOnly: string
}
demo: {
/** figure 의 접근 가능한 이름 */
label: string
/** 화면 낭독용 설명. 애니메이션 자체는 aria-hidden 이다. */
description: string
note: string
windowTitle: string
docLine: string
/** 캡슐의 처리 중 문구 */
processing: string
/** 캡슐 완료 표시 — 앱 recording-tip 의 done 상태 */
inserted: string
/** 캡슐 오른쪽 키 안내 */
cancelHint: string
stateIdle: string
stateListening: string
statePolishing: string
stateDone: string
pause: string
play: string
/** raw 는 띄어쓰기 단위로 한 낱말씩 나타난다. */
samples: [DemoSample, DemoSample]
}
features: {
title: string
subtitle: string
items: [TitledText, TitledText, TitledText, TitledText, TitledText, TitledText]
}
how: {
title: string
subtitle: string
steps: [TitledText, TitledText, TitledText, TitledText]
}
privacy: {
title: string
subtitle: string
points: [TitledText, TitledText, TitledText, TitledText]
policyLink: string
}
pricing: {
title: string
subtitle: string
perMonth: string
unlimited: string
note: string
free: PlanCopy
pro: PlanCopy
proPlus: PlanCopy
}
download: {
title: string
subtitle: string
windowsName: string
windowsMeta: string
cta: string
/** {date}: 게시일 (release.ts의 DESKTOP_RELEASE_DATE, 버전 동기화 대상) */
details: string
releaseNotes: string
checksum: string
soonTitle: string
soon: [TitledText, TitledText]
}
/** 사실만 모은 요약. 검색·AI 답변이 그대로 인용할 수 있게 짧은 정의형 문장으로 쓴다. */
facts: {
title: string
/** {version}·{date}·{pro}·{proPlus} 는 release.ts·plan-catalog 정본에서 채운다. */
rows: [TitledText, TitledText, TitledText, TitledText, TitledText, TitledText, TitledText, TitledText]
}
faq: {
title: string
items: [FaqItem, FaqItem, FaqItem, FaqItem, FaqItem, FaqItem, FaqItem, FaqItem]
}
footer: {
description: string
privacy: string
terms: string
deleteAccount: string
releaseNotes: string
copyright: string
}
}
/** `{key}` 자리표시자를 값으로 바꾼다. */
export function fmt(template: string, values: Record<string, string | number>): string {
return template.replace(/\{(\w+)\}/g, (match, key: string) =>
key in values ? String(values[key]) : match,
)
}
// ── Locale metadata ────────────────────────────────────
export interface LocaleMeta {
code: Locale
label: string
nativeName: string
/** Intl 포맷용 BCP 47 태그 */
bcp47: string
}
export const LOCALES: LocaleMeta[] = [
{ code: 'ko', label: 'KO', nativeName: '한국어', bcp47: 'ko-KR' },
{ code: 'en', label: 'EN', nativeName: 'English', bcp47: 'en-US' },
{ code: 'ja', label: 'JA', nativeName: '日本語', bcp47: 'ja-JP' },
{ code: 'zh', label: 'ZH', nativeName: '简体中文', bcp47: 'zh-CN' },
{ code: 'es', label: 'ES', nativeName: 'Español', bcp47: 'es-ES' },
{ code: 'fr', label: 'FR', nativeName: 'Français', bcp47: 'fr-FR' },
{ code: 'de', label: 'DE', nativeName: 'Deutsch', bcp47: 'de-DE' },
{ code: 'pt', label: 'PT', nativeName: 'Português', bcp47: 'pt-BR' },
{ code: 'ru', label: 'RU', nativeName: 'Русский', bcp47: 'ru-RU' },
{ code: 'vi', label: 'VI', nativeName: 'Tiếng Việt', bcp47: 'vi-VN' },
]
// ── Translations map ───────────────────────────────────
export const translations: Record<Locale, Translations> = {
en, ko, ja, zh, es, fr, de, pt, ru, vi,
}
/** 기본 언어. 루트(`/`)가 이 언어이고, 나머지는 `/{code}/` 에 따로 미리 그려진다. */
export const DEFAULT_LOCALE: Locale = 'ko'
export function isLocale(value: string): value is Locale {
return Object.prototype.hasOwnProperty.call(translations, value)
}
/** 언어별 페이지 경로. 검색 엔진이 언어마다 다른 주소를 색인하도록 언어를 주소로 나눈다. */
export function localePath(locale: Locale): string {
return locale === DEFAULT_LOCALE ? '/' : `/${locale}/`
}
export function localeMeta(locale: Locale): LocaleMeta {
return LOCALES.find((l) => l.code === locale) ?? LOCALES[0]
}
// ── Context ────────────────────────────────────────────
interface I18nContextValue {
locale: Locale
bcp47: string
t: Translations
}
const I18nContext = createContext<I18nContextValue | null>(null)
export function useI18n(): I18nContextValue {
const ctx = useContext(I18nContext)
if (!ctx) throw new Error('useI18n must be used within I18nProvider')
return ctx
}
// ── Provider ───────────────────────────────────────────
interface I18nProviderProps {
/** 페이지 언어. 빌드 때 언어별 HTML을 미리 그리고, 브라우저는 같은 언어로 이어받는다. */
locale: Locale
children: ReactNode
}
export function I18nProvider({ locale, children }: I18nProviderProps) {
const value = { locale, bcp47: localeMeta(locale).bcp47, t: translations[locale] }
return createElement(I18nContext.Provider, { value }, children)
}