Skip to content

Commit 9effb0e

Browse files
committed
Query dialogs: clear the AI badge + hints to AA contrast and size the dialog up a step
Polishes accessibility and readability of the Search and Select dialogs (M7), plus two latent bugs surfaced along the way. - a11y contrast (the static `a11y-contrast` check is the only contrast gate; axe disables `color-contrast`): - Extend the check to model the Search/Select pairs the rule walker can't see (`query_dialog_states.go`, reusing the `dropdown_states.go` scenario type + accent-matrix sweep): the `ToggleGroup` "AI" badge, the shortcut hint, the under-cursor result row's muted columns on the accent-tinted cursor bg, and the footer shortcut hints (on the dialog surface and on the primary button). Added a `BgExpr` escape hatch alongside `FgExpr` so a pair can model an `opacity` composite or a tint-over-surface bg. - Fix the real violations these caught: the `.tg-hint` shortcut hint dropped to ~3:1 under `opacity: 0.7`, the under-cursor result columns fell to ~3.9-4.4:1 on the lightest accents, and the footer hints dropped below AA at `opacity: 0.8`. All now clear 4.5:1 by dropping the opacity crutch (`--color-text-tertiary` already reads quieter) and routing the under-cursor muted columns to `--color-text-primary`. No new token needed. - Font bump: the dialog now runs one `--font-size-*` step larger across bar, mode chips, type toggle, filter chips, results rows, path pills, and footer, both dialogs. Shared `ToggleGroup` cells are bumped via scoped overrides so Settings keeps its sizing. The results list isn't virtualized (search caps at 30 rows, Selection lists one folder), so row height is content-driven with no constant to desync; PathPills and the Name column measure from the rendered font and self-adjust; `ch`-unit columns scale with it; the bump is rem-based so it composes with the system text-size watcher. - Type toggle `aria-checked`: memoize the value array handed to Ark/zag (`$derived([value])`) so a same-value parent re-render (for example after a query runs) can't churn the controlled value and blip the single-select `aria-checked` state. - Bug (caught by the slow E2E lane): the `search-modes` spec still asserted the pre-M5 "empty target buffer stays empty" behavior; updated it to the shipped M5 carry-over (empty target seeds the outgoing term, non-empty target preserved). - Bug (caught by the Search-dialog a11y E2E): `.results-container` set `role="listbox"` whenever `results.length > 0`, so a reopened dialog re-running (spinner showing, persisted results still set) rendered an orphan listbox with no `option` children (axe `aria-required-children`, critical). Gated `role`/`aria-label` on a `showingRows` derived that mirrors when rows actually render. Tests: contrast-check red->green captured in `query_dialog_states_test.go`; virtualization-free row-sizing + cursor-class tests; `aria-checked` attribute test; orphan-listbox a11y regression test. Full `pnpm check` green; affected Playwright specs (Search dialog light+dark a11y, modes, open-in-pane) verified green on a fresh data dir.
1 parent df81950 commit 9effb0e

19 files changed

Lines changed: 411 additions & 48 deletions

apps/desktop/src/lib/query-ui/AiPromptStrip.svelte

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@
100100
.ai-prompt {
101101
margin: 0;
102102
color: var(--color-text-secondary);
103-
font-size: var(--font-size-sm);
103+
font-size: var(--font-size-md);
104104
line-height: 1.3;
105105
overflow: hidden;
106106
white-space: nowrap;
@@ -111,7 +111,7 @@
111111
display: flex;
112112
flex-direction: column;
113113
gap: var(--spacing-xxs);
114-
font-size: var(--font-size-sm);
114+
font-size: var(--font-size-md);
115115
line-height: 1.3;
116116
}
117117
@@ -160,7 +160,7 @@
160160
.ai-caveat {
161161
margin: 0;
162162
color: var(--color-text-tertiary);
163-
font-size: var(--font-size-sm);
163+
font-size: var(--font-size-md);
164164
font-style: italic;
165165
line-height: 1.3;
166166
overflow: hidden;
@@ -171,7 +171,7 @@
171171
.refine-button {
172172
flex-shrink: 0;
173173
padding: var(--spacing-xxs) var(--spacing-sm);
174-
font-size: var(--font-size-sm);
174+
font-size: var(--font-size-md);
175175
font-weight: 500;
176176
line-height: 1;
177177
color: var(--color-text-secondary);

apps/desktop/src/lib/query-ui/CLAUDE.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,11 @@ popover, and the `createQueryFilterState()` factory. Filter-chip internals live
5858
populated-results a11y test with a comment pointing at the rationale; don't "fix" it by retabbing.
5959
- **Status bar stays empty whenever the content area shows a state message** (Searching / No files match / Loading).
6060
When you add a content-area state in `QueryResults`, make `getStatusText()` return `''` for it, or it reads as broken.
61+
- **`.results-container` carries `role="listbox"` ONLY when option rows actually render** (the `showingRows` derived:
62+
`isIndexAvailable && isIndexReady && !isSearching && results.length > 0`). Don't gate it on `results.length > 0`
63+
alone: on reopen the dialog re-runs with the persisted `results` still set while the spinner shows, so a length-only
64+
gate puts `role="listbox"` on a container with no `option` children = axe `aria-required-children` (critical). Pinned
65+
by the "searching with stale results" test in `QueryResults.a11y.test.ts`.
6166
- **Content chip is visible-disabled with NO shortcut** (`⌘4` reserved): wiring a shortcut to a disabled control is
6267
hostile UX. When Content ships it claims `⌘3` and Regex moves to `⌘4`.
6368
- **AI mode never auto-applies** (cost); filename/regex auto-apply behind `search.autoApply` (default on, 1,000 ms

apps/desktop/src/lib/query-ui/ModeChips.svelte

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,4 +87,17 @@
8787
background: var(--color-bg-primary);
8888
flex-wrap: wrap;
8989
}
90+
91+
/* The query dialogs run one font-size step larger than Settings (the dialog is the
92+
focal surface, not a settings row), so bump the shared `ToggleGroup` cells here only.
93+
Scoped to `.mode-chips-wrap` so Settings' segmented controls keep their own sizing.
94+
`:global` because `ToggleGroup` prints `.tg-*` via `:global` (see ToggleGroup.svelte). */
95+
.mode-chips-wrap :global(.tg-root .tg-item) {
96+
font-size: var(--font-size-md);
97+
}
98+
99+
.mode-chips-wrap :global(.tg-root .tg-badge),
100+
.mode-chips-wrap :global(.tg-root .tg-hint) {
101+
font-size: var(--font-size-sm);
102+
}
90103
</style>

apps/desktop/src/lib/query-ui/PathPills.svelte

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -271,9 +271,11 @@
271271
272272
.sep {
273273
color: var(--color-text-tertiary);
274-
/* Path text reads at --font-size-sm (matching Name) instead of --font-size-xs.
275-
The eye reads the path as quickly as the filename, not as a footnote. */
276-
font-size: var(--font-size-sm);
274+
/* Path text reads at --font-size-md (matching Name in the dialog's one-step-larger
275+
type). The eye reads the path as quickly as the filename, not as a footnote.
276+
Widths are measured from the rendered font (`readFont` → pretext), so the collapse
277+
layout self-adjusts to this size; no constant to keep in sync. */
278+
font-size: var(--font-size-md);
277279
user-select: none;
278280
}
279281
@@ -284,7 +286,7 @@
284286
vertical padding stays at 0 since the row padding handles vertical rhythm. */
285287
padding: 0 var(--spacing-xxs);
286288
border-radius: var(--radius-sm);
287-
font-size: var(--font-size-sm);
289+
font-size: var(--font-size-md);
288290
font-family: inherit;
289291
color: var(--color-text-tertiary);
290292
line-height: 1.2;

apps/desktop/src/lib/query-ui/QueryBar.svelte

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@
143143
144144
.query-input {
145145
flex: 1;
146-
font-size: var(--font-size-lg);
146+
font-size: var(--font-size-xl);
147147
border: 1px solid transparent;
148148
background: transparent;
149149
color: var(--color-text-primary);
@@ -170,7 +170,7 @@
170170
.run-hint {
171171
flex-shrink: 0;
172172
color: var(--color-text-tertiary);
173-
font-size: var(--font-size-xs);
173+
font-size: var(--font-size-sm);
174174
white-space: nowrap;
175175
}
176176
@@ -189,7 +189,7 @@
189189
color: var(--color-text-secondary);
190190
cursor: default;
191191
line-height: 1;
192-
font-size: var(--font-size-sm);
192+
font-size: var(--font-size-md);
193193
}
194194
195195
.run-label {

apps/desktop/src/lib/query-ui/QueryDialog.svelte

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1037,7 +1037,7 @@
10371037
background: var(--color-bg-primary);
10381038
border-bottom: 1px solid var(--color-border-subtle);
10391039
color: var(--color-text-tertiary);
1040-
font-size: var(--font-size-xs);
1040+
font-size: var(--font-size-sm);
10411041
flex-shrink: 0;
10421042
}
10431043
@@ -1068,16 +1068,19 @@
10681068
padding: var(--spacing-sm) var(--spacing-lg);
10691069
}
10701070
1071+
/* No `opacity` dimming on the shortcut hints: at `opacity: 0.8` the composited
1072+
tertiary gray (and the accent-fg on the primary button) dropped below WCAG AA on
1073+
the lighter accents. `--color-text-tertiary` already reads quieter than the button
1074+
label, and `--color-accent-fg` is auto-picked for max contrast on its accent.
1075+
The contrast checker models both pairs (`scripts/check-a11y-contrast/query_dialog_states.go`). */
10711076
.shortcut-hint {
10721077
margin-left: var(--spacing-xs);
10731078
font-family: var(--font-mono);
1074-
font-size: var(--font-size-xs);
1079+
font-size: var(--font-size-sm);
10751080
color: var(--color-text-tertiary);
1076-
opacity: 0.8;
10771081
}
10781082
10791083
.shortcut-hint.shortcut-on-primary {
10801084
color: var(--color-accent-fg);
1081-
opacity: 0.8;
10821085
}
10831086
</style>

apps/desktop/src/lib/query-ui/QueryResults.a11y.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,41 @@ describe('SearchResults a11y', () => {
138138
await expectNoA11yViolations(target)
139139
})
140140

141+
// Regression: a reopened dialog re-runs (spinner showing) while `results` still holds the
142+
// persisted prior set. The spinner replaces the rows, so `role="listbox"` must NOT be set
143+
// (no `option` children = axe `aria-required-children` critical). Pre-fix the role gated on
144+
// `results.length > 0` alone and tripped here; now it gates on actually-rendered rows.
145+
it('searching with stale results still present has no a11y violations (no orphan listbox)', async () => {
146+
const stale: SearchResultEntry[] = [
147+
{
148+
name: 'photo1.jpg',
149+
path: '/Users/test/pictures/photo1.jpg',
150+
parentPath: '/Users/test/pictures',
151+
isDirectory: false,
152+
size: 1_500_000,
153+
modifiedAt: 1_710_000_000,
154+
iconId: 'ext:jpg',
155+
},
156+
]
157+
const target = document.createElement('div')
158+
document.body.appendChild(target)
159+
mount(SearchResults, {
160+
target,
161+
props: {
162+
...defaultProps,
163+
isSearching: true,
164+
hasSearched: true,
165+
query: '*.jpg',
166+
results: stale,
167+
totalCount: 1,
168+
},
169+
})
170+
await tick()
171+
// The container must not claim to be a listbox while it shows the spinner.
172+
expect(target.querySelector('[role="listbox"]')).toBeNull()
173+
await expectNoA11yViolations(target)
174+
})
175+
141176
// Populated rows are `role="option"` AND contain interactive children
142177
// (path-pill `<button>`s and the `…` row-menu `<button>`). The inner buttons
143178
// are mouse-only and intentionally outside the keyboard Tab order

apps/desktop/src/lib/query-ui/QueryResults.states.svelte.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,3 +212,43 @@ describe('SearchResults round 2 states', () => {
212212
expect(status?.textContent ?? '').toBe('')
213213
})
214214
})
215+
216+
describe('SearchResults row rendering (font-bump sizing)', () => {
217+
function makeResults(n: number): SearchResultEntry[] {
218+
return Array.from({ length: n }, (_, i) => ({
219+
path: `/dir/file-${String(i)}.txt`,
220+
name: `file-${String(i)}.txt`,
221+
parentPath: '/dir',
222+
isDirectory: false,
223+
size: i,
224+
modifiedAt: 0,
225+
iconId: 'ext:txt',
226+
}))
227+
}
228+
229+
// The results list is plain DOM (no virtualization: Search caps at 30 rows, Selection
230+
// lists a single folder), so the dialog's one-step-larger font can't desync from a
231+
// fixed row-height constant — there is none. This pins the invariant the font bump
232+
// relies on: every result renders its own `.result-row`, so the rendered count tracks
233+
// the data exactly at any font size. If someone ever virtualizes this list, they'll
234+
// have to re-derive the row height for the bumped font, and this count check guards it.
235+
it('renders one row per result (no windowing, no clipped rows)', async () => {
236+
const results = makeResults(30)
237+
const target = mountWith({ results, hasSearched: true, query: '*.txt', totalCount: 30 })
238+
await tick()
239+
expect(target.querySelectorAll('.result-row').length).toBe(30)
240+
})
241+
242+
// The under-cursor row routes the muted columns (path / size / modified) to
243+
// `--color-text-primary` for AA contrast on the accent-tinted cursor bg. That CSS
244+
// hangs off the `is-under-cursor` class, so pin that exactly one row carries it and
245+
// it's the cursor row.
246+
it('marks exactly the cursor row with is-under-cursor (drives the AA color override)', async () => {
247+
const results = makeResults(5)
248+
const target = mountWith({ results, hasSearched: true, query: '*.txt', totalCount: 5, cursorIndex: 2 })
249+
await tick()
250+
const cursorRows = target.querySelectorAll('.result-row.is-under-cursor')
251+
expect(cursorRows.length).toBe(1)
252+
expect(cursorRows[0].textContent).toContain('file-2.txt')
253+
})
254+
})

apps/desktop/src/lib/query-ui/QueryResults.svelte

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,13 @@
159159
return out
160160
}
161161
162+
// True only when the `{:else}` branch below actually renders option rows. `role="listbox"`
163+
// requires `option` children, so it must NOT be set during the searching / loading / empty
164+
// states (which replace the rows with a spinner or message) even when `results` still holds a
165+
// stale set. Gating on `results.length > 0` alone tripped axe's `aria-required-children` when
166+
// a reopened dialog re-ran (spinner showing, persisted results still in `results`).
167+
const showingRows = $derived(isIndexAvailable && isIndexReady && !isSearching && results.length > 0)
168+
162169
/** Scrolls the cursor row into view. Called by the parent after cursor changes. */
163170
export function scrollCursorIntoView(): void {
164171
void tick().then(() => {
@@ -185,8 +192,8 @@
185192
<div
186193
class="results-container"
187194
bind:this={resultsContainer}
188-
role={results.length > 0 ? 'listbox' : undefined}
189-
aria-label={results.length > 0 ? 'Search results' : undefined}
195+
role={showingRows ? 'listbox' : undefined}
196+
aria-label={showingRows ? 'Search results' : undefined}
190197
>
191198
{#if !isIndexAvailable}
192199
<div class="index-unavailable">
@@ -343,7 +350,7 @@
343350
}
344351
345352
.col-label {
346-
font-size: var(--font-size-sm);
353+
font-size: var(--font-size-md);
347354
color: var(--color-text-tertiary);
348355
overflow: hidden;
349356
text-overflow: ellipsis;
@@ -410,7 +417,7 @@
410417
margin: 0;
411418
padding: 0 0 0 1.25em;
412419
color: var(--color-text-tertiary);
413-
font-size: var(--font-size-sm);
420+
font-size: var(--font-size-md);
414421
text-align: left;
415422
}
416423
@@ -420,11 +427,13 @@
420427
421428
.result-row {
422429
/* Vertical padding sits at --spacing-xxs (~4 px) instead of --spacing-xs
423-
(~8 px) so the Path column can use --font-size-sm without growing the row.
430+
(~8 px) to keep the row compact at the dialog's --font-size-md type.
424431
All cells vertically center via the grid's `align-items: center` rule above,
425-
so the look stays clean with the tighter padding. */
432+
so the look stays clean with the tighter padding. Rows aren't virtualized
433+
(search caps at 30, Selection lists one folder), so the height is content-
434+
driven: no row-height constant to keep in sync with the font. */
426435
padding: var(--spacing-xxs) var(--spacing-lg);
427-
font-size: var(--font-size-sm);
436+
font-size: var(--font-size-md);
428437
color: var(--color-text-primary);
429438
}
430439
@@ -436,6 +445,17 @@
436445
background: var(--color-accent-subtle);
437446
}
438447
448+
/* Under the cursor the muted columns (path / size / modified) read at full
449+
`--color-text-primary`: the tertiary / secondary tokens drop below WCAG AA
450+
on the lightest accent tints of the cursor bg (verified by the contrast
451+
checker, `scripts/check-a11y-contrast/query_dialog_states.go`). Full-contrast
452+
text on the active row is also the expected "this row is focused" read. */
453+
.result-row.is-under-cursor .result-path,
454+
.result-row.is-under-cursor .result-size,
455+
.result-row.is-under-cursor .result-modified {
456+
color: var(--color-text-primary);
457+
}
458+
439459
.result-icon {
440460
display: flex;
441461
align-items: center;
@@ -499,7 +519,7 @@
499519
padding: var(--spacing-xs) var(--spacing-lg);
500520
background: var(--color-bg-secondary);
501521
border-top: 1px solid var(--color-border-subtle);
502-
font-size: var(--font-size-sm);
522+
font-size: var(--font-size-md);
503523
color: var(--color-text-tertiary);
504524
flex-shrink: 0;
505525
}
@@ -522,7 +542,7 @@
522542
523543
.unavailable-progress {
524544
color: var(--color-text-tertiary);
525-
font-size: var(--font-size-sm);
545+
font-size: var(--font-size-md);
526546
margin: var(--spacing-xs) 0 0;
527547
}
528548
</style>

apps/desktop/src/lib/query-ui/filter-chips/FilterChip.svelte

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@
130130
align-items: center;
131131
gap: var(--spacing-xs);
132132
padding: var(--spacing-xxs) var(--spacing-sm);
133-
font-size: var(--font-size-sm);
133+
font-size: var(--font-size-md);
134134
font-weight: 500;
135135
line-height: 1;
136136
color: var(--color-text-secondary);
@@ -191,7 +191,7 @@
191191
height: 14px;
192192
border-radius: var(--radius-full);
193193
color: var(--color-text-tertiary);
194-
font-size: var(--font-size-sm);
194+
font-size: var(--font-size-md);
195195
line-height: 1;
196196
}
197197

0 commit comments

Comments
 (0)