Skip to content

Commit 554b380

Browse files
committed
License dialog: Hand cursor on support and buy links via LinkButton
- Extend `LinkButton` to render `<a>` when `href` is set, preserving link semantics for screen readers and "right-click → Copy link" while still routing via `openExternalUrl()`. The eslint disable for `svelte/no-navigation-without-resolve` lives in this one component. - Migrate `.support-link` (4 mailto spots) and `.buy-link` (1 pricing link) in `LicenseKeyDialog` to `LinkButton`, dropping the duplicated CSS. Hand cursor now appears on hover, matching link affordance. - Side a11y improvement: drops the hover color switch to `--color-accent-hover`, which doesn't meet 4.5:1 contrast on white. The resting accent-text color stays; the underline is enough affordance. - A11y tests cover both `<button>` and `<a>` (https + mailto) modes. - `lib/ui/CLAUDE.md` updated with the dual-mode usage and the eslint rationale.
1 parent cf8e381 commit 554b380

4 files changed

Lines changed: 87 additions & 53 deletions

File tree

apps/desktop/src/lib/licensing/LicenseKeyDialog.svelte

Lines changed: 11 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
} from './licensing-store.svelte'
2121
import ModalDialog from '$lib/ui/ModalDialog.svelte'
2222
import Button from '$lib/ui/Button.svelte'
23+
import LinkButton from '$lib/ui/LinkButton.svelte'
2324
import { addToast } from '$lib/ui/toast/toast-store.svelte'
2425
2526
interface Props {
@@ -324,8 +325,8 @@
324325
<div class="warning-banner">
325326
<span class="warning-text">
326327
This key couldn't be verified with the server. Please try a different key or email us at
327-
<a href="mailto:{SUPPORT_EMAIL}" class="support-link" onclick={handleEmailClick}
328-
>{SUPPORT_EMAIL}</a
328+
<LinkButton href="mailto:{SUPPORT_EMAIL}" onclick={handleEmailClick}
329+
>{SUPPORT_EMAIL}</LinkButton
329330
>.
330331
</span>
331332
</div>
@@ -384,13 +385,12 @@
384385
</div>
385386
{:else if !isLoading}
386387
<p class="description">
387-
Paste your license key from the email you received after purchase. Don't have one yet? <a
388+
Paste your license key from the email you received after purchase. Don't have one yet? <LinkButton
388389
href="https://getcmdr.com/pricing"
389-
class="buy-link"
390390
onclick={(event: MouseEvent) => {
391391
event.preventDefault()
392392
void openExternalUrl('https://getcmdr.com/pricing')
393-
}}>Get a license</a
393+
}}>Get a license</LinkButton
394394
>.
395395
</p>
396396

@@ -419,22 +419,22 @@
419419
{#if isServerInvalidError && serverInvalidRetryCount >= 3}
420420
We've tried {serverInvalidRetryCount} times and it didn't work. We're sorry for the trouble — please
421421
drop us a message at
422-
<a href="mailto:{SUPPORT_EMAIL}" class="support-link" onclick={handleEmailClick}
423-
>{SUPPORT_EMAIL}</a
422+
<LinkButton href="mailto:{SUPPORT_EMAIL}" onclick={handleEmailClick}
423+
>{SUPPORT_EMAIL}</LinkButton
424424
>
425425

426426
and we'll sort it out.
427427
{:else if isServerInvalidError}
428428
If you believe this is a mistake, email us at
429-
<a href="mailto:{SUPPORT_EMAIL}" class="support-link" onclick={handleEmailClick}
430-
>{SUPPORT_EMAIL}</a
429+
<LinkButton href="mailto:{SUPPORT_EMAIL}" onclick={handleEmailClick}
430+
>{SUPPORT_EMAIL}</LinkButton
431431
>
432432

433433
and we'll sort it out.
434434
{:else}
435435
If you need help, contact us at
436-
<a href="mailto:{SUPPORT_EMAIL}" class="support-link" onclick={handleEmailClick}
437-
>{SUPPORT_EMAIL}</a
436+
<LinkButton href="mailto:{SUPPORT_EMAIL}" onclick={handleEmailClick}
437+
>{SUPPORT_EMAIL}</LinkButton
438438
>.
439439
{/if}
440440
</p>
@@ -596,24 +596,6 @@
596596
line-height: 1.5;
597597
}
598598
599-
.support-link {
600-
color: var(--color-accent-text);
601-
text-decoration: underline;
602-
}
603-
604-
.support-link:hover {
605-
color: var(--color-accent-hover);
606-
}
607-
608-
.buy-link {
609-
color: var(--color-accent-text);
610-
text-decoration: underline;
611-
}
612-
613-
.buy-link:hover {
614-
color: var(--color-accent-hover);
615-
}
616-
617599
.button-row {
618600
display: flex;
619601
gap: var(--spacing-md);

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

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,18 @@ Reusable UI components used across the entire desktop app.
44

55
## Key files
66

7-
| File | Purpose |
8-
| ------------------------ | ------------------------------------------------------------------------ |
9-
| `ModalDialog.svelte` | Central modal container: overlay, dragging, Escape, focus, MCP tracking |
10-
| `dialog-registry.ts` | `SOFT_DIALOG_REGISTRY` array — single source of truth for all dialog IDs |
11-
| `Button.svelte` | Styled button with variant and size props |
12-
| `LinkButton.svelte` | Link-styled button; the only sanctioned `cursor: pointer` in the app |
13-
| `CommandBox.svelte` | Copyable terminal command (monospace + Copy button) |
14-
| `LoadingIcon.svelte` | Animated spinner with progressive status text |
15-
| `AlertDialog.svelte` | Single-action confirmation dialog built on `ModalDialog` |
16-
| `ProgressBar.svelte` | Reusable progress bar (just the bar, no labels or layout) |
17-
| `ProgressOverlay.svelte` | Floating top-right progress indicator: spinner, progress bar, ETA |
18-
| `toast/` | Centralized toast notification system — store, container, item |
7+
| File | Purpose |
8+
| ------------------------ | ---------------------------------------------------------------------------------------------- |
9+
| `ModalDialog.svelte` | Central modal container: overlay, dragging, Escape, focus, MCP tracking |
10+
| `dialog-registry.ts` | `SOFT_DIALOG_REGISTRY` array — single source of truth for all dialog IDs |
11+
| `Button.svelte` | Styled button with variant and size props |
12+
| `LinkButton.svelte` | Link-styled `<button>` (default) or `<a>` (with `href`); the only sanctioned `cursor: pointer` |
13+
| `CommandBox.svelte` | Copyable terminal command (monospace + Copy button) |
14+
| `LoadingIcon.svelte` | Animated spinner with progressive status text |
15+
| `AlertDialog.svelte` | Single-action confirmation dialog built on `ModalDialog` |
16+
| `ProgressBar.svelte` | Reusable progress bar (just the bar, no labels or layout) |
17+
| `ProgressOverlay.svelte` | Floating top-right progress indicator: spinner, progress bar, ETA |
18+
| `toast/` | Centralized toast notification system — store, container, item |
1919

2020
## ModalDialog
2121

@@ -85,18 +85,21 @@ Variants: `primary` | `secondary` (default) | `danger`. Sizes: `regular` (defaul
8585

8686
## LinkButton
8787

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.
88+
Use this for anything that should look and behave like a link. Renders a `<button>` by default (in-app actions like
89+
"Open settings", "Show format help"), or an `<a>` when you pass `href` (for external URLs — mailto:, https:// — that
90+
your `onclick` intercepts and routes through `openExternalUrl()`). It is the **only** place in the app that opts back
91+
into `cursor: pointer` — Cmdr globally sets `cursor: default` on `html` and `<a>` for native macOS feel
92+
(`app.css:363-366`), and stylelint blocks `cursor: pointer` everywhere else (`.stylelintrc.mjs:38`). Don't roll your own
93+
link-styled button or anchor with raw CSS; the cursor opt-in stays in one place by convention.
9394

9495
Hover keeps the resting accent-text color (the lighter `--color-accent-hover` doesn't meet 4.5:1 contrast on white) —
9596
the underline is enough affordance.
9697

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.
98+
The `href` mode includes a per-line eslint disable for `svelte/no-navigation-without-resolve`. That rule wants
99+
SvelteKit's `resolve()`, which is for internal routes; we route external URLs through `openExternalUrl()` after
100+
`event.preventDefault()` in `onclick`. The `<a href>` is decorative — it gives screen readers the right semantics and
101+
preserves "right-click → Copy link." For SvelteKit-internal navigation, don't use `LinkButton`; use `<a>` with
102+
`resolve()` directly.
100103

101104
## LoadingIcon
102105

apps/desktop/src/lib/ui/LinkButton.a11y.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,23 @@ describe('LinkButton a11y', () => {
5151
await tick()
5252
await expectNoA11yViolations(target)
5353
})
54+
55+
it('href mode (renders <a>) has no a11y violations', async () => {
56+
const target = document.createElement('div')
57+
document.body.appendChild(target)
58+
mount(LinkButton, {
59+
target,
60+
props: { href: 'https://getcmdr.com/pricing', children: snip('Get a license') },
61+
})
62+
await tick()
63+
await expectNoA11yViolations(target)
64+
})
65+
66+
it('href mode with mailto has no a11y violations', async () => {
67+
const target = document.createElement('div')
68+
document.body.appendChild(target)
69+
mount(LinkButton, { target, props: { href: 'mailto:hi@example.com', children: snip('hi@example.com') } })
70+
await tick()
71+
await expectNoA11yViolations(target)
72+
})
5473
})

apps/desktop/src/lib/ui/LinkButton.svelte

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,50 @@
1+
<!--
2+
Link-styled element. Renders <button> for in-app actions (default) or <a> when
3+
`href` is set. Owns the only sanctioned `cursor: pointer` in the app — Cmdr
4+
globally sets `cursor: default` on `html` and `<a>` for native macOS feel.
5+
6+
When using `href`: the URL is decorative (for screen readers, right-click "Copy
7+
link"). Always intercept the click via `onclick` and route through
8+
`openExternalUrl()` — Tauri blocks raw `<a>` navigation. The eslint disable for
9+
`svelte/no-navigation-without-resolve` is intentional here: that rule wants
10+
SvelteKit's `resolve()`, which doesn't apply to externally-intercepted URLs.
11+
-->
112
<script lang="ts">
213
import type { Snippet } from 'svelte'
314
415
interface Props {
16+
href?: string
17+
target?: string
18+
rel?: string
519
type?: 'button' | 'submit'
620
disabled?: boolean
721
onclick?: (e: MouseEvent) => void
822
'aria-label'?: string
923
children: Snippet
1024
}
1125
12-
const { type = 'button', disabled = false, onclick, 'aria-label': ariaLabel, children }: Props = $props()
26+
const {
27+
href,
28+
target,
29+
rel,
30+
type = 'button',
31+
disabled = false,
32+
onclick,
33+
'aria-label': ariaLabel,
34+
children,
35+
}: Props = $props()
1336
</script>
1437

15-
<button class="link-button" {type} {disabled} {onclick} aria-label={ariaLabel}>
16-
{@render children()}
17-
</button>
38+
{#if href}
39+
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
40+
<a class="link-button" {href} {target} {rel} {onclick} aria-label={ariaLabel}>
41+
{@render children()}
42+
</a>
43+
{:else}
44+
<button class="link-button" {type} {disabled} {onclick} aria-label={ariaLabel}>
45+
{@render children()}
46+
</button>
47+
{/if}
1848

1949
<style>
2050
.link-button {

0 commit comments

Comments
 (0)