diff --git a/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.i18n.yaml b/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.i18n.yaml index 6707b51680..f30d9be553 100644 --- a/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.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-09-20-focus-ring-brand-primary.md -2026-09-20-focus-ring-brand-primary.md: 3c6c041ac04774e6f5f66085f4616467020bd40e -2026-09-20-focus-ring-brand-primary.zh.md: 541537c54e3ee1016c133f99557a0f2fa9204587 +2026-09-20-focus-ring-brand-primary.md: 288f3722428a5dd52822c3fecbbaaea5c7c6310f +2026-09-20-focus-ring-brand-primary.zh.md: 792374b92cbd056bf181b79f8b22e1aec572d3bb diff --git a/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.md b/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.md index 3c6c041ac0..288f372242 100644 --- a/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.md +++ b/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.md @@ -6,28 +6,38 @@ English | [中文](2026-09-20-focus-ring-brand-primary.zh.md) ## Problem -Keyboard focus rings carried at least five different colours across the client: `--dsw-alias-state-business-primary` on 31 declarations, `--dsw-alias-label-primary`, `--dsw-alias-label-tertiary`, `--dsw-alias-state-warn-label`, `--dsw-alias-border-l4` and two button fills elsewhere. Controls that declared no ring at all fell back to the browser default, whose colour follows the operating-system accent — so the same build shows orange on one machine and blue on another, and a control can change colour when nothing about the control changed. +Different explicit focus colours and browser-default outlines do not provide a consistent keyboard-focus cue. Browser defaults can also depend on the operating-system accent. + +Focus feedback has separate interaction and geometry constraints. A browser's `:focus-visible` heuristic can reveal a pointer-focused button after a key that does not move focus, while editable text controls need focus feedback on click. Containers that clip their contents can hide an outer ring. ## Decision -Every keyboard focus ring uses `--dsw-alias-brand-primary`. +[The theme's focus stylesheet](../../../../packages/client/ui-theme/src/styles/focus.css) owns the shared ring colour and standard width. [Input modality](../../../../packages/client/ui-primitives/README.md#input-modality) distinguishes tooltip input from keyboard focus navigation. -Two parts implement this, and both are required. `ui-theme/src/styles/focus.css` declares a global `:focus-visible { outline-color: var(--dsw-alias-brand-primary) }`, which reaches the controls that draw the browser default. The 49 component declarations that named another colour now name the same token. Only the colour changed: each declaration keeps its own width, style and offset. +**One colour.** Global and component `:focus-visible` outlines and focus-ring shadows read `--dsw-focus-ring-color`, falling back to `--dsw-alias-brand-primary`. The global fallback sets only outline colour; it does not create an outline where the component disables one. -The global sheet sets `outline-color` alone. A component that owns a focus ring keeps it, because its `.class:focus-visible` selector outweighs the bare `:focus-visible` rule; a control with no ring of its own inherits the brand colour instead of the system accent. +**Context-owned geometry.** `--dsw-focus-ring-width` carries the standard 2px width. Dense tables and toolbars may keep 1px so the ring does not dominate compact content. Components own their offsets and use an inset ring where an outer ring would be clipped. + +**Pointer focus does not imply keyboard navigation.** In pointer modality, `:focus-visible:not(:read-write)` controls use a transparent ring colour. Descendant and pseudo-element rings inherit it. Only focus-ring paint is suppressed: selected-state borders and elevation shadows remain, and editable text controls keep their own focus feedback on click. DOM focus stays on the control. + +Tooltips follow the last input and treat any key as keyboard input. Rings instead resume keyboard styling on non-composing navigation keys, or on focus reaching a different control after a non-composing key. Refocusing the same control is not navigation; pointer input, composition keys, and window blur clear the pending key. ## Alternatives considered -**Only rewrite the component declarations.** The 49 explicit rings would agree, but every control that relies on the browser default — the sidebar rail toggles among them — would still paint the operating-system accent, which is the visible symptom this change exists to remove. +**Rewrite only the component declarations.** Explicit rings would agree, but controls using a browser-default outline would retain its colour. Requiring every control to declare its own ring would also leave new controls inconsistent until their authors add that rule. -**Give each remaining control its own ring declaration.** This reaches the same result, but each new control reintroduces the defect until someone remembers to add a ring, and the shell styles would have to anticipate every interactive element it renders. +**Unify width and offset globally.** Positive offsets can be clipped by a scrolling ancestor, while a uniform thick ring can overwhelm dense content. Neither colour consistency nor input modality requires identical geometry. -**Unify width and offset as well.** The reported inconsistency included those, but the confirmed design decision covers colour. Width and offset stay per component until a design decision fixes them. +**Suppress by moving focus instead of by style.** Calling `blur()` on a pointer-focused button would hide its ring but also remove the target for subsequent Enter or Space activation. -**Replace every `outline: none` declaration with the shared ring.** Those declarations accompany deliberate alternatives (inset `box-shadow` rings, container-level focus treatment) or suppress focus on containers that must not show one. Removing them changes behaviour beyond colour. +**Clear all shadows on pointer focus.** A `box-shadow` can contain elevation or selected-state paint as well as a focus ring. Suppressing its ring colour preserves those independent cues; exempting `:read-write` preserves text-entry feedback. + +**Replace every `outline: none` with a shared ring.** Some controls use an inset shadow or container-level focus treatment instead; some containers deliberately show no ring. Replacing those declarations would change their focus treatment, not merely its colour. + +**Give tooltips and rings the same modality rule.** A key can justify a tooltip on subsequent programmatic focus without justifying a ring on the same pointer-focused button. Keeping separate answers preserves both behaviours. ## Consequences -The focus colour no longer tracks the operating-system accent, so a screen recording or screenshot is reproducible across machines. Components keep their geometry, and controls that never declared a ring now match those that did. The global rule is the only place that decides the fallback, so a future design change edits one token reference rather than every module. +Focus rings share a theme colour while components retain their geometry and non-focus state cues. Ring visibility changes without changing the keyboard's active target. These rules do not guarantee identical browser rendering or eliminate component-specific clipping. -`ui-theme/tests/client-styles.client.spec.ts` pins the sheet in the injection order. `pnpm run test:gui` covers the client suites; the recorded Web replay covers the assembled browser. +[Stylesheet-order checks](../../../../packages/client/ui-theme/tests/client-styles.client.spec.ts), [focus-style checks](../../../../packages/client/ui-theme/tests/focus-styles.client.spec.ts), and [input-modality tests](../../../../packages/client/ui-primitives/tests/input-modality.client.spec.ts) cover stylesheet declarations and modality transitions. Static declarations and synthetic events do not establish rendered ring visibility or cross-platform behaviour. diff --git a/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.zh.md b/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.zh.md index 541537c54e..792374b92c 100644 --- a/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.zh.md +++ b/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.zh.md @@ -6,28 +6,38 @@ Status: implemented ## 问题 -客户端的键盘焦点环至少使用了五种颜色:31 处声明用 `--dsw-alias-state-business-primary`,其余分散在 `--dsw-alias-label-primary`、`--dsw-alias-label-tertiary`、`--dsw-alias-state-warn-label`、`--dsw-alias-border-l4` 和两种按钮填充色上。完全没有声明焦点环的控件则回退到浏览器默认环,其颜色跟随操作系统强调色——同一个构建在一台机器上显示橙色、另一台显示蓝色,控件本身没变颜色却变了。 +不同的显式焦点颜色与浏览器默认轮廓无法提供一致的键盘焦点提示。浏览器默认值还可能取决于操作系统强调色。 + +焦点反馈另有交互与几何约束。浏览器的 `:focus-visible` 启发式可能在不移动焦点的按键之后显出指针聚焦按钮的环,而可编辑文本控件在点击时需要焦点反馈。裁切内容的容器可能隐藏外扩环。 ## 决策 -所有键盘焦点环统一使用 `--dsw-alias-brand-primary`。 +[主题焦点样式表](../../../../packages/client/ui-theme/src/styles/focus.css) 拥有共享焦点环颜色与标准宽度。[输入模态](../../../../packages/client/ui-primitives/README.zh.md#input-modality) 区分 tooltip 输入与键盘焦点导航。 -实现分两部分,缺一不可。`ui-theme/src/styles/focus.css` 声明全局 `:focus-visible { outline-color: var(--dsw-alias-brand-primary) }`,覆盖那些画浏览器默认环的控件;49 处原本指定了其它颜色的组件声明改为同一个 token。只改颜色:每条声明保留自己的宽度、样式和 offset。 +**一个颜色。** 全局与组件的 `:focus-visible` 轮廓和焦点环阴影读取 `--dsw-focus-ring-color`,回退到 `--dsw-alias-brand-primary`。全局兜底只设置轮廓颜色;组件禁用轮廓时,它不会创建轮廓。 -全局规则只设置 `outline-color`。自带焦点环的组件不受影响,因为它的 `.class:focus-visible` 选择器权重高于裸 `:focus-visible` 规则;没有焦点环的控件则继承品牌色,而不是系统强调色。 +**场景决定几何。** `--dsw-focus-ring-width` 提供标准的 2px 宽度。密集表格与工具栏可以保留 1px,避免焦点环压过紧凑内容。组件拥有自己的 offset,并在外扩环会被裁切时使用内嵌环。 + +**指针焦点不等于键盘导航。** 指针模态下,`:focus-visible:not(:read-write)` 控件使用透明的焦点环颜色。后代与伪元素上的环继承该值。只抑制焦点环绘制:选中态边框与 elevation 阴影保留,可编辑文本控件在点击时保留自身焦点反馈。DOM 焦点仍留在控件上。 + +Tooltip 跟随最近一次输入,将任意按键视为键盘输入。焦点环则在非组合输入的导航键到达时,或非组合输入按键之后焦点到达不同控件时,恢复键盘样式。重新聚焦同一控件不算导航;指针输入、组合输入按键和 window blur 会清除待处理按键。 ## 考虑过的其他做法 -**只重写组件声明。** 49 个显式焦点环会一致,但所有依赖浏览器默认环的控件——包括侧栏轨道的切换按钮——仍会画出操作系统强调色,而这正是本次改动要消除的可见症状。 +**只重写组件声明。** 显式焦点环会一致,但使用浏览器默认轮廓的控件仍会保留其颜色。要求每个控件自行声明焦点环,也会让新控件在作者补上规则之前保持不一致。 -**给剩下每个控件单独加焦点环声明。** 结果相同,但每新增一个控件都会重新引入缺陷,直到有人记得补环;壳层样式还得预判它渲染的每个可交互元素。 +**全局统一宽度与 offset。** 正 offset 可能被滚动祖先裁切,统一的粗环则可能压过密集内容。颜色一致性和输入模态都不要求相同的几何。 -**连宽度和 offset 一起统一。** 反馈中的不一致确实包含这两项,但已确认的设计决策只覆盖颜色。在设计定下之前,宽度和 offset 保持逐组件。 +**用移动焦点取代样式抑制。** 对指针聚焦的按钮调用 `blur()` 可以隐藏焦点环,但也会移除后续 Enter 或 Space 激活的目标。 -**把所有 `outline: none` 改为共享焦点环。** 这些声明分别对应刻意的替代方案(内嵌 `box-shadow` 环、容器级焦点处理),或用于抑制本就不应显示焦点的容器。删除它们会改变颜色之外的行为。 +**指针聚焦时清除所有阴影。** 一个 `box-shadow` 除了焦点环,还可能包含 elevation 或选中态绘制。抑制其焦点环颜色可保留这些独立提示;豁免 `:read-write` 可保留文本输入反馈。 + +**把所有 `outline: none` 改为共享焦点环。** 一些控件改用内嵌阴影或容器级焦点处理;一些容器则刻意不显示焦点环。替换这些声明会改变焦点处理方式,而不只是颜色。 + +**让 tooltip 与焦点环共用同一条模态规则。** 按键可以让后续程序化聚焦显示 tooltip,却不一定需要在同一个指针聚焦按钮上显示焦点环。分别记录两个答案可保留两种行为。 ## 影响 -焦点颜色不再跟随操作系统强调色,因此录屏和截图在不同机器上可复现。组件保留自身几何形状,从未声明焦点环的控件现在与已声明的控件一致。全局规则是决定兜底颜色的唯一位置,未来的设计变更只需改一处 token 引用,而非每个模块。 +焦点环共享主题颜色,组件保留自己的几何与非焦点状态提示。焦点环可见性的变化不改变键盘的活动目标。这些规则不保证浏览器渲染完全相同,也不会消除组件特有的裁切。 -`ui-theme/tests/client-styles.client.spec.ts` 固定了该样式表在注入顺序中的位置。`pnpm run test:gui` 覆盖客户端套件;录制式 Web 回放覆盖组装后的浏览器。 +[样式表顺序检查](../../../../packages/client/ui-theme/tests/client-styles.client.spec.ts)、[焦点样式检查](../../../../packages/client/ui-theme/tests/focus-styles.client.spec.ts)与[输入模态测试](../../../../packages/client/ui-primitives/tests/input-modality.client.spec.ts)覆盖样式声明和模态转换。静态声明与合成事件不能确认焦点环的实际渲染可见性或跨平台行为。 diff --git a/apps/desktop/src/preload-menu.ts b/apps/desktop/src/preload-menu.ts index aa2f54a423..d913f1ed3e 100644 --- a/apps/desktop/src/preload-menu.ts +++ b/apps/desktop/src/preload-menu.ts @@ -22,7 +22,8 @@ export function installWindowsMenu(): { update(): void; dispose(): void } { font: inherit; font-size: 14px; cursor: default; } button:hover, button[aria-expanded=true] { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); } - button:focus-visible { outline: 2px solid var(--dsw-alias-label-primary); outline-offset: -2px; } + button:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: -2px; } + :host-context(html[data-input-modality='pointer']) button:focus-visible { outline-color: transparent; } ` const bar = document.createElement('div') bar.setAttribute('role', 'menubar') diff --git a/apps/web/tests/focus-rings.e2e.ts b/apps/web/tests/focus-rings.e2e.ts new file mode 100644 index 0000000000..49a17c6b2e --- /dev/null +++ b/apps/web/tests/focus-rings.e2e.ts @@ -0,0 +1,357 @@ +/** Real assembled UI plus source-compiled browser regressions for focus-ring paint. */ +import { join } from 'node:path' +import { build } from 'vite' +import { chromium, type Locator, type Page } from 'playwright' +import { beforeAll, describe, expect, it, onTestFinished } from 'vitest' +import { launchWebScaffold, watchConsole } from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, REPO_ROOT } from './support.ts' + +/** Read rendered paint and browser focus, not just the modality publisher's attribute. */ +async function paint(target: Locator) { + return target.evaluate((element) => { + const style = getComputedStyle(element) + const resolveColor = (value: string): string => { + const probe = document.createElement('span') + probe.style.color = value + document.body.append(probe) + const color = getComputedStyle(probe).color + probe.remove() + return color + } + return { + active: document.activeElement === element, + focusVisible: element.matches(':focus-visible'), + modality: document.documentElement.getAttribute('data-input-modality'), + brand: resolveColor(style.getPropertyValue('--dsw-alias-brand-primary').trim()), + hover: resolveColor(style.getPropertyValue('--dsw-alias-interactive-bg-hover').trim()), + outline: style.outlineColor, + outlineStyle: style.outlineStyle, + outlineWidth: style.outlineWidth, + shadow: style.boxShadow, + background: style.backgroundColor, + } + }) +} + +async function expectSilent(target: Locator): Promise { + const state = await paint(target) + expect(state.active).toBe(true) + expect(state.modality).toBe('pointer') + expect(state.brand).toMatch(/^rgb/) + expect(state.outlineStyle === 'none' || state.outline === 'rgba(0, 0, 0, 0)').toBe(true) + expect(state.shadow).not.toContain(state.brand) +} + +async function expectBrand(target: Locator, kind: 'outline' | 'shadow'): Promise { + await expect.poll(async () => { + const state = await paint(target) + return { + active: state.active, + visible: state.focusVisible, + modality: state.modality, + painted: kind === 'shadow' + ? state.shadow.includes(state.brand) + : state.outline === state.brand && state.outlineStyle === 'solid' && Number.parseFloat(state.outlineWidth) > 0, + } + }).toEqual({ active: true, visible: true, modality: 'keyboard', painted: true }) +} + +it('assembled app: pointer keys stay silent; keyboard navigation and menu activation retain feedback', async () => { + const scaffold = await launchWebScaffold() + onTestFinished(() => scaffold.close()) + const browser = await chromium.launch({ headless: true }) + onTestFinished(() => browser.close()) + const page = await newEnglishPage(browser) + const consoleWatch = watchConsole(page) + await page.goto(scaffold.authenticatedUrl) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + const trigger = page.getByRole('button', { name: /^Access mode, current:/ }) + + await trigger.click() + await page.getByRole('menu').waitFor() + await expectSilent(trigger) + await page.keyboard.press('Shift') + await expectSilent(trigger) + await page.keyboard.press('Escape') + await page.getByRole('menu').waitFor({ state: 'hidden' }) + await expectSilent(trigger) + + for (const key of ['Home', 'End']) { + await trigger.click() + await page.keyboard.press('Escape') + await expectSilent(trigger) + await page.keyboard.press(key) + await expectBrand(trigger, 'shadow') + } + + await trigger.click() + await page.keyboard.press('Escape') + await page.keyboard.press('Shift+Tab') + expect(await trigger.evaluate(element => element === document.activeElement)).toBe(false) + await page.keyboard.press('Tab') + await expectBrand(trigger, 'shadow') + for (const key of ['Enter', 'Space']) { + await page.keyboard.press(key) + await page.getByRole('menu').waitFor() + await expectBrand(trigger, 'shadow') + const menu = page.getByRole('menu') + for (const [navigation, index] of [['End', -1], ['Home', 0]] as const) { + await page.keyboard.press(navigation) + const row = menu.getByRole('menuitem').nth(index) + const state = await paint(row) + expect(state.active).toBe(true) + expect(state.focusVisible).toBe(true) + expect(state.modality).toBe('keyboard') + // Menu rows intentionally use the hover fill instead of a second outline. + expect(state.background).toBe(state.hover) + expect(state.background).not.toBe('rgba(0, 0, 0, 0)') + } + await page.keyboard.press('Escape') + await expectBrand(trigger, 'shadow') + } + expect(consoleWatch.warnings).toEqual([]) + expect(consoleWatch.pageErrors).toEqual([]) +}) + +/** + * Supplementary fixture, not an assembled app: Vite compiles the actual Menu, + * Switch, input-modality module and CSS Modules in memory. Only arrangement and + * state ownership belong to the fixture; no test writes the modality attribute. + */ +async function compileFixture(): Promise<{ script: string; css: string }> { + const source = (relative: string): string => JSON.stringify(join(REPO_ROOT, 'packages/client', relative).replaceAll('\\', '/')) + const entry = join(REPO_ROOT, 'apps/web/focus-rings-fixture.tsx').replaceAll('\\', '/') + const code = ` + import React, { useState } from 'react' + import { createRoot } from 'react-dom/client' + import { Menu } from ${source('ui-primitives/src/Menu.tsx')} + import { Switch } from ${source('ui-primitives/src/Switch.tsx')} + import ${source('ui-primitives/src/input-modality.ts')} + import ${source('ui-theme/src/styles/base.css')} + import ${source('ui-theme/src/styles/design-platform.css')} + import ${source('ui-theme/src/styles/focus.css')} + import ${source('ui-theme/src/styles/gradient-shadow-text.css')} + import triggerCss from ${source('ui-permission-presets/src/client/PermissionSelect.module.css')} + import feedbackCss from ${source('ui-message-feedback/src/client/FeedbackDialog.module.css')} + import workflowCss from ${source('ui-workflow-run/src/client/WorkflowRunPanel.module.css')} + import cardCss from ${source('ui-primitives/src/HoverCard.module.css')} + import pillCss from ${source('ui-primitives/src/Pill.module.css')} + import trajectoryCss from ${source('ui-trajectory/src/client/TrajectoryTable.module.css')} + function Fixture() { + const [checked, setChecked] = useState(false) + const [open, setOpen] = useState(false) + return
+ + + setOpen(false)} + anchor={} + items={[{ id: 'first', label: 'First item' }, { id: 'last', label: 'Last item' }]} /> +