fix(i18n): route the family sidebar rows through the SDK locale runtime

GUI verification showed the L1 sidebar rows of task-board, dsh-ssh and
dsh-skill-explorer staying Chinese while their settings sections switched
language: those rows read a package-local t/tt helper that picks zh/en
from documentElement.lang, which the SDK Language switch never updates
(the rows also stayed Chinese under English — proven by a live switch
test). The module-level helpers now prefer a wired SDK translate seat
(ctx.locale.bind(NS), set in apply once the dictionaries register) and
fall back to the document-language pick only when unwired; the shared
sidebar-entry core gains an optional refresh subscription (ctx.locale
.subscribe) that re-applies label/aria-label/tooltip on locale changes,
and the board/panel mounts re-render an open view on the same signal.
Verified live: switching to Русский flips the rows to Доска задач /
Центр навыков without a reload.
This commit is contained in:
zhu1090093659
2026-08-31 09:30:22 +08:00
parent a86735ff6a
commit 170b3df318
22 changed files with 369 additions and 51 deletions
@@ -18,6 +18,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
// Type-only: pulls the LocaleNamespaceMap merge table.
import type {} from '@deepseek-ai/dsh-client-ui-slots'
import { SkillApi } from './api.ts'
import { setRuntimeTranslate } from './panel-helpers.ts'
import { en, zh, type SkillExplorerKey } from './locales.ts'
import { mountPanel } from './panel-mount.tsx'
import { mountSidebarEntry } from './sidebar-entry.ts'
@@ -58,11 +59,16 @@ export function apply(ctx: ClientContext): void {
}
}, 'skill-explorer: dictionaries')
// Wire the SDK translate seat into the module-level tt (sidebar row and
// other plain-DOM callers): reads the active locale at call time, so they
// follow the Language setting without a reload.
try { setRuntimeTranslate(ctx.locale.bind(NS)) } catch { /* locale missing: document-language fallback stays */ }
const api = new SkillApi()
const panel = mountPanel(api)
const panel = mountPanel(api, ctx.locale)
const disposers: Array<() => void> = []
try {
disposers.push(mountSidebarEntry(() => panel.toggle()))
disposers.push(mountSidebarEntry(() => panel.toggle(), ctx.locale))
disposers.push(() => panel.dispose())
} catch (error) {
// DOM failures degrade the panel, never the GUI.
@@ -13,8 +13,22 @@ export function dictionary(): Record<string, string> {
return lang.toLowerCase().startsWith('en') ? { ...en } : { ...zh }
}
/**
* SDK translate seat wired by the browser apply() once ctx.locale is bound
* (setRuntimeTranslate): reads the ACTIVE locale at call time, so plain-DOM
* surfaces follow a runtime language switch. The document-language pick stays
* as the unwired fallback (locale service absent, module-scope early callers).
*/
let runtimeT: ((key: SkillExplorerKey, values?: TranslateValues) => string) | undefined
/** Wire the SDK translate seat; pass undefined to restore the document-language pick. */
export function setRuntimeTranslate(t: ((key: SkillExplorerKey, values?: TranslateValues) => string) | undefined): void {
runtimeT = t
}
/** Translate a key with optional {name} template params (current language). */
export function tt(key: SkillExplorerKey, values?: TranslateValues): string {
if (runtimeT !== undefined) return runtimeT(key, values)
let text: string = dictionary()[key] ?? key
if (values !== undefined) {
for (const [name, value] of Object.entries(values)) {
@@ -9,6 +9,7 @@
import { createRoot, type Root } from 'react-dom/client'
import type { SkillApi } from './api.ts'
import { SkillPanel } from './SkillPanel.tsx'
import type { LocaleRefreshSource } from './sidebar-entry.ts'
/** Mounted panel controller: toggle/open/close plus the disposer. */
export interface SkillPanelMount {
@@ -21,11 +22,14 @@ export interface SkillPanelMount {
/**
* Mount the skill center overlay panel.
* @param api - the skill center API client.
* @param locale - locale-change source; when given, re-renders an open panel
* on a Language switch.
* @returns controller (toggle/open/close) and the disposer.
*/
export function mountPanel(api: SkillApi): SkillPanelMount {
export function mountPanel(api: SkillApi, locale?: LocaleRefreshSource): SkillPanelMount {
let root: Root | undefined
let container: HTMLDivElement | undefined
let unsubscribeLocale: (() => void) | undefined
const close = (): void => {
if (root === undefined) return
@@ -34,6 +38,11 @@ export function mountPanel(api: SkillApi): SkillPanelMount {
container?.remove()
container = undefined
}
try {
unsubscribeLocale = locale?.subscribe(() => {
if (root !== undefined) root.render(<SkillPanel api={api} onClose={close} />)
})
} catch { /* locale service absent: the panel follows its next natural re-render */ }
const open = (): void => {
if (root !== undefined) return
@@ -52,5 +61,5 @@ export function mountPanel(api: SkillApi): SkillPanelMount {
else open()
}
return { toggle, open, close, dispose: close }
return { toggle, open, close, dispose: () => { close(); unsubscribeLocale?.() } }
}
@@ -36,6 +36,14 @@ export interface SidebarEntryOptions {
label(): string
/** Optional localized tooltip (title attribute). */
tooltip?(): string
/**
* Optional locale-change subscription: re-applies label / aria-label /
* tooltip whenever the active locale changes. Plain-DOM rows would
* otherwise keep the label captured at mount; pass the SDK locale runtime
* subscription (ctx.locale.subscribe) so the row follows the language
* switch without a reload.
*/
refresh?: { subscribe(listener: () => void): () => void }
/** Click action (open/toggle the owning panel). */
onToggle(): void
/** Family-block position: 'before' inserts ahead of sibling plugin rows, 'after' behind them. */
@@ -76,7 +84,7 @@ function newSessionButton(root: HTMLElement): HTMLButtonElement | undefined {
}
/** Build the entry row (a detached button; insert once the shell is up). */
function createEntry(options: SidebarEntryOptions): HTMLButtonElement {
function createEntry(options: SidebarEntryOptions): { entry: HTMLButtonElement; applyLabel: () => void } {
const entry = document.createElement('button')
entry.type = 'button'
entry.setAttribute(options.rowAttribute, '')
@@ -85,12 +93,20 @@ function createEntry(options: SidebarEntryOptions): HTMLButtonElement {
entry.setAttribute('data-dsh-part', 'sidebar-entry')
}
entry.className = options.css['entry'] ?? ''
entry.setAttribute('aria-label', options.label())
if (options.tooltip !== undefined) entry.setAttribute('title', options.tooltip())
entry.innerHTML = '<span class="' + (options.css['entryIcon'] ?? '') + '">' + options.icon
+ '</span><span class="' + (options.css['entryLabel'] ?? '') + '">' + options.label() + '</span>'
const labelSpan = document.createElement('span')
labelSpan.className = options.css['entryLabel'] ?? ''
const iconSpan = document.createElement('span')
iconSpan.className = options.css['entryIcon'] ?? ''
iconSpan.innerHTML = options.icon
entry.append(iconSpan, labelSpan)
const applyLabel = (): void => {
entry.setAttribute('aria-label', options.label())
if (options.tooltip !== undefined) entry.setAttribute('title', options.tooltip())
labelSpan.textContent = options.label()
}
applyLabel()
entry.addEventListener('click', options.onToggle)
return entry
return { entry, applyLabel }
}
/** Re-insert the entry after the New Session row (before the browser region). */
@@ -132,9 +148,18 @@ export function mountSidebarEntry(options: SidebarEntryOptions): () => void {
if (typeof document !== 'undefined' && document.querySelector(options.rowSelector) !== null) {
return () => {}
}
const entry = createEntry(options)
const { entry, applyLabel } = createEntry(options)
let root: HTMLElement | undefined
let placed = false
let unsubscribeRefresh: (() => void) | undefined
if (options.refresh !== undefined) {
try {
unsubscribeRefresh = options.refresh.subscribe(applyLabel)
} catch {
// A throwing subscription must not break the mount; the label stays at
// its initial value and the next reload resolves it again.
}
}
const tryPlace = (): void => {
if (root !== undefined && !root.isConnected) {
@@ -202,6 +227,7 @@ export function mountSidebarEntry(options: SidebarEntryOptions): () => void {
return () => {
waitObserver.disconnect()
rootObserver.disconnect()
unsubscribeRefresh?.()
unsubscribeActive?.()
entry.remove()
}
@@ -20,13 +20,18 @@ export const ENTRY_SELECTOR = '[data-dsh-skill-explorer-entry]'
/** Inline book icon normalized to the shell's 18px navigation glyph size. */
const ICON = '<svg viewBox="0 0 16 16" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 3.2C6.6 2 4.5 2 3 2v10.5c1.5 0 3.6 0 5 1.3 1.4-1.3 3.5-1.3 5-1.3V2c-1.5 0-3.6 0-5 1.2z"/><path d="M8 3.2v10.6"/></svg>'
/** Locale-change subscription the shared core asks for (ctx.locale.subscribe shape). */
export interface LocaleRefreshSource { subscribe(listener: () => void): () => void }
/**
* Mount the sidebar entry, waiting for the shell to render and self-healing
* on later React re-renders.
* @param onClick - opens the skill center overlay.
* @param locale - locale-change source; when given, re-applies the label on
* a Language switch (the plain-DOM row otherwise keeps the mount-time copy).
* @returns disposer removing the entry and its observers.
*/
export function mountSidebarEntry(onClick: () => void): () => void {
export function mountSidebarEntry(onClick: () => void, locale?: LocaleRefreshSource): () => void {
return mountSharedSidebarEntry({
rowAttribute: 'data-dsh-skill-explorer-entry',
rowSelector: ENTRY_SELECTOR,
@@ -37,6 +42,7 @@ export function mountSidebarEntry(onClick: () => void): () => void {
css,
label: () => tt('entry.label'),
tooltip: () => tt('entry.tooltip'),
refresh: locale === undefined ? undefined : { subscribe: (listener) => locale.subscribe(listener) },
onToggle: onClick,
position: 'after',
familySelectors: ['[data-dsh-taskboard-entry]', '[data-dsh-ssh-entry]', '[data-dsh-skill-explorer-entry]'],
@@ -0,0 +1,22 @@
import { afterEach, describe, expect, it } from 'vitest'
import { setRuntimeTranslate, tt } from '../src/client/panel-helpers.ts'
describe('skill-explorer localized tt runtime wiring', () => {
afterEach(() => { setRuntimeTranslate(undefined) })
it('prefers the wired SDK translate seat (reads the active locale at call time)', () => {
setRuntimeTranslate((key) => `RU(${key})`)
expect(tt('entry.label')).toBe('RU(entry.label)')
})
it('falls back to the document-language dictionary when unwired', () => {
const label = tt('entry.label')
expect(label.startsWith('RU(')).toBe(false)
expect(label.length).toBeGreaterThan(0)
})
it('passes template params through to the wired seat', () => {
setRuntimeTranslate((key, values) => `${key}:${String(values?.count)}`)
expect(tt('list.count', { count: 3 })).toBe('list.count:3')
})
})
+8 -2
View File
@@ -25,6 +25,7 @@ import { en, zh, type SshKey } from './locales.ts'
import { mountPanel } from './mount.tsx'
import { PanelController } from './panel/controller.ts'
import type { TerminalFontSource } from './panel/helpers.ts'
import { setRuntimeTranslate } from './panel/helpers.ts'
import { mountSidebarEntry } from './sidebar-entry.ts'
import { reportDailyHeartbeat } from './telemetry.ts'
@@ -89,6 +90,11 @@ export function apply(ctx: ClientContext): void {
}
}, 'dsh-ssh: dictionaries')
// Wire the SDK translate seat into the module-level tt (sidebar row and
// other plain-DOM callers): reads the active locale at call time, so they
// follow the Language setting without a reload.
try { setRuntimeTranslate(ctx.locale.bind(NS)) } catch { /* locale missing: document-language fallback stays */ }
const controller = new PanelController()
const api = new SshApi()
// Live terminal-font preference (issue #577): the settings namespace is
@@ -105,8 +111,8 @@ export function apply(ctx: ClientContext): void {
}
const disposers: Array<() => void> = []
try {
disposers.push(mountSidebarEntry(controller))
disposers.push(mountPanel(controller, api, terminalFont))
disposers.push(mountSidebarEntry(controller, ctx.locale))
disposers.push(mountPanel(controller, api, terminalFont, ctx.locale))
} catch (error) {
// DOM failures degrade the panel, never the GUI.
console.warn('[dsh-ssh] mount failed:', error)
+11 -1
View File
@@ -16,6 +16,7 @@ import type { SshApi } from './api.ts'
import type { PanelController } from './panel/controller.ts'
import type { TerminalFontSource } from './panel/helpers.ts'
import { SshPanel } from './panel/SshPanel.tsx'
import type { LocaleRefreshSource } from './sidebar-entry.ts'
import css from './panel/panel.module.css'
/** The injected panel container (kept in the DOM, hidden when inactive). */
@@ -40,11 +41,19 @@ function conversationColumn(): HTMLElement | undefined {
* @param controller - the panel controller driving the view.
* @param api - the SSH API client the tabs operate through.
* @param terminalFont - live terminal-font setting source (issue #577).
* @param locale - locale-change source; when given, re-renders an open panel
* on a Language switch.
* @returns disposer unmounting the tree and restoring the column.
*/
export function mountPanel(controller: PanelController, api: SshApi, terminalFont?: TerminalFontSource): () => void {
export function mountPanel(controller: PanelController, api: SshApi, terminalFont?: TerminalFontSource, locale?: LocaleRefreshSource): () => void {
let root: Root | undefined
let container: HTMLDivElement | undefined
let unsubscribeLocale: (() => void) | undefined
try {
unsubscribeLocale = locale?.subscribe(() => {
if (root !== undefined) root.render(<SshPanel controller={controller} api={api} terminalFont={terminalFont} />)
})
} catch { /* locale service absent: the panel follows its next natural re-render */ }
const ensure = (): void => {
if (container !== undefined) {
@@ -110,6 +119,7 @@ export function mountPanel(controller: PanelController, api: SshApi, terminalFon
document.removeEventListener(ACTIVATE_EVENT, onOtherActivate)
waitObserver.disconnect()
unsubscribe()
unsubscribeLocale?.()
document.documentElement.removeAttribute(ACTIVE_ATTR)
root?.unmount()
root = undefined
@@ -14,8 +14,22 @@ export function dictionary(): Record<string, string> {
return lang.toLowerCase().startsWith('en') ? { ...en } : { ...zh }
}
/**
* SDK translate seat wired by the browser apply() once ctx.locale is bound
* (setRuntimeTranslate): reads the ACTIVE locale at call time, so plain-DOM
* surfaces follow a runtime language switch. The document-language pick stays
* as the unwired fallback (locale service absent, module-scope early callers).
*/
let runtimeT: ((key: SshKey, values?: TranslateValues) => string) | undefined
/** Wire the SDK translate seat; pass undefined to restore the document-language pick. */
export function setRuntimeTranslate(t: ((key: SshKey, values?: TranslateValues) => string) | undefined): void {
runtimeT = t
}
/** Translate a key with optional {name} template params (current language). */
export function tt(key: SshKey, values?: TranslateValues): string {
if (runtimeT !== undefined) return runtimeT(key, values)
return t(dictionary(), key, values)
}
@@ -36,6 +36,14 @@ export interface SidebarEntryOptions {
label(): string
/** Optional localized tooltip (title attribute). */
tooltip?(): string
/**
* Optional locale-change subscription: re-applies label / aria-label /
* tooltip whenever the active locale changes. Plain-DOM rows would
* otherwise keep the label captured at mount; pass the SDK locale runtime
* subscription (ctx.locale.subscribe) so the row follows the language
* switch without a reload.
*/
refresh?: { subscribe(listener: () => void): () => void }
/** Click action (open/toggle the owning panel). */
onToggle(): void
/** Family-block position: 'before' inserts ahead of sibling plugin rows, 'after' behind them. */
@@ -76,7 +84,7 @@ function newSessionButton(root: HTMLElement): HTMLButtonElement | undefined {
}
/** Build the entry row (a detached button; insert once the shell is up). */
function createEntry(options: SidebarEntryOptions): HTMLButtonElement {
function createEntry(options: SidebarEntryOptions): { entry: HTMLButtonElement; applyLabel: () => void } {
const entry = document.createElement('button')
entry.type = 'button'
entry.setAttribute(options.rowAttribute, '')
@@ -85,12 +93,20 @@ function createEntry(options: SidebarEntryOptions): HTMLButtonElement {
entry.setAttribute('data-dsh-part', 'sidebar-entry')
}
entry.className = options.css['entry'] ?? ''
entry.setAttribute('aria-label', options.label())
if (options.tooltip !== undefined) entry.setAttribute('title', options.tooltip())
entry.innerHTML = '<span class="' + (options.css['entryIcon'] ?? '') + '">' + options.icon
+ '</span><span class="' + (options.css['entryLabel'] ?? '') + '">' + options.label() + '</span>'
const labelSpan = document.createElement('span')
labelSpan.className = options.css['entryLabel'] ?? ''
const iconSpan = document.createElement('span')
iconSpan.className = options.css['entryIcon'] ?? ''
iconSpan.innerHTML = options.icon
entry.append(iconSpan, labelSpan)
const applyLabel = (): void => {
entry.setAttribute('aria-label', options.label())
if (options.tooltip !== undefined) entry.setAttribute('title', options.tooltip())
labelSpan.textContent = options.label()
}
applyLabel()
entry.addEventListener('click', options.onToggle)
return entry
return { entry, applyLabel }
}
/** Re-insert the entry after the New Session row (before the browser region). */
@@ -132,9 +148,18 @@ export function mountSidebarEntry(options: SidebarEntryOptions): () => void {
if (typeof document !== 'undefined' && document.querySelector(options.rowSelector) !== null) {
return () => {}
}
const entry = createEntry(options)
const { entry, applyLabel } = createEntry(options)
let root: HTMLElement | undefined
let placed = false
let unsubscribeRefresh: (() => void) | undefined
if (options.refresh !== undefined) {
try {
unsubscribeRefresh = options.refresh.subscribe(applyLabel)
} catch {
// A throwing subscription must not break the mount; the label stays at
// its initial value and the next reload resolves it again.
}
}
const tryPlace = (): void => {
if (root !== undefined && !root.isConnected) {
@@ -202,6 +227,7 @@ export function mountSidebarEntry(options: SidebarEntryOptions): () => void {
return () => {
waitObserver.disconnect()
rootObserver.disconnect()
unsubscribeRefresh?.()
unsubscribeActive?.()
entry.remove()
}
+7 -1
View File
@@ -19,13 +19,18 @@ export const ENTRY_SELECTOR = '[data-dsh-ssh-entry]'
/** Inline terminal glyph sized to the shell's current sidebar navigation icons. */
const ICON = '<svg viewBox="0 0 16 16" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="1.75" y="2.25" width="12.5" height="11.5" rx="1.75"/><path d="M4.25 5.25l2.75 2.75-2.75 2.75"/><path d="M8.5 10.75h3.25"/></svg>'
/** Locale-change subscription the shared core asks for (ctx.locale.subscribe shape). */
export interface LocaleRefreshSource { subscribe(listener: () => void): () => void }
/**
* Mount the sidebar entry, waiting for the shell to render and self-healing
* on later React re-renders.
* @param controller - the panel controller the entry toggles.
* @param locale - locale-change source; when given, re-applies the label on
* a Language switch (the plain-DOM row otherwise keeps the mount-time copy).
* @returns disposer removing the entry and its observers.
*/
export function mountSidebarEntry(controller: PanelController): () => void {
export function mountSidebarEntry(controller: PanelController, locale?: LocaleRefreshSource): () => void {
return mountSharedSidebarEntry({
rowAttribute: 'data-dsh-ssh-entry',
rowSelector: ENTRY_SELECTOR,
@@ -34,6 +39,7 @@ export function mountSidebarEntry(controller: PanelController): () => void {
css,
label: () => tt('entry.label'),
tooltip: () => tt('entry.tooltip'),
refresh: locale === undefined ? undefined : { subscribe: (listener) => locale.subscribe(listener) },
onToggle: () => { controller.toggle() },
position: 'after',
familySelectors: ['[data-dsh-taskboard-entry]', '[data-dsh-ssh-entry]'],
@@ -0,0 +1,22 @@
import { afterEach, describe, expect, it } from 'vitest'
import { setRuntimeTranslate, tt } from '../src/client/panel/helpers.ts'
describe('dsh-ssh localized tt runtime wiring', () => {
afterEach(() => { setRuntimeTranslate(undefined) })
it('prefers the wired SDK translate seat (reads the active locale at call time)', () => {
setRuntimeTranslate((key) => `RU(${key})`)
expect(tt('entry.label')).toBe('RU(entry.label)')
})
it('falls back to the document-language dictionary when unwired', () => {
const label = tt('entry.label')
expect(label.startsWith('RU(')).toBe(false)
expect(label.length).toBeGreaterThan(0)
})
it('passes template params through to the wired seat', () => {
setRuntimeTranslate((key, values) => `${key}:${String(values?.count)}`)
expect(tt('hosts.group.count', { count: 2 })).toBe('hosts.group.count:2')
})
})
@@ -17,6 +17,7 @@ describe('mountSidebarEntry L2 semantic attributes (#506)', () => {
setAttribute: vi.fn(),
addEventListener: vi.fn(),
remove: vi.fn(),
append: vi.fn(),
}
vi.stubGlobal('document', {
querySelector: () => null,
@@ -15,6 +15,7 @@
import { createRoot, type Root } from 'react-dom/client'
import type { BoardController } from '../core/controller.ts'
import { TaskBoard } from './board/TaskBoard.tsx'
import type { LocaleRefreshSource } from './sidebar-entry.ts'
import css from './board.module.css'
/** The injected board container (kept in the DOM, hidden when inactive). */
@@ -37,11 +38,19 @@ function conversationColumn(): HTMLElement | undefined {
* Mount the board React tree into the center column and bind its visibility
* to the controller's boardOpen state.
* @param controller - the board controller driving the view.
* @param locale - locale-change source; when given, re-renders a mounted board
* on a Language switch.
* @returns disposer unmounting the tree and restoring the column.
*/
export function mountBoard(controller: BoardController): () => void {
export function mountBoard(controller: BoardController, locale?: LocaleRefreshSource): () => void {
let root: Root | undefined
let container: HTMLDivElement | undefined
let unsubscribeLocale: (() => void) | undefined
try {
unsubscribeLocale = locale?.subscribe(() => {
if (root !== undefined) root.render(<TaskBoard controller={controller} />)
})
} catch { /* locale service absent: the board follows its next natural re-render */ }
const ensure = (): void => {
if (container !== undefined) {
@@ -106,6 +115,7 @@ export function mountBoard(controller: BoardController): () => void {
document.removeEventListener(ACTIVATE_EVENT, onOtherActivate)
waitObserver.disconnect()
unsubscribe()
unsubscribeLocale?.()
document.documentElement.removeAttribute(ACTIVE_ATTR)
root?.unmount()
root = undefined
+9 -3
View File
@@ -27,7 +27,7 @@ import { claimTaskboardApply, releaseTaskboardApply } from './apply-guard.ts'
import { mountBoard } from './board-mount.tsx'
import { mountSidebarEntry } from './sidebar-entry.ts'
import { TaskBoardSettingsCard, TaskBoardSettingsCardController, type TaskBoardSettings } from './TaskBoardSettingsCard.tsx'
import { en, zh, type TaskBoardKey } from './locales.ts'
import { en, zh, setRuntimeTranslate, type TaskBoardKey } from './locales.ts'
import { HttpTaskBoardHostTransport } from './host-api.ts'
import { reportDailyHeartbeat } from './telemetry.ts'
@@ -157,6 +157,12 @@ export function apply(ctx: ClientContext): void {
}
}, 'task-board: dictionaries')
// Wire the SDK translate seat into the module-level t (sidebar row and
// other plain-DOM callers): reads the active locale at call time, so they
// follow the Language setting without a reload. The register effect above
// guarantees the dictionaries exist before the first read.
try { setRuntimeTranslate(ctx.locale.bind(NS)) } catch { /* locale missing: document-language fallback stays */ }
// Plugin configuration card: one staged form over the `task-board` settings
// namespace, contributed to the Web UI plugin group.
const binder = ctx.get('webUiSettings') ?? ctx.settingsScope
@@ -246,8 +252,8 @@ export function apply(ctx: ClientContext): void {
void pushPresetOptions()
disposers.push(ctx.on('connection/reset', () => { void pushPresetOptions() }))
try {
disposers.push(mountSidebarEntry(controller))
disposers.push(mountBoard(controller))
disposers.push(mountSidebarEntry(controller, ctx.locale))
disposers.push(mountBoard(controller, ctx.locale))
} catch (error) {
// DOM failures degrade the board, never the GUI.
console.error('[dsh-task-board] mount failed:', error)
@@ -307,8 +307,23 @@ export function dictionary(): Record<TaskBoardKey, string> {
return lang.toLowerCase().startsWith('en') ? en : zh
}
/**
* SDK translate seat wired by the browser apply() once ctx.locale is bound
* (setRuntimeTranslate). When present it reads the ACTIVE locale at call
* time, so plain-DOM surfaces (sidebar row, toggles) follow a runtime
* language switch; the document-language pick above stays only as the
* unwired fallback (locale service absent, module-scope early callers).
*/
let runtimeT: ((key: TaskBoardKey, params?: Record<string, string>) => string) | undefined
/** Wire the SDK translate seat; pass undefined to restore the document-language pick. */
export function setRuntimeTranslate(t: ((key: TaskBoardKey, params?: Record<string, string>) => string) | undefined): void {
runtimeT = t
}
/** Translate a key with optional {name} template params. */
export function t(key: TaskBoardKey, params?: Record<string, string>): string {
if (runtimeT !== undefined) return runtimeT(key, params)
let text: string = dictionary()[key]
if (params !== undefined) {
for (const [name, value] of Object.entries(params)) {
@@ -36,6 +36,14 @@ export interface SidebarEntryOptions {
label(): string
/** Optional localized tooltip (title attribute). */
tooltip?(): string
/**
* Optional locale-change subscription: re-applies label / aria-label /
* tooltip whenever the active locale changes. Plain-DOM rows would
* otherwise keep the label captured at mount; pass the SDK locale runtime
* subscription (ctx.locale.subscribe) so the row follows the language
* switch without a reload.
*/
refresh?: { subscribe(listener: () => void): () => void }
/** Click action (open/toggle the owning panel). */
onToggle(): void
/** Family-block position: 'before' inserts ahead of sibling plugin rows, 'after' behind them. */
@@ -76,7 +84,7 @@ function newSessionButton(root: HTMLElement): HTMLButtonElement | undefined {
}
/** Build the entry row (a detached button; insert once the shell is up). */
function createEntry(options: SidebarEntryOptions): HTMLButtonElement {
function createEntry(options: SidebarEntryOptions): { entry: HTMLButtonElement; applyLabel: () => void } {
const entry = document.createElement('button')
entry.type = 'button'
entry.setAttribute(options.rowAttribute, '')
@@ -85,12 +93,20 @@ function createEntry(options: SidebarEntryOptions): HTMLButtonElement {
entry.setAttribute('data-dsh-part', 'sidebar-entry')
}
entry.className = options.css['entry'] ?? ''
entry.setAttribute('aria-label', options.label())
if (options.tooltip !== undefined) entry.setAttribute('title', options.tooltip())
entry.innerHTML = '<span class="' + (options.css['entryIcon'] ?? '') + '">' + options.icon
+ '</span><span class="' + (options.css['entryLabel'] ?? '') + '">' + options.label() + '</span>'
const labelSpan = document.createElement('span')
labelSpan.className = options.css['entryLabel'] ?? ''
const iconSpan = document.createElement('span')
iconSpan.className = options.css['entryIcon'] ?? ''
iconSpan.innerHTML = options.icon
entry.append(iconSpan, labelSpan)
const applyLabel = (): void => {
entry.setAttribute('aria-label', options.label())
if (options.tooltip !== undefined) entry.setAttribute('title', options.tooltip())
labelSpan.textContent = options.label()
}
applyLabel()
entry.addEventListener('click', options.onToggle)
return entry
return { entry, applyLabel }
}
/** Re-insert the entry after the New Session row (before the browser region). */
@@ -132,9 +148,18 @@ export function mountSidebarEntry(options: SidebarEntryOptions): () => void {
if (typeof document !== 'undefined' && document.querySelector(options.rowSelector) !== null) {
return () => {}
}
const entry = createEntry(options)
const { entry, applyLabel } = createEntry(options)
let root: HTMLElement | undefined
let placed = false
let unsubscribeRefresh: (() => void) | undefined
if (options.refresh !== undefined) {
try {
unsubscribeRefresh = options.refresh.subscribe(applyLabel)
} catch {
// A throwing subscription must not break the mount; the label stays at
// its initial value and the next reload resolves it again.
}
}
const tryPlace = (): void => {
if (root !== undefined && !root.isConnected) {
@@ -202,6 +227,7 @@ export function mountSidebarEntry(options: SidebarEntryOptions): () => void {
return () => {
waitObserver.disconnect()
rootObserver.disconnect()
unsubscribeRefresh?.()
unsubscribeActive?.()
entry.remove()
}
@@ -22,13 +22,18 @@ export const ENTRY_SELECTOR = '[data-dsh-taskboard-entry]'
/** Inline icon normalized to the shell's 18px navigation glyph size. */
const ICON = '<svg viewBox="0 0 16 16" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="2.5" width="12" height="11" rx="1.5"/><path d="M2 6.5h12M6.5 6.5v7"/></svg>'
/** Locale-change subscription the shared core asks for (ctx.locale.subscribe shape). */
export interface LocaleRefreshSource { subscribe(listener: () => void): () => void }
/**
* Mount the sidebar entry, waiting for the shell to render and self-healing
* on later React re-renders.
* @param controller - the board controller the entry toggles.
* @param locale - locale-change source; when given, re-applies the label on
* a Language switch (the plain-DOM row otherwise keeps the mount-time copy).
* @returns disposer removing the entry and its observers.
*/
export function mountSidebarEntry(controller: BoardController): () => void {
export function mountSidebarEntry(controller: BoardController, locale?: LocaleRefreshSource): () => void {
return mountSharedSidebarEntry({
rowAttribute: 'data-dsh-taskboard-entry',
rowSelector: ENTRY_SELECTOR,
@@ -36,6 +41,7 @@ export function mountSidebarEntry(controller: BoardController): () => void {
icon: ICON,
css,
label: () => t('entry.label'),
refresh: locale === undefined ? undefined : { subscribe: (listener) => locale.subscribe(listener) },
onToggle: () => { controller.toggleBoard() },
position: 'before',
familySelectors: ['[data-dsh-taskboard-entry]', '[data-dsh-ssh-entry]'],
@@ -0,0 +1,22 @@
import { afterEach, describe, expect, it } from 'vitest'
import { t, setRuntimeTranslate } from '../src/client/locales.ts'
describe('task-board localized t runtime wiring', () => {
afterEach(() => { setRuntimeTranslate(undefined) })
it('prefers the wired SDK translate seat (reads the active locale at call time)', () => {
setRuntimeTranslate((key) => `RU(${key})`)
expect(t('entry.label')).toBe('RU(entry.label)')
})
it('falls back to the document-language dictionary when unwired', () => {
const label = t('entry.label')
expect(label.startsWith('RU(')).toBe(false)
expect(label.length).toBeGreaterThan(0)
})
it('passes template params through to the wired seat', () => {
setRuntimeTranslate((key, params) => `${key}:${String(params?.count)}`)
expect(t('board.archiveView', { count: '3' })).toBe('board.archiveView:3')
})
})
@@ -34,6 +34,7 @@ describe('mountSidebarEntry DOM idempotency', () => {
setAttribute: vi.fn(),
addEventListener: vi.fn(),
remove: vi.fn(),
append: vi.fn(),
}
const createElement = vi.fn(() => entryEl)
vi.stubGlobal('document', {
@@ -55,7 +56,8 @@ describe('mountSidebarEntry DOM idempotency', () => {
const dispose = mountSidebarEntry(controller)
expect(createElement).toHaveBeenCalledTimes(1)
// createElement now builds the row plus its two spans (icon/label).
expect(createElement).toHaveBeenCalledTimes(3)
dispose()
expect(entryEl.remove).toHaveBeenCalledTimes(1)
})
@@ -66,6 +68,7 @@ describe('mountSidebarEntry DOM idempotency', () => {
setAttribute: vi.fn(),
addEventListener: vi.fn(),
remove: vi.fn(),
append: vi.fn(),
}
vi.stubGlobal('document', {
querySelector: () => null,
+33 -7
View File
@@ -35,6 +35,14 @@ export interface SidebarEntryOptions {
label(): string
/** Optional localized tooltip (title attribute). */
tooltip?(): string
/**
* Optional locale-change subscription: re-applies label / aria-label /
* tooltip whenever the active locale changes. Plain-DOM rows would
* otherwise keep the label captured at mount; pass the SDK locale runtime
* subscription (ctx.locale.subscribe) so the row follows the language
* switch without a reload.
*/
refresh?: { subscribe(listener: () => void): () => void }
/** Click action (open/toggle the owning panel). */
onToggle(): void
/** Family-block position: 'before' inserts ahead of sibling plugin rows, 'after' behind them. */
@@ -75,7 +83,7 @@ function newSessionButton(root: HTMLElement): HTMLButtonElement | undefined {
}
/** Build the entry row (a detached button; insert once the shell is up). */
function createEntry(options: SidebarEntryOptions): HTMLButtonElement {
function createEntry(options: SidebarEntryOptions): { entry: HTMLButtonElement; applyLabel: () => void } {
const entry = document.createElement('button')
entry.type = 'button'
entry.setAttribute(options.rowAttribute, '')
@@ -84,12 +92,20 @@ function createEntry(options: SidebarEntryOptions): HTMLButtonElement {
entry.setAttribute('data-dsh-part', 'sidebar-entry')
}
entry.className = options.css['entry'] ?? ''
entry.setAttribute('aria-label', options.label())
if (options.tooltip !== undefined) entry.setAttribute('title', options.tooltip())
entry.innerHTML = '<span class="' + (options.css['entryIcon'] ?? '') + '">' + options.icon
+ '</span><span class="' + (options.css['entryLabel'] ?? '') + '">' + options.label() + '</span>'
const labelSpan = document.createElement('span')
labelSpan.className = options.css['entryLabel'] ?? ''
const iconSpan = document.createElement('span')
iconSpan.className = options.css['entryIcon'] ?? ''
iconSpan.innerHTML = options.icon
entry.append(iconSpan, labelSpan)
const applyLabel = (): void => {
entry.setAttribute('aria-label', options.label())
if (options.tooltip !== undefined) entry.setAttribute('title', options.tooltip())
labelSpan.textContent = options.label()
}
applyLabel()
entry.addEventListener('click', options.onToggle)
return entry
return { entry, applyLabel }
}
/** Re-insert the entry after the New Session row (before the browser region). */
@@ -131,9 +147,18 @@ export function mountSidebarEntry(options: SidebarEntryOptions): () => void {
if (typeof document !== 'undefined' && document.querySelector(options.rowSelector) !== null) {
return () => {}
}
const entry = createEntry(options)
const { entry, applyLabel } = createEntry(options)
let root: HTMLElement | undefined
let placed = false
let unsubscribeRefresh: (() => void) | undefined
if (options.refresh !== undefined) {
try {
unsubscribeRefresh = options.refresh.subscribe(applyLabel)
} catch {
// A throwing subscription must not break the mount; the label stays at
// its initial value and the next reload resolves it again.
}
}
const tryPlace = (): void => {
if (root !== undefined && !root.isConnected) {
@@ -201,6 +226,7 @@ export function mountSidebarEntry(options: SidebarEntryOptions): () => void {
return () => {
waitObserver.disconnect()
rootObserver.disconnect()
unsubscribeRefresh?.()
unsubscribeActive?.()
entry.remove()
}
+44 -8
View File
@@ -9,9 +9,12 @@ interface FakeElement {
nextElementSibling: FakeElement | undefined
isConnected: boolean
innerHTML: string
textContent: string
className: string
attrs: Record<string, string>
removed: boolean
append(...children: FakeElement[]): void
appendChild(child: FakeElement): void
setAttribute(name: string, value: string): void
insertBefore(child: FakeElement, anchor?: FakeElement): void
remove(): void
@@ -34,13 +37,19 @@ function makeElement(tag: string, children: FakeElement[] = []): FakeElement {
nextElementSibling: undefined,
isConnected: true,
innerHTML: '',
textContent: '',
className: '',
attrs: {},
removed: false,
append(...children) { for (const child of children) element.appendChild(child) },
setAttribute(name, value) {
element.attrs[name] = value
if (name.startsWith('data-')) element.dataset[name.slice('data-'.length)] = value
},
appendChild(child) {
element.children.push(child)
child.parentElement = element
},
insertBefore(child, anchor) {
const index = anchor === undefined ? element.children.length : element.children.indexOf(anchor)
element.children.splice(index < 0 ? element.children.length : index, 0, child)
@@ -113,16 +122,19 @@ describe('shared sidebar-entry core', () => {
css: { entry: 'entry-css', entryIcon: 'icon-css', entryLabel: 'label-css' },
onToggle: () => { toggled += 1 },
}))
expect(created).toHaveLength(1)
expect(created[0]!.className).toBe('entry-css')
expect(created[0]!.innerHTML).toContain('icon-css')
expect(created[0]!.innerHTML).toContain('label-css')
expect(created[0]!.innerHTML).toContain('<svg/>')
expect(created[0]!.innerHTML).toContain('X')
created[0]!.click()
const entry = created[0]!
expect(entry!.className).toBe('entry-css')
expect(entry!.attrs['aria-label']).toBe('X')
const iconSpan = entry!.children[0]
const labelSpan = entry!.children[1]
expect(iconSpan!.className).toBe('icon-css')
expect(iconSpan!.innerHTML).toContain('<svg/>')
expect(labelSpan!.className).toBe('label-css')
expect(labelSpan!.textContent).toBe('X')
entry!.click()
expect(toggled).toBe(1)
dispose()
expect(created[0]!.removed).toBe(true)
expect(entry!.removed).toBe(true)
})
it('outputs the L2 semantic attributes only when the plugin option is set (#506)', () => {
@@ -155,6 +167,30 @@ describe('shared sidebar-entry core', () => {
expect(dispose()).toBeUndefined()
})
it('re-applies the label through the refresh subscription and unsubscribes on dispose', () => {
installShell()
const listeners = new Set<() => void>()
let label = 'X'
const created: FakeElement[] = []
stubDocument(false, created)
const dispose = mountSidebarEntry(options({
refresh: {
subscribe: (listener) => { listeners.add(listener); return () => listeners.delete(listener) },
},
label: () => label,
}))
const entry = created[0]!
expect(entry!.attrs['aria-label']).toBe('X')
const labelSpan = entry!.children[1]!
expect(labelSpan.textContent).toBe('X')
label = 'Рус'
for (const listener of listeners) listener()
expect(entry!.attrs['aria-label']).toBe('Рус')
expect(labelSpan.textContent).toBe('Рус')
dispose()
expect(listeners.size).toBe(0)
})
it('highlights the row while the active state is open and clears on close', () => {
installShell()
let open = true