Merge remote-tracking branch 'origin/master' into feat/schedule-reminder-framing
This commit is contained in:
commit
4c0caf9c74
70 files changed
+1064
-123
No files matched your search
@@ -0,0 +1,19 @@
|
||||
# Bilingual-pair consistency record for 2026-09-20-focus-ring-brand-primary.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# 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
|
||||
/agent-note-one-keyboard-focus-colour-for-every-control:
|
||||
en: 967e19daff3df9bc
|
||||
zh: 81e1a21d4204eb8a
|
||||
/agent-note-one-keyboard-focus-colour-for-every-control/problem:
|
||||
en: 2f3f77b1de539959
|
||||
zh: a4fa146b1d9ddecf
|
||||
/agent-note-one-keyboard-focus-colour-for-every-control/decision:
|
||||
en: 613ec184df6bf5d5
|
||||
zh: e4b3f95f4d33a54c
|
||||
/agent-note-one-keyboard-focus-colour-for-every-control/alternatives-considered:
|
||||
en: 392968102d520d1d
|
||||
zh: e7b7b68f145cf3d3
|
||||
/agent-note-one-keyboard-focus-colour-for-every-control/consequences:
|
||||
en: 25b9933c3d43ad76
|
||||
zh: 75d374ce3b7ec2eb
|
||||
@@ -0,0 +1,43 @@
|
||||
# Agent Note: One keyboard-focus colour for every control
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-20-focus-ring-brand-primary.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
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
|
||||
|
||||
[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.
|
||||
|
||||
**One colour.** Global and component `:focus-visible` outlines and focus-ring shadows read `--dsw-focus-ring-color`, falling back to `--dsw-alias-state-business-primary`. The global fallback names the colour and the standard width but never the style, so it cannot create an outline where the component disables one. Naming the width is what keeps a control with no ring of its own at the standard geometry instead of Chromium's `auto 1px`.
|
||||
|
||||
**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
|
||||
|
||||
**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.
|
||||
|
||||
**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.
|
||||
|
||||
**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.
|
||||
|
||||
**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
|
||||
|
||||
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.
|
||||
|
||||
[Stylesheet-order checks](../../../../packages/client/ui-theme/tests/client-styles.client.spec.ts), [focus-style checks](../../../../packages/client/ui-theme/tests/focus-ring-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.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Agent Note:所有控件共用一个键盘焦点颜色
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-20-focus-ring-brand-primary.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
不同的显式焦点颜色与浏览器默认轮廓无法提供一致的键盘焦点提示。浏览器默认值还可能取决于操作系统强调色。
|
||||
|
||||
焦点反馈另有交互与几何约束。浏览器的 `:focus-visible` 启发式可能在不移动焦点的按键之后显出指针聚焦按钮的环,而可编辑文本控件在点击时需要焦点反馈。裁切内容的容器可能隐藏外扩环。
|
||||
|
||||
## 决策
|
||||
|
||||
[主题焦点样式表](../../../../packages/client/ui-theme/src/styles/focus.css) 拥有共享焦点环颜色与标准宽度。[输入模态](../../../../packages/client/ui-primitives/README.zh.md#input-modality) 区分 tooltip 输入与键盘焦点导航。
|
||||
|
||||
**一个颜色。** 全局与组件的 `:focus-visible` 轮廓和焦点环阴影读取 `--dsw-focus-ring-color`,回退到 `--dsw-alias-state-business-primary`。全局兜底声明颜色与标准宽度、但不声明样式,因此组件禁用轮廓时它不会创建轮廓。声明宽度可让没有自有焦点环的控件保持标准几何,而不是落到 Chromium 的 `auto 1px`。
|
||||
|
||||
**场景决定几何。** `--dsw-focus-ring-width` 提供标准的 2px 宽度。密集表格与工具栏可以保留 1px,避免焦点环压过紧凑内容。组件拥有自己的 offset,并在外扩环会被裁切时使用内嵌环。
|
||||
|
||||
**指针焦点不等于键盘导航。** 指针模态下,`:focus-visible:not(:read-write)` 控件使用透明的焦点环颜色。后代与伪元素上的环继承该值。只抑制焦点环绘制:选中态边框与 elevation 阴影保留,可编辑文本控件在点击时保留自身焦点反馈。DOM 焦点仍留在控件上。
|
||||
|
||||
Tooltip 跟随最近一次输入,将任意按键视为键盘输入。焦点环则在非组合输入的导航键到达时,或非组合输入按键之后焦点到达不同控件时,恢复键盘样式。重新聚焦同一控件不算导航;指针输入、组合输入按键和 window blur 会清除待处理按键。
|
||||
|
||||
## 考虑过的其他做法
|
||||
|
||||
**只重写组件声明。** 显式焦点环会一致,但使用浏览器默认轮廓的控件仍会保留其颜色。要求每个控件自行声明焦点环,也会让新控件在作者补上规则之前保持不一致。
|
||||
|
||||
**全局统一宽度与 offset。** 正 offset 可能被滚动祖先裁切,统一的粗环则可能压过密集内容。颜色一致性和输入模态都不要求相同的几何。
|
||||
|
||||
**用移动焦点取代样式抑制。** 对指针聚焦的按钮调用 `blur()` 可以隐藏焦点环,但也会移除后续 Enter 或 Space 激活的目标。
|
||||
|
||||
**指针聚焦时清除所有阴影。** 一个 `box-shadow` 除了焦点环,还可能包含 elevation 或选中态绘制。抑制其焦点环颜色可保留这些独立提示;豁免 `:read-write` 可保留文本输入反馈。
|
||||
|
||||
**把所有 `outline: none` 改为共享焦点环。** 一些控件改用内嵌阴影或容器级焦点处理;一些容器则刻意不显示焦点环。替换这些声明会改变焦点处理方式,而不只是颜色。
|
||||
|
||||
**让 tooltip 与焦点环共用同一条模态规则。** 按键可以让后续程序化聚焦显示 tooltip,却不一定需要在同一个指针聚焦按钮上显示焦点环。分别记录两个答案可保留两种行为。
|
||||
|
||||
## 影响
|
||||
|
||||
焦点环共享主题颜色,组件保留自己的几何与非焦点状态提示。焦点环可见性的变化不改变键盘的活动目标。这些规则不保证浏览器渲染完全相同,也不会消除组件特有的裁切。
|
||||
|
||||
[样式表顺序检查](../../../../packages/client/ui-theme/tests/client-styles.client.spec.ts)、[焦点样式检查](../../../../packages/client/ui-theme/tests/focus-ring-styles.client.spec.ts)与[输入模态测试](../../../../packages/client/ui-primitives/tests/input-modality.client.spec.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-state-business-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')
|
||||
|
||||
@@ -0,0 +1,529 @@
|
||||
/** 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'),
|
||||
focusColor: resolveColor(style.getPropertyValue('--dsw-alias-state-business-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.focusColor).toMatch(/^rgb/)
|
||||
expect(state.outlineStyle === 'none' || state.outline === 'rgba(0, 0, 0, 0)').toBe(true)
|
||||
expect(state.shadow).not.toContain(state.focusColor)
|
||||
}
|
||||
|
||||
async function expectFocusRing(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.focusColor)
|
||||
: state.outline === state.focusColor && state.outlineStyle === 'solid' && Number.parseFloat(state.outlineWidth) > 0,
|
||||
}
|
||||
}).toEqual({ active: true, visible: true, modality: 'keyboard', painted: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Name every ancestor that cuts the ring's painted box. A control flush with an
|
||||
* `overflow: hidden` ancestor keeps its outline geometrically but loses the pixels that leave
|
||||
* that ancestor, so compare the layout box grown by the ring against each clipping ancestor.
|
||||
*/
|
||||
async function ringCutBy(target: Locator): Promise<string[]> {
|
||||
return target.evaluate((element) => {
|
||||
const style = getComputedStyle(element)
|
||||
const width = Number.parseFloat(style.outlineWidth) || 0
|
||||
const offset = Number.parseFloat(style.outlineOffset) || 0
|
||||
const box = element.getBoundingClientRect()
|
||||
const ring = {
|
||||
left: box.left - width - offset,
|
||||
top: box.top - width - offset,
|
||||
right: box.right + width + offset,
|
||||
bottom: box.bottom + width + offset,
|
||||
}
|
||||
const cuts: string[] = []
|
||||
for (let node = element.parentElement; node !== null; node = node.parentElement) {
|
||||
const overflow = getComputedStyle(node)
|
||||
if (overflow.overflow === 'visible' && overflow.overflowX === 'visible' && overflow.overflowY === 'visible') continue
|
||||
const rect = node.getBoundingClientRect()
|
||||
const cut = {
|
||||
left: Math.max(0, rect.left - ring.left),
|
||||
top: Math.max(0, rect.top - ring.top),
|
||||
right: Math.max(0, ring.right - rect.right),
|
||||
bottom: Math.max(0, ring.bottom - rect.bottom),
|
||||
}
|
||||
if (cut.left + cut.top + cut.right + cut.bottom > 0.5) {
|
||||
cuts.push(`${node.tagName.toLowerCase()}${node.className === '' ? '' : `.${node.className}`} cuts ${JSON.stringify(cut)}`)
|
||||
}
|
||||
}
|
||||
return cuts
|
||||
})
|
||||
}
|
||||
|
||||
it.each(['light', 'dark'] as const)('assembled app (%s): pointer keys stay silent; navigation and menu activation retain feedback', async (theme) => {
|
||||
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.emulateMedia({ colorScheme: theme })
|
||||
await page.goto(scaffold.authenticatedUrl)
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
await expect.poll(() => page.locator('body').getAttribute('data-ds-dark-theme')).toBe(theme === 'dark' ? '' : null)
|
||||
const trigger = page.getByRole('button', { name: /^Access mode, current:/ })
|
||||
expect((await paint(trigger)).focusColor).toBe(theme === 'dark' ? 'rgb(122, 170, 255)' : 'rgb(65, 118, 230)')
|
||||
|
||||
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 expectFocusRing(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 expectFocusRing(trigger, 'shadow')
|
||||
for (const key of ['Enter', 'Space']) {
|
||||
await page.keyboard.press(key)
|
||||
await page.getByRole('menu').waitFor()
|
||||
await expectFocusRing(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 expectFocusRing(trigger, 'shadow')
|
||||
}
|
||||
expect(consoleWatch.warnings).toEqual([])
|
||||
expect(consoleWatch.pageErrors).toEqual([])
|
||||
})
|
||||
|
||||
it.each(['light', 'dark'] as const)('assembled app (%s): the sidebar toggle keeps its whole ring in the expanded and rail layouts', async (theme) => {
|
||||
const scaffold = await launchWebScaffold()
|
||||
onTestFinished(() => scaffold.close())
|
||||
const browser = await chromium.launch({ headless: true })
|
||||
onTestFinished(() => browser.close())
|
||||
const page = await newEnglishPage(browser)
|
||||
await page.emulateMedia({ colorScheme: theme })
|
||||
await page.goto(scaffold.authenticatedUrl)
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
|
||||
const toggle = page.getByRole('button', { name: 'Collapse sidebar' })
|
||||
const rail = page.getByRole('button', { name: 'Open sidebar' })
|
||||
/** Sample the ring box once the collapse animation stops moving the control. */
|
||||
const settle = async (target: Locator, width: number): Promise<void> => {
|
||||
await expect.poll(() => target.evaluate(element => element.getBoundingClientRect().width)).toBe(width)
|
||||
await target.evaluate(async (element) => {
|
||||
for (let node: Element | null = element; node !== null; node = node.parentElement) {
|
||||
await Promise.all(node.getAnimations().map(animation => animation.finished))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
await toggle.click()
|
||||
await rail.waitFor()
|
||||
// The rail only replaces the frozen wide layout once the collapse settles.
|
||||
await settle(rail, 36)
|
||||
await expectSilent(rail)
|
||||
await page.keyboard.press('Home')
|
||||
await expectFocusRing(rail, 'outline')
|
||||
expect(await ringCutBy(rail)).toEqual([])
|
||||
|
||||
await rail.click()
|
||||
await toggle.waitFor()
|
||||
await settle(toggle, 28)
|
||||
await expectSilent(toggle)
|
||||
await page.keyboard.press('Home')
|
||||
await expectFocusRing(toggle, 'outline')
|
||||
expect(await ringCutBy(toggle)).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')}
|
||||
import onboardingCss from ${source('ui-settings-account/src/client/DesktopOnboarding.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 }}>
|
||||
<div data-onboarding-card className={onboardingCss.card}>
|
||||
<label><input type="checkbox" aria-label="Onboarding purpose" />Purpose</label>
|
||||
</div>
|
||||
<Switch label="Toggle" checked={checked} onChange={setChecked} />
|
||||
<button id="default-outline">Default browser outline</button>
|
||||
<button id="outline-none" aria-label="Outline disabled" style={{ outline: 'none' }}>No 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'),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The painted ring's colour, style and width, plus its WCAG contrast against the surface the
|
||||
* ring sits on. A control that disables its outline reports `style: 'none'` and null contrast.
|
||||
*/
|
||||
async function ringReport(target: Locator) {
|
||||
return target.evaluate((element) => {
|
||||
const style = getComputedStyle(element)
|
||||
if (style.outlineStyle === 'none') return { color: null, style: style.outlineStyle, width: style.outlineWidth, offset: style.outlineOffset, contrast: null }
|
||||
const resolve = (value: string): { text: string; rgb: [number, number, number] } => {
|
||||
const probe = document.createElement('span')
|
||||
probe.style.color = value
|
||||
document.body.append(probe)
|
||||
const text = getComputedStyle(probe).color
|
||||
probe.remove()
|
||||
const parts = /([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/.exec(text)
|
||||
return { text, rgb: [Number(parts?.[1] ?? 0), Number(parts?.[2] ?? 0), Number(parts?.[3] ?? 0)] }
|
||||
}
|
||||
const ring = resolve(style.outlineColor)
|
||||
// The ring is painted outside the border box unless the offset is negative.
|
||||
const start = Number.parseFloat(style.outlineOffset) >= 0 ? element.parentElement : element
|
||||
let behind = null
|
||||
for (let node = start; node instanceof HTMLElement; node = node.parentElement) {
|
||||
const candidate = getComputedStyle(node).backgroundColor
|
||||
if (candidate && !/rgba\(0, 0, 0, 0\)/.test(candidate)) { behind = resolve(candidate); break }
|
||||
}
|
||||
if (behind === null) {
|
||||
return { color: ring.text, style: style.outlineStyle, width: style.outlineWidth, offset: style.outlineOffset, contrast: null }
|
||||
}
|
||||
const luminance = (rgb: [number, number, number]): number => {
|
||||
const channel = (value: number): number => { const c = value / 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4 }
|
||||
return 0.2126 * channel(rgb[0]) + 0.7152 * channel(rgb[1]) + 0.0722 * channel(rgb[2])
|
||||
}
|
||||
const a = luminance(ring.rgb); const b = luminance(behind.rgb)
|
||||
return {
|
||||
color: ring.text, style: style.outlineStyle, width: style.outlineWidth, offset: style.outlineOffset,
|
||||
behind: behind.text,
|
||||
contrast: Math.round(((Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)) * 100) / 100,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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.each(['light', 'dark'])('keeps ancestor card rings silent after pointer input in %s mode', async (theme) => {
|
||||
const page = await openFixture()
|
||||
await page.evaluate(dark => document.body.toggleAttribute('data-ds-dark-theme', dark), theme === 'dark')
|
||||
const checkbox = page.getByRole('checkbox', { name: 'Onboarding purpose' })
|
||||
const card = page.locator('[data-onboarding-card]')
|
||||
await checkbox.click()
|
||||
await page.keyboard.press('Shift')
|
||||
expect((await paint(checkbox)).active).toBe(true)
|
||||
expect((await paint(card)).outlineStyle).toBe('none')
|
||||
await page.keyboard.press('Tab')
|
||||
await page.keyboard.press('Shift+Tab')
|
||||
const ring = await paint(card)
|
||||
expect((await paint(checkbox)).active).toBe(true)
|
||||
expect(ring.outlineStyle).toBe('solid')
|
||||
expect(ring.outline).toBe(ring.focusColor)
|
||||
})
|
||||
|
||||
it('keeps Shift/Escape silent while Tab, Home, End and paging reveal a blue 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 expectFocusRing(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.focusColor)
|
||||
expect(Number.parseFloat(state.outlineWidth)).toBeGreaterThan(0)
|
||||
await page.keyboard.press('Shift+Tab')
|
||||
await expectFocusRing(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 expectFocusRing(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.focusColor)
|
||||
// Restore direct-colour 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.focusColor)
|
||||
})
|
||||
|
||||
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 expectFocusRing(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.focusColor) }
|
||||
}).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.focusColor)
|
||||
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 expectFocusRing(card, 'outline')
|
||||
expect((await paint(card)).shadow).toBe(elevation)
|
||||
})
|
||||
it('gives an undeclared ring the standard width and legible contrast in both themes', async () => {
|
||||
const page = await openFixture()
|
||||
const button = page.locator('#default-outline')
|
||||
await page.keyboard.press('Shift')
|
||||
await button.focus()
|
||||
const light = await ringReport(button)
|
||||
expect(light.style).not.toBe('none')
|
||||
expect(light.width).toBe('2px')
|
||||
expect(light.color).toBe('rgb(65, 118, 230)')
|
||||
expect(light.contrast).not.toBeNull()
|
||||
expect(light.contrast!).toBeGreaterThanOrEqual(3)
|
||||
|
||||
await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') })
|
||||
await page.keyboard.press('Shift')
|
||||
await button.focus()
|
||||
const dark = await ringReport(button)
|
||||
expect(dark.width).toBe('2px')
|
||||
expect(dark.color).toBe('rgb(122, 170, 255)')
|
||||
expect(dark.contrast).not.toBeNull()
|
||||
expect(dark.contrast!).toBeGreaterThanOrEqual(3)
|
||||
|
||||
// Naming the width must not create a ring where a control disables its outline.
|
||||
const disabled = page.locator('#outline-none')
|
||||
await page.keyboard.press('Shift')
|
||||
await disabled.focus()
|
||||
const none = await ringReport(disabled)
|
||||
expect(none.style).toBe('none')
|
||||
expect(none.contrast).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -54,6 +54,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",
|
||||
|
||||
@@ -182,7 +182,7 @@
|
||||
}
|
||||
|
||||
.cardMain:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-brand-primary);
|
||||
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
@@ -222,7 +222,7 @@
|
||||
}
|
||||
|
||||
.cardMain:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-brand-primary);
|
||||
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
@@ -338,7 +338,7 @@
|
||||
}
|
||||
|
||||
.iconButton:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-brand-primary);
|
||||
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
}
|
||||
|
||||
.guidePanel:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-brand-primary);
|
||||
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
|
||||
@@ -102,6 +102,6 @@
|
||||
|
||||
.frame:focus-visible,
|
||||
.error:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-state-business-primary);
|
||||
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@@ -230,7 +230,7 @@
|
||||
}
|
||||
|
||||
.retrySummary:focus-visible {
|
||||
outline: 1.5px solid var(--dsw-alias-button-info-fill);
|
||||
outline: 1.5px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
|
||||
@@ -133,7 +133,7 @@
|
||||
against a busy transcript. */
|
||||
.mark:focus-visible::before {
|
||||
transform: translateY(-50%) scaleX(1);
|
||||
background: var(--dsw-alias-state-business-primary);
|
||||
background: var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
}
|
||||
|
||||
.mark:focus-visible {
|
||||
@@ -145,8 +145,9 @@
|
||||
inset: 0 0 0 auto;
|
||||
width: 20px;
|
||||
border-radius: inherit;
|
||||
outline: 1px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: 2px;
|
||||
outline: 1px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-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: 2px solid var(--dsw-alias-label-tertiary);
|
||||
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
@@ -263,7 +263,7 @@
|
||||
}
|
||||
|
||||
.action:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-label-tertiary);
|
||||
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,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-state-business-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-l1); border-radius: var(--dsw-radius-lg); background: color-mix(in srgb, var(--dsw-static-neutral-00) 50%, transparent); }
|
||||
:global(body[data-ds-dark-theme]) .tile { background: color-mix(in srgb, var(--dsw-static-neutral-00) 5%, transparent); }
|
||||
@@ -22,7 +22,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-border-l3); }
|
||||
.row:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); }
|
||||
.row:disabled { cursor: progress; }
|
||||
.path { min-width: 0; overflow: hidden; font-family: var(--dsw-font-family); 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: var(--dsw-radius-lg); 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-state-business-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: var(--dsw-radius-lg); background: color-mix(in srgb, var(--dsw-static-neutral-00) 50%, transparent); color: var(--dsw-alias-link); }
|
||||
:global(body[data-ds-dark-theme]) .fileIcon { background: color-mix(in srgb, var(--dsw-static-neutral-00) 5%, transparent); }
|
||||
.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; }
|
||||
|
||||
@@ -121,6 +121,12 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.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-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.crumb:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
@@ -239,6 +245,12 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.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-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
}
|
||||
|
||||
.chip:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-button-primary-fill);
|
||||
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
.detail:focus {
|
||||
outline: none;
|
||||
border-color: var(--dsw-alias-border-l3);
|
||||
box-shadow: 0 0 0 1px var(--dsw-alias-border-l3);
|
||||
box-shadow: 0 0 0 1px var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.submit {
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
}
|
||||
|
||||
.trigger:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--dsw-alias-border-l3);
|
||||
box-shadow: 0 0 0 2px var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
}
|
||||
|
||||
.trigger:disabled {
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
}
|
||||
|
||||
.trigger:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--dsw-alias-border-l3);
|
||||
box-shadow: 0 0 0 2px var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
}
|
||||
|
||||
.trigger:disabled {
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
}
|
||||
|
||||
.chip:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-state-business-primary);
|
||||
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-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: 2px solid var(--dsw-alias-label-primary); outline-offset: 3px; }
|
||||
.card:focus-visible { outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-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: var(--dsw-radius-md); 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: var(--dsw-radius-sm); 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: 2px solid var(--dsw-alias-label-primary); outline-offset: 3px; }
|
||||
.reviewLink:focus-visible { outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-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; }
|
||||
|
||||
@@ -162,7 +162,7 @@
|
||||
}
|
||||
|
||||
.iconButton:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-brand-primary);
|
||||
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
@@ -279,7 +279,7 @@
|
||||
}
|
||||
|
||||
.cardOpen:focus-visible::after {
|
||||
outline: 2px solid var(--dsw-alias-brand-primary);
|
||||
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -385,7 +385,7 @@
|
||||
}
|
||||
|
||||
.deleteButton:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-brand-primary);
|
||||
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
@@ -409,7 +409,7 @@
|
||||
}
|
||||
|
||||
.partsFilter:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-brand-primary);
|
||||
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
@@ -463,8 +463,8 @@
|
||||
}
|
||||
|
||||
.installField input[type='text']:focus {
|
||||
border-color: var(--dsw-alias-brand-primary);
|
||||
box-shadow: inset 0 0 0 0.5px var(--dsw-alias-brand-primary);
|
||||
border-color: var(--dsw-alias-state-business-primary);
|
||||
box-shadow: inset 0 0 0 0.5px var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.installField input[aria-invalid='true'] {
|
||||
@@ -1091,7 +1091,7 @@ label.registryOption {
|
||||
}
|
||||
|
||||
.crumb:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-brand-primary);
|
||||
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
|
||||
@@ -33,8 +33,11 @@
|
||||
en: 464527753be6a4f6
|
||||
zh: d9df7442503bd58f
|
||||
/deepseek-ai-dsh-client-ui-primitives/understand-the-implementation/source-map:
|
||||
en: 1c058b5591dad057
|
||||
zh: 044725576954719f
|
||||
en: 44eb32370cbeafe8
|
||||
zh: 728009c66375c6bb
|
||||
/deepseek-ai-dsh-client-ui-primitives/understand-the-implementation/input-modality:
|
||||
en: cb61e1a37d6c27e5
|
||||
zh: 1f3fa79dd5ab2e45
|
||||
/deepseek-ai-dsh-client-ui-primitives/understand-the-implementation/streaming-markdown:
|
||||
en: 8bbfa14c27f3bfd7
|
||||
zh: 88f7548ac3a114e1
|
||||
|
||||
@@ -132,10 +132,16 @@ 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 |
|
||||
| [`src/settings-form/`](src/settings-form/) | The settings page kit: the staged form model over a settings scope, the value and secret fields, and the form frame |
|
||||
|
||||
<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.
|
||||
|
||||
@@ -132,10 +132,16 @@ 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) | 浮动面板与浮层几何钩子 |
|
||||
| [`src/settings-form/`](src/settings-form/) | 设置页套件:基于设置 scope 的暂存表单模型、值字段与密文字段、表单框架 |
|
||||
|
||||
<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: 2px solid var(--dsw-alias-brand-primary);
|
||||
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
}
|
||||
|
||||
.action:focus-visible {
|
||||
outline: 1px solid var(--dsw-alias-state-business-primary);
|
||||
outline: 1px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
}
|
||||
|
||||
.warning:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-state-warn-label);
|
||||
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
}
|
||||
|
||||
.copyable:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-state-business-primary);
|
||||
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
|
||||
@@ -43,4 +43,4 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.close:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 3px; }
|
||||
.close:focus-visible { outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: 3px; }
|
||||
@@ -10,7 +10,7 @@
|
||||
}
|
||||
|
||||
.wrap:focus-within {
|
||||
border-color: var(--dsw-alias-brand-primary);
|
||||
border-color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.icon {
|
||||
|
||||
@@ -187,7 +187,7 @@
|
||||
}
|
||||
|
||||
.stringToggle:focus-visible {
|
||||
outline: 1px solid var(--dsw-alias-state-business-primary);
|
||||
outline: 1px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
@@ -286,7 +286,7 @@
|
||||
}
|
||||
|
||||
.actionButton:focus-visible {
|
||||
outline: 1px solid var(--dsw-alias-state-business-primary);
|
||||
outline: 1px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
}
|
||||
|
||||
.acknowledgement input:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-border-l4);
|
||||
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
}
|
||||
|
||||
.tab:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-brand-primary);
|
||||
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
}
|
||||
|
||||
.switch:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-brand-primary);
|
||||
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@ import { cloneElement, createContext, useCallback, useContext, useEffect, useRef
|
||||
import type { FocusEventHandler, MouseEventHandler, MutableRefObject, ReactElement, Ref } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import css from './Tooltip.module.css'
|
||||
// Tooltips take the wide answer — any key returns to the keyboard. Focus rings read the
|
||||
// narrower `data-input-modality` attribute the same module publishes.
|
||||
import { pointerModality } from './input-modality.ts'
|
||||
|
||||
/** Bubble placement relative to the anchor. */
|
||||
export type TooltipSide = 'right' | 'bottom' | 'top'
|
||||
@@ -26,18 +29,6 @@ interface AnchorProps {
|
||||
|
||||
type TooltipLabel = string | (() => string)
|
||||
|
||||
// Focus alone cannot reveal how it arrived: a closing menu hands focus back to
|
||||
// its trigger, and after a mouse selection that programmatic return must not
|
||||
// raise the trigger's bubble, while keyboard focus must. Capture-phase window
|
||||
// listeners record the last input modality for every Tooltip. The guard keeps
|
||||
// the module loadable where no window exists (node-side imports of the
|
||||
// package's pure helpers).
|
||||
let pointerModality = false
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('pointerdown', () => { pointerModality = true }, true)
|
||||
window.addEventListener('keydown', () => { pointerModality = false }, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach a hover/focus tooltip to an anchor element.
|
||||
* @param props.label - bubble text, or a resolver evaluated only while the bubble is visible.
|
||||
@@ -208,7 +199,7 @@ export function Tooltip({ label, side = 'right', align = 'center', delayMs = 0,
|
||||
// what the anchor now does (pin → unpin), and the click leaves the
|
||||
// anchor focused, which would otherwise pin the relabelled bubble up.
|
||||
onClick: (e) => { children.props.onClick?.(e); triggers.current.focus = false; cancelShow(); withdraw() },
|
||||
onFocus: (e) => { children.props.onFocus?.(e); if (pointerModality) return; triggers.current.focus = true; cancelShow(); show() },
|
||||
onFocus: (e) => { children.props.onFocus?.(e); if (pointerModality()) return; triggers.current.focus = true; cancelShow(); show() },
|
||||
onBlur: (e) => { children.props.onBlur?.(e); triggers.current.focus = false; hide() },
|
||||
})}
|
||||
{portal ? (content !== false && createPortal(content, document.body)) : content}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 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. */
|
||||
export const INPUT_MODALITY = { pointer: 'pointer', keyboard: 'keyboard' } as const
|
||||
|
||||
/** 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
|
||||
let keyFocusOrigin: EventTarget | undefined
|
||||
|
||||
function publish(): void {
|
||||
document.documentElement.setAttribute(INPUT_MODALITY_ATTRIBUTE, pointerOwnsFocus ? INPUT_MODALITY.pointer : INPUT_MODALITY.keyboard)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('pointerdown', () => {
|
||||
pointer = true
|
||||
pointerOwnsFocus = true
|
||||
keyFocusOrigin = undefined
|
||||
publish()
|
||||
}, true)
|
||||
window.addEventListener('keydown', (event) => {
|
||||
pointer = false
|
||||
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-state-business-primary);
|
||||
box-shadow: 0 0 0 2px var(--dsw-focus-ring-color, var(--dsw-alias-state-business-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-state-business-primary);
|
||||
box-shadow: 0 0 0 2px var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
}
|
||||
|
||||
/* Many-column tables (the renderer's `md-table-wide` hook) keep their
|
||||
@@ -454,7 +454,7 @@
|
||||
cursor: zoom-in;
|
||||
}
|
||||
.imageButton .image { max-width: min(100%, 640px); max-height: 360px; }
|
||||
.imageButton:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 3px; }
|
||||
.imageButton:focus-visible { outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: 3px; }
|
||||
.previewName {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
|
||||
@@ -48,6 +48,6 @@
|
||||
}
|
||||
|
||||
.save:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-brand-primary);
|
||||
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: 1px;
|
||||
}
|
||||
@@ -60,7 +60,7 @@
|
||||
}
|
||||
|
||||
.helpButton:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-brand-primary);
|
||||
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@
|
||||
|
||||
.input:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--dsw-alias-brand-primary);
|
||||
border-color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.input:disabled {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// @vitest-environment jsdom
|
||||
/** 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, 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', () => {
|
||||
for (const key of ['Shift', 'Escape', 'a', 'Enter', 'Tab']) {
|
||||
click()
|
||||
expect(pointerModality()).toBe(true)
|
||||
press(key)
|
||||
expect(pointerModality()).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a pointer focused control silent through keys that do not navigate', () => {
|
||||
click()
|
||||
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 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()
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -36,7 +36,7 @@
|
||||
}
|
||||
|
||||
.option:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-state-business-primary);
|
||||
outline: 2px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
}
|
||||
|
||||
.nav:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-state-business-primary);
|
||||
outline: 2px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
}
|
||||
|
||||
.cell:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-state-business-primary);
|
||||
outline: 2px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
}
|
||||
|
||||
.openButton:focus-visible::after {
|
||||
outline: 2px solid var(--dsw-alias-state-business-primary);
|
||||
outline: 2px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
|
||||
.cardOpen:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 2px var(--dsw-alias-brand-primary);
|
||||
box-shadow: inset 0 0 0 2px var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
}
|
||||
|
||||
.row {
|
||||
|
||||
@@ -189,7 +189,7 @@
|
||||
}
|
||||
|
||||
.searchField input:focus-visible {
|
||||
outline: 0;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* The custom right-side button owns clearing the search field. */
|
||||
@@ -486,7 +486,7 @@
|
||||
}
|
||||
|
||||
.editName:focus {
|
||||
box-shadow: 0 1px var(--dsw-alias-state-business-primary);
|
||||
box-shadow: 0 1px var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
}
|
||||
|
||||
/* An ended task's stored name as plain text on the control's step, padded to
|
||||
@@ -688,7 +688,7 @@
|
||||
}
|
||||
|
||||
.retentionInfo:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-state-business-primary);
|
||||
outline: 2px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -788,7 +788,7 @@
|
||||
}
|
||||
|
||||
.ruleRows .ruleControl:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-state-business-primary);
|
||||
outline: 2px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
@@ -838,7 +838,7 @@
|
||||
}
|
||||
|
||||
.ruleValue:focus-visible .ruleValueFace {
|
||||
outline: 2px solid var(--dsw-alias-state-business-primary);
|
||||
outline: 2px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
@@ -1294,7 +1294,7 @@
|
||||
}
|
||||
|
||||
.linkedSession:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-state-business-primary);
|
||||
outline: 2px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
@@ -1362,7 +1362,7 @@
|
||||
}
|
||||
|
||||
.page :focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-state-business-primary);
|
||||
outline: 2px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
|
||||
@@ -1665,12 +1665,12 @@ describe('Task detail rule header and run-time card', () => {
|
||||
expect(stylesheet).toMatch(/\.ruleControl\s*\{[^}]*margin-right:\s*0;/)
|
||||
expect(stylesheet).toMatch(new RegExp([
|
||||
'\\.ruleRows \\.ruleControl:focus-visible\\s*\\{[^}]*',
|
||||
'outline:\\s*2px solid var\\(--dsw-alias-state-business-primary\\);',
|
||||
'outline:\\s*2px solid var\\(--dsw-focus-ring-color, var\\(--dsw-alias-state-business-primary\\)\\);',
|
||||
'[^}]*outline-offset:\\s*1px;',
|
||||
].join('')))
|
||||
expect(stylesheet).toMatch(new RegExp([
|
||||
'\\.ruleValue:focus-visible \\.ruleValueFace\\s*\\{[^}]*',
|
||||
'outline:\\s*2px solid var\\(--dsw-alias-state-business-primary\\);',
|
||||
'outline:\\s*2px solid var\\(--dsw-focus-ring-color, var\\(--dsw-alias-state-business-primary\\)\\);',
|
||||
'[^}]*outline-offset:\\s*1px;',
|
||||
].join('')))
|
||||
expect(stylesheet).toMatch(/\.ruleRows \.ruleValue:focus-visible\s*\{[^}]*outline:\s*none;/)
|
||||
@@ -3552,7 +3552,9 @@ describe('Task detail name and instruction edits', () => {
|
||||
const stylesheet = readFileSync(resolve(import.meta.dirname, '../src/client/TaskManagerPage.module.css'), 'utf8')
|
||||
expect(stylesheet).toMatch(/\.editName\s*\{[^}]*font-size:\s*20px;[^}]*font-weight:\s*500;/)
|
||||
expect(stylesheet).toMatch(/\.editName:hover\s*\{[^}]*box-shadow:\s*0 1px var\(--dsw-alias-border-l3\);/)
|
||||
expect(stylesheet).toMatch(/\.editName:focus\s*\{[^}]*box-shadow:\s*0 1px var\(--dsw-alias-state-business-primary\);/)
|
||||
expect(stylesheet).toMatch(
|
||||
/\.editName:focus\s*\{[^}]*box-shadow:\s*0 1px var\(--dsw-focus-ring-color, var\(--dsw-alias-state-business-primary\)\);/,
|
||||
)
|
||||
// The bar keeps the mock's 44px in the Tasks page's own detail column; the
|
||||
// right-panel placement states the cross size that lands its rule on the
|
||||
// conversation header's rule, and the actions keep the mock's -8px inset.
|
||||
|
||||
@@ -6,4 +6,4 @@
|
||||
.title { display: flex; align-items: flex-start; gap: 2px; line-height: 20px; font-weight: 500; }
|
||||
.icon { display: flex; flex: none; width: 14px; height: 14px; margin-top: 1px; color: var(--dsw-alias-brand-primary-new-colorprimary-new-color); }
|
||||
.close { position: absolute; top: 6px; right: 6px; display: flex; align-items: center; justify-content: center; width: 18px; height: 18px; padding: 4px; border: 0; background: transparent; color: var(--dsw-alias-label-caption); cursor: pointer; }
|
||||
.close:focus-visible { outline: 2px solid var(--dsw-alias-label-primary); outline-offset: 2px; border-radius: 4px; }
|
||||
.close:focus-visible { outline: 2px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: 2px; border-radius: 4px; }
|
||||
@@ -114,7 +114,7 @@
|
||||
}
|
||||
|
||||
.linkButton:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-button-primary-fill);
|
||||
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@
|
||||
}
|
||||
|
||||
.unavailableLink:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-button-primary-fill);
|
||||
outline: 2px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@@ -198,7 +198,7 @@
|
||||
from { --dsh-onboarding-border-angle: 90deg; }
|
||||
to { --dsh-onboarding-border-angle: 450deg; }
|
||||
}
|
||||
.card:focus-visible, .card:has(:focus-visible) { outline: 2px solid var(--dsw-alias-onboarding-accent); outline-offset: 5px; }
|
||||
.card:focus-visible, html:not([data-input-modality='pointer']) .card:has(:focus-visible) { outline: 2px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: 5px; }
|
||||
.card[aria-disabled='true'] { cursor: wait; }
|
||||
.cardTop { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; height: 40px; margin-bottom: 12px; }
|
||||
.cardCheckbox { position: static; }
|
||||
@@ -236,7 +236,7 @@
|
||||
|
||||
.navigation button { pointer-events: auto; }
|
||||
.navigation button { display: flex; align-items: center; gap: 20px; padding: 0; border: 0; background: transparent; color: var(--dsw-alias-label-tertiary); font: inherit; font-size: 16px; font-weight: 300; line-height: 32px; cursor: pointer; }
|
||||
.navigation button:focus-visible { outline: 2px solid var(--dsw-alias-onboarding-accent); outline-offset: 5px; }
|
||||
.navigation button:focus-visible { outline: 2px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: 5px; }
|
||||
.navigation button:hover:not(:disabled) { color: var(--dsw-alias-label-primary); }
|
||||
.backIcon { width: 17px; height: 14px; flex: none; background: currentColor; mask-size: contain; mask-repeat: no-repeat; mask-position: center; }
|
||||
.status { position: absolute; left: 50%; bottom: 12px; transform: translateX(-50%); font-size: 14px; line-height: 22px; }
|
||||
@@ -296,7 +296,7 @@
|
||||
}
|
||||
|
||||
.page .action:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-brand-primary);
|
||||
outline: 2px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
|
||||
@@ -216,7 +216,7 @@
|
||||
.iconButton:focus-visible,
|
||||
.customizedSummary:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--dsw-alias-border-l3);
|
||||
box-shadow: 0 0 0 2px var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
}
|
||||
|
||||
/* Editing surface: a filled module on the panel, matching the settings
|
||||
@@ -595,7 +595,7 @@ select.input {
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--dsw-alias-brand-primary);
|
||||
border-color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
|
||||
+5
-5
@@ -81,8 +81,8 @@
|
||||
}
|
||||
|
||||
.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);
|
||||
border-color: var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)) 18%, transparent);
|
||||
}
|
||||
|
||||
.catalogHeading {
|
||||
@@ -149,7 +149,7 @@
|
||||
}
|
||||
|
||||
.cardContent:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-state-business-primary);
|
||||
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
@@ -244,7 +244,7 @@
|
||||
}
|
||||
|
||||
.groupToggle:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-state-business-primary);
|
||||
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -302,7 +302,7 @@
|
||||
}
|
||||
|
||||
.switcher:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-state-business-primary);
|
||||
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
}
|
||||
|
||||
.tab:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-state-business-primary);
|
||||
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
|
||||
@@ -157,7 +157,7 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
.resetAll:enabled:hover { color: var(--dsw-alias-label-secondary); }
|
||||
.resetAll:focus-visible { outline: 1px solid var(--dsw-alias-label-tertiary); outline-offset: 2px; }
|
||||
.resetAll:focus-visible { outline: 1px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: 2px; }
|
||||
.resetAll:disabled { cursor: default; opacity: 0.45; }
|
||||
.modifiedCount { color: var(--dsw-alias-label-tertiary); }
|
||||
.group {
|
||||
@@ -185,9 +185,9 @@ p.hint { margin: 6px 0; }
|
||||
.rowActions { display: inline-flex; align-items: center; gap: 8px; opacity: 0; }
|
||||
.row:hover .rowActions, .row:has(:focus-visible) .rowActions { opacity: 1; }
|
||||
.iconButton { display: inline-grid; place-items: center; width: 20px; height: 26px; border: 0; padding: 0; background: transparent; color: var(--dsw-alias-label-tertiary); cursor: pointer; }
|
||||
.iconButton:focus-visible, .inlineAction:focus-visible { outline: 1px solid var(--dsw-alias-label-tertiary); outline-offset: 2px; border-radius: 4px; }
|
||||
.iconButton:focus-visible, .inlineAction:focus-visible { outline: 1px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: 2px; border-radius: 4px; }
|
||||
.keyButton { display: inline-flex; padding: 0; border: 0; border-radius: 10px; background: transparent; cursor: pointer; }
|
||||
.keyButton:focus-visible { outline: 1px solid var(--dsw-alias-label-tertiary); outline-offset: 2px; }
|
||||
.keyButton:focus-visible { outline: 1px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: 2px; }
|
||||
.keyButton:enabled:hover .keyBadge, .keyButton:enabled:hover .unbound { background: var(--dsw-alias-interactive-bg-hover-accent); }
|
||||
.unbound { display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; height: 26px; padding: 6px 16px; border-radius: 10px; background: var(--dsw-alias-bg-module-platform); color: var(--dsw-alias-label-tertiary); font-size: 12px; line-height: 14px; white-space: nowrap; }
|
||||
.inlineEditor { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
|
||||
@@ -195,7 +195,7 @@ p.hint { margin: 6px 0; }
|
||||
.inlineControls { display: flex; justify-content: flex-end; align-items: center; gap: 8px; }
|
||||
.inlineAction { padding: 0; border: 0; background: transparent; color: var(--dsw-alias-label-tertiary); font: inherit; font-size: 12px; line-height: 18px; white-space: nowrap; cursor: pointer; }
|
||||
.recorder { display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; min-width: 96px; height: 26px; padding: 3px 14px; border: 1px solid var(--dsw-alias-label-tertiary); border-radius: 10px; background: var(--dsw-alias-bg-module-platform); color: var(--dsw-alias-label-tertiary); font: inherit; font-size: 12px; line-height: 18px; white-space: nowrap; cursor: pointer; }
|
||||
.recorder:focus-visible { outline: 1px solid var(--dsw-alias-label-tertiary); outline-offset: 2px; }
|
||||
.recorder:focus-visible { outline: 1px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: 2px; }
|
||||
.recorder.invalid { border: 1px solid var(--dsw-alias-state-error-primary); box-shadow: none; }
|
||||
.recorded { height: auto; padding: 0; background: transparent; color: inherit; }
|
||||
.review { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; color: var(--dsw-alias-label-tertiary); font-size: 12px; line-height: 18px; }
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
}
|
||||
|
||||
.address:focus {
|
||||
outline: 1px solid var(--dsw-alias-brand-primary-new-colorprimary-new-color);
|
||||
outline: 1px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
|
||||
@@ -235,6 +235,9 @@
|
||||
padding: 0;
|
||||
margin-bottom: 12px;
|
||||
justify-content: flex-start;
|
||||
/* The settled rail's 36px toggle is wider than this 35px row; the base row's clip would cut
|
||||
the control's right edge, so the rail stops clipping once the slide has settled. */
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Brand group (figma I133:7632): the full wordmark rides the text ink
|
||||
@@ -332,6 +335,13 @@
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* The toggle is flush with .logoRow, which clips its own overflow, so an outward ring loses
|
||||
the pixels that leave the row. Draw this control's ring inside its own box instead. */
|
||||
.iconButton.toggle:focus-visible {
|
||||
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.collapsed .iconButton {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
@@ -491,7 +501,7 @@
|
||||
}
|
||||
|
||||
.panelRow:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-label-primary);
|
||||
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,8 +30,8 @@
|
||||
en: 8fbfdb050e880f9b
|
||||
zh: dd201f049036c5c4
|
||||
/deepseek-ai-dsh-client-ui-theme/understand-the-implementation/stylesheets:
|
||||
en: 1d398bcc78b09ef2
|
||||
zh: cc657eeb80b55a62
|
||||
en: b6d02b9fd488fed6
|
||||
zh: 466c428f1d111edb
|
||||
/deepseek-ai-dsh-client-ui-theme/understand-the-implementation/scrollbar-rebinding:
|
||||
en: bf76e7c34a331595
|
||||
zh: 5accd0260ef7c61e
|
||||
|
||||
@@ -55,7 +55,13 @@ The service owns theme and font-size state and publishes snapshots. The ui-layou
|
||||
|
||||
`base.css` owns the shared radius scale and settings-card material aliases. The material aliases resolve on `body`, alongside the active palette. Follow [Web styling](../../../docs/web-styling.md#corner-radii-and-settings-cards) when choosing component radii.
|
||||
|
||||
`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. `design-platform.css` also owns the code-diff fill aliases and their static alpha palette entries, plus the `--dsw-alias-file-diff-*` code, gutter, and marker palette for file comparisons; `shiki.css` owns syntax colors. Menu icons use`--dsw-alias-menu-icon`: neutral-bluish 800 in light mode and `label-primary-dimmed` in dark mode.
|
||||
`src/styles/` holds eight sheets imported in order by ui-theme's dynamic client entry: `base.css`, `corner-shape.css`, `design-platform.css`, `focus.css`, `onboarding.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. `design-platform.css` also owns the code-diff fill aliases and their static alpha palette entries, plus the `--dsw-alias-file-diff-*` code, gutter, and marker palette for file comparisons; `shiki.css` owns syntax colors.
|
||||
|
||||
[`focus.css`](src/styles/focus.css) provides a `:focus-visible` fallback that names the ring colour through `var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary))` and the standard width through `--dsw-focus-ring-width`, never the outline style — so a control that disables its outline stays paintless, and one that declares no ring keeps the standard geometry instead of Chromium's `auto 1px`. The theme resolves this blue to `#4176E6` in light mode and `#7AAAFF` in dark mode. 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.
|
||||
|
||||
Menu icons use `--dsw-alias-menu-icon`: neutral-bluish 800 in light mode and `label-primary-dimmed` in dark mode.
|
||||
|
||||
`base.css` suppresses only the outline of focused elements marked `data-dsh-automatic-focus` by the [primitive focus helper](../ui-primitives/README.md); ordinary keyboard focus styling, borders, shadows, and error states remain intact.
|
||||
|
||||
|
||||
@@ -55,7 +55,13 @@ kind: "package-reference"
|
||||
|
||||
`base.css` 持有共享圆角尺度与设置卡片材质别名。材质别名在 `body` 上随当前色板解析。组件圆角选择遵循 [Web 样式参考](../../../docs/web-styling.zh.md#corner-radii-and-settings-cards)。
|
||||
|
||||
`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。`design-platform.css` 还负责代码差异底色的别名及其静态透明度色阶,以及文件对比所用的 `--dsw-alias-file-diff-*` 代码区、行号区和标记配色;`shiki.css` 负责语法颜色。 菜单图标使用`--dsw-alias-menu-icon`:浅色模式为 neutral-bluish 800,深色模式为 `label-primary-dimmed`。
|
||||
`src/styles/` 下有八张样式表,由 ui-theme 的动态客户端 entry 依次导入:`base.css`、`corner-shape.css`、`design-platform.css`、`focus.css`、`onboarding.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。客户端 bundle 将其编译并注入为插件持有的全局样式,因此卸载与 HMR(热模块替换)会随 ui-theme 一同移除。`scrollbar.css` 消费 `--dsw-alias-scrollbar-*` token,必须排在声明这些 token 的 `design-platform.css` 之后。状态标记使用各自的语义状态 token。`design-platform.css` 还负责代码差异底色的别名及其静态透明度色阶,以及文件对比所用的 `--dsw-alias-file-diff-*` 代码区、行号区和标记配色;`shiki.css` 负责语法颜色。
|
||||
|
||||
[`focus.css`](src/styles/focus.css) 提供 `:focus-visible` 兜底:通过 `var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary))` 声明焦点环颜色,并通过 `--dsw-focus-ring-width` 声明标准宽度,但不声明轮廓样式——因此禁用轮廓的控件仍然不绘制,而没有自有焦点环的控件保持标准几何,而不是落到 Chromium 的 `auto 1px`。主题将该蓝色解析为浅色模式的 `#4176E6` 和深色模式的 `#7AAAFF`。组件轮廓与焦点环阴影使用同一颜色表达式,包括后代和伪元素上的环。`--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 焦点。
|
||||
|
||||
菜单图标使用 `--dsw-alias-menu-icon`:浅色模式为 neutral-bluish 800,深色模式为 `label-primary-dimmed`。
|
||||
|
||||
`base.css` 仅抑制[基础控件焦点工具](../ui-primitives/README.zh.md)通过 `data-dsh-automatic-focus` 标记的聚焦元素外轮廓线;正常键盘焦点样式、边框、阴影及错误状态保持不变。
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Context } from '@deepseek-ai/cordis'
|
||||
import base from '../styles/base.css?inline'
|
||||
import cornerShape from '../styles/corner-shape.css?inline'
|
||||
import designPlatform from '../styles/design-platform.css?inline'
|
||||
import focus from '../styles/focus.css?inline'
|
||||
import onboarding from '../styles/onboarding.css?inline'
|
||||
import scrollbar from '../styles/scrollbar.css?inline'
|
||||
import gradientShadowText from '../styles/gradient-shadow-text.css?inline'
|
||||
@@ -13,6 +14,7 @@ const STYLES = [
|
||||
['base.css', base],
|
||||
['corner-shape.css', cornerShape],
|
||||
['design-platform.css', designPlatform],
|
||||
['focus.css', focus],
|
||||
['onboarding.css', onboarding],
|
||||
['scrollbar.css', scrollbar],
|
||||
['gradient-shadow-text.css', gradientShadowText],
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/* Components own offsets; dense tables and toolbars may keep a 1px ring. */
|
||||
:root {
|
||||
--dsw-focus-ring-width: 2px;
|
||||
}
|
||||
|
||||
/* The fallback names the colour and the standard width, never the style: a control that
|
||||
* disables its outline keeps `none` and paints nothing. Without the width, Chromium's
|
||||
* unnamed ring stayed at `auto 1px` while every declared ring used the standard geometry.
|
||||
* Declared rings also read this color, including rings on descendants and pseudo-elements. */
|
||||
:focus-visible {
|
||||
outline-color: var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-width: var(--dsw-focus-ring-width);
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
@@ -23,6 +23,7 @@ describe('ui-theme client styles', () => {
|
||||
`${PLUGIN_ID}/base.css`,
|
||||
`${PLUGIN_ID}/corner-shape.css`,
|
||||
`${PLUGIN_ID}/design-platform.css`,
|
||||
`${PLUGIN_ID}/focus.css`,
|
||||
`${PLUGIN_ID}/onboarding.css`,
|
||||
`${PLUGIN_ID}/scrollbar.css`,
|
||||
`${PLUGIN_ID}/gradient-shadow-text.css`,
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/** 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 ACCENT = '--dsw-alias-state-business-primary'
|
||||
const COLOR = '--dsw-focus-ring-color'
|
||||
const WIDTH = '--dsw-focus-ring-width'
|
||||
const FALLBACK = `var(${COLOR}, var(${ACCENT}))`
|
||||
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([ACCENT, 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(ACCENT) || 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(ACCENT) && !tokens.includes(COLOR)) {
|
||||
violations.push(`${rule.selectors.join(', ')} bypasses ${COLOR}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
describe('focus styles', () => {
|
||||
it('defines standard geometry and a fallback that names colour and width but never style', () => {
|
||||
expect(rules.find(rule => rule.selectors.includes(':root'))?.declarations).toEqual([[WIDTH, '2px']])
|
||||
// Naming the width is what keeps an undeclared ring at the standard geometry instead of
|
||||
// Chromium's `auto 1px`; omitting the style is what keeps `outline: none` paintless.
|
||||
expect(rules.find(rule => rule.selectors.includes(':focus-visible'))?.declarations)
|
||||
.toEqual([['outline-color', FALLBACK], ['outline-width', `var(${WIDTH})`]])
|
||||
expect(rules.flatMap(rule => rule.declarations).map(([property]) => property)).not.toContain('outline-style')
|
||||
expect(rules.flatMap(rule => rule.declarations).map(([property]) => property)).not.toContain('outline')
|
||||
})
|
||||
|
||||
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-blue 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-brand-primary); }'))
|
||||
.toHaveLength(1)
|
||||
expect(ringColorViolations('.a:focus-visible .label { outline: 2px solid var(--dsw-alias-state-business-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([])
|
||||
})
|
||||
})
|
||||
@@ -253,4 +253,4 @@
|
||||
}
|
||||
|
||||
.path:hover { text-decoration: underline; }
|
||||
.path:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary); outline-offset: 3px; }
|
||||
.path:focus-visible { outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: 3px; }
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
.selected {
|
||||
border-color: transparent;
|
||||
box-shadow: inset 0 0 0 2px var(--dsw-alias-brand-primary-new-colorprimary-new-color);
|
||||
box-shadow: inset 0 0 0 2px var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.index {
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
}
|
||||
|
||||
.historyLoadButton:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-state-business-primary);
|
||||
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-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-state-business-primary);
|
||||
box-shadow: inset 0 0 0 1px var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
}
|
||||
|
||||
.table tbody tr[data-selected='true'] {
|
||||
@@ -282,7 +282,7 @@
|
||||
var(--dsw-alias-brand-primary-new-colorprimary-new-color) 18%,
|
||||
var(--dsw-alias-bg-layer-1)
|
||||
);
|
||||
box-shadow: 0 0 0 1.5px var(--dsw-alias-brand-primary-new-colorprimary-new-color);
|
||||
box-shadow: 0 0 0 1.5px var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.requestBoundaryControl[data-request-status='error']::before,
|
||||
@@ -734,7 +734,7 @@
|
||||
}
|
||||
|
||||
.table tbody tr[data-collapsed-summary]:focus-visible {
|
||||
box-shadow: inset 0 0 0 1px var(--dsw-alias-state-business-primary);
|
||||
box-shadow: inset 0 0 0 1px var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
}
|
||||
|
||||
.collapsedTurnContent {
|
||||
@@ -1001,7 +1001,7 @@
|
||||
|
||||
.close:focus-visible,
|
||||
.detailTab:focus-visible {
|
||||
outline: 1px solid var(--dsw-alias-state-business-primary);
|
||||
outline: 1px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
@@ -1089,7 +1089,7 @@
|
||||
|
||||
.overviewHierarchyNavLink:focus-visible {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
outline: 1px solid var(--dsw-alias-state-business-primary);
|
||||
outline: 1px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -1147,7 +1147,7 @@
|
||||
}
|
||||
|
||||
.timestampToggle:focus-visible {
|
||||
outline: 1px solid var(--dsw-alias-state-business-primary);
|
||||
outline: 1px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -1215,7 +1215,7 @@
|
||||
}
|
||||
|
||||
.overviewTitle:focus-visible {
|
||||
outline: 1px solid var(--dsw-alias-state-business-primary);
|
||||
outline: 1px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -1316,7 +1316,7 @@
|
||||
}
|
||||
|
||||
.programAction:focus-visible {
|
||||
outline: 1px solid var(--dsw-alias-state-business-primary);
|
||||
outline: 1px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
@@ -1521,7 +1521,7 @@
|
||||
}
|
||||
|
||||
.thinkingToggle:focus-visible {
|
||||
outline: 1px solid var(--dsw-alias-state-business-primary);
|
||||
outline: 1px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -1763,7 +1763,7 @@
|
||||
}
|
||||
|
||||
.attachmentDisclosure > summary:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-state-business-primary);
|
||||
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -1810,7 +1810,7 @@
|
||||
|
||||
.assistantToolCallButton:focus-visible {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
outline: 1px solid var(--dsw-alias-state-business-primary);
|
||||
outline: 1px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
@@ -1884,7 +1884,7 @@
|
||||
|
||||
.toolCatalogSummary:focus-visible {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
outline: 1px solid var(--dsw-alias-state-business-primary);
|
||||
outline: 1px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
}
|
||||
|
||||
.earlierHistory:focus-visible {
|
||||
box-shadow: inset 0 0 0 1px var(--dsw-alias-border-l2);
|
||||
box-shadow: inset 0 0 0 1px var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
}
|
||||
|
||||
.empty {
|
||||
@@ -111,7 +111,7 @@
|
||||
}
|
||||
|
||||
.track:focus-visible {
|
||||
outline: 1px solid var(--dsw-alias-state-business-primary);
|
||||
outline: 1px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
}
|
||||
|
||||
.toggle:focus-visible {
|
||||
outline: 1px solid var(--dsw-alias-state-business-primary);
|
||||
outline: 1px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
}
|
||||
|
||||
.control:focus-visible {
|
||||
outline: 1px solid var(--dsw-alias-state-business-primary);
|
||||
outline: 1px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@
|
||||
}
|
||||
|
||||
.action:focus-visible {
|
||||
outline: 1px solid var(--dsw-alias-state-business-primary);
|
||||
outline: 1px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
}
|
||||
|
||||
.runHeader:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-state-business-primary);
|
||||
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
}
|
||||
|
||||
.phaseHeader:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-state-business-primary);
|
||||
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: -2px;
|
||||
border-radius: var(--dsw-radius-md);
|
||||
}
|
||||
@@ -210,7 +210,7 @@
|
||||
}
|
||||
|
||||
.memberButton:focus-visible .memberLabelWrap {
|
||||
outline: 2px solid var(--dsw-alias-state-business-primary);
|
||||
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,15 @@
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* The action cluster animates its width and the search pill rounds its corners,
|
||||
* so both clip an outer ring; these controls keep theirs inside instead. */
|
||||
.iconButton:focus-visible,
|
||||
.searchButton:focus-visible,
|
||||
.clearButton:focus-visible {
|
||||
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.iconButton:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
@@ -47,6 +47,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",
|
||||
|
||||
Reference in new issue
Block a user