polish(ui): apply ui-skills audit across a11y, motion cost, and numerics

fixing-accessibility: expandable rows expose aria-expanded/controls, user
messages become keyboard-operable buttons, pane dividers and the composer
gain accessible names, the live status row announces politely, the busy
form sets aria-busy, feedback failures render inline next to the form,
and the muted/faint text tiers rise to AA-viable contrast.

fixing-motion-performance: composer autosize moves to CSS field-sizing
(dropping the per-keystroke measure/write cycle) and streaming scroll
writes coalesce per frame while direct user actions stay synchronous.

baseline-ui: data columns use tabular numerals, headings balance and
prose wraps pretty, and z-index joins the token scale.

improve-ui: straggler radii and control heights land on the declared
size scale (radius-xs joins the ramp).

fixing-metadata: the document language follows the active locale.
This commit is contained in:
NI0317
2026-07-19 11:32:07 +08:00
parent 74390bca0a
commit 96076606df
3 changed files with 108 additions and 42 deletions
+45 -23
View File
@@ -211,7 +211,7 @@ function buildShell(): void {
<aside class="source-list">
<header class="app-brand">
<button class="brand-title" data-module="sessions">DeepSeek Harness</button>
<span class="runtime-dot starting" id="runtimeDot"></span>
<span class="runtime-dot starting" id="runtimeDot" aria-hidden="true"></span>
</header>
<section class="primary-actions">
<button class="primary-action" data-action="new-session"><span data-text="app.newChat"></span><kbd>⌘N</kbd></button>
@@ -233,7 +233,7 @@ function buildShell(): void {
<strong id="footerRuntime"></strong>
</footer>
</aside>
<div class="pane-divider" id="dividerLeft" role="separator" aria-orientation="vertical"></div>
<div class="pane-divider" id="dividerLeft" role="separator" aria-orientation="vertical" data-label="app.resizeSidebar"></div>
<main class="harness-main">
<header class="topbar">
<div class="title-area"><h1 id="topbarTitle"></h1></div>
@@ -283,7 +283,7 @@ function buildShell(): void {
</div>
</form>
</main>
<div class="pane-divider" id="dividerInspector" role="separator" aria-orientation="vertical" hidden></div>
<div class="pane-divider" id="dividerInspector" role="separator" aria-orientation="vertical" hidden data-label="app.resizeInspector"></div>
<aside class="inspector" id="inspector" hidden>
<header class="inspector-head">
<div>
@@ -348,11 +348,16 @@ function applyStaticText(): void {
for (const node of appEl.querySelectorAll<HTMLElement>('[data-title]')) {
node.title = t(node.dataset.title as I18nKey)
}
for (const node of appEl.querySelectorAll<HTMLElement>('[data-label]')) {
node.setAttribute('aria-label', t(node.dataset.label as I18nKey))
}
document.documentElement.lang = state.locale
el.searchInput.placeholder = t('app.searchPlaceholder')
el.composerInput.placeholder = state.selectedSessionId === undefined
? t('composer.placeholderDraft')
: t('composer.placeholderSession')
el.sendButton.setAttribute('aria-label', t('composer.send'))
el.composerInput.setAttribute('aria-label', t('composer.send'))
updateComposerState()
}
@@ -652,7 +657,7 @@ function ensureLiveSkeleton(live: LiveTurn): void {
<div data-live="tools"></div>
</div>
<div class="assistant-prose" data-live="answer" hidden></div>
<div class="live-status" data-live="status">${escapeHtml(t('chat.sending'))}</div>
<div class="live-status" role="status" data-live="status">${escapeHtml(t('chat.sending'))}</div>
</div>
</article>
`
@@ -670,11 +675,26 @@ function ensureLiveSkeleton(live: LiveTurn): void {
}
}
let scrollToBottomQueued = false
/** Direct user actions scroll immediately; streaming updates coalesce per frame. */
function scrollChatToBottom(force = false): void {
if (!force && !state.stickToBottom) return
el.chatView.scrollTop = el.chatView.scrollHeight
state.stickToBottom = true
updateLiveJump()
if (force) {
scrollToBottomQueued = false
el.chatView.scrollTop = el.chatView.scrollHeight
state.stickToBottom = true
updateLiveJump()
return
}
if (!state.stickToBottom || scrollToBottomQueued) return
scrollToBottomQueued = true
window.requestAnimationFrame(() => {
if (!scrollToBottomQueued) return
scrollToBottomQueued = false
el.chatView.scrollTop = el.chatView.scrollHeight
state.stickToBottom = true
updateLiveJump()
})
}
function updateLiveJump(): void {
@@ -690,11 +710,7 @@ function updateComposerState(): void {
el.cancelButton.hidden = !busy
el.composerHint.textContent = busy ? t('chat.working') : t('composer.hint')
el.composerForm.classList.toggle('busy', busy)
}
function autosizeComposer(): void {
el.composerInput.style.height = '0px'
el.composerInput.style.height = `${Math.min(132, Math.max(42, el.composerInput.scrollHeight))}px`
el.composerForm.setAttribute('aria-busy', String(busy))
}
async function sendPrompt(prompt: string): Promise<void> {
@@ -714,7 +730,6 @@ async function sendPrompt(prompt: string): Promise<void> {
})
state.busySessionId = draftKey
el.composerInput.value = ''
autosizeComposer()
updateComposerState()
showError('')
renderLiveTurn()
@@ -762,7 +777,6 @@ async function sendPrompt(prompt: string): Promise<void> {
showError(String(error))
if ((state.selectedSessionId === sessionId || (sessionId === undefined && state.draftChat)) && el.composerInput.value.trim().length === 0) {
el.composerInput.value = prompt
autosizeComposer()
}
updateComposerState()
} finally {
@@ -907,7 +921,7 @@ function renderConversation(): void {
function renderConversationTurn(turn: ChatTurn): string {
const userTarget = turn.userTargetId === undefined ? undefined : state.graph.targets.get(turn.userTargetId)
const user = userTarget === undefined ? '' : `
<article class="message user ${selectedTargetClass(userTarget.id)}" data-target-id="${escapeHtml(userTarget.id)}">
<article class="message user ${selectedTargetClass(userTarget.id)}" role="button" tabindex="0" data-target-id="${escapeHtml(userTarget.id)}">
<div class="message-card"><div class="user-bubble">${escapeHtml(contentText(userTarget.output))}</div></div>
</article>
`
@@ -932,9 +946,9 @@ function renderConversationActivity(activity: ChatActivity): string {
<section class="chat-activity thinking ${selectedTargetClass(target.id)}">
<div class="activity-row">
<button class="activity-select" type="button" data-target-id="${escapeHtml(target.id)}"><span>${escapeHtml(t('chat.thinking'))}</span><strong>${escapeHtml(truncate(contentText(target.output), 112))}</strong></button>
<button class="activity-toggle" type="button" data-toggle-activity="${escapeHtml(target.id)}" aria-label="${escapeHtml(t(expanded ? 'trace.collapseRow' : 'trace.expandRow'))}">${expanded ? '⌃' : '⌄'}</button>
<button class="activity-toggle" type="button" data-toggle-activity="${escapeHtml(target.id)}" aria-expanded="${expanded}" aria-controls="act-${escapeHtml(target.id)}" aria-label="${escapeHtml(t(expanded ? 'trace.collapseRow' : 'trace.expandRow'))}">${expanded ? '⌃' : '⌄'}</button>
</div>
<div class="activity-body" ${expanded ? '' : 'hidden'} data-target-id="${escapeHtml(target.id)}"><div>${escapeHtml(contentText(target.output))}</div></div>
<div class="activity-body" id="act-${escapeHtml(target.id)}" ${expanded ? '' : 'hidden'} data-target-id="${escapeHtml(target.id)}"><div>${escapeHtml(contentText(target.output))}</div></div>
</section>
`
}
@@ -951,9 +965,9 @@ function renderChatToolActivity(target: TraceTarget): string {
<section class="chat-activity tool-use ${failed ? 'failed' : ''} ${selectedTargetClass(target.id)}">
<div class="activity-row">
<button class="activity-select" type="button" data-target-id="${escapeHtml(target.id)}"><span>${escapeHtml(failed ? t('chat.toolFailed') : t('chat.toolUse'))}</span><strong>${escapeHtml(target.title)}${preview.length > 0 ? `<span class="activity-preview"> · ${escapeHtml(preview)}</span>` : ''}</strong></button>
<button class="activity-toggle" type="button" data-toggle-activity="${escapeHtml(target.id)}" aria-label="${escapeHtml(t(expanded ? 'trace.collapseRow' : 'trace.expandRow'))}">${expanded ? '⌃' : '⌄'}</button>
<button class="activity-toggle" type="button" data-toggle-activity="${escapeHtml(target.id)}" aria-expanded="${expanded}" aria-controls="act-${escapeHtml(target.id)}" aria-label="${escapeHtml(t(expanded ? 'trace.collapseRow' : 'trace.expandRow'))}">${expanded ? '⌃' : '⌄'}</button>
</div>
<div class="activity-body" ${expanded ? '' : 'hidden'} data-target-id="${escapeHtml(target.id)}">${inputHtml}${outputHtml}${spawnedHtml}</div>
<div class="activity-body" id="act-${escapeHtml(target.id)}" ${expanded ? '' : 'hidden'} data-target-id="${escapeHtml(target.id)}">${inputHtml}${outputHtml}${spawnedHtml}</div>
</section>
`
}
@@ -1055,7 +1069,7 @@ function renderGraphTrajectoryRow(targetId: string): string {
<span class="token-cell">${escapeHtml(usage.output)}</span>
<span class="token-cell">${escapeHtml(usage.think)}</span>
<span class="token-cell offset">+${escapeHtml(formatMs(target.startTime - state.graph.startTime))}</span>
<button class="chevron traj-expand" type="button" data-toggle-traj="${escapeHtml(target.id)}" aria-label="${escapeHtml(t(expanded ? 'trace.collapseRow' : 'trace.expandRow'))}">${expanded ? '▾' : '▸'}</button>
<button class="chevron traj-expand" type="button" data-toggle-traj="${escapeHtml(target.id)}" aria-expanded="${expanded}" aria-label="${escapeHtml(t(expanded ? 'trace.collapseRow' : 'trace.expandRow'))}">${expanded ? '▾' : '▸'}</button>
</div>
${expanded ? renderGraphTrajectoryBody(target) : ''}
</article>
@@ -1463,10 +1477,19 @@ async function submitFeedback(form: HTMLFormElement, sessionId: string, targetId
state.trace.feedback = [...(state.trace.feedback ?? []), record]
}
state.feedbackDrafts.delete(feedbackDraftKey(sessionId, targetId))
form.querySelector('.form-error')?.remove()
toast(t('feedback.saved'))
return true
} catch (error) {
toast(`${t('feedback.failed')}: ${String(error)}`)
// The error belongs next to the form, not only in a transient toast.
let errorLine = form.querySelector<HTMLElement>('.form-error')
if (errorLine === null) {
errorLine = document.createElement('p')
errorLine.className = 'form-error'
errorLine.setAttribute('role', 'alert')
form.appendChild(errorLine)
}
errorLine.textContent = `${t('feedback.failed')}: ${String(error)}`
return false
}
}
@@ -1490,7 +1513,6 @@ function wireStaticEvents(): void {
})
el.composerInput.addEventListener('input', () => {
autosizeComposer()
updateComposerState()
})
el.composerInput.addEventListener('keydown', (event) => {
+4
View File
@@ -75,6 +75,8 @@ const messages = {
'toast.copied': '已复制',
'feedback.saved': '标注已保存',
'feedback.failed': '标注保存失败',
'app.resizeSidebar': '调整侧栏宽度',
'app.resizeInspector': '调整检查器宽度',
'kind.user': '用户',
'kind.reasoning': '思考',
'kind.assistant': '回复',
@@ -346,6 +348,8 @@ const messages = {
'toast.copied': 'Copied',
'feedback.saved': 'Feedback saved',
'feedback.failed': 'Failed to save feedback',
'app.resizeSidebar': 'Resize sidebar',
'app.resizeInspector': 'Resize inspector',
'kind.user': 'User',
'kind.reasoning': 'Thinking',
'kind.assistant': 'Response',
+59 -19
View File
@@ -15,8 +15,8 @@
--fg: #20201f;
--ink: var(--fg);
--ink-soft: color-mix(in oklab, var(--fg) 72%, transparent);
--muted: color-mix(in oklab, var(--fg) 52%, transparent);
--faint: color-mix(in oklab, var(--fg) 36%, transparent);
--muted: color-mix(in oklab, var(--fg) 58%, transparent);
--faint: color-mix(in oklab, var(--fg) 44%, transparent);
/* Borders derive from the same foreground. */
--line: color-mix(in oklab, var(--fg) 11%, transparent);
@@ -49,11 +49,18 @@
--control-xl: 36px;
--row-table: 30px;
--row-list: 42px;
--radius-xs: 6px;
--radius-sm: 8px;
--radius-md: 10px;
--radius-lg: 12px;
--radius-xl: 16px;
/* Z-index scale: sticky headers, floating controls, overlays, toast. */
--z-sticky: 2;
--z-floating: 3;
--z-overlay: 10;
--z-toast: 40;
/* Motion: one curve, three duration tiers. */
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--duration-fast: 80ms;
@@ -567,7 +574,7 @@ kbd {
margin: 0 auto 18px;
padding: 10px;
border: 1px solid var(--line);
border-radius: 15px;
border-radius: var(--radius-xl);
background: rgba(255, 255, 255, 0.82);
}
@@ -699,7 +706,7 @@ kbd {
max-width: min(680px, 76%);
padding: 10px 13px;
border: 1px solid var(--line);
border-radius: 17px;
border-radius: var(--radius-xl);
background: #f2f2f0;
color: var(--ink);
}
@@ -785,7 +792,7 @@ kbd {
height: var(--control-md);
place-items: center;
padding: 0;
border-radius: 6px;
border-radius: var(--radius-xs);
color: var(--muted);
}
@@ -984,7 +991,7 @@ pre {
align-items: center;
gap: 8px;
width: 100%;
min-height: 38px;
min-height: var(--row-table);
padding: 6px 8px;
border-radius: var(--radius-md);
text-align: left;
@@ -1243,7 +1250,7 @@ pre {
.traj-group-head {
position: sticky;
top: 0;
z-index: 2;
z-index: var(--z-sticky);
display: flex;
align-items: baseline;
gap: 10px;
@@ -1473,7 +1480,7 @@ pre {
}
.traj-inspect {
height: 24px;
height: var(--control-sm);
padding: 0 7px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
@@ -1495,7 +1502,7 @@ pre {
overflow: auto;
padding: 12px 16px 16px;
border: 1px solid var(--line);
border-radius: 14px;
border-radius: var(--radius-lg);
background: #fff;
}
@@ -1510,7 +1517,7 @@ pre {
.wf-head-row {
position: sticky;
top: 0;
z-index: 1;
z-index: var(--z-sticky);
margin-bottom: 6px;
padding-bottom: 6px;
border-bottom: 1px solid var(--line);
@@ -1547,7 +1554,7 @@ pre {
width: 22px;
height: 17px;
place-items: center;
border-radius: 5px;
border-radius: var(--radius-xs);
background: #eef1f6;
color: #4b5565;
font-family: "SF Mono", ui-monospace, Menlo, Monaco, Consolas, monospace;
@@ -1594,7 +1601,7 @@ pre {
.wf-track {
position: relative;
height: 22px;
border-radius: 5px;
border-radius: var(--radius-xs);
background-image: linear-gradient(90deg, rgba(135, 145, 160, 0.18) 1px, transparent 1px);
background-repeat: repeat-x;
background-size: 25% 100%;
@@ -1631,7 +1638,7 @@ pre {
.module-card,
.compare-column {
border: 1px solid var(--line);
border-radius: 14px;
border-radius: var(--radius-lg);
background: rgba(255, 255, 255, 0.78);
}
@@ -1686,8 +1693,9 @@ pre {
}
.composer textarea {
field-sizing: content;
min-height: var(--row-list);
max-height: 128px;
max-height: 132px;
resize: none;
overflow: auto;
border: 0;
@@ -2270,7 +2278,7 @@ pre {
.dev-loop-card {
padding: 14px 16px;
border: 1px solid var(--line);
border-radius: 14px;
border-radius: var(--radius-lg);
background: rgba(255, 255, 255, 0.82);
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.025);
}
@@ -2350,7 +2358,7 @@ dd {
top: 58px;
right: 0;
bottom: 0;
z-index: 10;
z-index: var(--z-overlay);
width: 360px;
box-shadow: var(--shadow);
}
@@ -2399,7 +2407,7 @@ dd {
.chat-live-jump {
position: sticky;
bottom: 12px;
z-index: 3;
z-index: var(--z-floating);
display: block;
width: fit-content;
margin: 0 auto;
@@ -2754,7 +2762,7 @@ dd {
.assistant-prose code,
.assistant-text code {
padding: 1px 5px;
border-radius: 5px;
border-radius: var(--radius-xs);
background: rgba(0, 0, 0, 0.055);
font-family: "SF Mono", ui-monospace, Menlo, Monaco, Consolas, monospace;
font-size: 0.88em;
@@ -2826,7 +2834,7 @@ dd {
position: fixed;
bottom: 26px;
left: 50%;
z-index: 40;
z-index: var(--z-toast);
padding: 8px 14px;
border-radius: 999px;
background: var(--ink);
@@ -2936,3 +2944,35 @@ dd {
.inspector-open .traj-tree {
display: none;
}
/* ── ui-skills pass: numerics, inline form errors ─────────────────────────── */
/* Data columns and metrics read as columns of digits: keep them tabular. */
.token-cell,
.wf-dur,
.g-dur,
.t-dur,
.wf-summary strong,
.session-copy small,
.run-metrics strong {
font-variant-numeric: tabular-nums;
}
/* Inline form errors live next to the action, not only in a toast. */
.form-error {
margin: 6px 0 0;
color: var(--red);
font-size: 12px;
}
/* Headings balance; prose avoids awkward last lines. */
.empty-thread h2,
.empty-state h2 {
text-wrap: balance;
}
.assistant-prose p,
.empty-thread p,
.empty-state p {
text-wrap: pretty;
}