From 9d0c6eba3166c247004651e19f80339f11561620 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sun, 23 Aug 2026 14:42:11 +0000 Subject: [PATCH] panel names take a late-bound thunk (494 -> 469) AppRegistryEntry.name is now `string | (() => string)` and the framework calls it if it is a function. Owner-approved, with both of their conditions met. Why a thunk rather than a key: `workspaces` has no dependencies at all, deliberately not even i18n, so resolving a catalogue key there would invert the graph -- officerdev depends on that package, not the reverse. The caller binds the words; the framework only asks for them. `string` stays valid, so the 27 names migrate one at a time and a name that never needs translating keeps a plain string. Minesweeper, SendOff, Tmux, Neovim, Claude Code and bTop are exactly that case: product names, and a key whose two sides can never legitimately differ is a maintenance cost bought for nothing. Resolved at every render via resolveEntryName, never cached onto the entry, with the reason stated at both call sites -- caching would pin the name to whatever locale the registry was seeded with, and it is seeded once before any locale has loaded. There is a test that re-renders under a different provider locale and asserts the name changes; it is the test that fails if anybody memoises this later. The thunk reads i18n/standalone, which mirrors the provider for callers with no hook in scope. Deliberately not on the package barrel and documented as not-a-general-escape- hatch, because it is not reactive: it is only correct where the caller re-renders for another reason. --- src/workspaces/i18n/src/I18nProvider.tsx | 5 +++ src/workspaces/i18n/src/index.ts | 2 + src/workspaces/i18n/src/standalone.ts | 31 +++++++++++++ .../officerdev/src/apps/AppStore/index.ts | 17 +++++++- .../officerdev/src/apps/Chat/index.ts | 3 +- .../officerdev/src/apps/ChatHistory/index.ts | 5 ++- .../officerdev/src/apps/CodeEditor/index.ts | 3 +- .../officerdev/src/apps/CommandHQ/index.ts | 7 +-- .../officerdev/src/apps/Dashboards/index.ts | 17 +++++--- .../officerdev/src/apps/Dav/index.ts | 33 ++++++++++++-- .../officerdev/src/apps/FileBrowser/index.ts | 5 ++- .../officerdev/src/apps/Plugins/index.ts | 17 +++++++- .../officerdev/src/apps/Preview/index.ts | 3 +- .../officerdev/src/apps/QrTransfer/index.ts | 5 ++- .../src/apps/SystemMonitor/index.ts | 17 +++++++- .../officerdev/src/apps/Widgets/index.ts | 3 +- .../officerdev/src/locales/en-GB.json | 27 ++++++++++++ .../officerdev/src/locales/index.ts | 1 + .../officerdev/src/locales/panelName.test.tsx | 43 +++++++++++++++++++ .../officerdev/src/locales/panelName.ts | 17 ++++++++ .../officerdev/src/locales/pt-PT.json | 27 ++++++++++++ .../workspaces/src/react/AppPicker.tsx | 5 ++- .../workspaces/src/react/PanelSlot.tsx | 5 ++- src/workspaces/workspaces/src/react/index.ts | 1 + src/workspaces/workspaces/src/react/types.ts | 23 +++++++++- 25 files changed, 291 insertions(+), 31 deletions(-) create mode 100644 src/workspaces/i18n/src/standalone.ts create mode 100644 src/workspaces/officerdev/src/locales/panelName.test.tsx create mode 100644 src/workspaces/officerdev/src/locales/panelName.ts diff --git a/src/workspaces/i18n/src/I18nProvider.tsx b/src/workspaces/i18n/src/I18nProvider.tsx index b5f15755..4da7f042 100644 --- a/src/workspaces/i18n/src/I18nProvider.tsx +++ b/src/workspaces/i18n/src/I18nProvider.tsx @@ -2,6 +2,7 @@ import { createContext, useMemo } from 'react'; import type { ReactNode } from 'react'; import type { I18nContextValue, InterpolationParams, TranslationMap } from './types'; import { interpolate, resolveKey } from './resolve'; +import { setActiveI18n } from './standalone'; export const I18nContext = createContext({ t: (key: string) => key, @@ -17,6 +18,10 @@ type I18nProviderProps = { }; export const I18nProvider = ({ translations, locale, fallbackLocale = 'en', children }: I18nProviderProps) => { + // Mirrored for the one caller that cannot use a hook — see standalone.ts. Set during render rather + // than in an effect so it is never a render behind what this provider is handing out. + setActiveI18n({ translations, locale, fallbackLocale }); + const value = useMemo(() => { const t = (key: string, params?: InterpolationParams) => { const count = typeof params?.count === 'number' ? params.count : undefined; diff --git a/src/workspaces/i18n/src/index.ts b/src/workspaces/i18n/src/index.ts index 14777e5c..63a6bd04 100644 --- a/src/workspaces/i18n/src/index.ts +++ b/src/workspaces/i18n/src/index.ts @@ -3,5 +3,7 @@ export { useTranslation } from './useTranslation'; export { useFormat } from './useFormat'; export { resolveKey, interpolate, isPluralForms, localeDirection } from './resolve'; export { findMissingKeys } from './parity'; +// Deliberately NOT re-exporting translateStandalone — see standalone.ts on why it is awkward to reach. +export { activeLocale } from './standalone'; export type { MissingKey } from './parity'; export type { TranslationMap, InterpolationParams, I18nContextValue, TranslationKeys, PluralForms } from './types'; diff --git a/src/workspaces/i18n/src/standalone.ts b/src/workspaces/i18n/src/standalone.ts new file mode 100644 index 00000000..ab21a8ec --- /dev/null +++ b/src/workspaces/i18n/src/standalone.ts @@ -0,0 +1,31 @@ +import type { InterpolationParams, TranslationMap } from './types'; +import { interpolate, resolveKey } from './resolve'; + +/** + * The active locale, readable OUTSIDE a React render. + * + * There is exactly one caller and it is not a shortcut: a panel's name in the app registry is + * module-level data, and the framework invokes its thunk from a render where no hook of ours is + * reachable — `workspaces` has no dependencies at all, deliberately, not even this package. + * + * NOT a general escape hatch. Anything inside a component uses `useTranslation`, which is reactive. + * This is not reactive: writing here re-renders nothing. It is only correct where the caller is + * already re-rendering for another reason, which is why `translateStandalone` is deliberately awkward + * to reach and documented rather than exported from the package barrel. + */ +type ActiveI18n = { translations: TranslationMap; locale: string; fallbackLocale: string }; + +let active: ActiveI18n = { translations: {}, locale: 'en', fallbackLocale: 'en' }; + +/** Called by `I18nProvider` on every render, so this never lags the provider it mirrors. */ +export const setActiveI18n = (next: ActiveI18n): void => { + active = next; +}; + +export const translateStandalone = (key: string, params?: InterpolationParams): string => { + const count = typeof params?.count === 'number' ? params.count : undefined; + return interpolate(resolveKey(active.translations, active.locale, key, active.fallbackLocale, count), params); +}; + +/** The locale the provider currently holds. Exposed so a caller can key a render on it. */ +export const activeLocale = (): string => active.locale; diff --git a/src/workspaces/officerdev/src/apps/AppStore/index.ts b/src/workspaces/officerdev/src/apps/AppStore/index.ts index 8e7fbc6e..89c65ffd 100644 --- a/src/workspaces/officerdev/src/apps/AppStore/index.ts +++ b/src/workspaces/officerdev/src/apps/AppStore/index.ts @@ -2,6 +2,7 @@ import { Store } from 'lucide-react'; import type { AppRegistryMeta } from '../../AppRegistry'; import { AppStoreList } from './AppStoreList'; import { AppStoreDetail } from './AppStoreDetail'; +import { panelName } from '../../locales'; export { AppStoreList } from './AppStoreList'; export { AppStoreDetail } from './AppStoreDetail'; @@ -12,6 +13,18 @@ export type { StoreItem, InstallMode, ConfigField } from './useAppStore'; // side by side. `availableOnPanel: false` keeps them off the generic panel picker — they only make // sense together, on /app-store. export const appRegistryMetas: AppRegistryMeta[] = [ - { key: 'app-store-list', name: 'App store', icon: Store, component: AppStoreList, availableOnPanel: false }, - { key: 'app-store-detail', name: 'App detail', icon: Store, component: AppStoreDetail, availableOnPanel: false }, + { + key: 'app-store-list', + name: panelName('panelName.appStore'), + icon: Store, + component: AppStoreList, + availableOnPanel: false, + }, + { + key: 'app-store-detail', + name: panelName('panelName.appDetail'), + icon: Store, + component: AppStoreDetail, + availableOnPanel: false, + }, ]; diff --git a/src/workspaces/officerdev/src/apps/Chat/index.ts b/src/workspaces/officerdev/src/apps/Chat/index.ts index f1039ffe..1895f14c 100644 --- a/src/workspaces/officerdev/src/apps/Chat/index.ts +++ b/src/workspaces/officerdev/src/apps/Chat/index.ts @@ -2,6 +2,7 @@ import type { AppRegistryMeta } from '../../AppRegistry'; import { MessageSquare } from 'lucide-react'; import { ChatPanelWrapper } from './ChatPanelWrapper'; import { ChatHeader } from './ChatHeader'; +import { panelName } from '../../locales'; export { MessageList } from './components/MessageList'; export { MessageBubble, StreamingBubble } from './components/MessageBubble'; @@ -26,7 +27,7 @@ export * from './types'; export const appRegistryMetas: AppRegistryMeta[] = [ { key: 'officerdev/chat', - name: 'Chat', + name: panelName('panelName.chat'), icon: MessageSquare, component: ChatPanelWrapper, header: ChatHeader, diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/index.ts b/src/workspaces/officerdev/src/apps/ChatHistory/index.ts index 576f9678..8d838db6 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/index.ts +++ b/src/workspaces/officerdev/src/apps/ChatHistory/index.ts @@ -2,6 +2,7 @@ import type { AppRegistryMeta } from '../../AppRegistry'; import { MessageSquare, List } from 'lucide-react'; import { SessionList } from './SessionList'; import { ChatDetailPanel } from './ChatDetailPanel'; +import { panelName } from '../../locales'; export { SessionList }; export { ChatDetailPanel }; @@ -11,14 +12,14 @@ export { chatListPath, chatNewPath, chatSessionPath, cwdFromSplat, GROUP_SEGMENT export const appRegistryMetas: AppRegistryMeta[] = [ { key: 'chat-session-list', - name: 'Sessions', + name: panelName('panelName.chatSessions'), icon: List, component: SessionList, availableOnPanel: false, }, { key: 'chat-detail', - name: 'Chat', + name: panelName('panelName.chat'), icon: MessageSquare, component: ChatDetailPanel, availableOnPanel: false, diff --git a/src/workspaces/officerdev/src/apps/CodeEditor/index.ts b/src/workspaces/officerdev/src/apps/CodeEditor/index.ts index 69ad01e4..508b1966 100644 --- a/src/workspaces/officerdev/src/apps/CodeEditor/index.ts +++ b/src/workspaces/officerdev/src/apps/CodeEditor/index.ts @@ -2,13 +2,14 @@ import type { AppRegistryMeta } from '../../AppRegistry'; import { Code } from 'lucide-react'; import { CodeEditorPanelWrapper } from './CodeEditorPanelWrapper'; import { CodeEditorHeader } from './CodeEditorHeader'; +import { panelName } from '../../locales'; export { CodeEditorView } from './CodeEditor'; export const appRegistryMetas: AppRegistryMeta[] = [ { key: 'officerdev/code-editor', - name: 'Code Editor', + name: panelName('panelName.codeEditor'), icon: Code, component: CodeEditorPanelWrapper, header: CodeEditorHeader, diff --git a/src/workspaces/officerdev/src/apps/CommandHQ/index.ts b/src/workspaces/officerdev/src/apps/CommandHQ/index.ts index a979a032..44e54f9d 100644 --- a/src/workspaces/officerdev/src/apps/CommandHQ/index.ts +++ b/src/workspaces/officerdev/src/apps/CommandHQ/index.ts @@ -15,25 +15,26 @@ export type { CommandHqTurn, CommandHqFire, } from './types'; +import { panelName } from '../../locales'; export const appRegistryMetas: AppRegistryMeta[] = [ { key: 'command-hq-categories', - name: 'Categories', + name: panelName('panelName.categories'), icon: Radar, component: CommandHqCategories, availableOnPanel: false, }, { key: 'command-hq-grid', - name: 'Commanders', + name: panelName('panelName.commanders'), icon: LayoutGrid, component: CommandHqGrid, availableOnPanel: false, }, { key: 'command-hq-detail', - name: 'Commander', + name: panelName('panelName.commander'), icon: MessageSquare, component: CommandHqDetail, availableOnPanel: false, diff --git a/src/workspaces/officerdev/src/apps/Dashboards/index.ts b/src/workspaces/officerdev/src/apps/Dashboards/index.ts index 70760d5c..17866ec3 100644 --- a/src/workspaces/officerdev/src/apps/Dashboards/index.ts +++ b/src/workspaces/officerdev/src/apps/Dashboards/index.ts @@ -2,23 +2,30 @@ import type { AppRegistryMeta } from '../../AppRegistry'; import { LayoutGrid } from 'lucide-react'; import { DashboardListApp } from './DashboardListApp'; import { DashboardPreview } from './DashboardPreview'; +import { panelName } from '../../locales'; export { DashboardListApp }; export { DashboardPreview }; -export { CREATING_DASHBOARD_KEY, EDITING_DASHBOARD_KEY, NEW_DASH_NAME_KEY, NEW_DASH_DESC_KEY, NEW_DASH_TEMPLATE_KEY } from './constants'; +export { + CREATING_DASHBOARD_KEY, + EDITING_DASHBOARD_KEY, + NEW_DASH_NAME_KEY, + NEW_DASH_DESC_KEY, + NEW_DASH_TEMPLATE_KEY, +} from './constants'; export const appRegistryMetas: AppRegistryMeta[] = [ { key: 'dashboard-list', - name: 'Dashboards', + name: panelName('panelName.dashboards'), icon: LayoutGrid, - component: DashboardListApp + component: DashboardListApp, }, { key: 'dashboard-preview', - name: 'Dashboard Preview', + name: panelName('panelName.dashboardPreview'), icon: LayoutGrid, component: DashboardPreview, - availableOnPanel: false + availableOnPanel: false, }, ]; diff --git a/src/workspaces/officerdev/src/apps/Dav/index.ts b/src/workspaces/officerdev/src/apps/Dav/index.ts index 727b07c4..9abca9ff 100644 --- a/src/workspaces/officerdev/src/apps/Dav/index.ts +++ b/src/workspaces/officerdev/src/apps/Dav/index.ts @@ -3,6 +3,7 @@ import { CalendarDays, Contact as ContactIcon, PanelLeft } from 'lucide-react'; import { CalendarNav, ContactsNav } from './CollectionNav'; import { CalendarView } from './CalendarView'; import { ContactsView } from './ContactsView'; +import { panelName } from '../../locales'; // One app folder for both /calendar and /contacts: they are the same DAV sidecar, the same collection // list and the same selection rule, differing only in which kind of collection they show. @@ -10,8 +11,32 @@ import { ContactsView } from './ContactsView'; export { CalendarNav, ContactsNav, CalendarView, ContactsView }; export const appRegistryMetas: AppRegistryMeta[] = [ - { key: 'calendar-nav', name: 'Calendar', icon: PanelLeft, component: CalendarNav, availableOnPanel: false }, - { key: 'calendar-view', name: 'Calendar', icon: CalendarDays, component: CalendarView, availableOnPanel: false }, - { key: 'contacts-nav', name: 'Contacts', icon: PanelLeft, component: ContactsNav, availableOnPanel: false }, - { key: 'contacts-view', name: 'Contacts', icon: ContactIcon, component: ContactsView, availableOnPanel: false }, + { + key: 'calendar-nav', + name: panelName('panelName.calendar'), + icon: PanelLeft, + component: CalendarNav, + availableOnPanel: false, + }, + { + key: 'calendar-view', + name: panelName('panelName.calendar'), + icon: CalendarDays, + component: CalendarView, + availableOnPanel: false, + }, + { + key: 'contacts-nav', + name: panelName('panelName.contacts'), + icon: PanelLeft, + component: ContactsNav, + availableOnPanel: false, + }, + { + key: 'contacts-view', + name: panelName('panelName.contacts'), + icon: ContactIcon, + component: ContactsView, + availableOnPanel: false, + }, ]; diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/index.ts b/src/workspaces/officerdev/src/apps/FileBrowser/index.ts index b11a203b..a6bbf219 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/index.ts +++ b/src/workspaces/officerdev/src/apps/FileBrowser/index.ts @@ -2,6 +2,7 @@ import type { AppRegistryMeta } from '../../AppRegistry'; import { FolderOpen } from 'lucide-react'; import { FileBrowserPanelWrapper } from './FileBrowserApp'; import { FileBrowserWidget } from './FileBrowserWidget'; +import { panelName } from '../../locales'; export { useFilesAPI, type DirEntry } from '../../hooks/useFilesAPI'; export { useTasks, type TaskSummary } from './useTasks'; @@ -14,13 +15,13 @@ export { TaskRunnerModal } from './FileBrowserApp/components/TaskRunnerModal'; export const appRegistryMetas: AppRegistryMeta[] = [ { key: 'officerdev/file-browser', - name: 'File Browser', + name: panelName('panelName.fileBrowser'), icon: FolderOpen, component: FileBrowserPanelWrapper, }, { key: 'officerdev/file-browser-widget', - name: 'File Browser', + name: panelName('panelName.fileBrowser'), icon: FolderOpen, component: FileBrowserWidget, availableOnPanel: false, diff --git a/src/workspaces/officerdev/src/apps/Plugins/index.ts b/src/workspaces/officerdev/src/apps/Plugins/index.ts index f95c5c1c..55dac251 100644 --- a/src/workspaces/officerdev/src/apps/Plugins/index.ts +++ b/src/workspaces/officerdev/src/apps/Plugins/index.ts @@ -2,6 +2,7 @@ import { Puzzle } from 'lucide-react'; import type { AppRegistryMeta } from '../../AppRegistry'; import { PluginsList } from './PluginsList'; import { PluginDetail } from './PluginDetail'; +import { panelName } from '../../locales'; export { PluginsList } from './PluginsList'; export { PluginDetail } from './PluginDetail'; @@ -12,6 +13,18 @@ export type { PluginItem, PluginPermission } from './usePlugins'; // both read it. `availableOnPanel: false` keeps them off the generic picker: they only make sense on // /plugins, together. export const appRegistryMetas: AppRegistryMeta[] = [ - { key: 'plugins-list', name: 'Plugins', icon: Puzzle, component: PluginsList, availableOnPanel: false }, - { key: 'plugin-detail', name: 'Plugin detail', icon: Puzzle, component: PluginDetail, availableOnPanel: false }, + { + key: 'plugins-list', + name: panelName('panelName.plugins'), + icon: Puzzle, + component: PluginsList, + availableOnPanel: false, + }, + { + key: 'plugin-detail', + name: panelName('panelName.pluginDetail'), + icon: Puzzle, + component: PluginDetail, + availableOnPanel: false, + }, ]; diff --git a/src/workspaces/officerdev/src/apps/Preview/index.ts b/src/workspaces/officerdev/src/apps/Preview/index.ts index 6e20bea9..5de8cae5 100644 --- a/src/workspaces/officerdev/src/apps/Preview/index.ts +++ b/src/workspaces/officerdev/src/apps/Preview/index.ts @@ -2,6 +2,7 @@ import type { AppRegistryMeta } from '../../AppRegistry'; import { Globe } from 'lucide-react'; import { PreviewApp } from './PreviewApp'; import { PreviewHeader } from './PreviewHeader'; +import { panelName } from '../../locales'; export { PreviewApp } from './PreviewApp'; export { PreviewHeader } from './PreviewHeader'; @@ -10,7 +11,7 @@ export { usePreview, normalizeUrl, type PreviewPanelState } from './use-preview' export const appRegistryMetas: AppRegistryMeta[] = [ { key: 'officerdev/preview', - name: 'Preview', + name: panelName('panelName.preview'), icon: Globe, component: PreviewApp, header: PreviewHeader, diff --git a/src/workspaces/officerdev/src/apps/QrTransfer/index.ts b/src/workspaces/officerdev/src/apps/QrTransfer/index.ts index d1c7479c..d2fcf6ff 100644 --- a/src/workspaces/officerdev/src/apps/QrTransfer/index.ts +++ b/src/workspaces/officerdev/src/apps/QrTransfer/index.ts @@ -2,6 +2,7 @@ import type { AppRegistryMeta } from '../../AppRegistry'; import { QrCode, ScanLine } from 'lucide-react'; import { QrSender } from './Sender'; import { QrReceiver } from './Receiver'; +import { panelName } from '../../locales'; export { QrSender } from './Sender'; export { QrReceiver } from './Receiver'; @@ -12,6 +13,6 @@ export * from './protocol'; // enough to reimplement on iOS and Android. export const appRegistryMetas: AppRegistryMeta[] = [ - { key: 'qr-send', name: 'QR Send', icon: QrCode, component: QrSender }, - { key: 'qr-receive', name: 'QR Receive', icon: ScanLine, component: QrReceiver }, + { key: 'qr-send', name: panelName('panelName.qrSend'), icon: QrCode, component: QrSender }, + { key: 'qr-receive', name: panelName('panelName.qrReceive'), icon: ScanLine, component: QrReceiver }, ]; diff --git a/src/workspaces/officerdev/src/apps/SystemMonitor/index.ts b/src/workspaces/officerdev/src/apps/SystemMonitor/index.ts index bbd76ecc..090eb45c 100644 --- a/src/workspaces/officerdev/src/apps/SystemMonitor/index.ts +++ b/src/workspaces/officerdev/src/apps/SystemMonitor/index.ts @@ -2,10 +2,23 @@ import type { AppRegistryMeta } from '../../AppRegistry'; import { Activity, PanelLeft } from 'lucide-react'; import { ScopeList } from './ScopeList'; import { MonitorMain } from './MonitorMain'; +import { panelName } from '../../locales'; export { ScopeList, MonitorMain }; export const appRegistryMetas: AppRegistryMeta[] = [ - { key: 'monitor-side', name: 'Scope', icon: PanelLeft, component: ScopeList, availableOnPanel: false }, - { key: 'monitor-main', name: 'System Monitor', icon: Activity, component: MonitorMain, availableOnPanel: false }, + { + key: 'monitor-side', + name: panelName('panelName.scope'), + icon: PanelLeft, + component: ScopeList, + availableOnPanel: false, + }, + { + key: 'monitor-main', + name: panelName('panelName.systemMonitor'), + icon: Activity, + component: MonitorMain, + availableOnPanel: false, + }, ]; diff --git a/src/workspaces/officerdev/src/apps/Widgets/index.ts b/src/workspaces/officerdev/src/apps/Widgets/index.ts index 0193d959..04d90047 100644 --- a/src/workspaces/officerdev/src/apps/Widgets/index.ts +++ b/src/workspaces/officerdev/src/apps/Widgets/index.ts @@ -1,11 +1,12 @@ import type { AppRegistryMeta } from '../../AppRegistry'; import { LayoutGrid } from 'lucide-react'; import { WidgetPanel } from './WidgetPanel'; +import { panelName } from '../../locales'; export const appRegistryMetas: AppRegistryMeta[] = [ { key: 'officerdev/widgets', - name: 'Widgets', + name: panelName('panelName.widgets'), icon: LayoutGrid, component: WidgetPanel, transparent: true, diff --git a/src/workspaces/officerdev/src/locales/en-GB.json b/src/workspaces/officerdev/src/locales/en-GB.json index 53445110..9bb9b5c0 100644 --- a/src/workspaces/officerdev/src/locales/en-GB.json +++ b/src/workspaces/officerdev/src/locales/en-GB.json @@ -109,5 +109,32 @@ "removeFile": "Remove {{name}}", "sendTo": "Send to {{name}}", "turningOff": "Turning someone off stops future sends and leaves what they already sent alone." + }, + "panelName": { + "appStore": "App store", + "appDetail": "App detail", + "calendar": "Calendar", + "contacts": "Contacts", + "categories": "Categories", + "chat": "Chat", + "chatSessions": "Sessions", + "codeEditor": "Code Editor", + "commander": "Commander", + "commanders": "Commanders", + "dashboardPreview": "Dashboard Preview", + "dashboards": "Dashboards", + "fileBrowser": "File Browser", + "pluginDetail": "Plugin detail", + "plugins": "Plugins", + "preview": "Preview", + "qrReceive": "QR Receive", + "qrSend": "QR Send", + "scope": "Scope", + "systemMonitor": "System Monitor", + "widgets": "Widgets", + "terminal": "Terminal", + "hostTerminal": "Host Terminal", + "runningShells": "Running Shells", + "pm2Processes": "pm2 processes" } } diff --git a/src/workspaces/officerdev/src/locales/index.ts b/src/workspaces/officerdev/src/locales/index.ts index e6077b48..cbea9026 100644 --- a/src/workspaces/officerdev/src/locales/index.ts +++ b/src/workspaces/officerdev/src/locales/index.ts @@ -7,3 +7,4 @@ export const panelCatalogs: TranslationMap = { 'en-GB': enGB, 'pt-PT': ptPT }; export { useTranslation } from './useTranslation'; export type { PanelTranslationKey } from './useTranslation'; +export { panelName } from './panelName'; diff --git a/src/workspaces/officerdev/src/locales/panelName.test.tsx b/src/workspaces/officerdev/src/locales/panelName.test.tsx new file mode 100644 index 00000000..a07c6ec8 --- /dev/null +++ b/src/workspaces/officerdev/src/locales/panelName.test.tsx @@ -0,0 +1,43 @@ +import { describe, expect, test } from 'bun:test'; +import { render, screen } from '@testing-library/react'; +import { I18nProvider, type TranslationMap } from 'i18n'; +import { resolveEntryName } from 'workspaces/react'; +import { panelName } from './panelName'; + +const translations: TranslationMap = { + 'en-GB': { panel: { panelName: { fileBrowser: 'File Browser' } } }, + 'pt-PT': { panel: { panelName: { fileBrowser: 'Explorador de ficheiros' } } }, +}; + +/** Stands in for AppPicker/PanelSlot: reads the name during ITS OWN render, exactly as they do. */ +const NameProbe = ({ entry }: { entry: { name: string | (() => string) } }) => {resolveEntryName(entry)}; + +describe('panelName', () => { + test('a plain string still works, so the 27 can migrate one at a time', () => { + render(); + expect(screen.getByText('Minesweeper')).toBeDefined(); + }); + + /** + * The condition the owner set: the thunk must be READ at render, so a locale change reaches it. + * Re-rendering under a different provider locale has to produce the other language — if the name + * were cached anywhere on the entry or in the module, this is the test that would fail. + */ + test('the name follows the provider locale across a re-render', () => { + const entry = { name: panelName('panelName.fileBrowser') }; + + const { rerender } = render( + + + , + ); + expect(screen.getByText('File Browser')).toBeDefined(); + + rerender( + + + , + ); + expect(screen.getByText('Explorador de ficheiros')).toBeDefined(); + }); +}); diff --git a/src/workspaces/officerdev/src/locales/panelName.ts b/src/workspaces/officerdev/src/locales/panelName.ts new file mode 100644 index 00000000..fcac3192 --- /dev/null +++ b/src/workspaces/officerdev/src/locales/panelName.ts @@ -0,0 +1,17 @@ +import { translateStandalone } from 'i18n/standalone'; +import type { PanelTranslationKey } from './useTranslation'; + +/** + * A panel's display name, as a thunk the framework calls during its own render. + * + * `AppRegistryEntry.name` accepts `string | (() => string)` precisely so this can be late-bound: the + * metas are module-level data, so there is no hook in scope, and `workspaces` has no dependencies to + * resolve a key with. See that type's comment for the layering argument. + * + * A panel whose name needs no translating — a product name like Minesweeper or SendOff — stays a plain + * string in its meta rather than becoming a key whose two sides can never legitimately differ. + */ +export const panelName = + (key: PanelTranslationKey): (() => string) => + () => + translateStandalone(`panel.${key}`); diff --git a/src/workspaces/officerdev/src/locales/pt-PT.json b/src/workspaces/officerdev/src/locales/pt-PT.json index de903ff3..54ce196a 100644 --- a/src/workspaces/officerdev/src/locales/pt-PT.json +++ b/src/workspaces/officerdev/src/locales/pt-PT.json @@ -109,5 +109,32 @@ "removeFile": "Remover {{name}}", "sendTo": "Enviar para {{name}}", "turningOff": "Desativar alguém impede envios futuros e deixa intacto o que já enviou." + }, + "panelName": { + "appStore": "Loja de aplicações", + "appDetail": "Detalhe da aplicação", + "calendar": "Calendário", + "contacts": "Contactos", + "categories": "Categorias", + "chat": "Chat", + "chatSessions": "Sessões", + "codeEditor": "Editor de código", + "commander": "Comandante", + "commanders": "Comandantes", + "dashboardPreview": "Pré-visualização do painel", + "dashboards": "Painéis", + "fileBrowser": "Explorador de ficheiros", + "pluginDetail": "Detalhe do plugin", + "plugins": "Plugins", + "preview": "Pré-visualização", + "qrReceive": "Receber por QR", + "qrSend": "Enviar por QR", + "scope": "Âmbito", + "systemMonitor": "Monitor do sistema", + "widgets": "Widgets", + "terminal": "Terminal", + "hostTerminal": "Terminal do anfitrião", + "runningShells": "Shells em execução", + "pm2Processes": "processos pm2" } } diff --git a/src/workspaces/workspaces/src/react/AppPicker.tsx b/src/workspaces/workspaces/src/react/AppPicker.tsx index 664fc0f0..6d82b664 100644 --- a/src/workspaces/workspaces/src/react/AppPicker.tsx +++ b/src/workspaces/workspaces/src/react/AppPicker.tsx @@ -1,4 +1,5 @@ import type { AppRegistryMap } from './types'; +import { resolveEntryName } from './types'; type AppPickerProps = { registry: AppRegistryMap; @@ -18,7 +19,9 @@ export const AppPicker = ({ registry, onSelect }: AppPickerProps) => { onClick={() => onSelect(key)} > - {entry.name} + {/* Resolved here, at render. Caching it would pin the name to the locale the registry was + seeded with — which is before any locale has loaded. See resolveEntryName. */} + {resolveEntryName(entry)} ))} diff --git a/src/workspaces/workspaces/src/react/PanelSlot.tsx b/src/workspaces/workspaces/src/react/PanelSlot.tsx index ec62cd4e..92de9101 100644 --- a/src/workspaces/workspaces/src/react/PanelSlot.tsx +++ b/src/workspaces/workspaces/src/react/PanelSlot.tsx @@ -2,6 +2,7 @@ import type { ComponentType } from 'react'; import { useCallback } from 'react'; import { ArrowLeftRight, ChevronLeft, X, Maximize2, Minimize2, ZoomIn, ZoomOut } from 'lucide-react'; import type { LayoutPanel, AppRegistryMap, PanelComponents, PanelComponentEntry } from './types'; +import { resolveEntryName } from './types'; import { useWorkspace } from './WorkspaceContext'; import { ZOOM_MIN, ZOOM_MAX, ZOOM_STEP } from '../core/layout'; import { Card } from './internal/Card'; @@ -419,7 +420,9 @@ export const PanelSlot = ({ ) : entry ? ( <> - {entry.name} + {/* Resolved here, at render. Caching it would pin the name to the locale the registry was + seeded with — which is before any locale has loaded. See resolveEntryName. */} + {resolveEntryName(entry)} ) : null; diff --git a/src/workspaces/workspaces/src/react/index.ts b/src/workspaces/workspaces/src/react/index.ts index e16d795c..c4272fd8 100644 --- a/src/workspaces/workspaces/src/react/index.ts +++ b/src/workspaces/workspaces/src/react/index.ts @@ -12,6 +12,7 @@ export type { WorkspaceViewProps } from './WorkspaceView'; export { WorkspaceLayout } from './WorkspaceLayout'; export { WorkspaceRenderer } from './WorkspaceRenderer'; export { PanelSlot } from './PanelSlot'; +export { resolveEntryName } from './types'; export { WorkspaceProvider, useWorkspace, inertInteraction } from './WorkspaceContext'; export type { WorkspaceContextValue } from './WorkspaceContext'; export { usePanelConfig } from './usePanelConfig'; diff --git a/src/workspaces/workspaces/src/react/types.ts b/src/workspaces/workspaces/src/react/types.ts index abb1fb41..a3f2c796 100644 --- a/src/workspaces/workspaces/src/react/types.ts +++ b/src/workspaces/workspaces/src/react/types.ts @@ -24,7 +24,17 @@ export type DashboardState = { * an icon library on anyone — Officer's lucide icons satisfy it without changing. */ export type AppRegistryEntry = { - name: string; + /** + * The panel's display name, as a string or as a thunk that returns one. + * + * A thunk because this package has NO dependencies — not even i18n — and resolving a catalogue key + * here would invert the dependency graph: officerdev depends on this, not the other way round. So the + * caller binds the words and this only asks for them. A name that never needs translating stays a + * plain string, which is why the union keeps `string` rather than requiring a thunk everywhere. + * + * Call it during render, NEVER cache the result onto the entry — see `resolveEntryName`. + */ + name: string | (() => string); icon: ComponentType<{ className?: string; size?: number | string }>; component: ComponentType<{ panelId: string }>; header?: ComponentType<{ panelId: string }>; @@ -37,6 +47,17 @@ export type AppRegistryEntry = { export type AppRegistryMap = Record; +/** + * Resolve a panel name for display. Called at every render on purpose. + * + * Memoising this onto the entry, or hoisting it out of the render, would freeze the name at whatever the + * locale was when the registry was seeded — and the registry is seeded once, before the locale has even + * loaded. A locale change would then update every string in the app except the panel titles. That is the + * `useClient()` shape documented in platform/CLAUDE.md, which cost days on the Jellyfin player. + */ +export const resolveEntryName = (entry: Pick): string => + typeof entry.name === 'function' ? entry.name() : entry.name; + /** A panel supplied by a screen rather than by the registry. Rendered with `panelId`, exactly as those are. */ export type PanelComponentEntry = { component: ComponentType<{ panelId: string }>;