From 08b310b5a8e70300150671b54aa95e650ffa24ac Mon Sep 17 00:00:00 2001 From: mektpoy Date: Sun, 20 Sep 2026 15:58:49 +0800 Subject: [PATCH 01/36] fix(client): paint every keyboard focus ring with the brand colour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Focus rings named eight different colours across 54 client stylesheets, and a control that declared no ring fell back to the browser default, whose colour follows the operating-system accent — so the same build showed orange on one machine and blue on another. Every keyboard focus ring now uses --dsw-alias-brand-primary. A global ui-theme focus.css sheet reaches the controls that declare no ring of their own, and 49 declarations name the same token. Only the colour changed: each declaration keeps its width, style and offset. --- ...6-09-20-focus-ring-brand-primary.i18n.yaml | 6 ++++ .../2026-09-20-focus-ring-brand-primary.md | 33 +++++++++++++++++++ .../2026-09-20-focus-ring-brand-primary.zh.md | 33 +++++++++++++++++++ .../ui-attachment/src/MessageImage.module.css | 2 +- .../src/client/chat/MessageItem.module.css | 2 +- .../src/client/chat/TurnNavigator.module.css | 2 +- .../src/client/queue/QueueDock.module.css | 4 +-- .../src/client/ChangedFiles.module.css | 2 +- .../src/client/FeedbackDialog.module.css | 4 +-- .../src/client/ModelSelect.module.css | 2 +- .../src/client/PermissionSelect.module.css | 2 +- .../src/client/PlanModeControl.module.css | 2 +- .../ui-plan/src/client/PlanPreview.module.css | 4 +-- .../src/ConnectionIndicator.module.css | 2 +- .../ui-primitives/src/HoverCard.module.css | 2 +- .../ui-primitives/src/JsonTree.module.css | 4 +-- .../src/RiskConfirmation.module.css | 2 +- .../src/markdown/MarkdownText.module.css | 4 +-- .../src/client/ModelsSection.module.css | 2 +- .../PluginInventorySettingsTab.module.css | 6 ++-- .../client/PluginsSettingsSection.module.css | 2 +- .../src/client/view/Browser.module.css | 2 +- .../src/client/SidebarRoot.module.css | 2 +- packages/client/ui-theme/src/client/styles.ts | 2 ++ packages/client/ui-theme/src/styles/focus.css | 8 +++++ .../tests/client-styles.client.spec.ts | 1 + .../tool/components/ToolDetails.module.css | 2 +- .../src/client/TrajectoryTable.module.css | 26 +++++++-------- .../src/client/TrajectoryTimeline.module.css | 4 +-- .../src/client/TrajectoryToolbar.module.css | 6 ++-- .../src/client/WorkflowRunPanel.module.css | 6 ++-- 31 files changed, 132 insertions(+), 49 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.md create mode 100644 .agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.zh.md create mode 100644 packages/client/ui-theme/src/styles/focus.css diff --git a/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.i18n.yaml b/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.i18n.yaml new file mode 100644 index 0000000000..6707b51680 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.md +2026-09-20-focus-ring-brand-primary.md: 3c6c041ac04774e6f5f66085f4616467020bd40e +2026-09-20-focus-ring-brand-primary.zh.md: 541537c54e3ee1016c133f99557a0f2fa9204587 diff --git a/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.md b/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.md new file mode 100644 index 0000000000..3c6c041ac0 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.md @@ -0,0 +1,33 @@ +# Agent Note: One keyboard-focus colour for every control + +Status: implemented + +English | [中文](2026-09-20-focus-ring-brand-primary.zh.md) + +## Problem + +Keyboard focus rings carried at least five different colours across the client: `--dsw-alias-state-business-primary` on 31 declarations, `--dsw-alias-label-primary`, `--dsw-alias-label-tertiary`, `--dsw-alias-state-warn-label`, `--dsw-alias-border-l4` and two button fills elsewhere. Controls that declared no ring at all fell back to the browser default, whose colour follows the operating-system accent — so the same build shows orange on one machine and blue on another, and a control can change colour when nothing about the control changed. + +## Decision + +Every keyboard focus ring uses `--dsw-alias-brand-primary`. + +Two parts implement this, and both are required. `ui-theme/src/styles/focus.css` declares a global `:focus-visible { outline-color: var(--dsw-alias-brand-primary) }`, which reaches the controls that draw the browser default. The 49 component declarations that named another colour now name the same token. Only the colour changed: each declaration keeps its own width, style and offset. + +The global sheet sets `outline-color` alone. A component that owns a focus ring keeps it, because its `.class:focus-visible` selector outweighs the bare `:focus-visible` rule; a control with no ring of its own inherits the brand colour instead of the system accent. + +## Alternatives considered + +**Only rewrite the component declarations.** The 49 explicit rings would agree, but every control that relies on the browser default — the sidebar rail toggles among them — would still paint the operating-system accent, which is the visible symptom this change exists to remove. + +**Give each remaining control its own ring declaration.** This reaches the same result, but each new control reintroduces the defect until someone remembers to add a ring, and the shell styles would have to anticipate every interactive element it renders. + +**Unify width and offset as well.** The reported inconsistency included those, but the confirmed design decision covers colour. Width and offset stay per component until a design decision fixes them. + +**Replace every `outline: none` declaration with the shared ring.** Those declarations accompany deliberate alternatives (inset `box-shadow` rings, container-level focus treatment) or suppress focus on containers that must not show one. Removing them changes behaviour beyond colour. + +## Consequences + +The focus colour no longer tracks the operating-system accent, so a screen recording or screenshot is reproducible across machines. Components keep their geometry, and controls that never declared a ring now match those that did. The global rule is the only place that decides the fallback, so a future design change edits one token reference rather than every module. + +`ui-theme/tests/client-styles.client.spec.ts` pins the sheet in the injection order. `pnpm run test:gui` covers the client suites; the recorded Web replay covers the assembled browser. diff --git a/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.zh.md b/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.zh.md new file mode 100644 index 0000000000..541537c54e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.zh.md @@ -0,0 +1,33 @@ +# Agent Note:所有控件共用一个键盘焦点颜色 + +Status: implemented + +[English](2026-09-20-focus-ring-brand-primary.md) | 中文 + +## 问题 + +客户端的键盘焦点环至少使用了五种颜色:31 处声明用 `--dsw-alias-state-business-primary`,其余分散在 `--dsw-alias-label-primary`、`--dsw-alias-label-tertiary`、`--dsw-alias-state-warn-label`、`--dsw-alias-border-l4` 和两种按钮填充色上。完全没有声明焦点环的控件则回退到浏览器默认环,其颜色跟随操作系统强调色——同一个构建在一台机器上显示橙色、另一台显示蓝色,控件本身没变颜色却变了。 + +## 决策 + +所有键盘焦点环统一使用 `--dsw-alias-brand-primary`。 + +实现分两部分,缺一不可。`ui-theme/src/styles/focus.css` 声明全局 `:focus-visible { outline-color: var(--dsw-alias-brand-primary) }`,覆盖那些画浏览器默认环的控件;49 处原本指定了其它颜色的组件声明改为同一个 token。只改颜色:每条声明保留自己的宽度、样式和 offset。 + +全局规则只设置 `outline-color`。自带焦点环的组件不受影响,因为它的 `.class:focus-visible` 选择器权重高于裸 `:focus-visible` 规则;没有焦点环的控件则继承品牌色,而不是系统强调色。 + +## 考虑过的其他做法 + +**只重写组件声明。** 49 个显式焦点环会一致,但所有依赖浏览器默认环的控件——包括侧栏轨道的切换按钮——仍会画出操作系统强调色,而这正是本次改动要消除的可见症状。 + +**给剩下每个控件单独加焦点环声明。** 结果相同,但每新增一个控件都会重新引入缺陷,直到有人记得补环;壳层样式还得预判它渲染的每个可交互元素。 + +**连宽度和 offset 一起统一。** 反馈中的不一致确实包含这两项,但已确认的设计决策只覆盖颜色。在设计定下之前,宽度和 offset 保持逐组件。 + +**把所有 `outline: none` 改为共享焦点环。** 这些声明分别对应刻意的替代方案(内嵌 `box-shadow` 环、容器级焦点处理),或用于抑制本就不应显示焦点的容器。删除它们会改变颜色之外的行为。 + +## 影响 + +焦点颜色不再跟随操作系统强调色,因此录屏和截图在不同机器上可复现。组件保留自身几何形状,从未声明焦点环的控件现在与已声明的控件一致。全局规则是决定兜底颜色的唯一位置,未来的设计变更只需改一处 token 引用,而非每个模块。 + +`ui-theme/tests/client-styles.client.spec.ts` 固定了该样式表在注入顺序中的位置。`pnpm run test:gui` 覆盖客户端套件;录制式 Web 回放覆盖组装后的浏览器。 diff --git a/packages/client/ui-attachment/src/MessageImage.module.css b/packages/client/ui-attachment/src/MessageImage.module.css index f551605a4a..127856cf99 100644 --- a/packages/client/ui-attachment/src/MessageImage.module.css +++ b/packages/client/ui-attachment/src/MessageImage.module.css @@ -102,6 +102,6 @@ .frame:focus-visible, .error:focus-visible { - outline: 2px solid var(--dsw-alias-state-business-primary); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.module.css b/packages/client/ui-chat/src/client/chat/MessageItem.module.css index a8ccc480a8..1fb36599ce 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-chat/src/client/chat/MessageItem.module.css @@ -231,7 +231,7 @@ } .retrySummary:focus-visible { - outline: 1.5px solid var(--dsw-alias-button-info-fill); + outline: 1.5px solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css b/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css index 0352ff42d6..b01654cab0 100644 --- a/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css +++ b/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css @@ -139,7 +139,7 @@ inset: 0 0 0 auto; width: 20px; border-radius: inherit; - outline: 1px solid var(--dsw-alias-state-business-primary); + outline: 1px solid var(--dsw-alias-brand-primary); outline-offset: 2px; content: ''; } diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css index 516ba7e83a..1853f80ac9 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css @@ -77,7 +77,7 @@ } .header:focus-visible { - outline: 2px solid var(--dsw-alias-label-tertiary); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: -2px; } @@ -258,7 +258,7 @@ } .action:focus-visible { - outline: 2px solid var(--dsw-alias-label-tertiary); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: -2px; } diff --git a/packages/client/ui-deliverables/src/client/ChangedFiles.module.css b/packages/client/ui-deliverables/src/client/ChangedFiles.module.css index 536bed4f6d..57d19d4130 100644 --- a/packages/client/ui-deliverables/src/client/ChangedFiles.module.css +++ b/packages/client/ui-deliverables/src/client/ChangedFiles.module.css @@ -21,7 +21,7 @@ button.header:disabled { cursor: progress; } .list { margin: 0; padding: 0; list-style: none; border-top: 0.5px solid var(--dsw-alias-border-l2); } .row { display: flex; align-items: center; justify-content: space-between; gap: 10px; box-sizing: border-box; width: 100%; min-width: 0; min-height: 24px; margin: 0; padding: 7px 18px 7px 14px; border: 0; background: transparent; color: var(--dsw-alias-label-tertiary); cursor: pointer; font-family: var(--ds-font-family-code); font-size: 11px; line-height: 18px; text-align: left; } .row:hover:not(:disabled), .row:focus-visible { background: var(--dsw-alias-interactive-bg-hover); } -.row:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--dsw-alias-border-l3); } +.row:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--dsw-alias-brand-primary); } .row:disabled { cursor: progress; } .path { min-width: 0; overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } .counts { display: inline-flex; flex: none; gap: 6px; white-space: nowrap; color: var(--dsw-alias-label-tertiary); } diff --git a/packages/client/ui-message-feedback/src/client/FeedbackDialog.module.css b/packages/client/ui-message-feedback/src/client/FeedbackDialog.module.css index af2cacebe7..ee2cc6ee16 100644 --- a/packages/client/ui-message-feedback/src/client/FeedbackDialog.module.css +++ b/packages/client/ui-message-feedback/src/client/FeedbackDialog.module.css @@ -39,7 +39,7 @@ } .chip:focus-visible { - outline: 2px solid var(--dsw-alias-button-primary-fill); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; } @@ -79,7 +79,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-brand-primary); } .submit { diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.module.css b/packages/client/ui-model-selection/src/client/ModelSelect.module.css index 799c10776c..c3a406d72e 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.module.css +++ b/packages/client/ui-model-selection/src/client/ModelSelect.module.css @@ -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-alias-brand-primary); } .trigger:disabled { diff --git a/packages/client/ui-permission-presets/src/client/PermissionSelect.module.css b/packages/client/ui-permission-presets/src/client/PermissionSelect.module.css index 32cc84dd14..00b48a345b 100644 --- a/packages/client/ui-permission-presets/src/client/PermissionSelect.module.css +++ b/packages/client/ui-permission-presets/src/client/PermissionSelect.module.css @@ -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-alias-brand-primary); } .trigger:disabled { diff --git a/packages/client/ui-plan/src/client/PlanModeControl.module.css b/packages/client/ui-plan/src/client/PlanModeControl.module.css index 793ca4e5d6..43664921b4 100644 --- a/packages/client/ui-plan/src/client/PlanModeControl.module.css +++ b/packages/client/ui-plan/src/client/PlanModeControl.module.css @@ -34,7 +34,7 @@ } .chip:focus-visible { - outline: 2px solid var(--dsw-alias-state-business-primary); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-plan/src/client/PlanPreview.module.css b/packages/client/ui-plan/src/client/PlanPreview.module.css index d63529070b..037803f7f2 100644 --- a/packages/client/ui-plan/src/client/PlanPreview.module.css +++ b/packages/client/ui-plan/src/client/PlanPreview.module.css @@ -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: 2px solid var(--dsw-alias-brand-primary); outline-offset: 3px; } .cardIcon { box-sizing: border-box; flex: none; display: grid; place-items: center; width: 40px; height: 40px; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 10px; background: var(--plan-card-fill); } .cardDetails { display: flex; flex: 1; flex-direction: column; gap: 2px; min-width: 0; } .cardTitle { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 500; line-height: 20px; } @@ -30,7 +30,7 @@ .cardOpen { box-sizing: border-box; display: inline-flex; flex: none; align-items: center; height: 28px; padding: 4px 8px; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 10px; background: var(--dsw-alias-button-floating-fill); font-size: 12px; line-height: 18px; } .reviewLink { display: inline-flex; align-items: center; gap: 4px; padding: 0; border: 0; background: transparent; color: var(--dsw-alias-label-secondary); font: inherit; cursor: pointer; } .reviewLink:hover { color: var(--dsw-alias-label-primary); } -.reviewLink:focus-visible { outline: 2px solid var(--dsw-alias-label-primary); outline-offset: 3px; } +.reviewLink:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 3px; } .preview { position: relative; height: 100%; overflow: auto; padding: 20px 24px 40px; box-sizing: border-box; } .document { color: var(--dsw-alias-label-primary); font-size: 14px; line-height: 1.75; overflow-wrap: anywhere; } .message { padding: 24px; color: var(--dsw-alias-label-secondary); font-size: 14px; } diff --git a/packages/client/ui-primitives/src/ConnectionIndicator.module.css b/packages/client/ui-primitives/src/ConnectionIndicator.module.css index e559fbca0e..d65fcc82d2 100644 --- a/packages/client/ui-primitives/src/ConnectionIndicator.module.css +++ b/packages/client/ui-primitives/src/ConnectionIndicator.module.css @@ -56,7 +56,7 @@ } .warning:focus-visible { - outline: 2px solid var(--dsw-alias-state-warn-label); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-primitives/src/HoverCard.module.css b/packages/client/ui-primitives/src/HoverCard.module.css index 8d425ca85c..c4164cd18f 100644 --- a/packages/client/ui-primitives/src/HoverCard.module.css +++ b/packages/client/ui-primitives/src/HoverCard.module.css @@ -27,7 +27,7 @@ } .copyable:focus-visible { - outline: 2px solid var(--dsw-alias-state-business-primary); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-primitives/src/JsonTree.module.css b/packages/client/ui-primitives/src/JsonTree.module.css index 9abbc23bdc..f348c5c82c 100644 --- a/packages/client/ui-primitives/src/JsonTree.module.css +++ b/packages/client/ui-primitives/src/JsonTree.module.css @@ -187,7 +187,7 @@ } .stringToggle:focus-visible { - outline: 1px solid var(--dsw-alias-state-business-primary); + outline: 1px solid var(--dsw-alias-brand-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-alias-brand-primary); outline-offset: -1px; } diff --git a/packages/client/ui-primitives/src/RiskConfirmation.module.css b/packages/client/ui-primitives/src/RiskConfirmation.module.css index ae45ba9e00..fcba264b38 100644 --- a/packages/client/ui-primitives/src/RiskConfirmation.module.css +++ b/packages/client/ui-primitives/src/RiskConfirmation.module.css @@ -56,7 +56,7 @@ } .acknowledgement input:focus-visible { - outline: 2px solid var(--dsw-alias-border-l4); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css index db318ac6eb..edef6040ae 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css @@ -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-alias-brand-primary); } /* Leading link-category glyph inside anchors, file mentions, and @@ -220,7 +220,7 @@ the sheet's link focus color. */ .tableScroll:focus-visible { outline: none; - box-shadow: 0 0 0 2px var(--dsw-alias-state-business-primary); + box-shadow: 0 0 0 2px var(--dsw-alias-brand-primary); } /* Many-column tables (the renderer's `md-table-wide` hook) keep their diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.module.css b/packages/client/ui-settings-models/src/client/ModelsSection.module.css index 15b8933fa3..071b2523c5 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.module.css +++ b/packages/client/ui-settings-models/src/client/ModelsSection.module.css @@ -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-alias-brand-primary); } /* Editing surface: a filled module on the panel, matching the settings diff --git a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css index bbf984fb20..510120d2e1 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css +++ b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css @@ -151,7 +151,7 @@ } .cardContent:focus-visible { - outline: 2px solid var(--dsw-alias-state-business-primary); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: -2px; } @@ -239,7 +239,7 @@ } .groupToggle:focus-visible { - outline: 2px solid var(--dsw-alias-state-business-primary); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; } @@ -297,7 +297,7 @@ } .switcher:focus-visible { - outline: 2px solid var(--dsw-alias-state-business-primary); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.module.css b/packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.module.css index bd6006ea56..c0f617575d 100644 --- a/packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.module.css +++ b/packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.module.css @@ -58,7 +58,7 @@ } .tab:focus-visible { - outline: 2px solid var(--dsw-alias-state-business-primary); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; border-radius: 2px; color: var(--dsw-alias-label-primary); diff --git a/packages/client/ui-sidebar-browser/src/client/view/Browser.module.css b/packages/client/ui-sidebar-browser/src/client/view/Browser.module.css index 0914d8e6f5..fa1340f2d3 100644 --- a/packages/client/ui-sidebar-browser/src/client/view/Browser.module.css +++ b/packages/client/ui-sidebar-browser/src/client/view/Browser.module.css @@ -108,7 +108,7 @@ } .address:focus { - outline: 1px solid var(--dsw-alias-brand-primary-new-colorprimary-new-color); + outline: 1px solid var(--dsw-alias-brand-primary); outline-offset: -1px; } diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index 144070ba3f..c99c729fbb 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -493,7 +493,7 @@ } .panelRow:focus-visible { - outline: 2px solid var(--dsw-alias-label-primary); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: -2px; } diff --git a/packages/client/ui-theme/src/client/styles.ts b/packages/client/ui-theme/src/client/styles.ts index 2f7c97fd4b..4da44d9696 100644 --- a/packages/client/ui-theme/src/client/styles.ts +++ b/packages/client/ui-theme/src/client/styles.ts @@ -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 scrollbar from '../styles/scrollbar.css?inline' import gradientShadowText from '../styles/gradient-shadow-text.css?inline' import shiki from '../styles/shiki.css?inline' @@ -12,6 +13,7 @@ const STYLES = [ ['base.css', base], ['corner-shape.css', cornerShape], ['design-platform.css', designPlatform], + ['focus.css', focus], ['scrollbar.css', scrollbar], ['gradient-shadow-text.css', gradientShadowText], ['shiki.css', shiki], diff --git a/packages/client/ui-theme/src/styles/focus.css b/packages/client/ui-theme/src/styles/focus.css new file mode 100644 index 0000000000..6837e76b1b --- /dev/null +++ b/packages/client/ui-theme/src/styles/focus.css @@ -0,0 +1,8 @@ +/* One keyboard-focus colour for every control. A control that declares its own + * focus ring sets the colour explicitly, so this sheet reaches only the ones + * that would otherwise paint the browser default — whose colour follows the + * operating-system accent and therefore differs per machine. Declaring + * `outline-color` alone preserves each control's own width, style and offset. */ +:focus-visible { + outline-color: var(--dsw-alias-brand-primary); +} diff --git a/packages/client/ui-theme/tests/client-styles.client.spec.ts b/packages/client/ui-theme/tests/client-styles.client.spec.ts index 9e5cb8a7bd..162d6d042f 100644 --- a/packages/client/ui-theme/tests/client-styles.client.spec.ts +++ b/packages/client/ui-theme/tests/client-styles.client.spec.ts @@ -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}/scrollbar.css`, `${PLUGIN_ID}/gradient-shadow-text.css`, `${PLUGIN_ID}/shiki.css`, diff --git a/packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css b/packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css index 824bd18fde..d5442160be 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css +++ b/packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css @@ -255,4 +255,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: 2px solid var(--dsw-alias-brand-primary); outline-offset: 3px; } diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css index b07fbe48a2..594c0b3570 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css @@ -75,7 +75,7 @@ } .historyLoadButton:focus-visible { - outline: 2px solid var(--dsw-alias-state-business-primary); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: -2px; } @@ -206,7 +206,7 @@ } .table tbody tr:not([data-collapsed-summary]):focus-visible { - box-shadow: inset 0 0 0 1px var(--dsw-alias-state-business-primary); + box-shadow: inset 0 0 0 1px var(--dsw-alias-brand-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-brand-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-alias-brand-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-alias-brand-primary); outline-offset: -1px; } @@ -1089,7 +1089,7 @@ .overviewHierarchyNavLink:focus-visible { color: var(--dsw-alias-label-primary); - outline: 1px solid var(--dsw-alias-state-business-primary); + outline: 1px solid var(--dsw-alias-brand-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-alias-brand-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-alias-brand-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-alias-brand-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-alias-brand-primary); outline-offset: 2px; } @@ -1763,7 +1763,7 @@ } .attachmentDisclosure > summary:focus-visible { - outline: 2px solid var(--dsw-alias-state-business-primary); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 2px; } @@ -1810,7 +1810,7 @@ .assistantToolCallButton:focus-visible { background: var(--dsw-alias-interactive-bg-hover); - outline: 1px solid var(--dsw-alias-state-business-primary); + outline: 1px solid var(--dsw-alias-brand-primary); outline-offset: -1px; } @@ -1884,7 +1884,7 @@ .toolCatalogSummary:focus-visible { background: var(--dsw-alias-interactive-bg-hover); - outline: 1px solid var(--dsw-alias-state-business-primary); + outline: 1px solid var(--dsw-alias-brand-primary); outline-offset: -1px; } diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css index a10fffb9d1..f78dc63472 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css @@ -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-alias-brand-primary); } .empty { @@ -111,7 +111,7 @@ } .track:focus-visible { - outline: 1px solid var(--dsw-alias-state-business-primary); + outline: 1px solid var(--dsw-alias-brand-primary); outline-offset: -1px; } diff --git a/packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css b/packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css index c286665666..ad7f485f08 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css @@ -52,7 +52,7 @@ } .toggle:focus-visible { - outline: 1px solid var(--dsw-alias-state-business-primary); + outline: 1px solid var(--dsw-alias-brand-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-alias-brand-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-alias-brand-primary); outline-offset: 1px; } diff --git a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css index 190f136f70..c7875b946c 100644 --- a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css +++ b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css @@ -17,7 +17,7 @@ } .runHeader:focus-visible { - outline: 2px solid var(--dsw-alias-state-business-primary); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: -2px; } @@ -80,7 +80,7 @@ } .phaseHeader:focus-visible { - outline: 2px solid var(--dsw-alias-state-business-primary); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: -2px; border-radius: 4px; } @@ -210,7 +210,7 @@ } .memberButton:focus-visible .memberLabelWrap { - outline: 2px solid var(--dsw-alias-state-business-primary); + outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: -1px; } From 9e7350bb8c91b763e65b868c8888f5fb17f0d651 Mon Sep 17 00:00:00 2001 From: mektpoy Date: Sun, 20 Sep 2026 17:05:51 +0800 Subject: [PATCH 02/36] fix(client): keep a pointer's focus silent until focus navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking a control leaves it focused with :focus-visible false, so nothing is drawn. Chromium then reveals the ring the moment any key arrives — a modifier, Escape, a typed character — which is the unexplained focus ring users report: they never navigated there, yet a ring appears under the pointer's last target. Record the input modality on the document element, publish the keyboard modality only for focus navigation (Tab, arrow keys), and let the global focus sheet keep a pointer's focus transparent. Tooltip's pointer modality moves into the same module so tooltips and rings follow one rule. --- packages/client/ui-primitives/src/Tooltip.tsx | 15 +------ .../ui-primitives/src/input-modality.ts | 42 +++++++++++++++++++ .../tests/input-modality.client.spec.ts | 30 +++++++++++++ packages/client/ui-theme/src/styles/focus.css | 9 ++++ 4 files changed, 83 insertions(+), 13 deletions(-) create mode 100644 packages/client/ui-primitives/src/input-modality.ts create mode 100644 packages/client/ui-primitives/tests/input-modality.client.spec.ts diff --git a/packages/client/ui-primitives/src/Tooltip.tsx b/packages/client/ui-primitives/src/Tooltip.tsx index 688a411fb9..56e96e3894 100644 --- a/packages/client/ui-primitives/src/Tooltip.tsx +++ b/packages/client/ui-primitives/src/Tooltip.tsx @@ -4,6 +4,7 @@ import { cloneElement, createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react' import type { FocusEventHandler, MouseEventHandler, MutableRefObject, ReactElement, Ref } from 'react' import css from './Tooltip.module.css' +import { pointerModality } from './input-modality.ts' /** Bubble placement relative to the anchor. */ export type TooltipSide = 'right' | 'bottom' | 'top' @@ -27,18 +28,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. @@ -194,7 +183,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() }, })} {visible && !suppressed && ( diff --git a/packages/client/ui-primitives/src/input-modality.ts b/packages/client/ui-primitives/src/input-modality.ts new file mode 100644 index 0000000000..039c2c2319 --- /dev/null +++ b/packages/client/ui-primitives/src/input-modality.ts @@ -0,0 +1,42 @@ +/** + * Last-input modality for the whole document. 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 stay silent, while keyboard focus + * must not. Capture-phase window listeners record the modality, and only focus + * navigation returns the document to the keyboard — a stray modifier or Escape + * is not a reason to reveal the pointer's focus. + * + * The guard keeps the module loadable where no window exists (node-side imports + * of the package's pure helpers). + */ + +/** Modality values published on the document element. */ +export const INPUT_MODALITY = { pointer: 'pointer', keyboard: 'keyboard' } as const + +/** Attribute carrying the current modality, read by the global focus sheet. */ +export const INPUT_MODALITY_ATTRIBUTE = 'data-input-modality' + +let pointer = false + +/** Navigation keys move focus; every other key must not reveal existing focus. */ +function isFocusNavigation(event: KeyboardEvent): boolean { + return event.key === 'Tab' || event.key.startsWith('Arrow') +} + +function publish(): void { + document.documentElement.setAttribute(INPUT_MODALITY_ATTRIBUTE, pointer ? INPUT_MODALITY.pointer : INPUT_MODALITY.keyboard) +} + +/** Whether the last input able to own focus came from a pointer. */ +export function pointerModality(): boolean { + return pointer +} + +if (typeof window !== 'undefined') { + window.addEventListener('pointerdown', () => { pointer = true; publish() }, true) + window.addEventListener('keydown', (event) => { + if (!isFocusNavigation(event)) return + pointer = false + publish() + }, true) +} diff --git a/packages/client/ui-primitives/tests/input-modality.client.spec.ts b/packages/client/ui-primitives/tests/input-modality.client.spec.ts new file mode 100644 index 0000000000..a36d818cb4 --- /dev/null +++ b/packages/client/ui-primitives/tests/input-modality.client.spec.ts @@ -0,0 +1,30 @@ +// @vitest-environment jsdom +/** The document modality that decides whether focus may reveal itself. */ +import { describe, expect, it } from 'vitest' +import { INPUT_MODALITY, INPUT_MODALITY_ATTRIBUTE, pointerModality } from '../src/input-modality.ts' + +const press = (key: string): void => { window.dispatchEvent(new KeyboardEvent('keydown', { key })) } +const click = (): void => { window.dispatchEvent(new Event('pointerdown')) } + +describe('input modality', () => { + it('keeps a pointer focused control silent through keys that do not navigate', () => { + click() + expect(pointerModality()).toBe(true) + expect(document.documentElement.getAttribute(INPUT_MODALITY_ATTRIBUTE)).toBe(INPUT_MODALITY.pointer) + + for (const key of ['Shift', 'Escape', 'a', 'Control', 'Meta', 'Enter', ' ']) press(key) + expect(pointerModality()).toBe(true) + expect(document.documentElement.getAttribute(INPUT_MODALITY_ATTRIBUTE)).toBe(INPUT_MODALITY.pointer) + }) + + it('returns to the keyboard modality on focus navigation', () => { + click() + press('Tab') + expect(pointerModality()).toBe(false) + expect(document.documentElement.getAttribute(INPUT_MODALITY_ATTRIBUTE)).toBe(INPUT_MODALITY.keyboard) + + click() + press('ArrowDown') + expect(pointerModality()).toBe(false) + }) +}) diff --git a/packages/client/ui-theme/src/styles/focus.css b/packages/client/ui-theme/src/styles/focus.css index 6837e76b1b..90296e5d78 100644 --- a/packages/client/ui-theme/src/styles/focus.css +++ b/packages/client/ui-theme/src/styles/focus.css @@ -6,3 +6,12 @@ :focus-visible { outline-color: var(--dsw-alias-brand-primary); } + +/* A pointer keeps its focus silent. Clicking a control leaves it focused with no + * visible ring, and a later stray key — a modifier, Escape, a typed character — + * must not be the moment that ring appears; the user never navigated there. + * Only focus navigation publishes the keyboard modality that reveals a ring + * again. Controls that own a ring keep their own colour and stay unaffected. */ +html[data-input-modality='pointer'] :focus-visible { + outline-color: transparent; +} From db9297014667d0074cdc4e71b660baf58a15f1d2 Mon Sep 17 00:00:00 2001 From: mektpoy Date: Sun, 20 Sep 2026 17:08:43 +0800 Subject: [PATCH 03/36] fix(client): keep focus rings inside the containers that clip them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The breadcrumb trail and the Miller column scroll, and the column's left edge carries no padding, so an outer ring on their buttons was clipped to a sliver. The TurnNavigator marker sits at the scroller's right edge, which clipped three of its four sides. Ring those inside the element instead — the direction issue #4655 allows — and change no layout value. --- .../ui-chat/src/client/chat/TurnNavigator.module.css | 3 ++- .../src/client/DirectoryBrowser.module.css | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css b/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css index b01654cab0..29065d039d 100644 --- a/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css +++ b/packages/client/ui-chat/src/client/chat/TurnNavigator.module.css @@ -140,7 +140,8 @@ width: 20px; border-radius: inherit; outline: 1px solid var(--dsw-alias-brand-primary); - outline-offset: 2px; + /* The scroller clips the right edge; keep the marker's ring inside it. */ + outline-offset: -1px; content: ''; } diff --git a/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.module.css b/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.module.css index 5e3bbb3775..666293d3da 100644 --- a/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.module.css +++ b/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -121,6 +121,12 @@ cursor: pointer; } +.crumb:focus-visible { + /* The trail scrolls, so an outer ring would be clipped; keep it inside. */ + outline: 2px solid var(--dsw-alias-brand-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: 2px solid var(--dsw-alias-brand-primary); + outline-offset: -2px; +} + .row:hover { background: var(--dsw-alias-interactive-bg-hover); } From 9a74ba5d08a60ea8ce3cb252ecd07fc1ace55f7b Mon Sep 17 00:00:00 2001 From: mektpoy Date: Sun, 20 Sep 2026 17:26:14 +0800 Subject: [PATCH 04/36] fix(client): give tooltips and focus rings the modality rule each needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tooltip asks what the last input was: after any key the user is working from the keyboard, so a programmatic focus may raise a bubble. A focus ring asks something narrower — Chromium reveals an already-focused control as soon as any key arrives, including a modifier or Escape that never navigated anywhere, which is the ring users report as unexplained. Record both answers and publish the ring's narrower one, so tooltips keep their existing behaviour and rings stay silent for a pointer's focus. --- .../ui-primitives/src/input-modality.ts | 34 ++++++++++++------- .../tests/input-modality.client.spec.ts | 30 ++++++++++------ 2 files changed, 41 insertions(+), 23 deletions(-) diff --git a/packages/client/ui-primitives/src/input-modality.ts b/packages/client/ui-primitives/src/input-modality.ts index 039c2c2319..8fdc4a0101 100644 --- a/packages/client/ui-primitives/src/input-modality.ts +++ b/packages/client/ui-primitives/src/input-modality.ts @@ -1,22 +1,26 @@ /** - * Last-input modality for the whole document. 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 stay silent, while keyboard focus - * must not. Capture-phase window listeners record the modality, and only focus - * navigation returns the document to the keyboard — a stray modifier or Escape - * is not a reason to reveal the pointer's focus. + * Input modality for the whole document, under the two rules its consumers need. * - * The guard keeps the module loadable where no window exists (node-side imports - * of the package's pure helpers). + * A tooltip asks what the last input was: after any key the user is working from + * the keyboard, so a programmatic focus may raise a bubble. A focus ring asks + * something narrower — a pointer's focus must stay invisible — because Chromium + * reveals an already-focused control as soon as any key arrives, including a + * modifier or Escape that never navigated anywhere. Only focus navigation + * returns that second answer to the keyboard. + * + * Both are recorded from capture-phase window listeners. The guard keeps the + * module loadable where no window exists (node-side imports of the package's + * pure helpers). */ /** Modality values published on the document element. */ export const INPUT_MODALITY = { pointer: 'pointer', keyboard: 'keyboard' } as const -/** Attribute carrying the current modality, read by the global focus sheet. */ +/** Attribute carrying whether focus navigation last owned focus. */ export const INPUT_MODALITY_ATTRIBUTE = 'data-input-modality' let pointer = false +let pointerOwnsFocus = false /** Navigation keys move focus; every other key must not reveal existing focus. */ function isFocusNavigation(event: KeyboardEvent): boolean { @@ -24,7 +28,7 @@ function isFocusNavigation(event: KeyboardEvent): boolean { } function publish(): void { - document.documentElement.setAttribute(INPUT_MODALITY_ATTRIBUTE, pointer ? INPUT_MODALITY.pointer : INPUT_MODALITY.keyboard) + document.documentElement.setAttribute(INPUT_MODALITY_ATTRIBUTE, pointerOwnsFocus ? INPUT_MODALITY.pointer : INPUT_MODALITY.keyboard) } /** Whether the last input able to own focus came from a pointer. */ @@ -33,10 +37,16 @@ export function pointerModality(): boolean { } if (typeof window !== 'undefined') { - window.addEventListener('pointerdown', () => { pointer = true; publish() }, true) + window.addEventListener('pointerdown', () => { + pointer = true + pointerOwnsFocus = true + publish() + }, true) window.addEventListener('keydown', (event) => { - if (!isFocusNavigation(event)) return + // Any key returns tooltips to the keyboard; only navigation reveals a ring. pointer = false + if (!isFocusNavigation(event)) return + pointerOwnsFocus = false publish() }, true) } diff --git a/packages/client/ui-primitives/tests/input-modality.client.spec.ts b/packages/client/ui-primitives/tests/input-modality.client.spec.ts index a36d818cb4..acf032cee7 100644 --- a/packages/client/ui-primitives/tests/input-modality.client.spec.ts +++ b/packages/client/ui-primitives/tests/input-modality.client.spec.ts @@ -1,30 +1,38 @@ // @vitest-environment jsdom -/** The document modality that decides whether focus may reveal itself. */ +/** The two modality answers: what a tooltip reads, and what a focus ring reads. */ import { describe, expect, it } from 'vitest' import { INPUT_MODALITY, INPUT_MODALITY_ATTRIBUTE, pointerModality } from '../src/input-modality.ts' const press = (key: string): void => { window.dispatchEvent(new KeyboardEvent('keydown', { key })) } const click = (): void => { window.dispatchEvent(new Event('pointerdown')) } +const published = (): string | null => document.documentElement.getAttribute(INPUT_MODALITY_ATTRIBUTE) describe('input modality', () => { - it('keeps a pointer focused control silent through keys that do not navigate', () => { + it('returns tooltips to the keyboard on any key', () => { click() expect(pointerModality()).toBe(true) - expect(document.documentElement.getAttribute(INPUT_MODALITY_ATTRIBUTE)).toBe(INPUT_MODALITY.pointer) - - for (const key of ['Shift', 'Escape', 'a', 'Control', 'Meta', 'Enter', ' ']) press(key) - expect(pointerModality()).toBe(true) - expect(document.documentElement.getAttribute(INPUT_MODALITY_ATTRIBUTE)).toBe(INPUT_MODALITY.pointer) + for (const key of ['Shift', 'Escape', 'a', 'Enter']) { + click() + press(key) + expect(pointerModality()).toBe(false) + } }) - it('returns to the keyboard modality on focus navigation', () => { + it('keeps a pointer focused control silent through keys that do not navigate', () => { + click() + expect(published()).toBe(INPUT_MODALITY.pointer) + + for (const key of ['Shift', 'Escape', 'a', 'Control', 'Meta', 'Enter', ' ']) press(key) + expect(published()).toBe(INPUT_MODALITY.pointer) + }) + + it('returns the ring to the keyboard on focus navigation', () => { click() press('Tab') - expect(pointerModality()).toBe(false) - expect(document.documentElement.getAttribute(INPUT_MODALITY_ATTRIBUTE)).toBe(INPUT_MODALITY.keyboard) + expect(published()).toBe(INPUT_MODALITY.keyboard) click() press('ArrowDown') - expect(pointerModality()).toBe(false) + expect(published()).toBe(INPUT_MODALITY.keyboard) }) }) From 5a7b2e6f33fd9ff891f5ddf9da326a6a092cf5af Mon Sep 17 00:00:00 2001 From: mektpoy Date: Sun, 20 Sep 2026 18:44:58 +0800 Subject: [PATCH 05/36] fix(client): ring the workspace header controls inside their clipping boxes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The action cluster animates its width with overflow hidden, and the search pill clips to its rounded shape, so an outer ring on the add, search and clear controls was cut to a sliver. This package declared no focus style at all — every control fell back to the browser default inside those boxes. Ring the three controls inside the element, the direction issue #4655 allows. --- .../src/client/rows/WorkspaceBrowser.module.css | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css index 930f492b94..7340b2d7b8 100644 --- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css @@ -30,6 +30,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: 2px solid var(--dsw-alias-brand-primary); + outline-offset: -2px; +} + .iconButton:hover { background: var(--dsw-alias-interactive-bg-hover); } From 65ba30ab4f4e68c9d0e55da079b11e67c5a73113 Mon Sep 17 00:00:00 2001 From: mektpoy Date: Sun, 20 Sep 2026 18:47:45 +0800 Subject: [PATCH 06/36] refactor(client): name the standard focus-ring width Every ring that used 2px now reads --dsw-focus-ring-width. The dense tables and toolbars keep their 1px ring on purpose: at 2px they visibly outweigh the rows they sit between, so the standard value and the dense exception stay distinct rather than converging on a number nobody measured. --- .../src/client/AgentPresetSection.module.css | 6 +++--- .../client/ui-attachment/src/MessageImage.module.css | 2 +- .../src/client/queue/QueueDock.module.css | 4 ++-- .../src/client/DirectoryBrowser.module.css | 4 ++-- .../src/client/FeedbackDialog.module.css | 2 +- .../ui-plan/src/client/PlanModeControl.module.css | 2 +- .../client/ui-plan/src/client/PlanPreview.module.css | 4 ++-- .../src/client/PluginManagerPage.module.css | 10 +++++----- packages/client/ui-primitives/src/Checkbox.module.css | 2 +- .../ui-primitives/src/ConnectionIndicator.module.css | 2 +- packages/client/ui-primitives/src/HoverCard.module.css | 2 +- .../ui-primitives/src/RiskConfirmation.module.css | 2 +- packages/client/ui-primitives/src/Switch.module.css | 2 +- .../src/client/PluginInventorySettingsTab.module.css | 6 +++--- .../src/client/PluginConfigForm.module.css | 2 +- .../src/client/PluginsSettingsSection.module.css | 2 +- .../ui-settings-plugins/src/client/fields.module.css | 2 +- .../ui-sidebar/src/client/SidebarRoot.module.css | 2 +- packages/client/ui-theme/src/styles/focus.css | 7 +++++++ .../src/client/tool/components/ToolDetails.module.css | 2 +- .../src/client/TrajectoryTable.module.css | 4 ++-- .../src/client/WorkflowRunPanel.module.css | 6 +++--- .../src/client/rows/WorkspaceBrowser.module.css | 2 +- 23 files changed, 43 insertions(+), 36 deletions(-) diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css index 91111b0682..2c467892a4 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css @@ -184,7 +184,7 @@ } .cardMain:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: -2px; } @@ -224,7 +224,7 @@ } .cardMain:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: -2px; } @@ -311,7 +311,7 @@ } .iconButton:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: -1px; } diff --git a/packages/client/ui-attachment/src/MessageImage.module.css b/packages/client/ui-attachment/src/MessageImage.module.css index 127856cf99..0ff6257b94 100644 --- a/packages/client/ui-attachment/src/MessageImage.module.css +++ b/packages/client/ui-attachment/src/MessageImage.module.css @@ -102,6 +102,6 @@ .frame:focus-visible, .error:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css index 1853f80ac9..0417786ef0 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css @@ -77,7 +77,7 @@ } .header:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: -2px; } @@ -258,7 +258,7 @@ } .action:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: -2px; } diff --git a/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.module.css b/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.module.css index 666293d3da..d4ee1a8231 100644 --- a/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.module.css +++ b/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -123,7 +123,7 @@ .crumb:focus-visible { /* The trail scrolls, so an outer ring would be clipped; keep it inside. */ - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: -2px; } @@ -247,7 +247,7 @@ .row:focus-visible { /* The column scrolls and its left edge carries no padding; keep the ring inside. */ - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: -2px; } diff --git a/packages/client/ui-message-feedback/src/client/FeedbackDialog.module.css b/packages/client/ui-message-feedback/src/client/FeedbackDialog.module.css index ee2cc6ee16..295d3baead 100644 --- a/packages/client/ui-message-feedback/src/client/FeedbackDialog.module.css +++ b/packages/client/ui-message-feedback/src/client/FeedbackDialog.module.css @@ -39,7 +39,7 @@ } .chip:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-plan/src/client/PlanModeControl.module.css b/packages/client/ui-plan/src/client/PlanModeControl.module.css index 43664921b4..bd0bb877c3 100644 --- a/packages/client/ui-plan/src/client/PlanModeControl.module.css +++ b/packages/client/ui-plan/src/client/PlanModeControl.module.css @@ -34,7 +34,7 @@ } .chip:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-plan/src/client/PlanPreview.module.css b/packages/client/ui-plan/src/client/PlanPreview.module.css index 037803f7f2..2b02db4606 100644 --- a/packages/client/ui-plan/src/client/PlanPreview.module.css +++ b/packages/client/ui-plan/src/client/PlanPreview.module.css @@ -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-brand-primary); outline-offset: 3px; } +.card:focus-visible { outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 3px; } .cardIcon { box-sizing: border-box; flex: none; display: grid; place-items: center; width: 40px; height: 40px; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 10px; background: var(--plan-card-fill); } .cardDetails { display: flex; flex: 1; flex-direction: column; gap: 2px; min-width: 0; } .cardTitle { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 500; line-height: 20px; } @@ -30,7 +30,7 @@ .cardOpen { box-sizing: border-box; display: inline-flex; flex: none; align-items: center; height: 28px; padding: 4px 8px; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 10px; background: var(--dsw-alias-button-floating-fill); font-size: 12px; line-height: 18px; } .reviewLink { display: inline-flex; align-items: center; gap: 4px; padding: 0; border: 0; background: transparent; color: var(--dsw-alias-label-secondary); font: inherit; cursor: pointer; } .reviewLink:hover { color: var(--dsw-alias-label-primary); } -.reviewLink:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 3px; } +.reviewLink:focus-visible { outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 3px; } .preview { position: relative; height: 100%; overflow: auto; padding: 20px 24px 40px; box-sizing: border-box; } .document { color: var(--dsw-alias-label-primary); font-size: 14px; line-height: 1.75; overflow-wrap: anywhere; } .message { padding: 24px; color: var(--dsw-alias-label-secondary); font-size: 14px; } diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css index 96286b3b16..c4380c9462 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css @@ -154,7 +154,7 @@ } .iconButton:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 1px; } @@ -267,7 +267,7 @@ } .cardOpen:focus-visible::after { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 2px; } @@ -373,7 +373,7 @@ } .deleteButton:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 1px; } @@ -397,7 +397,7 @@ } .partsFilter:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 1px; } @@ -1067,7 +1067,7 @@ label.registryOption { } .crumb:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-primitives/src/Checkbox.module.css b/packages/client/ui-primitives/src/Checkbox.module.css index a78e56c45a..c834dcc72d 100644 --- a/packages/client/ui-primitives/src/Checkbox.module.css +++ b/packages/client/ui-primitives/src/Checkbox.module.css @@ -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-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-primitives/src/ConnectionIndicator.module.css b/packages/client/ui-primitives/src/ConnectionIndicator.module.css index d65fcc82d2..a05e3d6ebf 100644 --- a/packages/client/ui-primitives/src/ConnectionIndicator.module.css +++ b/packages/client/ui-primitives/src/ConnectionIndicator.module.css @@ -56,7 +56,7 @@ } .warning:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-primitives/src/HoverCard.module.css b/packages/client/ui-primitives/src/HoverCard.module.css index c4164cd18f..7492157b09 100644 --- a/packages/client/ui-primitives/src/HoverCard.module.css +++ b/packages/client/ui-primitives/src/HoverCard.module.css @@ -27,7 +27,7 @@ } .copyable:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-primitives/src/RiskConfirmation.module.css b/packages/client/ui-primitives/src/RiskConfirmation.module.css index fcba264b38..73ff979683 100644 --- a/packages/client/ui-primitives/src/RiskConfirmation.module.css +++ b/packages/client/ui-primitives/src/RiskConfirmation.module.css @@ -56,7 +56,7 @@ } .acknowledgement input:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-primitives/src/Switch.module.css b/packages/client/ui-primitives/src/Switch.module.css index c038331a66..6d8d7f345a 100644 --- a/packages/client/ui-primitives/src/Switch.module.css +++ b/packages/client/ui-primitives/src/Switch.module.css @@ -31,7 +31,7 @@ } .switch:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css index 510120d2e1..8ed285e745 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css +++ b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css @@ -151,7 +151,7 @@ } .cardContent:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: -2px; } @@ -239,7 +239,7 @@ } .groupToggle:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 2px; } @@ -297,7 +297,7 @@ } .switcher:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-settings-plugins/src/client/PluginConfigForm.module.css b/packages/client/ui-settings-plugins/src/client/PluginConfigForm.module.css index cf1aade88f..151719c5af 100644 --- a/packages/client/ui-settings-plugins/src/client/PluginConfigForm.module.css +++ b/packages/client/ui-settings-plugins/src/client/PluginConfigForm.module.css @@ -48,6 +48,6 @@ } .save:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 1px; } diff --git a/packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.module.css b/packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.module.css index c0f617575d..432ffeb79e 100644 --- a/packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.module.css +++ b/packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.module.css @@ -58,7 +58,7 @@ } .tab:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 2px; border-radius: 2px; color: var(--dsw-alias-label-primary); diff --git a/packages/client/ui-settings-plugins/src/client/fields.module.css b/packages/client/ui-settings-plugins/src/client/fields.module.css index 4db120dc7c..c38eff8226 100644 --- a/packages/client/ui-settings-plugins/src/client/fields.module.css +++ b/packages/client/ui-settings-plugins/src/client/fields.module.css @@ -60,7 +60,7 @@ } .helpButton:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 1px; } diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index c99c729fbb..e2b531df7f 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -493,7 +493,7 @@ } .panelRow:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: -2px; } diff --git a/packages/client/ui-theme/src/styles/focus.css b/packages/client/ui-theme/src/styles/focus.css index 90296e5d78..4f2c1b3348 100644 --- a/packages/client/ui-theme/src/styles/focus.css +++ b/packages/client/ui-theme/src/styles/focus.css @@ -1,3 +1,10 @@ +/* The standard ring geometry. A component inside a clipping container sets its + * own offset, and the dense tables and toolbars keep a thinner 1px ring, rather + * than bending this value — those exceptions are deliberate, not drift. */ +:root { + --dsw-focus-ring-width: 2px; +} + /* One keyboard-focus colour for every control. A control that declares its own * focus ring sets the colour explicitly, so this sheet reaches only the ones * that would otherwise paint the browser default — whose colour follows the diff --git a/packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css b/packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css index d5442160be..f5a26ef446 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css +++ b/packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css @@ -255,4 +255,4 @@ } .path:hover { text-decoration: underline; } -.path:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 3px; } +.path:focus-visible { outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 3px; } diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css index 594c0b3570..05fddcebb3 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css @@ -75,7 +75,7 @@ } .historyLoadButton:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: -2px; } @@ -1763,7 +1763,7 @@ } .attachmentDisclosure > summary:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: 2px; } diff --git a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css index c7875b946c..483abb218d 100644 --- a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css +++ b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css @@ -17,7 +17,7 @@ } .runHeader:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: -2px; } @@ -80,7 +80,7 @@ } .phaseHeader:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: -2px; border-radius: 4px; } @@ -210,7 +210,7 @@ } .memberButton:focus-visible .memberLabelWrap { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: -1px; } diff --git a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css index 7340b2d7b8..2e692a9fe4 100644 --- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css @@ -35,7 +35,7 @@ .iconButton:focus-visible, .searchButton:focus-visible, .clearButton:focus-visible { - outline: 2px solid var(--dsw-alias-brand-primary); + outline: var(--dsw-focus-ring-width) solid var(--dsw-alias-brand-primary); outline-offset: -2px; } From bca9cecc7d901de348b08ebf10b1012a527687c2 Mon Sep 17 00:00:00 2001 From: mektpoy Date: Tue, 22 Sep 2026 20:28:10 +0800 Subject: [PATCH 07/36] fix(client): keep pointer focus silent through descendant and editable rings Pointer suppression previously cleared `outline-color` on the focused element alone, so a ring painted by a descendant, a pseudo-element, or a `box-shadow` stayed visible, and editable text controls lost the focus feedback they need on click. Ring colour now flows through `--dsw-focus-ring-color`, which component outlines and focus-ring shadows read alongside the brand token. Every ring that inherits the variable is suppressed together, and `body :focus-visible:not(:read-write)` limits the suppression to rings while editable controls keep their own feedback. Keyboard modality returns on Tab, Home, End, PageUp, or PageDown, and on focus reaching a different control after a non-composing key. IME composition keys, refocusing the same control, and window blur leave rings silent. The desktop preload menubar suppresses its own ring inside its shadow root, where the document-level attribute does not reach. --- ...6-09-20-focus-ring-brand-primary.i18n.yaml | 4 +- .../2026-09-20-focus-ring-brand-primary.md | 30 +- .../2026-09-20-focus-ring-brand-primary.zh.md | 30 +- apps/desktop/src/preload-menu.ts | 3 +- apps/web/tests/focus-rings.e2e.ts | 357 ++++++++++++++++++ apps/web/tsconfig.json | 1 + .../src/client/AgentPresetSection.module.css | 6 +- .../ui-attachment/src/MessageImage.module.css | 2 +- .../src/client/chat/MessageItem.module.css | 2 +- .../src/client/chat/TurnNavigator.module.css | 2 +- .../src/client/queue/QueueDock.module.css | 4 +- .../src/client/ChangedFiles.module.css | 4 +- .../src/client/Deliverables.module.css | 2 +- .../src/client/DirectoryBrowser.module.css | 4 +- .../src/client/FeedbackDialog.module.css | 2 +- .../src/client/ModelSelect.module.css | 2 +- .../src/client/PermissionSelect.module.css | 2 +- .../src/client/PlanModeControl.module.css | 2 +- .../ui-plan/src/client/PlanPreview.module.css | 4 +- .../src/client/PluginManagerPage.module.css | 10 +- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 6 + packages/client/ui-primitives/README.zh.md | 6 + .../ui-primitives/src/Checkbox.module.css | 2 +- .../src/ConnectionIndicator.module.css | 2 +- .../ui-primitives/src/HoverCard.module.css | 2 +- .../ui-primitives/src/JsonTree.module.css | 4 +- .../src/RiskConfirmation.module.css | 2 +- .../ui-primitives/src/Switch.module.css | 2 +- .../ui-primitives/src/input-modality.ts | 47 ++- .../src/markdown/MarkdownText.module.css | 4 +- .../tests/input-modality.client.spec.ts | 87 ++++- .../src/client/ModelsSection.module.css | 2 +- .../PluginInventorySettingsTab.module.css | 8 +- .../src/client/PluginConfigForm.module.css | 2 +- .../client/PluginsSettingsSection.module.css | 2 +- .../src/client/fields.module.css | 2 +- .../src/client/SidebarRoot.module.css | 2 +- packages/client/ui-theme/README.i18n.yaml | 4 +- packages/client/ui-theme/README.md | 6 +- packages/client/ui-theme/README.zh.md | 6 +- packages/client/ui-theme/src/styles/focus.css | 24 +- .../tests/focus-styles.client.spec.ts | 66 ++++ .../tool/components/ToolDetails.module.css | 2 +- .../src/client/TrajectoryTable.module.css | 24 +- .../src/client/TrajectoryTimeline.module.css | 4 +- .../src/client/TrajectoryToolbar.module.css | 6 +- .../src/client/WorkflowRunPanel.module.css | 6 +- .../client/rows/WorkspaceBrowser.module.css | 2 +- tsconfig.host.json | 1 + 50 files changed, 669 insertions(+), 141 deletions(-) create mode 100644 apps/web/tests/focus-rings.e2e.ts create mode 100644 packages/client/ui-theme/tests/focus-styles.client.spec.ts diff --git a/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.i18n.yaml b/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.i18n.yaml index 6707b51680..f30d9be553 100644 --- a/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.md -2026-09-20-focus-ring-brand-primary.md: 3c6c041ac04774e6f5f66085f4616467020bd40e -2026-09-20-focus-ring-brand-primary.zh.md: 541537c54e3ee1016c133f99557a0f2fa9204587 +2026-09-20-focus-ring-brand-primary.md: 288f3722428a5dd52822c3fecbbaaea5c7c6310f +2026-09-20-focus-ring-brand-primary.zh.md: 792374b92cbd056bf181b79f8b22e1aec572d3bb diff --git a/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.md b/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.md index 3c6c041ac0..288f372242 100644 --- a/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.md +++ b/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.md @@ -6,28 +6,38 @@ English | [中文](2026-09-20-focus-ring-brand-primary.zh.md) ## Problem -Keyboard focus rings carried at least five different colours across the client: `--dsw-alias-state-business-primary` on 31 declarations, `--dsw-alias-label-primary`, `--dsw-alias-label-tertiary`, `--dsw-alias-state-warn-label`, `--dsw-alias-border-l4` and two button fills elsewhere. Controls that declared no ring at all fell back to the browser default, whose colour follows the operating-system accent — so the same build shows orange on one machine and blue on another, and a control can change colour when nothing about the control changed. +Different explicit focus colours and browser-default outlines do not provide a consistent keyboard-focus cue. Browser defaults can also depend on the operating-system accent. + +Focus feedback has separate interaction and geometry constraints. A browser's `:focus-visible` heuristic can reveal a pointer-focused button after a key that does not move focus, while editable text controls need focus feedback on click. Containers that clip their contents can hide an outer ring. ## Decision -Every keyboard focus ring uses `--dsw-alias-brand-primary`. +[The theme's focus stylesheet](../../../../packages/client/ui-theme/src/styles/focus.css) owns the shared ring colour and standard width. [Input modality](../../../../packages/client/ui-primitives/README.md#input-modality) distinguishes tooltip input from keyboard focus navigation. -Two parts implement this, and both are required. `ui-theme/src/styles/focus.css` declares a global `:focus-visible { outline-color: var(--dsw-alias-brand-primary) }`, which reaches the controls that draw the browser default. The 49 component declarations that named another colour now name the same token. Only the colour changed: each declaration keeps its own width, style and offset. +**One colour.** Global and component `:focus-visible` outlines and focus-ring shadows read `--dsw-focus-ring-color`, falling back to `--dsw-alias-brand-primary`. The global fallback sets only outline colour; it does not create an outline where the component disables one. -The global sheet sets `outline-color` alone. A component that owns a focus ring keeps it, because its `.class:focus-visible` selector outweighs the bare `:focus-visible` rule; a control with no ring of its own inherits the brand colour instead of the system accent. +**Context-owned geometry.** `--dsw-focus-ring-width` carries the standard 2px width. Dense tables and toolbars may keep 1px so the ring does not dominate compact content. Components own their offsets and use an inset ring where an outer ring would be clipped. + +**Pointer focus does not imply keyboard navigation.** In pointer modality, `:focus-visible:not(:read-write)` controls use a transparent ring colour. Descendant and pseudo-element rings inherit it. Only focus-ring paint is suppressed: selected-state borders and elevation shadows remain, and editable text controls keep their own focus feedback on click. DOM focus stays on the control. + +Tooltips follow the last input and treat any key as keyboard input. Rings instead resume keyboard styling on non-composing navigation keys, or on focus reaching a different control after a non-composing key. Refocusing the same control is not navigation; pointer input, composition keys, and window blur clear the pending key. ## Alternatives considered -**Only rewrite the component declarations.** The 49 explicit rings would agree, but every control that relies on the browser default — the sidebar rail toggles among them — would still paint the operating-system accent, which is the visible symptom this change exists to remove. +**Rewrite only the component declarations.** Explicit rings would agree, but controls using a browser-default outline would retain its colour. Requiring every control to declare its own ring would also leave new controls inconsistent until their authors add that rule. -**Give each remaining control its own ring declaration.** This reaches the same result, but each new control reintroduces the defect until someone remembers to add a ring, and the shell styles would have to anticipate every interactive element it renders. +**Unify width and offset globally.** Positive offsets can be clipped by a scrolling ancestor, while a uniform thick ring can overwhelm dense content. Neither colour consistency nor input modality requires identical geometry. -**Unify width and offset as well.** The reported inconsistency included those, but the confirmed design decision covers colour. Width and offset stay per component until a design decision fixes them. +**Suppress by moving focus instead of by style.** Calling `blur()` on a pointer-focused button would hide its ring but also remove the target for subsequent Enter or Space activation. -**Replace every `outline: none` declaration with the shared ring.** Those declarations accompany deliberate alternatives (inset `box-shadow` rings, container-level focus treatment) or suppress focus on containers that must not show one. Removing them changes behaviour beyond colour. +**Clear all shadows on pointer focus.** A `box-shadow` can contain elevation or selected-state paint as well as a focus ring. Suppressing its ring colour preserves those independent cues; exempting `:read-write` preserves text-entry feedback. + +**Replace every `outline: none` with a shared ring.** Some controls use an inset shadow or container-level focus treatment instead; some containers deliberately show no ring. Replacing those declarations would change their focus treatment, not merely its colour. + +**Give tooltips and rings the same modality rule.** A key can justify a tooltip on subsequent programmatic focus without justifying a ring on the same pointer-focused button. Keeping separate answers preserves both behaviours. ## Consequences -The focus colour no longer tracks the operating-system accent, so a screen recording or screenshot is reproducible across machines. Components keep their geometry, and controls that never declared a ring now match those that did. The global rule is the only place that decides the fallback, so a future design change edits one token reference rather than every module. +Focus rings share a theme colour while components retain their geometry and non-focus state cues. Ring visibility changes without changing the keyboard's active target. These rules do not guarantee identical browser rendering or eliminate component-specific clipping. -`ui-theme/tests/client-styles.client.spec.ts` pins the sheet in the injection order. `pnpm run test:gui` covers the client suites; the recorded Web replay covers the assembled browser. +[Stylesheet-order checks](../../../../packages/client/ui-theme/tests/client-styles.client.spec.ts), [focus-style checks](../../../../packages/client/ui-theme/tests/focus-styles.client.spec.ts), and [input-modality tests](../../../../packages/client/ui-primitives/tests/input-modality.client.spec.ts) cover stylesheet declarations and modality transitions. Static declarations and synthetic events do not establish rendered ring visibility or cross-platform behaviour. diff --git a/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.zh.md b/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.zh.md index 541537c54e..792374b92c 100644 --- a/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.zh.md +++ b/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.zh.md @@ -6,28 +6,38 @@ Status: implemented ## 问题 -客户端的键盘焦点环至少使用了五种颜色:31 处声明用 `--dsw-alias-state-business-primary`,其余分散在 `--dsw-alias-label-primary`、`--dsw-alias-label-tertiary`、`--dsw-alias-state-warn-label`、`--dsw-alias-border-l4` 和两种按钮填充色上。完全没有声明焦点环的控件则回退到浏览器默认环,其颜色跟随操作系统强调色——同一个构建在一台机器上显示橙色、另一台显示蓝色,控件本身没变颜色却变了。 +不同的显式焦点颜色与浏览器默认轮廓无法提供一致的键盘焦点提示。浏览器默认值还可能取决于操作系统强调色。 + +焦点反馈另有交互与几何约束。浏览器的 `:focus-visible` 启发式可能在不移动焦点的按键之后显出指针聚焦按钮的环,而可编辑文本控件在点击时需要焦点反馈。裁切内容的容器可能隐藏外扩环。 ## 决策 -所有键盘焦点环统一使用 `--dsw-alias-brand-primary`。 +[主题焦点样式表](../../../../packages/client/ui-theme/src/styles/focus.css) 拥有共享焦点环颜色与标准宽度。[输入模态](../../../../packages/client/ui-primitives/README.zh.md#input-modality) 区分 tooltip 输入与键盘焦点导航。 -实现分两部分,缺一不可。`ui-theme/src/styles/focus.css` 声明全局 `:focus-visible { outline-color: var(--dsw-alias-brand-primary) }`,覆盖那些画浏览器默认环的控件;49 处原本指定了其它颜色的组件声明改为同一个 token。只改颜色:每条声明保留自己的宽度、样式和 offset。 +**一个颜色。** 全局与组件的 `:focus-visible` 轮廓和焦点环阴影读取 `--dsw-focus-ring-color`,回退到 `--dsw-alias-brand-primary`。全局兜底只设置轮廓颜色;组件禁用轮廓时,它不会创建轮廓。 -全局规则只设置 `outline-color`。自带焦点环的组件不受影响,因为它的 `.class:focus-visible` 选择器权重高于裸 `:focus-visible` 规则;没有焦点环的控件则继承品牌色,而不是系统强调色。 +**场景决定几何。** `--dsw-focus-ring-width` 提供标准的 2px 宽度。密集表格与工具栏可以保留 1px,避免焦点环压过紧凑内容。组件拥有自己的 offset,并在外扩环会被裁切时使用内嵌环。 + +**指针焦点不等于键盘导航。** 指针模态下,`:focus-visible:not(:read-write)` 控件使用透明的焦点环颜色。后代与伪元素上的环继承该值。只抑制焦点环绘制:选中态边框与 elevation 阴影保留,可编辑文本控件在点击时保留自身焦点反馈。DOM 焦点仍留在控件上。 + +Tooltip 跟随最近一次输入,将任意按键视为键盘输入。焦点环则在非组合输入的导航键到达时,或非组合输入按键之后焦点到达不同控件时,恢复键盘样式。重新聚焦同一控件不算导航;指针输入、组合输入按键和 window blur 会清除待处理按键。 ## 考虑过的其他做法 -**只重写组件声明。** 49 个显式焦点环会一致,但所有依赖浏览器默认环的控件——包括侧栏轨道的切换按钮——仍会画出操作系统强调色,而这正是本次改动要消除的可见症状。 +**只重写组件声明。** 显式焦点环会一致,但使用浏览器默认轮廓的控件仍会保留其颜色。要求每个控件自行声明焦点环,也会让新控件在作者补上规则之前保持不一致。 -**给剩下每个控件单独加焦点环声明。** 结果相同,但每新增一个控件都会重新引入缺陷,直到有人记得补环;壳层样式还得预判它渲染的每个可交互元素。 +**全局统一宽度与 offset。** 正 offset 可能被滚动祖先裁切,统一的粗环则可能压过密集内容。颜色一致性和输入模态都不要求相同的几何。 -**连宽度和 offset 一起统一。** 反馈中的不一致确实包含这两项,但已确认的设计决策只覆盖颜色。在设计定下之前,宽度和 offset 保持逐组件。 +**用移动焦点取代样式抑制。** 对指针聚焦的按钮调用 `blur()` 可以隐藏焦点环,但也会移除后续 Enter 或 Space 激活的目标。 -**把所有 `outline: none` 改为共享焦点环。** 这些声明分别对应刻意的替代方案(内嵌 `box-shadow` 环、容器级焦点处理),或用于抑制本就不应显示焦点的容器。删除它们会改变颜色之外的行为。 +**指针聚焦时清除所有阴影。** 一个 `box-shadow` 除了焦点环,还可能包含 elevation 或选中态绘制。抑制其焦点环颜色可保留这些独立提示;豁免 `:read-write` 可保留文本输入反馈。 + +**把所有 `outline: none` 改为共享焦点环。** 一些控件改用内嵌阴影或容器级焦点处理;一些容器则刻意不显示焦点环。替换这些声明会改变焦点处理方式,而不只是颜色。 + +**让 tooltip 与焦点环共用同一条模态规则。** 按键可以让后续程序化聚焦显示 tooltip,却不一定需要在同一个指针聚焦按钮上显示焦点环。分别记录两个答案可保留两种行为。 ## 影响 -焦点颜色不再跟随操作系统强调色,因此录屏和截图在不同机器上可复现。组件保留自身几何形状,从未声明焦点环的控件现在与已声明的控件一致。全局规则是决定兜底颜色的唯一位置,未来的设计变更只需改一处 token 引用,而非每个模块。 +焦点环共享主题颜色,组件保留自己的几何与非焦点状态提示。焦点环可见性的变化不改变键盘的活动目标。这些规则不保证浏览器渲染完全相同,也不会消除组件特有的裁切。 -`ui-theme/tests/client-styles.client.spec.ts` 固定了该样式表在注入顺序中的位置。`pnpm run test:gui` 覆盖客户端套件;录制式 Web 回放覆盖组装后的浏览器。 +[样式表顺序检查](../../../../packages/client/ui-theme/tests/client-styles.client.spec.ts)、[焦点样式检查](../../../../packages/client/ui-theme/tests/focus-styles.client.spec.ts)与[输入模态测试](../../../../packages/client/ui-primitives/tests/input-modality.client.spec.ts)覆盖样式声明和模态转换。静态声明与合成事件不能确认焦点环的实际渲染可见性或跨平台行为。 diff --git a/apps/desktop/src/preload-menu.ts b/apps/desktop/src/preload-menu.ts index aa2f54a423..d913f1ed3e 100644 --- a/apps/desktop/src/preload-menu.ts +++ b/apps/desktop/src/preload-menu.ts @@ -22,7 +22,8 @@ export function installWindowsMenu(): { update(): void; dispose(): void } { font: inherit; font-size: 14px; cursor: default; } button:hover, button[aria-expanded=true] { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); } - button:focus-visible { outline: 2px solid var(--dsw-alias-label-primary); outline-offset: -2px; } + button:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: -2px; } + :host-context(html[data-input-modality='pointer']) button:focus-visible { outline-color: transparent; } ` const bar = document.createElement('div') bar.setAttribute('role', 'menubar') diff --git a/apps/web/tests/focus-rings.e2e.ts b/apps/web/tests/focus-rings.e2e.ts new file mode 100644 index 0000000000..49a17c6b2e --- /dev/null +++ b/apps/web/tests/focus-rings.e2e.ts @@ -0,0 +1,357 @@ +/** Real assembled UI plus source-compiled browser regressions for focus-ring paint. */ +import { join } from 'node:path' +import { build } from 'vite' +import { chromium, type Locator, type Page } from 'playwright' +import { beforeAll, describe, expect, it, onTestFinished } from 'vitest' +import { launchWebScaffold, watchConsole } from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, REPO_ROOT } from './support.ts' + +/** Read rendered paint and browser focus, not just the modality publisher's attribute. */ +async function paint(target: Locator) { + return target.evaluate((element) => { + const style = getComputedStyle(element) + const resolveColor = (value: string): string => { + const probe = document.createElement('span') + probe.style.color = value + document.body.append(probe) + const color = getComputedStyle(probe).color + probe.remove() + return color + } + return { + active: document.activeElement === element, + focusVisible: element.matches(':focus-visible'), + modality: document.documentElement.getAttribute('data-input-modality'), + brand: resolveColor(style.getPropertyValue('--dsw-alias-brand-primary').trim()), + hover: resolveColor(style.getPropertyValue('--dsw-alias-interactive-bg-hover').trim()), + outline: style.outlineColor, + outlineStyle: style.outlineStyle, + outlineWidth: style.outlineWidth, + shadow: style.boxShadow, + background: style.backgroundColor, + } + }) +} + +async function expectSilent(target: Locator): Promise { + const state = await paint(target) + expect(state.active).toBe(true) + expect(state.modality).toBe('pointer') + expect(state.brand).toMatch(/^rgb/) + expect(state.outlineStyle === 'none' || state.outline === 'rgba(0, 0, 0, 0)').toBe(true) + expect(state.shadow).not.toContain(state.brand) +} + +async function expectBrand(target: Locator, kind: 'outline' | 'shadow'): Promise { + await expect.poll(async () => { + const state = await paint(target) + return { + active: state.active, + visible: state.focusVisible, + modality: state.modality, + painted: kind === 'shadow' + ? state.shadow.includes(state.brand) + : state.outline === state.brand && state.outlineStyle === 'solid' && Number.parseFloat(state.outlineWidth) > 0, + } + }).toEqual({ active: true, visible: true, modality: 'keyboard', painted: true }) +} + +it('assembled app: pointer keys stay silent; keyboard navigation and menu activation retain feedback', async () => { + const scaffold = await launchWebScaffold() + onTestFinished(() => scaffold.close()) + const browser = await chromium.launch({ headless: true }) + onTestFinished(() => browser.close()) + const page = await newEnglishPage(browser) + const consoleWatch = watchConsole(page) + await page.goto(scaffold.authenticatedUrl) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + const trigger = page.getByRole('button', { name: /^Access mode, current:/ }) + + await trigger.click() + await page.getByRole('menu').waitFor() + await expectSilent(trigger) + await page.keyboard.press('Shift') + await expectSilent(trigger) + await page.keyboard.press('Escape') + await page.getByRole('menu').waitFor({ state: 'hidden' }) + await expectSilent(trigger) + + for (const key of ['Home', 'End']) { + await trigger.click() + await page.keyboard.press('Escape') + await expectSilent(trigger) + await page.keyboard.press(key) + await expectBrand(trigger, 'shadow') + } + + await trigger.click() + await page.keyboard.press('Escape') + await page.keyboard.press('Shift+Tab') + expect(await trigger.evaluate(element => element === document.activeElement)).toBe(false) + await page.keyboard.press('Tab') + await expectBrand(trigger, 'shadow') + for (const key of ['Enter', 'Space']) { + await page.keyboard.press(key) + await page.getByRole('menu').waitFor() + await expectBrand(trigger, 'shadow') + const menu = page.getByRole('menu') + for (const [navigation, index] of [['End', -1], ['Home', 0]] as const) { + await page.keyboard.press(navigation) + const row = menu.getByRole('menuitem').nth(index) + const state = await paint(row) + expect(state.active).toBe(true) + expect(state.focusVisible).toBe(true) + expect(state.modality).toBe('keyboard') + // Menu rows intentionally use the hover fill instead of a second outline. + expect(state.background).toBe(state.hover) + expect(state.background).not.toBe('rgba(0, 0, 0, 0)') + } + await page.keyboard.press('Escape') + await expectBrand(trigger, 'shadow') + } + expect(consoleWatch.warnings).toEqual([]) + expect(consoleWatch.pageErrors).toEqual([]) +}) + +/** + * Supplementary fixture, not an assembled app: Vite compiles the actual Menu, + * Switch, input-modality module and CSS Modules in memory. Only arrangement and + * state ownership belong to the fixture; no test writes the modality attribute. + */ +async function compileFixture(): Promise<{ script: string; css: string }> { + const source = (relative: string): string => JSON.stringify(join(REPO_ROOT, 'packages/client', relative).replaceAll('\\', '/')) + const entry = join(REPO_ROOT, 'apps/web/focus-rings-fixture.tsx').replaceAll('\\', '/') + const code = ` + import React, { useState } from 'react' + import { createRoot } from 'react-dom/client' + import { Menu } from ${source('ui-primitives/src/Menu.tsx')} + import { Switch } from ${source('ui-primitives/src/Switch.tsx')} + import ${source('ui-primitives/src/input-modality.ts')} + import ${source('ui-theme/src/styles/base.css')} + import ${source('ui-theme/src/styles/design-platform.css')} + import ${source('ui-theme/src/styles/focus.css')} + import ${source('ui-theme/src/styles/gradient-shadow-text.css')} + import triggerCss from ${source('ui-permission-presets/src/client/PermissionSelect.module.css')} + import feedbackCss from ${source('ui-message-feedback/src/client/FeedbackDialog.module.css')} + import workflowCss from ${source('ui-workflow-run/src/client/WorkflowRunPanel.module.css')} + import cardCss from ${source('ui-primitives/src/HoverCard.module.css')} + import pillCss from ${source('ui-primitives/src/Pill.module.css')} + import trajectoryCss from ${source('ui-trajectory/src/client/TrajectoryTable.module.css')} + function Fixture() { + const [checked, setChecked] = useState(false) + const [open, setOpen] = useState(false) + return
+ + + setOpen(false)} + anchor={} + items={[{ id: 'first', label: 'First item' }, { id: 'last', label: 'Last item' }]} /> +