fix(desktop): drop native title= from all 24 button sites the fixed guard flags

Convert every `<button>`/`<Button>` that carried a native `title=` under
apps/desktop/src, per the DESIGN.md rule (tips only where hover teaches
something new; otherwise `aria-label` for a11y):

- `aria-label` swap where the visible affordance already teaches the action
  (zoom/lightbox triggers, delete icon, install "+", reference thumbnail).
- `title=` deleted where an equal `aria-label` or visible text already exists
  (remove/disconnect trash icons, send arrow, stop glyph, Download button,
  MCP tool chips with `aria-pressed`, Lock face + its caption, pet toggles,
  spectator bubble with `aria-expanded`, Create Group whose dialog copy already
  says "Pick 2–N bots" — a tip on a `disabled` Button never opens anyway).
- `<Tip>` where hover reveals something not on screen: full path on changed
  files rows (`key` on the Tip inside the map), "Restore checkpoint — rerun
  from this prompt", "Disconnect (runs the removal command in the terminal)",
  group-chat attach hint and the speaker-handle toggle.

Guard hardening on top of the salvaged scanner: skip `//` and `/* */` comments
inside the opening tag (an apostrophe in `// Don't steal focus…` between
attributes otherwise opens a phantom string and hides `title=` — two shipped
sites were invisible that way), skip `*.test.tsx` fixtures, and match `title=`
as its own attribute (not `data-title=`). One fixture test pins those cases.

Co-authored-by: wnuuee1 <poli.koltsova@gmail.com>
This commit is contained in:
teknium1
2026-09-18 01:52:05 -07:00
committed by Teknium
parent b2fd69b1f7
commit 0aa13a5e4b
21 changed files with 189 additions and 128 deletions

View File

@@ -192,7 +192,6 @@ export function PetInlineToggle() {
onClick={toggle}
// Don't steal focus from the search input on click.
onMouseDown={event => event.preventDefault()}
title={enabled ? copy.turnOff : copy.turnOn}
type="button"
>
{busy ? <Loader2 className="size-4 animate-spin" /> : <PawPrint className="size-4" />}

View File

@@ -20,7 +20,7 @@ export function ReferenceChip({ name, onRemove, src }: ReferenceChipProps) {
return (
<div className="ml-auto flex h-6 items-center gap-2 self-start rounded-lg border border-border/60 bg-background/50 pl-1 pr-2">
<button className="shrink-0" onClick={() => setViewing(true)} title={t.desktop.openImage} type="button">
<button aria-label={t.desktop.openImage} className="shrink-0" onClick={() => setViewing(true)} type="button">
<img alt={name} className="size-4 rounded-md object-cover" src={src} />
</button>

View File

@@ -469,7 +469,6 @@ export function PetOverlayApp() {
top: 0,
width: 24
}}
title="Open in Hermes"
type="button"
>
<Mail style={{ height: 13, width: 13 }} />

View File

@@ -589,7 +589,6 @@ export function AppearanceSettings() {
setTheme(theme.name)
}
}}
title={a.removeTheme}
type="button"
>
<Trash2 className="size-3.5" />

View File

@@ -122,7 +122,6 @@ export function KeyField({
disabled={busy}
onClick={() => void onClear(varKey)}
size="icon-xs"
title={t.settings.credentials.remove}
type="button"
variant="ghost"
>

View File

@@ -276,11 +276,11 @@ export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: C
</Button>
{endpoint.source !== 'direct-config' && (
<Button
aria-label={t.settings.customEndpoints.deleteEndpoint}
className="hover:text-destructive"
disabled={deleting === endpoint.id}
onClick={() => void handleDelete(endpoint)}
size="icon-sm"
title={t.settings.customEndpoints.deleteEndpoint}
variant="ghost"
>
{deleting === endpoint.id ? <Loader2 className="animate-spin" /> : <Trash2 />}

View File

@@ -16,6 +16,7 @@ import {
import { Button } from '@/components/ui/button'
import { RowButton } from '@/components/ui/row-button'
import { SearchField } from '@/components/ui/search-field'
import { Tip } from '@/components/ui/tooltip'
import { disconnectOAuthProvider, listOAuthProviders } from '@/hermes'
import { useI18n } from '@/i18n'
import { Check, ChevronDown, ChevronRight, KeyRound, Loader2, Terminal, Trash2 } from '@/lib/icons'
@@ -282,7 +283,6 @@ function ConnectedProviderRow({
disabled={disconnecting}
onClick={() => onDisconnect(provider)}
size="icon-xs"
title={`${t.common.remove} ${title}`}
type="button"
variant="ghost"
>
@@ -290,16 +290,17 @@ function ConnectedProviderRow({
</Button>
)}
{terminalDisconnect && (
<Button
aria-label={`${copy.disconnect} ${title}`}
onClick={() => onTerminalDisconnect(provider)}
size="icon-xs"
title={copy.disconnectInTerminal}
type="button"
variant="ghost"
>
<Trash2 className="size-3" />
</Button>
<Tip label={copy.disconnectInTerminal}>
<Button
aria-label={`${copy.disconnect} ${title}`}
onClick={() => onTerminalDisconnect(provider)}
size="icon-xs"
type="button"
variant="ghost"
>
<Trash2 className="size-3" />
</Button>
</Tip>
)}
</div>
</div>

View File

@@ -1330,7 +1330,6 @@ function ServerConfig({
disabled={!saved}
key={tool.name}
onClick={() => onToggleTool(tool.name)}
title={on ? m.disableTool(tool.name) : m.enableTool(tool.name)}
type="button"
>
{tool.name}

View File

@@ -8,6 +8,7 @@ import { WIDGET_SHELL_CLASS } from '@/components/chat/widget-shell'
import { DiffCount } from '@/components/ui/diff-count'
import { FadeScroll } from '@/components/ui/fade-scroll'
import { FileTypeIcon } from '@/components/ui/file-type-icon'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { displayPath } from '@/lib/display-path'
import { cn } from '@/lib/utils'
@@ -57,17 +58,17 @@ export const ChangedFilesCard: FC<{ parts: readonly unknown[] }> = ({ parts }) =
</div>
<FadeScroll className="-mx-1.5 mt-1.5 flex flex-col overscroll-y-auto px-1.5" maxHeight={MAX_ROWS_HEIGHT}>
{files.map(file => (
<button
className="row-hover flex shrink-0 items-center gap-2 rounded-md px-1.5 py-1 text-left"
key={file.path}
onClick={() => void openReviewForPath(file.path, scopeCwd, composerScope.target)}
title={displayPath(file.path)}
type="button"
>
<FileTypeIcon className="shrink-0 text-(--ui-text-tertiary)" path={file.path} size="0.875rem" />
<span className="min-w-0 flex-1 truncate text-(--ui-text-secondary)">{file.name}</span>
<DiffCount added={file.added} removed={file.removed} />
</button>
<Tip key={file.path} label={displayPath(file.path)}>
<button
className="row-hover flex shrink-0 items-center gap-2 rounded-md px-1.5 py-1 text-left"
onClick={() => void openReviewForPath(file.path, scopeCwd, composerScope.target)}
type="button"
>
<FileTypeIcon className="shrink-0 text-(--ui-text-tertiary)" path={file.path} size="0.875rem" />
<span className="min-w-0 flex-1 truncate text-(--ui-text-secondary)">{file.name}</span>
<DiffCount added={file.added} removed={file.removed} />
</button>
</Tip>
))}
</FadeScroll>
</div>

View File

@@ -921,7 +921,6 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
// the edit silently never sends. The restore button guards the
// same way.
onPointerDown={event => event.preventDefault()}
title={copy.sendEdited}
type="button"
>
{submitting ? StopGlyph : <Codicon name="arrow-up" size={USER_ACTION_ICON_SIZE} />}

View File

@@ -10,6 +10,7 @@ import { type RestoreMessageTarget } from '@/components/assistant-ui/thread/type
import { useMessageReactions } from '@/components/assistant-ui/thread/use-message-reactions'
import { UserMessageText } from '@/components/assistant-ui/thread/user-message-text'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
@@ -463,7 +464,6 @@ export const UserMessage: FC<{
triggerHaptic('selection')
setExpanded(value => !value)
}}
title={bodyClamped ? (expanded ? t.common.collapse : copy.expandMessage) : undefined}
type="button"
>
{bubbleContent}
@@ -511,33 +511,33 @@ export const UserMessage: FC<{
event.stopPropagation()
void onCancel?.()
}}
title={copy.stop}
type="button"
>
{StopGlyph}
</button>
) : (
<button
aria-label={copy.restoreCheckpoint}
className={cn('pointer-events-auto size-6', USER_ACTION_ICON_BUTTON_CLASS)}
onClick={event => {
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
onRequestRestoreConfirm?.(messageId, {
text: messageText,
userOrdinal: runtimeUserOrdinal
})
}}
onPointerDown={event => {
event.preventDefault()
event.stopPropagation()
}}
title={copy.restoreFromHere}
type="button"
>
<Codicon name="discard" size="0.875rem" />
</button>
<Tip label={copy.restoreFromHere}>
<button
aria-label={copy.restoreCheckpoint}
className={cn('pointer-events-auto size-6', USER_ACTION_ICON_BUTTON_CLASS)}
onClick={event => {
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
onRequestRestoreConfirm?.(messageId, {
text: messageText,
userOrdinal: runtimeUserOrdinal
})
}}
onPointerDown={event => {
event.preventDefault()
event.stopPropagation()
}}
type="button"
>
<Codicon name="discard" size="0.875rem" />
</button>
</Tip>
)}
</div>
)}

View File

@@ -134,9 +134,9 @@ export const GeneratedImage: FC<{ aspectRatio?: string; result?: unknown }> = ({
)}
{src && (
<button
aria-label={copy.openImage}
className="absolute inset-0 block size-full cursor-zoom-in"
onClick={() => setLightboxOpen(true)}
title={copy.openImage}
type="button"
>
<img

View File

@@ -138,7 +138,6 @@ export function PreviewAttachment({ source = 'manual', target }: { source?: Prev
className="flex shrink-0 items-center gap-1 rounded-md border border-(--ui-stroke-tertiary) bg-background/40 px-2 py-1 text-[0.7rem] font-medium text-muted-foreground transition-colors hover:bg-accent/55 hover:text-foreground disabled:opacity-50"
disabled={downloading}
onClick={() => void downloadFile()}
title={t.fileMenu.download}
type="button"
>
<Download className="size-3" />

View File

@@ -32,10 +32,10 @@ export function ZoomableImage({ className, containerClassName, src, alt, slot, .
data-slot={slot ?? 'aui_zoomable-image'}
>
<button
aria-label={canOpen ? copy.openImage : undefined}
className="contents"
disabled={!canOpen}
onClick={() => canOpen && setLightboxOpen(true)}
title={canOpen ? copy.openImage : undefined}
type="button"
>
<img alt={alt ?? ''} className={className} src={src} {...props} />
@@ -125,7 +125,6 @@ export function ImageActionButton({
event.stopPropagation()
void onClick()
}}
title={saving ? copy.savingImage : copy.downloadImage}
type="button"
>
<Download className={cn('size-4', saving && 'animate-pulse')} />

View File

@@ -7,12 +7,12 @@ import { describe, expect, it } from 'vitest'
// may use the native HTML `title=` attribute. Native tooltips are unstyled,
// delayed (~500ms OS default), and visually inconsistent with the themed `Tip`.
// When a tip is warranted (see DESIGN.md — not every icon, never menu triggers),
// use `<Tip label={...}>` instead of `title=`.
// use `<Tip label={...}>` instead of `title=`; otherwise keep an `aria-label`.
//
// This is a source-text scan, not a behavior test — it's the same category as
// an ESLint rule, expressed as a vitest so it runs with the rest of the suite.
// Recursively walk a directory and collect all .tsx file paths.
// Recursively walk a directory and collect all shipped .tsx file paths.
function collectTsxFiles(dir: string): string[] {
const results: string[] = []
@@ -27,7 +27,9 @@ function collectTsxFiles(dir: string): string[] {
if (stat.isDirectory()) {
results.push(...collectTsxFiles(fullPath))
} else if (entry.endsWith('.tsx')) {
} else if (entry.endsWith('.tsx') && !entry.endsWith('.test.tsx')) {
// Test fixtures (`<button title={title}>` stand-ins for SDK components)
// are not shipped UI.
results.push(fullPath)
}
}
@@ -35,67 +37,75 @@ function collectTsxFiles(dir: string): string[] {
return results
}
describe('no native title= on button elements', () => {
// Scan every .tsx file under src/ for <button or <Button opening tags that
// also carry a title= attribute (anywhere in the opening tag, which may span
// multiple lines).
it('uses <Tip> instead of native title= on all button elements', () => {
const violations: string[] = []
const srcDir = resolve(__dirname, '../../..')
for (const filePath of collectTsxFiles(srcDir)) {
const content = readFileSync(filePath, 'utf-8')
const relativePath = filePath.replace(srcDir + '/', '')
// Match <Button ...> or <button ...> opening tags (may span multiple lines).
// Scan to the brace-depth-0 `>` so a `>` inside a JSX expression (e.g.
// `onClick={() => ...}`) doesn't truncate the tag and hide a later
// title= (#113688). String literals are skipped the same way.
for (const { tagName, attrs, index } of eachButtonOpenTag(content)) {
if (/\btitle=/.test(attrs)) {
const lineNum = content.slice(0, index).split('\n').length
violations.push(`${relativePath}:${lineNum} <${tagName}> has title= — use <Tip>`)
}
}
}
expect(violations, violations.join('\n')).toEqual([])
})
})
function eachButtonOpenTag(content: string): Array<{ tagName: string; attrs: string; index: number }> {
const tags: Array<{ tagName: string; attrs: string; index: number }> = []
const openPattern = /<(Button|button)\b/gsu
/** Every `<button …>` / `<Button …>` opening tag in `content`, with the
* attribute text up to the tag's real closing `>`. */
function eachButtonOpenTag(content: string): Array<{ attrs: string; index: number; tagName: string }> {
const tags: Array<{ attrs: string; index: number; tagName: string }> = []
const openPattern = /<(Button|button)\b/gu
let match: RegExpExecArray | null
while ((match = openPattern.exec(content)) !== null) {
const end = findBraceDepthZeroClose(content, match.index + match[0].length)
const attrsStart = match.index + match[0].length
const end = findTagClose(content, attrsStart)
if (end < 0) {
continue
}
tags.push({ tagName: match[1], attrs: content.slice(match.index, end), index: match.index })
tags.push({ attrs: content.slice(attrsStart, end), index: match.index, tagName: match[1] })
}
return tags
}
function findBraceDepthZeroClose(content: string, start: number): number {
// The opening tag ends at the first `>` outside every `{…}` expression, string
// literal and comment. A plain `[^>]*?>` stops inside `onClick={() =>` and
// hides any `title=` written after the handler (#113688). Comments matter too:
// a `// …doesn't…` note between attributes would otherwise open a quote.
function findTagClose(content: string, start: number): number {
let depth = 0
let quote: string | null = null
let quote: null | string = null
for (let i = start; i < content.length; i++) {
const char = content[i]
if (quote) {
if (char === quote && content[i - 1] !== '\\') {
if (char === '\\') {
i++
} else if (char === quote) {
quote = null
}
continue
}
if (char === '/' && content[i + 1] === '/') {
const eol = content.indexOf('\n', i)
if (eol < 0) {
return -1
}
i = eol
continue
}
if (char === '/' && content[i + 1] === '*') {
const close = content.indexOf('*/', i + 2)
if (close < 0) {
return -1
}
i = close + 1
continue
}
if (char === '"' || char === "'" || char === '`') {
quote = char
continue
}
if (char === '{') {
} else if (char === '{') {
depth++
} else if (char === '}') {
depth--
@@ -106,3 +116,62 @@ function findBraceDepthZeroClose(content: string, start: number): number {
return -1
}
// `title=` as an attribute of its own — not `data-title=` / `subtitle=`.
const TITLE_ATTR = /(?<![\w.-])title=/u
function titleViolations(content: string, relativePath: string): string[] {
const violations: string[] = []
for (const { attrs, index, tagName } of eachButtonOpenTag(content)) {
if (TITLE_ATTR.test(attrs)) {
const lineNum = content.slice(0, index).split('\n').length
violations.push(`${relativePath}:${lineNum} <${tagName}> has title= — use <Tip> or aria-label`)
}
}
return violations
}
describe('no native title= on button elements', () => {
it('sees title= after inline handlers and inside comment-bearing tags, and nowhere else', () => {
const flagged = [
'<button onClick={() => {}} title="probe">hi</button>',
'<Button\n onClick={event => {\n event.preventDefault()\n }}\n // macOS doesn\'t focus a button on mousedown\n title={copy.send}\n type="button"\n>',
"<button className={cn(saved ? 'a' : 'b')} title={on ? m.off(t) : m.on(t)}>"
]
const clean = [
'<button aria-label="a > b" onClick={() => {}}>hi</button>',
'<button data-title="x" onClick={() => {}}>hi</button>',
'<button onClick={() => {}}>{`title=${x}`}</button>',
'<span title="fine"><button type="button">hi</button></span>'
]
for (const source of flagged) {
expect(titleViolations(source, 'probe.tsx'), source).toHaveLength(1)
}
for (const source of clean) {
expect(titleViolations(source, 'probe.tsx'), source).toEqual([])
}
})
// Scan every shipped .tsx file under src/ for <button or <Button opening tags
// that also carry a title= attribute (anywhere in the opening tag, which may
// span multiple lines).
it('uses <Tip> or aria-label instead of native title= on all button elements', () => {
const violations: string[] = []
const srcDir = resolve(__dirname, '../../..')
for (const filePath of collectTsxFiles(srcDir)) {
const content = readFileSync(filePath, 'utf-8')
const relativePath = filePath.replace(srcDir + '/', '')
violations.push(...titleViolations(content, relativePath))
}
expect(violations, violations.join('\n')).toEqual([])
})
})

View File

@@ -14,7 +14,7 @@ describe('Zoomable', () => {
)
expect(screen.queryByTestId('overlay')).toBeNull()
fireEvent.click(screen.getByTitle('Open diagram'))
fireEvent.click(screen.getByRole('button', { name: 'Open diagram' }))
expect(screen.getByTestId('overlay')).toBeTruthy()
})
@@ -25,7 +25,7 @@ describe('Zoomable', () => {
</Zoomable>
)
fireEvent.click(screen.getByTitle('Open diagram'))
fireEvent.click(screen.getByRole('button', { name: 'Open diagram' }))
const dialog = screen.getByRole('dialog')
const body = dialog.firstElementChild

View File

@@ -35,9 +35,9 @@ export function Zoomable({ children, overlay, onCopy, label = 'Open full view',
<div className={cn('group/zoomable relative', className)}>
{/* The whole content is the trigger — click anywhere to open, like an image. */}
<button
aria-label={label}
className="block w-full cursor-zoom-in text-left"
onClick={() => setOpen(true)}
title={label}
type="button"
>
{children}

View File

@@ -192,7 +192,6 @@ export function AvatarPicker({ shape, color, image, onShape, onColor, onImage, g
<Button
onClick={() => onShape(blobShapeString(locked ? '' : pickerName, kind))}
size="sm"
title={locked ? b.avatar.unlockFollowsName : 'Keep this exact face even if the name changes'}
type="button"
variant="ghost"
>

View File

@@ -1347,11 +1347,7 @@ export function CreateGroupChatDialog({ open, roster, onClose, onCreated }: Crea
<Button onClick={onClose} variant="secondary">
{t.common.cancel}
</Button>
<Button
disabled={!canCreate}
onClick={create}
title={selected.length < 2 ? 'Pick at least 2 bots' : undefined}
>{`Create Group${selected.length ? ` (${selected.length})` : ''}`}</Button>
<Button disabled={!canCreate} onClick={create}>{`Create Group${selected.length ? ` (${selected.length})` : ''}`}</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -962,16 +962,18 @@ export function GroupChatWorkspace({ group, members, onBack, visible = true }: G
}
const attachButton = (thread: null | string) => (
<Button
className="shrink-0 text-(--ui-text-tertiary) hover:text-foreground"
onClick={() => void pickGroupAttachments().then(picked => addImages(thread, picked))}
size="sm"
title={b.group.attachHint}
type="button"
variant="ghost"
>
<Codicon name="attach" />
</Button>
<Tip label={b.group.attachHint}>
<Button
aria-label={b.group.attachHint}
className="shrink-0 text-(--ui-text-tertiary) hover:text-foreground"
onClick={() => void pickGroupAttachments().then(picked => addImages(thread, picked))}
size="sm"
type="button"
variant="ghost"
>
<Codicon name="attach" />
</Button>
</Tip>
)
// #91359: recognized @mentions render as inline references; recomputed
@@ -1076,15 +1078,16 @@ export function GroupChatWorkspace({ group, members, onBack, visible = true }: G
{isUser ? (
<span className="text-[0.7rem] font-semibold text-foreground">{label}</span>
) : (
<Button
className="text-left text-[0.7rem] font-semibold text-(--ui-accent)"
onClick={() => setRevealedSpeaker(revealed ? null : entryKey)}
size="inline"
title={revealed ? 'Hide full handle' : 'Show full handle'}
variant="text"
>
{label}
</Button>
<Tip label={revealed ? 'Hide full handle' : 'Show full handle'}>
<Button
className="text-left text-[0.7rem] font-semibold text-(--ui-accent)"
onClick={() => setRevealedSpeaker(revealed ? null : entryKey)}
size="inline"
variant="text"
>
{label}
</Button>
</Tip>
)}
<span className="text-[0.625rem] text-(--ui-text-quaternary)">{relativeTime(entry.at)}</span>
{entry.text.trim() || !isUser ? (

View File

@@ -252,11 +252,11 @@ export function HubSkillsSection({ forProfile, onInstalled }: HubSkillsSectionPr
<span className="shrink-0 text-[0.65rem] text-(--ui-text-tertiary)">✓ added</span>
) : (
<Button
aria-label={`Install "${r.name}" and add it to the list above`}
className="shrink-0 px-2 font-semibold"
disabled={installing !== null}
onClick={() => void install(r.name)}
size="sm"
title={`Install "${r.name}" and add it to the list above`}
variant="ghost"
>
{installing === r.name ? '…' : '+'}