Skip to content

Commit cb88685

Browse files
committed
Settings: Focus search on open, add LinkButton for hand cursor
- Settings window now focuses the search input on open (and on `focus-self` re-focus from ⌘,) so users can start typing immediately. Previous "browser tab order" comment never landed focus reliably. - New `LinkButton` UI primitive in `lib/ui/`: link-styled button (accent text, underline, no border/padding) that owns the only sanctioned `cursor: pointer` in the app — Cmdr globally sets `cursor: default` on `html` and `<a>` for native macOS feel, and stylelint enforces it everywhere else. - `AppearanceSection` switched both link-styled buttons (`.appearance-link`, `.help-toggle`) to `LinkButton`, dropping the duplicated CSS. - Documented the convention in `lib/ui/CLAUDE.md`.
1 parent 0c71636 commit cb88685

4 files changed

Lines changed: 87 additions & 34 deletions

File tree

apps/desktop/src/lib/settings/sections/AppearanceSection.svelte

Lines changed: 9 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import SettingSelect from '../components/SettingSelect.svelte'
77
import SettingToggleGroup from '../components/SettingToggleGroup.svelte'
88
import SettingRadioGroup from '../components/SettingRadioGroup.svelte'
9+
import LinkButton from '$lib/ui/LinkButton.svelte'
910
import { getSettingDefinition, getSetting, setSetting, onSpecificSettingChange } from '$lib/settings'
1011
import { createShouldShow } from '$lib/settings/settings-search'
1112
import { openAppearanceSettings } from '$lib/tauri-commands'
@@ -68,8 +69,8 @@
6869
<SettingRow id="appearance.appColor" label={appColorDef.label} description="" split {searchQuery}>
6970
{#snippet descriptionContent()}
7071
To change your system theme color, go to
71-
<button type="button" class="appearance-link" onclick={() => void openAppearanceSettings()}
72-
>{isMacOS() ? 'System Settings > Appearance' : 'your system appearance settings'}</button
72+
<LinkButton onclick={() => void openAppearanceSettings()}
73+
>{isMacOS() ? 'System Settings > Appearance' : 'your system appearance settings'}</LinkButton
7374
>.
7475
{/snippet}
7576
<div class="app-color-options">
@@ -173,13 +174,11 @@
173174
<div class="format-preview">
174175
Preview: <strong>{formatPreview(customFormat)}</strong>
175176
</div>
176-
<button
177-
type="button"
178-
class="help-toggle"
179-
onclick={() => (showFormatHelp = !showFormatHelp)}
180-
>
181-
{showFormatHelp ? 'Hide format help' : 'Show format help'}
182-
</button>
177+
<span class="help-toggle-wrapper">
178+
<LinkButton onclick={() => (showFormatHelp = !showFormatHelp)}>
179+
{showFormatHelp ? 'Hide format help' : 'Show format help'}
180+
</LinkButton>
181+
</span>
183182
{#if showFormatHelp}
184183
<div class="format-help">
185184
<h4>Format placeholders</h4>
@@ -265,22 +264,6 @@
265264
font-size: var(--font-size-sm);
266265
}
267266
268-
.appearance-link {
269-
color: var(--color-accent-text);
270-
font-size: var(--font-size-sm);
271-
text-decoration: underline;
272-
padding: 0;
273-
background: none;
274-
border: none;
275-
}
276-
277-
.appearance-link:hover {
278-
/* Keep the a11y-safe accent-text color on hover; add underline for visual
279-
affordance instead of switching to the lighter `--color-accent-hover`
280-
which doesn't meet 4.5:1 on white. */
281-
text-decoration: underline;
282-
}
283-
284267
.date-time-setting {
285268
/* Fill the split column; min-width prevents collapse */
286269
width: 100%;
@@ -319,14 +302,8 @@
319302
font-family: var(--font-mono);
320303
}
321304
322-
.help-toggle {
305+
.help-toggle-wrapper {
323306
align-self: flex-start;
324-
padding: 0;
325-
background: none;
326-
border: none;
327-
color: var(--color-accent-text);
328-
font-size: var(--font-size-sm);
329-
text-decoration: underline;
330307
}
331308
332309
.format-help {

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Reusable UI components used across the entire desktop app.
99
| `ModalDialog.svelte` | Central modal container: overlay, dragging, Escape, focus, MCP tracking |
1010
| `dialog-registry.ts` | `SOFT_DIALOG_REGISTRY` array — single source of truth for all dialog IDs |
1111
| `Button.svelte` | Styled button with variant and size props |
12+
| `LinkButton.svelte` | Link-styled button; the only sanctioned `cursor: pointer` in the app |
1213
| `CommandBox.svelte` | Copyable terminal command (monospace + Copy button) |
1314
| `LoadingIcon.svelte` | Animated spinner with progressive status text |
1415
| `AlertDialog.svelte` | Single-action confirmation dialog built on `ModalDialog` |
@@ -82,6 +83,21 @@ inside `{ html }` tooltips. The `html` variant renders via `innerHTML` — only
8283
Variants: `primary` | `secondary` (default) | `danger`. Sizes: `regular` (default) | `mini`. Extends
8384
`HTMLButtonAttributes` so all native button attributes pass through.
8485

86+
## LinkButton
87+
88+
Use this for any "link" that's actually an in-app action (open settings, toggle help, etc.). It renders a `<button>`
89+
styled as a link and is the **only** place in the app that opts back into `cursor: pointer` — Cmdr globally sets
90+
`cursor: default` on `html` and `<a>` for native macOS feel (`app.css:363-366`), and stylelint blocks `cursor: pointer`
91+
everywhere else (`.stylelintrc.mjs:38`). Don't roll your own link-styled button with raw CSS; the cursor opt-in stays in
92+
one place by convention.
93+
94+
Hover keeps the resting accent-text color (the lighter `--color-accent-hover` doesn't meet 4.5:1 contrast on white) —
95+
the underline is enough affordance.
96+
97+
Real `<a>` tags for external URLs are a different concern: they go through `openExternalUrl()` (or the markdown link
98+
delegate in `ErrorPane`) and need SvelteKit's `resolve()` for internal routes. Don't extend `LinkButton` to cover those
99+
without thinking through the navigation layer.
100+
85101
## LoadingIcon
86102

87103
Progressive status text driven by props (mutually exclusive, evaluated top-down):
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<script lang="ts">
2+
import type { Snippet } from 'svelte'
3+
4+
interface Props {
5+
type?: 'button' | 'submit'
6+
disabled?: boolean
7+
onclick?: (e: MouseEvent) => void
8+
'aria-label'?: string
9+
children: Snippet
10+
}
11+
12+
const { type = 'button', disabled = false, onclick, 'aria-label': ariaLabel, children }: Props = $props()
13+
</script>
14+
15+
<button class="link-button" {type} {disabled} {onclick} aria-label={ariaLabel}>
16+
{@render children()}
17+
</button>
18+
19+
<style>
20+
.link-button {
21+
font: inherit;
22+
color: var(--color-accent-text);
23+
text-decoration: underline;
24+
background: none;
25+
border: none;
26+
padding: 0;
27+
/* Cmdr sets `cursor: default` globally on `html` and `a` for native feel.
28+
Links opt back in here — the only sanctioned `cursor: pointer` in the app. */
29+
/* stylelint-disable-next-line declaration-property-value-disallowed-list */
30+
cursor: pointer;
31+
}
32+
33+
.link-button:hover {
34+
/* Keep the a11y-safe accent-text color on hover; the lighter
35+
--color-accent-hover doesn't meet 4.5:1 on white. Underline is enough
36+
affordance — already present in the resting state. */
37+
text-decoration: underline;
38+
}
39+
40+
.link-button:focus-visible {
41+
outline: 2px solid var(--color-accent);
42+
outline-offset: 1px;
43+
box-shadow: var(--shadow-focus-contrast);
44+
}
45+
46+
.link-button:disabled {
47+
opacity: 0.4;
48+
cursor: not-allowed;
49+
pointer-events: none;
50+
}
51+
</style>

apps/desktop/src/routes/settings/+page.svelte

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,16 +113,25 @@
113113
114114
initialized = true
115115
116-
// Focus will be handled naturally by the browser's tab order
117116
await tick()
118117
118+
// Focus the search input on open so users can start typing immediately.
119+
const searchInput = document.querySelector('.search-input')
120+
if (searchInput instanceof HTMLElement) {
121+
searchInput.focus()
122+
}
123+
119124
// Listen for focus-self events (from ⌘, when window is already open).
120125
// Self-focusing is needed because cross-window setFocus() doesn't reliably
121126
// bring a window to front on macOS.
122127
unlistenFocusSelf = await listen('focus-self', () => {
123128
// setTimeout(0) defers past the originating keydown handler —
124129
// without it, macOS restores focus to the main window.
125-
setTimeout(() => void getCurrentWindow().setFocus(), 0)
130+
setTimeout(() => {
131+
void getCurrentWindow().setFocus()
132+
const input = document.querySelector('.search-input')
133+
if (input instanceof HTMLElement) input.focus()
134+
}, 0)
126135
})
127136
128137
log.debug('Settings page ready')

0 commit comments

Comments
 (0)