Merge remote-tracking branch 'origin/master' into worktree/i18n-complete-non-readme

# Conflicts:
#	packages/client/i18n/README.md
#	packages/client/locale/README.i18n.yaml
#	packages/client/locale/README.zh.md
#	packages/client/ui-layout/README.md
#	packages/client/ui-theme/README.md
This commit is contained in:
Tianyi Cui
2026-07-26 14:37:46 +08:00
136 changed files with 4696 additions and 459 deletions

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: 9015af2b44a33771b06863ace139fe97695df616
README.zh.md: 6b129bcabbef5b5a00c5073ebc9142a0e406ddba

View File

@@ -0,0 +1,18 @@
# @deepseek-ai/dsh-client-locale
English | [中文](README.zh.md)
Locale plugin: LocaleService — the browser locale preference (`zh`/`en`, persisted under `dsh.locale`, getter/setter with `locale/change` snapshots) plus the ns×locale dictionary registry (`bind(ns)`→t with a stable function identity; lookup chain active → zh → key).
## Model Experience
None, as the locale registry serves browser UI copy; nothing here reaches a model request.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Only the Settings surface is translated** — other pages keep inline copy; repo-wide extraction into dictionaries is deferred.
- **Locale switching re-renders subscribed consumers only** — sections not wired to `locale/change` keep their rendered text until remount.

View File

@@ -0,0 +1,18 @@
# @deepseek-ai/dsh-client-locale
[English](README.md) | 中文
locale 插件LocaleService 包含浏览器 locale 偏好(`zh``en`,以 `dsh.locale` 为键持久化;提供 gettersetter并生成 `locale/change` 快照),以及 ns×locale 字典注册表(`bind(ns)`→t 的函数标识稳定;查找链为 active → zh → key
## 模型体验
无。locale 注册表为浏览器 UI 文案提供服务;这里没有任何内容进入模型请求。
#### KV Cache 影响
无;该包既不组装也不发送提供方请求。
## 已知限制与暂缓事项
- **只有设置界面完成翻译**:其他页面仍保留内联文案;将全仓文案提取到字典的工作暂缓。
- **切换 locale 只重新渲染已订阅的消费方**:未接入 `locale/change` 的分区会保留已渲染文本,直到重新挂载。

View File

@@ -0,0 +1,62 @@
{
"name": "@deepseek-ai/dsh-client-locale",
"description": "Locale plugin: LocaleService (zh/en preference with getter/setter/change event + persistence; ns x locale dictionaries, bind(ns) -> t); registers the Language settings row",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-runtime"
],
"platform": "web",
"immediately": true
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
}
}

View File

@@ -0,0 +1,47 @@
/* Language row (figma 'Setting-Cell': gap 8, pad 16/0, hairline separator;
* the section column removes the separator on its last child). */
.row {
display: flex;
align-items: center;
gap: 8px;
padding: 16px 0;
border-bottom: 1px solid var(--dsw-alias-border-l2);
}
.rowText {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
padding-right: 48px;
}
.title {
font-size: 14px;
font-weight: 400;
line-height: 22px;
color: var(--dsw-alias-label-primary);
}
/* Selector pill (figma 'Selector': h36 r18, fill #F5F6F7, pad 0/14, gap 12). */
.selector {
display: inline-flex;
align-items: center;
gap: 12px;
height: 36px;
padding: 0 14px;
border: none;
border-radius: 18px;
background: var(--dsw-alias-bg-module-platform);
font: inherit;
font-size: 14px;
line-height: 22px;
color: var(--dsw-alias-label-primary);
cursor: pointer;
}
.chevron {
flex: none;
}

View File

@@ -0,0 +1,68 @@
/**
* Language preference row registered into the General section item slot
* (figma 501:30011 'Setting-Cell'): title + selector pill opening the locale
* menu. Registered by this package — the locale feature owns its own
* settings surface.
*/
import { useState } from 'react'
import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
import type {} from './settings-contract.ts'
import type { createLanguageRowStore } from './settings-store.ts'
import css from './LanguageRow.module.css'
/** Injected business face: namespace-bound translate + the preference write. */
export interface LanguageRowInjected {
/** Translate a `settings.locale` dictionary key to the active-locale text. */
t: (key: string) => string
/** Switch the active locale (a registered locale id). */
setLocale: (id: string) => void
}
/** Full component props: runtime share + store share + injected face. */
export type LanguageRowComponentProps =
PropsRuntime<'settings.general.item'> & PropsStore<ReturnType<typeof createLanguageRowStore>> & LanguageRowInjected
/**
* Render the Language row.
* @param props - composed slot props.
* @returns the row element tree.
*/
export function LanguageRow({ t, setLocale, useStore }: LanguageRowComponentProps) {
const active = useStore(s => s.active)
const options = useStore(s => s.options)
const [open, setOpen] = useState(false)
const activeLabel = options.find(o => o.id === active)?.label ?? active
return (
<div className={css.row}>
<div className={css.rowText}>
<div className={css.title}>{t('language.title')}</div>
</div>
<Menu
open={open}
onClose={() => { setOpen(false) }}
items={options.map(o => ({ id: o.id, label: o.label }))}
selectedId={active}
onSelect={(id) => {
setLocale(id)
setOpen(false)
}}
align="end"
portal
anchor={(
<button
type="button"
className={css.selector}
aria-haspopup="menu"
aria-expanded={open}
onClick={() => { setOpen(v => !v) }}
>
{activeLabel}
<IconChevronDownOutline14 className={css.chevron} />
</button>
)}
/>
</div>
)
}

View File

@@ -0,0 +1,248 @@
/**
* Browser-side locale registry. Bound translation functions retain stable
* identity for injected consumers. The plugin also registers the Language
* preference row into the settings General section — the locale feature owns
* its own settings surface.
*/
import type { Context } from 'cordis'
import { deferRegistration, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import { en } from '../locales/en.ts'
import { zh } from '../locales/zh.ts'
import type { LanguageRowInjected } from './LanguageRow.tsx'
import { LanguageRow } from './LanguageRow.tsx'
import { createLanguageRowStore } from './settings-store.ts'
export type { LanguageRowComponentProps, LanguageRowInjected } from './LanguageRow.tsx'
export type { LanguageOptionRow, LanguageRowState } from './settings-store.ts'
export type { SettingsGeneralItemOwnerProps } from './settings-contract.ts'
/** Translate a key with optional params. */
export type Translate = (key: string, params?: Record<string, unknown>) => string
/** Locale dictionary: flat key to template string ({name} placeholders). */
export type LocaleDict = Record<string, string>
/** Locale identifier: the two shipped locales. */
export type LocaleId = 'zh' | 'en'
/** One selectable locale: id plus its self-described display name. */
export interface LocaleDefinition {
/** Locale id (persisted; the setLocale argument). */
id: LocaleId
/** Display name in its own language (中文 / English). */
label: string
}
/** Immutable locale state published on every change. */
export interface LocaleSnapshot {
/** Active locale id. */
active: LocaleId
/** Selectable locales in display order. */
locales: readonly LocaleDefinition[]
/** Monotonic change counter (registry or active changes). */
revision: number
}
declare module 'cordis' {
interface Context {
locale: LocaleService
}
interface Events {
/**
* Locale state changed (active locale switched or registry updated).
* @param snapshot - Current immutable locale snapshot.
* @mode emit
*/
'locale/change'(snapshot: LocaleSnapshot): void
}
}
/** Fallback locale consulted after the active locale misses (also the default). */
export const FALLBACK_LOCALE: LocaleId = 'zh'
/** Shared namespace for shell-level texts. */
export const COMMON_NS = 'common'
/** Namespace owning this feature's settings-row copy. */
export const SETTINGS_NS = 'settings.locale'
/** localStorage key holding the persisted locale id. */
export const STORAGE_KEY = 'dsh.locale'
/** The two shipped locales. */
const LOCALES: readonly LocaleDefinition[] = Object.freeze([
{ id: 'zh', label: '中文' },
{ id: 'en', label: 'English' },
])
/**
* Dictionary registry plus locale preference. Lookup chain per key: active
* locale -> zh fallback -> the key itself (missing text stays visible, fail
* loud in the UI rather than blank). Reads go through {@link getLocale};
* writes only through {@link setLocale}; continuous sync only through the
* `locale/change` event.
*/
export class LocaleService {
private dicts = new Map<string, Map<string, LocaleDict>>()
private bound = new Map<string, Translate>()
private snapshot: LocaleSnapshot
private readonly ctx: Context
/**
* @param ctx - owning context (change events are emitted on it).
*/
constructor(ctx: Context) {
this.ctx = ctx
this.snapshot = Object.freeze({ active: restorePreference(), locales: LOCALES, revision: 0 })
}
/**
* Read the current immutable locale snapshot.
* @returns the current snapshot (stable reference until the next change).
*/
getLocale(): LocaleSnapshot {
return this.snapshot
}
/**
* Switch the active locale — the only preference write entry. Persists the
* id and emits `locale/change`.
* @param id - a registered locale id; unknown ids throw.
*/
setLocale(id: string): void {
const match = this.snapshot.locales.find(l => l.id === id)
if (match === undefined) throw new Error(`locale "${id}" is not registered`)
if (this.snapshot.active === match.id) return
this.snapshot = Object.freeze({
active: match.id,
locales: this.snapshot.locales,
revision: this.snapshot.revision + 1,
})
persistPreference(match.id)
this.ctx.emit('locale/change', this.snapshot)
}
/**
* Register a dictionary for a namespace and locale. Duplicate (ns, locale)
* throws (single occupant; a namespace's texts have one owner).
* @param ns - namespace.
* @param locale - locale tag (zh/en to start).
* @param dict - dictionary.
* @returns disposer (idempotent).
*/
register(ns: string, locale: string, dict: LocaleDict): () => void {
let locales = this.dicts.get(ns)
if (!locales) {
locales = new Map()
this.dicts.set(ns, locales)
}
if (locales.has(locale)) throw new Error(`locale namespace "${ns}" already has locale "${locale}"`)
locales.set(locale, dict)
return () => {
const owner = this.dicts.get(ns)
if (owner?.get(locale) === dict) owner.delete(locale)
}
}
/**
* Bind a namespace to a translate function. The returned reference is
* stable per namespace (repeat binds return the same function), so it can
* ride inject surfaces without breaking memoization.
* @param ns - namespace.
* @returns the translate function (reads the active locale at call time).
*/
bind(ns: string): Translate {
let t = this.bound.get(ns)
if (!t) {
t = (key, params) => this.translate(ns, key, params)
this.bound.set(ns, t)
return t
}
return t
}
private translate(ns: string, key: string, params?: Record<string, unknown>): string {
const locales = this.dicts.get(ns)
const template = locales?.get(this.snapshot.active)?.[key]
?? locales?.get(FALLBACK_LOCALE)?.[key]
?? key
if (!params) return template
return template.replace(/\{(\w+)\}/g, (match, name: string) =>
name in params ? String(params[name]) : match)
}
}
/** Read the persisted locale id; unknown or unreadable values fall back to zh. */
function restorePreference(): LocaleId {
// Non-browser runs (node e2e booting the client tree) have no localStorage.
if (typeof localStorage === 'undefined') return FALLBACK_LOCALE
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored === 'zh' || stored === 'en') return stored
} catch {
// Storage access can throw (privacy mode); the default below covers it.
}
return FALLBACK_LOCALE
}
/** Persist the locale id; storage failures are non-fatal (preference resets next boot). */
function persistPreference(id: LocaleId): void {
if (typeof localStorage === 'undefined') return
try {
localStorage.setItem(STORAGE_KEY, id)
} catch {
// Storage access can throw (privacy mode / quota); the preference simply
// does not survive the session.
}
}
/** Required services: the slot registry (the feature registers its own settings row). */
export const inject = ['slots']
/**
* Client plugin body: provide the locale service with base dictionaries and
* register the feature-owned Language preference row into the General
* section's item slot (a feature owns its settings surface).
* @param ctx - client cordis context.
*/
export function apply(ctx: ClientContext): void {
const locale = new LocaleService(ctx)
locale.register(COMMON_NS, 'zh', zh)
locale.register(COMMON_NS, 'en', en)
locale.register(SETTINGS_NS, 'zh', { 'language.title': '语言' })
locale.register(SETTINGS_NS, 'en', { 'language.title': 'Language' })
ctx.provide('locale', locale)
const store = createLanguageRowStore()
let bound: BoundActions<typeof store> | undefined
const sync = (snapshot: LocaleSnapshot): void => {
bound?.sync(
snapshot.active,
snapshot.locales.map(l => ({ id: l.id, label: l.label })),
snapshot.revision,
)
}
ctx.on('locale/change', sync)
const injected = (actions: BoundActions<typeof store>): LanguageRowInjected => {
bound = actions
// Re-sync from the getter so no event is lost between registration and
// first render (the store's revision guard drops stale duplicates).
sync(locale.getLocale())
return {
t: locale.bind(SETTINGS_NS),
setLocale: (id) => { locale.setLocale(id) },
}
}
ctx.effect(() => {
const deferred = deferRegistration(ctx.slots, 'settings.general.item', LanguageRow, () =>
ctx.slots.register({
name: 'settings.general.item',
id: 'language',
order: 0,
store,
inject: injected,
}, LanguageRow))
return () => { deferred.dispose() }
}, 'locale: language settings row registration')
}

View File

@@ -0,0 +1,26 @@
/**
* The `settings.general.item` slot type — one preference row inside the
* settings General section, contributed by the feature plugin that owns the
* preference (locale → Language, ui-theme → Appearance). Options: `id` (row
* key), `order` (row position). Rows draw their own internals (row layout,
* separators via CSS); the section column only stacks them.
*
* TYPE HOME RATIONALE: the slot is declared at runtime by
* ui-settings-general's General entry, but its type lives here — this
* package is the common dependency of every item registrant (any settings
* row carries copy, so every registrant already depends on locale), whereas
* the declarer's own contract is unreachable for locale/ui-theme without a
* reference cycle.
*/
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
/** One preference row inside the settings General section (see module JSDoc). */
'settings.general.item': { kind: 'list'; scope: 'root'; owner: SettingsGeneralItemOwnerProps }
}
}
/** Owner share of a General preference row (the section supplies nothing). */
export interface SettingsGeneralItemOwnerProps {
/** Marker field: item owner props are intentionally empty. */
children?: never
}

View File

@@ -0,0 +1,47 @@
/**
* Language row slot store: a mirror of the locale service snapshot. The
* plugin's apply-world change listener is the only writer; the row component
* reads via props.useStore.
*/
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client'
/** One selectable locale row (id + self-described label). */
export interface LanguageOptionRow {
/** Locale id (the setLocale argument). */
id: string
/** Display name in its own language (中文 / English). */
label: string
}
/** Store state mirrored from the locale snapshot. */
export interface LanguageRowState {
/** Active locale id. */
active: string
/** Selectable locales in display order. */
options: LanguageOptionRow[]
/** Service revision; -1 until first sync so revision 0 lands as a change. */
revision: number
}
/** Declared action shape giving the exported factory a stable return type. */
type LanguageRowActions = {
sync: (draft: LanguageRowState, active: string, options: LanguageOptionRow[], revision: number) => void
}
/**
* Declares the Language row state and write surface.
* @returns the store handle.
*/
export function createLanguageRowStore(): EngineStoreHandle<LanguageRowState, LanguageRowActions> {
return defineStore({
init: (): LanguageRowState => ({ active: '', options: [], revision: -1 }),
actions: {
sync: (d, active: string, options: LanguageOptionRow[], revision: number) => {
if (revision <= d.revision) return
d.active = active
d.options = options
d.revision = revision
},
},
})
}

View File

@@ -0,0 +1,6 @@
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}
declare module '*.css'

View File

@@ -0,0 +1,4 @@
/** Host loader entry for the browser implementation exported from `./client`. */
/** Host plugin body — no host-side behavior for the locale plugin. */
export function apply(): void {}

View File

@@ -0,0 +1,32 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-locale`.
* @module @deepseek-ai/dsh-client-locale/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-locale'
/** Cordis companion plugin name. */
export const name = 'client-locale-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: ns-by-locale dictionary registry with a stable
* bind(ns) surface — it emits no cordis events and owns no cross-plugin
* mutable relation; fallback-chain resolution and locale-store behavior are
* asserted directly by this package's behavior specs.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,2 @@
/** en base dictionary for the common namespace (starter skeleton; texts land with their features). */
export const en: Record<string, string> = {}

View File

@@ -0,0 +1,2 @@
/** zh base dictionary for the common namespace (starter skeleton; texts land with their features). */
export const zh: Record<string, string> = {}

View File

@@ -0,0 +1,116 @@
/** locale apply wiring: service + dictionaries provision, declaration-aware
* Language row registration, snapshot projection into the row store, and
* recovery after an HMR collapse of the declaring entry. */
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { apply, inject, SETTINGS_NS } from '@deepseek-ai/dsh-client-locale/client'
import type { LanguageRowInjected, LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { LanguageRow } from '../src/client/LanguageRow.tsx'
import type { createLanguageRowStore } from '../src/client/settings-store.ts'
const SLOT = 'settings.general.item'
async function bench() {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
return { ctx, slots: ctx.get('slots') as SlotsService }
}
/** Stand in for the settings shell: declare the General item slot from root. */
function declareItems(slots: SlotsService): () => void {
return slots.register(
{ name: 'root', children: { [SLOT]: { kind: 'list', scope: 'root' } } } as never,
() => null,
)
}
/** Mirror the framework's inject choreography: bake a real instance from the
* declared handle and hand its actions to the entry's inject factory. */
function faceOf(slots: SlotsService) {
const entry = slots.entries(SLOT).find(e => e.component === LanguageRow)!
const handle = entry.store as ReturnType<typeof createLanguageRowStore>
const instance = handle.create()
const face = (entry.inject as unknown as (a: typeof instance.actions) => LanguageRowInjected)(instance.actions)
return { entry, instance, face }
}
describe('locale apply', () => {
it('declares the slot service', () => {
expect(inject).toEqual(['slots'])
})
it('provides the service with base + settings dictionaries and registers the row (declaration before or after apply)', async () => {
const before = await bench()
declareItems(before.slots)
await before.ctx.plugin({ inject: [...inject], apply }).await()
const locale = before.ctx.get('locale') as LocaleService
// Base dictionaries are registered: the (ns, locale) seats are occupied.
expect(() => locale.register('common', 'zh', {})).toThrow('already has locale')
expect(() => locale.register('common', 'en', {})).toThrow('already has locale')
expect(locale.bind(SETTINGS_NS)('language.title')).toBe('语言')
const entry = before.slots.entries(SLOT).find(e => e.component === LanguageRow)!
expect(entry.options).toMatchObject({ id: 'language', order: 0 })
const after = await bench()
const fiber = after.ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(after.slots.entries(SLOT)).toHaveLength(0)
declareItems(after.slots)
await Promise.resolve()
expect(after.slots.entries(SLOT).some(e => e.component === LanguageRow)).toBe(true)
})
it('projects service snapshots into the row store and routes face writes back', async () => {
const b = await bench()
declareItems(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const locale = b.ctx.get('locale') as LocaleService
// An event ahead of any inject hits the unbound-actions arm.
locale.setLocale('en')
const { instance, face } = faceOf(b.slots)
// The inject-time re-sync sealed the init window: the mirror is current.
expect(instance.getSnapshot().active).toBe('en')
expect(instance.getSnapshot().options.map(o => o.id)).toEqual(['zh', 'en'])
expect(face.t('language.title')).toBe('Language')
face.setLocale('zh')
expect(locale.getLocale().active).toBe('zh')
expect(instance.getSnapshot().active).toBe('zh')
expect(face.t('language.title')).toBe('语言')
})
it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => {
const b = await bench()
const host = declareItems(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
expect(b.slots.entries(SLOT)).toHaveLength(1)
// Collapse: the declarer dies, the cascade removes our entry while the
// apply closure still holds its (now stale) disposer.
host()
expect(b.slots.entries(SLOT)).toHaveLength(0)
declareItems(b.slots)
await Promise.resolve()
expect(b.slots.entries(SLOT).some(e => e.component === LanguageRow)).toBe(true)
})
it('teardown removes the row; teardown without a declaration is quiet', async () => {
const b = await bench()
declareItems(b.slots)
const fiber = b.ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(b.slots.entries(SLOT)).toHaveLength(1)
await fiber.dispose()
expect(b.slots.entries(SLOT)).toHaveLength(0)
// Never-declared bench: the effect disposer's dispose arm stays undefined.
const quiet = await bench()
const f2 = quiet.ctx.plugin({ inject: [...inject], apply })
await f2.await()
await f2.dispose()
expect(quiet.slots.entries(SLOT)).toHaveLength(0)
})
})

View File

@@ -0,0 +1,34 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { apply as nodeApply } from '@deepseek-ai/dsh-client-locale'
import { apply as clientApply, COMMON_NS, LocaleService, inject } from '@deepseek-ai/dsh-client-locale/client'
import * as LocaleInvariant from '@deepseek-ai/dsh-client-locale/invariant'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import InvariantService from '@deepseek-ai/dsh-invariants'
describe('invariant companion', () => {
it('registers under the package name with an empty installer', async () => {
const ctx = new Context()
await ctx.plugin(InvariantService, { enabled: true })
await expect(ctx.plugin(LocaleInvariant).await()).resolves.toBeDefined()
})
it('node-half apply is a no-op host placeholder', () => {
nodeApply()
expect(true).toBe(true) // reaching here without throw is the contract
})
it('client apply provides ctx.locale seeded with the zh/en common namespace', async () => {
// The feature registers its own Language settings row, hence the slots edge.
expect(inject).toEqual(['slots'])
const ctx = new Context()
new SlotsService(ctx)
await ctx.plugin({ inject, apply: clientApply }).await()
const locale = ctx.get('locale')
expect(locale).toBeInstanceOf(LocaleService)
// Seeded dictionaries occupy the (ns, locale) seats even while empty.
expect(() => (locale as LocaleService).register(COMMON_NS, 'zh', {})).toThrow('already has locale')
expect(() => (locale as LocaleService).register(COMMON_NS, 'en', {})).toThrow('already has locale')
})
})

View File

@@ -0,0 +1,82 @@
// @vitest-environment jsdom
/** LanguageRow behavior: selector pill shows the active locale, the menu
* opens/closes, and selection drives setLocale. */
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { createSnapshotStore, type SessionListState, type WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { LanguageRow } from '../src/client/LanguageRow.tsx'
import type { LanguageRowComponentProps } from '../src/client/LanguageRow.tsx'
import { createLanguageRowStore } from '../src/client/settings-store.ts'
afterEach(cleanup)
const OPTIONS = [{ id: 'zh', label: '中文' }, { id: 'en', label: 'English' }]
/** Empty global standard-kit hooks (the row reads neither). */
function emptySessions() {
const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready' })
return bindSnapshotSelector(store)
}
function emptyWorkspaces() {
const store = createSnapshotStore<WorkspaceListState>({
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})
return bindSnapshotSelector(store)
}
function mount(active = 'en') {
// Real store instance — the sanctioned zero-machinery path for tests.
const store = createLanguageRowStore().create()
store.actions.sync(active, OPTIONS, 0)
const setLocale = vi.fn()
const props: LanguageRowComponentProps = {
useSessions: emptySessions(),
useWorkspaces: emptyWorkspaces(),
useStore: bindSnapshotSelector(store),
actions: store.actions,
t: (key: string) => key === 'language.title' ? 'Language' : key,
setLocale,
}
render(<LanguageRow {...props} />)
return { store, setLocale }
}
describe('LanguageRow', () => {
it('shows the title and the active locale label on the selector pill', () => {
mount('en')
expect(screen.getByText('Language')).toBeDefined()
const trigger = screen.getByRole('button', { name: /English/ })
expect(trigger.getAttribute('aria-expanded')).toBe('false')
})
it('opens the menu, selects a locale, and closes', () => {
const b = mount('en')
const trigger = screen.getByRole('button', { name: /English/ })
fireEvent.click(trigger)
expect(trigger.getAttribute('aria-expanded')).toBe('true')
fireEvent.click(screen.getByRole('menuitem', { name: '中文' }))
expect(b.setLocale).toHaveBeenCalledWith('zh')
expect(trigger.getAttribute('aria-expanded')).toBe('false')
expect(screen.queryByRole('menuitem', { name: '中文' })).toBeNull()
})
it('closes on outside pointerdown without selecting', () => {
const b = mount('en')
fireEvent.click(screen.getByRole('button', { name: /English/ }))
expect(screen.getByRole('menuitem', { name: '中文' })).toBeDefined()
fireEvent.pointerDown(document.body)
expect(screen.queryByRole('menuitem', { name: '中文' })).toBeNull()
expect(b.setLocale).not.toHaveBeenCalled()
})
it('follows store changes; an unknown active id falls back to the id itself', () => {
const b = mount('en')
act(() => { b.store.actions.sync('zh', OPTIONS, 1) })
expect(screen.getByRole('button', { name: /中文/ })).toBeDefined()
act(() => { b.store.actions.sync('fr', OPTIONS, 2) })
expect(screen.getByRole('button', { name: /fr/ })).toBeDefined()
})
})

View File

@@ -0,0 +1,102 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client'
import { LocaleService, STORAGE_KEY } from '@deepseek-ai/dsh-client-locale/client'
const make = (): { ctx: Context; svc: LocaleService; events: LocaleSnapshot[] } => {
const ctx = new Context()
const events: LocaleSnapshot[] = []
ctx.on('locale/change', (snapshot) => { events.push(snapshot) })
return { ctx, svc: new LocaleService(ctx), events }
}
describe('LocaleService', () => {
beforeEach(() => {
localStorage.clear()
})
it('translates through the active-locale -> zh -> key chain', () => {
const { svc } = make()
svc.register('ns', 'zh', { hello: '你好', onlyZh: '仅中文' })
svc.register('ns', 'en', { hello: 'Hello' })
const t = svc.bind('ns')
expect(svc.getLocale().active).toBe('zh')
expect(t('hello')).toBe('你好')
svc.setLocale('en')
expect(t('hello')).toBe('Hello')
expect(t('onlyZh')).toBe('仅中文')
expect(t('missing.key')).toBe('missing.key')
})
it('interpolates {name} params and leaves unknown placeholders intact', () => {
const { svc } = make()
svc.register('ns', 'zh', { greet: '你好,{name}!第 {n} 次', partial: '{known} 与 {unknown}' })
const t = svc.bind('ns')
expect(t('greet', { name: '世界', n: 2 })).toBe('你好,世界!第 2 次')
expect(t('partial', { known: 'A' })).toBe('A 与 {unknown}')
})
it('bind returns a stable per-namespace function identity', () => {
const { svc } = make()
expect(svc.bind('a')).toBe(svc.bind('a'))
expect(svc.bind('a')).not.toBe(svc.bind('b'))
})
it('rejects duplicate (ns, locale) and disposer only removes its own dict', () => {
const { svc } = make()
const dispose = svc.register('ns', 'zh', { k: 'v1' })
expect(() => svc.register('ns', 'zh', { k: 'v2' })).toThrow('already has locale')
dispose()
const t = svc.bind('ns')
expect(t('k')).toBe('k')
svc.register('ns', 'zh', { k: 'v2' })
expect(t('k')).toBe('v2')
dispose()
expect(t('k')).toBe('v2')
})
it('setLocale persists, republishes an immutable snapshot, and no-ops on same value', () => {
const { svc, events } = make()
svc.setLocale('en')
expect(svc.getLocale().active).toBe('en')
expect(localStorage.getItem(STORAGE_KEY)).toBe('en')
expect(events).toHaveLength(1)
expect(events[0]).toBe(svc.getLocale())
expect(events[0]!.revision).toBe(1)
svc.setLocale('en')
expect(events).toHaveLength(1)
})
it('throws on unknown locale ids', () => {
const { svc } = make()
expect(() => { svc.setLocale('fr') }).toThrow('not registered')
})
it('restores a persisted locale and falls back to zh on garbage', () => {
localStorage.setItem(STORAGE_KEY, 'en')
expect(make().svc.getLocale().active).toBe('en')
localStorage.setItem(STORAGE_KEY, 'fr')
expect(make().svc.getLocale().active).toBe('zh')
})
it('runs without localStorage (node boots): defaults on read, no-op on write', () => {
vi.stubGlobal('localStorage', undefined)
try {
const { svc } = make()
expect(svc.getLocale().active).toBe('zh')
svc.setLocale('en')
expect(svc.getLocale().active).toBe('en')
} finally {
vi.unstubAllGlobals()
}
})
it('exposes the two shipped locales with self-described labels', () => {
const { svc } = make()
expect(svc.getLocale().locales).toEqual([
{ id: 'zh', label: '中文' },
{ id: 'en', label: 'English' },
])
})
})

View File

@@ -0,0 +1,30 @@
/** Language row store: snapshot-mirror action and the revision guard. */
import { describe, expect, it } from 'vitest'
import { createLanguageRowStore } from '../src/client/settings-store.ts'
const OPTIONS = [{ id: 'zh', label: '中文' }, { id: 'en', label: 'English' }]
describe('createLanguageRowStore', () => {
it('init shape: empty mirror with revision at -1', () => {
const store = createLanguageRowStore().create()
expect(store.getSnapshot()).toEqual({ active: '', options: [], revision: -1 })
})
it('sync mirrors the snapshot and advances the revision', () => {
const store = createLanguageRowStore().create()
store.actions.sync('zh', OPTIONS, 0)
expect(store.getSnapshot()).toEqual({ active: 'zh', options: OPTIONS, revision: 0 })
store.actions.sync('en', OPTIONS, 1)
expect(store.getSnapshot().active).toBe('en')
expect(store.getSnapshot().revision).toBe(1)
})
it('revision guard drops stale and duplicate writes', () => {
const store = createLanguageRowStore().create()
store.actions.sync('en', OPTIONS, 5)
store.actions.sync('zh', OPTIONS, 4)
store.actions.sync('zh', OPTIONS, 5)
expect(store.getSnapshot().active).toBe('en')
expect(store.getSnapshot().revision).toBe(5)
})
})

View File

@@ -0,0 +1,27 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../runtime"
},
{
"path": "../ui-primitives"
},
{
"path": "../ui-slots"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -0,0 +1,3 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-locale', ['lib/types/index.js', 'lib/types/invariant.js'])