feat(kimi-web): consistent rules for the right-side detail layer

The transient detail panels (thinking, compaction summary, subagent
detail, mobile file/media preview) now share one set of rules:
- the aside is a labelled role=complementary region (aria-hidden when
  collapsed),
- Escape closes whichever panel is open — handled in App on the capture
  phase so it takes precedence over the conversation's "Esc interrupts a
  run" handler instead of firing both,
- every close button has an aria-label (not just a title) and a
  focus-visible ring; thinking/subagent close targets bumped to 28x28,
- FilePreview toolbar buttons get focus-visible rings too.
This commit is contained in:
qer 2026-06-16 15:38:30 +08:00
parent 22f6c1d265
commit f5caf4cca7
6 changed files with 53 additions and 8 deletions

View file

@ -1,6 +1,6 @@
<!-- apps/kimi-web/src/App.vue -->
<script setup lang="ts">
import { computed, nextTick, onMounted, provide, ref, watch } from 'vue';
import { computed, nextTick, onMounted, onUnmounted, provide, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import Sidebar from './components/Sidebar.vue';
import ResizeHandle from './components/ResizeHandle.vue';
@ -75,8 +75,33 @@ function openOnboarding(): void {
onMounted(() => {
void client.load();
// Capture-phase so Escape closes the side detail layer BEFORE the
// conversation pane's bubble-phase handler interrupts a running prompt.
document.addEventListener('keydown', onGlobalKeydown, true);
});
onUnmounted(() => {
document.removeEventListener('keydown', onGlobalKeydown, true);
});
// Escape closes whichever transient right-side detail panel is open (thinking,
// compaction summary, subagent detail, or the mobile file/media preview).
function closeOpenSidePanel(): boolean {
if (thinkingVisible.value) { closeThinkingPanel(); return true; }
if (compactionPanelVisible.value) { closeCompactionPanel(); return true; }
if (agentPanelVisible.value) { closeAgentPanel(); return true; }
if (sidePreviewVisible.value) { closeFilePreview(); return true; }
return false;
}
function onGlobalKeydown(e: KeyboardEvent): void {
if (e.key !== 'Escape') return;
if (closeOpenSidePanel()) {
e.stopPropagation();
e.preventDefault();
}
}
// ---------------------------------------------------------------------------
// Layout: resizable session column. ResizeHandle owns the column width (with
// localStorage persistence); we mirror it here to drive the App grid.
@ -849,6 +874,9 @@ function openPr(url: string): void {
v-if="!isMobile || sidePanelVisible"
class="global-preview"
:class="{ open: sidePanelVisible, mobile: isMobile, 'no-anim': panelDragging }"
role="complementary"
:aria-label="t('layout.detailPanelAria')"
:aria-hidden="!sidePanelVisible"
>
<ThinkingPanel
v-if="thinkingVisible"

View file

@ -55,7 +55,7 @@ watch(
<span class="ap-title">{{ t('common.preview') }}</span>
<span class="ap-sub">{{ member.name }}</span>
<span class="ap-phase" :class="`phase-${member.phase}`">{{ phaseLabel(member.phase) }}</span>
<button type="button" class="ap-close" :title="t('thinking.close')" @click="emit('close')">
<button type="button" class="ap-close" :title="t('thinking.close')" :aria-label="t('thinking.close')" @click="emit('close')">
<svg viewBox="0 0 12 12" width="11" height="11" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><line x1="2" y1="2" x2="10" y2="10"/><line x1="10" y1="2" x2="2" y2="10"/></svg>
</button>
</div>
@ -134,8 +134,8 @@ watch(
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
width: 28px;
height: 28px;
background: none;
border: none;
border-radius: 5px;
@ -146,6 +146,10 @@ watch(
background: var(--hover);
color: var(--ink);
}
.ap-close:focus-visible {
outline: 2px solid var(--blue);
outline-offset: -2px;
}
.ap-body {
flex: 1;

View file

@ -439,7 +439,7 @@ function truncatePath(path: string, maxLen = 55): string {
<svg v-if="!copied" viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="9" height="9" rx="1.5"/><path d="M6 1h7a1 1 0 0 1 1 1v7"/></svg>
<svg v-else viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3,8 6.5,11.5 13,5"/></svg>
</button>
<button v-if="closable" type="button" class="fp-close" :title="t('filePreview.close')" @click="emit('close')">
<button v-if="closable" type="button" class="fp-close" :title="t('filePreview.close')" :aria-label="t('filePreview.close')" @click="emit('close')">
×
</button>
</div>
@ -685,6 +685,13 @@ function truncatePath(path: string, maxLen = 55): string {
color: var(--blue2);
border-color: var(--bd);
}
.fp-action:focus-visible,
.fp-icon-btn:focus-visible,
.fp-close:focus-visible,
.fp-seg-btn:focus-visible {
outline: 2px solid var(--blue);
outline-offset: -1px;
}
.fp-action.copied {
color: var(--ok);
border-color: color-mix(in srgb, var(--ok) 35%, var(--bg));

View file

@ -41,7 +41,7 @@ watch(
<div class="tp-header">
<span class="tp-title">{{ t('common.preview') }}</span>
<span class="tp-sub">{{ subtitle ?? t('thinking.panelTitle') }}</span>
<button type="button" class="tp-close" :title="t('thinking.close')" @click="emit('close')">
<button type="button" class="tp-close" :title="t('thinking.close')" :aria-label="t('thinking.close')" @click="emit('close')">
<svg viewBox="0 0 12 12" width="11" height="11" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><line x1="2" y1="2" x2="10" y2="10"/><line x1="10" y1="2" x2="2" y2="10"/></svg>
</button>
</div>
@ -95,8 +95,8 @@ watch(
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
width: 28px;
height: 28px;
background: none;
border: none;
border-radius: 5px;
@ -107,6 +107,10 @@ watch(
background: var(--hover);
color: var(--ink);
}
.tp-close:focus-visible {
outline: 2px solid var(--blue);
outline-offset: -2px;
}
.tp-body {
flex: 1;

View file

@ -1,4 +1,5 @@
export default {
resizeHandleAria: 'Resize sidebar width',
resizePreviewAria: 'Resize preview panel width',
detailPanelAria: 'Detail panel',
} as const;

View file

@ -1,4 +1,5 @@
export default {
resizeHandleAria: '调整侧栏宽度',
resizePreviewAria: '调整预览面板宽度',
detailPanelAria: '详情面板',
};