# i18n Locale Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Let consumers localize the 8 hardcoded aria-labels + 1 visible string in shipped components via a lightweight provide/inject locale dictionary, English defaults preserved.

**Architecture:** A single `src/locale/index.ts` module exports a `TvuLocale` interface, frozen English `defaultLocale`, an injection key, an imperative `provideTvuLocale()` helper, and a `useLocale()` composable that merges an injected partial over the defaults. Seven component/base SFCs read `useLocale()` at the affected sites. `src/index.ts` re-exports the public symbols.

**Tech Stack:** Vue 3.4+ (`provide`/`inject`, `computed`, `toRef`), Vitest + @vue/test-utils.

## Global Constraints

- Non-breaking: `defaultLocale` values MUST be byte-equal to the current hardcoded strings (`Previous`, `Next`, `Page`, `Close`, `Clear value`, `Remove option`, `decrement`, `increment`).
- No heavy i18n dependency (no vue-i18n). Vue built-ins only.
- Flat 8-key dictionary; no nesting, no pluralization/interpolation.
- Relative imports (codebase convention — no `@/` alias in SFC imports).
- Executor does NOT commit/push; plan owner reviews then commits (per AGENTS 标准闭环). [In this inline session the same operator runs verification before each commit.]

---

### Task 1: locale module + composable (unit-tested)

**Files:**
- Create: `src/locale/index.ts`
- Create: `tests/locale.test.ts`
- Modify: `src/index.ts` (add re-exports)

**Interfaces:**
- Produces:
  - `interface TvuLocale { previous; next; pageUnit; close; clearValue; removeOption; decrement; increment: string }`
  - `const defaultLocale: Readonly<TvuLocale>`
  - `const TVU_LOCALE_KEY: InjectionKey<Ref<Partial<TvuLocale>>>`
  - `function provideTvuLocale(locale: MaybeRefOrGetter<Partial<TvuLocale>>): void`
  - `function useLocale(): ComputedRef<TvuLocale>`

- [ ] **Step 1: Write the failing test** — `tests/locale.test.ts`

```ts
import { describe, it, expect } from 'vitest'
import { defineComponent, h, nextTick, ref } from 'vue'
import { mount } from '@vue/test-utils'
import { defaultLocale, provideTvuLocale, useLocale, TVU_LOCALE_KEY } from '../src/locale'

// Mount a child that captures useLocale(); optional parent provides a locale.
function harness(provided?: unknown) {
  let captured: ReturnType<typeof useLocale> | undefined
  const Child = defineComponent({
    setup() {
      captured = useLocale()
      return () => h('div')
    },
  })
  const Parent = defineComponent({
    setup() {
      if (provided !== undefined) provideTvuLocale(provided as never)
      return () => h(Child)
    },
  })
  const wrapper = mount(Parent)
  return { get: () => captured!, wrapper }
}

describe('useLocale', () => {
  it('returns the English defaults with no provider', () => {
    const { get } = harness()
    expect(get().value).toEqual(defaultLocale)
    expect(get().value.close).toBe('Close')
  })

  it('merges an injected partial over the defaults', () => {
    const { get } = harness({ close: '关闭', previous: '上一页' })
    expect(get().value.close).toBe('关闭')
    expect(get().value.previous).toBe('上一页')
    // untouched keys keep English defaults
    expect(get().value.next).toBe('Next')
    expect(get().value.pageUnit).toBe('Page')
  })

  it('is reactive to a provided ref', async () => {
    const loc = ref({ close: 'A' })
    const { get } = harness(loc)
    expect(get().value.close).toBe('A')
    loc.value = { close: 'B' }
    await nextTick()
    expect(get().value.close).toBe('B')
  })

  it('defaultLocale is frozen (cannot be mutated by a consumer)', () => {
    expect(Object.isFrozen(defaultLocale)).toBe(true)
  })

  it('exposes the injection key for direct app.provide use', () => {
    expect(typeof TVU_LOCALE_KEY).toBe('symbol')
  })
})
```

- [ ] **Step 2: Run test to verify it fails**

Run: `pnpm exec vitest run tests/locale.test.ts`
Expected: FAIL — cannot resolve `../src/locale`.

- [ ] **Step 3: Write minimal implementation** — `src/locale/index.ts`

```ts
import {
  computed,
  inject,
  provide,
  toRef,
  type ComputedRef,
  type InjectionKey,
  type MaybeRefOrGetter,
  type Ref,
} from 'vue'

/** App-wide localizable strings baked into shipped components. Flat by design. */
export interface TvuLocale {
  previous: string
  next: string
  /** Noun used in the page-size selector, e.g. "10/Page". */
  pageUnit: string
  close: string
  clearValue: string
  removeOption: string
  decrement: string
  increment: string
}

/** English defaults — byte-equal to the previously-hardcoded strings. Frozen. */
export const defaultLocale: Readonly<TvuLocale> = Object.freeze({
  previous: 'Previous',
  next: 'Next',
  pageUnit: 'Page',
  close: 'Close',
  clearValue: 'Clear value',
  removeOption: 'Remove option',
  decrement: 'decrement',
  increment: 'increment',
})

/** Injection key for consumers who prefer `app.provide(TVU_LOCALE_KEY, ref({...}))`. */
export const TVU_LOCALE_KEY: InjectionKey<Ref<Partial<TvuLocale>>> = Symbol('tvu-locale')

/**
 * Provide a locale from a component setup() at (or near) the app root.
 * Accepts a plain object, a ref, or a getter — normalized to a ref so a
 * reactive source propagates to every consuming component.
 */
export function provideTvuLocale(locale: MaybeRefOrGetter<Partial<TvuLocale>>): void {
  provide(TVU_LOCALE_KEY, toRef(locale))
}

/**
 * Read the effective locale inside a component. Returns a ComputedRef of the
 * full dictionary: the injected partial merged over the English defaults.
 */
export function useLocale(): ComputedRef<TvuLocale> {
  const injected = inject(TVU_LOCALE_KEY, null)
  return computed(() => ({ ...defaultLocale, ...(injected?.value ?? {}) }))
}
```

- [ ] **Step 4: Add re-exports to `src/index.ts`**

After the existing icon type export block (near line 108), add:

```ts
export {
  defaultLocale,
  provideTvuLocale,
  useLocale,
  TVU_LOCALE_KEY,
} from './locale'
export type { TvuLocale } from './locale'
```

- [ ] **Step 5: Run test to verify it passes**

Run: `pnpm exec vitest run tests/locale.test.ts`
Expected: PASS (5 tests).

- [ ] **Step 6: Typecheck**

Run: `pnpm exec vue-tsc --noEmit`
Expected: no errors.

- [ ] **Step 7: Commit**

```bash
git add src/locale/index.ts tests/locale.test.ts src/index.ts
git commit -m "feat(F65): add TvuLocale provide/inject locale module"
```

---

### Task 2: wire the 7 SFC sites + integration tests

**Files:**
- Modify: `src/components/Pagination/Pagination.vue` (page-size `Page`, Previous, Next)
- Modify: `src/components/Notification/Notification.vue` (Close)
- Modify: `src/components/Message/Message.vue` (Close)
- Modify: `src/components/PopupBox/PopupBox.vue` (Close)
- Modify: `src/components/InputNumber/InputNumber.vue` (decrement, increment)
- Modify: `src/canonical/InputBoxBase.vue` (Clear value)
- Modify: `src/canonical/SelectBoxBase.vue` (Remove option, Clear value)
- Test: `tests/locale-integration.test.ts`

**Interfaces:**
- Consumes: `useLocale` from Task 1 (`import { useLocale } from '../../locale'` for `components/*`; `'../locale'` for `canonical/*`).
- Wiring rule per site: add `const locale = useLocale()` in `<script setup>`; replace the literal `aria-label="X"` with `:aria-label="locale.<key>"` in template; for the Pagination page-size string use `locale.value.pageUnit` in the script computed. No `$attrs` plumbing is added (multi-button components can't disambiguate a single root `$attrs`).

- [ ] **Step 1: Write the failing integration test** — `tests/locale-integration.test.ts`

```ts
import { describe, it, expect } from 'vitest'
import { ref } from 'vue'
import { mount } from '@vue/test-utils'
import { TVU_LOCALE_KEY } from '../src/locale'
import Pagination from '../src/canonical/Pagination.vue'
import PopupBox from '../src/canonical/PopupBox.vue'
import InputNumber from '../src/canonical/InputNumber.vue'

const zh = ref({
  previous: '上一页',
  next: '下一页',
  pageUnit: '页',
  close: '关闭',
  decrement: '减少',
  increment: '增加',
})
const withZh = { global: { provide: { [TVU_LOCALE_KEY]: zh } } }

describe('locale integration', () => {
  it('Pagination localizes Previous/Next aria-labels + page unit', () => {
    const w = mount(Pagination, { props: { total: 100, currentPage: 2, pageSize: 10 }, ...withZh })
    const html = w.html()
    expect(w.find('[aria-label="上一页"]').exists()).toBe(true)
    expect(w.find('[aria-label="下一页"]').exists()).toBe(true)
    expect(html).toContain('/页')
    expect(html).not.toContain('aria-label="Previous"')
  })

  it('Pagination keeps English aria-labels with no provider', () => {
    const w = mount(Pagination, { props: { total: 100, currentPage: 2, pageSize: 10 } })
    expect(w.find('[aria-label="Previous"]').exists()).toBe(true)
    expect(w.find('[aria-label="Next"]').exists()).toBe(true)
  })

  it('PopupBox localizes the close button aria-label', () => {
    const w = mount(PopupBox, { props: { modelValue: true, title: 'x' }, ...withZh })
    expect(w.find('[aria-label="关闭"]').exists()).toBe(true)
  })

  it('InputNumber localizes decrement/increment aria-labels', () => {
    const w = mount(InputNumber, { props: { modelValue: 1 }, ...withZh })
    expect(w.find('[aria-label="减少"]').exists()).toBe(true)
    expect(w.find('[aria-label="增加"]').exists()).toBe(true)
  })
})
```

- [ ] **Step 2: Run test to verify it fails**

Run: `pnpm exec vitest run tests/locale-integration.test.ts`
Expected: FAIL — components still render English literals.

> NOTE on props: the mounting props above are the minimal required set. During
> execution, confirm each component's `defineProps` in its SFC and adjust prop
> names/values if the SFC differs (e.g. `currentPage` vs `current`). The assertion
> targets (localized aria-labels / `/页`) do not change.

- [ ] **Step 3: Wire `src/components/Pagination/Pagination.vue`**

In `<script setup>`, after existing imports add `import { useLocale } from '../../locale'`, and after the other `const`s add `const locale = useLocale()`.

Change the page-size option label (currently `` { label: `${option}/Page`, value: option } ``) to:
```ts
{ label: `${option}/${locale.value.pageUnit}`, value: option }
```
In template, change `aria-label="Previous"` → `:aria-label="locale.previous"` and `aria-label="Next"` → `:aria-label="locale.next"`.

- [ ] **Step 4: Wire the four `Close` sites**

For each of `src/components/Notification/Notification.vue`, `src/components/Message/Message.vue`, `src/components/PopupBox/PopupBox.vue`: add `import { useLocale } from '../../locale'` + `const locale = useLocale()` in `<script setup>`, then change `aria-label="Close"` → `:aria-label="locale.close"`.

- [ ] **Step 5: Wire `src/components/InputNumber/InputNumber.vue`**

Add `import { useLocale } from '../../locale'` + `const locale = useLocale()`. Change the minus button `aria-label="decrement"` → `:aria-label="locale.decrement"` and the plus button `aria-label="increment"` → `:aria-label="locale.increment"`. (Leave the input-body `$attrs['aria-label'] ?? 'number input'` untouched.)

- [ ] **Step 6: Wire the two canonical bases**

`src/canonical/InputBoxBase.vue`: add `import { useLocale } from '../locale'` + `const locale = useLocale()`; change `aria-label="Clear value"` → `:aria-label="locale.clearValue"`.

`src/canonical/SelectBoxBase.vue`: add `import { useLocale } from '../locale'` + `const locale = useLocale()`; change `aria-label="Remove option"` → `:aria-label="locale.removeOption"` and `aria-label="Clear value"` → `:aria-label="locale.clearValue"`.

- [ ] **Step 7: Run integration test to verify it passes**

Run: `pnpm exec vitest run tests/locale-integration.test.ts`
Expected: PASS.

- [ ] **Step 8: Full suite + typecheck + no residual literals**

```bash
pnpm exec vitest run
pnpm exec vue-tsc --noEmit
grep -rn 'aria-label="\(Previous\|Next\|Close\|Clear value\|Remove option\|decrement\|increment\)"' src/components src/canonical
```
Expected: all tests pass; no type errors; grep returns nothing (all literals replaced).

- [ ] **Step 9: Commit**

```bash
git add src/components src/canonical tests/locale-integration.test.ts
git commit -m "feat(F65): localize hardcoded aria-labels + page unit via useLocale"
```

---

## Self-Review

**Spec coverage:** TvuLocale/defaultLocale/TVU_LOCALE_KEY/provideTvuLocale/useLocale (Task 1); all 8 aria-label sites + 1 visible page-unit (Task 2 steps 3-6); index re-export (Task 1 step 4); non-breaking defaults (Task 1 test + Task 2 no-provider test); reactivity (Task 1 test). Covered.

**Placeholder scan:** Test props carry a NOTE to confirm against each SFC's defineProps at execution — the only non-final detail, flagged explicitly, assertions unaffected.

**Type consistency:** `useLocale` returns `ComputedRef<TvuLocale>` (template auto-unwrap → `locale.key`; script → `locale.value.key`, used correctly in Pagination step 3). Keys match defaultLocale everywhere.
