fix(client): keep pointer focus silent through descendant and editable rings

Pointer suppression previously cleared `outline-color` on the focused element
alone, so a ring painted by a descendant, a pseudo-element, or a `box-shadow`
stayed visible, and editable text controls lost the focus feedback they need on
click.

Ring colour now flows through `--dsw-focus-ring-color`, which component outlines
and focus-ring shadows read alongside the brand token. Every ring that inherits
the variable is suppressed together, and `body :focus-visible:not(:read-write)`
limits the suppression to rings while editable controls keep their own feedback.

Keyboard modality returns on Tab, Home, End, PageUp, or PageDown, and on focus
reaching a different control after a non-composing key. IME composition keys,
refocusing the same control, and window blur leave rings silent. The desktop
preload menubar suppresses its own ring inside its shadow root, where the
document-level attribute does not reach.
This commit is contained in:
mektpoy committed 2026-09-22 20:28:10 +08:00
1 parent 65ba30ab4f
commit bca9cecc7d
50 files changed
+669 -141

No files matched your search

@@ -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
@@ -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.
@@ -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)覆盖样式声明和模态转换。静态声明与合成事件不能确认焦点环的实际渲染可见性或跨平台行为。
+2 -1
View File
@@ -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')
+357
View File
@@ -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<void> {
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<void> {
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 <main style={{ padding: 40, display: 'grid', gap: 24, width: 420 }}>
<Switch label="Toggle" checked={checked} onChange={setChecked} />
<button id="default-outline">Default browser outline</button>
<Menu open={open} autoFocus onClose={() => setOpen(false)}
anchor={<button className={triggerCss.trigger} onClick={() => setOpen(!open)}>Open menu</button>}
items={[{ id: 'first', label: 'First item' }, { id: 'last', label: 'Last item' }]} />
<textarea aria-label="Feedback" className={feedbackCss.detail} />
<button aria-label="Workflow member" className={workflowCss.memberButton}>
<span data-member-ring className={workflowCss.memberLabelWrap}>
<span className={workflowCss.memberLabel}>Member</span>
</span>
</button>
<button aria-label="Elevated card" className={cardCss.card + ' ' + cardCss.copyable}
style={{ position: 'relative' }}>Card</button>
<button aria-label="Selected pill" aria-pressed="true" className={pillCss.pill + ' ' + pillCss.active}>Selected</button>
<table className={trajectoryCss.table} data-scroll-ready="true"><tbody>
<tr aria-label="Trajectory row" tabIndex={0}><td>Row</td></tr>
</tbody></table>
<button aria-label="Selected request" aria-pressed="true"
className={trajectoryCss.requestBoundaryControl + ' ' + trajectoryCss.requestBoundaryControlActive}
style={{ position: 'relative', width: 40, height: 30 }} />
</main>
}
createRoot(document.getElementById('root')).render(<Fixture />)
`
const result = await build({
configFile: false,
root: join(REPO_ROOT, 'apps/web'),
logLevel: 'error',
plugins: [{
name: 'focus-rings-fixture',
resolveId(id) { if (id === entry) return entry },
load(id) { if (id === entry) return code },
}],
esbuild: { jsx: 'automatic', jsxDev: false },
define: { 'process.env.NODE_ENV': JSON.stringify('production') },
build: {
write: false,
minify: false,
lib: { entry, name: 'FocusRingsFixture', formats: ['iife'] },
},
})
if ('on' in result) throw new Error('expected an in-memory build, not a watcher')
const outputs = (Array.isArray(result) ? result : [result]).flatMap(bundle => bundle.output)
return {
script: outputs.flatMap(output => output.type === 'chunk' ? [output.code] : []).join('\n'),
css: outputs.flatMap((output) => {
if (output.type !== 'asset' || !output.fileName.endsWith('.css')) return []
return [typeof output.source === 'string' ? output.source : new TextDecoder().decode(output.source)]
}).join('\n'),
}
}
describe('source-compiled supplementary focus paint', () => {
let fixture: Awaited<ReturnType<typeof compileFixture>>
beforeAll(async () => { fixture = await compileFixture() })
async function openFixture(): Promise<Page> {
const browser = await chromium.launch({ headless: true })
onTestFinished(() => browser.close())
const page = await newEnglishPage(browser)
const consoleWatch = watchConsole(page)
onTestFinished(() => { expect(consoleWatch.pageErrors).toEqual([]) })
await page.emulateMedia({ reducedMotion: 'reduce' })
await page.setContent('<!doctype html><html><body><div id="root"></div></body></html>')
await page.addStyleTag({ content: fixture.css })
await page.addScriptTag({ content: fixture.script })
expect(consoleWatch.pageErrors).toEqual([])
await page.getByRole('switch', { name: 'Toggle' }).waitFor()
return page
}
it('keeps Shift/Escape silent while Tab, Home, End and paging reveal a brand outline', async () => {
const page = await openFixture()
const toggle = page.getByRole('switch', { name: 'Toggle' })
await toggle.click()
for (const key of ['Shift', 'Escape']) {
await page.keyboard.press(key)
await expectSilent(toggle)
}
expect((await paint(toggle)).focusVisible).toBe(true)
for (const key of ['Home', 'End', 'PageUp', 'PageDown']) {
await toggle.click()
await page.keyboard.press(key)
await expectBrand(toggle, 'outline')
}
await toggle.click()
await page.keyboard.press('Tab')
const fallback = page.locator('#default-outline')
const state = await paint(fallback)
expect(state.active).toBe(true)
expect(state.focusVisible).toBe(true)
expect(state.outline).toBe(state.brand)
expect(Number.parseFloat(state.outlineWidth)).toBeGreaterThan(0)
await page.keyboard.press('Shift+Tab')
await expectBrand(toggle, 'outline')
})
it.each(['Enter', 'Space'])('%s opens the real autoFocus Menu after a pointer-owned trigger', async (key) => {
const page = await openFixture()
const trigger = page.getByRole('button', { name: 'Open menu' })
await trigger.click()
await page.getByRole('menuitem', { name: 'First item' }).waitFor()
await trigger.click()
await page.getByRole('menu').waitFor({ state: 'hidden' })
await expectSilent(trigger)
await page.keyboard.press(key)
const first = page.getByRole('menuitem', { name: 'First item' })
await expect.poll(async () => (await paint(first)).active).toBe(true)
const state = await paint(first)
expect(state.modality).toBe('keyboard')
expect(state.focusVisible).toBe(true)
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')
})
it('negative control: blanket shadow suppression destroys input and card paint and misses descendants', async () => {
const page = await openFixture()
const input = page.getByRole('textbox', { name: 'Feedback' })
const card = page.getByRole('button', { name: 'Elevated card' })
const elevation = (await paint(card)).shadow
await input.click()
const focusedInput = await paint(input)
expect(focusedInput.active).toBe(true)
expect(focusedInput.shadow).toContain(focusedInput.brand)
// Restore direct-brand fallback and the rejected global eraser only in this page.
await page.addStyleTag({ content: `
html[data-input-modality='pointer'] body :focus-visible {
--dsw-focus-ring-color: initial !important;
outline-color: transparent !important;
box-shadow: none !important;
}
` })
expect((await paint(input)).shadow).toBe('none')
await card.click()
await page.keyboard.press('Shift')
expect((await paint(card)).active).toBe(true)
expect((await paint(card)).shadow).toBe('none')
expect((await paint(card)).shadow).not.toBe(elevation)
const member = page.getByRole('button', { name: 'Workflow member' })
await member.click()
await page.keyboard.press('Shift')
expect((await paint(member)).active).toBe(true)
const descendant = await paint(member.locator('[data-member-ring]'))
expect(descendant.outlineStyle).toBe('solid')
expect(descendant.outline).toBe(descendant.brand)
})
it('silences high-specificity table rings without hiding selected-state shadows', async () => {
const page = await openFixture()
const row = page.getByRole('row', { name: 'Trajectory row' })
await row.click()
await page.keyboard.press('Shift')
await expectSilent(row)
expect((await paint(row)).focusVisible).toBe(true)
await page.keyboard.press('Home')
await expectBrand(row, 'shadow')
for (const [name, pseudo] of [['Selected pill', null], ['Selected request', '::before']] as const) {
const selected = page.getByRole('button', { name })
const shadow = (): Promise<string> => selected.evaluate((element, pseudo) => getComputedStyle(element, pseudo).boxShadow, pseudo)
await selected.click()
// The request marker animates its border; sample after its transition settles.
await selected.evaluate(async (element) => {
await Promise.all(element.getAnimations({ subtree: true }).map(animation => animation.finished))
})
const before = await shadow()
expect(before).not.toBe('none')
expect(before).not.toContain('rgba(0, 0, 0, 0)')
await page.keyboard.press('Shift')
expect(await selected.getAttribute('aria-pressed')).toBe('true')
expect((await paint(selected)).active).toBe(true)
await expect.poll(shadow).toBe(before)
}
})
it('preserves clicked text-input paint and card elevation while suppressing descendant rings', async () => {
const page = await openFixture()
const input = page.getByRole('textbox', { name: 'Feedback' })
await input.click()
await page.keyboard.type('Still focused')
await expect.poll(async () => {
const state = await paint(input)
return { active: state.active, visible: state.focusVisible, modality: state.modality, ring: state.shadow.includes(state.brand) }
}).toEqual({ active: true, visible: true, modality: 'pointer', ring: true })
const member = page.getByRole('button', { name: 'Workflow member' })
const ring = member.locator('[data-member-ring]')
await member.click()
await page.keyboard.press('Shift')
expect((await paint(member)).active).toBe(true)
expect((await paint(member)).focusVisible).toBe(true)
expect((await paint(ring)).outline).toBe('rgba(0, 0, 0, 0)')
await page.keyboard.press('Home')
const descendant = await paint(ring)
expect((await paint(member)).active).toBe(true)
expect(descendant.outline).toBe(descendant.brand)
expect(descendant.outlineStyle).toBe('solid')
expect(Number.parseFloat(descendant.outlineWidth)).toBeGreaterThan(0)
const card = page.getByRole('button', { name: 'Elevated card' })
const elevation = (await paint(card)).shadow
expect(elevation).not.toBe('none')
await card.click()
await page.keyboard.press('Shift')
await expectSilent(card)
expect((await paint(card)).focusVisible).toBe(true)
expect((await paint(card)).shadow).toBe(elevation)
await page.keyboard.press('Home')
await expectBrand(card, 'outline')
expect((await paint(card)).shadow).toBe(elevation)
})
})
+1
View File
@@ -41,6 +41,7 @@
"tests/plan-review.e2e.ts",
"tests/steering.e2e.ts",
"tests/navigation-panes.e2e.ts",
"tests/focus-rings.e2e.ts",
"tests/chat-scroll-fixture.ts",
"tests/trajectory-virtualization.e2e.ts",
"tests/lifecycle-chrome.e2e.ts",
@@ -184,7 +184,7 @@
}
.cardMain:focus-visible {
outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary);
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
outline-offset: -2px;
}
@@ -224,7 +224,7 @@
}
.cardMain:focus-visible {
outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary);
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
outline-offset: -2px;
}
@@ -311,7 +311,7 @@
}
.iconButton:focus-visible {
outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary);
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
outline-offset: -1px;
}
@@ -102,6 +102,6 @@
.frame:focus-visible,
.error:focus-visible {
outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary);
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
outline-offset: 2px;
}
@@ -231,7 +231,7 @@
}
.retrySummary:focus-visible {
outline: 1.5px solid var(--dsw-alias-brand-primary);
outline: 1.5px solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
outline-offset: 2px;
}
@@ -139,7 +139,7 @@
inset: 0 0 0 auto;
width: 20px;
border-radius: inherit;
outline: 1px solid var(--dsw-alias-brand-primary);
outline: 1px solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
/* The scroller clips the right edge; keep the marker's ring inside it. */
outline-offset: -1px;
content: '';
@@ -77,7 +77,7 @@
}
.header:focus-visible {
outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary);
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
outline-offset: -2px;
}
@@ -258,7 +258,7 @@
}
.action:focus-visible {
outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary);
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
outline-offset: -2px;
}
@@ -4,7 +4,7 @@
.header { display: flex; align-items: center; gap: 10px; box-sizing: border-box; width: 100%; min-width: 0; height: 60px; margin: 0; padding: 8px 10px; border: 0; background: var(--changes-fill); color: inherit; font: inherit; text-align: left; }
button.header { cursor: pointer; transition: background-color 120ms ease; }
button.header:hover:not(:disabled), button.header:focus-visible { background: var(--changes-hover); }
button.header:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--dsw-alias-brand-primary); }
button.header:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary)); }
button.header:disabled { cursor: progress; }
.tile { display: grid; flex: none; place-items: center; box-sizing: border-box; width: 40px; height: 40px; border: 0.5px solid var(--dsw-alias-border-l2); border-radius: 10px; background: var(--changes-fill); }
.tileMark { display: grid; place-items: center; width: 20px; height: 20px; border-radius: 8px; background: var(--dsw-alias-link); color: var(--dsw-static-neutral-00); }
@@ -21,7 +21,7 @@ button.header:disabled { cursor: progress; }
.list { margin: 0; padding: 0; list-style: none; border-top: 0.5px solid var(--dsw-alias-border-l2); }
.row { display: flex; align-items: center; justify-content: space-between; gap: 10px; box-sizing: border-box; width: 100%; min-width: 0; min-height: 24px; margin: 0; padding: 7px 18px 7px 14px; border: 0; background: transparent; color: var(--dsw-alias-label-tertiary); cursor: pointer; font-family: var(--ds-font-family-code); font-size: 11px; line-height: 18px; text-align: left; }
.row:hover:not(:disabled), .row:focus-visible { background: var(--dsw-alias-interactive-bg-hover); }
.row:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--dsw-alias-brand-primary); }
.row:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary)); }
.row:disabled { cursor: progress; }
.path { min-width: 0; overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.counts { display: inline-flex; flex: none; gap: 6px; white-space: nowrap; color: var(--dsw-alias-label-tertiary); }
@@ -10,7 +10,7 @@
.file { position: relative; box-sizing: border-box; display: flex; align-items: center; gap: 10px; min-width: 0; height: 60px; padding: 8px 10px; overflow: hidden; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 18px; background: var(--deliverable-fill); color: var(--dsw-alias-label-primary); transition: background-color 120ms ease; }
.file:hover { background: var(--deliverable-hover); }
.cardPreview { position: absolute; z-index: 1; inset: 0; width: 100%; padding: 0; border: 0; border-radius: inherit; background: transparent; cursor: pointer; }
.cardPreview:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--dsw-alias-brand-primary); }
.cardPreview:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary)); }
.fileIcon { position: relative; z-index: 2; box-sizing: border-box; flex: 0 0 auto; display: grid; place-items: center; width: 40px; height: 40px; overflow: hidden; pointer-events: none; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 10px; background: var(--deliverable-fill); color: var(--dsw-alias-link); }
.fileBody { position: relative; z-index: 2; display: flex; flex: 1; align-items: center; justify-content: space-between; gap: 12px; min-width: 0; pointer-events: none; }
.details { display: flex; flex: 1; flex-direction: column; justify-content: center; gap: 2px; min-width: 0; }
@@ -123,7 +123,7 @@
.crumb:focus-visible {
/* The trail scrolls, so an outer ring would be clipped; keep it inside. */
outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary);
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
outline-offset: -2px;
}
@@ -247,7 +247,7 @@
.row:focus-visible {
/* The column scrolls and its left edge carries no padding; keep the ring inside. */
outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary);
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
outline-offset: -2px;
}
@@ -39,7 +39,7 @@
}
.chip:focus-visible {
outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary);
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
outline-offset: 2px;
}
@@ -34,7 +34,7 @@
}
.trigger:focus-visible {
box-shadow: 0 0 0 2px var(--dsw-alias-brand-primary);
box-shadow: 0 0 0 2px var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
}
.trigger:disabled {
@@ -23,7 +23,7 @@
}
.trigger:focus-visible {
box-shadow: 0 0 0 2px var(--dsw-alias-brand-primary);
box-shadow: 0 0 0 2px var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
}
.trigger:disabled {
@@ -34,7 +34,7 @@
}
.chip:focus-visible {
outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary);
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
outline-offset: 2px;
}
@@ -22,7 +22,7 @@
}
:global(body[data-ds-dark-theme]) .card { --plan-card-fill: var(--dsw-static-neutral-850); --plan-card-hover: var(--dsw-static-neutral-800); }
.card:hover { background: var(--plan-card-hover); }
.card:focus-visible { outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 3px; }
.card:focus-visible { outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary)); outline-offset: 3px; }
.cardIcon { box-sizing: border-box; flex: none; display: grid; place-items: center; width: 40px; height: 40px; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 10px; background: var(--plan-card-fill); }
.cardDetails { display: flex; flex: 1; flex-direction: column; gap: 2px; min-width: 0; }
.cardTitle { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 500; line-height: 20px; }
@@ -30,7 +30,7 @@
.cardOpen { box-sizing: border-box; display: inline-flex; flex: none; align-items: center; height: 28px; padding: 4px 8px; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 10px; background: var(--dsw-alias-button-floating-fill); font-size: 12px; line-height: 18px; }
.reviewLink { display: inline-flex; align-items: center; gap: 4px; padding: 0; border: 0; background: transparent; color: var(--dsw-alias-label-secondary); font: inherit; cursor: pointer; }
.reviewLink:hover { color: var(--dsw-alias-label-primary); }
.reviewLink:focus-visible { outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 3px; }
.reviewLink:focus-visible { outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary)); outline-offset: 3px; }
.preview { position: relative; height: 100%; overflow: auto; padding: 20px 24px 40px; box-sizing: border-box; }
.document { color: var(--dsw-alias-label-primary); font-size: 14px; line-height: 1.75; overflow-wrap: anywhere; }
.message { padding: 24px; color: var(--dsw-alias-label-secondary); font-size: 14px; }
@@ -154,7 +154,7 @@
}
.iconButton:focus-visible {
outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary);
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
outline-offset: 1px;
}
@@ -267,7 +267,7 @@
}
.cardOpen:focus-visible::after {
outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary);
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
outline-offset: 2px;
}
@@ -373,7 +373,7 @@
}
.deleteButton:focus-visible {
outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary);
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
outline-offset: 1px;
}
@@ -397,7 +397,7 @@
}
.partsFilter:focus-visible {
outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary);
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
outline-offset: 1px;
}
@@ -1067,7 +1067,7 @@ label.registryOption {
}
.crumb:focus-visible {
outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary);
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
outline-offset: 2px;
}
@@ -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-primitives/README.md
README.md: 51ad53a79a676f013b61213764479a1247de522c
README.zh.md: c41d19af1855c8ab1582fa92b956ef283d3466cf
README.md: 964f82862bfb422d59410569c185296ec90c366e
README.zh.md: 73f8366a7fdf4fc8c1c9e423812df76c869617e9
+6
View File
@@ -110,9 +110,15 @@ The package enforces one separation: presentational React atoms with zero Cordis
| [`src/SearchBlock.tsx`](src/SearchBlock.tsx) / [`src/WebBlock.tsx`](src/WebBlock.tsx) | Search and web-retrieval cards |
| [`src/icons/`](src/icons/) | Size-neutral `Regular` and `Medium` product glyph components |
| [`src/code-highlighting.ts`](src/code-highlighting.ts) | Shared filename grammar selection and lazy line highlighting |
| [`src/input-modality.ts`](src/input-modality.ts) | Document-wide input modality published on `<html>` |
| [`src/plugin-artwork.tsx`](src/plugin-artwork.tsx) | Fixed-palette plugin artwork with per-instance SVG def ids |
| [`src/useAnchoredPosition.ts`](src/useAnchoredPosition.ts) / [`src/useAnchoredMaxHeight.ts`](src/useAnchoredMaxHeight.ts) | Floating-panel and overlay geometry hooks |
<a id="input-modality"></a>
### Input modality
[`input-modality.ts`](src/input-modality.ts) tracks input for tooltips and publishes `data-input-modality` on `<html>` for the [theme's focus styles](../ui-theme/README.md#understand-the-implementation). `pointerModality()` is true after pointer input and false after any key, including IME composition keys; `Tooltip` uses it to decide whether focus may show a bubble. The published attribute stays `pointer` until a non-composing navigation key (Tab, arrows, Home/End, PageUp/PageDown), or focus on a different control after a non-composing key. Refocusing the same control does not restore keyboard modality. Pointer input, an IME composition key, and window blur each clear the pending key; focus changes without a pending key leave the modality unchanged. The listeners live for the document lifetime; Node imports install none. The focus-change rule observes events exposed to window; components own additional navigation inside shadow roots that do not expose those events.
### Streaming markdown
While a reply streams, `MarkdownText` parses incrementally: all but the trailing two blocks freeze as cached React elements and only the source tail re-parses per chunk, so per-chunk work tracks the tail instead of the whole reply. A final unclosed top-level fence keeps its parsed code node and sends only the last completed line plus the current partial line through the same GFM grammar; a closing fence or ambiguous parse returns to the ordinary tail path. Highlighting likewise resumes from saved Shiki grammar state and publishes only newly completed lines plus the mutable tail. `CodeBlock` seals completed lines into fixed-size React groups, reuses earlier groups, and retains the whole highlighted tree across settlement when code and language are unchanged. The settled full parse still resolves references that crossed the freeze boundary.
@@ -110,9 +110,15 @@ kind: "package-library"
| [`src/SearchBlock.tsx`](src/SearchBlock.tsx) / [`src/WebBlock.tsx`](src/WebBlock.tsx) | 搜索与网页检索卡片 |
| [`src/icons/`](src/icons/) | 与尺寸无关的 `Regular` 和 `Medium` 产品图标组件 |
| [`src/code-highlighting.ts`](src/code-highlighting.ts) | 共享的文件名 grammar 选择与惰性逐行高亮 |
| [`src/input-modality.ts`](src/input-modality.ts) | 全文档输入模态,发布到 `<html>` |
| [`src/plugin-artwork.tsx`](src/plugin-artwork.tsx) | 固定配色插件插画,SVG def id 按实例生成 |
| [`src/useAnchoredPosition.ts`](src/useAnchoredPosition.ts) / [`src/useAnchoredMaxHeight.ts`](src/useAnchoredMaxHeight.ts) | 浮动面板与浮层几何钩子 |
<a id="input-modality"></a>
### 输入模态
[`input-modality.ts`](src/input-modality.ts) 为 tooltip 跟踪输入,并在 `<html>` 上发布 `data-input-modality`,供[主题焦点样式](../ui-theme/README.zh.md#understand-the-implementation)使用。`pointerModality()` 在指针输入后为 true,在任意按键后为 false,包括 IME(输入法)组合输入按键;`Tooltip` 据此决定聚焦时是否可以显示气泡。发布的属性保持 `pointer`,直到非组合输入的导航键(Tab、方向键、Home/End、PageUp/PageDown)到达,或非组合输入按键之后焦点移到不同控件。重新聚焦同一控件不会恢复键盘模态。指针输入、IME 组合输入按键和 window blur 都会清除待处理按键;没有待处理按键的焦点变化不改变模态。监听器与文档同寿命;从 Node 导入时不安装监听器。 焦点变化规则只观察传到 window 的事件;shadow root 内部不向外暴露这些事件的额外导航由组件负责。
### 流式 Markdown
回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复。末尾的顶层未闭合 fence 会保留已解析的 code node,只把最后一个已完成行与当前未完成行交给同一套 GFM grammar;闭合 fence 或有歧义的解析会回到普通尾部路径。高亮同样从保存的 Shiki grammar state 续接,并只发布新完成行与可变尾部。`CodeBlock` 把已完成行封入固定大小的 React 分组、复用更早的分组,并在代码与语言未变化时跨定稿保留整棵高亮树。定稿时的全量解析仍会解析跨过冻结边界的引用。
@@ -18,7 +18,7 @@
}
.checkbox input:focus-visible {
outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary);
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
outline-offset: 2px;
}
@@ -56,7 +56,7 @@
}
.warning:focus-visible {
outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary);
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
outline-offset: 2px;
}
@@ -27,7 +27,7 @@
}
.copyable:focus-visible {
outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary);
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
outline-offset: 2px;
}
@@ -187,7 +187,7 @@
}
.stringToggle:focus-visible {
outline: 1px solid var(--dsw-alias-brand-primary);
outline: 1px solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
outline-offset: 1px;
}
@@ -286,7 +286,7 @@
}
.actionButton:focus-visible {
outline: 1px solid var(--dsw-alias-brand-primary);
outline: 1px solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
outline-offset: -1px;
}
@@ -56,7 +56,7 @@
}
.acknowledgement input:focus-visible {
outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary);
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
outline-offset: 2px;
}
@@ -31,7 +31,7 @@
}
.switch:focus-visible {
outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary);
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
outline-offset: 2px;
}
@@ -1,16 +1,8 @@
/**
* Input modality for the whole document, under the two rules its consumers need.
*
* A tooltip asks what the last input was: after any key the user is working from
* the keyboard, so a programmatic focus may raise a bubble. A focus ring asks
* something narrower — a pointer's focus must stay invisible — because Chromium
* reveals an already-focused control as soon as any key arrives, including a
* modifier or Escape that never navigated anywhere. Only focus navigation
* returns that second answer to the keyboard.
*
* Both are recorded from capture-phase window listeners. The guard keeps the
* module loadable where no window exists (node-side imports of the package's
* pure helpers).
* Document-wide input tracking shared by tooltips and focus-ring styles.
* Tooltips follow the last input; rings follow navigation or a key followed by
* focus on a different control. Modifiers and refocusing alone keep rings silent.
* Module-level listeners live for the document lifetime; Node imports are inert.
*/
/** Modality values published on the document element. */
@@ -19,19 +11,20 @@ export const INPUT_MODALITY = { pointer: 'pointer', keyboard: 'keyboard' } as co
/** Attribute carrying whether focus navigation last owned focus. */
export const INPUT_MODALITY_ATTRIBUTE = 'data-input-modality'
const FOCUS_NAVIGATION = new Set(['Tab', 'Home', 'End', 'PageUp', 'PageDown'])
let pointer = false
let pointerOwnsFocus = false
/** Navigation keys move focus; every other key must not reveal existing focus. */
function isFocusNavigation(event: KeyboardEvent): boolean {
return event.key === 'Tab' || event.key.startsWith('Arrow')
}
let keyFocusOrigin: EventTarget | undefined
function publish(): void {
document.documentElement.setAttribute(INPUT_MODALITY_ATTRIBUTE, pointerOwnsFocus ? INPUT_MODALITY.pointer : INPUT_MODALITY.keyboard)
}
/** Whether the last input able to own focus came from a pointer. */
/**
* Whether the last input came from a pointer, independently of ring visibility.
* @returns True after pointer input; false after any key.
*/
export function pointerModality(): boolean {
return pointer
}
@@ -40,13 +33,27 @@ if (typeof window !== 'undefined') {
window.addEventListener('pointerdown', () => {
pointer = true
pointerOwnsFocus = true
keyFocusOrigin = undefined
publish()
}, true)
window.addEventListener('keydown', (event) => {
// Any key returns tooltips to the keyboard; only navigation reveals a ring.
pointer = false
if (!isFocusNavigation(event)) return
if (event.isComposing) {
keyFocusOrigin = undefined
return
}
// composedPath preserves the real control inside an open shadow root.
keyFocusOrigin = event.composedPath()[0]
if (!FOCUS_NAVIGATION.has(event.key) && !event.key.startsWith('Arrow')) return
pointerOwnsFocus = false
publish()
}, true)
window.addEventListener('focusin', (event) => {
if (keyFocusOrigin === undefined || event.composedPath()[0] === keyFocusOrigin) return
keyFocusOrigin = undefined
if (!pointerOwnsFocus) return
pointerOwnsFocus = false
publish()
}, true)
window.addEventListener('blur', () => { keyFocusOrigin = undefined })
}
@@ -83,7 +83,7 @@
.markdown a:focus-visible,
.fileMention:focus-visible {
box-shadow: 0 0 0 2px var(--dsw-alias-brand-primary);
box-shadow: 0 0 0 2px var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
}
/* Leading link-category glyph inside anchors, file mentions, and
@@ -220,7 +220,7 @@
the sheet's link focus color. */
.tableScroll:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--dsw-alias-brand-primary);
box-shadow: 0 0 0 2px var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
}
/* Many-column tables (the renderer's `md-table-wide` hook) keep their
@@ -1,18 +1,30 @@
// @vitest-environment jsdom
/** The two modality answers: what a tooltip reads, and what a focus ring reads. */
import { describe, expect, it } from 'vitest'
/** Keyboard navigation and pointer focus use distinct tooltip and ring policies. */
import { afterEach, describe, expect, it } from 'vitest'
import { INPUT_MODALITY, INPUT_MODALITY_ATTRIBUTE, pointerModality } from '../src/input-modality.ts'
const press = (key: string): void => { window.dispatchEvent(new KeyboardEvent('keydown', { key })) }
const press = (key: string, init: KeyboardEventInit = {}): void => {
document.activeElement?.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, composed: true, ...init }))
}
const click = (): void => { window.dispatchEvent(new Event('pointerdown')) }
const published = (): string | null => document.documentElement.getAttribute(INPUT_MODALITY_ATTRIBUTE)
const button = (): HTMLButtonElement => {
const element = document.createElement('button')
document.body.append(element)
return element
}
afterEach(() => {
document.body.replaceChildren()
press('Tab')
document.documentElement.removeAttribute(INPUT_MODALITY_ATTRIBUTE)
})
describe('input modality', () => {
it('returns tooltips to the keyboard on any key', () => {
click()
expect(pointerModality()).toBe(true)
for (const key of ['Shift', 'Escape', 'a', 'Enter']) {
for (const key of ['Shift', 'Escape', 'a', 'Enter', 'Tab']) {
click()
expect(pointerModality()).toBe(true)
press(key)
expect(pointerModality()).toBe(false)
}
@@ -20,19 +32,68 @@ describe('input modality', () => {
it('keeps a pointer focused control silent through keys that do not navigate', () => {
click()
expect(published()).toBe(INPUT_MODALITY.pointer)
for (const key of ['Shift', 'Escape', 'a', 'Control', 'Meta', 'Enter', ' ']) press(key)
expect(published()).toBe(INPUT_MODALITY.pointer)
})
it('returns the ring to the keyboard on focus navigation', () => {
click()
press('Tab')
expect(published()).toBe(INPUT_MODALITY.keyboard)
it('returns the ring to the keyboard on focus-navigation keys', () => {
for (const key of ['Tab', 'Home', 'End', 'PageUp', 'PageDown', 'ArrowDown', 'ArrowLeft']) {
click()
press(key)
expect(published()).toBe(INPUT_MODALITY.keyboard)
}
})
it.each(['Enter', ' ', 'Escape'])('returns the ring when %s moves focus to another control', (key) => {
const anchor = button()
const destination = button()
click()
press('ArrowDown')
anchor.focus()
press(key)
expect(published()).toBe(INPUT_MODALITY.pointer)
destination.focus()
expect(document.activeElement).toBe(destination)
expect(published()).toBe(INPUT_MODALITY.keyboard)
})
it('keeps keyboard modality when a navigation key moves focus', () => {
click()
button().focus()
press('Tab')
button().focus()
expect(published()).toBe(INPUT_MODALITY.keyboard)
})
it('keeps pointer focus silent when no key precedes the focus change', () => {
click()
button().focus()
expect(published()).toBe(INPUT_MODALITY.pointer)
})
it('does not treat refocusing the same control after Shift as navigation', () => {
const target = button()
click()
target.focus()
press('Shift')
target.blur()
target.focus()
expect(published()).toBe(INPUT_MODALITY.pointer)
})
it('does not carry a pending key across a window blur', () => {
click()
button().focus()
press('Meta')
window.dispatchEvent(new Event('blur'))
button().focus()
expect(published()).toBe(INPUT_MODALITY.pointer)
})
it('does not interpret input-method navigation as focus navigation', () => {
click()
press('ArrowDown', { isComposing: true })
expect(published()).toBe(INPUT_MODALITY.pointer)
button().focus()
expect(published()).toBe(INPUT_MODALITY.pointer)
})
})
@@ -216,7 +216,7 @@
.iconButton:focus-visible,
.customizedSummary:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--dsw-alias-brand-primary);
box-shadow: 0 0 0 2px var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
}
/* Editing surface: a filled module on the panel, matching the settings
@@ -82,7 +82,7 @@
.search input:focus-visible {
border-color: var(--dsw-alias-state-business-primary);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary) 18%, transparent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary)) 18%, transparent);
}
.catalogHeading {
@@ -151,7 +151,7 @@
}
.cardContent:focus-visible {
outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary);
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
outline-offset: -2px;
}
@@ -239,7 +239,7 @@
}
.groupToggle:focus-visible {
outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary);
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
outline-offset: 2px;
}
@@ -297,7 +297,7 @@
}
.switcher:focus-visible {
outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary);
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
outline-offset: 2px;
}
@@ -48,6 +48,6 @@
}
.save:focus-visible {
outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary);
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
outline-offset: 1px;
}
@@ -58,7 +58,7 @@
}
.tab:focus-visible {
outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary);
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
outline-offset: 2px;
border-radius: 2px;
color: var(--dsw-alias-label-primary);
@@ -60,7 +60,7 @@
}
.helpButton:focus-visible {
outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary);
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
outline-offset: 1px;
}
@@ -493,7 +493,7 @@
}
.panelRow:focus-visible {
outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary);
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
outline-offset: -2px;
}
+2 -2
View File
@@ -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-theme/README.md
README.md: bc8f0402eadbe30971db265f330ceb6af19095ce
README.zh.md: dc3a3672fee40903c9c6a4de4922db6988187de1
README.md: 21fdfd9b2064f15a2aca2f1b9e05cbaa55ed71ed
README.zh.md: 9972d360d1f046c4f0f6d48c4e008e6ef9c38afc
+5 -1
View File
@@ -51,7 +51,11 @@ The service owns theme and font-size state and publishes snapshots. The ui-layou
### Stylesheets
`src/styles/` holds six sheets imported in order by ui-theme's dynamic client entry: `base.css`, `corner-shape.css`, `design-platform.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. The client bundle compiles and injects them as plugin-owned global styles, so unload and HMR remove them with ui-theme. `scrollbar.css` consumes the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them. Status marks use their own semantic state tokens.
`src/styles/` holds seven sheets imported in order by ui-theme's dynamic client entry: `base.css`, `corner-shape.css`, `design-platform.css`, `focus.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. The client bundle compiles and injects them as plugin-owned global styles, so unload and HMR remove them with ui-theme. `scrollbar.css` consumes the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them. Status marks use their own semantic state tokens.
[`focus.css`](src/styles/focus.css) provides a `:focus-visible` outline-colour fallback through `var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary))` without changing outline geometry. Component outlines and focus-ring shadows use the same colour expression, including rings on descendants and pseudo-elements. `--dsw-focus-ring-width` (2px) is the standard width; dense tables and toolbars may keep 1px, and offsets remain component-owned.
In pointer modality, `html[data-input-modality='pointer'] body :focus-visible:not(:read-write)` makes the ring colour transparent. Descendants and pseudo-elements inherit that value; the rule does not clear `box-shadow`, so elevation and selected-state borders remain independent of ring visibility. Editable text controls matching `:read-write` retain their own focus feedback on click. [Input modality](../ui-primitives/README.md#input-modality) determines when keyboard focus styling resumes; it does not move DOM focus.
`corner-shape.css` smooths every rounded corner: inside `@supports (corner-shape: superellipse(1.5))` it defines `--dsw-corner-shape` and applies it to all elements and their `::before`/`::after` through the universal selector, so engines without `corner-shape` keep circular corners. Full-round shapes — `border-radius: 50%` circles and pill radii — pair `corner-shape: round` with their radius in the owning component sheet because a superellipse deforms them; the corner-shape stylesheet spec enforces that pairing across every package stylesheet.
+5 -1
View File
@@ -51,7 +51,11 @@ kind: "package-reference"
### 样式表
`src/styles/` 下有六张样式表,由 ui-theme 的动态客户端 entry 依次导入:`base.css`、`corner-shape.css`、`design-platform.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。客户端 bundle 将其编译并注入为插件持有的全局样式,因此卸载与 HMR(热模块替换)会随 ui-theme 一同移除。`scrollbar.css` 消费 `--dsw-alias-scrollbar-*` token,必须排在声明这些 token 的 `design-platform.css` 之后。状态标记使用各自的语义状态 token。
`src/styles/` 下有七张样式表,由 ui-theme 的动态客户端 entry 依次导入:`base.css`、`corner-shape.css`、`design-platform.css`、`focus.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。客户端 bundle 将其编译并注入为插件持有的全局样式,因此卸载与 HMR(热模块替换)会随 ui-theme 一同移除。`scrollbar.css` 消费 `--dsw-alias-scrollbar-*` token,必须排在声明这些 token 的 `design-platform.css` 之后。状态标记使用各自的语义状态 token。
[`focus.css`](src/styles/focus.css) 通过 `var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary))` 提供 `:focus-visible` 轮廓颜色兜底,不改变轮廓几何。组件轮廓与焦点环阴影使用同一颜色表达式,包括后代和伪元素上的环。`--dsw-focus-ring-width`(2px)是标准宽度;密集表格与工具栏可以保留 1px,offset 仍由组件决定。
指针模态下,`html[data-input-modality='pointer'] body :focus-visible:not(:read-write)` 将焦点环颜色设为透明。后代与伪元素继承该值;规则不清除 `box-shadow`,因此 elevation 阴影与选中态边框独立于焦点环可见性。匹配 `:read-write` 的可编辑文本控件在点击时保留自身焦点反馈。[输入模态](../ui-primitives/README.zh.md#input-modality)决定何时恢复键盘焦点样式;它不移动 DOM 焦点。
`corner-shape.css` 平滑所有圆角:在 `@supports (corner-shape: superellipse(1.5))` 内定义 `--dsw-corner-shape`,并通过通配选择器应用到所有元素及其 `::before`/`::after`,因此不支持 `corner-shape` 的引擎保持普通圆弧。正圆形状——`border-radius: 50%` 的圆与胶囊半径——因超级椭圆会使其变形,须在所属组件样式表中把 `corner-shape: round` 与半径声明配对;corner-shape 样式表 spec 跨全部包样式表强制这一配对。
+9 -15
View File
@@ -1,24 +1,18 @@
/* The standard ring geometry. A component inside a clipping container sets its
* own offset, and the dense tables and toolbars keep a thinner 1px ring, rather
* than bending this value — those exceptions are deliberate, not drift. */
/* Components own offsets; dense tables and toolbars may keep a 1px ring. */
:root {
--dsw-focus-ring-width: 2px;
}
/* One keyboard-focus colour for every control. A control that declares its own
* focus ring sets the colour explicitly, so this sheet reaches only the ones
* that would otherwise paint the browser default — whose colour follows the
* operating-system accent and therefore differs per machine. Declaring
* `outline-color` alone preserves each control's own width, style and offset. */
/* The fallback changes no geometry. Declared rings also read this color, including
* rings drawn on descendants and pseudo-elements. */
:focus-visible {
outline-color: var(--dsw-alias-brand-primary);
outline-color: var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
}
/* A pointer keeps its focus silent. Clicking a control leaves it focused with no
* visible ring, and a later stray key — a modifier, Escape, a typed character —
* must not be the moment that ring appears; the user never navigated there.
* Only focus navigation publishes the keyboard modality that reveals a ring
* again. Controls that own a ring keep their own colour and stay unaffected. */
html[data-input-modality='pointer'] :focus-visible {
/* ui-primitives/input-modality publishes the attribute. Editable controls keep
* their focus feedback on click; other controls reveal rings on keyboard
* navigation. Only ring paint is suppressed, not elevation or selected borders. */
html[data-input-modality='pointer'] body :focus-visible:not(:read-write) {
--dsw-focus-ring-color: transparent;
outline-color: transparent;
}
@@ -0,0 +1,66 @@
/** Static ring declarations; browser interaction coverage lives in focus-rings.e2e.ts. */
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { INPUT_MODALITY, INPUT_MODALITY_ATTRIBUTE } from '../../ui-primitives/src/input-modality.ts'
import { packageStylesheets, parseRules, varReferences } from './stylesheet-scan.ts'
const BRAND = '--dsw-alias-brand-primary'
const COLOR = '--dsw-focus-ring-color'
const WIDTH = '--dsw-focus-ring-width'
const FALLBACK = `var(${COLOR}, var(${BRAND}))`
const css = readFileSync(new URL('../src/styles/focus.css', import.meta.url), 'utf8')
const rules = parseRules(css)
const properties = new Set(['outline', 'outline-color', 'box-shadow'])
const allowedTokens = new Set([BRAND, COLOR, WIDTH])
function ringColorViolations(source: string): string[] {
const violations: string[] = []
for (const rule of parseRules(source)) {
if (!rule.selectors.some(selector => /:focus(?:-visible)?\b/.test(selector))) continue
for (const [property, value] of rule.declarations) {
if (!properties.has(property)) continue
const tokens = varReferences(value)
const hasColor = tokens.includes(BRAND) || tokens.includes(COLOR)
const colorless = /^(none|transparent|currentcolor|inherit)$/i.test(value)
if (tokens.some(token => !allowedTokens.has(token)) || (!hasColor && !colorless)) {
violations.push(`${rule.selectors.join(', ')} { ${property}: ${value} }`)
}
if (rule.selectors.every(selector => selector.includes(':focus-visible'))
&& tokens.includes(BRAND) && !tokens.includes(COLOR)) {
violations.push(`${rule.selectors.join(', ')} bypasses ${COLOR}`)
}
}
}
return violations
}
describe('focus styles', () => {
it('defines standard geometry and a color-only browser fallback', () => {
expect(rules.find(rule => rule.selectors.includes(':root'))?.declarations).toEqual([[WIDTH, '2px']])
expect(rules.find(rule => rule.selectors.includes(':focus-visible'))?.declarations)
.toEqual([['outline-color', FALLBACK]])
})
it('pairs pointer suppression with the modality publisher and leaves editable controls alone', () => {
const selector = `html[${INPUT_MODALITY_ATTRIBUTE}='${INPUT_MODALITY.pointer}'] body :focus-visible:not(:read-write)`
expect(rules.find(rule => rule.selectors.includes(selector))?.declarations)
.toEqual([[COLOR, 'transparent'], ['outline-color', 'transparent']])
expect(rules.flatMap(rule => rule.declarations).some(([property]) => property === 'box-shadow')).toBe(false)
})
it('rejects a non-brand ring and a keyboard ring that bypasses pointer suppression', () => {
expect(ringColorViolations('.a:focus-visible { outline: 2px solid red; }')).toHaveLength(1)
expect(ringColorViolations('.a:focus { box-shadow: 0 0 0 2px var(--dsw-alias-state-business-primary); }'))
.toHaveLength(1)
expect(ringColorViolations('.a:focus-visible .label { outline: 2px solid var(--dsw-alias-brand-primary); }'))
.toHaveLength(1)
expect(ringColorViolations(`.a:focus-visible::after { outline: 2px solid ${FALLBACK}; }`)).toEqual([])
})
it('uses the shared ring color throughout a non-empty client stylesheet corpus', () => {
const files = packageStylesheets().filter(file => file.includes('/packages/client/'))
expect(files.length).toBeGreaterThan(0)
const failures = files.flatMap(file => ringColorViolations(readFileSync(file, 'utf8')).map(value => `${file}: ${value}`))
expect(failures).toEqual([])
})
})
@@ -255,4 +255,4 @@
}
.path:hover { text-decoration: underline; }
.path:focus-visible { outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 3px; }
.path:focus-visible { outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary)); outline-offset: 3px; }
@@ -75,7 +75,7 @@
}
.historyLoadButton:focus-visible {
outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary);
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
outline-offset: -2px;
}
@@ -206,7 +206,7 @@
}
.table tbody tr:not([data-collapsed-summary]):focus-visible {
box-shadow: inset 0 0 0 1px var(--dsw-alias-brand-primary);
box-shadow: inset 0 0 0 1px var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
}
.table tbody tr[data-selected='true'] {
@@ -734,7 +734,7 @@
}
.table tbody tr[data-collapsed-summary]:focus-visible {
box-shadow: inset 0 0 0 1px var(--dsw-alias-brand-primary);
box-shadow: inset 0 0 0 1px var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
}
.collapsedTurnContent {
@@ -1001,7 +1001,7 @@
.close:focus-visible,
.detailTab:focus-visible {
outline: 1px solid var(--dsw-alias-brand-primary);
outline: 1px solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
outline-offset: -1px;
}
@@ -1089,7 +1089,7 @@
.overviewHierarchyNavLink:focus-visible {
color: var(--dsw-alias-label-primary);
outline: 1px solid var(--dsw-alias-brand-primary);
outline: 1px solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
outline-offset: 2px;
}
@@ -1147,7 +1147,7 @@
}
.timestampToggle:focus-visible {
outline: 1px solid var(--dsw-alias-brand-primary);
outline: 1px solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
outline-offset: 2px;
}
@@ -1215,7 +1215,7 @@
}
.overviewTitle:focus-visible {
outline: 1px solid var(--dsw-alias-brand-primary);
outline: 1px solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
outline-offset: 2px;
}
@@ -1316,7 +1316,7 @@
}
.programAction:focus-visible {
outline: 1px solid var(--dsw-alias-brand-primary);
outline: 1px solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
outline-offset: -1px;
}
@@ -1521,7 +1521,7 @@
}
.thinkingToggle:focus-visible {
outline: 1px solid var(--dsw-alias-brand-primary);
outline: 1px solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
outline-offset: 2px;
}
@@ -1763,7 +1763,7 @@
}
.attachmentDisclosure > summary:focus-visible {
outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary);
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
outline-offset: 2px;
}
@@ -1810,7 +1810,7 @@
.assistantToolCallButton:focus-visible {
background: var(--dsw-alias-interactive-bg-hover);
outline: 1px solid var(--dsw-alias-brand-primary);
outline: 1px solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
outline-offset: -1px;
}
@@ -1884,7 +1884,7 @@
.toolCatalogSummary:focus-visible {
background: var(--dsw-alias-interactive-bg-hover);
outline: 1px solid var(--dsw-alias-brand-primary);
outline: 1px solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
outline-offset: -1px;
}
@@ -98,7 +98,7 @@
}
.earlierHistory:focus-visible {
box-shadow: inset 0 0 0 1px var(--dsw-alias-brand-primary);
box-shadow: inset 0 0 0 1px var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
}
.empty {
@@ -111,7 +111,7 @@
}
.track:focus-visible {
outline: 1px solid var(--dsw-alias-brand-primary);
outline: 1px solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
outline-offset: -1px;
}
@@ -52,7 +52,7 @@
}
.toggle:focus-visible {
outline: 1px solid var(--dsw-alias-brand-primary);
outline: 1px solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
outline-offset: 1px;
}
@@ -95,7 +95,7 @@
}
.control:focus-visible {
outline: 1px solid var(--dsw-alias-brand-primary);
outline: 1px solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
outline-offset: 1px;
}
@@ -156,7 +156,7 @@
}
.action:focus-visible {
outline: 1px solid var(--dsw-alias-brand-primary);
outline: 1px solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
outline-offset: 1px;
}
@@ -17,7 +17,7 @@
}
.runHeader:focus-visible {
outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary);
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
outline-offset: -2px;
}
@@ -80,7 +80,7 @@
}
.phaseHeader:focus-visible {
outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary);
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
outline-offset: -2px;
border-radius: 4px;
}
@@ -210,7 +210,7 @@
}
.memberButton:focus-visible .memberLabelWrap {
outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary);
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
outline-offset: -1px;
}
@@ -35,7 +35,7 @@
.iconButton:focus-visible,
.searchButton:focus-visible,
.clearButton:focus-visible {
outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary);
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary));
outline-offset: -2px;
}
+1
View File
@@ -38,6 +38,7 @@
"apps/web/tests/plan-review.e2e.ts",
"apps/web/tests/steering.e2e.ts",
"apps/web/tests/navigation-panes.e2e.ts",
"apps/web/tests/focus-rings.e2e.ts",
"apps/web/tests/chat-scroll-fixture.ts",
"apps/web/tests/trajectory-virtualization.e2e.ts",
"apps/web/tests/lifecycle-chrome.e2e.ts",