Skip to content

Commit 0fadd97

Browse files
committed
feat(hub-ui): translate the hub UI and add a language setting
Every hub-ui string now goes through a small in-house t() with ten locales (en, zh-CN, zh-TW, ja, ko, es, fr, de, pt-BR, ru) and English as the fallback. A Language select in Settings > Appearance persists the visitor's pick per browser; createUi({ locale }) seeds the default via ConnectionMeta.configs.ui.locale, and the running UI writes the effective language back into the connection it publishes so same-origin frames read it at boot. refs #398
1 parent cac6900 commit 0fadd97

54 files changed

Lines changed: 2130 additions & 239 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.agents/06-design-system.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ Two shadow-root gotchas the ahead-of-time CSS builder MUST compensate for (both
2828
- **A surface keeping its own component CSS** (inspect, a11y) sources every color from `@antfu/design`'s semantic shortcuts via `--at-apply` (expanded by `transformerDirectives`) rather than hardcoding a palette, so it tracks the shared theme and the `.dark` class.
2929
- **Plain `.ts`/vanilla views** MUST opt `.ts` into UnoCSS extraction (`content.pipeline.include` for Vite, or `content.filesystem` globs for the `@unocss/postcss` setup Next uses), since UnoCSS only scans framework files by default.
3030
- Before making new components or inline DOM elements, always check whether an `@antfu/design` component can be reused.
31+
- **hub-ui text goes through `t()`.** Every user-facing string in `packages/hub-ui/src/client` (templates, tooltips, `aria-label`s, command titles, confirm copy) MUST be a key in `client/i18n/locales/en.json` rendered with `t()` / `tAround()` from `client/i18n`, never a literal. A new key lands in every locale file in the same change; the i18n test enforces the lockstep. Brand names and `DF` diagnostics stay untranslated.
3132

3233
## Storybook
3334

‎docs/content/1.guide/18.hub-initiate.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ interface DevframeHubUi {
5959
- `'normal'` (default): shows immediately.
6060
- `'passive'`: hidden until `Shift+Alt+D`, then persisted per-origin (later browser sessions start shown).
6161
- `'hidden'`: hidden until `Shift+Alt+D`, that browser session only.
62+
- **`locale`** sets the default UI language, one of the tags in `HUB_UI_LOCALES` (`en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `es`, `fr`, `de`, `pt-BR`, `ru`). Without it the UI follows the browser language and falls back to English. A visitor's own pick in Settings → Appearance wins and persists per browser. The hub UI writes the language it resolved back into `ConnectionMeta.configs.ui.locale` on its window, so a same-origin frame that inherits the connection (see [Client](/guide/client)) reads the effective language at boot.
63+
64+
To add a language, add its tag and native name to `packages/hub-ui/src/locales.ts`, translate a copy of `packages/hub-ui/src/client/i18n/locales/en.json` under that tag, and register the file in the `messages` map in `packages/hub-ui/src/client/i18n/index.ts`. The i18n test fails on a missing key or a changed `{slot}`.
6265

6366
## Renderer modules
6467

‎packages/devframe/src/client/connection-storage.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ export function readStoredAuthToken(userAuthToken?: string): string | undefined
4444
return readFromWindows<string>(CONNECTION_AUTH_TOKEN_KEY)
4545
}
4646

47+
/**
48+
* Publish the connection on this window for same-origin frames to inherit.
49+
* Call again after mutating `connection.connectionMeta` so the legacy meta
50+
* copy below follows.
51+
*/
4752
export function storeConnection(connection: DevframeConnection): void {
4853
;(globalThis as any)[DEVFRAME_CONNECTION_KEY] = connection
4954
// Keep the established metadata/auth globals in sync for viewers that still

‎packages/devframe/src/client/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { getDevframeRpcClient } from './rpc'
22

33
export * from './connection'
4+
export { storeConnection } from './connection-storage'
45
export * from './otp'
56
export * from './rpc'
67
export type { DevframeServiceClientHandle, DevframeServicesClient } from './rpc-services'

‎packages/hub-ui/.storybook/preview.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import type { Decorator, Preview } from '@storybook/vue3-vite'
2+
import { setLocalePreference } from '../src/client/i18n'
3+
import { HUB_UI_LOCALES } from '../src/locales'
24
// Reset first so uno utilities win over its resets, matching the production
35
// `[reset, userStyle, unoCss, ...]` order; otherwise stories lose the reset.
46
import '@unocss/reset/tailwind.css'
@@ -18,6 +20,7 @@ function applyTheme(theme: string): void {
1820

1921
const withTheme: Decorator = (story, context) => {
2022
applyTheme(context.globals.theme ?? 'dark')
23+
setLocalePreference(context.globals.locale ?? 'en')
2124
return { components: { story }, template: '<story />' }
2225
}
2326

@@ -46,6 +49,16 @@ const preview: Preview = {
4649
dynamicTitle: true,
4750
},
4851
},
52+
locale: {
53+
description: 'UI language',
54+
defaultValue: 'en',
55+
toolbar: {
56+
title: 'Language',
57+
icon: 'globe',
58+
items: Object.entries(HUB_UI_LOCALES).map(([value, title]) => ({ value, title })),
59+
dynamicTitle: true,
60+
},
61+
},
4962
},
5063
decorators: [withTheme],
5164
}

‎packages/hub-ui/playground/main.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useLocalStorage } from '@vueuse/core'
44
import { watchEffect } from 'vue'
55
import { isDark } from '../src/client/state/color-mode'
66
import { DEFAULT_DOCK_PANEL_STORE } from '../src/client/state/docks'
7+
import { setupLocale } from '../src/client/state/locale'
78

89
/**
910
* The base `hub-plugin.ts` mounts the playground's hub instance at. Kept as
@@ -34,6 +35,7 @@ watchEffect(() => {
3435

3536
async function main(): Promise<void> {
3637
const rpc = await getDevframeRpcClient({ baseURL: HUB_BASE, simpleAuth: false })
38+
setupLocale(rpc)
3739
const { createDocksContext } = await import('../src/client/state/context')
3840

3941
if (mode === 'embedded') {

‎packages/hub-ui/src/client/components/command-palette/CommandPalette.vue‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { DocksContext } from '@devframes/hub/client'
44
import type { PaletteCrumb, PaletteFlatItem } from '../../state/palette'
55
import Fuse from 'fuse.js'
66
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
7+
import { t, tAround } from '../../i18n'
78
import { flattenPaletteCommands, paletteActionKeepsOpen, paletteScopeTrail, paletteTrailScopeId, reconcilePaletteTrail, resolvePaletteSelection } from '../../state/palette'
89
import BrandWordmark from '../icons/BrandWordmark.vue'
910
import CommandPaletteItem from './CommandPaletteItem.vue'
@@ -13,6 +14,7 @@ const props = defineProps<{
1314
}>()
1415
1516
const commandsCtx = computed(() => props.context.commands)
17+
const noResults = computed(() => tAround('palette.noResults', 'query'))
1618
const show = computed({
1719
get: () => commandsCtx.value.paletteOpen,
1820
set: (v) => { commandsCtx.value.paletteOpen = v },
@@ -311,7 +313,7 @@ function getKeybindings(id: string) {
311313
ref="searchInput"
312314
v-model="search"
313315
class="flex-1 bg-transparent py-3 outline-none text-sm color-base"
314-
placeholder="Type a command..."
316+
:placeholder="t('palette.placeholder')"
315317
@keydown="onKeyDown"
316318
>
317319
</header>
@@ -334,10 +336,10 @@ function getKeybindings(id: string) {
334336
<div v-if="!filtered.length" class="py-8 flex flex-col items-center justify-center gap-2 op50 text-sm">
335337
<div class="i-ph-magnifying-glass-duotone w-6 h-6" />
336338
<div v-if="search">
337-
No results for "<strong class="text-primary op100">{{ search }}</strong>"
339+
{{ noResults[0] }}<strong class="text-primary op100">{{ search }}</strong>{{ noResults[1] }}
338340
</div>
339341
<div v-else>
340-
No commands available
342+
{{ t('palette.empty') }}
341343
</div>
342344
</div>
343345
</div>
@@ -346,15 +348,15 @@ function getKeybindings(id: string) {
346348
<footer class="border-t border-base flex items-center justify-between gap-4 px-3 py-1.5 text-[10px] op50">
347349
<div class="flex items-center gap-1.5">
348350
<kbd class="px-1 py-0.5 rounded border border-base bg-base font-mono">&darr;&uarr;</kbd>
349-
<span>navigate</span>
351+
<span>{{ t('palette.navigate') }}</span>
350352
</div>
351353
<div class="flex items-center gap-1.5">
352354
<kbd class="px-1 py-0.5 rounded border border-base bg-base font-mono">esc</kbd>
353-
<span>{{ breadcrumb.length > 0 || dynamicItems ? 'back' : 'close' }}</span>
355+
<span>{{ breadcrumb.length > 0 || dynamicItems ? t('palette.back') : t('palette.close') }}</span>
354356
</div>
355357
<div class="flex items-center gap-1.5">
356358
<kbd class="px-1 py-0.5 rounded border border-base bg-base font-mono">&crarr;</kbd>
357-
<span>select</span>
359+
<span>{{ t('palette.select') }}</span>
358360
</div>
359361
</footer>
360362
</div>

‎packages/hub-ui/src/client/components/command-palette/CommandPaletteItem.vue‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<script setup lang="ts">
22
import type { DevframeCommandEntry, DevframeCommandKeybinding } from '@devframes/hub'
33
import DisplayKbd from '@antfu/design/components/Display/DisplayKbd.vue'
4+
import { t } from '../../i18n'
45
import DockIcon from '../dock/DockIcon.vue'
56
67
defineProps<{
@@ -44,7 +45,7 @@ defineEmits<{
4445
<span
4546
v-if="entry.source === 'server'"
4647
class="text-[10px] px-1 py-0 rounded bg-blue/10 text-blue shrink-0 leading-4"
47-
>server</span>
48+
>{{ t('palette.server') }}</span>
4849
</div>
4950
<div v-if="selected && entry.description" class="truncate text-xs op40 mt-0.5">
5051
{{ entry.description }}

‎packages/hub-ui/src/client/components/display/Confirm.vue‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<script setup lang="ts">
22
import ActionButton from '@antfu/design/components/Action/ActionButton.vue'
33
import { useTemplateRef, watchPostEffect } from 'vue'
4+
import { t } from '../../i18n'
45
import { ConfirmPromise } from '../../state/confirm'
56
67
const confirmButton = useTemplateRef<InstanceType<typeof ActionButton>>('confirmButton')
@@ -44,10 +45,10 @@ function resolveConfirm(resolve: (value: boolean) => void, value: boolean) {
4445

4546
<div class="flex items-center justify-end gap-2 mt-6">
4647
<ActionButton variant="text" size="sm" @click="resolveConfirm(resolve, false)">
47-
{{ options.cancelText ?? 'Cancel' }}
48+
{{ options.cancelText ?? t('common.cancel') }}
4849
</ActionButton>
4950
<ActionButton ref="confirmButton" variant="primary" size="sm" @click="resolveConfirm(resolve, true)">
50-
{{ options.confirmText ?? 'OK' }}
51+
{{ options.confirmText ?? t('common.ok') }}
5152
</ActionButton>
5253
</div>
5354
</div>

‎packages/hub-ui/src/client/components/display/OtpInput.vue‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<script setup lang="ts">
22
import type { ComponentPublicInstance } from 'vue'
33
import { computed, nextTick, ref } from 'vue'
4+
import { t } from '../../i18n'
45
56
const props = withDefaults(defineProps<{
67
/** The current code (controlled). */
@@ -19,7 +20,6 @@ const props = withDefaults(defineProps<{
1920
disabled: false,
2021
invalid: false,
2122
autofocus: true,
22-
label: 'One-time authorization code',
2323
})
2424
2525
const emit = defineEmits<{
@@ -160,7 +160,7 @@ defineExpose({
160160
<template>
161161
<div
162162
role="group"
163-
:aria-label="label"
163+
:aria-label="label ?? t('auth.otpGroupLabel')"
164164
class="flex items-center justify-center gap-2 sm:gap-2.5"
165165
:class="invalid ? 'devframes-shake' : ''"
166166
>
@@ -177,7 +177,7 @@ defineExpose({
177177
data-bwignore="true"
178178
data-form-type="other"
179179
:autofocus="autofocus && i === 0"
180-
:aria-label="`Digit ${i + 1} of ${length}`"
180+
:aria-label="t('auth.otpDigit', { index: i + 1, length })"
181181
:aria-invalid="invalid || undefined"
182182
:disabled="disabled"
183183
maxlength="1"

0 commit comments

Comments
 (0)