Procházet zdrojové kódy

fix(web): align connection indicator labels

imccyu před 2 týdny
rodič
revize
84c7ae3398

+ 2 - 2
.agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.i18n.yaml

@@ -2,5 +2,5 @@
 # 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 .agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.md
-2026-08-28-web-connection-recovery-control.md: 7adf372498105bad643606cf7cd99c71406b634b
-2026-08-28-web-connection-recovery-control.zh.md: c1172c530035590ebcd636e8735bc378c15bc69d
+2026-08-28-web-connection-recovery-control.md: 6fec265c8e166836a5ab9413f1612cdaa6461a1d
+2026-08-28-web-connection-recovery-control.zh.md: e45119b272c297a783a650b71743e4f81c1a1565

+ 1 - 1
.agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.md

@@ -16,7 +16,7 @@ The Client Connection service exposes the identity-stable `ctx.connection.state`
 
 The [Web Client architecture](../architecture/2026-07-19-gui-web-client-architecture.md), [Remote event delivery](../architecture/2026-08-10-remote-event-delivery.md), and [Session event transport](../architecture/2026-08-18-session-history-and-event-transport.md) retain their broader ownership decisions; this note supersedes only their former retry timing.
 
-The Settings shell is a recovery-specific consumer and therefore injects Connection directly; ordinary feature code continues to use `ctx.remote`. Its private hooks compartment binds the state observable and reconnect command. The expanded sidebar renders `ConnectionIndicator` immediately to the right of Settings: `disconnected` is a pale-yellow **Connection issue** action, `connecting` stays yellow while one to three dots advance every 500ms independently of retry timing, and a recovered connection displays pale-green **Connected** for two seconds. Hover or keyboard focus on either yellow state changes only the text to **Reconnect now**; press feedback uses a small warning-color transition, and no native title tooltip is present. Every visible state reserves the widest localized label and uses fixed icon and text columns, so state changes do not move or resize the control. Initial startup and uninterrupted healthy operation render nothing.
+The Settings shell is a recovery-specific consumer and therefore injects Connection directly; ordinary feature code continues to use `ctx.remote`. Its private hooks compartment binds the state observable and reconnect command. The expanded sidebar renders `ConnectionIndicator` immediately to the right of Settings: `disconnected` is a pale-yellow **Disconnected** action, `connecting` stays yellow while one to three dots advance every 500ms independently of retry timing, and a recovered connection displays pale-green **Connected** for two seconds. Hover or keyboard focus on either yellow state changes only the text to **Reconnect now**; press feedback uses a small warning-color transition, and no native title tooltip is present. Every visible state reserves the widest localized label and uses fixed icon and left-aligned text columns, so state changes do not move or resize the control. Initial startup and uninterrupted healthy operation render nothing.
 
 ## Alternatives considered
 

+ 1 - 1
.agents/notes/implemented/feature/2026-08-28-web-connection-recovery-control.zh.md

@@ -16,7 +16,7 @@ Client Connection 服务暴露 identity 稳定的 `ctx.connection.state` observa
 
 [Web Client 架构](../architecture/2026-07-19-gui-web-client-architecture.zh.md)、[Remote 事件投递](../architecture/2026-08-10-remote-event-delivery.zh.md)和[会话事件传输](../architecture/2026-08-18-session-history-and-event-transport.zh.md)继续持有各自更宽的所有权决策;本笔记只取代其中原有的重试时序。
 
-Settings 外壳是恢复功能专用消费方,因此直接注入 Connection;普通功能代码仍使用 `ctx.remote`。它的私有 hooks compartment 绑定状态 observable 与重连命令。展开的侧边栏在 Settings 右侧渲染 `ConnectionIndicator`:`disconnected` 是浅黄色的**连接异常**操作;`connecting` 保持黄色,其中一至三个点每 500ms 前进一次,与 retry 时序无关;恢复后则以浅绿色显示**连接成功**并驻留 2 秒。鼠标悬浮或键盘聚焦任一黄色状态时只把文字改为**立即重连**;按压反馈采用轻微的警告色过渡,不使用原生 title tooltip。所有可见状态都为最宽的本地化文字预留空间,并使用固定的图标列和文字列,因此状态变化不会移动控件或改变其宽度。首次启动和未曾中断的健康连接都不渲染。
+Settings 外壳是恢复功能专用消费方,因此直接注入 Connection;普通功能代码仍使用 `ctx.remote`。它的私有 hooks compartment 绑定状态 observable 与重连命令。展开的侧边栏在 Settings 右侧渲染 `ConnectionIndicator`:`disconnected` 是浅黄色的**连接异常**操作;`connecting` 保持黄色,其中一至三个点每 500ms 前进一次,与 retry 时序无关;恢复后则以浅绿色显示**连接成功**并驻留 2 秒。鼠标悬浮或键盘聚焦任一黄色状态时只把文字改为**立即重连**;按压反馈采用轻微的警告色过渡,不使用原生 title tooltip。所有可见状态都为最宽的本地化文字预留空间,并使用固定的图标列和左对齐文字列,因此状态变化不会移动控件或改变其宽度。首次启动和未曾中断的健康连接都不渲染。
 
 ## Alternatives considered
 

+ 22 - 12
apps/web/tests/lifecycle-chrome.e2e.ts

@@ -23,7 +23,7 @@ import {
   launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
 import {
-  connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft, ZH_BROWSER_LOCALE,
+  connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft,
 } from './support.ts'
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome', import.meta.url))
@@ -286,10 +286,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
   }, 60_000)
 
   it.skipIf(MODE === 'record')('shows automatic and user-requested connection recovery beside Settings', async () => {
-    const recoveryPage = await browser.newPage({
-      viewport: { width: 1680, height: 1000 },
-      locale: ZH_BROWSER_LOCALE,
-    })
+    const recoveryPage = await newEnglishPage(browser)
     const recoveryTripwire = watchConsole(recoveryPage)
     const sockets: WebSocketRoute[] = []
     let rejectConnections = false
@@ -310,7 +307,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
       await recoveryPage.context().setOffline(true)
       await expect.poll(() => recoveryPage.evaluate(() => navigator.onLine)).toBe(false)
       const offline = recoveryPage.getByRole('button', {
-        name: '连接异常,点击立即重连', exact: true,
+        name: 'Disconnected, reconnect now', exact: true,
       })
       await offline.waitFor({ timeout: 2_000 })
       await recoveryPage.waitForTimeout(750)
@@ -319,22 +316,24 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
       await recoveryPage.context().setOffline(false)
       await expect.poll(() => recoveryPage.evaluate(() => navigator.onLine)).toBe(true)
       const connecting = recoveryPage.getByRole('button', {
-        name: '连接中,点击立即重连', exact: true,
+        name: 'Connecting, restart now', exact: true,
       })
       await connecting.waitFor({ timeout: 10_000 })
-      expect(await connecting.innerText()).toMatch(/^连接中\.{1,3}$/)
+      expect(await connecting.innerText()).toMatch(/^Connecting\.{1,3}$/)
       const connectingGeometry = await connectionIndicatorGeometry(connecting)
+      expect(await connectionIndicatorTextAlignment(connecting)).toBe('left')
       await connecting.hover()
-      expect(await connecting.innerText()).toBe('立即重连')
+      expect(await connecting.innerText()).toBe('Reconnect now')
       expect(await connectionIndicatorGeometry(connecting)).toEqual(connectingGeometry)
       await recoveryPage.mouse.move(0, 0)
 
       await expect.poll(() => sockets.length, { timeout: 40_000 }).toBe(7)
       const indicator = recoveryPage.getByRole('button', {
-        name: '连接异常,点击立即重连', exact: true,
+        name: 'Disconnected, reconnect now', exact: true,
       })
       await indicator.waitFor({ timeout: 10_000 })
       expect(await connectionIndicatorGeometry(indicator)).toEqual(connectingGeometry)
+      expect(await connectionIndicatorTextAlignment(indicator)).toBe('left')
       const snapshot = await captureStableAria(recoveryPage, '[class*="footArea"]', scaffold.workspaceCwd)
       await compareOrRefreshGolden(CONNECTION_ERROR_EXPECTED, snapshot, MODE)
       const style = await indicator.evaluate((element) => {
@@ -359,7 +358,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
       expect(await indicator.getAttribute('title')).toBeNull()
       const idleBackground = await indicator.evaluate(element => getComputedStyle(element).backgroundColor)
       await indicator.hover()
-      expect(await indicator.innerText()).toBe('立即重连')
+      expect(await indicator.innerText()).toBe('Reconnect now')
       const hoverBackground = await indicator.evaluate(element => getComputedStyle(element).backgroundColor)
       expect(hoverBackground).toBe(idleBackground)
       await recoveryPage.mouse.down()
@@ -371,8 +370,9 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
       await expect.poll(() => sockets.length).toBe(8)
       const recovered = recoveryPage.getByRole('status')
       await recovered.waitFor({ timeout: 10_000 })
-      expect(await recovered.innerText()).toBe('连接成功')
+      expect(await recovered.innerText()).toBe('Connected')
       expect(await connectionIndicatorGeometry(recovered)).toEqual(connectingGeometry)
+      expect(await connectionIndicatorTextAlignment(recovered)).toBe('left')
       await recovered.waitFor({ state: 'detached', timeout: 5_000 })
       expect(recoveryTripwire.pageErrors).toEqual([])
       expect(recoveryTripwire.warnings.filter(warning => /connection lost, retry #[1-6]/i.test(warning)))
@@ -410,3 +410,13 @@ async function connectionIndicatorGeometry(locator: ReturnType<Page['getByRole']
     }
   })
 }
+
+async function connectionIndicatorTextAlignment(
+  locator: ReturnType<Page['getByRole']>,
+): Promise<string> {
+  return await locator.evaluate((element) => {
+    const label = element.children.item(1)
+    if (label === null) throw new Error('connection indicator label missing')
+    return getComputedStyle(label).textAlign
+  })
+}

+ 1 - 0
packages/client/ui-primitives/src/ConnectionIndicator.module.css

@@ -50,6 +50,7 @@
 
 .label {
   display: grid;
+  text-align: left;
 }
 
 .stateLabel,

+ 4 - 4
packages/client/ui-primitives/tests/atoms.client.spec.tsx

@@ -423,11 +423,11 @@ describe('ConnectionIndicator', () => {
   it('renders outage, attempt progress, and recovered states without a native tooltip', () => {
     const reconnect = vi.fn()
     const labels = {
-      disconnectedLabel: 'Connection issue',
+      disconnectedLabel: 'Disconnected',
       reconnectLabel: 'Reconnect',
       connectingLabel: 'Connecting',
       recoveredLabel: 'Connected',
-      reconnectActionLabel: 'Connection issue, reconnect now',
+      reconnectActionLabel: 'Disconnected, reconnect now',
       restartActionLabel: 'Connecting, restart now',
       onReconnect: reconnect,
     }
@@ -436,8 +436,8 @@ describe('ConnectionIndicator', () => {
     )
     expect(container.firstChild).toBeNull()
     rerender(<ConnectionIndicator state="disconnected" {...labels} />)
-    const indicator = screen.getByRole('button', { name: 'Connection issue, reconnect now' })
-    expect(indicator.textContent).toContain('Connection issue')
+    const indicator = screen.getByRole('button', { name: 'Disconnected, reconnect now' })
+    expect(indicator.textContent).toContain('Disconnected')
     expect(indicator.textContent).toContain('Reconnect')
     expect(indicator.hasAttribute('title')).toBe(false)
     expect(indicator.querySelector('svg')).toBeTruthy()

+ 2 - 2
packages/client/ui-settings-general/README.i18n.yaml

@@ -2,5 +2,5 @@
 # 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 packages/client/ui-settings-general/README.md
-README.md: acae84a4d4a5dddabb9e11135487911c84d47c96
-README.zh.md: db96d7d867a6d73aba75115c38d47937a1259ad4
+README.md: 1d231c2c1de2d8870c6f16e0d993b2387c876e4d
+README.zh.md: 13fe7b9f4a58f0f8836437485333127e76e2410e

+ 1 - 1
packages/client/ui-settings-general/README.md

@@ -25,7 +25,7 @@ English | [中文](README.zh.md)
 <a id="use-this-package"></a>
 ## Use this package
 
-Users reach the shell through the sidebar's bottom Settings control; feature plugins contribute their pages and onboarding steps through the slot ledgers this shell projects. After a Host connection failure, a pale-yellow **Connection issue** action appears to the right of Settings. Automatic recovery shows **Connecting** with one to three dots advancing every 500ms. Hover or keyboard focus changes either yellow label to **Reconnect now** without changing its background; press feedback stays within the warning palette, and selecting it starts retry 1 immediately. Recovery changes the region to pale-green **Connected** for two seconds before it disappears. The icon, text origin, height, and width remain fixed across every visible state. Initial startup and uninterrupted healthy operation remain silent. The shell renders the modal panel, the navigation built from `settings.section` entries, and exactly one mounted onboarding step at a time.
+Users reach the shell through the sidebar's bottom Settings control; feature plugins contribute their pages and onboarding steps through the slot ledgers this shell projects. After a Host connection failure, a pale-yellow **Disconnected** action appears to the right of Settings. Automatic recovery shows **Connecting** with one to three dots advancing every 500ms. Hover or keyboard focus changes either yellow label to **Reconnect now** without changing its background; press feedback stays within the warning palette, and selecting it starts retry 1 immediately. Recovery changes the region to pale-green **Connected** for two seconds before it disappears. The icon, left-aligned text origin, height, and width remain fixed across every visible state. Initial startup and uninterrupted healthy operation remain silent. The shell renders the modal panel, the navigation built from `settings.section` entries, and exactly one mounted onboarding step at a time.
 
 ### The General section
 

+ 1 - 1
packages/client/ui-settings-general/README.zh.md

@@ -25,7 +25,7 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 ## 使用本包
 
-用户通过侧边栏底部的 Settings 控件进入外壳;功能插件通过本外壳所投影的 slot 账本贡献自己的页面与引导步骤。Host 连接失败后,浅黄色的**连接异常**操作会出现在 Settings 右侧;自动恢复期间显示**连接中**,其后一至三个点每 500ms 前进一次。鼠标悬浮或键盘聚焦任一黄色状态时,只有文案变为**立即重连**,背景保持不变;按压反馈留在黄色色阶内,选中后立即从 retry 1 开始。恢复后该区域变为浅绿色的**连接成功**,驻留 2 秒再消失。图标、文字起点、高度和宽度在所有可见状态中保持固定。首次启动与未曾中断的健康连接保持静默。外壳渲染模态面板、由 `settings.section` 条目构建的导航,以及每次只挂载一个的引导步骤。
+用户通过侧边栏底部的 Settings 控件进入外壳;功能插件通过本外壳所投影的 slot 账本贡献自己的页面与引导步骤。Host 连接失败后,浅黄色的**连接异常**操作会出现在 Settings 右侧;自动恢复期间显示**连接中**,其后一至三个点每 500ms 前进一次。鼠标悬浮或键盘聚焦任一黄色状态时,只有文案变为**立即重连**,背景保持不变;按压反馈留在黄色色阶内,选中后立即从 retry 1 开始。恢复后该区域变为浅绿色的**连接成功**,驻留 2 秒再消失。所有可见状态的文字都左对齐,且图标、文字起点、高度和宽度保持固定。首次启动与未曾中断的健康连接保持静默。外壳渲染模态面板、由 `settings.section` 条目构建的导航,以及每次只挂载一个的引导步骤。
 
 ### 「通用」分区
 

+ 2 - 2
packages/client/ui-settings-general/src/client/locales.ts

@@ -27,10 +27,10 @@ export const en = {
   'openDocument': 'Open configuration file',
   'openDocument.error': 'Could not open configuration file',
   'general.nav': 'General',
-  'connection.error': 'Connection issue',
+  'connection.error': 'Disconnected',
   'connection.retry': 'Reconnect now',
   'connection.connecting': 'Connecting',
   'connection.connected': 'Connected',
-  'connection.reconnect': 'Connection issue, reconnect now',
+  'connection.reconnect': 'Disconnected, reconnect now',
   'connection.restart': 'Connecting, restart now',
 } satisfies Record<SettingsKey, string>

+ 1 - 1
packages/client/ui-settings-general/tests/apply.client.spec.ts

@@ -132,7 +132,7 @@ describe('ui-settings-general apply', () => {
     expect(b.locale.bind('settings')('connection.connected')).toBe('连接成功')
     b.locale.setLocale('en')
     expect(b.locale.bind('settings')('close')).toBe('Close')
-    expect(b.locale.bind('settings')('connection.reconnect')).toBe('Connection issue, reconnect now')
+    expect(b.locale.bind('settings')('connection.reconnect')).toBe('Disconnected, reconnect now')
     b.locale.setLocale('zh')
     await fiber.dispose()
     // The (ns, locale) seats are free again — the dictionary disposer ran.

+ 4 - 4
packages/client/ui-settings-general/tests/settings-root.client.spec.tsx

@@ -140,11 +140,11 @@ describe('SettingsRoot trigger', () => {
   it('shows outage, retry progress, and a two-second recovery confirmation', () => {
     vi.useFakeTimers()
     const mounted = mount()
-    expect(screen.queryByRole('button', { name: 'Connection issue, reconnect now' })).toBeNull()
+    expect(screen.queryByRole('button', { name: 'Disconnected, reconnect now' })).toBeNull()
 
     mounted.setConnectionState('disconnected')
-    const indicator = screen.getByRole('button', { name: 'Connection issue, reconnect now' })
-    expect(indicator.textContent).toContain('Connection issue')
+    const indicator = screen.getByRole('button', { name: 'Disconnected, reconnect now' })
+    expect(indicator.textContent).toContain('Disconnected')
     expect(indicator.hasAttribute('title')).toBe(false)
     expect(indicator.querySelector('svg')).toBeTruthy()
     fireEvent.click(indicator)
@@ -164,7 +164,7 @@ describe('SettingsRoot trigger', () => {
 
   it('keeps the reconnect indicator out of the collapsed rail', () => {
     mount({ wide: false, connectionState: 'disconnected' })
-    expect(screen.queryByRole('button', { name: 'Connection issue, reconnect now' })).toBeNull()
+    expect(screen.queryByRole('button', { name: 'Disconnected, reconnect now' })).toBeNull()
   })
 })
 

+ 3 - 3
snapshots/web/lifecycle-chrome/connection-error.expected.md

@@ -1,4 +1,4 @@
-- button "设置":
+- button "Settings":
   - img
-  - text: 设置
-- button "连接异常,点击立即重连": 连接异常
+  - text: Settings
+- button "Disconnected, reconnect now": Disconnected